moving from lists and namespaces to projects.

This commit is contained in:
Benimautner 2023-07-23 01:50:55 +02:00
parent f19eda317e
commit 9c5ad58299
23 changed files with 1016 additions and 153 deletions

View File

@ -8,9 +8,9 @@ class BucketAPIService extends APIService implements BucketService {
BucketAPIService(Client client) : super(client);
@override
Future<Bucket?> add(int listId, Bucket bucket) {
Future<Bucket?> 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<Response?> getAllByList(int listId,
Future<Response?> getAllByList(int projectId,
[Map<String, List<String>>? 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<Bucket?> 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);

View File

@ -79,7 +79,19 @@ class Client {
Future<Response?> get(String url,
[Map<String, List<String>>? 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));
}

View File

@ -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<Project?> 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<Project?> 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<Project?> update(int projectId) {
// TODO: implement update
throw UnimplementedError();
Future<Project?> 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<String> 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<String?> getDefaultList() {
return _storage.read(key: "default_list_id");
}
@override
void setDefaultList(int? listId) {
_storage.write(key: "default_list_id", value: listId.toString());
}
}

View File

@ -11,9 +11,9 @@ class TaskAPIService extends APIService implements TaskService {
TaskAPIService(Client client) : super(client);
@override
Future<Task?> add(int listId, Task task) {
Future<Task?> 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<Task?> 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<Response?> getAllByList(int listId,
Future<Response?> getAllByProject(int projectId,
[Map<String, List<String>>? queryParameters]) {
return client
.get('/lists/$listId/tasks', queryParameters).then(
.get('/projects/$projectId/tasks', queryParameters).then(
(response) {
return response != null ?
new Response(

View File

@ -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<BucketTaskCard> with AutomaticKeepAlive
super.build(context);
if (_cardSize == null) _updateCardSize(context);
final taskState = Provider.of<ListProvider>(context);
final taskState = Provider.of<ProjectProvider>(context);
final bucket = taskState.buckets[taskState.buckets.indexWhere((b) => b.id == widget.task.bucketId)];
// default chip height: 32
const double chipHeight = 28;

View File

@ -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<int, BucketProps> _bucketProps = {};
KanbanClass(this.context, this.notify, this._onViewTapped, this._addItemDialog, this._list) {
taskState = Provider.of<ListProvider>(context);
taskState = Provider.of<ProjectProvider>(context);
}
@ -177,12 +178,12 @@ class KanbanClass {
return;
}
await Provider.of<ListProvider>(context, listen: false).addBucket(
await Provider.of<ProjectProvider>(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<void> _updateBucket(BuildContext context, Bucket bucket) {
return Provider.of<ListProvider>(context, listen: false)
return Provider.of<ProjectProvider>(context, listen: false)
.updateBucket(
context: context,
bucket: bucket,
@ -211,9 +212,9 @@ class KanbanClass {
}
Future<void> _deleteBucket(BuildContext context, Bucket bucket) async {
await Provider.of<ListProvider>(context, listen: false).deleteBucket(
await Provider.of<ProjectProvider>(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<ListProvider>(context, listen: false)
Provider.of<ProjectProvider>(context, listen: false)
.moveTaskToBucket(
context: context,
task: data.task,
@ -539,7 +540,7 @@ class KanbanClass {
}
Future<void> loadBucketsForPage(int page) {
return Provider.of<ListProvider>(context, listen: false).loadBuckets(
return Provider.of<ProjectProvider>(context, listen: false).loadBuckets(
context: context,
listId: _list.id,
page: page

View File

@ -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<void> _moveTaskToBucket(BuildContext context, Task task, int index) async {
await Provider.of<ListProvider>(context, listen: false).moveTaskToBucket(
await Provider.of<ProjectProvider>(context, listen: false).moveTaskToBucket(
context: context,
task: task,
newBucketId: bucket.id,

View File

@ -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<TaskTile> with AutomaticKeepAliveClientMixin {
@override
Widget build(BuildContext context) {
super.build(context);
final taskState = Provider.of<ListProvider>(context);
final taskState = Provider.of<ProjectProvider>(context);
Duration? durationUntilDue = _currentTask.dueDate?.difference(DateTime.now());
if (_currentTask.loading) {
return ListTile(
@ -119,7 +120,7 @@ class TaskTileState extends State<TaskTile> with AutomaticKeepAliveClientMixin {
}
Future<Task?> _updateTask(Task task, bool checked) {
return Provider.of<ListProvider>(context, listen: false).updateTask(
return Provider.of<ProjectProvider>(context, listen: false).updateTask(
context: context,
task: task.copyWith(
done: checked,

View File

@ -69,7 +69,7 @@ class VikunjaGlobalState extends State<VikunjaGlobal> {
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);

View File

@ -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<String, dynamic> 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,

View File

@ -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<Project>? 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<String, dynamic> 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,
);
}
}

View File

@ -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<HomePage> {
List<Widget> widgets = [
ChangeNotifierProvider<ListProvider>(
create: (_) => new ListProvider(),
ChangeNotifierProvider<ProjectProvider>(
create: (_) => new ProjectProvider(),
child: LandingPage(),
),
ProjectOverviewPage(),

View File

@ -65,8 +65,8 @@ class _ListPageState extends State<ListPage> {
@override
Widget build(BuildContext context) {
taskState = Provider.of<ListProvider>(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<ListPage> {
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: () {},
);
}

View File

@ -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,

View File

@ -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<ProjectOverviewPage>
ListTile(
onTap: () {
setState(() {
onSelectProject(context, project);
openList(context, project);
});
},
contentPadding: insets,
@ -101,7 +101,7 @@ class _ProjectOverviewPageState extends State<ProjectOverviewPage>
if (_selectedDrawerIndex > -1) {
return new WillPopScope(
child: ProjectPage(project: _projects[_selectedDrawerIndex]),
child: ListPage(project: _projects[_selectedDrawerIndex]),
onWillPop: () async {
setState(() {
_selectedDrawerIndex = -2;

View File

@ -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<ProjectPage> {
@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);
}

View File

@ -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<StatefulWidget> createState() => _ProjectEditPageState();
}
class _ProjectEditPageState extends State<ProjectEditPage> {
final _formKey = GlobalKey<FormState>();
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: <Widget>[
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<String> matches = <String>[];
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),
),
);
});
}
}

View File

@ -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<ListPage> {
final _keyboardController = KeyboardVisibilityController();
int _viewIndex = 0;
late Project _project;
List<Task> _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<ProjectProvider>(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: <Widget>[
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>[
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<Widget> 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<ProjectProvider>(context, listen: false).updateTask(
context: context,
task: task.copyWith(done: done),
);
},
),
);
}
Future<void> 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<void> _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<void> _loadTasksForPage(int page) {
return Provider.of<ProjectProvider>(context, listen: false)
.loadTasks(
context: context,
listId: _project.id,
page: page,
displayDoneTasks: displayDoneTasks);
}
Future<void> _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<void> _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<ProjectProvider>(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<ProjectProvider>(
create: (_) => new ProjectProvider(),
child: ListPage(
project: project,
),
),
// ListPage(taskList: list)
));
}

View File

@ -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<SettingsPage> {
List<TaskList>? taskListList;
int? defaultList;
List<Project>? projectList;
int? defaultProject;
bool? ignoreCertificates;
bool? getVersionNotifications;
String? versionTag, newestVersionTag;
@ -27,13 +28,13 @@ class SettingsPageState extends State<SettingsPage> {
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<SettingsPage> {
Theme.of(context).primaryColor, BlendMode.multiply)),
),
),
taskListList != null
projectList != null
? ListTile(
title: Text("Default List"),
trailing: DropdownButton<int>(
@ -93,14 +94,14 @@ class SettingsPageState extends State<SettingsPage> {
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);

View File

@ -170,12 +170,6 @@ class MockedTaskService implements TaskService {
return Future.value(task);
}
@override
Future<Response> getAllByList(int listId,
[Map<String, List<String>>? queryParameters]) {
return Future.value(new Response(_tasks.values.toList(), 200, {}));
}
@override
int get maxPages => 1;
Future<Task> get(int taskId) {
@ -194,6 +188,12 @@ class MockedTaskService implements TaskService {
// TODO: implement getAll
throw UnimplementedError();
}
@override
Future<Response?> getAllByProject(int projectId, [Map<String, List<String>>? queryParameters]) {
// TODO: implement getAllByProject
return Future.value(new Response(_tasks.values.toList(), 200, {}));
}
}
class MockedUserService implements UserService {

View File

@ -134,8 +134,14 @@ abstract class ProjectService {
Future<Project?> get(int projectId);
Future<Project?> create(Project p);
Future<Project?> update(int projectId);
Future<Project?> update(Project p);
Future delete(int projectId);
Future<String?> getDisplayDoneTasks(int listId);
void setDisplayDoneTasks(int listId, String value);
Future<String?> getDefaultList();
void setDefaultList(int? listId);
}
@ -184,7 +190,7 @@ abstract class TaskService {
Future<List<Task>?> getAll();
Future<Response?> getAllByList(int listId,
Future<Response?> getAllByProject(int projectId,
[Map<String, List<String>> queryParameters]);
Future<List<Task>?> getByOptions(TaskServiceOptions options);

View File

@ -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<void> loadBuckets({required BuildContext context, required int listId, int page = 1}) {

View File

@ -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<Task> _tasks = [];
List<Bucket> _buckets = [];
bool get taskDragging => _taskDragging;
set taskDragging(bool value) {
_taskDragging = value;
notifyListeners();
}
int get maxPages => _maxPages;
set tasks(List<Task> tasks) {
_tasks = tasks;
notifyListeners();
}
List<Task> get tasks => _tasks;
set buckets(List<Bucket> buckets) {
_buckets = buckets;
notifyListeners();
}
List<Bucket> get buckets => _buckets;
PageStatus _pageStatus = PageStatus.built;
PageStatus get pageStatus => _pageStatus;
set pageStatus(PageStatus ps) {
_pageStatus = ps;
print("new PageStatus: ${ps.toString()}");
notifyListeners();
}
Future<void> loadTasks({required BuildContext context, required int listId, int page = 1, bool displayDoneTasks = true}) {
_tasks = [];
notifyListeners();
Map<String, List<String>> 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<void> loadBuckets({required BuildContext context, required int listId, int page = 1}) {
_buckets = [];
pageStatus = PageStatus.loading;
notifyListeners();
Map<String, List<String>> 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<void> 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<void> 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<Task?> 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<void> 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<void> 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<void> 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<void> 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();
}
}