1
0
mirror of https://github.com/go-vikunja/app synced 2024-06-01 02:06:51 +00:00

Merge branch 'main' of github.com:go-vikunja/app

This commit is contained in:
Benimautner 2022-08-08 18:10:30 +02:00
commit 0b0650de62
22 changed files with 2060 additions and 423 deletions

View File

@ -0,0 +1,54 @@
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<Bucket> 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');
}
/* Not implemented in the Vikunja API
@override
Future<Bucket> get(int listId, int bucketId) {
return client
.get('/lists/$listId/buckets/$bucketId')
.then((response) => Bucket.fromJSON(response.body));
}
*/
@override
Future<Response> getAllByList(int listId,
[Map<String, List<String>> 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<Bucket> update(Bucket bucket) {
return client
.post('/lists/${bucket.listId}/buckets/${bucket.id}', body: bucket.toJSON())
.then((response) => Bucket.fromJSON(response.body));
}
}

View File

@ -0,0 +1,77 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:vikunja_app/models/bucket.dart';
class BucketLimitDialog extends StatefulWidget {
final Bucket bucket;
const BucketLimitDialog({Key key, @required this.bucket}) : super(key: key);
@override
State<BucketLimitDialog> createState() => _BucketLimitDialogState();
}
class _BucketLimitDialogState extends State<BucketLimitDialog> {
final _controller = TextEditingController();
@override
Widget build(BuildContext context) {
if (_controller.text.isEmpty) _controller.text = '${widget.bucket.limit}';
return AlertDialog(
title: Text('Limit for ${widget.bucket.title}'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Expanded(
child: TextField(
controller: _controller,
decoration: InputDecoration(
labelText: 'Limit: ',
helperText: 'Set limit of 0 for no limit.',
),
keyboardType: TextInputType.number,
inputFormatters: <TextInputFormatter>[
FilteringTextInputFormatter.digitsOnly,
],
onSubmitted: (text) => Navigator.of(context).pop(int.parse(text)),
),
),
Column(
children: <Widget>[
IconButton(
onPressed: () => _controller.text = '${int.parse(_controller.text) + 1}',
icon: Icon(Icons.expand_less),
),
IconButton(
onPressed: () {
final limit = int.parse(_controller.text);
_controller.text = '${limit == 0 ? 0 : (limit - 1)}';
},
icon: Icon(Icons.expand_more),
),
],
),
],
),
],
),
actions: <TextButton>[
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(0),
child: Text('Remove Limit'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(int.parse(_controller.text)),
child: Text('Done'),
)
],
);
}
}

View File

@ -0,0 +1,321 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
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';
enum DropLocation {above, below, none}
class TaskData {
final Task task;
final Size size;
TaskData(this.task, this.size);
}
class BucketTaskCard extends StatefulWidget {
final Task task;
final int index;
final DragUpdateCallback onDragUpdate;
final void Function(Task, int) onAccept;
const BucketTaskCard({
Key key,
@required this.task,
@required this.index,
@required this.onDragUpdate,
@required this.onAccept,
}) : assert(task != null),
assert(index != null),
assert(onDragUpdate != null),
assert(onAccept != null),
super(key: key);
@override
State<BucketTaskCard> createState() => _BucketTaskCardState();
}
class _BucketTaskCardState extends State<BucketTaskCard> with AutomaticKeepAliveClientMixin {
Size _cardSize;
bool _dragging = false;
DropLocation _dropLocation = DropLocation.none;
TaskData _dropData;
@override
Widget build(BuildContext context) {
super.build(context);
if (_cardSize == null) _updateCardSize(context);
final taskState = Provider.of<ListProvider>(context);
final bucket = taskState.buckets[taskState.buckets.indexWhere((b) => b.id == widget.task.bucketId)];
// default chip height: 32
const double chipHeight = 28;
const chipConstraints = BoxConstraints(maxHeight: chipHeight);
final theme = Theme.of(context);
final identifierRow = Row(
children: <Widget>[
Text(
widget.task.identifier.isNotEmpty
? '#${widget.task.identifier.substring(1)}' : '${widget.task.id}',
style: theme.textTheme.subtitle2.copyWith(
color: Colors.grey,
),
),
],
);
if (widget.task.done) {
identifierRow.children.insert(0, Container(
constraints: chipConstraints,
padding: EdgeInsets.only(right: 4),
child: FittedBox(
child: Chip(
label: Text('Done'),
labelStyle: theme.textTheme.labelLarge.copyWith(
fontWeight: FontWeight.bold,
color: theme.brightness == Brightness.dark
? Colors.black : Colors.white,
),
backgroundColor: vGreen,
),
),
));
}
final titleRow = Row(
children: <Widget>[
Expanded(
child: Text(
widget.task.title,
style: theme.textTheme.titleMedium.copyWith(
color: widget.task.textColor,
),
),
),
],
);
if (widget.task.hasDueDate) {
final duration = widget.task.dueDate.difference(DateTime.now());
final pastDue = duration.isNegative && !widget.task.done;
titleRow.children.add(Container(
constraints: chipConstraints,
padding: EdgeInsets.only(left: 4),
child: FittedBox(
child: Chip(
avatar: Icon(
Icons.calendar_month,
color: pastDue ? Colors.red : null,
),
label: Text(durationToHumanReadable(duration)),
labelStyle: theme.textTheme.labelLarge.copyWith(
color: pastDue ? Colors.red : null,
),
backgroundColor: pastDue ? Colors.red.withAlpha(20) : null,
),
),
));
}
final labelRow = Wrap(
children: <Widget>[],
spacing: 4,
runSpacing: 4,
);
widget.task.labels?.sort((a, b) => a.title.compareTo(b.title));
widget.task.labels?.asMap()?.forEach((i, label) {
labelRow.children.add(Chip(
label: Text(label.title),
labelStyle: theme.textTheme.labelLarge.copyWith(
color: label.textColor,
),
backgroundColor: label.color,
));
});
if (widget.task.hasCheckboxes) {
final checkboxStatistics = widget.task.checkboxStatistics;
final iconSize = (theme.textTheme.labelLarge.fontSize ?? 14) + 2;
labelRow.children.add(Chip(
avatar: Container(
constraints: BoxConstraints(maxHeight: iconSize, maxWidth: iconSize),
child: CircularProgressIndicator(
value: checkboxStatistics.checked / checkboxStatistics.total,
backgroundColor: Colors.grey,
),
),
label: Text(
(checkboxStatistics.checked == checkboxStatistics.total ? '' : '${checkboxStatistics.checked} of ')
+ '${checkboxStatistics.total} tasks'
),
));
}
if (widget.task.attachments != null && widget.task.attachments.isNotEmpty) {
labelRow.children.add(Chip(
label: Transform.rotate(
angle: -pi / 4.0,
child: Icon(Icons.attachment),
),
));
}
if (widget.task.description.isNotEmpty) {
labelRow.children.add(Chip(
label: Icon(Icons.notes),
));
}
final rowConstraints = BoxConstraints(minHeight: chipHeight);
final card = Card(
color: widget.task.color,
child: InkWell(
child: Theme(
data: Theme.of(context).copyWith(
// Remove enforced margins
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
child: Padding(
padding: const EdgeInsets.all(4),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Container(
constraints: rowConstraints,
child: identifierRow,
),
Padding(
padding: EdgeInsets.only(top: 4, bottom: labelRow.children.isNotEmpty ? 8 : 0),
child: Container(
constraints: rowConstraints,
child: titleRow,
),
),
labelRow,
],
),
),
),
onTap: () {
FocusScope.of(context).unfocus();
Navigator.push<Task>(
context,
MaterialPageRoute(
builder: (context) => TaskEditPage(
task: widget.task,
taskState: taskState,
),
),
);
},
),
);
return LongPressDraggable<TaskData>(
data: TaskData(widget.task, _cardSize),
maxSimultaneousDrags: taskState.taskDragging ? 0 : 1, // only one task can be dragged at a time
onDragStarted: () {
taskState.taskDragging = true;
setState(() => _dragging = true);
},
onDragUpdate: widget.onDragUpdate,
onDragEnd: (_) {
taskState.taskDragging = false;
setState(() => _dragging = false);
},
feedback: (_cardSize == null) ? SizedBox.shrink() : SizedBox.fromSize(
size: _cardSize,
child: Card(
color: card.color,
child: (card.child as InkWell).child,
elevation: (card.elevation ?? 0) + 5,
),
),
childWhenDragging: SizedBox.shrink(),
child: () {
if (_dragging || _cardSize == null) return card;
final dropBoxSize = _dropData?.size ?? _cardSize;
final dropBox = DottedBorder(
color: Colors.grey,
child: SizedBox.fromSize(size: dropBoxSize),
);
final dropAbove = taskState.taskDragging && _dropLocation == DropLocation.above;
final dropBelow = taskState.taskDragging && _dropLocation == DropLocation.below;
final DragTargetLeave<TaskData> dragTargetOnLeave = (data) => setState(() {
_dropLocation = DropLocation.none;
_dropData = null;
});
final dragTargetOnWillAccept = (TaskData data, DropLocation dropLocation) {
if (data.task.bucketId != bucket.id)
if (bucket.limit != 0 && bucket.tasks.length >= bucket.limit)
return false;
setState(() {
_dropLocation = dropLocation;
_dropData = data;
});
return true;
};
final DragTargetAccept<TaskData> dragTargetOnAccept = (data) {
final index = bucket.tasks.indexOf(widget.task);
widget.onAccept(data.task, _dropLocation == DropLocation.above ? index : index + 1);
setState(() {
_dropLocation = DropLocation.none;
_dropData = null;
});
};
return SizedBox(
width: _cardSize.width,
height: _cardSize.height + (dropAbove || dropBelow ? dropBoxSize.height + 4 : 0),
child: Stack(
children: <Widget>[
Column(
children: [
if (dropAbove) dropBox,
card,
if (dropBelow) dropBox,
],
),
Column(
children: <SizedBox>[
SizedBox(
height: (_cardSize.height / 2) + (dropAbove ? dropBoxSize.height : 0),
child: DragTarget<TaskData>(
onWillAccept: (data) => dragTargetOnWillAccept(data, DropLocation.above),
onAccept: dragTargetOnAccept,
onLeave: dragTargetOnLeave,
builder: (_, __, ___) => SizedBox.expand(),
),
),
SizedBox(
height: (_cardSize.height / 2) + (dropBelow ? dropBoxSize.height : 0),
child: DragTarget<TaskData>(
onWillAccept: (data) => dragTargetOnWillAccept(data, DropLocation.below),
onAccept: dragTargetOnAccept,
onLeave: dragTargetOnLeave,
builder: (_, __, ___) => SizedBox.expand(),
),
),
],
),
],
),
);
}(),
);
}
void _updateCardSize(BuildContext context) {
SchedulerBinding.instance.addPostFrameCallback((_) {
if (mounted) setState(() {
_cardSize = context.size;
});
});
}
@override
bool get wantKeepAlive => _dragging;
}

