diff --git a/lib/api/bucket_implementation.dart b/lib/api/bucket_implementation.dart index 492b93e..df6a7f8 100644 --- a/lib/api/bucket_implementation.dart +++ b/lib/api/bucket_implementation.dart @@ -8,9 +8,9 @@ class BucketAPIService extends APIService implements BucketService { BucketAPIService(Client client) : super(client); @override - Future add(int listId, Bucket bucket) { + Future add(int projectId, Bucket bucket) { return client - .put('/lists/$listId/buckets', body: bucket.toJSON()) + .put('/projects/$projectId/buckets', body: bucket.toJSON()) .then((response) { if (response == null) return null; return Bucket.fromJSON(response.body); @@ -18,9 +18,9 @@ class BucketAPIService extends APIService implements BucketService { } @override - Future delete(int listId, int bucketId) { + Future delete(int projectId, int bucketId) { return client - .delete('/lists/$listId/buckets/$bucketId'); + .delete('/projects/$projectId/buckets/$bucketId'); } /* Not implemented in the Vikunja API @@ -33,10 +33,10 @@ class BucketAPIService extends APIService implements BucketService { */ @override - Future getAllByList(int listId, + Future getAllByList(int projectId, [Map>? queryParameters]) { return client - .get('/lists/$listId/buckets', queryParameters) + .get('/projects/$projectId/buckets', queryParameters) .then((response) => response != null ? new Response( convertList(response.body, (result) => Bucket.fromJSON(result)), response.statusCode, @@ -51,7 +51,7 @@ class BucketAPIService extends APIService implements BucketService { @override Future update(Bucket bucket) { return client - .post('/lists/${bucket.listId}/buckets/${bucket.id}', body: bucket.toJSON()) + .post('/projects/${bucket.projectId}/buckets/${bucket.id}', body: bucket.toJSON()) .then((response) { if (response == null) return null; return Bucket.fromJSON(response.body); diff --git a/lib/api/client.dart b/lib/api/client.dart index 0d12456..27939fb 100644 --- a/lib/api/client.dart +++ b/lib/api/client.dart @@ -79,7 +79,19 @@ class Client { Future get(String url, [Map>? queryParameters]) { - return http.get('${this.base}$url'.toUri()!, headers: _headers) + Uri uri = Uri.tryParse('${this.base}$url')!; + // why are we doing it like this? because Uri doesnt have setters. wtf. + uri = Uri( + scheme: uri.scheme, + userInfo: uri.userInfo, + host: uri.host, + port: uri.port, + path: uri.path, + queryParameters: {...uri.queryParameters, ...?queryParameters}, + fragment: uri.fragment + ); + + return http.get(uri, headers: _headers) .then(_handleResponse).onError((error, stackTrace) => _handleError(error, stackTrace)); } diff --git a/lib/api/project.dart b/lib/api/project.dart index 04523e2..9f2f6b4 100644 --- a/lib/api/project.dart +++ b/lib/api/project.dart @@ -1,9 +1,12 @@ +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:vikunja_app/api/service.dart'; import 'package:vikunja_app/models/project.dart'; import 'package:vikunja_app/service/services.dart'; class ProjectAPIService extends APIService implements ProjectService { - ProjectAPIService(super.client); + FlutterSecureStorage _storage; + + ProjectAPIService(client, storage) : _storage = storage, super(client); @override Future create(Project p) { @@ -13,14 +16,24 @@ class ProjectAPIService extends APIService implements ProjectService { @override Future delete(int projectId) { - // TODO: implement delete - throw UnimplementedError(); + return client.delete('/projects/$projectId').then((_) {}); } @override Future get(int projectId) { - // TODO: implement get - throw UnimplementedError(); + return client.get('/projects/$projectId').then((response) { + if (response == null) return null; + final map = response.body; + /*if (map.containsKey('id')) { + return client + .get("/lists/$projectId/tasks") + .then((tasks) { + map['tasks'] = tasks?.body; + return Project.fromJson(map); + }); + }*/ + return Project.fromJson(map); + }); } @override @@ -33,9 +46,41 @@ class ProjectAPIService extends APIService implements ProjectService { } @override - Future update(int projectId) { - // TODO: implement update - throw UnimplementedError(); + Future update(Project p) { + return client + .post('/projects/${p.id}', body: p.toJSON()) + .then((response) { + if (response == null) return null; + return Project.fromJson(response.body); + }); + } + + @override + Future getDisplayDoneTasks(int listId) { + return _storage.read(key: "display_done_tasks_list_$listId").then((value) + { + if(value == null) { + // TODO: implement default value + setDisplayDoneTasks(listId, "1"); + return Future.value("1"); + } + return value; + }); + } + + @override + void setDisplayDoneTasks(int listId, String value) { + _storage.write(key: "display_done_tasks_list_$listId", value: value); + } + + @override + Future getDefaultList() { + return _storage.read(key: "default_list_id"); + } + + @override + void setDefaultList(int? listId) { + _storage.write(key: "default_list_id", value: listId.toString()); } } \ No newline at end of file diff --git a/lib/api/task_implementation.dart b/lib/api/task_implementation.dart index 281c9d7..b916a12 100644 --- a/lib/api/task_implementation.dart +++ b/lib/api/task_implementation.dart @@ -11,9 +11,9 @@ class TaskAPIService extends APIService implements TaskService { TaskAPIService(Client client) : super(client); @override - Future add(int listId, Task task) { + Future add(int projectId, Task task) { return client - .put('/lists/$listId', body: task.toJSON()) + .put('/projects/$projectId', body: task.toJSON()) .then((response) { if (response == null) return null; return Task.fromJson(response.body); @@ -23,7 +23,7 @@ class TaskAPIService extends APIService implements TaskService { @override Future get(int listId) { return client - .get('/list/$listId/tasks') + .get('/project/$listId/tasks') .then((response) { if (response == null) return null; return Task.fromJson(response.body); @@ -75,10 +75,10 @@ class TaskAPIService extends APIService implements TaskService { } @override - Future getAllByList(int listId, + Future getAllByProject(int projectId, [Map>? queryParameters]) { return client - .get('/lists/$listId/tasks', queryParameters).then( + .get('/projects/$projectId/tasks', queryParameters).then( (response) { return response != null ? new Response( diff --git a/lib/components/BucketTaskCard.dart b/lib/components/BucketTaskCard.dart index dc89c1f..074bcd4 100644 --- a/lib/components/BucketTaskCard.dart +++ b/lib/components/BucketTaskCard.dart @@ -6,10 +6,11 @@ import 'package:dotted_border/dotted_border.dart'; import 'package:provider/provider.dart'; import 'package:vikunja_app/models/task.dart'; import 'package:vikunja_app/pages/list/task_edit.dart'; -import 'package:vikunja_app/stores/list_store.dart'; import 'package:vikunja_app/utils/misc.dart'; import 'package:vikunja_app/theme/constants.dart'; +import '../stores/project_store.dart'; + enum DropLocation {above, below, none} class TaskData { @@ -47,7 +48,7 @@ class _BucketTaskCardState extends State with AutomaticKeepAlive super.build(context); if (_cardSize == null) _updateCardSize(context); - final taskState = Provider.of(context); + final taskState = Provider.of(context); final bucket = taskState.buckets[taskState.buckets.indexWhere((b) => b.id == widget.task.bucketId)]; // default chip height: 32 const double chipHeight = 28; diff --git a/lib/components/KanbanWidget.dart b/lib/components/KanbanWidget.dart index b1ba2cc..f3d2368 100644 --- a/lib/components/KanbanWidget.dart +++ b/lib/components/KanbanWidget.dart @@ -8,8 +8,9 @@ import 'package:provider/provider.dart'; import '../global.dart'; import '../models/bucket.dart'; import '../models/list.dart'; +import '../models/project.dart'; import '../pages/list/list.dart'; -import '../stores/list_store.dart'; +import '../stores/project_store.dart'; import '../utils/calculate_item_position.dart'; import 'AddDialog.dart'; import 'BucketLimitDialog.dart'; @@ -19,19 +20,19 @@ import 'SliverBucketPersistentHeader.dart'; class KanbanClass { PageController? _pageController; - ListProvider? taskState; + ProjectProvider? taskState; int? _draggedBucketIndex; BuildContext context; Function _onViewTapped, _addItemDialog, notify; Duration _lastTaskDragUpdateAction = Duration.zero; - TaskList _list; + Project _list; Map _bucketProps = {}; KanbanClass(this.context, this.notify, this._onViewTapped, this._addItemDialog, this._list) { - taskState = Provider.of(context); + taskState = Provider.of(context); } @@ -177,12 +178,12 @@ class KanbanClass { return; } - await Provider.of(context, listen: false).addBucket( + await Provider.of(context, listen: false).addBucket( context: context, newBucket: Bucket( title: title, createdBy: currentUser, - listId: _list.id, + projectId: _list.id, limit: 0, ), listId: _list.id, @@ -196,7 +197,7 @@ class KanbanClass { } Future _updateBucket(BuildContext context, Bucket bucket) { - return Provider.of(context, listen: false) + return Provider.of(context, listen: false) .updateBucket( context: context, bucket: bucket, @@ -211,9 +212,9 @@ class KanbanClass { } Future _deleteBucket(BuildContext context, Bucket bucket) async { - await Provider.of(context, listen: false).deleteBucket( + await Provider.of(context, listen: false).deleteBucket( context: context, - listId: bucket.listId, + listId: bucket.projectId, bucketId: bucket.id, ); @@ -499,7 +500,7 @@ class KanbanClass { return true; }, onAccept: (data) { - Provider.of(context, listen: false) + Provider.of(context, listen: false) .moveTaskToBucket( context: context, task: data.task, @@ -539,7 +540,7 @@ class KanbanClass { } Future loadBucketsForPage(int page) { - return Provider.of(context, listen: false).loadBuckets( + return Provider.of(context, listen: false).loadBuckets( context: context, listId: _list.id, page: page diff --git a/lib/components/SliverBucketList.dart b/lib/components/SliverBucketList.dart index b6cf39d..07c94f2 100644 --- a/lib/components/SliverBucketList.dart +++ b/lib/components/SliverBucketList.dart @@ -3,7 +3,8 @@ import 'package:provider/provider.dart'; import 'package:vikunja_app/components/BucketTaskCard.dart'; import 'package:vikunja_app/models/bucket.dart'; import 'package:vikunja_app/models/task.dart'; -import 'package:vikunja_app/stores/list_store.dart'; + +import '../stores/project_store.dart'; class SliverBucketList extends StatelessWidget { final Bucket bucket; @@ -33,7 +34,7 @@ class SliverBucketList extends StatelessWidget { } Future _moveTaskToBucket(BuildContext context, Task task, int index) async { - await Provider.of(context, listen: false).moveTaskToBucket( + await Provider.of(context, listen: false).moveTaskToBucket( context: context, task: task, newBucketId: bucket.id, diff --git a/lib/components/TaskTile.dart b/lib/components/TaskTile.dart index 78efc22..324231c 100644 --- a/lib/components/TaskTile.dart +++ b/lib/components/TaskTile.dart @@ -5,7 +5,8 @@ import 'package:provider/provider.dart'; import 'package:vikunja_app/models/task.dart'; import 'package:vikunja_app/utils/misc.dart'; import 'package:vikunja_app/pages/list/task_edit.dart'; -import 'package:vikunja_app/stores/list_store.dart'; + +import '../stores/project_store.dart'; class TaskTile extends StatefulWidget { final Task task; @@ -41,7 +42,7 @@ class TaskTileState extends State with AutomaticKeepAliveClientMixin { @override Widget build(BuildContext context) { super.build(context); - final taskState = Provider.of(context); + final taskState = Provider.of(context); Duration? durationUntilDue = _currentTask.dueDate?.difference(DateTime.now()); if (_currentTask.loading) { return ListTile( @@ -119,7 +120,7 @@ class TaskTileState extends State with AutomaticKeepAliveClientMixin { } Future _updateTask(Task task, bool checked) { - return Provider.of(context, listen: false).updateTask( + return Provider.of(context, listen: false).updateTask( context: context, task: task.copyWith( done: checked, diff --git a/lib/global.dart b/lib/global.dart index 2df37a1..a9f68ec 100644 --- a/lib/global.dart +++ b/lib/global.dart @@ -69,7 +69,7 @@ class VikunjaGlobalState extends State { NamespaceService get namespaceService => new NamespaceAPIService(client); - ProjectService get projectService => new ProjectAPIService(client); + ProjectService get projectService => new ProjectAPIService(client, _storage); TaskService get taskService => new TaskAPIService(client); diff --git a/lib/models/bucket.dart b/lib/models/bucket.dart index 09055dd..f6ed3e6 100644 --- a/lib/models/bucket.dart +++ b/lib/models/bucket.dart @@ -5,7 +5,7 @@ import 'package:vikunja_app/models/user.dart'; @JsonSerializable() class Bucket { - int id, listId, limit; + int id, projectId, limit; String title; double? position; final DateTime created, updated; @@ -15,7 +15,7 @@ class Bucket { Bucket({ this.id = 0, - required this.listId, + required this.projectId, required this.title, this.position, required this.limit, @@ -30,7 +30,7 @@ class Bucket { Bucket.fromJSON(Map json) : id = json['id'], - listId = json['list_id'], + projectId = json['project_id'], title = json['title'], position = json['position'] is int ? json['position'].toDouble() @@ -48,7 +48,7 @@ class Bucket { toJSON() => { 'id': id, - 'list_id': listId, + 'list_id': projectId, 'title': title, 'position': position, 'limit': limit, diff --git a/lib/models/project.dart b/lib/models/project.dart index 4ed5d1c..0eaf64a 100644 --- a/lib/models/project.dart +++ b/lib/models/project.dart @@ -1,20 +1,31 @@ +import 'dart:ui'; + import 'package:vikunja_app/models/user.dart'; class Project { final int id; + final double position; final User? owner; final int parentProjectId; final String description; final String title; final DateTime created, updated; + final Color? color; + final bool isArchived, isFavourite; + Iterable? subprojects; Project( - {this.id = 0, - this.owner, - this.parentProjectId = 0, - this.description = '', - required this.title, + { + this.id = 0, + this.owner, + this.parentProjectId = 0, + this.description = '', + this.position = 0, + this.color, + this.isArchived = false, + this.isFavourite = false, + required this.title, created, updated}) : this.created = created ?? DateTime.now(), @@ -24,9 +35,15 @@ class Project { : title = json['title'], description = json['description'], id = json['id'], + position = json['position'].toDouble(), + isArchived = json['is_archived'], + isFavourite = json['is_archived'], parentProjectId = json['parent_project_id'], created = DateTime.parse(json['created']), updated = DateTime.parse(json['updated']), + color = json['hex_color'] != '' + ? Color(int.parse(json['hex_color'], radix: 16) + 0xFF000000) + : null, owner = json['owner'] != null ? User.fromJson(json['owner']) : null; Map toJSON() => { @@ -36,6 +53,39 @@ class Project { 'title': title, 'owner': owner?.toJSON(), 'description': description, - 'parent_project_id': parentProjectId + 'parent_project_id': parentProjectId, + 'hex_color': color?.value.toRadixString(16).padLeft(8, '0').substring(2), + 'is_archived': isArchived, + 'is_favourite': isFavourite, + 'position': position }; + + Project copyWith({ + int? id, + DateTime? created, + DateTime? updated, + String? title, + User? owner, + String? description, + int? parentProjectId, + Color? color, + bool? isArchived, + bool? isFavourite, + double? position, + + }) { + return Project( + id: id ?? this.id, + created: created ?? this.created, + updated: updated ?? this.updated, + title: title ?? this.title, + owner: owner ?? this.owner, + description: description ?? this.description, + parentProjectId: parentProjectId ?? this.parentProjectId, + color: color ?? this.color, + isArchived: isArchived ?? this.isArchived, + isFavourite: isFavourite ?? this.isFavourite, + position: position ?? this.position, + ); + } } \ No newline at end of file diff --git a/lib/pages/home.dart b/lib/pages/home.dart index ba456b9..48100e4 100644 --- a/lib/pages/home.dart +++ b/lib/pages/home.dart @@ -16,7 +16,8 @@ import 'package:vikunja_app/models/namespace.dart'; import 'package:vikunja_app/pages/namespace/overview.dart'; import 'package:vikunja_app/pages/project/overview.dart'; import 'package:vikunja_app/pages/settings.dart'; -import 'package:vikunja_app/stores/list_store.dart'; + +import '../stores/project_store.dart'; class HomePage extends StatefulWidget { @override @@ -29,8 +30,8 @@ class HomePageState extends State { List widgets = [ - ChangeNotifierProvider( - create: (_) => new ListProvider(), + ChangeNotifierProvider( + create: (_) => new ProjectProvider(), child: LandingPage(), ), ProjectOverviewPage(), diff --git a/lib/pages/list/list.dart b/lib/pages/list/list.dart index 70c5f46..479e6af 100644 --- a/lib/pages/list/list.dart +++ b/lib/pages/list/list.dart @@ -65,8 +65,8 @@ class _ListPageState extends State { @override Widget build(BuildContext context) { taskState = Provider.of(context); - _kanban = KanbanClass( - context, nullSetState, _onViewTapped, _addItemDialog, _list); + //_kanban = KanbanClass( + // context, nullSetState, _onViewTapped, _addItemDialog, _list); Widget body; @@ -242,16 +242,8 @@ class _ListPageState extends State { TaskTile _buildLoadingTile(Task task) { return TaskTile( task: task, - loading: true, - onEdit: () => Navigator.push( - context, - MaterialPageRoute( - builder: (context) => TaskEditPage( - task: task, - taskState: taskState, - ), - ), - ), + loading: true, onEdit: () {}, + ); } diff --git a/lib/pages/list/task_edit.dart b/lib/pages/list/task_edit.dart index 24ce7f2..1f4a3e1 100644 --- a/lib/pages/list/task_edit.dart +++ b/lib/pages/list/task_edit.dart @@ -6,12 +6,13 @@ 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/stores/list_store.dart'; import 'package:vikunja_app/utils/repeat_after_parse.dart'; +import '../../stores/project_store.dart'; + class TaskEditPage extends StatefulWidget { final Task task; - final ListProvider taskState; + final ProjectProvider taskState; TaskEditPage({ required this.task, diff --git a/lib/pages/project/overview.dart b/lib/pages/project/overview.dart index 22a78bc..9e0cbb5 100644 --- a/lib/pages/project/overview.dart +++ b/lib/pages/project/overview.dart @@ -2,7 +2,7 @@ import 'package:after_layout/after_layout.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; -import 'package:vikunja_app/pages/project/project.dart'; +import 'package:vikunja_app/pages/project/project_task_list.dart'; import '../../components/AddDialog.dart'; import '../../components/ErrorDialog.dart'; @@ -56,7 +56,7 @@ class _ProjectOverviewPageState extends State ListTile( onTap: () { setState(() { - onSelectProject(context, project); + openList(context, project); }); }, contentPadding: insets, @@ -101,7 +101,7 @@ class _ProjectOverviewPageState extends State if (_selectedDrawerIndex > -1) { return new WillPopScope( - child: ProjectPage(project: _projects[_selectedDrawerIndex]), + child: ListPage(project: _projects[_selectedDrawerIndex]), onWillPop: () async { setState(() { _selectedDrawerIndex = -2; diff --git a/lib/pages/project/project.dart b/lib/pages/project/project.dart index 528e179..e69de29 100644 --- a/lib/pages/project/project.dart +++ b/lib/pages/project/project.dart @@ -1,64 +0,0 @@ -import 'package:flutter/cupertino.dart'; -import 'package:flutter/material.dart'; - -import '../../models/project.dart'; - -class ProjectPage extends StatefulWidget { - final Project project; - - ProjectPage({required this.project}) - : super(key: Key(project.id.toString())); - - @override - _ProjectPageState createState() => new _ProjectPageState(); -} - -class _ProjectPageState extends State { - @override - Widget build(BuildContext context) { - return Scaffold( - body: Column( - children: [ - buildSubProjectSelector(), - ] - ), - appBar: AppBar( - title: Text(widget.project.title), - ),); - } - Widget buildSubProjectSelector() { - return Container( - height: 80, - child: - ListView( - scrollDirection: Axis.horizontal, - //mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - ...?widget.project.subprojects?.map((elem) => - InkWell( - onTap: () {onSelectProject(context, elem);}, - child: - Container( - alignment: Alignment.center, - height: 20, - width: 100, - child: - Text(elem.title, overflow: TextOverflow.ellipsis,softWrap: false,))) - ), - ], - ), - ); - } -} - - -onSelectProject(BuildContext context, Project project) { - Navigator.push( - context, - MaterialPageRoute( - builder: (buildContext) => ProjectPage( - project: project, - ), - )); - //setState(() => _selectedDrawerIndex = index); -} \ No newline at end of file diff --git a/lib/pages/project/project_edit.dart b/lib/pages/project/project_edit.dart new file mode 100644 index 0000000..c7a589e --- /dev/null +++ b/lib/pages/project/project_edit.dart @@ -0,0 +1,173 @@ +import 'dart:ffi'; +import 'dart:developer'; +import 'package:flutter/material.dart'; +import 'package:vikunja_app/global.dart'; +import 'package:vikunja_app/theme/button.dart'; +import 'package:vikunja_app/theme/buttonText.dart'; + +import '../../models/project.dart'; + +class ProjectEditPage extends StatefulWidget { + final Project project; + + ProjectEditPage({required this.project}) : super(key: Key(project.toString())); + + @override + State createState() => _ProjectEditPageState(); +} + +class _ProjectEditPageState extends State { + final _formKey = GlobalKey(); + bool _loading = false; + String _title = '', _description = ''; + bool? displayDoneTasks; + late int listId; + + @override + void initState(){ + listId = widget.project.id; + super.initState(); + } + + @override + Widget build(BuildContext ctx) { + if(displayDoneTasks == null) + VikunjaGlobal.of(context).projectService.getDisplayDoneTasks(listId).then( + (value) => setState(() => displayDoneTasks = value == "1")); + else + log("Display done tasks: " + displayDoneTasks.toString()); + return Scaffold( + appBar: AppBar( + title: Text('Edit Project'), + ), + body: Builder( + builder: (BuildContext context) => SafeArea( + child: Form( + key: _formKey, + child: ListView( + //reverse: true, + padding: const EdgeInsets.all(16.0), + children: [ + Padding( + padding: EdgeInsets.symmetric(vertical: 10.0), + child: TextFormField( + maxLines: null, + keyboardType: TextInputType.multiline, + initialValue: widget.project.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.project.description, + onSaved: (description) => _description = description ?? '', + validator: (description) { + if(description == null) + return null; + if (description.length > 1000) { + return 'The description can have a maximum of 1000 characters.'; + } + return null; + }, + decoration: new InputDecoration( + labelText: 'Description', + border: OutlineInputBorder(), + ), + ), + ), + Padding( + padding: EdgeInsets.symmetric(vertical: 10.0), + child: CheckboxListTile( + value: displayDoneTasks ?? false, + title: Text("Show done tasks"), + onChanged: (value) { + value ??= false; + VikunjaGlobal.of(context).listService.setDisplayDoneTasks(listId, value ? "1" : "0"); + setState(() => displayDoneTasks = value); + }, + ), + ), + Builder( + builder: (context) => Padding( + padding: EdgeInsets.symmetric(vertical: 10.0), + child: FancyButton( + onPressed: !_loading + ? () { + if (_formKey.currentState!.validate()) { + Form.of(context)?.save(); + _saveList(context); + } + } + : () {}, + child: _loading + ? CircularProgressIndicator() + : VikunjaButtonText('Save'), + ))), + /*ExpansionTile( + title: Text("Sharing"), + children: [ + TypeAheadFormField( + onSuggestionSelected: (suggestion) {}, + itemBuilder: (BuildContext context, Object? itemData) { + return Card( + child: Container( + padding: EdgeInsets.all(10), + child: Text(itemData.toString())), + );}, + suggestionsCallback: (String pattern) { + List matches = []; + matches.addAll(["test", "test2", "test3"]); + matches.retainWhere((s){ + return s.toLowerCase().contains(pattern.toLowerCase()); + }); + return matches; + },) + ], + )*/ + ] + ), + ), + ), + ), + ); + } + + _saveList(BuildContext context) async { + setState(() => _loading = true); + // FIXME: is there a way we can update the list without creating a new list object? + // aka updating the existing list we got from context (setters?) + Project newProject = widget.project.copyWith( + title: _title, + description: _description + ); + VikunjaGlobal.of(context).projectService.update(newProject).then((_) { + setState(() => _loading = false); + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text('The task was updated successfully!'), + )); + }).catchError((err) { + setState(() => _loading = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Something went wrong: ' + err.toString()), + action: SnackBarAction( + label: 'CLOSE', + onPressed: ScaffoldMessenger.of(context).hideCurrentSnackBar), + ), + ); + }); + } +} diff --git a/lib/pages/project/project_task_list.dart b/lib/pages/project/project_task_list.dart new file mode 100644 index 0000000..7220e39 --- /dev/null +++ b/lib/pages/project/project_task_list.dart @@ -0,0 +1,388 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:flutter_keyboard_visibility/flutter_keyboard_visibility.dart'; +import 'package:vikunja_app/components/AddDialog.dart'; +import 'package:vikunja_app/components/KanbanWidget.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/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/pages/project/project_edit.dart'; + +import '../../components/pagestatus.dart'; +import '../../models/project.dart'; +import '../../stores/project_store.dart'; + +enum BucketMenu { limit, done, delete } + +class BucketProps { + final ScrollController controller = ScrollController(); + final TextEditingController titleController = TextEditingController(); + bool scrollable = false; + bool portrait = true; + int bucketLength = 0; + Size? taskDropSize; +} + +class ListPage extends StatefulWidget { + final Project project; + + //ListPage({this.taskList}) : super(key: Key(taskList.id.toString())); + ListPage({required this.project}) + : super(key: Key(Random().nextInt(100000).toString())); + + @override + _ListPageState createState() => _ListPageState(); +} + +class _ListPageState extends State { + final _keyboardController = KeyboardVisibilityController(); + int _viewIndex = 0; + late Project _project; + List _loadingTasks = []; + int _currentPage = 1; + bool displayDoneTasks = false; + late ProjectProvider taskState; + late KanbanClass _kanban; + + @override + void initState() { + _project = widget.project; + _keyboardController.onChange.listen((visible) { + if (!visible && mounted) FocusScope.of(context).unfocus(); + }); + super.initState(); + } + + void nullSetState() { + setState(() {}); + } + + @override + Widget build(BuildContext context) { + taskState = Provider.of(context); + _kanban = KanbanClass( + context, nullSetState, _onViewTapped, _addItemDialog, _project); + + Widget body; + + switch (taskState.pageStatus) { + case PageStatus.built: + Future.delayed(Duration.zero, _loadList); + body = new Stack(children: [ + ListView(), + Center( + child: CircularProgressIndicator(), + ) + ]); + break; + case PageStatus.loading: + body = new Stack(children: [ + ListView(), + Center( + child: CircularProgressIndicator(), + ) + ]); + break; + case PageStatus.error: + body = new Stack(children: [ + ListView(), + Center(child: Text("There was an error loading this view")) + ]); + break; + case PageStatus.success: + body = taskState.tasks.length > 0 || taskState.buckets.length > 0 + ? ListenableProvider.value( + value: taskState, + child: Theme( + data: (ThemeData base) { + return base.copyWith( + chipTheme: base.chipTheme.copyWith( + labelPadding: EdgeInsets.symmetric(horizontal: 2), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(5)), + ), + ), + ); + }(Theme.of(context)), + child: () { + switch (_viewIndex) { + case 0: + return _listView(context); + case 1: + return _kanban.kanbanView(); + default: + return _listView(context); + } + }(), + ), + ) + : Stack(children: [ + ListView(), + Center(child: Text('This list is empty.')) + ]); + break; + case PageStatus.empty: + body = new Stack(children: [ + ListView(), + Center(child: Text("This view is empty")) + ]); + break; + } + + return new Scaffold( + appBar: AppBar( + title: Text(_project.title), + actions: [ + IconButton( + icon: Icon(Icons.edit), + onPressed: () => Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ProjectEditPage( + project: _project, + ), + )).whenComplete(() => _loadList()), + ), + ], + ), + body: RefreshIndicator(onRefresh: () => _loadList(), child: body), + floatingActionButton: _viewIndex == 1 + ? null + : Builder( + builder: (context) => FloatingActionButton( + onPressed: () => _addItemDialog(context), + child: Icon(Icons.add)), + ), + bottomNavigationBar: BottomNavigationBar( + items: const [ + BottomNavigationBarItem( + icon: Icon(Icons.view_list), + label: 'List', + tooltip: 'List', + ), + BottomNavigationBarItem( + icon: Icon(Icons.view_kanban), + label: 'Kanban', + tooltip: 'Kanban', + ), + ], + currentIndex: _viewIndex, + onTap: _onViewTapped, + ), + ); + } + + Widget buildSubProjectSelector() { + return Container( + height: 80, + child: + ListView( + scrollDirection: Axis.horizontal, + //mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + ...?widget.project.subprojects?.map((elem) => + InkWell( + onTap: () {openList(context, elem);}, + child: + Container( + alignment: Alignment.center, + height: 20, + width: 100, + child: + Text(elem.title, overflow: TextOverflow.ellipsis,softWrap: false,))) + ), + ], + ), + ); + } + + void _onViewTapped(int index) { + _loadList().then((_) { + _currentPage = 1; + setState(() { + _viewIndex = index; + }); + }); + } + + Widget _listView(BuildContext context) { + List subProjectView = []; + if(widget.project.subprojects?.length != 0) { + subProjectView.add(Padding(child: Text("Projects", style: TextStyle(fontWeight: FontWeight.bold),), padding: EdgeInsets.fromLTRB(0, 10, 0, 0),)); + subProjectView.add(buildSubProjectSelector()); + subProjectView.add(Padding(child: Text("Tasks", style: TextStyle(fontWeight: FontWeight.bold),), padding: EdgeInsets.fromLTRB(0, 10, 0, 0),)); + subProjectView.add(Divider()); + } + + return Column( + children: [ + ...subProjectView, + Expanded(child: + ListView.builder( + padding: EdgeInsets.symmetric(vertical: 8.0), + itemCount: taskState.tasks.length * 2, + itemBuilder: (context, i) { + if (i.isOdd) return Divider(); + + if (_loadingTasks.isNotEmpty) { + final loadingTask = _loadingTasks.removeLast(); + return _buildLoadingTile(loadingTask); + } + + final index = i ~/ 2; + + if (taskState.maxPages == _currentPage && + index == taskState.tasks.length) + throw Exception("Check itemCount attribute"); + + if (index >= taskState.tasks.length && + _currentPage < taskState.maxPages) { + _currentPage++; + _loadTasksForPage(_currentPage); + } + return _buildTile(taskState.tasks[index]); + }))]); + } + + Widget _buildTile(Task task) { + return ListenableProvider.value( + value: taskState, + child: TaskTile( + task: task, + loading: false, + onEdit: () {}, + onMarkedAsDone: (done) { + Provider.of(context, listen: false).updateTask( + context: context, + task: task.copyWith(done: done), + ); + }, + ), + ); + } + + Future updateDisplayDoneTasks() { + return VikunjaGlobal.of(context) + .projectService + .getDisplayDoneTasks(_project.id) + .then((value) { + displayDoneTasks = value == "1"; + }); + } + + TaskTile _buildLoadingTile(Task task) { + return TaskTile( + task: task, + loading: true, + onEdit: () => Navigator.push( + context, + MaterialPageRoute( + builder: (context) => TaskEditPage( + task: task, + taskState: taskState, + ), + ), + ), + ); + } + + Future _loadList() async { + taskState.pageStatus = (PageStatus.loading); + + updateDisplayDoneTasks().then((value) async { + switch (_viewIndex) { + case 0: + _loadTasksForPage(1); + break; + case 1: + await _kanban + .loadBucketsForPage(1); + // load all buckets to get length for RecordableListView + while (_currentPage < taskState.maxPages) { + _currentPage++; + await _kanban + .loadBucketsForPage(_currentPage); + } + break; + default: + _loadTasksForPage(1); + } + }); + } + + Future _loadTasksForPage(int page) { + return Provider.of(context, listen: false) + .loadTasks( + context: context, + listId: _project.id, + page: page, + displayDoneTasks: displayDoneTasks); + } + + Future _addItemDialog(BuildContext context, [Bucket? bucket]) { + return showDialog( + context: context, + builder: (_) => AddDialog( + onAdd: (title) => _addItem(title, context, bucket), + decoration: InputDecoration( + labelText: + (bucket != null ? '\'${bucket.title}\': ' : '') + 'New Task Name', + hintText: 'eg. Milk', + ), + ), + ); + } + + Future _addItem(String title, BuildContext context, + [Bucket? bucket]) async { + final currentUser = VikunjaGlobal.of(context).currentUser; + if (currentUser == null) { + return; + } + + final newTask = Task( + title: title, + createdBy: currentUser, + done: false, + bucketId: bucket?.id, + projectId: _project.id, + ); + setState(() => _loadingTasks.add(newTask)); + return Provider.of(context, listen: false) + .addTask( + context: context, + newTask: newTask, + listId: _project.id, + ) + .then((_) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text('The task was added successfully' + + (bucket != null ? ' to \'${bucket.title}\'' : '') + + '!'), + )); + setState(() { + _loadingTasks.remove(newTask); + }); + }); + } +} + + +openList(BuildContext context, Project project) { + Navigator.of(context).push(MaterialPageRoute( + builder: (context) => ChangeNotifierProvider( + create: (_) => new ProjectProvider(), + child: ListPage( + project: project, + ), + ), + // ListPage(taskList: list) + )); +} \ No newline at end of file diff --git a/lib/pages/settings.dart b/lib/pages/settings.dart index db19b58..ffb55d8 100644 --- a/lib/pages/settings.dart +++ b/lib/pages/settings.dart @@ -5,6 +5,7 @@ import 'package:vikunja_app/global.dart'; import 'package:vikunja_app/models/list.dart'; import '../main.dart'; +import '../models/project.dart'; class SettingsPage extends StatefulWidget { @@ -13,8 +14,8 @@ class SettingsPage extends StatefulWidget { } class SettingsPageState extends State { - List? taskListList; - int? defaultList; + List? projectList; + int? defaultProject; bool? ignoreCertificates; bool? getVersionNotifications; String? versionTag, newestVersionTag; @@ -27,13 +28,13 @@ class SettingsPageState extends State { durationTextController = TextEditingController(); VikunjaGlobal.of(context) - .listService + .projectService .getAll() - .then((value) => setState(() => taskListList = value)); + .then((value) => setState(() => projectList = value)); - VikunjaGlobal.of(context).listService.getDefaultList().then((value) => + VikunjaGlobal.of(context).projectService.getDefaultList().then((value) => setState( - () => defaultList = value == null ? null : int.tryParse(value))); + () => defaultProject = value == null ? null : int.tryParse(value))); VikunjaGlobal.of(context).settingsManager.getIgnoreCertificates().then( (value) => @@ -84,7 +85,7 @@ class SettingsPageState extends State { Theme.of(context).primaryColor, BlendMode.multiply)), ), ), - taskListList != null + projectList != null ? ListTile( title: Text("Default List"), trailing: DropdownButton( @@ -93,14 +94,14 @@ class SettingsPageState extends State { child: Text("None"), value: null, ), - ...taskListList! + ...projectList! .map((e) => DropdownMenuItem( child: Text(e.title), value: e.id)) .toList() ], - value: defaultList, + value: defaultProject, onChanged: (int? value) { - setState(() => defaultList = value); + setState(() => defaultProject = value); VikunjaGlobal.of(context) .listService .setDefaultList(value); diff --git a/lib/service/mocked_services.dart b/lib/service/mocked_services.dart index c5808fc..273f872 100644 --- a/lib/service/mocked_services.dart +++ b/lib/service/mocked_services.dart @@ -170,12 +170,6 @@ class MockedTaskService implements TaskService { return Future.value(task); } - @override - Future getAllByList(int listId, - [Map>? queryParameters]) { - return Future.value(new Response(_tasks.values.toList(), 200, {})); - } - @override int get maxPages => 1; Future get(int taskId) { @@ -194,6 +188,12 @@ class MockedTaskService implements TaskService { // TODO: implement getAll throw UnimplementedError(); } + + @override + Future getAllByProject(int projectId, [Map>? queryParameters]) { + // TODO: implement getAllByProject + return Future.value(new Response(_tasks.values.toList(), 200, {})); + } } class MockedUserService implements UserService { diff --git a/lib/service/services.dart b/lib/service/services.dart index 38b29c8..26d2180 100644 --- a/lib/service/services.dart +++ b/lib/service/services.dart @@ -134,8 +134,14 @@ abstract class ProjectService { Future get(int projectId); Future create(Project p); - Future update(int projectId); + Future update(Project p); Future delete(int projectId); + + + Future getDisplayDoneTasks(int listId); + void setDisplayDoneTasks(int listId, String value); + Future getDefaultList(); + void setDefaultList(int? listId); } @@ -184,7 +190,7 @@ abstract class TaskService { Future?> getAll(); - Future getAllByList(int listId, + Future getAllByProject(int projectId, [Map> queryParameters]); Future?> getByOptions(TaskServiceOptions options); diff --git a/lib/stores/list_store.dart b/lib/stores/list_store.dart index 3b344eb..98ef752 100644 --- a/lib/stores/list_store.dart +++ b/lib/stores/list_store.dart @@ -1,3 +1,4 @@ + import 'package:flutter/material.dart'; import 'package:vikunja_app/models/task.dart'; import 'package:vikunja_app/models/bucket.dart'; @@ -65,6 +66,8 @@ class ListProvider with ChangeNotifier { "filter_value": ["false"] }); } + return Future.value(); + /* return VikunjaGlobal.of(context).taskService.getAllByList(listId, queryParams).then((response) { if(response == null) { pageStatus = PageStatus.error; @@ -75,7 +78,7 @@ class ListProvider with ChangeNotifier { } _tasks.addAll(response.body); pageStatus = PageStatus.success; - }); + });*/ } Future loadBuckets({required BuildContext context, required int listId, int page = 1}) { diff --git a/lib/stores/project_store.dart b/lib/stores/project_store.dart new file mode 100644 index 0000000..a5d8a52 --- /dev/null +++ b/lib/stores/project_store.dart @@ -0,0 +1,251 @@ +import 'package:flutter/material.dart'; +import 'package:vikunja_app/models/task.dart'; +import 'package:vikunja_app/models/bucket.dart'; +import 'package:vikunja_app/utils/calculate_item_position.dart'; +import 'package:vikunja_app/global.dart'; + +import '../components/pagestatus.dart'; + +class ProjectProvider with ChangeNotifier { + bool _taskDragging = false; + int _maxPages = 0; + + // TODO: Streams + List _tasks = []; + List _buckets = []; + + + bool get taskDragging => _taskDragging; + + set taskDragging(bool value) { + _taskDragging = value; + notifyListeners(); + } + + int get maxPages => _maxPages; + + set tasks(List tasks) { + _tasks = tasks; + notifyListeners(); + } + + List get tasks => _tasks; + + set buckets(List buckets) { + _buckets = buckets; + notifyListeners(); + } + + List get buckets => _buckets; + + + PageStatus _pageStatus = PageStatus.built; + + PageStatus get pageStatus => _pageStatus; + + set pageStatus(PageStatus ps) { + _pageStatus = ps; + print("new PageStatus: ${ps.toString()}"); + notifyListeners(); + } + + Future loadTasks({required BuildContext context, required int listId, int page = 1, bool displayDoneTasks = true}) { + _tasks = []; + notifyListeners(); + + Map> queryParams = { + "sort_by": ["done", "id"], + "order_by": ["asc", "desc"], + "page": [page.toString()] + }; + + if(!displayDoneTasks) { + queryParams.addAll({ + "filter_by": ["done"], + "filter_value": ["false"], + "sort_by": ["done"], + }); + } + return VikunjaGlobal.of(context).taskService.getAllByProject(listId, queryParams).then((response) { + if(response == null) { + pageStatus = PageStatus.error; + return; + } + if (response.headers["x-pagination-total-pages"] != null) { + _maxPages = int.parse(response.headers["x-pagination-total-pages"]!); + } + _tasks.addAll(response.body); + pageStatus = PageStatus.success; + }); + } + + Future loadBuckets({required BuildContext context, required int listId, int page = 1}) { + _buckets = []; + pageStatus = PageStatus.loading; + notifyListeners(); + + Map> queryParams = { + "page": [page.toString()] + }; + + return VikunjaGlobal.of(context).bucketService.getAllByList(listId, queryParams).then((response) { + if(response == null) { + pageStatus = PageStatus.error; + return; + } + if (response.headers["x-pagination-total-pages"] != null) { + _maxPages = int.parse(response.headers["x-pagination-total-pages"]!); + } + _buckets.addAll(response.body); + + pageStatus = PageStatus.success; + }); + } + + Future addTaskByTitle( + {required BuildContext context, required String title, required int projectId}) async{ + final globalState = VikunjaGlobal.of(context); + if (globalState.currentUser == null) { + return; + } + + final newTask = Task( + title: title, + createdBy: globalState.currentUser!, + done: false, + projectId: projectId, + ); + pageStatus = PageStatus.loading; + + return globalState.taskService.add(projectId, newTask).then((task) { + if(task != null) + _tasks.insert(0, task); + pageStatus = PageStatus.success; + }); + } + + Future addTask({required BuildContext context, required Task newTask, required int listId}) { + var globalState = VikunjaGlobal.of(context); + if (newTask.bucketId == null) pageStatus = PageStatus.loading; + notifyListeners(); + + return globalState.taskService.add(listId, newTask).then((task) { + if (task == null) { + pageStatus = PageStatus.error; + return; + } + if (_tasks.isNotEmpty) + _tasks.insert(0, task); + if (_buckets.isNotEmpty) { + final bucket = _buckets[_buckets.indexWhere((b) => task.bucketId == b.id)]; + bucket.tasks.add(task); + } + pageStatus = PageStatus.success; + }); + } + + Future updateTask({required BuildContext context, required Task task}) { + return VikunjaGlobal.of(context).taskService.update(task).then((task) { + // 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 definitely fix it later. + if(task == null) + return null; + _tasks.asMap().forEach((i, t) { + if (task.id == t.id) { + _tasks[i] = task; + } + }); + _buckets.asMap().forEach((i, b) => b.tasks.asMap().forEach((v, t) { + if (task.id == t.id){ + _buckets[i].tasks[v] = task; + } + })); + notifyListeners(); + return task; + }); + } + + Future addBucket({required BuildContext context, required Bucket newBucket, required int listId}) { + notifyListeners(); + return VikunjaGlobal.of(context).bucketService.add(listId, newBucket) + .then((bucket) { + if(bucket == null) + return null; + _buckets.add(bucket); + notifyListeners(); + }); + } + + Future updateBucket({required BuildContext context, required Bucket bucket}) { + return VikunjaGlobal.of(context).bucketService.update(bucket) + .then((rBucket) { + if(rBucket == null) + return null; + _buckets[_buckets.indexWhere((b) => rBucket.id == b.id)] = rBucket; + _buckets.sort((a, b) => a.position!.compareTo(b.position!)); + notifyListeners(); + }); + } + + Future deleteBucket({required BuildContext context, required int listId, required int bucketId}) { + return VikunjaGlobal.of(context).bucketService.delete(listId, bucketId) + .then((_) { + _buckets.removeWhere((bucket) => bucket.id == bucketId); + notifyListeners(); + }); + } + + Future moveTaskToBucket({required BuildContext context, required Task? task, int? newBucketId, required int index}) async { + if(task == null) + throw Exception("Task to be moved may not be null"); + final sameBucket = task.bucketId == newBucketId; + final newBucketIndex = _buckets.indexWhere((b) => b.id == newBucketId); + if (sameBucket && index > _buckets[newBucketIndex].tasks.indexWhere((t) => t.id == task?.id)) index--; + + _buckets[_buckets.indexWhere((b) => b.id == task?.bucketId)].tasks.remove(task); + if (index >= _buckets[newBucketIndex].tasks.length) + _buckets[newBucketIndex].tasks.add(task); + else + _buckets[newBucketIndex].tasks.insert(index, task); + + task = await VikunjaGlobal.of(context).taskService.update(task.copyWith( + bucketId: newBucketId, + kanbanPosition: calculateItemPosition( + positionBefore: index != 0 + ? _buckets[newBucketIndex].tasks[index - 1].kanbanPosition : null, + positionAfter: index < _buckets[newBucketIndex].tasks.length - 1 + ? _buckets[newBucketIndex].tasks[index + 1].kanbanPosition : null, + ), + )); + if(task == null) + return; + _buckets[newBucketIndex].tasks[index] = task; + + // make sure the first 2 tasks don't have 0 kanbanPosition + Task? secondTask; + if (index == 0 && _buckets[newBucketIndex].tasks.length > 1 + && _buckets[newBucketIndex].tasks[1].kanbanPosition == 0) { + secondTask = await VikunjaGlobal.of(context).taskService.update( + _buckets[newBucketIndex].tasks[1].copyWith( + kanbanPosition: calculateItemPosition( + positionBefore: task.kanbanPosition, + positionAfter: 1 < _buckets[newBucketIndex].tasks.length - 1 + ? _buckets[newBucketIndex].tasks[2].kanbanPosition : null, + ), + )); + if(secondTask != null) + _buckets[newBucketIndex].tasks[1] = secondTask; + } + + if (_tasks.isNotEmpty) { + _tasks[_tasks.indexWhere((t) => t.id == task?.id)] = task; + if (secondTask != null) + _tasks[_tasks.indexWhere((t) => t.id == secondTask!.id)] = secondTask; + } + + _buckets[newBucketIndex].tasks[_buckets[newBucketIndex].tasks.indexWhere((t) => t.id == task?.id)] = task; + _buckets[newBucketIndex].tasks.sort((a, b) => a.kanbanPosition!.compareTo(b.kanbanPosition!)); + + notifyListeners(); + } +}