diff --git a/lib/api/client.dart b/lib/api/client.dart index fca43e5..29d616c 100644 --- a/lib/api/client.dart +++ b/lib/api/client.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:convert'; import 'package:http/http.dart' as http; +import 'package:vikunja_app/api/response.dart'; class Client { final JsonDecoder _decoder = new JsonDecoder(); @@ -25,33 +26,45 @@ class Client { 'Content-Type': 'application/json' }; - Future get(String url) { - return http - .get('${this.base}$url', headers: _headers) - .then(_handleResponse); + Future get(String url, + [Map> 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 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 delete(String url) { + Future delete(String url) { return http .delete('${this.base}$url', headers: _headers) .then(_handleResponse); } - Future post(String url, {dynamic body}) { + Future post(String url, {dynamic body}) { return http .post('${this.base}$url', headers: _headers, body: _encoder.convert(body)) .then(_handleResponse); } - Future put(String url, {dynamic body}) { + Future put(String url, {dynamic body}) { return http .put('${this.base}$url', 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 +78,14 @@ class Client { throw new ApiException( response.statusCode, response.request.url.toString()); } - return _decoder.convert(response.body); + return new 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 +98,7 @@ class InvalidRequestApiException extends ApiException { class ApiException implements Exception { final int errorCode; final String path; + ApiException(this.errorCode, this.path); @override diff --git a/lib/api/list_implementation.dart b/lib/api/list_implementation.dart index 38228d7..78bc9bd 100644 --- a/lib/api/list_implementation.dart +++ b/lib/api/list_implementation.dart @@ -12,7 +12,7 @@ class ListAPIService extends APIService implements ListService { Future 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,25 +22,27 @@ class ListAPIService extends APIService implements ListService { @override Future get(int listId) { - return client.get('/lists/$listId').then((map) => TaskList.fromJson(map)); + return client + .get('/lists/$listId') + .then((response) => TaskList.fromJson(response.body)); } @override Future> 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> 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 update(TaskList tl) { return client .post('/lists/${tl.id}', body: tl.toJSON()) - .then((map) => TaskList.fromJson(map)); + .then((response) => TaskList.fromJson(response.body)); } } diff --git a/lib/api/namespace_implementation.dart b/lib/api/namespace_implementation.dart index b5d5508..dda454d 100644 --- a/lib/api/namespace_implementation.dart +++ b/lib/api/namespace_implementation.dart @@ -12,7 +12,7 @@ class NamespaceAPIService extends APIService implements NamespaceService { Future 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 get(int namespaceId) { return client .get('/namespaces/$namespaceId') - .then((map) => Namespace.fromJson(map)); + .then((response) => Namespace.fromJson(response.body)); } @override Future> 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 update(Namespace ns) { return client .post('/namespaces/${ns.id}', body: ns.toJSON()) - .then((map) => Namespace.fromJson(map)); + .then((response) => Namespace.fromJson(response.body)); } } diff --git a/lib/api/response.dart b/lib/api/response.dart new file mode 100644 index 0000000..cfbf4b2 --- /dev/null +++ b/lib/api/response.dart @@ -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 headers; +} diff --git a/lib/api/task_implementation.dart b/lib/api/task_implementation.dart index 1d85b1d..61f5d92 100644 --- a/lib/api/task_implementation.dart +++ b/lib/api/task_implementation.dart @@ -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 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,16 @@ class TaskAPIService extends APIService implements TaskService { Future 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 getAll(int listId, + [Map> queryParameters]) { + return client.get('/lists/$listId/tasks', queryParameters).then( + (response) => new Response( + convertList(response.body, (result) => Task.fromJson(result)), + response.statusCode, + response.headers)); } } diff --git a/lib/api/user_implementation.dart b/lib/api/user_implementation.dart index a07bb2e..5064fb8 100644 --- a/lib/api/user_implementation.dart +++ b/lib/api/user_implementation.dart @@ -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 getCurrentUser() { - return client.get('/user').then((map) => User.fromJson(map)); + return client.get('/user').then((response) => User.fromJson(response.body)); } } diff --git a/lib/models/list.dart b/lib/models/list.dart index 4578c4d..cfe6625 100644 --- a/lib/models/list.dart +++ b/lib/models/list.dart @@ -1,5 +1,4 @@ import 'package:meta/meta.dart'; -import 'package:vikunja_app/models/task.dart'; import 'package:vikunja_app/models/user.dart'; class TaskList { @@ -7,7 +6,6 @@ class TaskList { final String title, description; final User owner; final DateTime created, updated; - final List tasks; TaskList( {@required this.id, @@ -15,8 +13,7 @@ class TaskList { this.description, this.owner, this.created, - this.updated, - this.tasks}); + this.updated}); TaskList.fromJson(Map json) : id = json['id'], @@ -24,10 +21,7 @@ class TaskList { description = json['description'], title = json['title'], updated = DateTime.parse(json['updated']), - created = DateTime.parse(json['created']), - tasks = (json['tasks'] == null ? [] : json['tasks'] as List) - ?.map((taskJson) => Task.fromJson(taskJson)) - ?.toList(); + created = DateTime.parse(json['created']); toJSON() { return { diff --git a/lib/models/task.dart b/lib/models/task.dart index f9a398e..0434b96 100644 --- a/lib/models/task.dart +++ b/lib/models/task.dart @@ -27,12 +27,16 @@ class Task { reminders = (json['reminder_dates'] as List) ?.map((r) => DateTime.parse(r)) ?.toList(), - due = - json['due_date'] != null ? DateTime.parse(json['due_date']) : null, + due = json['due_date'].toString() == 'null' + ? null + : DateTime.parse(json['due_date']), description = json['description'], title = json['title'], done = json['done'], - owner = User.fromJson(json['created_by']); + owner = json['created_by'].toString() == "null" + ? null + : User.fromJson(json[ + 'created_by']); // There has to be a better way of doing this... toJSON() => { 'id': id, diff --git a/lib/pages/list/list.dart b/lib/pages/list/list.dart index 6a943c3..8759692 100644 --- a/lib/pages/list/list.dart +++ b/lib/pages/list/list.dart @@ -1,12 +1,12 @@ 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/stores/list_store.dart'; class ListPage extends StatefulWidget { final TaskList taskList; @@ -19,24 +19,18 @@ class ListPage extends StatefulWidget { class _ListPageState extends State { TaskList _list; - List _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); + Future.microtask(() => _loadList()); super.initState(); } - @override - void didChangeDependencies() { - super.didChangeDependencies(); - _loadList(); - } - @override Widget build(BuildContext context) { + final taskState = Provider.of(context); return Scaffold( appBar: AppBar( title: new Text(_list.title), @@ -51,15 +45,31 @@ class _ListPageState extends State { )))) ], ), - 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(), - ) + 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, ) @@ -70,56 +80,31 @@ class _ListPageState extends State { )); } - List _listTasks() { - var tasks = (_list?.tasks?.map(_buildTile) ?? []).toList(); - tasks.addAll(_loadingTasks.map(_buildLoadingTile)); - return tasks; + Future _loadList() async { + _loadTasksForPage(1); } - TaskTile _buildTile(Task task) { - return TaskTile(task: task, loading: false); - } - - TaskTile _buildLoadingTile(Task task) { - return TaskTile( - task: task, - loading: true, + void _loadTasksForPage(int page) { + Provider.of(context, listen: false).loadTasks( + context: context, + listId: _list.id, + page: page, ); } - Future _loadList() { - return VikunjaGlobal.of(context) - .listService - .get(widget.taskList.id) - .then((list) { - setState(() { - _loading = false; - _list = list; - }); - }); - } - _addItemDialog(BuildContext context) { showDialog( context: context, builder: (_) => AddDialog( - onAdd: (name) => _addItem(name, context), + onAdd: (title) => _addItem(title, context), decoration: new InputDecoration( labelText: 'Task Name', hintText: 'eg. Milk'))); } - _addItem(String name, BuildContext context) { - var globalState = VikunjaGlobal.of(context); - var newTask = Task( - id: null, title: name, owner: globalState.currentUser, done: false); - setState(() => _loadingTasks.add(newTask)); - globalState.taskService.add(_list.id, newTask).then((task) { - setState(() { - _list.tasks.add(task); - }); - }).then((_) { - _loadList(); - setState(() => _loadingTasks.remove(newTask)); + _addItem(String title, BuildContext context) { + Provider.of(context, listen: false) + .addTask(context: context, title: title, listId: _list.id) + .then((_) { Scaffold.of(context).showSnackBar(SnackBar( content: Text('The task was added successfully!'), )); diff --git a/lib/pages/namespace/namespace.dart b/lib/pages/namespace/namespace.dart index 07f6f87..4b2f18c 100644 --- a/lib/pages/namespace/namespace.dart +++ b/lib/pages/namespace/namespace.dart @@ -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; @@ -89,6 +91,7 @@ class _NamespacePageState extends State } Future _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) @@ -99,8 +102,14 @@ class _NamespacePageState extends State } _openList(BuildContext context, TaskList list) { - Navigator.of(context).push( - MaterialPageRoute(builder: (context) => ListPage(taskList: list))); + Navigator.of(context).push(MaterialPageRoute( + builder: (context) => ChangeNotifierProvider( + create: (_) => new ListProvider(), + child: ListPage( + taskList: list, + ), + ), + )); } _addListDialog(BuildContext context) { @@ -116,7 +125,7 @@ class _NamespacePageState extends State _addList(String name, BuildContext context) { VikunjaGlobal.of(context) .listService - .create(widget.namespace.id, TaskList(id: null, title: name, tasks: [])) + .create(widget.namespace.id, TaskList(id: null, title: name)) .then((_) { setState(() {}); _loadLists(); diff --git a/lib/service/mocked_services.dart b/lib/service/mocked_services.dart index 1980e31..e40afe2 100644 --- a/lib/service/mocked_services.dart +++ b/lib/service/mocked_services.dart @@ -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'; @@ -28,7 +29,6 @@ var _lists = { 1: TaskList( id: 1, title: 'List 1', - tasks: _tasks.values.toList(), owner: _users[1], description: 'A nice list', created: DateTime.now(), @@ -120,20 +120,13 @@ class MockedListService implements ListService { class MockedTaskService implements TaskService { @override Future delete(int taskId) { - _lists.forEach( - (_, list) => list.tasks.removeWhere((task) => task.id == taskId)); _tasks.remove(taskId); return Future.value(); } @override Future update(Task task) { - _lists.forEach((_, list) { - if (list.tasks.where((t) => t.id == task.id).length > 0) { - list.tasks.removeWhere((t) => t.id == task.id); - list.tasks.add(task); - } - }); + _tasks[task.id] = task; return Future.value(_tasks[task.id] = task); } @@ -141,9 +134,17 @@ class MockedTaskService implements TaskService { Future add(int listId, Task task) { var id = _tasks.keys.last + 1; _tasks[id] = task; - _lists[listId].tasks.add(task); return Future.value(task); } + + @override + Future getAll(int listId, + [Map> queryParameters]) { + return Future.value(new Response(_tasks.values.toList(), 200, {})); + } + + @override + int get maxPages => 1; } class MockedUserService implements UserService { diff --git a/lib/service/services.dart b/lib/service/services.dart index 54f8c51..1825629 100644 --- a/lib/service/services.dart +++ b/lib/service/services.dart @@ -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'; @@ -26,6 +27,8 @@ abstract class TaskService { Future update(Task task); Future delete(int taskId); Future add(int listId, Task task); + Future getAll(int listId, + [Map> queryParameters]); } abstract class UserService { diff --git a/lib/stores/list_store.dart b/lib/stores/list_store.dart new file mode 100644 index 0000000..1c7891d --- /dev/null +++ b/lib/stores/list_store.dart @@ -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 _tasks = []; + + bool get isLoading => _isLoading; + + int get maxPages => _maxPages; + + set tasks(List tasks) { + _tasks = tasks; + notifyListeners(); + } + + List 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 addTask({BuildContext context, String title, int listId}) { + var globalState = VikunjaGlobal.of(context); + var newTask = Task( + id: null, title: title, owner: globalState.currentUser, done: false); + _isLoading = true; + notifyListeners(); + + return globalState.taskService.add(listId, newTask).then((task) { + _tasks.insert(0, task); + _isLoading = false; + notifyListeners(); + }); + } +} diff --git a/pubspec.lock b/pubspec.lock index af02983..0b06ee7 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -139,6 +139,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.1.8" + nested: + dependency: transitive + description: + name: nested + url: "https://pub.dartlang.org" + source: hosted + version: "0.0.4" path: dependency: transitive description: @@ -160,6 +167,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.4.0" + provider: + dependency: "direct main" + description: + name: provider + url: "https://pub.dartlang.org" + source: hosted + version: "4.0.5+1" quiver: dependency: transitive description: @@ -258,3 +272,4 @@ packages: version: "2.2.0" sdks: dart: ">=2.6.0 <3.0.0" + flutter: ">=1.12.1" diff --git a/pubspec.yaml b/pubspec.yaml index 9ce7b6f..bcab75f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -14,6 +14,7 @@ dependencies: http: ^0.12.1 after_layout: ^1.0.7 sentry: ^3.0.1 + provider: ^4.0.5 dev_dependencies: flutter_test: