diff --git a/lib/api/bucket_implementation.dart b/lib/api/bucket_implementation.dart new file mode 100644 index 0000000..d622fb4 --- /dev/null +++ b/lib/api/bucket_implementation.dart @@ -0,0 +1,53 @@ +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/bucket.dart'; +import 'package:vikunja_app/service/services.dart'; + +class BucketAPIService extends APIService implements BucketService { + BucketAPIService(Client client) : super(client); + + @override + Future add(int listId, Bucket bucket) { + return client + .put('/lists/$listId/buckets', body: bucket.toJSON()) + .then((response) => Bucket.fromJSON(response.body)); + } + + @override + Future delete(int listId, int bucketId) { + return client + .delete('/lists/$listId/buckets/$bucketId'); + } + + @override + Future get(int listId, int bucketId) { + // Might not exist in the API, it isn't in the docs + return client + .get('/lists/$listId/buckets/$bucketId') + .then((response) => Bucket.fromJSON(response.body)); + } + + @override + Future getAllByList(int listId, + [Map> queryParameters]) { + return client + .get('/lists/$listId/buckets', queryParameters) + .then((response) => new Response( + convertList(response.body, (result) => Bucket.fromJSON(result)), + response.statusCode, + response.headers + )); + } + + @override + // TODO: implement maxPages + int get maxPages => maxPages; + + @override + Future update(Bucket bucket) { + return client + .post('/lists/${bucket.listId}/buckets/${bucket.id}', body: bucket.toJSON()) + .then((response) => Bucket.fromJSON(response.body)); + } +} \ No newline at end of file diff --git a/lib/components/BucketListView.dart b/lib/components/BucketListView.dart new file mode 100644 index 0000000..f9c9363 --- /dev/null +++ b/lib/components/BucketListView.dart @@ -0,0 +1,54 @@ +import 'package:flutter/material.dart'; +import 'package:vikunja_app/components/BucketTaskCard.dart'; +import 'package:vikunja_app/models/bucket.dart'; +import 'package:vikunja_app/models/task.dart'; + +class BucketListView extends StatefulWidget { + final Bucket bucket; + final Function onEdit; + final Function onAddTask; + + const BucketListView({Key key, @required this.bucket, this.onEdit, this.onAddTask}) + : assert(bucket != null), + super(key: key); + + @override + State createState() => _BucketListViewState(this.bucket); +} + +class _BucketListViewState extends State { + Bucket _currentBucket; + + _BucketListViewState(this._currentBucket) + : assert(_currentBucket != null); + + @override + Widget build(BuildContext context) { + return Container( + width: MediaQuery.of(context).size.width * 0.8, + child: ListView.builder( + padding: EdgeInsets.symmetric(horizontal: 10), + itemBuilder: (context, i) { + if (i == 0) { + return Text(_currentBucket.title); + } + + final index = i - 1; + + if (_currentBucket.tasks == null || index >= _currentBucket.tasks.length) + return null; + + return index < _currentBucket.tasks.length + ? _buildBucketTaskTile(_currentBucket.tasks[index]) + : null; + }, + ), + ); + } + + BucketTaskCard _buildBucketTaskTile(Task task) { + return BucketTaskCard( + task: task, + ); + } +} diff --git a/lib/components/BucketTaskCard.dart b/lib/components/BucketTaskCard.dart new file mode 100644 index 0000000..df5ecd7 --- /dev/null +++ b/lib/components/BucketTaskCard.dart @@ -0,0 +1,60 @@ +import 'package:flutter/material.dart'; +import 'package:vikunja_app/models/task.dart'; +import 'package:vikunja_app/theme/constants.dart'; + +class BucketTaskCard extends StatefulWidget { + final Task task; + + const BucketTaskCard({Key key, @required this.task}) + : assert(task != null), + super(key: key); + + @override + State createState() => _BucketTaskCardState(this.task); +} + +class _BucketTaskCardState extends State { + Task _currentTask; + + _BucketTaskCardState(this._currentTask) + : assert(_currentTask != null); + + @override + Widget build(BuildContext context) { + final numRow = Row( + children: [ + Text('#${_currentTask.id}'), + ], + ); + if (_currentTask.done) { + numRow.children.insert(0, Chip( + label: Text('Done'), + labelStyle: TextStyle( + fontWeight: FontWeight.bold, + color: Theme + .of(context) + .brightness == Brightness.dark + ? Colors.black : Colors.white, + ), + backgroundColor: vGreen, + )); + } + + final titleRow = Row( + children: [ + Text(_currentTask.title), + ], + ); + // TODO: add due date + + final labelRow = Row(); + // TODO: add labels, checklist completion, attachment icon, description icon + + return Card( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [numRow, titleRow, labelRow], + ), + ); + } +} diff --git a/lib/global.dart b/lib/global.dart index 4b70422..909296c 100644 --- a/lib/global.dart +++ b/lib/global.dart @@ -4,6 +4,7 @@ import 'dart:developer' as dev; import 'package:flutter/material.dart'; import 'package:flutter_native_timezone/flutter_native_timezone.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:vikunja_app/api/bucket_implementation.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'; @@ -65,6 +66,8 @@ class VikunjaGlobalState extends State { TaskService get taskService => new TaskAPIService(client); + BucketService get bucketService => new BucketAPIService(client); + ListService get listService => new ListAPIService(client, _storage); notifs.FlutterLocalNotificationsPlugin get notificationsPlugin => new notifs.FlutterLocalNotificationsPlugin(); diff --git a/lib/models/bucket.dart b/lib/models/bucket.dart new file mode 100644 index 0000000..4d7590d --- /dev/null +++ b/lib/models/bucket.dart @@ -0,0 +1,61 @@ +import 'package:meta/meta.dart'; +import 'package:json_annotation/json_annotation.dart'; + +import 'package:vikunja_app/models/task.dart'; +import 'package:vikunja_app/models/user.dart'; + +@JsonSerializable() +class Bucket { + int id, listId, limit; + double position; + String title; + DateTime created, updated; + User createdBy; + bool isDoneBucket; + List tasks; + + Bucket({ + @required this.id, + @required this.listId, + this.title, + this.position, + this.limit, + this.isDoneBucket, + this.created, + this.updated, + this.createdBy, + this.tasks, + }); + + Bucket.fromJSON(Map json) + : id = json['id'], + listId = json['list_id'], + title = json['title'], + position = json['position'] is int + ? json['position'].toDouble() + : json['position'], + limit = json['limit'], + isDoneBucket = json['is_done_bucket'], + created = DateTime.parse(json['created']), + updated = DateTime.parse(json['updated']), + createdBy = json['created_by'] == null + ? null + : User.fromJson(json['created_by']), + tasks = (json['tasks'] as List) + ?.map((task) => Task.fromJson(task)) + ?.cast() + ?.toList(); + + toJSON() => { + 'id': id, + 'list_id': listId, + 'title': title, + 'position': position, + 'limit': limit, + 'is_done_bucket': isDoneBucket ?? false, + 'created': created?.toUtc()?.toIso8601String(), + 'updated': updated?.toUtc()?.toIso8601String(), + 'createdBy': createdBy?.toJSON(), + 'tasks': tasks?.map((task) => task.toJSON())?.toList(), + }; +} \ No newline at end of file diff --git a/lib/pages/list/list.dart b/lib/pages/list/list.dart index 8fe26f3..10e6210 100644 --- a/lib/pages/list/list.dart +++ b/lib/pages/list/list.dart @@ -5,9 +5,11 @@ 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/components/BucketListView.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/models/bucket.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'; @@ -66,35 +68,19 @@ class _ListPageState extends State { // TODO: it brakes the flow with _loadingTasks and conflicts with the provider body: !taskState.isLoading ? RefreshIndicator( - child: taskState.tasks.length > 0 + child: taskState.tasks.length > 0 || taskState.buckets.length > 0 ? ListenableProvider.value( value: taskState, - child: ListView.builder( - padding: EdgeInsets.symmetric(vertical: 8.0), - itemBuilder: (context, i) { - if (i.isOdd) return Divider(); - - if (_loadingTasks.isNotEmpty) { - final loadingTask = _loadingTasks.removeLast(); - return _buildLoadingTile(loadingTask); - } - - 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) - return null; - - if (index >= taskState.tasks.length && - _currentPage < taskState.maxPages) { - _currentPage++; - _loadTasksForPage(_currentPage); - } - return index < taskState.tasks.length - ? _buildTile(taskState.tasks[index]) - : null; - }), + child: () { + switch (_viewIndex) { + case 0: + return _listView(context); + case 1: + return _kanbanView(context); + default: + return _listView(context); + } + }(), ) : Center(child: Text('This list is empty.')), onRefresh: _loadList, @@ -124,11 +110,63 @@ class _ListPageState extends State { } void _onViewTapped(int index) { - setState(() { - _viewIndex = index; + _loadList().then((_) { + _currentPage = 1; + setState(() { + _viewIndex = index; + }); }); } + ListView _listView(BuildContext context) { + return ListView.builder( + padding: EdgeInsets.symmetric(vertical: 8.0), + itemBuilder: (context, i) { + if (i.isOdd) return Divider(); + + if (_loadingTasks.isNotEmpty) { + final loadingTask = _loadingTasks.removeLast(); + return _buildLoadingTile(loadingTask); + } + + 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) + return null; + + if (index >= taskState.tasks.length && + _currentPage < taskState.maxPages) { + _currentPage++; + _loadTasksForPage(_currentPage); + } + return index < taskState.tasks.length + ? _buildTile(taskState.tasks[index]) + : null; + } + ); + } + + ListView _kanbanView(BuildContext context) { + return ListView.builder( + scrollDirection: Axis.horizontal, + padding: EdgeInsets.symmetric(vertical: 10), + itemBuilder: (context, i) { + if (taskState.maxPages == _currentPage && i == taskState.buckets.length) + return null; + + if (i >= taskState.buckets.length && _currentPage < taskState.maxPages) { + _currentPage++; + _loadBucketsForPage(_currentPage); + } + return i < taskState.buckets.length + ? _buildBucketTile(taskState.buckets[i]) + : null; + }, + ); + } + TaskTile _buildTile(Task task) { return TaskTile( task: task, @@ -154,6 +192,12 @@ class _ListPageState extends State { ); } + BucketListView _buildBucketTile(Bucket bucket) { + return BucketListView( + bucket: bucket, + ); + } + Future updateDisplayDoneTasks() { return VikunjaGlobal.of(context).listService.getDisplayDoneTasks(_list.id) .then((value) {displayDoneTasks = value == "1";}); @@ -175,7 +219,18 @@ class _ListPageState extends State { } Future _loadList() async { - updateDisplayDoneTasks().then((value) => _loadTasksForPage(1)); + updateDisplayDoneTasks().then((value) { + switch (_viewIndex) { + case 0: + _loadTasksForPage(1); + break; + case 1: + _loadBucketsForPage(1); + break; + default: + _loadTasksForPage(1); + } + }); } void _loadTasksForPage(int page) { @@ -187,6 +242,14 @@ class _ListPageState extends State { ); } + void _loadBucketsForPage(int page) { + Provider.of(context, listen: false).loadBuckets( + context: context, + listId: _list.id, + page: page + ); + } + _addItemDialog(BuildContext context) { showDialog( context: context, diff --git a/lib/service/services.dart b/lib/service/services.dart index 0b48372..b3ed739 100644 --- a/lib/service/services.dart +++ b/lib/service/services.dart @@ -8,6 +8,7 @@ import 'package:vikunja_app/models/list.dart'; import 'package:vikunja_app/models/namespace.dart'; import 'package:vikunja_app/models/task.dart'; import 'package:vikunja_app/models/user.dart'; +import 'package:vikunja_app/models/bucket.dart'; import '../models/server.dart'; @@ -112,6 +113,17 @@ abstract class TaskService { int get maxPages; } +abstract class BucketService { + Future get(int listId, int bucketId); + Future update(Bucket bucket); + Future delete(int listId, int bucketId); + Future add(int listId, Bucket bucket); + Future getAllByList(int listId, + [Map> queryParameters]); + + int get maxPages; +} + abstract class UserService { Future login(String username, password, {bool rememberMe = false, String totp}); Future register(String username, email, password); diff --git a/lib/stores/list_store.dart b/lib/stores/list_store.dart index dc84633..60230bd 100644 --- a/lib/stores/list_store.dart +++ b/lib/stores/list_store.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:vikunja_app/models/task.dart'; +import 'package:vikunja_app/models/bucket.dart'; import 'package:vikunja_app/global.dart'; class ListProvider with ChangeNotifier { @@ -8,6 +9,7 @@ class ListProvider with ChangeNotifier { // TODO: Streams List _tasks = []; + List _buckets = []; bool get isLoading => _isLoading; @@ -20,6 +22,13 @@ class ListProvider with ChangeNotifier { List get tasks => _tasks; + set buckets(List buckets) { + _buckets = buckets; + notifyListeners(); + } + + List get buckets => _buckets; + void loadTasks({BuildContext context, int listId, int page = 1, bool displayDoneTasks = true}) { _tasks = []; _isLoading = true; @@ -48,6 +57,26 @@ class ListProvider with ChangeNotifier { }); } + void loadBuckets({BuildContext context, int listId, int page = 1}) { + _buckets = []; + _isLoading = true; + notifyListeners(); + + Map> queryParams = { + "page": [page.toString()] + }; + + VikunjaGlobal.of(context).bucketService.getAllByList(listId, queryParams).then((response) { + if (response.headers["x-pagination-total-pages"] != null) { + _maxPages = int.parse(response.headers["x-pagination-total-pages"]); + } + _buckets.addAll(response.body); + + _isLoading = false; + notifyListeners(); + }); + } + Future addTaskByTitle( {BuildContext context, String title, int listId}) { var globalState = VikunjaGlobal.of(context); diff --git a/lib/theme/theme.dart b/lib/theme/theme.dart index 30dcef1..83c33fd 100644 --- a/lib/theme/theme.dart +++ b/lib/theme/theme.dart @@ -38,9 +38,8 @@ ThemeData _buildVikunjaTheme(ThemeData base) { // Make bottomNavigationBar backgroundColor darker to provide more separation backgroundColor: () { final _hslColor = HSLColor.fromColor( - base.bottomNavigationBarTheme.backgroundColor != null - ? base.bottomNavigationBarTheme.backgroundColor - : base.scaffoldBackgroundColor + base.bottomNavigationBarTheme.backgroundColor + ?? base.scaffoldBackgroundColor ); return _hslColor.withLightness(max(_hslColor.lightness - 0.03, 0)).toColor(); }(),