Merge branch 'master' of ssh://git.kolaente.de:9022/vikunja/app into feature/drone-ios
the build was successful Details

This commit is contained in:
Jonas Franz 2018-10-08 16:31:08 +02:00
commit fcf0d9ecf9
No known key found for this signature in database
GPG Key ID: 506AEEBE80BEDECD
17 changed files with 494 additions and 201 deletions

View File

@ -34,7 +34,7 @@ pipeline:
- flutter packages get
- make build-all
- mkdir apks
- mv build/app/outputs/apk/*/*.apk apks
- mv build/app/outputs/apk/*/*/*.apk apks
when:
event: [ push, tag ]

View File

@ -1,6 +0,0 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="main.dart" type="FlutterRunConfigurationType" factoryName="Flutter">
<option name="filePath" value="$PROJECT_DIR$/lib/main.dart" />
<method />
</configuration>
</component>

View File

@ -19,15 +19,15 @@ build-all: build-release build-debug build-profile
.PHONY: build-release
build-release:
flutter build apk --release --build-name=$(VERSION)
flutter build apk --release --build-name=$(VERSION) --flavor main
.PHONY: build-debug
build-debug:
flutter build apk --debug --build-name=$(VERSION)
flutter build apk --debug --build-name=$(VERSION) --flavor unsigned
.PHONY: build-profile
build-profile:
flutter build apk --profile --build-name=$(VERSION)
flutter build apk --profile --build-name=$(VERSION) --flavor unsigned
.PHONY: build-ios-all
build-ios-all: build-ios-release build-ios-debug build-ios-profile

View File

@ -38,12 +38,38 @@ android {
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId "io.vikunja.flutteringvikunja"
applicationId "io.vikunja.app"
minSdkVersion 18
targetSdkVersion 27
versionCode flutterVersionCode.toInteger()
versionName flutterVersionName
testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
}
flavorDimensions "deploy"
productFlavors {
fdroid {
dimension "deploy"
signingConfig null
}
unsigned {
dimension "deploy"
signingConfig null
}
main {
dimension "deploy"
signingConfig signingConfigs.debug // TODO add signing key
}
}
android.applicationVariants.all { variant ->
if (variant.flavorName == "fdroid") {
variant.outputs.all { output ->
output.outputFileName = "app-fdroid-release.apk"
}
}
}
buildTypes {

View File

@ -19,6 +19,16 @@ class UserAPIService extends APIService implements UserService {
.then((user) => UserTokenPair(user, token));
}
@override
Future<UserTokenPair> register(String username, email, password) async {
var newUser = await client.post('/register', body: {
'username': username,
'email': email,
'password': password
}).then((resp) => resp['username']);
return login(newUser, password);
}
@override
Future<User> getCurrentUser() {
return client.get('/user').then((map) => User.fromJson(map));

View File

@ -0,0 +1,39 @@
import 'package:flutter/material.dart';
class AddDialog extends StatelessWidget {
final ValueChanged<String> onAdd;
final InputDecoration decoration;
const AddDialog({Key key, this.onAdd, this.decoration}) : super(key: key);
@override
Widget build(BuildContext context) {
var textController = TextEditingController();
return new AlertDialog(
contentPadding: const EdgeInsets.all(16.0),
content: new Row(children: <Widget>[
Expanded(
child: new TextField(
autofocus: true,
decoration: this.decoration,
controller: textController,
),
)
]),
actions: <Widget>[
new FlatButton(
child: const Text('CANCEL'),
onPressed: () => Navigator.pop(context),
),
new FlatButton(
child: const Text('ADD'),
onPressed: () {
if (this.onAdd != null && textController.text.isNotEmpty)
this.onAdd(textController.text);
Navigator.pop(context);
},
)
],
);
}
}

View File

@ -0,0 +1,89 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:vikunja_app/global.dart';
import 'package:vikunja_app/models/task.dart';
class TaskTile extends StatefulWidget {
final Task task;
final VoidCallback onEdit;
final bool loading;
const TaskTile(
{Key key, @required this.task, this.onEdit, this.loading = false})
: assert(task != null),
super(key: key);
@override
TaskTileState createState() {
return new TaskTileState(this.task, this.loading);
}
}
class TaskTileState extends State<TaskTile> {
bool _loading;
Task _currentTask;
TaskTileState(this._currentTask, this._loading)
: assert(_currentTask != null),
assert(_loading != null);
@override
Widget build(BuildContext context) {
if (_loading) {
return ListTile(
leading: Padding(
padding: const EdgeInsets.all(8.0),
child: SizedBox(
height: Checkbox.width,
width: Checkbox.width,
child: CircularProgressIndicator(
strokeWidth: 2.0,
)),
),
title: Text(_currentTask.text),
subtitle:
_currentTask.description == null || _currentTask.description.isEmpty
? null
: Text(_currentTask.description),
trailing: IconButton(icon: Icon(Icons.settings), onPressed: null),
);
}
return CheckboxListTile(
title: Text(_currentTask.text),
controlAffinity: ListTileControlAffinity.leading,
value: _currentTask.done ?? false,
subtitle:
_currentTask.description == null || _currentTask.description.isEmpty
? null
: Text(_currentTask.description),
secondary:
IconButton(icon: Icon(Icons.settings), onPressed: widget.onEdit),
onChanged: _change,
);
}
void _change(bool value) async {
setState(() {
this._loading = true;
});
Task newTask = await _updateTask(_currentTask, value);
setState(() {
this._currentTask = newTask;
this._loading = false;
});
}
Future<Task> _updateTask(Task task, bool checked) {
// TODO use copyFrom
return VikunjaGlobal.of(context).taskService.update(Task(
id: task.id,
done: checked,
text: task.text,
description: task.description,
owner: null,
));
}
}
typedef Future<void> TaskChanged(Task task, bool newValue);

View File

@ -2,6 +2,7 @@ import 'dart:async';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:vikunja_app/components/AddDialog.dart';
import 'package:vikunja_app/global.dart';
import 'package:vikunja_app/models/namespace.dart';
import 'package:vikunja_app/models/task.dart';
@ -18,35 +19,41 @@ class NamespaceFragment extends StatefulWidget {
class _NamespaceFragmentState extends State<NamespaceFragment> {
List<TaskList> _lists = [];
bool _loading = true;
@override
Widget build(BuildContext context) {
return Scaffold(
body: new ListView(
padding: EdgeInsets.symmetric(vertical: 8.0),
children: ListTile.divideTiles(
context: context,
tiles: _lists.map((ls) => Dismissible(
key: Key(ls.id.toString()),
direction: DismissDirection.startToEnd,
child: ListTile(
title: new Text(ls.title),
onTap: () => _openList(context, ls),
trailing: Icon(Icons.arrow_right),
),
background: Container(
color: Colors.red,
child: const ListTile(
leading: Icon(Icons.delete,
color: Colors.white, size: 36.0)),
),
onDismissed: (direction) {
_removeList(ls).then((_) => Scaffold.of(context)
.showSnackBar(
SnackBar(content: Text("${ls.title} removed"))));
},
))).toList(),
),
body: !this._loading
? RefreshIndicator(
child: new ListView(
padding: EdgeInsets.symmetric(vertical: 8.0),
children: ListTile.divideTiles(
context: context,
tiles: _lists.map((ls) => Dismissible(
key: Key(ls.id.toString()),
direction: DismissDirection.startToEnd,
child: ListTile(
title: new Text(ls.title),
onTap: () => _openList(context, ls),
trailing: Icon(Icons.arrow_right),
),
background: Container(
color: Colors.red,
child: const ListTile(
leading: Icon(Icons.delete,
color: Colors.white, size: 36.0)),
),
onDismissed: (direction) {
_removeList(ls).then((_) => Scaffold.of(context)
.showSnackBar(SnackBar(
content: Text("${ls.title} removed"))));
},
))).toList(),
),
onRefresh: _updateLists,
)
: Center(child: CircularProgressIndicator()),
floatingActionButton: FloatingActionButton(
onPressed: () => _addListDialog(), child: const Icon(Icons.add)),
);
@ -65,11 +72,14 @@ class _NamespaceFragmentState extends State<NamespaceFragment> {
.then((_) => _updateLists());
}
_updateLists() {
VikunjaGlobal.of(context)
Future<void> _updateLists() {
return VikunjaGlobal.of(context)
.listService
.getByNamespace(widget.namespace.id)
.then((lists) => setState(() => this._lists = lists));
.then((lists) => setState(() {
this._lists = lists;
this._loading = false;
}));
}
_openList(BuildContext context, TaskList list) {
@ -78,37 +88,12 @@ class _NamespaceFragmentState extends State<NamespaceFragment> {
}
_addListDialog() {
var textController = new TextEditingController();
showDialog(
context: context,
child: new AlertDialog(
contentPadding: const EdgeInsets.all(16.0),
content: new Row(children: <Widget>[
Expanded(
child: new TextField(
autofocus: true,
decoration: new InputDecoration(
labelText: 'List Name', hintText: 'eg. Shopping List'),
controller: textController,
),
)
]),
actions: <Widget>[
new FlatButton(
child: const Text('CANCEL'),
onPressed: () => Navigator.pop(context),
),
new FlatButton(
child: const Text('ADD'),
onPressed: () {
if (textController.text.isNotEmpty) {
_addList(textController.text);
}
Navigator.pop(context);
},
)
],
),
builder: (_) => AddDialog(
onAdd: _addList,
decoration: new InputDecoration(
labelText: 'List Name', hintText: 'eg. Shopping List')),
);
}

View File

@ -37,8 +37,7 @@ class VikunjaGlobalState extends State<VikunjaGlobal> {
Client get client => _client;
UserManager get userManager => new UserManager(_storage);
UserService get userService => new UserAPIService(_client);
UserService newLoginService(base) => new UserAPIService(Client(null, base));
UserService newUserService(base) => new UserAPIService(Client(null, base));
NamespaceService get namespaceService => new NamespaceAPIService(client);
TaskService get taskService => new TaskAPIService(client);
ListService get listService => new ListAPIService(client);

View File

@ -1,4 +1,7 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:vikunja_app/components/AddDialog.dart';
import 'package:vikunja_app/components/GravatarImage.dart';
import 'package:vikunja_app/fragments/namespace.dart';
import 'package:vikunja_app/fragments/placeholder.dart';
@ -19,6 +22,7 @@ class HomePageState extends State<HomePage> {
? _namespaces[_selectedDrawerIndex]
: null;
int _selectedDrawerIndex = -1;
bool _loading = true;
_getDrawerItemWidget(int pos) {
if (pos == -1) {
@ -33,38 +37,13 @@ class HomePageState extends State<HomePage> {
}
_addNamespaceDialog() {
var textController = new TextEditingController();
showDialog(
context: context,
child: new AlertDialog(
contentPadding: const EdgeInsets.all(16.0),
content: new Row(children: <Widget>[
Expanded(
child: new TextField(
autofocus: true,
context: context,
builder: (_) => AddDialog(
onAdd: _addNamespace,
decoration: new InputDecoration(
labelText: 'Namespace', hintText: 'eg. Family Namespace'),
controller: textController,
),
)
]),
actions: <Widget>[
new FlatButton(
child: const Text('CANCEL'),
onPressed: () => Navigator.pop(context),
),
new FlatButton(
child: const Text('ADD'),
onPressed: () {
if (textController.text.isNotEmpty) {
_addNamespace(textController.text);
}
Navigator.pop(context);
},
)
],
),
);
labelText: 'Namespace', hintText: 'eg. Personal Namespace'),
));
}
_addNamespace(String name) {
@ -74,9 +53,10 @@ class HomePageState extends State<HomePage> {
.then((_) => _updateNamespaces());
}
_updateNamespaces() {
VikunjaGlobal.of(context).namespaceService.getAll().then((result) {
Future<void> _updateNamespaces() {
return VikunjaGlobal.of(context).namespaceService.getAll().then((result) {
setState(() {
_loading = false;
_namespaces = result;
});
});
@ -102,7 +82,7 @@ class HomePageState extends State<HomePage> {
)));
return new Scaffold(
appBar: AppBar(title: new Text(_currentNamespace?.name ?? 'Vakunja')),
appBar: AppBar(title: new Text(_currentNamespace?.name ?? 'Vikunja')),
drawer: new Drawer(
child: new Column(children: <Widget>[
new UserAccountsDrawerHeader(
@ -121,11 +101,16 @@ class HomePageState extends State<HomePage> {
),
),
new Expanded(
child: ListView(
padding: EdgeInsets.zero,
children:
ListTile.divideTiles(context: context, tiles: drawerOptions)
.toList())),
child: this._loading
? Center(child: CircularProgressIndicator())
: RefreshIndicator(
child: ListView(
padding: EdgeInsets.zero,
children: ListTile.divideTiles(
context: context, tiles: drawerOptions)
.toList()),
onRefresh: _updateNamespaces,
)),
new Align(
alignment: FractionalOffset.bottomCenter,
child: new ListTile(

View File

@ -1,4 +1,8 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:vikunja_app/components/AddDialog.dart';
import 'package:vikunja_app/components/TaskTile.dart';
import 'package:vikunja_app/global.dart';
import 'package:vikunja_app/models/task.dart';
@ -12,117 +16,99 @@ class ListPage extends StatefulWidget {
}
class _ListPageState extends State<ListPage> {
TaskList items;
TaskList _items;
List<Task> _loadingTasks = [];
bool _loading = true;
@override
void initState() {
items = TaskList(
_items = TaskList(
id: widget.taskList.id, title: widget.taskList.title, tasks: []);
super.initState();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: new Text(items.title),
),
body: ListView(
padding: EdgeInsets.symmetric(vertical: 8.0),
children: ListTile.divideTiles(
context: context,
tiles: items?.tasks?.map((task) => CheckboxListTile(
title: Text(task.text),
controlAffinity: ListTileControlAffinity.leading,
value: task.done ?? false,
subtitle: task.description == null
? null
: Text(task.description),
onChanged: (bool value) => _updateTask(task, value),
)) ??
[])
.toList(),
),
floatingActionButton: FloatingActionButton(
onPressed: () => _addItemDialog(), child: Icon(Icons.add)),
);
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
_updateList();
}
_updateTask(Task task, bool checked) {
// TODO use copyFrom
VikunjaGlobal.of(context)
.taskService
.update(Task(
id: task.id,
done: checked,
text: task.text,
description: task.description,
owner: null,
))
.then((_) => _updateList());
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: new Text(_items.title),
actions: <Widget>[
IconButton(
icon: Icon(Icons.edit),
onPressed: () => {/* TODO add edit list functionality */},
)
],
),
body: !this._loading
? RefreshIndicator(
child: ListView(
padding: EdgeInsets.symmetric(vertical: 8.0),
children:
ListTile.divideTiles(context: context, tiles: _listTasks())
.toList(),
),
onRefresh: _updateList,
)
: Center(child: CircularProgressIndicator()),
floatingActionButton: FloatingActionButton(
onPressed: () => _addItemDialog(), child: Icon(Icons.add)),
);
}
_updateList() {
VikunjaGlobal.of(context).listService.get(widget.taskList.id).then((tasks) {
List<Widget> _listTasks() {
var tasks = (_items?.tasks?.map(_buildTile) ?? []).toList();
tasks.addAll(_loadingTasks.map(_buildLoadingTile));
return tasks;
}
TaskTile _buildTile(Task task) {
return TaskTile(task: task, loading: false);
}
TaskTile _buildLoadingTile(Task task) {
return TaskTile(
task: task,
loading: true,
);
}
Future<void> _updateList() {
return VikunjaGlobal.of(context)
.listService
.get(widget.taskList.id)
.then((tasks) {
setState(() {
items = tasks;
_loading = false;
_items = tasks;
});
});
}
_addItemDialog() {
var textController = new TextEditingController();
showDialog(
context: context,
child: new AlertDialog(
contentPadding: const EdgeInsets.all(16.0),
content: new Row(children: <Widget>[
Expanded(
child: new TextField(
autofocus: true,
decoration: new InputDecoration(
labelText: 'List Item', hintText: 'eg. Milk'),
controller: textController,
),
)
]),
actions: <Widget>[
new FlatButton(
child: const Text('CANCEL'),
onPressed: () => Navigator.pop(context),
),
new FlatButton(
child: const Text('ADD'),
onPressed: () {
if (textController.text.isNotEmpty) _addItem(textController.text);
Navigator.pop(context);
},
)
],
),
);
context: context,
builder: (_) => AddDialog(
onAdd: _addItem,
decoration: new InputDecoration(
labelText: 'List Item', hintText: 'eg. Milk')));
}
_addItem(String name) {
var globalState = VikunjaGlobal.of(context);
globalState.taskService
.add(
items.id,
Task(
id: null,
text: name,
owner: globalState.currentUser,
done: false))
.then((task) {
var newTask =
Task(id: null, text: name, owner: globalState.currentUser, done: false);
setState(() => _loadingTasks.add(newTask));
globalState.taskService.add(_items.id, newTask).then((task) {
setState(() {
items.tasks.add(task);
_items.tasks.add(task);
});
}).then((_) => _updateList());
}).then((_) => _updateList()
.then((_) => setState(() => _loadingTasks.remove(newTask))));
}
}

View File

@ -1,15 +1,13 @@
import 'package:flutter/material.dart';
import 'package:vikunja_app/global.dart';
import 'package:vikunja_app/main.dart';
import 'package:vikunja_app/pages/register_page.dart';
import 'package:vikunja_app/utils/validator.dart';
class LoginPage extends StatefulWidget {
@override
_LoginPageState createState() => _LoginPageState();
}
final RegExp _url = new RegExp(
r'https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)');
class _LoginPageState extends State<LoginPage> {
final _formKey = GlobalKey<FormState>();
String _server, _username, _password;
@ -44,8 +42,7 @@ class _LoginPageState extends State<LoginPage> {
child: TextFormField(
onSaved: (serverAddress) => _server = serverAddress,
validator: (address) {
var hasMatch = _url.hasMatch(address);
return hasMatch ? null : 'Invalid URL';
return isUrl(address) ? null : 'Invalid URL';
},
decoration: new InputDecoration(
labelText: 'Server Address'),
@ -85,6 +82,19 @@ class _LoginPageState extends State<LoginPage> {
? CircularProgressIndicator()
: Text('Login'),
))),
Builder(
builder: (context) => ButtonTheme(
height: _loading ? 55.0 : 36.0,
child: RaisedButton(
onPressed: () => Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
RegisterPage())),
child: _loading
? CircularProgressIndicator()
: Text('Register'),
))),
],
)),
),
@ -96,10 +106,9 @@ class _LoginPageState extends State<LoginPage> {
try {
var vGlobal = VikunjaGlobal.of(context);
var newUser =
await vGlobal.newLoginService(_server).login(_username, _password);
await vGlobal.newUserService(_server).login(_username, _password);
vGlobal.changeUser(newUser.user, token: newUser.token, base: _server);
} catch (ex) {
print(ex);
showDialog(
context: context,
builder: (context) => new AlertDialog(

View File

@ -0,0 +1,152 @@
import 'package:flutter/material.dart';
import 'package:vikunja_app/global.dart';
import 'package:vikunja_app/utils/validator.dart';
class RegisterPage extends StatefulWidget {
@override
_RegisterPageState createState() => _RegisterPageState();
}
class _RegisterPageState extends State<RegisterPage> {
final _formKey = GlobalKey<FormState>();
final passwordController = TextEditingController();
String _server, _username, _email, _password;
bool _loading = false;
@override
Widget build(BuildContext ctx) {
return Scaffold(
appBar: AppBar(
title: Text('Register to Vikunja'),
),
body: Builder(
builder: (BuildContext context) => SafeArea(
top: false,
bottom: false,
child: Form(
key: _formKey,
child: ListView(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
children: <Widget>[
Padding(
padding: const EdgeInsets.all(8.0),
child: Image(
image: AssetImage('assets/vikunja_logo.png'),
height: 128.0,
semanticLabel: 'Vikunja Logo',
),
),
Padding(
padding: EdgeInsets.all(8.0),
child: TextFormField(
onSaved: (serverAddress) => _server = serverAddress,
validator: (address) {
return isUrl(address) ? null : 'Invalid URL';
},
decoration: new InputDecoration(
labelText: 'Server Address'),
),
),
Padding(
padding: const EdgeInsets.all(8.0),
child: TextFormField(
onSaved: (username) => _username = username.trim(),
validator: (username) {
return username.trim().isNotEmpty ? null : 'Please specify a username';
},
decoration:
new InputDecoration(labelText: 'Username'),
),
),
Padding(
padding: const EdgeInsets.all(8.0),
child: TextFormField(
onSaved: (email) => _email = email,
validator: (email) {
return isEmail(email)
? null
: 'Email adress is invalid';
},
decoration:
new InputDecoration(labelText: 'Email Address'),
),
),
Padding(
padding: const EdgeInsets.all(8.0),
child: TextFormField(
controller: passwordController,
onSaved: (password) => _password = password,
validator: (password) {
return password.length >= 8 ? null : 'Please use at least 8 characters';
},
decoration:
new InputDecoration(labelText: 'Password'),
obscureText: true,
),
),
Padding(
padding: const EdgeInsets.all(8.0),
child: TextFormField(
validator: (password) {
return passwordController.text == password
? null
: 'Passwords don\'t match.';
},
decoration: new InputDecoration(
labelText: 'Repeat Password'),
obscureText: true,
),
),
Builder(
builder: (context) => ButtonTheme(
height: _loading ? 55.0 : 36.0,
child: RaisedButton(
onPressed: !_loading
? () {
if (_formKey.currentState
.validate()) {
Form.of(context).save();
_registerUser(context);
} else {
print("awhat");
}
}
: null,
child: _loading
? CircularProgressIndicator()
: Text('Register'),
))),
],
)),
),
));
}
_registerUser(BuildContext context) async {
setState(() => _loading = true);
try {
var vGlobal = VikunjaGlobal.of(context);
var newUserLoggedIn = await vGlobal
.newUserService(_server)
.register(_username, _email, _password);
vGlobal.changeUser(newUserLoggedIn.user,
token: newUserLoggedIn.token, base: _server);
} catch (ex) {
showDialog(
context: context,
builder: (context) => new AlertDialog(
title: const Text(
'Registration failed! Please check your server url and credentials.'),
actions: <Widget>[
FlatButton(
onPressed: () => Navigator.pop(context),
child: const Text('CLOSE'))
],
));
} finally {
setState(() {
_loading = false;
});
}
}
}

View File

@ -151,6 +151,11 @@ class MockedUserService implements UserService {
return Future.value(UserTokenPair(_users[1], 'abcdefg'));
}
@override
Future<UserTokenPair> register(String username, email, password) {
return Future.value(UserTokenPair(_users[1], 'abcdefg'));
}
@override
Future<User> getCurrentUser() {
return Future.value(_users[1]);

View File

@ -29,5 +29,6 @@ abstract class TaskService {
abstract class UserService {
Future<UserTokenPair> login(String username, password);
Future<UserTokenPair> register(String username, email, password);
Future<User> getCurrentUser();
}

13
lib/utils/validator.dart Normal file
View File

@ -0,0 +1,13 @@
final RegExp _emailRegex = new RegExp(
r'^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$');
bool isEmail(email) {
return _emailRegex.hasMatch(email);
}
final RegExp _url = new RegExp(
r'https?:\/\/((([a-zA-Z0-9.\-\_]+)\.[a-zA-Z]+)|(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}))(:[0-9]+)?');
bool isUrl(url) {
return _url.hasMatch(url);
}

View File

@ -1,7 +1,7 @@
name: vikunja_app
description: Vikunja as Flutter cross platform app
version: 1.0.0+1
version: 0.0.1
environment:
sdk: ">=2.0.0-dev.63.0 <3.0.0"