Merged and fixed PRs 37 & 39

This commit is contained in:
Aleksandr Borisenko 2021-06-04 12:34:25 +03:00
parent 7bb04473a3
commit 74f7756626
37 changed files with 1637 additions and 313 deletions

View File

@ -2,6 +2,7 @@
# This is a generated file; do not edit or check into version control.
export "FLUTTER_ROOT=C:\Android\flutter"
export "FLUTTER_APPLICATION_PATH=C:\Users\Aleksandr\AndroidStudioProjects\vikunja"
export "COCOAPODS_PARALLEL_CODE_SIGN=true"
export "FLUTTER_TARGET=lib\main.dart"
export "FLUTTER_BUILD_DIR=build"
export "SYMROOT=${SOURCE_ROOT}/../build\ios"

View File

@ -1,6 +1,8 @@
import 'dart:async';
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:vikunja_app/api/response.dart';
import 'package:vikunja_app/components/string_extension.dart';
class Client {
final JsonDecoder _decoder = new JsonDecoder();
@ -25,33 +27,46 @@ class Client {
'Content-Type': 'application/json'
};
Future<dynamic> get(String url) {
return http.get(Uri.parse('${this.base}$url'),
Future<Response> get(String url,
[Map<String, List<String>> queryParameters]) {
// TODO: This could be moved to a seperate function
var uri = Uri.parse('${this.base}$url');
// Because these are all final values, we can't just add the queryParameters and must instead build a new Uri Object every time this method is called.
var newUri = Uri(
scheme: uri.scheme,
userInfo: uri.userInfo,
host: uri.host,
port: uri.port,
path: uri.path,
query: uri.query,
queryParameters: queryParameters,
// Because dart takes a Map<String, String> here, it is only possible to sort by one parameter while the api supports n parameters.
fragment: uri.fragment);
return http.get(newUri, headers: _headers)
.then(_handleResponse);
}
Future<Response> delete(String url) {
return http.delete('${this.base}$url'.toUri(),
headers: _headers,
).then(_handleResponse);
}
Future<dynamic> delete(String url) {
return http.delete(Uri.parse('${this.base}$url'),
headers: _headers,
).then(_handleResponse);
}
Future<dynamic> post(String url, {dynamic body}) {
return http.post(Uri.parse('${this.base}$url'),
Future<Response> post(String url, {dynamic body}) {
return http.post('${this.base}$url'.toUri(),
headers: _headers,
body: _encoder.convert(body),
).then(_handleResponse);
}
Future<dynamic> put(String url, {dynamic body}) {
return http.put(Uri.parse('${this.base}$url'),
Future<Response> put(String url, {dynamic body}) {
return http.put('${this.base}$url'.toUri(),
headers: _headers,
body: _encoder.convert(body),
).then(_handleResponse);
}
dynamic _handleResponse(http.Response response) {
Response _handleResponse(http.Response response) {
if (response.statusCode < 200 ||
response.statusCode >= 400 ||
json == null) {
@ -65,12 +80,17 @@ class Client {
throw new ApiException(
response.statusCode, response.request.url.toString());
}
return _decoder.convert(response.body);
return Response(
_decoder.convert(response.body),
response.statusCode,
response.headers
);
}
}
class InvalidRequestApiException extends ApiException {
final String message;
InvalidRequestApiException(int errorCode, String path, this.message)
: super(errorCode, path);
@ -83,6 +103,7 @@ class InvalidRequestApiException extends ApiException {
class ApiException implements Exception {
final int errorCode;
final String path;
ApiException(this.errorCode, this.path);
@override

30
lib/api/label_task.dart Normal file
View File

@ -0,0 +1,30 @@
import 'package:vikunja_app/api/client.dart';
import 'package:vikunja_app/api/service.dart';
import 'package:vikunja_app/models/label.dart';
import 'package:vikunja_app/models/labelTask.dart';
import 'package:vikunja_app/service/services.dart';
class LabelTaskAPIService extends APIService implements LabelTaskService {
LabelTaskAPIService(Client client) : super(client);
@override
Future<Label> create(LabelTask lt) async {
return client.put('/tasks/${lt.task.id}/labels', body: lt.toJSON())
.then((result) => Label.fromJson(result.body));
}
@override
Future<Label> delete(LabelTask lt) async {
return client.delete('/tasks/${lt.task.id}/labels/${lt.label.id}')
.then((result) => Label.fromJson(result.body));
}
@override
Future<List<Label>> getAll(LabelTask lt, {String query}) async {
String params =
query == '' ? null : '?s=' + Uri.encodeQueryComponent(query);
return client.get('/tasks/${lt.task.id}/labels$params').then(
(label) => convertList(label, (result) => Label.fromJson(result)));
}
}

View File

@ -0,0 +1,20 @@
import 'package:vikunja_app/api/client.dart';
import 'package:vikunja_app/api/service.dart';
import 'package:vikunja_app/models/label.dart';
import 'package:vikunja_app/models/labelTaskBulk.dart';
import 'package:vikunja_app/models/task.dart';
import 'package:vikunja_app/service/services.dart';
class LabelTaskBulkAPIService extends APIService
implements LabelTaskBulkService {
LabelTaskBulkAPIService(Client client) : super(client);
@override
Future<List<Label>> update(Task task, List<Label> labels) {
return client
.post('/tasks/${task.id}/labels/bulk',
body: LabelTaskBulk(labels: labels).toJSON())
.then((response) =>
convertList(response.body['labels'], (result) => Label.fromJson(result)));
}
}

41
lib/api/labels.dart Normal file
View File

@ -0,0 +1,41 @@
import 'package:vikunja_app/api/client.dart';
import 'package:vikunja_app/api/service.dart';
import 'package:vikunja_app/models/label.dart';
import 'package:vikunja_app/service/services.dart';
class LabelAPIService extends APIService implements LabelService {
LabelAPIService(Client client) : super(client);
@override
Future<Label> create(Label label) {
return client.put('/labels', body: label.toJSON())
.then((response) => Label.fromJson(response.body));
}
@override
Future<Label> delete(Label label) {
return client.delete('/labels/${label.id}')
.then((response) => Label.fromJson(response.body));
}
@override
Future<Label> get(int labelID) {
return client.get('/labels/$labelID')
.then((response) => Label.fromJson(response.body));
}
@override
Future<List<Label>> getAll({String query}) {
String params =
query == '' ? null : '?s=' + Uri.encodeQueryComponent(query);
return client.get('/labels$params').then(
(label) => convertList(label, (result) => Label.fromJson(result)));
}
@override
Future<Label> update(Label label) {
return client
.post('/labels/${label.id}', body: label)
.then((response) => Label.fromJson(response.body));
}
}

View File

@ -12,7 +12,7 @@ class ListAPIService extends APIService implements ListService {
Future<TaskList> create(namespaceId, TaskList tl) {
return client
.put('/namespaces/$namespaceId/lists', body: tl.toJSON())
.then((map) => TaskList.fromJson(map));
.then((response) => TaskList.fromJson(response.body));
}
@override
@ -22,6 +22,10 @@ class ListAPIService extends APIService implements ListService {
@override
Future<TaskList> get(int listId) {
return client
.get('/lists/$listId')
.then((response) => TaskList.fromJson(response.body));
/*
return client.get('/lists/$listId').then((map) {
if (map.containsKey('id')) {
return client.get("/lists/$listId/tasks").then((tasks) => TaskList.fromJson(
@ -29,24 +33,25 @@ class ListAPIService extends APIService implements ListService {
}
return TaskList.fromJson(map);
});
*/
}
@override
Future<List<TaskList>> getAll() {
return client.get('/lists').then(
(list) => convertList(list, (result) => TaskList.fromJson(result)));
return client.get('/lists').then((response) =>
convertList(response.body, (result) => TaskList.fromJson(result)));
}
@override
Future<List<TaskList>> getByNamespace(int namespaceId) {
return client.get('/namespaces/$namespaceId/lists').then(
(list) => convertList(list, (result) => TaskList.fromJson(result)));
return client.get('/namespaces/$namespaceId/lists').then((response) =>
convertList(response.body, (result) => TaskList.fromJson(result)));
}
@override
Future<TaskList> update(TaskList tl) {
return client
.post('/lists/${tl.id}', body: tl.toJSON())
.then((map) => TaskList.fromJson(map));
.then((response) => TaskList.fromJson(response.body));
}
}

View File

@ -12,7 +12,7 @@ class NamespaceAPIService extends APIService implements NamespaceService {
Future<Namespace> create(Namespace ns) {
return client
.put('/namespaces', body: ns.toJSON())
.then((map) => Namespace.fromJson(map));
.then((response) => Namespace.fromJson(response.body));
}
@override
@ -24,19 +24,19 @@ class NamespaceAPIService extends APIService implements NamespaceService {
Future<Namespace> get(int namespaceId) {
return client
.get('/namespaces/$namespaceId')
.then((map) => Namespace.fromJson(map));
.then((response) => Namespace.fromJson(response.body));
}
@override
Future<List<Namespace>> getAll() {
return client.get('/namespaces').then(
(list) => convertList(list, (result) => Namespace.fromJson(result)));
return client.get('/namespaces').then((response) =>
convertList(response.body, (result) => Namespace.fromJson(result)));
}
@override
Future<Namespace> update(Namespace ns) {
return client
.post('/namespaces/${ns.id}', body: ns.toJSON())
.then((map) => Namespace.fromJson(map));
.then((response) => Namespace.fromJson(response.body));
}
}

9
lib/api/response.dart Normal file
View File

@ -0,0 +1,9 @@
// This is a wrapper class to be able to return the headers up to the provider
// to properly handle things like pagination with it.
class Response {
Response(this.body, this.statusCode, this.headers);
final dynamic body;
final int statusCode;
final Map<String, String> headers;
}

View File

@ -1,6 +1,7 @@
import 'dart:async';
import 'package:vikunja_app/api/client.dart';
import 'package:vikunja_app/api/response.dart';
import 'package:vikunja_app/api/service.dart';
import 'package:vikunja_app/models/task.dart';
import 'package:vikunja_app/service/services.dart';
@ -12,7 +13,7 @@ class TaskAPIService extends APIService implements TaskService {
Future<Task> add(int listId, Task task) {
return client
.put('/lists/$listId', body: task.toJSON())
.then((map) => Task.fromJson(map));
.then((response) => Task.fromJson(response.body));
}
@override
@ -24,6 +25,19 @@ class TaskAPIService extends APIService implements TaskService {
Future<Task> update(Task task) {
return client
.post('/tasks/${task.id}', body: task.toJSON())
.then((map) => Task.fromJson(map));
.then((response) => Task.fromJson(response.body));
}
@override
Future<Response> getAll(int listId,
[Map<String, List<String>> queryParameters]) {
return client.get('/lists/$listId/tasks', queryParameters).then(
(response) => new Response(
convertList(response.body, (result) => Task.fromJson(result)),
response.statusCode,
response.headers));
}
@override
int get maxPages => throw UnimplementedError();
}

View File

@ -13,7 +13,7 @@ class UserAPIService extends APIService implements UserService {
var token = await client.post('/login', body: {
'username': username,
'password': password
}).then((map) => map['token']);
}).then((response) => response.body['token']);
return UserAPIService(Client(token, client.base))
.getCurrentUser()
.then((user) => UserTokenPair(user, token));
@ -25,12 +25,12 @@ class UserAPIService extends APIService implements UserService {
'username': username,
'email': email,
'password': password
}).then((resp) => resp['username']);
}).then((response) => response.body['username']);
return login(newUser, password);
}
@override
Future<User> getCurrentUser() {
return client.get('/user').then((map) => User.fromJson(map));
return client.get('/user').then((response) => User.fromJson(response.body));
}
}

View File

@ -7,9 +7,15 @@ import 'package:vikunja_app/models/task.dart';
class TaskTile extends StatefulWidget {
final Task task;
final VoidCallback onEdit;
final ValueSetter<bool> onMarkedAsDone;
final bool loading;
const TaskTile({Key key, @required this.task, this.onEdit, this.loading = false})
const TaskTile(
{Key key,
@required this.task,
this.onEdit,
this.loading = false,
this.onMarkedAsDone})
: assert(task != null),
super(key: key);
@ -40,28 +46,30 @@ class TaskTileState extends State<TaskTile> {
strokeWidth: 2.0,
)),
),
title: Text(_currentTask.title),
subtitle: _currentTask.description == null || _currentTask.description.isEmpty
? null
: Text(_currentTask.description),
title: Text(widget.task.title),
subtitle:
widget.task.description == null || widget.task.description.isEmpty
? null
: Text(widget.task.description),
trailing: IconButton(
icon: Icon(Icons.settings),
onPressed: () {}, // TODO: implement edit task
onPressed: () => widget.onEdit,
),
);
}
return CheckboxListTile(
title: Text(_currentTask.title),
title: Text(widget.task.title),
controlAffinity: ListTileControlAffinity.leading,
value: _currentTask.done ?? false,
subtitle: _currentTask.description == null || _currentTask.description.isEmpty
? null
: Text(_currentTask.description),
value: widget.task.done ?? false,
subtitle:
widget.task.description == null || widget.task.description.isEmpty
? null
: Text(widget.task.description),
secondary: IconButton(
icon: Icon(Icons.settings),
onPressed: widget.onEdit,
icon: Icon(Icons.settings),
onPressed: widget.onEdit,
),
onChanged: _change,
onChanged: widget.onMarkedAsDone,
);
}
@ -69,24 +77,22 @@ class TaskTileState extends State<TaskTile> {
setState(() {
this._loading = true;
});
Task newTask = await _updateTask(_currentTask, value);
Task newTask = await _updateTask(widget.task, value);
setState(() {
this._currentTask = newTask;
//this.widget.task = newTask;
this._loading = false;
});
}
Future<Task> _updateTask(Task task, bool checked) {
// TODO use copyFrom
return VikunjaGlobal.of(context).taskService.update(
Task(
return VikunjaGlobal.of(context).taskService.update(Task(
id: task.id,
done: checked,
title: task.title,
description: task.description,
owner: null,
)
);
createdBy: null,
));
}
}

View File

@ -0,0 +1,51 @@
import 'package:datetime_picker_formfield/datetime_picker_formfield.dart';
import 'package:flutter/material.dart';
import 'package:vikunja_app/theme/constants.dart';
class VikunjaDateTimePicker extends StatelessWidget {
final String label;
final Function onSaved;
final Function onChanged;
final DateTime initialValue;
final EdgeInsetsGeometry padding;
final Icon icon;
final InputBorder border;
const VikunjaDateTimePicker({
Key key,
@required this.label,
this.onSaved,
this.onChanged,
this.initialValue,
this.padding = const EdgeInsets.symmetric(vertical: 10.0),
this.icon = const Icon(Icons.date_range),
this.border = InputBorder.none,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return DateTimeField(
//dateOnly: false,
//editable: false, // Otherwise editing the date is not possible, this setting affects the underlying text field.
initialValue: initialValue == DateTime.fromMillisecondsSinceEpoch(0)
? null
: initialValue,
format: vDateFormatLong,
decoration: InputDecoration(
labelText: label,
border: border,
icon: icon,
),
onSaved: onSaved,
onChanged: onChanged,
onShowPicker: (context, currentValue) {
return showDatePicker(
context: context,
firstDate: DateTime(1900),
initialDate: currentValue ?? DateTime.now(),
lastDate: DateTime(2100));
},
);
}
}

52
lib/components/label.dart Normal file
View File

@ -0,0 +1,52 @@
import 'package:flutter/material.dart';
import 'package:vikunja_app/models/label.dart';
import 'package:vikunja_app/theme/constants.dart';
class LabelComponent extends StatefulWidget {
final Label label;
final VoidCallback onDelete;
const LabelComponent({Key key, @required this.label, this.onDelete})
: super(key: key);
@override
State<StatefulWidget> createState() {
return new LabelComponentState();
}
}
class LabelComponentState extends State<LabelComponent> {
@override
Widget build(BuildContext context) {
Color backgroundColor = widget.label.color ?? vLabelDefaultColor;
Color textColor =
backgroundColor.computeLuminance() > 0.5 ? vLabelDark : vLabelLight;
return Chip(
label: Text(
widget.label.title,
style: TextStyle(
color: textColor,
),
),
backgroundColor: backgroundColor,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(3)),
),
onDeleted: widget.onDelete,
deleteIconColor: textColor,
deleteIcon: Container(
padding: EdgeInsets.all(3),
decoration: BoxDecoration(
color: Color.fromARGB(50, 0, 0, 0),
shape: BoxShape.circle,
),
child: Icon(
Icons.close,
color: textColor,
size: 15,
),
),
);
}
}

View File

@ -0,0 +1,4 @@
extension StringExtensions on String {
Uri toUri() => Uri.tryParse(this);
}

View File

@ -1,6 +1,9 @@
import 'package:flutter/material.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:vikunja_app/api/client.dart';
import 'package:vikunja_app/api/label_task.dart';
import 'package:vikunja_app/api/label_task_bulk.dart';
import 'package:vikunja_app/api/labels.dart';
import 'package:vikunja_app/api/list_implementation.dart';
import 'package:vikunja_app/api/namespace_implementation.dart';
import 'package:vikunja_app/api/task_implementation.dart';
@ -45,6 +48,13 @@ class VikunjaGlobalState extends State<VikunjaGlobal> {
ListService get listService => new ListAPIService(client);
LabelService get labelService => new LabelAPIService(client);
LabelTaskService get labelTaskService => new LabelTaskAPIService(client);
LabelTaskBulkAPIService get labelTaskBulkService =>
new LabelTaskBulkAPIService(client);
@override
void initState() {
super.initState();

View File

@ -3,7 +3,7 @@ import 'package:vikunja_app/global.dart';
import 'package:vikunja_app/pages/home.dart';
import 'package:vikunja_app/pages/user/login.dart';
import 'package:vikunja_app/theme/theme.dart';
import 'package:alice/alice.dart';
//import 'package:alice/alice.dart';
void main() => runApp(VikunjaGlobal(
child: new VikunjaApp(home: HomePage()),
@ -19,12 +19,12 @@ class VikunjaApp extends StatefulWidget {
}
class _VikunjaAppState extends State<VikunjaApp> {
Alice alice = Alice(showNotification: true);
//Alice alice = Alice(showNotification: true);
@override
Widget build(BuildContext context) {
return new MaterialApp(
navigatorKey: alice.getNavigatorKey(),
//navigatorKey: alice.getNavigatorKey(),
title: 'Vikunja',
theme: buildVikunjaTheme(),
darkTheme: buildVikunjaDarkTheme(),

43
lib/models/label.dart Normal file
View File

@ -0,0 +1,43 @@
import 'dart:ui';
import 'package:meta/meta.dart';
import 'package:vikunja_app/models/user.dart';
class Label {
final int id;
final String title, description;
final DateTime created, updated;
final User createdBy;
final Color color;
Label(
{this.id,
this.title,
this.description,
this.color,
this.created,
this.updated,
this.createdBy});
Label.fromJson(Map<String, dynamic> json)
: id = json['id'],
title = json['title'],
description = json['description'],
color = json['hex_color'] == ''
? null
: new Color(int.parse(json['hex_color'], radix: 16) + 0xFF000000),
updated = DateTime.fromMillisecondsSinceEpoch(json['updated']),
created = DateTime.fromMillisecondsSinceEpoch(json['created']),
createdBy = User.fromJson(json['created_by']);
toJSON() => {
'id': id,
'title': title,
'description': description,
'hex_color':
color?.value?.toRadixString(16)?.padLeft(8, '0')?.substring(2),
'created_by': createdBy?.toJSON(),
'updated': updated?.millisecondsSinceEpoch,
'created': created?.millisecondsSinceEpoch,
};
}

18
lib/models/labelTask.dart Normal file
View File

@ -0,0 +1,18 @@
import 'package:meta/meta.dart';
import 'package:vikunja_app/models/label.dart';
import 'package:vikunja_app/models/task.dart';
class LabelTask {
final Label label;
final Task task;
LabelTask({@required this.label, @required this.task});
LabelTask.fromJson(Map<String, dynamic> json)
: label = new Label(id: json['label_id']),
task = null;
toJSON() => {
'label_id': label.id,
};
}

View File

@ -0,0 +1,15 @@
import 'package:meta/meta.dart';
import 'package:vikunja_app/models/label.dart';
class LabelTaskBulk {
final List<Label> labels;
LabelTaskBulk({@required this.labels});
LabelTaskBulk.fromJson(Map<String, dynamic> json)
: labels = json['labels']?.map((label) => Label.fromJson(label));
toJSON() => {
'labels': labels.map((label) => label.toJSON()).toList(),
};
}

View File

@ -1,48 +1,84 @@
import 'package:vikunja_app/models/user.dart';
import 'package:meta/meta.dart';
import 'package:json_annotation/json_annotation.dart';
import 'package:vikunja_app/models/label.dart';
import 'package:vikunja_app/models/user.dart';
import 'package:vikunja_app/utils/datetime_to_unix.dart';
@JsonSerializable()
class Task {
final int id;
final DateTime created, updated, due;
final List<DateTime> reminders;
final int id, parentTaskId, priority;
final DateTime created, updated, dueDate, startDate, endDate;
final List<DateTime> reminderDates;
final String title, description;
final bool done;
final User owner;
final User createdBy;
final Duration repeatAfter;
final List<Task> subtasks;
final List<Label> labels;
Task(
{@required this.id,
this.title,
this.description,
this.done,
this.reminderDates,
this.dueDate,
this.startDate,
this.endDate,
this.parentTaskId,
this.priority,
this.repeatAfter,
this.subtasks,
this.labels,
this.created,
this.updated,
this.reminders,
this.due,
@required this.title,
this.description,
@required this.done,
@required this.owner});
this.createdBy});
Task.fromJson(Map<String, dynamic> json)
: id = json['id'],
updated = DateTime.parse(json['updated']),
created = DateTime.parse(json['created']),
reminders = (json['reminder_dates'] as List<dynamic>)
?.map((r) => DateTime.parse(r))
?.toList(),
due = json['due_date'] != null ? DateTime.parse(json['due_date']) : null,
description = json['description'],
title = json['title'],
description = json['description'],
done = json['done'],
owner = json['created_by'] != null ? User.fromJson(json['created_by']) : null;
reminderDates = (json['reminderDates'] as List<dynamic>)
?.map((ts) => dateTimeFromUnixTimestamp(ts))
?.cast<DateTime>()
?.toList(),
dueDate = dateTimeFromUnixTimestamp(json['dueDate']),
startDate = dateTimeFromUnixTimestamp(json['startDate']),
endDate = dateTimeFromUnixTimestamp(json['endDate']),
parentTaskId = json['parentTaskID'],
priority = json['priority'],
repeatAfter = Duration(seconds: json['repeatAfter']),
labels = (json['labels'] as List<dynamic>)
?.map((label) => Label.fromJson(label))
?.cast<Label>()
?.toList(),
subtasks = (json['subtasks'] as List<dynamic>)
?.map((subtask) => Task.fromJson(subtask))
?.cast<Task>()
?.toList(),
updated = dateTimeFromUnixTimestamp(json['updated']),
created = dateTimeFromUnixTimestamp(json['created']),
createdBy = User.fromJson(json['createdBy']);
toJSON() => {
'id': id,
'updated': updated?.toIso8601String(),
'created': created?.toIso8601String(),
'reminder_dates':
reminders?.map((date) => date.toIso8601String())?.toList(),
'due_date': due?.toIso8601String(),
'description': description,
'title': title,
'description': description,
'done': done ?? false,
'created_by': owner?.toJSON()
'reminderDates': reminderDates
?.map((date) => datetimeToUnixTimestamp(date))
?.toList(),
'dueDate': datetimeToUnixTimestamp(dueDate),
'startDate': datetimeToUnixTimestamp(startDate),
'endDate': datetimeToUnixTimestamp(endDate),
'priority': priority,
'repeatAfter': repeatAfter?.inSeconds,
'labels': labels?.map((label) => label.toJSON())?.toList(),
'subtasks': subtasks?.map((subtask) => subtask.toJSON())?.toList(),
'createdBy': createdBy?.toJSON(),
'updated': datetimeToUnixTimestamp(updated),
'created': datetimeToUnixTimestamp(created),
};
}

View File

@ -18,9 +18,10 @@ class HomePage extends StatefulWidget {
class HomePageState extends State<HomePage> with AfterLayoutMixin<HomePage> {
List<Namespace> _namespaces = [];
Namespace get _currentNamespace => _selectedDrawerIndex >= 0 && _selectedDrawerIndex < _namespaces.length
? _namespaces[_selectedDrawerIndex]
: null;
Namespace get _currentNamespace =>
_selectedDrawerIndex >= 0 && _selectedDrawerIndex < _namespaces.length
? _namespaces[_selectedDrawerIndex]
: null;
int _selectedDrawerIndex = -1;
bool _loading = true;
bool _showUserDetails = false;
@ -32,14 +33,15 @@ class HomePageState extends State<HomePage> with AfterLayoutMixin<HomePage> {
Widget _namespacesWidget() {
List<Widget> namespacesList = <Widget>[];
_namespaces.asMap().forEach((i, namespace) => namespacesList.add(
ListTile(
leading: const Icon(Icons.folder),
title: Text(namespace.title),
selected: i == _selectedDrawerIndex,
onTap: () => _onSelectItem(i),
)
));
_namespaces
.asMap()
.forEach((i, namespace) => namespacesList.add(ListTile(
leading: const Icon(Icons.folder),
title: Text(namespace.title),
selected: i == _selectedDrawerIndex,
onTap: () => _onSelectItem(i),
))
);
return this._loading
? Center(child: CircularProgressIndicator())
@ -92,6 +94,7 @@ class HomePageState extends State<HomePage> with AfterLayoutMixin<HomePage> {
drawer: Drawer(
child: Column(children: <Widget>[
UserAccountsDrawerHeader(
// Removed until we find a way to disable the user email only for some occasions and not everywhere
accountEmail: currentUser?.email == null ? null : Text(currentUser.email),
accountName: currentUser?.username == null ? null : Text(currentUser.username),
onDetailsPressed: () {
@ -151,17 +154,26 @@ class HomePageState extends State<HomePage> with AfterLayoutMixin<HomePage> {
context: context,
builder: (_) => AddDialog(
onAdd: (name) => _addNamespace(name, context),
decoration: InputDecoration(labelText: 'Namespace', hintText: 'eg. Personal Namespace'),
decoration: InputDecoration(
labelText: 'Namespace',
hintText: 'eg. Personal Namespace',
),
));
}
_addNamespace(String name, BuildContext context) {
VikunjaGlobal.of(context).namespaceService.create(Namespace(id: null, title: name)).then((_) {
VikunjaGlobal.of(context)
.namespaceService
.create(Namespace(id: null, title: name))
.then((_) {
_loadNamespaces();
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text('The namespace was created successfully!'),
));
}).catchError((error) => showDialog(context: context, builder: (context) => ErrorDialog(error: error)));
}).catchError((error) => showDialog(
context: context,
builder: (context) => ErrorDialog(error: error),
));
}
Future<void> _loadNamespaces() {

View File

@ -1,12 +1,15 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:provider/provider.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/list.dart';
import 'package:vikunja_app/models/task.dart';
import 'package:vikunja_app/pages/list/list_edit.dart';
import 'package:vikunja_app/pages/list/task_edit.dart';
import 'package:vikunja_app/stores/list_store.dart';
class ListPage extends StatefulWidget {
final TaskList taskList;
@ -19,12 +22,18 @@ class ListPage extends StatefulWidget {
class _ListPageState extends State<ListPage> {
TaskList _list;
List<Task> _tasks = [];
List<Task> _loadingTasks = [];
bool _loading = true;
int _currentPage = 1;
@override
void initState() {
_list = TaskList(id: widget.taskList.id, title: widget.taskList.title, tasks: []);
_list = TaskList(
id: widget.taskList.id,
title: widget.taskList.title,
tasks: [],
);
super.initState();
}
@ -36,6 +45,10 @@ class _ListPageState extends State<ListPage> {
@override
Widget build(BuildContext context) {
var tasks = (_list?.tasks?.map(_buildTile) ?? []).toList();
tasks.addAll(_loadingTasks.map(_buildLoadingTile));
final taskState = Provider.of<ListProvider>(context);
return Scaffold(
appBar: AppBar(
title: Text(_list.title),
@ -53,13 +66,32 @@ class _ListPageState extends State<ListPage> {
),
],
),
body: !this._loading
body: !taskState.isLoading
? RefreshIndicator(
child: _list.tasks.length > 0
? ListView(
child: taskState.tasks.length > 0
? ListView.builder(
padding: EdgeInsets.symmetric(vertical: 8.0),
children: ListTile.divideTiles(context: context, tiles: _listTasks()).toList(),
)
// children: ListTile.divideTiles(context: context, tiles: _listTasks()).toList(),
itemBuilder: (context, i) {
if (i.isOdd) return Divider();
final index = i ~/ 2;
// This handles the case if there are no more elements in the list left which can be provided by the api
if (taskState.maxPages == _currentPage &&
index == taskState.tasks.length - 1) return null;
if (index >= taskState.tasks.length &&
_currentPage < taskState.maxPages) {
_currentPage++;
_loadTasksForPage(_currentPage);
}
return index < taskState.tasks.length
? TaskTile(
task: taskState.tasks[index],
)
: null;
})
: Center(child: Text('This list is empty.')),
onRefresh: _loadList,
)
@ -72,13 +104,41 @@ class _ListPageState extends State<ListPage> {
}
List<Widget> _listTasks() {
var tasks = (_list?.tasks?.map(_buildTile) ?? []).toList();
var tasks = (_tasks?.map(_buildTile) ?? []).toList();
tasks.addAll(_loadingTasks.map(_buildLoadingTile));
return tasks;
}
TaskTile _buildTile(Task task) {
return TaskTile(task: task, loading: false);
return TaskTile(
task: task,
loading: false,
onEdit: () => Navigator.push(
context,
MaterialPageRoute(
builder: (context) => TaskEditPage(
task: task,
),
),
),
onMarkedAsDone: (done) {
VikunjaGlobal.of(context)
.taskService
.update(Task(
id: task.id,
done: done,
))
.then((newTask) => setState(() {
// FIXME: This is ugly. We should use a redux to not have to do these kind of things.
// This is enough for now (it works) but we should definitly fix it later.
_list.tasks.asMap().forEach((i, t) {
if (newTask.id == t.id) {
_list.tasks[i] = newTask;
}
});
}));
},
);
}
TaskTile _buildLoadingTile(Task task) {
@ -88,32 +148,66 @@ class _ListPageState extends State<ListPage> {
);
}
void _loadTasksForPage(int page) {
Provider.of<ListProvider>(context, listen: false).loadTasks(
context: context,
listId: _list.id,
page: page,
);
}
// Future<void> _loadTasksForPage(int page) {
// return VikunjaGlobal.of(context).taskService.getAll(_list.id, {
// "sort_by": ["done", "id"],
// "order_by": ["asc", "desc"],
// "page": [page.toString()]
// }).then((tasks) {
// setState(() {
// _loading = false;
// _tasks.addAll(tasks);
// });
// });
// }
Future<void> _loadList() {
return VikunjaGlobal.of(context).listService.get(widget.taskList.id).then((list) {
return VikunjaGlobal.of(context)
.listService
.get(widget.taskList.id)
.then((list) {
setState(() {
_loading = false;
_loading = true;
_list = list;
});
_loadTasksForPage(_currentPage);
});
}
_addItemDialog(BuildContext context) {
showDialog(
context: context,
builder: (_) => AddDialog(
onAdd: (name) => _addItem(name, context),
decoration: InputDecoration(labelText: 'Task Name', hintText: 'eg. Milk'),
context: context,
builder: (_) => AddDialog(
onAdd: (title) => _addItem(title, context),
decoration: InputDecoration(
labelText: 'Task Name',
hintText: 'eg. Milk',
),
),
);
}
_addItem(String name, BuildContext context) {
_addItem(String title, BuildContext context) {
// FIXME: Use provider
var globalState = VikunjaGlobal.of(context);
var newTask = Task(id: null, title: name, owner: globalState.currentUser, done: false);
var newTask = Task(
id: null,
title: title,
createdBy: globalState.currentUser,
done: false,
);
setState(() => _loadingTasks.add(newTask));
globalState.taskService.add(_list.id, newTask).then((task) {
setState(() {
_list.tasks.add(task);
_tasks.add(task);
});
}).then((_) {
_loadList();
@ -123,4 +217,12 @@ class _ListPageState extends State<ListPage> {
));
});
}
Future<Task> _updateTask(Task task, bool checked) {
// TODO use copyFrom
return VikunjaGlobal.of(context).taskService.update(Task(
id: task.id,
done: checked,
));
}
}

View File

@ -0,0 +1,437 @@
import 'package:flutter/material.dart';
import 'package:flutter_typeahead/flutter_typeahead.dart';
import 'package:vikunja_app/components/datetimePicker.dart';
import 'package:vikunja_app/components/label.dart';
import 'package:vikunja_app/global.dart';
import 'package:vikunja_app/models/label.dart';
import 'package:vikunja_app/models/task.dart';
import 'package:vikunja_app/theme/button.dart';
import 'package:vikunja_app/theme/buttonText.dart';
import 'package:vikunja_app/utils/repeat_after_parse.dart';
class TaskEditPage extends StatefulWidget {
final Task task;
TaskEditPage({this.task}) : super(key: Key(task.toString()));
@override
State<StatefulWidget> createState() => _TaskEditPageState();
}
class _TaskEditPageState extends State<TaskEditPage> {
final _formKey = GlobalKey<FormState>();
bool _loading = false;
int _priority;
DateTime _dueDate, _startDate, _endDate;
List<DateTime> _reminderDates;
String _title, _description, _repeatAfterType;
Duration _repeatAfter;
List<Label> _labels;
List<Label>
_suggestedLabels; // we use this to find the label object after a user taps on the suggestion, because the typeahead only uses strings, not full objects.
var _reminderInputs = new List<Widget>();
final _labelTypeAheadController = TextEditingController();
@override
Widget build(BuildContext ctx) {
// This builds the initial list of reminder inputs only once.
if (_reminderDates == null) {
_reminderDates = widget.task.reminderDates ?? new List();
_reminderDates?.asMap()?.forEach((i, time) =>
setState(() => _reminderInputs?.add(VikunjaDateTimePicker(
initialValue: time,
label: 'Reminder',
onSaved: (reminder) => _reminderDates[i] = reminder,
))));
}
if (_labels == null) {
_labels = widget.task.labels ?? new List();
}
return Scaffold(
appBar: AppBar(
title: Text('Edit Task'),
),
body: Builder(
builder: (BuildContext context) => SafeArea(
child: Form(
key: _formKey,
child: ListView(padding: const EdgeInsets.all(16.0), children: <
Widget>[
Padding(
padding: EdgeInsets.symmetric(vertical: 10.0),
child: TextFormField(
maxLines: null,
keyboardType: TextInputType.multiline,
initialValue: widget.task.title,
onSaved: (title) => _title = title,
validator: (title) {
if (title.length < 3 || title.length > 250) {
return 'The title needs to have between 3 and 250 characters.';
}
return null;
},
decoration: new InputDecoration(
labelText: 'Title',
border: OutlineInputBorder(),
),
),
),
Padding(
padding: EdgeInsets.symmetric(vertical: 10.0),
child: TextFormField(
maxLines: null,
keyboardType: TextInputType.multiline,
initialValue: widget.task.description,
onSaved: (description) => _description = description,
validator: (description) {
if (description.length > 1000) {
return 'The description can have a maximum of 1000 characters.';
}
return null;
},
decoration: new InputDecoration(
labelText: 'Description',
border: OutlineInputBorder(),
),
),
),
VikunjaDateTimePicker(
icon: Icon(Icons.access_time),
label: 'Due Date',
initialValue: widget.task.dueDate,
onSaved: (duedate) => _dueDate = duedate,
),
VikunjaDateTimePicker(
label: 'Start Date',
initialValue: widget.task.startDate,
onSaved: (startDate) => _startDate = startDate,
),
VikunjaDateTimePicker(
label: 'End Date',
initialValue: widget.task.endDate,
onSaved: (endDate) => _endDate = endDate,
),
Row(
children: [
Expanded(
flex: 2,
child: TextFormField(
keyboardType: TextInputType.number,
initialValue: getRepeatAfterValueFromDuration(
widget.task.repeatAfter)
?.toString(),
onSaved: (repeatAfter) => _repeatAfter =
getDurationFromType(repeatAfter, _repeatAfterType),
decoration: new InputDecoration(
labelText: 'Repeat after',
border: InputBorder.none,
icon: Icon(Icons.repeat),
),
),
),
Expanded(
child: DropdownButton<String>(
isExpanded: true,
isDense: true,
value: _repeatAfterType ??
getRepeatAfterTypeFromDuration(
widget.task.repeatAfter),
onChanged: (String newValue) {
setState(() {
_repeatAfterType = newValue;
});
},
items: <String>[
'Hours',
'Days',
'Weeks',
'Months',
'Years'
].map<DropdownMenuItem<String>>((String value) {
return DropdownMenuItem<String>(
value: value,
child: Text(value),
);
}).toList(),
),
),
],
),
Column(
children: _reminderInputs,
),
GestureDetector(
child: Padding(
padding: EdgeInsets.symmetric(vertical: 10),
child: Row(
children: <Widget>[
Padding(
padding: EdgeInsets.only(right: 15, left: 2),
child: Icon(
Icons.alarm_add,
color: Colors.grey,
)),
Text(
'Add a reminder',
style: TextStyle(
color: Colors.grey,
fontSize: 16,
),
),
],
),
),
onTap: () {
// We add a new entry every time we add a new input, to make sure all inputs have a place where they can put their value.
_reminderDates.add(null);
var currentIndex = _reminderDates.length - 1;
// FIXME: Why does putting this into a row fails?
setState(() => _reminderInputs.add(Row(
children: <Widget>[
VikunjaDateTimePicker(
label: 'Reminder',
onSaved: (reminder) =>
_reminderDates[currentIndex] = reminder,
),
GestureDetector(
onTap: () => print('tapped'),
child: Icon(Icons.close),
)
],
)));
}),
InputDecorator(
isEmpty: _priority == null,
decoration: InputDecoration(
icon: const Icon(Icons.flag),
labelText: 'Priority',
border: InputBorder.none,
),
child: new DropdownButton<String>(
value: _priorityToString(_priority),
isExpanded: true,
isDense: true,
onChanged: (String newValue) {
setState(() {
_priority = _priorityFromString(newValue);
});
},
items: ['Unset', 'Low', 'Medium', 'High', 'Urgent', 'DO NOW']
.map((String value) {
return new DropdownMenuItem(
value: value,
child: new Text(value),
);
}).toList(),
),
),
Wrap(
spacing: 10,
children: _labels.map((Label label) {
return LabelComponent(
label: label,
onDelete: () {
_removeLabel(label);
},
);
}).toList()),
Row(
children: <Widget>[
Container(
width: MediaQuery.of(context).size.width - 80,
child: TypeAheadFormField(
textFieldConfiguration: TextFieldConfiguration(
controller: _labelTypeAheadController,
decoration:
InputDecoration(labelText: 'Add a new label')),
suggestionsCallback: (pattern) {
return _searchLabel(pattern);
},
itemBuilder: (context, suggestion) {
return ListTile(
title: Text(suggestion),
);
},
transitionBuilder: (context, suggestionsBox, controller) {
return suggestionsBox;
},
onSuggestionSelected: (suggestion) {
_addLabel(suggestion);
},
),
),
IconButton(
onPressed: () =>
_createAndAddLabel(_labelTypeAheadController.text),
icon: Icon(Icons.add),
)
],
),
Builder(
builder: (context) => Padding(
padding: EdgeInsets.symmetric(vertical: 10.0),
child: FancyButton(
onPressed: !_loading
? () {
if (_formKey.currentState.validate()) {
Form.of(context).save();
_saveTask(context);
}
}
: null,
child: _loading
? CircularProgressIndicator()
: VikunjaButtonText('Save'),
))),
]),
),
),
),
);
}
_saveTask(BuildContext context) async {
setState(() => _loading = true);
// Removes all reminders with no value set.
_reminderDates.removeWhere((d) => d == null);
Task updatedTask = Task(
id: widget.task.id,
title: _title,
description: _description,
done: widget.task.done,
reminderDates: _reminderDates,
createdBy: widget.task.createdBy,
dueDate: _dueDate,
startDate: _startDate,
endDate: _endDate,
priority: _priority,
repeatAfter: _repeatAfter,
);
// update the labels
VikunjaGlobal.of(context)
.labelTaskBulkService
.update(updatedTask, _labels)
.catchError((err) {
setState(() => _loading = false);
Scaffold.of(context).showSnackBar(
SnackBar(
content: Text('Something went wrong: ' + err.toString()),
),
);
});
VikunjaGlobal.of(context).taskService.update(updatedTask).then((_) {
setState(() => _loading = false);
Scaffold.of(context).showSnackBar(SnackBar(
content: Text('The task was updated successfully!'),
));
}).catchError((err) {
setState(() => _loading = false);
Scaffold.of(context).showSnackBar(
SnackBar(
content: Text('Something went wrong: ' + err.toString()),
action: SnackBarAction(
label: 'CLOSE',
onPressed: Scaffold.of(context).hideCurrentSnackBar),
),
);
});
}
_removeLabel(Label label) {
setState(() {
_labels.removeWhere((l) => l.id == label.id);
});
}
_searchLabel(String query) {
return VikunjaGlobal.of(context)
.labelService
.getAll(query: query)
.then((labels) {
// Only show those labels which aren't already added to the task
labels.removeWhere((labelToRemove) => _labels.contains(labelToRemove));
_suggestedLabels = labels;
return labels.map((label) => label.title).toList();
});
}
_addLabel(String labelTitle) {
// FIXME: This is not an optimal solution...
bool found = false;
_suggestedLabels.forEach((label) {
if (label.title == labelTitle) {
_labels.add(label);
found = true;
}
});
if (found) {
_labelTypeAheadController.clear();
}
}
_createAndAddLabel(String labelTitle) {
// Only add a label if there are none to add
if (labelTitle == '' || _suggestedLabels.length > 0) {
return;
}
Label newLabel = Label(title: labelTitle);
VikunjaGlobal.of(context)
.labelService
.create(newLabel)
.then((createdLabel) {
setState(() {
_labels.add(createdLabel);
_labelTypeAheadController.clear();
});
});
}
// FIXME: Move the following two functions to an extra class or type.
_priorityFromString(String priority) {
switch (priority) {
case 'Low':
return 1;
case 'Medium':
return 2;
case 'High':
return 3;
case 'Urgent':
return 4;
case 'DO NOW':
return 5;
default:
// unset
return 0;
}
}
_priorityToString(int priority) {
switch (priority) {
case 0:
return 'Unset';
case 1:
return 'Low';
case 2:
return 'Medium';
case 3:
return 'High';
case 4:
return 'Urgent';
case 5:
return 'DO NOW';
default:
return null;
}
}
}

View File

@ -3,12 +3,14 @@ import 'dart:async';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:after_layout/after_layout.dart';
import 'package:provider/provider.dart';
import 'package:vikunja_app/components/AddDialog.dart';
import 'package:vikunja_app/global.dart';
import 'package:vikunja_app/models/list.dart';
import 'package:vikunja_app/models/namespace.dart';
import 'package:vikunja_app/pages/list/list.dart';
import 'package:vikunja_app/stores/list_store.dart';
class NamespacePage extends StatefulWidget {
final Namespace namespace;
@ -86,6 +88,7 @@ class _NamespacePageState extends State<NamespacePage>
}
Future<void> _loadLists() {
// FIXME: This is called even when the tasks on a list are loaded - which is not needed at all
return VikunjaGlobal.of(context)
.listService
.getByNamespace(widget.namespace.id)
@ -96,8 +99,15 @@ class _NamespacePageState extends State<NamespacePage>
}
_openList(BuildContext context, TaskList list) {
Navigator.of(context).push(
MaterialPageRoute(builder: (context) => ListPage(taskList: list)));
Navigator.of(context).push(MaterialPageRoute(
builder: (context) => ChangeNotifierProvider<ListProvider>(
create: (_) => new ListProvider(),
child: ListPage(
taskList: list,
),
),
// ListPage(taskList: list)
));
}
_addListDialog(BuildContext context) {

View File

@ -4,20 +4,26 @@ class PlaceholderPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return new Container(
padding: EdgeInsets.only(left: 16.0),
child: new Column(
padding: EdgeInsets.all(16),
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
children: <Widget>[
new Container(
Container(
padding: EdgeInsets.only(top: 32.0),
child: new Text(
child: Text(
'Welcome to Vikunja',
style: Theme.of(context).textTheme.headline,
style: Theme.of(context).textTheme.headline5,
),
),
new Text('Please select a namespace by tapping the ☰ icon.',
style: Theme.of(context).textTheme.subhead),
Padding(
padding: EdgeInsets.symmetric(vertical: 10),
child: Text('Please select a namespace by tapping the ☰ icon.',
style: Theme.of(context).textTheme.subtitle1),
)
],
));
),
),
);
}
}

View File

@ -140,7 +140,7 @@ class _RegisterPageState extends State<RegisterPage> {
'Registration failed! Please check your server url and credentials. ' +
ex.toString()),
actions: <Widget>[
FlatButton(
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Close'))
],

View File

@ -1,5 +1,6 @@
import 'dart:async';
import 'package:vikunja_app/api/response.dart';
import 'package:vikunja_app/models/list.dart';
import 'package:vikunja_app/models/namespace.dart';
import 'package:vikunja_app/models/task.dart';
@ -39,7 +40,7 @@ var _tasks = {
1: Task(
id: 1,
title: 'Task 1',
owner: _users[1],
createdBy: _users[1],
updated: DateTime.now(),
created: DateTime.now(),
description: 'A descriptive task',
@ -121,7 +122,8 @@ class MockedTaskService implements TaskService {
@override
Future delete(int taskId) {
_lists.forEach(
(_, list) => list.tasks.removeWhere((task) => task.id == taskId));
(_, list) => list.tasks.removeWhere((task) => task.id == taskId)
);
_tasks.remove(taskId);
return Future.value();
}
@ -144,6 +146,15 @@ class MockedTaskService implements TaskService {
_lists[listId].tasks.add(task);
return Future.value(task);
}
@override
Future<Response> getAll(int listId,
[Map<String, List<String>> queryParameters]) {
return Future.value(new Response(_tasks.values.toList(), 200, {}));
}
@override
int get maxPages => 1;
}
class MockedUserService implements UserService {

View File

@ -1,5 +1,8 @@
import 'dart:async';
import 'package:vikunja_app/api/response.dart';
import 'package:vikunja_app/models/label.dart';
import 'package:vikunja_app/models/labelTask.dart';
import 'package:vikunja_app/models/list.dart';
import 'package:vikunja_app/models/namespace.dart';
import 'package:vikunja_app/models/task.dart';
@ -26,6 +29,10 @@ abstract class TaskService {
Future<Task> update(Task task);
Future delete(int taskId);
Future<Task> add(int listId, Task task);
Future<Response> getAll(int listId,
[Map<String, List<String>> queryParameters]);
// TODO: Avoid having to add this to each abstract class
int get maxPages;
}
abstract class UserService {
@ -33,3 +40,21 @@ abstract class UserService {
Future<UserTokenPair> register(String username, email, password);
Future<User> getCurrentUser();
}
abstract class LabelService {
Future<List<Label>> getAll({String query});
Future<Label> get(int labelID);
Future<Label> create(Label label);
Future<Label> delete(Label label);
Future<Label> update(Label label);
}
abstract class LabelTaskService {
Future<List<Label>> getAll(LabelTask lt, {String query});
Future<Label> create(LabelTask lt);
Future<Label> delete(LabelTask lt);
}
abstract class LabelTaskBulkService {
Future<List<Label>> update(Task task, List<Label> labels);
}

View File

@ -0,0 +1,54 @@
import 'package:flutter/material.dart';
import 'package:vikunja_app/models/task.dart';
import 'package:vikunja_app/global.dart';
class ListProvider with ChangeNotifier {
bool _isLoading = false;
int _maxPages = 0;
// TODO: Streams
List<Task> _tasks = [];
bool get isLoading => _isLoading;
int get maxPages => _maxPages;
set tasks(List<Task> tasks) {
_tasks = tasks;
notifyListeners();
}
List<Task> get tasks => _tasks;
void loadTasks({BuildContext context, int listId, int page = 1}) {
_isLoading = true;
notifyListeners();
VikunjaGlobal.of(context).taskService.getAll(listId, {
"sort_by": ["done", "id"],
"order_by": ["asc", "desc"],
"page": [page.toString()]
}).then((response) {
if (response.headers["x-pagination-total-pages"] != null) {
_maxPages = int.parse(response.headers["x-pagination-total-pages"]);
}
_tasks.addAll(response.body);
_isLoading = false;
notifyListeners();
});
}
Future<void> addTask({BuildContext context, String title, int listId}) {
var globalState = VikunjaGlobal.of(context);
var newTask = Task(
id: null, title: title, createdBy: globalState.currentUser, done: false);
_isLoading = true;
notifyListeners();
return globalState.taskService.add(listId, newTask).then((task) {
_tasks.insert(0, task);
_isLoading = false;
notifyListeners();
});
}
}

View File

@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
/////////
// Colors
@ -18,9 +19,15 @@ const vButtonTextColor = vWhite;
const vButtonShadowDark = Color(0xFF0b2a4a);
const vButtonShadow = Color(0xFFb2d9ff);
const vLabelLight = Color(0xFFf2f2f2);
const vLabelDark = Color(0xFF4a4a4a);
const vLabelDefaultColor = vGreen;
///////////
// Paddings
////////
const vStandardVerticalPadding = EdgeInsets.symmetric(vertical: 5.0);
const vStandardHorizontalPadding = EdgeInsets.symmetric(horizontal: 5.0);
const vStandardPadding = EdgeInsets.symmetric(horizontal: 5.0, vertical: 5.0);
var vDateFormatLong = DateFormat("EEEE, MMMM d, yyyy 'at' H:mm");

View File

@ -0,0 +1,11 @@
datetimeToUnixTimestamp(DateTime dt) {
return dt?.millisecondsSinceEpoch == null
? null
: (dt.millisecondsSinceEpoch / 1000).round();
}
dateTimeFromUnixTimestamp(int timestamp) {
return timestamp == null
? 0
: DateTime.fromMillisecondsSinceEpoch(timestamp * 1000);
}

View File

@ -0,0 +1,66 @@
getRepeatAfterTypeFromDuration(Duration repeatAfter) {
if (repeatAfter == null || repeatAfter.inSeconds == 0) {
return null;
}
// if its dividable by 24, its something with days, otherwise hours
if (repeatAfter.inHours % 24 == 0) {
if (repeatAfter.inDays % 7 == 0) {
return 'Weeks';
} else if (repeatAfter.inDays % 365 == 0) {
return 'Years';
} else if (repeatAfter.inDays % 30 == 0) {
return 'Months';
} else {
return 'Days';
}
}
return 'Hours';
}
getRepeatAfterValueFromDuration(Duration repeatAfter) {
if (repeatAfter == null || repeatAfter.inSeconds == 0) {
return null;
}
// if its dividable by 24, its something with days, otherwise hours
if (repeatAfter.inHours % 24 == 0) {
if (repeatAfter.inDays % 7 == 0) {
// Weeks
return (repeatAfter.inDays / 7).round();
} else if (repeatAfter.inDays % 365 == 0) {
// Years
return (repeatAfter.inDays / 365).round();
} else if (repeatAfter.inDays % 30 == 0) {
// Months
return (repeatAfter.inDays / 30).round();
} else {
return repeatAfter.inDays; // Days
}
}
// Otherwise Hours
return repeatAfter.inHours;
}
getDurationFromType(String value, String type) {
// Return an empty duration if either of the values is not set
if (value == null || value == '' || type == null || type == '') {
return Duration();
}
int val = int.parse(value);
switch (type) {
case 'Hours':
return Duration(hours: val);
case 'Days':
return Duration(days: val);
case 'Weeks':
return Duration(days: val * 7);
case 'Months':
return Duration(days: val * 30);
case 'Years':
return Duration(days: val * 365);
}
}

View File

@ -1,48 +1,48 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
_fe_analyzer_shared:
dependency: transitive
description:
name: _fe_analyzer_shared
url: "https://pub.dartlang.org"
source: hosted
version: "22.0.0"
after_layout:
dependency: "direct main"
description:
name: after_layout
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.7+2"
alice:
dependency: "direct main"
version: "1.1.0"
analyzer:
dependency: transitive
description:
name: alice
name: analyzer
url: "https://pub.dartlang.org"
source: hosted
version: "0.1.12"
version: "1.7.1"
archive:
dependency: transitive
description:
name: archive
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.13"
version: "3.1.2"
args:
dependency: transitive
description:
name: args
url: "https://pub.dartlang.org"
source: hosted
version: "1.6.0"
version: "2.0.0"
async:
dependency: transitive
description:
name: async
url: "https://pub.dartlang.org"
source: hosted
version: "2.5.0"
better_player:
dependency: transitive
description:
name: better_player
url: "https://pub.dartlang.org"
source: hosted
version: "0.0.61"
version: "2.6.1"
boolean_selector:
dependency: transitive
description:
@ -50,6 +50,20 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.0"
build:
dependency: "direct main"
description:
name: build
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.2"
build_config:
dependency: transitive
description:
name: build_config
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.0"
characters:
dependency: transitive
description:
@ -64,13 +78,20 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "1.2.0"
chopper:
checked_yaml:
dependency: transitive
description:
name: chopper
name: checked_yaml
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.6"
version: "2.0.1"
cli_util:
dependency: transitive
description:
name: cli_util
url: "https://pub.dartlang.org"
source: hosted
version: "0.3.0"
clock:
dependency: transitive
description:
@ -91,35 +112,42 @@ packages:
name: convert
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.1"
version: "3.0.0"
coverage:
dependency: transitive
description:
name: coverage
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.3"
crypto:
dependency: transitive
description:
name: crypto
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.5"
csslib:
dependency: transitive
description:
name: csslib
url: "https://pub.dartlang.org"
source: hosted
version: "0.16.2"
version: "3.0.1"
cupertino_icons:
dependency: "direct main"
description:
name: cupertino_icons
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.2"
dio:
version: "1.0.3"
dart_style:
dependency: transitive
description:
name: dio
name: dart_style
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.10"
version: "2.0.1"
datetime_picker_formfield:
dependency: "direct main"
description:
name: datetime_picker_formfield
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.0"
fake_async:
dependency: transitive
description:
@ -127,13 +155,6 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "1.2.0"
ffi:
dependency: transitive
description:
name: ffi
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.0"
file:
dependency: transitive
description:
@ -146,86 +167,107 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
flutter_keyboard_visibility:
dependency: transitive
description:
name: flutter_keyboard_visibility
url: "https://pub.dartlang.org"
source: hosted
version: "5.0.2"
flutter_keyboard_visibility_platform_interface:
dependency: transitive
description:
name: flutter_keyboard_visibility_platform_interface
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.0"
flutter_keyboard_visibility_web:
dependency: transitive
description:
name: flutter_keyboard_visibility_web
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.0"
flutter_launcher_icons:
dependency: "direct dev"
description:
name: flutter_launcher_icons
url: "https://pub.dartlang.org"
source: hosted
version: "0.8.1"
flutter_local_notifications:
dependency: transitive
description:
name: flutter_local_notifications
url: "https://pub.dartlang.org"
source: hosted
version: "4.0.1+2"
flutter_local_notifications_platform_interface:
dependency: transitive
description:
name: flutter_local_notifications_platform_interface
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.0+1"
version: "0.9.0"
flutter_secure_storage:
dependency: "direct main"
description:
name: flutter_secure_storage
url: "https://pub.dartlang.org"
source: hosted
version: "4.1.0"
version: "4.2.0"
flutter_test:
dependency: "direct dev"
description: flutter
source: sdk
version: "0.0.0"
flutter_typeahead:
dependency: "direct main"
description:
name: flutter_typeahead
url: "https://pub.dartlang.org"
source: hosted
version: "3.1.3"
flutter_web_plugins:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
flutter_widget_from_html_core:
glob:
dependency: transitive
description:
name: flutter_widget_from_html_core
name: glob
url: "https://pub.dartlang.org"
source: hosted
version: "0.5.2+1"
html:
dependency: transitive
description:
name: html
url: "https://pub.dartlang.org"
source: hosted
version: "0.14.0+4"
version: "2.0.1"
http:
dependency: "direct main"
description:
name: http
url: "https://pub.dartlang.org"
source: hosted
version: "0.12.2"
version: "0.13.3"
http_multi_server:
dependency: transitive
description:
name: http_multi_server
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.1"
http_parser:
dependency: transitive
description:
name: http_parser
url: "https://pub.dartlang.org"
source: hosted
version: "3.1.4"
version: "4.0.0"
image:
dependency: transitive
description:
name: image
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.19"
import_js_library:
version: "3.0.2"
intl:
dependency: transitive
description:
name: import_js_library
name: intl
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.2"
version: "0.17.0"
io:
dependency: transitive
description:
name: io
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.0"
js:
dependency: transitive
description:
@ -233,13 +275,27 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "0.6.3"
json_annotation:
dependency: transitive
description:
name: json_annotation
url: "https://pub.dartlang.org"
source: hosted
version: "4.0.1"
json_serializable:
dependency: "direct main"
description:
name: json_serializable
url: "https://pub.dartlang.org"
source: hosted
version: "4.1.3"
logging:
dependency: transitive
description:
name: logging
url: "https://pub.dartlang.org"
source: hosted
version: "0.11.4"
version: "1.0.1"
matcher:
dependency: transitive
description:
@ -260,21 +316,28 @@ packages:
name: mime
url: "https://pub.dartlang.org"
source: hosted
version: "0.9.7"
open_file:
version: "1.0.0"
nested:
dependency: transitive
description:
name: open_file
name: nested
url: "https://pub.dartlang.org"
source: hosted
version: "3.1.0"
package_info:
version: "1.0.0"
node_preamble:
dependency: transitive
description:
name: package_info
name: node_preamble
url: "https://pub.dartlang.org"
source: hosted
version: "0.4.3+4"
version: "2.0.0"
package_config:
dependency: transitive
description:
name: package_config
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.0"
path:
dependency: transitive
description:
@ -282,41 +345,6 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "1.8.0"
path_provider:
dependency: transitive
description:
name: path_provider
url: "https://pub.dartlang.org"
source: hosted
version: "1.6.27"
path_provider_linux:
dependency: transitive
description:
name: path_provider_linux
url: "https://pub.dartlang.org"
source: hosted
version: "0.0.1+2"
path_provider_macos:
dependency: transitive
description:
name: path_provider_macos
url: "https://pub.dartlang.org"
source: hosted
version: "0.0.4+8"
path_provider_platform_interface:
dependency: transitive
description:
name: path_provider_platform_interface
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.4"
path_provider_windows:
dependency: transitive
description:
name: path_provider_windows
url: "https://pub.dartlang.org"
source: hosted
version: "0.0.5"
pedantic:
dependency: transitive
description:
@ -324,81 +352,109 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "1.11.0"
permission_handler:
dependency: transitive
description:
name: permission_handler
url: "https://pub.dartlang.org"
source: hosted
version: "5.1.0+2"
permission_handler_platform_interface:
dependency: transitive
description:
name: permission_handler_platform_interface
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.2"
petitparser:
dependency: transitive
dependency: "direct main"
description:
name: petitparser
url: "https://pub.dartlang.org"
source: hosted
version: "3.1.0"
platform:
dependency: transitive
description:
name: platform
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.0"
version: "4.1.0"
plugin_platform_interface:
dependency: transitive
description:
name: plugin_platform_interface
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.3"
process:
version: "2.0.0"
pool:
dependency: transitive
description:
name: process
name: pool
url: "https://pub.dartlang.org"
source: hosted
version: "4.1.0"
rxdart:
version: "1.5.0"
provider:
dependency: "direct main"
description:
name: provider
url: "https://pub.dartlang.org"
source: hosted
version: "5.0.0"
pub_semver:
dependency: transitive
description:
name: rxdart
name: pub_semver
url: "https://pub.dartlang.org"
source: hosted
version: "0.25.0"
sensors:
version: "2.0.0"
pubspec_parse:
dependency: transitive
description:
name: sensors
name: pubspec_parse
url: "https://pub.dartlang.org"
source: hosted
version: "0.4.2+6"
share:
version: "1.0.0"
shelf:
dependency: transitive
description:
name: share
name: shelf
url: "https://pub.dartlang.org"
source: hosted
version: "0.6.5+4"
version: "1.1.4"
shelf_packages_handler:
dependency: transitive
description:
name: shelf_packages_handler
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.0"
shelf_static:
dependency: transitive
description:
name: shelf_static
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.0"
shelf_web_socket:
dependency: transitive
description:
name: shelf_web_socket
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.1"
sky_engine:
dependency: transitive
description: flutter
source: sdk
version: "0.0.99"
source_gen:
dependency: transitive
description:
name: source_gen
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.1"
source_map_stack_trace:
dependency: transitive
description:
name: source_map_stack_trace
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.0"
source_maps:
dependency: transitive
description:
name: source_maps
url: "https://pub.dartlang.org"
source: hosted
version: "0.10.10"
source_span:
dependency: transitive
description:
name: source_span
url: "https://pub.dartlang.org"
source: hosted
version: "1.8.0"
version: "1.8.1"
stack_trace:
dependency: transitive
description:
@ -427,20 +483,27 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "1.2.0"
test:
dependency: "direct dev"
description:
name: test
url: "https://pub.dartlang.org"
source: hosted
version: "1.16.8"
test_api:
dependency: transitive
description:
name: test_api
url: "https://pub.dartlang.org"
source: hosted
version: "0.2.19"
timezone:
version: "0.3.0"
test_core:
dependency: transitive
description:
name: timezone
name: test_core
url: "https://pub.dartlang.org"
source: hosted
version: "0.6.1"
version: "0.3.19"
typed_data:
dependency: transitive
description:
@ -455,62 +518,48 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.0"
visibility_detector:
vm_service:
dependency: transitive
description:
name: visibility_detector
name: vm_service
url: "https://pub.dartlang.org"
source: hosted
version: "0.1.5"
wakelock:
version: "6.2.0"
watcher:
dependency: transitive
description:
name: wakelock
name: watcher
url: "https://pub.dartlang.org"
source: hosted
version: "0.2.1+1"
wakelock_platform_interface:
version: "1.0.0"
web_socket_channel:
dependency: transitive
description:
name: wakelock_platform_interface
name: web_socket_channel
url: "https://pub.dartlang.org"
source: hosted
version: "0.1.0+1"
wakelock_web:
version: "2.1.0"
webkit_inspection_protocol:
dependency: transitive
description:
name: wakelock_web
name: webkit_inspection_protocol
url: "https://pub.dartlang.org"
source: hosted
version: "0.1.0+3"
win32:
dependency: transitive
description:
name: win32
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.0"
xdg_directories:
dependency: transitive
description:
name: xdg_directories
url: "https://pub.dartlang.org"
source: hosted
version: "0.1.2"
version: "1.0.0"
xml:
dependency: transitive
description:
name: xml
url: "https://pub.dartlang.org"
source: hosted
version: "4.5.1"
version: "5.1.1"
yaml:
dependency: transitive
description:
name: yaml
url: "https://pub.dartlang.org"
source: hosted
version: "2.2.1"
version: "3.1.0"
sdks:
dart: ">=2.12.0 <3.0.0"
flutter: ">=1.26.0-17.6.pre"
flutter: ">=2.0.0"

View File

@ -9,16 +9,24 @@ environment:
dependencies:
flutter:
sdk: flutter
cupertino_icons: 1.0.2
flutter_secure_storage: 4.1.0
http: 0.12.2
after_layout: 1.0.7+2
alice: ^0.1.12
cupertino_icons: 1.0.3
flutter_secure_storage: 4.2.0
http: 0.13.3
after_layout: 1.1.0
#alice: 0.2.1
datetime_picker_formfield: 2.0.0
flutter_typeahead: 3.1.3
build: 2.0.2
json_serializable: 4.1.3
petitparser: 4.1.0
provider: 5.0.0
dev_dependencies:
flutter_test:
sdk: flutter
flutter_launcher_icons: 0.8.1
version: any
test: 1.16.8
flutter_launcher_icons: 0.9.0
flutter_icons:
image_path: "assets/vikunja_logo.png"

View File

@ -0,0 +1,22 @@
import 'dart:convert';
import 'dart:ui';
import 'package:test/test.dart';
import 'package:vikunja_app/models/label.dart';
void main() {
test('label color from json', () {
final String json = '{"TaskID": 123,"id": 1,"title": "this","description": "","hex_color": "e8e8e8","created_by":{"id": 1,"username": "user","email": "test@example.com","created": 1537855131,"updated": 1545233325},"created": 1552903790,"updated": 1552903790}';
final JsonDecoder _decoder = new JsonDecoder();
Label label = Label.fromJson(_decoder.convert(json));
expect(label.color, Color(0xFFe8e8e8));
});
test('hex color string from object', () {
Label label = Label(id: 1, color: Color(0xFFe8e8e8));
var json = label.toJSON();
expect(json.toString(), '{id: 1, title: null, description: null, hex_color: e8e8e8, created_by: null, updated: null, created: null}');
});
}

View File

@ -0,0 +1,76 @@
import 'package:test/test.dart';
import 'package:vikunja_app/utils/repeat_after_parse.dart';
void main() {
test('Repeat after hours', () {
Duration testDuration = Duration(hours: 6);
expect(getRepeatAfterTypeFromDuration(testDuration), 'Hours');
expect(getRepeatAfterValueFromDuration(testDuration), 6);
});
test('Repeat after days', () {
Duration testDuration = Duration(days: 6);
expect(getRepeatAfterTypeFromDuration(testDuration), 'Days');
expect(getRepeatAfterValueFromDuration(testDuration), 6);
});
test('Repeat after weeks', () {
Duration testDuration = Duration(days: 6 * 7);
expect(getRepeatAfterTypeFromDuration(testDuration), 'Weeks');
expect(getRepeatAfterValueFromDuration(testDuration), 6);
});
test('Repeat after months', () {
Duration testDuration = Duration(days: 6 * 30);
expect(getRepeatAfterTypeFromDuration(testDuration), 'Months');
expect(getRepeatAfterValueFromDuration(testDuration), 6);
});
test('Repeat after years', () {
Duration testDuration = Duration(days: 6 * 365);
expect(getRepeatAfterTypeFromDuration(testDuration), 'Years');
expect(getRepeatAfterValueFromDuration(testDuration), 6);
});
test('Repeat null value', () {
Duration testDuration = Duration();
expect(getRepeatAfterTypeFromDuration(testDuration), null);
expect(getRepeatAfterValueFromDuration(testDuration), null);
});
test('Hours to duration', () {
Duration parsedDuration = getDurationFromType('6', 'Hours');
expect(parsedDuration, Duration(hours: 6));
});
test('Days to duration', () {
Duration parsedDuration = getDurationFromType('6', 'Days');
expect(parsedDuration, Duration(days: 6));
});
test('Weeks to duration', () {
Duration parsedDuration = getDurationFromType('6', 'Weeks');
expect(parsedDuration, Duration(days: 6 * 7));
});
test('Months to duration', () {
Duration parsedDuration = getDurationFromType('6', 'Months');
expect(parsedDuration, Duration(days: 6 * 30));
});
test('Years to duration', () {
Duration parsedDuration = getDurationFromType('6', 'Years');
expect(parsedDuration, Duration(days: 6 * 365));
});
test('null to duration', () {
Duration parsedDuration = getDurationFromType(null, null);
expect(parsedDuration, Duration());
});
}

View File

@ -0,0 +1,52 @@
import 'dart:convert';
import 'package:vikunja_app/models/task.dart';
import 'package:test/test.dart';
void main() {
test('Check encoding with all values set', () {
final String json = '{"id": 1,"text": "test","description": "Lorem Ipsum","done": true,"dueDate": 1543834800,"reminderDates": [1543834800,1544612400],"repeatAfter": 3600,"parentTaskID": 0,"priority": 100,"startDate": 1543834800,"endDate": 1543835000,"assignees": null,"labels": null,"subtasks": null,"created": 1542465818,"updated": 1552771527,"createdBy": {"id": 1,"username": "user","email": "test@example.com","created": 1537855131,"updated": 1545233325}}';
final JsonDecoder _decoder = new JsonDecoder();
final task = Task.fromJson(_decoder.convert(json));
expect(task.id, 1);
expect(task.title, 'test');
expect(task.description, 'Lorem Ipsum');
expect(task.done, true);
expect(task.reminderDates, [
DateTime.fromMillisecondsSinceEpoch(1543834800 * 1000),
DateTime.fromMillisecondsSinceEpoch(1544612400 * 1000),
]);
expect(task.dueDate, DateTime.fromMillisecondsSinceEpoch(1543834800 * 1000));
expect(task.repeatAfter, Duration(seconds: 3600));
expect(task.parentTaskId, 0);
expect(task.priority, 100);
expect(task.startDate, DateTime.fromMillisecondsSinceEpoch(1543834800 * 1000));
expect(task.endDate, DateTime.fromMillisecondsSinceEpoch(1543835000 * 1000));
expect(task.labels, null);
expect(task.subtasks, null);
expect(task.created, DateTime.fromMillisecondsSinceEpoch(1542465818 * 1000));
expect(task.updated, DateTime.fromMillisecondsSinceEpoch(1552771527 * 1000));
});
test('Check encoding with reminder dates as null', () {
final String json = '{"id": 1,"text": "test","description": "Lorem Ipsum","done": true,"dueDate": 1543834800,"reminderDates": null,"repeatAfter": 3600,"parentTaskID": 0,"priority": 100,"startDate": 1543834800,"endDate": 1543835000,"assignees": null,"labels": null,"subtasks": null,"created": 1542465818,"updated": 1552771527,"createdBy": {"id": 1,"username": "user","email": "test@example.com","created": 1537855131,"updated": 1545233325}}';
final JsonDecoder _decoder = new JsonDecoder();
final task = Task.fromJson(_decoder.convert(json));
expect(task.id, 1);
expect(task.title, 'test');
expect(task.description, 'Lorem Ipsum');
expect(task.done, true);
expect(task.reminderDates, null);
expect(task.dueDate, DateTime.fromMillisecondsSinceEpoch(1543834800 * 1000));
expect(task.repeatAfter, Duration(seconds: 3600));
expect(task.parentTaskId, 0);
expect(task.priority, 100);
expect(task.startDate, DateTime.fromMillisecondsSinceEpoch(1543834800 * 1000));
expect(task.endDate, DateTime.fromMillisecondsSinceEpoch(1543835000 * 1000));
expect(task.labels, null);
expect(task.subtasks, null);
expect(task.created, DateTime.fromMillisecondsSinceEpoch(1542465818 * 1000));
expect(task.updated, DateTime.fromMillisecondsSinceEpoch(1552771527 * 1000));
});
}