View File

@ -0,0 +1,48 @@
import 'package:flutter/material.dart';
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';
class SliverBucketList extends StatelessWidget {
final Bucket bucket;
final DragUpdateCallback onTaskDragUpdate;
const SliverBucketList({
Key key,
@required this.bucket,
@required this.onTaskDragUpdate,
}) : assert(bucket != null),
assert(onTaskDragUpdate != null),
super(key: key);
@override
Widget build(BuildContext context) {
return SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
if (bucket.tasks == null) return null;
return index >= bucket.tasks.length ? null : BucketTaskCard(
key: ObjectKey(bucket.tasks[index]),
task: bucket.tasks[index],
index: index,
onDragUpdate: onTaskDragUpdate,
onAccept: (task, index) {
_moveTaskToBucket(context, task, index);
},
);
}),
);
}
Future<void> _moveTaskToBucket(BuildContext context, Task task, int index) {
return Provider.of<ListProvider>(context, listen: false).moveTaskToBucket(
context: context,
task: task,
newBucketId: bucket.id,
index: index,
).then((_) => ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text('${task.title} was moved to ${bucket.title} successfully!'),
)));
}
}

View File

@ -0,0 +1,47 @@
import 'package:flutter/material.dart';
class SliverBucketPersistentHeader extends StatelessWidget {
final Widget child;
final double minExtent;
final double maxExtent;
const SliverBucketPersistentHeader({
Key key,
@required this.child,
this.minExtent = 10.0,
this.maxExtent = 10.0,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return SliverPersistentHeader(
pinned: true,
delegate: _SliverBucketPersistentHeaderDelegate(child, minExtent, maxExtent),
);
}
}
class _SliverBucketPersistentHeaderDelegate extends SliverPersistentHeaderDelegate {
final Widget child;
final double min;
final double max;
_SliverBucketPersistentHeaderDelegate(this.child, this.min, this.max);
@override
Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) {
return child;
}
@override
double get maxExtent => max;
@override
double get minExtent => min;
@override
bool shouldRebuild(covariant _SliverBucketPersistentHeaderDelegate oldDelegate) {
return oldDelegate.child != child || oldDelegate.min != min || oldDelegate.max != max;
}
}

View File

@ -1,12 +1,11 @@
import 'dart:async';
import 'dart:developer';
import 'package:flutter/material.dart';
import 'package:vikunja_app/global.dart';
import 'package:provider/provider.dart';
import 'package:vikunja_app/models/task.dart';
import 'package:vikunja_app/utils/misc.dart';
import '../pages/list/task_edit.dart';
import 'package:vikunja_app/pages/list/task_edit.dart';
import 'package:vikunja_app/stores/list_store.dart';
class TaskTile extends StatefulWidget {
final Task task;
@ -30,7 +29,7 @@ class TaskTile extends StatefulWidget {
TaskTileState createState() => TaskTileState(this.task);
}
class TaskTileState extends State<TaskTile> {
class TaskTileState extends State<TaskTile> with AutomaticKeepAliveClientMixin {
Task _currentTask;
TaskTileState(this._currentTask)
@ -38,6 +37,7 @@ class TaskTileState extends State<TaskTile> {
@override
Widget build(BuildContext context) {
super.build(context);
Duration durationUntilDue = _currentTask.dueDate.difference(DateTime.now());
if (_currentTask.loading) {
return ListTile(
@ -69,26 +69,32 @@ class TaskTileState extends State<TaskTile> {
// TODO: get list name of task
//TextSpan(text: widget.task.list.title+" - ", style: TextStyle(color: Colors.grey)),
TextSpan(text: widget.task.title),
]
],
style: TextStyle(
color: Theme.of(context).brightness == Brightness.dark ? Colors.white : Colors.black,
),
)
) : Text(_currentTask.title),
controlAffinity: ListTileControlAffinity.leading,
value: _currentTask.done ?? false,
subtitle: widget.showInfo && _currentTask.dueDate.year > 2 ?
Text("Due in " + durationToHumanReadable(durationUntilDue),style: TextStyle(color: durationUntilDue.isNegative ? Colors.red : null),)
subtitle: widget.showInfo && _currentTask.hasDueDate ?
Text("Due " + durationToHumanReadable(durationUntilDue), style: TextStyle(color: durationUntilDue.isNegative ? Colors.red : null),)
: _currentTask.description == null || _currentTask.description.isEmpty
? null
: Text(_currentTask.description),
secondary:
IconButton(icon: Icon(Icons.settings), onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => TaskEditPage(
task: _currentTask,
))).whenComplete(() {
widget.onEdit();
});
Navigator.push<Task>(
context,
MaterialPageRoute(
builder: (buildContext) => TaskEditPage(
task: _currentTask,
taskState: Provider.of<ListProvider>(context),
),
),
).then((task) => setState(() {
if (task != null) _currentTask = task;
})).whenComplete(() => widget.onEdit());
}),
onChanged: _change,
);
@ -107,16 +113,16 @@ class TaskTileState extends State<TaskTile> {
}
Future<Task> _updateTask(Task task, bool checked) {
// TODO use copyFrom
return VikunjaGlobal.of(context).taskService.update(Task(
id: task.id,
done: checked,
title: task.title,
description: task.description,
createdBy: task.createdBy,
dueDate: task.dueDate
));
return Provider.of<ListProvider>(context, listen: false).updateTask(
context: context,
task: task.copyWith(
done: checked,
),
);
}
@override
bool get wantKeepAlive => _currentTask != widget.task;
}
typedef Future<void> TaskChanged(Task task, bool newValue);

View File

@ -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';
@ -67,6 +68,8 @@ class VikunjaGlobalState extends State<VikunjaGlobal> {
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();

61
lib/models/bucket.dart Normal file
View File

@ -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<Task> 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<String, dynamic> 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<dynamic>)
?.map((task) => Task.fromJson(task))
?.cast<Task>()
?.toList() ?? <Task>[];
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(),
};
}

View File

@ -1,6 +1,7 @@
import 'dart:ui';
import 'package:vikunja_app/models/user.dart';
import 'package:vikunja_app/theme/constants.dart';
class Label {
final int id;
@ -13,7 +14,7 @@ class Label {
{this.id,
this.title,
this.description,
this.color,
this.color = vLabelDefaultColor,
this.created,
this.updated,
this.createdBy});
@ -39,4 +40,6 @@ class Label {
'updated': updated?.toUtc()?.toIso8601String(),
'created': created?.toUtc()?.toIso8601String(),
};
Color get textColor => color != null && color.computeLuminance() <= 0.5 ? vLabelLight : vLabelDark;
}

View File

@ -1,46 +1,63 @@
import 'package:meta/meta.dart';
import 'package:flutter/material.dart';
import 'package:json_annotation/json_annotation.dart';
import 'package:vikunja_app/components/date_extension.dart';
import 'package:vikunja_app/models/label.dart';
import 'package:vikunja_app/models/user.dart';
import 'package:vikunja_app/models/taskAttachment.dart';
import 'package:vikunja_app/utils/checkboxes_in_text.dart';
@JsonSerializable()
class Task {
int id, parentTaskId, priority, listId;
DateTime created, updated, dueDate, startDate, endDate;
List<DateTime> reminderDates;
String title, description;
bool done;
User createdBy;
Duration repeatAfter;
List<Task> subtasks;
List<Label> labels;
final int id, parentTaskId, priority, listId, bucketId;
final DateTime created, updated, dueDate, startDate, endDate;
final List<DateTime> reminderDates;
final String title, description, identifier;
final bool done;
final Color color;
final double kanbanPosition;
final User createdBy;
final Duration repeatAfter;
final List<Task> subtasks;
final List<Label> labels;
final List<TaskAttachment> attachments;
// TODO: add position(?)
// // TODO: use `late final` once upgraded to current dart version
CheckboxStatistics _checkboxStatistics;
bool loading = false;
Task(
{@required this.id,
this.title,
this.description,
this.done = false,
this.reminderDates,
this.dueDate,
this.startDate,
this.endDate,
this.parentTaskId,
this.priority,
this.repeatAfter,
this.subtasks,
this.labels,
this.created,
this.updated,
this.createdBy,
this.listId});
Task({
@required this.id,
this.title,
this.description,
this.identifier,
this.done = false,
this.reminderDates,
this.dueDate,
this.startDate,
this.endDate,
this.parentTaskId,
this.priority,
this.repeatAfter,
this.color,
this.kanbanPosition,
this.subtasks,
this.labels,
this.attachments,
this.created,
this.updated,
this.createdBy,
this.listId,
this.bucketId,
});
Task.fromJson(Map<String, dynamic> json)
: id = json['id'],
title = json['title'],
description = json['description'],
identifier = json['identifier'],
done = json['done'],
reminderDates = (json['reminder_dates'] as List<dynamic>)
?.map((ts) => DateTime.parse(ts))
@ -52,6 +69,12 @@ class Task {
parentTaskId = json['parent_task_id'],
priority = json['priority'],
repeatAfter = Duration(seconds: json['repeat_after']),
color = json['hex_color'] == ''
? null
: new Color(int.parse(json['hex_color'], radix: 16) + 0xFF000000),
kanbanPosition = json['kanban_position'] is int
? json['kanban_position'].toDouble()
: json['kanban_position'],
labels = (json['labels'] as List<dynamic>)
?.map((label) => Label.fromJson(label))
?.cast<Label>()
@ -60,9 +83,14 @@ class Task {
?.map((subtask) => Task.fromJson(subtask))
?.cast<Task>()
?.toList(),
attachments = (json['attachments'] as List<dynamic>)
?.map((attachment) => TaskAttachment.fromJSON(attachment))
?.cast<TaskAttachment>()
?.toList(),
updated = DateTime.parse(json['updated']),
created = DateTime.parse(json['created']),
listId = json['list_id'],
bucketId = json['bucket_id'],
createdBy = json['created_by'] == null
? null
: User.fromJson(json['created_by']);
@ -71,6 +99,7 @@ class Task {
'id': id,
'title': title,
'description': description,
'identifier': identifier,
'done': done ?? false,
'reminder_dates':
reminderDates?.map((date) => date?.toUtc()?.toIso8601String())?.toList(),
@ -79,10 +108,79 @@ class Task {
'end_date': endDate?.toUtc()?.toIso8601String(),
'priority': priority,
'repeat_after': repeatAfter?.inSeconds,
'hex_color': color?.value?.toRadixString(16)?.padLeft(8, '0')?.substring(2),
'kanban_position': kanbanPosition,
'labels': labels?.map((label) => label.toJSON())?.toList(),
'subtasks': subtasks?.map((subtask) => subtask.toJSON())?.toList(),
'attachments': attachments?.map((attachment) => attachment.toJSON())?.toList(),
'bucket_id': bucketId,
'created_by': createdBy?.toJSON(),
'updated': updated?.toUtc()?.toIso8601String(),
'created': created?.toUtc()?.toIso8601String(),
};
Color get textColor => color != null
? color.computeLuminance() > 0.5 ? Colors.black : Colors.white
: null;
CheckboxStatistics get checkboxStatistics {
if (_checkboxStatistics != null)
return _checkboxStatistics;
if (description.isEmpty)
return null;
_checkboxStatistics = getCheckboxStatistics(description);
return _checkboxStatistics;
}
bool get hasCheckboxes {
final checkboxStatistics = this.checkboxStatistics;
if (checkboxStatistics != null && checkboxStatistics.total != 0)
return true;
else
return false;
}
bool get hasDueDate => dueDate.year != 1;
Task copyWith({
int id, int parentTaskId, int priority, int listId, int bucketId,
DateTime created, DateTime updated, DateTime dueDate, DateTime startDate, DateTime endDate,
List<DateTime> reminderDates,
String title, String description, String identifier,
bool done,
Color color,
bool resetColor,
double kanbanPosition,
User createdBy,
Duration repeatAfter,
List<Task> subtasks,
List<Label> labels,
List<TaskAttachment> attachments,
}) {
return Task(
id: id ?? this.id,
parentTaskId: parentTaskId ?? this.parentTaskId,
priority: priority ?? this.priority,
listId: listId ?? this.listId,
bucketId: bucketId ?? this.bucketId,
created: created ?? this.created,
updated: updated ?? this.updated,
dueDate: dueDate ?? this.dueDate,
startDate: startDate ?? this.startDate,
endDate: endDate ?? this.endDate,
reminderDates: reminderDates ?? this.reminderDates,
title: title ?? this.title,
description: description ?? this.description,
identifier: identifier ?? this.identifier,
done: done ?? this.done,
color: (resetColor ?? false) ? null : (color ?? this.color),
kanbanPosition: kanbanPosition ?? this.kanbanPosition,
createdBy: createdBy ?? this.createdBy,
repeatAfter: repeatAfter ?? this.repeatAfter,
subtasks: subtasks ?? this.subtasks,
labels: labels ?? this.labels,
attachments: attachments ?? this.attachments,
);
}
}

View File

@ -0,0 +1,34 @@
import 'package:flutter/material.dart';
import 'package:json_annotation/json_annotation.dart';
import 'package:vikunja_app/models/user.dart';
@JsonSerializable()
class TaskAttachment {
int id, taskId;
DateTime created;
User createdBy;
// TODO: add file
TaskAttachment({
@required this.id,
@required this.taskId,
this.created,
this.createdBy,
});
TaskAttachment.fromJSON(Map<String, dynamic> json)
: id = json['id'],
taskId = json['task_id'],
created = DateTime.parse(json['created']),
createdBy = json['created_by'] == null
? null
: User.fromJson(json['created_by']);
toJSON() => {
'id': id,
'task_id': taskId,
'created': created?.toUtc()?.toIso8601String(),
'created_by': createdBy?.toJSON(),
};
}

View File

@ -1,16 +1,37 @@
import 'dart:async';
import 'dart:math';
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:provider/provider.dart';
import 'package:dotted_border/dotted_border.dart';
import 'package:flutter_keyboard_visibility/flutter_keyboard_visibility.dart';
import 'package:vikunja_app/components/AddDialog.dart';
import 'package:vikunja_app/components/TaskTile.dart';
import 'package:vikunja_app/components/SliverBucketList.dart';
import 'package:vikunja_app/components/SliverBucketPersistentHeader.dart';
import 'package:vikunja_app/components/BucketLimitDialog.dart';
import 'package:vikunja_app/components/BucketTaskCard.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';
import 'package:vikunja_app/utils/calculate_item_position.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 TaskList taskList;
@ -23,12 +44,18 @@ class ListPage extends StatefulWidget {
}
class _ListPageState extends State<ListPage> {
final _keyboardController = KeyboardVisibilityController();
int _viewIndex = 0;
TaskList _list;
List<Task> _loadingTasks = [];
int _currentPage = 1;
bool _loading = true;
bool displayDoneTasks;
ListProvider taskState;
PageController _pageController;
Map<int, BucketProps> _bucketProps = {};
int _draggedBucketIndex;
Duration _lastTaskDragUpdateAction = Duration.zero;
@override
void initState() {
@ -37,6 +64,9 @@ class _ListPageState extends State<ListPage> {
title: widget.taskList.title,
tasks: [],
);
_keyboardController.onChange.listen((visible) {
if (!visible && mounted) FocusScope.of(context).unfocus();
});
super.initState();
Future.delayed(Duration.zero, (){
_loadList();
@ -46,88 +76,494 @@ class _ListPageState extends State<ListPage> {
@override
Widget build(BuildContext context) {
taskState = Provider.of<ListProvider>(context);
return Scaffold(
appBar: AppBar(
title: Text(_list.title),
actions: <Widget>[
IconButton(
icon: Icon(Icons.edit),
onPressed: () => Navigator.push(
context,
MaterialPageRoute(
builder: (context) => ListEditPage(
list: _list,
),
)).whenComplete(() => _loadList()),
),
],
),
// TODO: it brakes the flow with _loadingTasks and conflicts with the provider
body: !taskState.isLoading
? RefreshIndicator(
child: taskState.tasks.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);
return GestureDetector(
onTap: () => FocusScope.of(context).unfocus(),
child: Scaffold(
appBar: AppBar(
title: Text(_list.title),
actions: <Widget>[
IconButton(
icon: Icon(Icons.edit),
onPressed: () => Navigator.push(
context,
MaterialPageRoute(
builder: (context) => ListEditPage(
list: _list,
),
)).whenComplete(() => _loadList()),
),
],
),
// TODO: it brakes the flow with _loadingTasks and conflicts with the provider
body: !taskState.isLoading
? RefreshIndicator(
child: 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 _kanbanView(context);
default:
return _listView(context);
}
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;
}),
)
: Center(child: Text('This list is empty.')),
onRefresh: _loadList,
)
: Center(child: CircularProgressIndicator()),
floatingActionButton: Builder(
builder: (context) => FloatingActionButton(
onPressed: () => _addItemDialog(context), child: Icon(Icons.add)),
}(),
),
)
: Center(child: Text('This list is empty.')),
onRefresh: _loadList,
)
: Center(child: CircularProgressIndicator()),
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,
),
),
);
}
TaskTile _buildTile(Task task) {
return TaskTile(
task: task,
loading: false,
onEdit: () {
/*Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
TaskEditPage(
task: task,
),
void _onViewTapped(int 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;
}
);
}
Widget _kanbanView(BuildContext context) {
final deviceData = MediaQuery.of(context);
final portrait = deviceData.orientation == Orientation.portrait;
final bucketFraction = portrait ? 0.8 : 0.4;
final bucketWidth = deviceData.size.width * bucketFraction;
if (_pageController == null) _pageController = PageController(viewportFraction: bucketFraction);
else if (_pageController.viewportFraction != bucketFraction)
_pageController = PageController(viewportFraction: bucketFraction);
return ReorderableListView.builder(
scrollDirection: Axis.horizontal,
scrollController: _pageController,
physics: PageScrollPhysics(),
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
itemCount: taskState.buckets.length,
buildDefaultDragHandles: false,
itemBuilder: (context, index) {
if (index > taskState.buckets.length) return null;
return ReorderableDelayedDragStartListener(
key: ValueKey<int>(index),
index: index,
enabled: taskState.buckets.length > 1 && !taskState.taskDragging,
child: SizedBox(
width: bucketWidth,
child: _buildBucketTile(taskState.buckets[index], portrait),
),
);*/
},
onMarkedAsDone: (done) {
Provider.of<ListProvider>(context, listen: false).updateTask(
context: context,
id: task.id,
done: done,
);
},
proxyDecorator: (child, index, animation) {
return AnimatedBuilder(
animation: animation,
child: child,
builder: (context, child) {
return Transform.scale(
scale: lerpDouble(1.0, 0.75, Curves.easeInOut.transform(animation.value)),
child: child,
);
},
);
},
footer: _draggedBucketIndex != null ? null : SizedBox(
width: deviceData.size.width * (1 - bucketFraction) * (portrait ? 1 : 2),
child: Align(
alignment: Alignment.topLeft,
child: Padding(
padding: EdgeInsets.only(
top: portrait ? 14 : 5,
),
child: RotatedBox(
quarterTurns: portrait ? 1 : 0,
child: ElevatedButton.icon(
onPressed: () => _addBucketDialog(context),
label: Text('Create Bucket'),
icon: Icon(Icons.add),
),
),
),
),
),
onReorderStart: (oldIndex) {
FocusScope.of(context).unfocus();
setState(() => _draggedBucketIndex = oldIndex);
},
onReorder: (_, __) {},
onReorderEnd: (newIndex) async {
bool indexUpdated = false;
if (newIndex > _draggedBucketIndex) {
newIndex -= 1;
indexUpdated = true;
}
final movedBucket = taskState.buckets.removeAt(_draggedBucketIndex);
if (newIndex >= taskState.buckets.length) {
taskState.buckets.add(movedBucket);
} else {
taskState.buckets.insert(newIndex, movedBucket);
}
taskState.buckets[newIndex].position = calculateItemPosition(
positionBefore: newIndex != 0
? taskState.buckets[newIndex - 1].position : null,
positionAfter: newIndex < taskState.buckets.length - 1
? taskState.buckets[newIndex + 1].position : null,
);
await _updateBucket(context, taskState.buckets[newIndex]);
// make sure the first 2 buckets don't have 0 position
if (newIndex == 0 && taskState.buckets.length > 1 && taskState.buckets[1].position == 0) {
taskState.buckets[1].position = calculateItemPosition(
positionBefore: taskState.buckets[0].position,
positionAfter: 1 < taskState.buckets.length - 1
? taskState.buckets[2].position : null,
);
_updateBucket(context, taskState.buckets[1]);
}
if (indexUpdated && portrait) _pageController.animateToPage(
newIndex - 1,
duration: Duration(milliseconds: 100),
curve: Curves.easeInOut,
);
setState(() => _draggedBucketIndex = null);
},
);
}
Widget _buildTile(Task task) {
return ListenableProvider.value(
value: taskState,
child: TaskTile(
task: task,
loading: false,
onEdit: () {},
onMarkedAsDone: (done) {
Provider.of<ListProvider>(context, listen: false).updateTask(
context: context,
task: task.copyWith(done: done),
);
},
),
);
}
Widget _buildBucketTile(Bucket bucket, bool portrait) {
final theme = Theme.of(context);
const bucketTitleHeight = 56.0;
final addTaskButton = ElevatedButton.icon(
icon: Icon(Icons.add),
label: Text('Add Task'),
onPressed: bucket.limit == 0 || bucket.tasks.length < bucket.limit
? () {
FocusScope.of(context).unfocus();
_addItemDialog(context, bucket);
}
: null,
);
if (_bucketProps[bucket.id] == null)
_bucketProps[bucket.id] = BucketProps();
if (_bucketProps[bucket.id].bucketLength != (bucket.tasks.length)
|| _bucketProps[bucket.id].portrait != portrait)
SchedulerBinding.instance.addPostFrameCallback((_) {
if (_bucketProps[bucket.id].controller.hasClients) setState(() {
_bucketProps[bucket.id].bucketLength = bucket.tasks.length;
_bucketProps[bucket.id].scrollable = _bucketProps[bucket.id].controller.position.maxScrollExtent > 0;
_bucketProps[bucket.id].portrait = portrait;
});
});
if (_bucketProps[bucket.id].titleController.text.isEmpty)
_bucketProps[bucket.id].titleController.text = bucket.title;
return Stack(
children: <Widget>[
CustomScrollView(
controller: _bucketProps[bucket.id].controller,
slivers: <Widget>[
SliverBucketPersistentHeader(
minExtent: bucketTitleHeight,
maxExtent: bucketTitleHeight,
child: Material(
color: theme.scaffoldBackgroundColor,
child: ListTile(
minLeadingWidth: 15,
horizontalTitleGap: 4,
contentPadding: const EdgeInsets.only(left: 16, right: 10),
leading: bucket.isDoneBucket ? Icon(
Icons.done_all,
color: Colors.green,
) : null,
title: Row(
children: <Widget>[
Expanded(
child: TextField(
controller: _bucketProps[bucket.id].titleController,
decoration: const InputDecoration.collapsed(
hintText: 'Bucket Title',
),
style: theme.textTheme.titleLarge,
onSubmitted: (title) {
if (title.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(
'Bucket title cannot be empty!',
style: TextStyle(color: Colors.red),
),
));
return;
}
bucket.title = title;
_updateBucket(context, bucket);
},
),
),
if (bucket.limit != 0) Padding(
padding: const EdgeInsets.only(right: 2),
child: Text(
'${bucket.tasks.length}/${bucket.limit}',
style: theme.textTheme.titleMedium.copyWith(
color: bucket.limit != 0 && bucket.tasks.length >= bucket.limit
? Colors.red : null,
),
),
),
],
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Icon(Icons.drag_handle),
PopupMenuButton<BucketMenu>(
child: Icon(Icons.more_vert),
onSelected: (item) {
switch (item) {
case BucketMenu.limit:
showDialog<int>(context: context,
builder: (_) => BucketLimitDialog(
bucket: bucket,
),
).then((limit) {
if (limit != null) {
bucket.limit = limit;
_updateBucket(context, bucket);
}
});
break;
case BucketMenu.done:
bucket.isDoneBucket = !bucket.isDoneBucket;
_updateBucket(context, bucket);
break;
case BucketMenu.delete:
_deleteBucket(context, bucket);
}
},
itemBuilder: (context) {
final enableDelete = taskState.buckets.length > 1;
return <PopupMenuEntry<BucketMenu>>[
PopupMenuItem<BucketMenu>(
value: BucketMenu.limit,
child: Text('Limit: ${bucket.limit}'),
),
PopupMenuItem<BucketMenu>(
value: BucketMenu.done,
child: Row(
children: <Widget>[
Padding(
padding: const EdgeInsets.only(right: 4),
child: Icon(
Icons.done_all,
color: bucket.isDoneBucket ? Colors.green : null,
),
),
Text('Done Bucket'),
],
),
),
const PopupMenuDivider(),
PopupMenuItem<BucketMenu>(
value: BucketMenu.delete,
enabled: enableDelete,
child: Row(
children: <Widget>[
Icon(
Icons.delete,
color: enableDelete ? Colors.red : null,
),
Text(
'Delete',
style: enableDelete ? TextStyle(color: Colors.red) : null,
),
],
),
),
];
},
),
],
),
),
),
),
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 8),
sliver: ListenableProvider.value(
value: taskState,
child: SliverBucketList(
bucket: bucket,
onTaskDragUpdate: (details) { // scroll when dragging a task
if (details.sourceTimeStamp - _lastTaskDragUpdateAction > const Duration(milliseconds: 600)) {
final screenSize = MediaQuery.of(context).size;
const scrollDuration = Duration(milliseconds: 250);
const scrollCurve = Curves.easeInOut;
final updateAction = () => setState(() => _lastTaskDragUpdateAction = details.sourceTimeStamp);
if (details.globalPosition.dx < screenSize.width * 0.1) { // scroll left
if (_pageController.position.extentBefore != 0)
_pageController.previousPage(duration: scrollDuration, curve: scrollCurve);
updateAction();
} else if (details.globalPosition.dx > screenSize.width * 0.9) { // scroll right
if (_pageController.position.extentAfter != 0)
_pageController.nextPage(duration: scrollDuration, curve: scrollCurve);
updateAction();
} else {
final viewingBucket = taskState.buckets[_pageController.page.floor()];
final bucketController = _bucketProps[viewingBucket.id].controller;
if (details.globalPosition.dy < screenSize.height * 0.2) { // scroll up
if (bucketController.position.extentBefore != 0)
bucketController.animateTo(bucketController.offset - 80,
duration: scrollDuration, curve: scrollCurve);
updateAction();
} else if (details.globalPosition.dy > screenSize.height * 0.8) { // scroll down
if (bucketController.position.extentAfter != 0)
bucketController.animateTo(bucketController.offset + 80,
duration: scrollDuration, curve: scrollCurve);
updateAction();
}
}
}
},
),
),
),
SliverVisibility(
visible: !_bucketProps[bucket.id].scrollable,
maintainState: true,
maintainAnimation: true,
maintainSize: true,
sliver: SliverFillRemaining(
hasScrollBody: false,
child: Stack(
children: <Widget>[
Column(
children: <Widget>[
if (_bucketProps[bucket.id].taskDropSize != null) DottedBorder(
color: Colors.grey,
child: SizedBox.fromSize(size: _bucketProps[bucket.id].taskDropSize),
),
Align(
alignment: Alignment.topCenter,
child: addTaskButton,
),
],
),
// DragTarget to drop tasks in empty buckets
if (bucket.tasks.length == 0) DragTarget<TaskData>(
onWillAccept: (data) {
setState(() => _bucketProps[bucket.id].taskDropSize = data.size);
return true;
},
onAccept: (data) {
Provider.of<ListProvider>(context, listen: false).moveTaskToBucket(
context: context,
task: data.task,
newBucketId: bucket.id,
index: 0,
).then((_) => ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text('${data.task.title} was moved to ${bucket.title} successfully!'),
)));
setState(() => _bucketProps[bucket.id].taskDropSize = null);
},
onLeave: (_) => setState(() => _bucketProps[bucket.id].taskDropSize = null),
builder: (_, __, ___) => SizedBox.expand(),
),
],
),
),
),
],
),
if (_bucketProps[bucket.id].scrollable) Align(
alignment: Alignment.bottomCenter,
child: addTaskButton,
),
],
);
}
@ -145,6 +581,7 @@ class _ListPageState extends State<ListPage> {
MaterialPageRoute(
builder: (context) => TaskEditPage(
task: task,
taskState: taskState,
),
),
),
@ -152,11 +589,27 @@ class _ListPageState extends State<ListPage> {
}
Future<void> _loadList() async {
updateDisplayDoneTasks().then((value) => _loadTasksForPage(1));
updateDisplayDoneTasks().then((value) async {
switch (_viewIndex) {
case 0:
_loadTasksForPage(1);
break;
case 1:
await _loadBucketsForPage(1);
// load all buckets to get length for RecordableListView
while (_currentPage < taskState.maxPages) {
_currentPage++;
await _loadBucketsForPage(_currentPage);
}
break;
default:
_loadTasksForPage(1);
}
});
}
void _loadTasksForPage(int page) {
Provider.of<ListProvider>(context, listen: false).loadTasks(
Future<void> _loadTasksForPage(int page) {
return Provider.of<ListProvider>(context, listen: false).loadTasks(
context: context,
listId: _list.id,
page: page,
@ -164,29 +617,38 @@ class _ListPageState extends State<ListPage> {
);
}
_addItemDialog(BuildContext context) {
showDialog(
Future<void> _loadBucketsForPage(int page) {
return Provider.of<ListProvider>(context, listen: false).loadBuckets(
context: context,
listId: _list.id,
page: page
);
}
Future<void> _addItemDialog(BuildContext context, [Bucket bucket]) {
return showDialog(
context: context,
builder: (_) => AddDialog(
onAdd: (title) => _addItem(title, context),
onAdd: (title) => _addItem(title, context, bucket),
decoration: InputDecoration(
labelText: 'Task Name',
labelText: (bucket != null ? '${bucket.title}: ' : '') + 'New Task Name',
hintText: 'eg. Milk',
),
),
);
}
_addItem(String title, BuildContext context) {
Future<void> _addItem(String title, BuildContext context, [Bucket bucket]) {
var globalState = VikunjaGlobal.of(context);
var newTask = Task(
id: null,
title: title,
createdBy: globalState.currentUser,
done: false,
bucketId: bucket?.id,
);
setState(() => _loadingTasks.add(newTask));
Provider.of<ListProvider>(context, listen: false)
return Provider.of<ListProvider>(context, listen: false)
.addTask(
context: context,
newTask: newTask,
@ -194,11 +656,73 @@ class _ListPageState extends State<ListPage> {
)
.then((_) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text('The task was added successfully!'),
content: Text('The task was added successfully' + (bucket != null ? ' to ${bucket.title}' : '') + '!'),
));
setState(() {
_loadingTasks.remove(newTask);
});
});
}
Future<void> _addBucketDialog(BuildContext context) {
FocusScope.of(context).unfocus();
return showDialog(
context: context,
builder: (_) => AddDialog(
onAdd: (title) => _addBucket(title, context),
decoration: InputDecoration(
labelText: 'New Bucket Name',
hintText: 'eg. To Do',
),
)
);
}
Future<void> _addBucket(String title, BuildContext context) {
return Provider.of<ListProvider>(context, listen: false).addBucket(
context: context,
newBucket: Bucket(
id: null,
title: title,
createdBy: VikunjaGlobal.of(context).currentUser,
listId: _list.id,
),
listId: _list.id,
).then((_) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text('The bucket was added successfully!'),
));
setState(() {});
});
}
Future<void> _updateBucket(BuildContext context, Bucket bucket) {
return Provider.of<ListProvider>(context, listen: false).updateBucket(
context: context,
bucket: bucket,
).then((_) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text('${bucket.title} bucket updated successfully!'),
));
setState(() {});
});
}
Future<void> _deleteBucket(BuildContext context, Bucket bucket) async {
await Provider.of<ListProvider>(context, listen: false).deleteBucket(
context: context,
listId: bucket.listId,
bucketId: bucket.id,
).then((_) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Row(
children: <Widget>[
Text('${bucket.title} was deleted.'),
Icon(Icons.delete),
],
),
));
});
_onViewTapped(1);
}
}

View File

@ -2,19 +2,25 @@ import 'dart:developer';
import 'package:flutter/material.dart';
import 'package:flutter_typeahead/flutter_typeahead.dart';
import 'package:flutter_colorpicker/flutter_colorpicker.dart';
import 'package:vikunja_app/components/datetimePicker.dart';
import 'package:vikunja_app/components/label.dart';
import 'package:vikunja_app/global.dart';
import 'package:vikunja_app/models/label.dart';
import 'package:vikunja_app/models/task.dart';
import 'package:vikunja_app/theme/button.dart';
import 'package:vikunja_app/theme/buttonText.dart';
import 'package:vikunja_app/stores/list_store.dart';
import 'package:vikunja_app/utils/repeat_after_parse.dart';
class TaskEditPage extends StatefulWidget {
final Task task;
final ListProvider taskState;
TaskEditPage({this.task}) : super(key: Key(task.toString()));
TaskEditPage({
@required this.task,
@required this.taskState,
}) : assert(task != null),
assert(taskState != null),
super(key: Key(task.toString()));
@override
State<StatefulWidget> createState() => _TaskEditPageState();
@ -22,6 +28,7 @@ class TaskEditPage extends StatefulWidget {
class _TaskEditPageState extends State<TaskEditPage> {
final _formKey = GlobalKey<FormState>();
final _listKey = GlobalKey();
bool _loading = false;
bool _changed = false;
@ -35,6 +42,9 @@ class _TaskEditPageState extends State<TaskEditPage> {
List<Label> _suggestedLabels;
var _reminderInputs = <Widget>[];
final _labelTypeAheadController = TextEditingController();
Color _color;
Color _pickerColor;
bool _resetColor = false;
@override
Widget build(BuildContext ctx) {
@ -44,169 +54,174 @@ class _TaskEditPageState extends State<TaskEditPage> {
_reminderDates?.asMap()?.forEach((i, time) =>
setState(() => _reminderInputs?.add(VikunjaDateTimePicker(
initialValue: time,
label: 'Reminder',
onSaved: (reminder) => _reminderDates[i] = reminder,
))));
initialValue: time,
label: 'Reminder',
onSaved: (reminder) => _reminderDates[i] = reminder,
)))
);
}
if (_labels == null) {
_labels = widget.task.labels ?? [];
}
return WillPopScope(onWillPop: () {
if(_changed) {
return _showConfirmationDialog();
}
return new Future(() => true);
},
child: Scaffold(
appBar: AppBar(
title: Text('Edit Task'),
),
body: Builder(
builder: (BuildContext context) => SafeArea(
child: Form(
key: _formKey,
child: ListView(padding: const EdgeInsets.all(16.0), children: <
Widget>[
Padding(
padding: EdgeInsets.symmetric(vertical: 10.0),
child: TextFormField(
maxLines: null,
keyboardType: TextInputType.multiline,
initialValue: widget.task.title,
onSaved: (title) => _title = title,
onChanged: (_) => _changed = true,
validator: (title) {
if (title.length < 3 || title.length > 250) {
return 'The title needs to have between 3 and 250 characters.';
}
return null;
},
decoration: new InputDecoration(
labelText: 'Title',
border: OutlineInputBorder(),
),
),
),
Padding(
padding: EdgeInsets.symmetric(vertical: 10.0),
child: TextFormField(
maxLines: null,
keyboardType: TextInputType.multiline,
initialValue: widget.task.description,
onSaved: (description) => _description = description,
onChanged: (_) => _changed = true,
validator: (description) {
if (description.length > 1000) {
return 'The description can have a maximum of 1000 characters.';
}
return null;
},
decoration: new InputDecoration(
labelText: 'Description',
border: OutlineInputBorder(),
),
),
),
VikunjaDateTimePicker(
icon: Icon(Icons.access_time),
label: 'Due Date',
initialValue: widget.task.dueDate,
onSaved: (duedate) => _dueDate = duedate,
onChanged: (_) => _changed = true,
),
VikunjaDateTimePicker(
label: 'Start Date',
initialValue: widget.task.startDate,
onSaved: (startDate) => _startDate = startDate,
onChanged: (_) => _changed = true,
),
VikunjaDateTimePicker(
label: 'End Date',
initialValue: widget.task.endDate,
onSaved: (endDate) => _endDate = endDate,
onChanged: (_) => _changed = true,
),
Row(
children: [
Expanded(
flex: 2,
child: TextFormField(
keyboardType: TextInputType.number,
initialValue: getRepeatAfterValueFromDuration(
widget.task.repeatAfter)
?.toString(),
onSaved: (repeatAfter) => _repeatAfter =
getDurationFromType(repeatAfter, _repeatAfterType),
onChanged: (_) => _changed = true,
decoration: new InputDecoration(
labelText: 'Repeat after',
border: InputBorder.none,
icon: Icon(Icons.repeat),
return WillPopScope(
onWillPop: () {
if(_changed) {
return _showConfirmationDialog();
}
return new Future(() => true);
},
child: GestureDetector(
onTap: () => FocusScope.of(context).unfocus(),
child: Scaffold(
appBar: AppBar(
title: Text('Edit Task'),
),
body: Builder(
builder: (BuildContext context) => SafeArea(
child: Form(
key: _formKey,
child: ListView(
key: _listKey,
padding: const EdgeInsets.all(16.0),
children: <Widget>[
Padding(
padding: EdgeInsets.symmetric(vertical: 10.0),
child: TextFormField(
maxLines: null,
keyboardType: TextInputType.multiline,
initialValue: widget.task.title,
onSaved: (title) => _title = title,
onChanged: (_) => _changed = true,
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(),
),
),
),
),
Expanded(
child: DropdownButton<String>(
isExpanded: true,
isDense: true,
value: _repeatAfterType ??
getRepeatAfterTypeFromDuration(
widget.task.repeatAfter),
onChanged: (String newValue) {
setState(() {
_repeatAfterType = newValue;
});
},
items: <String>[
'Hours',
'Days',
'Weeks',
'Months',
'Years'
].map<DropdownMenuItem<String>>((String value) {
return DropdownMenuItem<String>(
value: value,
child: Text(value),
);
}).toList(),
Padding(
padding: EdgeInsets.symmetric(vertical: 10.0),
child: TextFormField(
maxLines: null,
keyboardType: TextInputType.multiline,
initialValue: widget.task.description,
onSaved: (description) => _description = description,
onChanged: (_) => _changed = true,
validator: (description) {
if (description.length > 1000) {
return 'The description can have a maximum of 1000 characters.';
}
return null;
},
decoration: new InputDecoration(
labelText: 'Description',
border: OutlineInputBorder(),
),
),
),
),
],
),
Column(
children: _reminderInputs,
),
GestureDetector(
child: Padding(
padding: EdgeInsets.symmetric(vertical: 10),
child: Row(
children: <Widget>[
Padding(
padding: EdgeInsets.only(right: 15, left: 2),
child: Icon(
Icons.alarm_add,
color: Colors.grey,
)),
Text(
'Add a reminder',
style: TextStyle(
color: Colors.grey,
fontSize: 16,
VikunjaDateTimePicker(
icon: Icon(Icons.access_time),
label: 'Due Date',
initialValue: widget.task.dueDate,
onSaved: (duedate) => _dueDate = duedate,
onChanged: (_) => _changed = true,
),
VikunjaDateTimePicker(
label: 'Start Date',
initialValue: widget.task.startDate,
onSaved: (startDate) => _startDate = startDate,
onChanged: (_) => _changed = true,
),
VikunjaDateTimePicker(
label: 'End Date',
initialValue: widget.task.endDate,
onSaved: (endDate) => _endDate = endDate,
onChanged: (_) => _changed = true,
),
Row(
children: [
Expanded(
flex: 2,
child: TextFormField(
keyboardType: TextInputType.number,
initialValue: getRepeatAfterValueFromDuration(
widget.task.repeatAfter)?.toString(),
onSaved: (repeatAfter) => _repeatAfter =
getDurationFromType(repeatAfter, _repeatAfterType),
onChanged: (_) => _changed = true,
decoration: new InputDecoration(
labelText: 'Repeat after',
border: InputBorder.none,
icon: Icon(Icons.repeat),
),
),
),
Expanded(
child: DropdownButton<String>(
isExpanded: true,
isDense: true,
value: _repeatAfterType ??
getRepeatAfterTypeFromDuration(
widget.task.repeatAfter),
onChanged: (String newValue) {
setState(() {
_repeatAfterType = newValue;
});
},
items: <String>[
'Hours',
'Days',
'Weeks',
'Months',
'Years'
].map<DropdownMenuItem<String>>((String value) {
return DropdownMenuItem<String>(
value: value,
child: Text(value),
);
}).toList(),
),
),
],
),
),
onTap: () {
// We add a new entry every time we add a new input, to make sure all inputs have a place where they can put their value.
_reminderDates.add(null);
var currentIndex = _reminderDates.length - 1;
Column(
children: _reminderInputs,
),
GestureDetector(
child: Padding(
padding: EdgeInsets.symmetric(vertical: 10),
child: Row(
children: <Widget>[
Padding(
padding: EdgeInsets.only(right: 15, left: 2),
child: Icon(
Icons.alarm_add,
color: Colors.grey,
)),
Text(
'Add a reminder',
style: TextStyle(
color: Colors.grey,
fontSize: 16,
),
),
],
),
),
onTap: () {
// We add a new entry every time we add a new input, to make sure all inputs have a place where they can put their value.
_reminderDates.add(null);
var currentIndex = _reminderDates.length - 1;
// FIXME: Why does putting this into a row fails?
setState(() => _reminderInputs.add(
// FIXME: Why does putting this into a row fails?
setState(() => _reminderInputs.add(
VikunjaDateTimePicker(
label: 'Reminder',
onSaved: (reminder) =>
@ -214,117 +229,156 @@ class _TaskEditPageState extends State<TaskEditPage> {
onChanged: (_) => _changed = true,
initialValue: DateTime.now(),
),
));
}),
InputDecorator(
isEmpty: _priority == null,
decoration: InputDecoration(
icon: const Icon(Icons.flag),
labelText: 'Priority',
border: InputBorder.none,
),
child: new DropdownButton<String>(
value: _priorityToString(_priority),
isExpanded: true,
isDense: true,
onChanged: (String newValue) {
setState(() {
_priority = _priorityFromString(newValue);
});
},
items: ['Unset', 'Low', 'Medium', 'High', 'Urgent', 'DO NOW']
.map((String value) {
return new DropdownMenuItem(
value: value,
child: new Text(value),
);
}).toList(),
),
),
Wrap(
spacing: 10,
children: _labels.map((Label label) {
return LabelComponent(
label: label,
onDelete: () {
_removeLabel(label);
},
);
}).toList()),
Row(
children: <Widget>[
Container(
width: MediaQuery.of(context).size.width - 80,
child: TypeAheadFormField(
textFieldConfiguration: TextFieldConfiguration(
controller: _labelTypeAheadController,
decoration:
InputDecoration(labelText: 'Add a new label')),
suggestionsCallback: (pattern) => _searchLabel(pattern),
itemBuilder: (context, suggestion) {
print(suggestion);
return new ListTile(title: Text(suggestion));
},
transitionBuilder: (context, suggestionsBox, controller) {
return suggestionsBox;
},
onSuggestionSelected: (suggestion) {
_addLabel(suggestion);
},
));
}),
InputDecorator(
isEmpty: _priority == null,
decoration: InputDecoration(
icon: const Icon(Icons.flag),
labelText: 'Priority',
border: InputBorder.none,
),
child: new DropdownButton<String>(
value: _priorityToString(_priority),
isExpanded: true,
isDense: true,
onChanged: (String newValue) {
setState(() {
_priority = _priorityFromString(newValue);
});
},
items: ['Unset', 'Low', 'Medium', 'High', 'Urgent', 'DO NOW']
.map((String value) {
return new DropdownMenuItem(
value: value,
child: new Text(value),
);
}).toList(),
),
),
),
IconButton(
onPressed: () =>
_createAndAddLabel(_labelTypeAheadController.text),
icon: Icon(Icons.add),
)
],
Wrap(
spacing: 10,
children: _labels.map((Label label) {
return LabelComponent(
label: label,
onDelete: () {
_removeLabel(label);
},
);
}).toList()),
Row(
children: <Widget>[
Container(
width: MediaQuery.of(context).size.width - 80,
child: TypeAheadFormField(
textFieldConfiguration: TextFieldConfiguration(
controller: _labelTypeAheadController,
decoration:
InputDecoration(labelText: 'Add a new label')),
suggestionsCallback: (pattern) => _searchLabel(pattern),
itemBuilder: (context, suggestion) {
print(suggestion);
return new ListTile(title: Text(suggestion));
},
transitionBuilder: (context, suggestionsBox, controller) {
return suggestionsBox;
},
onSuggestionSelected: (suggestion) {
_addLabel(suggestion);
},
),
),
IconButton(
onPressed: () =>
_createAndAddLabel(_labelTypeAheadController.text),
icon: Icon(Icons.add),
)
],
),
Padding(
padding: const EdgeInsets.only(top: 15),
child: Row(
children: <Widget>[
Padding(
padding: const EdgeInsets.only(right: 15, left: 2),
child: Icon(
Icons.palette,
color: Colors.grey,
),
),
ElevatedButton(
child: Text(
'Color',
style: _resetColor || (_color ?? widget.task.color) == null ? null : TextStyle(
color: (_color ?? widget.task.color)
.computeLuminance() > 0.5 ? Colors.black : Colors.white,
),
),
style: _resetColor ? null : ButtonStyle(
backgroundColor: MaterialStateProperty
.resolveWith((_) => _color ?? widget.task.color),
),
onPressed: _onColorEdit,
),
Padding(
padding: const EdgeInsets.only(left: 15),
child: () {
String colorString = (_resetColor ? null : (_color ?? widget.task.color))?.toString();
colorString = colorString?.substring(10, colorString.length - 1)?.toUpperCase();
colorString = colorString != null ? '#$colorString' : 'None';
return Text(
'$colorString',
style: TextStyle(
color: Colors.grey,
fontStyle: FontStyle.italic,
),
);
}(),
),
],
),
),
],
),
),
Builder(
builder: (context) => Padding(
padding: EdgeInsets.symmetric(vertical: 10.0),
child: FancyButton(
onPressed: !_loading
? () {
if (_formKey.currentState.validate()) {
Form.of(context).save();
_saveTask(context);
}
}
: null,
child: _loading
? CircularProgressIndicator()
: VikunjaButtonText('Save'),
))),
]),
),
),
floatingActionButton: FloatingActionButton(
onPressed: !_loading ? () {
if (_formKey.currentState.validate()) {
Form.of(_listKey.currentContext).save();
_saveTask(_listKey.currentContext);
}
} : null,
child: Icon(Icons.save),
),
),
),
));
);
}
_saveTask(BuildContext context) async {
_saveTask(BuildContext context) async {
setState(() => _loading = true);
// Removes all reminders with no value set.
_reminderDates.removeWhere((d) => d == null);
Task updatedTask = Task(
id: widget.task.id,
Task updatedTask = widget.task.copyWith(
title: _title,
description: _description,
done: widget.task.done,
reminderDates: _reminderDates,
createdBy: widget.task.createdBy,
dueDate: _dueDate,
startDate: _startDate,
endDate: _endDate,
priority: _priority,
repeatAfter: _repeatAfter,
labels: _labels,
color: _resetColor ? null : (_color ?? widget.task.color),
resetColor: _resetColor,
);
// update the labels
VikunjaGlobal.of(context)
await VikunjaGlobal.of(context)
.labelTaskBulkService
.update(updatedTask, _labels)
.catchError((err) {
@ -336,11 +390,15 @@ class _TaskEditPageState extends State<TaskEditPage> {
);
});
VikunjaGlobal.of(context).taskService.update(updatedTask).then((_) {
widget.taskState.updateTask(
context: context,
task: updatedTask,
).then((task) {
setState(() { _loading = false; _changed = false;});
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text('The task was updated successfully!'),
));
Navigator.of(context).pop(task);
}).catchError((err) {
setState(() => _loading = false);
ScaffoldMessenger.of(context).showSnackBar(
@ -362,15 +420,13 @@ class _TaskEditPageState extends State<TaskEditPage> {
_searchLabel(String query) {
return VikunjaGlobal.of(context)
.labelService
.getAll(query: query)
.then((labels) {
// Only show those labels which aren't already added to the task
labels.removeWhere((labelToRemove) => _labels.contains(labelToRemove));
_suggestedLabels = labels;
List<String> labelText = labels.map((label) => label.title).toList();
return labelText;
});
.labelService.getAll(query: query).then((labels) {
// Only show those labels which aren't already added to the task
labels.removeWhere((labelToRemove) => _labels.contains(labelToRemove));
_suggestedLabels = labels;
List<String> labelText = labels.map((label) => label.title).toList();
return labelText;
});
}
_addLabel(String labelTitle) {
@ -445,6 +501,61 @@ class _TaskEditPageState extends State<TaskEditPage> {
}
}
_onColorEdit() {
_pickerColor = _resetColor || (_color ?? widget.task.color) == null
? Colors.black
: _color ?? widget.task.color;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Task Color'),
content: SingleChildScrollView(
child: ColorPicker(
pickerColor: _pickerColor,
enableAlpha: false,
labelTypes: const [ColorLabelType.hsl, ColorLabelType.rgb],
paletteType: PaletteType.hslWithLightness,
hexInputBar: true,
onColorChanged: (color) => setState(() => _pickerColor = color),
),
),
actions: <TextButton>[
TextButton(
child: Text('Cancel'),
onPressed: () => Navigator.of(context).pop(),
),
TextButton(
child: Text('Reset'),
onPressed: () {
setState(() {
_color = null;
_resetColor = true;
_changed = _color != widget.task.color;
});
Navigator.of(context).pop();
},
),
TextButton(
child: Text('Ok'),
onPressed: () {
if (_pickerColor != Colors.black) setState(() {
_color = _pickerColor;
_resetColor = false;
_changed = _color != widget.task.color;
});
else setState(() {
_color = null;
_resetColor = true;
_changed = _color != widget.task.color;
});
Navigator.of(context).pop();
},
),
],
),
);
}
Future<bool> _showConfirmationDialog() async {
return showDialog<bool>(
context: context,

View File

@ -9,6 +9,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';
@ -113,6 +114,18 @@ abstract class TaskService {
int get maxPages;
}
abstract class BucketService {
// Not implemented in the Vikunja API
// Future<Bucket> get(int listId, int bucketId);
Future<Bucket> update(Bucket bucket);
Future delete(int listId, int bucketId);
Future<Bucket> add(int listId, Bucket bucket);
Future<Response> getAllByList(int listId,
[Map<String, List<String>> queryParameters]);
int get maxPages;
}
abstract class UserService {
Future<UserTokenPair> login(String username, password, {bool rememberMe = false, String totp});
Future<UserTokenPair> register(String username, email, password);

View File

@ -1,16 +1,27 @@
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';
class ListProvider with ChangeNotifier {
bool _isLoading = false;
bool _taskDragging = false;
int _maxPages = 0;
// TODO: Streams
List<Task> _tasks = [];
List<Bucket> _buckets = [];
bool get isLoading => _isLoading;
bool get taskDragging => _taskDragging;
set taskDragging(bool value) {
_taskDragging = value;
notifyListeners();
}
int get maxPages => _maxPages;
set tasks(List<Task> tasks) {
@ -20,7 +31,14 @@ class ListProvider with ChangeNotifier {
List<Task> get tasks => _tasks;
void loadTasks({BuildContext context, int listId, int page = 1, bool displayDoneTasks = true}) {
set buckets(List<Bucket> buckets) {
_buckets = buckets;
notifyListeners();
}
List<Bucket> get buckets => _buckets;
Future<void> loadTasks({BuildContext context, int listId, int page = 1, bool displayDoneTasks = true}) {
_tasks = [];
_isLoading = true;
notifyListeners();
@ -37,7 +55,7 @@ class ListProvider with ChangeNotifier {
"filter_value": ["false"]
});
}
VikunjaGlobal.of(context).taskService.getAllByList(listId, queryParams).then((response) {
return VikunjaGlobal.of(context).taskService.getAllByList(listId, queryParams).then((response) {
if (response.headers["x-pagination-total-pages"] != null) {
_maxPages = int.parse(response.headers["x-pagination-total-pages"]);
}
@ -48,6 +66,26 @@ class ListProvider with ChangeNotifier {
});
}
Future<void> loadBuckets({BuildContext context, int listId, int page = 1}) {
_buckets = [];
_isLoading = true;
notifyListeners();
Map<String, List<String>> queryParams = {
"page": [page.toString()]
};
return 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<void> addTaskByTitle(
{BuildContext context, String title, int listId}) {
var globalState = VikunjaGlobal.of(context);
@ -69,24 +107,27 @@ class ListProvider with ChangeNotifier {
Future<void> addTask({BuildContext context, Task newTask, int listId}) {
var globalState = VikunjaGlobal.of(context);
_isLoading = true;
if (newTask.bucketId == null) _isLoading = true;
notifyListeners();
return globalState.taskService.add(listId, newTask).then((task) {
_tasks.insert(0, task);
if (_tasks.isNotEmpty)
_tasks.insert(0, task);
if (_buckets.isNotEmpty) {
final bucket = _buckets[_buckets.indexWhere((b) => task.bucketId == b.id)];
if (bucket.tasks != null) {
bucket.tasks.add(task);
} else {
bucket.tasks = <Task>[task];
}
}
_isLoading = false;
notifyListeners();
});
}
void updateTask({BuildContext context, int id, bool done}) {
var globalState = VikunjaGlobal.of(context);
globalState.taskService
.update(Task(
id: id,
done: done,
))
.then((task) {
Future<Task> updateTask({BuildContext context, 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 definitly fix it later.
_tasks.asMap().forEach((i, t) {
@ -94,7 +135,87 @@ class ListProvider with ChangeNotifier {
_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({BuildContext context, Bucket newBucket, int listId}) {
notifyListeners();
return VikunjaGlobal.of(context).bucketService.add(listId, newBucket)
.then((bucket) {
_buckets.add(bucket);
notifyListeners();
});
}
Future<void> updateBucket({BuildContext context, Bucket bucket}) {
return VikunjaGlobal.of(context).bucketService.update(bucket)
.then((rBucket) {
_buckets[_buckets.indexWhere((b) => rBucket.id == b.id)] = rBucket;
_buckets.sort((a, b) => a.position.compareTo(b.position));
notifyListeners();
});
}
Future<void> deleteBucket({BuildContext context, int listId, int bucketId}) {
return VikunjaGlobal.of(context).bucketService.delete(listId, bucketId)
.then((_) {
_buckets.removeWhere((bucket) => bucket.id == bucketId);
notifyListeners();
});
}
Future<void> moveTaskToBucket({BuildContext context, Task task, int newBucketId, int index}) async {
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 ?? task.bucketId,
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,
),
));
_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,
),
));
_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();
}
}

View File

@ -21,7 +21,7 @@ const vButtonShadow = Color(0xFFb2d9ff);
const vLabelLight = Color(0xFFf2f2f2);
const vLabelDark = Color(0xFF4a4a4a);
const vLabelDefaultColor = vGreen;
const vLabelDefaultColor = Color(0xFFe8e8e8);
///////////
// Paddings

View File

@ -1,3 +1,5 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:vikunja_app/theme/constants.dart';
@ -10,15 +12,12 @@ ThemeData _buildVikunjaTheme(ThemeData base) {
primaryColor: vPrimaryDark,
primaryColorLight: vPrimary,
primaryColorDark: vBlueDark,
buttonTheme: base.buttonTheme.copyWith(
buttonColor: vPrimary,
textTheme: ButtonTextTheme.normal,
colorScheme: base.buttonTheme.colorScheme.copyWith(
// Why does this not work?
onSurface: vWhite,
onSecondary: vWhite,
background: vBlue,
),
colorScheme: base.colorScheme.copyWith(
primary: vPrimaryDark,
secondary: vPrimary,
),
floatingActionButtonTheme: base.floatingActionButtonTheme.copyWith(
foregroundColor: vWhite,
),
textTheme: base.textTheme.copyWith(
// headline: base.textTheme.headline.copyWith(
@ -32,5 +31,15 @@ ThemeData _buildVikunjaTheme(ThemeData base) {
vWhite, // This does not work, looks like a bug in Flutter: https://github.com/flutter/flutter/issues/19623
),
),
bottomNavigationBarTheme: base.bottomNavigationBarTheme.copyWith(
// Make bottomNavigationBar backgroundColor darker to provide more separation
backgroundColor: () {
final _hslColor = HSLColor.fromColor(
base.bottomNavigationBarTheme.backgroundColor
?? base.scaffoldBackgroundColor
);
return _hslColor.withLightness(max(_hslColor.lightness - 0.03, 0)).toColor();
}(),
),
);
}

View File

@ -0,0 +1,21 @@
import 'dart:math';
double calculateItemPosition({double positionBefore, double positionAfter}) {
// only
if (positionBefore == null && positionAfter == null) {
return 0;
}
// first
if (positionBefore == null && positionAfter != null) {
return positionAfter / 2;
}
// last
if (positionBefore != null && positionAfter == null) {
return positionBefore + pow(2.0, 16.0);
}
// in the middle (positionBefore != null && positionAfter != null)
return (positionBefore + positionAfter) / 2;
}

View File

@ -0,0 +1,51 @@
import 'package:meta/meta.dart';
class CheckboxStatistics {
final int total;
final int checked;
const CheckboxStatistics({
@required this.total,
@required this.checked,
});
}
class MatchedCheckboxes {
final Iterable<Match> checked;
final Iterable<Match> unchecked;
const MatchedCheckboxes({
@required this.checked,
@required this.unchecked,
});
}
MatchedCheckboxes getCheckboxesInText(String text) {
const checkedString = '[x]';
final checked = <Match>[];
final unchecked = <Match>[];
final matches = RegExp(r'[*-] \[[ x]]').allMatches(text);
for (final match in matches) {
if (match[0].endsWith(checkedString))
checked.add(match);
else
unchecked.add(match);
}
return MatchedCheckboxes(
checked: checked,
unchecked: unchecked,
);
}
CheckboxStatistics getCheckboxStatistics(String text) {
final checkboxes = getCheckboxesInText(text);
return CheckboxStatistics(
total: checkboxes.checked.length + checkboxes.unchecked.length,
checked: checkboxes.checked.length,
);
}

View File

@ -1,17 +1,21 @@
String durationToHumanReadable(Duration dur) {
var durString = '';
if(dur.inDays.abs() > 1)
return dur.inDays.toString() + " days";
if(dur.inDays.abs() == 1)
return dur.inDays.toString() + " day";
durString = dur.inDays.abs().toString() + " days";
else if(dur.inDays.abs() == 1)
durString = dur.inDays.abs().toString() + " day";
if(dur.inHours.abs() > 1)
return dur.inHours.toString() + " hours";
if(dur.inHours.abs() == 1)
return dur.inHours.toString() + " hour";
else if(dur.inHours.abs() > 1)
durString = dur.inHours.abs().toString() + " hours";
else if(dur.inHours.abs() == 1)
durString = dur.inHours.abs().toString() + " hour";
if(dur.inMinutes.abs() > 1)
return dur.inMinutes.toString() + " minutes";
if(dur.inMinutes.abs() == 1)
return dur.inMinutes.toString() + " minute";
return "under 1 minute";
else if(dur.inMinutes.abs() > 1)
durString = dur.inMinutes.abs().toString() + " minutes";
else if(dur.inMinutes.abs() == 1)
durString = dur.inMinutes.abs().toString() + " minute";
else durString = "less than a minute";
if (dur.isNegative) return durString + " ago";
return "in " + durString;
}

View File

@ -148,6 +148,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "0.7.6"
dotted_border:
dependency: "direct main"
description:
name: dotted_border
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.0+2"
fake_async:
dependency: transitive
description:
@ -174,8 +181,15 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
flutter_colorpicker:
dependency: "direct main"
description:
name: flutter_colorpicker
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.3"
flutter_keyboard_visibility:
dependency: transitive
dependency: "direct main"
description:
name: flutter_keyboard_visibility
url: "https://pub.dartlang.org"
@ -429,6 +443,20 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "1.8.1"
path_drawing:
dependency: transitive
description:
name: path_drawing
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.0"
path_parsing:
dependency: transitive
description:
name: path_parsing
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.0"
petitparser:
dependency: "direct main"
description:

View File

@ -24,6 +24,9 @@ dependencies:
petitparser: ^5.0.0
provider: ^6.0.3
webview_flutter: ^3.0.4
flutter_colorpicker: ^1.0.3
flutter_keyboard_visibility: ^5.3.0
dotted_border: ^2.0.0+2
dev_dependencies:
flutter_test: