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:
commit
0b0650de62
54
lib/api/bucket_implementation.dart
Normal file
54
lib/api/bucket_implementation.dart
Normal 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));
|
||||
}
|
||||
}
|
77
lib/components/BucketLimitDialog.dart
Normal file
77
lib/components/BucketLimitDialog.dart
Normal 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'),
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
321
lib/components/BucketTaskCard.dart
Normal file
321
lib/components/BucketTaskCard.dart
Normal 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;
|
||||
}
|
48
lib/components/SliverBucketList.dart
Normal file
48
lib/components/SliverBucketList.dart
Normal 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!'),
|
||||
)));
|
||||
}
|
||||
}
|
47
lib/components/SliverBucketPersistentHeader.dart
Normal file
47
lib/components/SliverBucketPersistentHeader.dart
Normal 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;
|
||||
}
|
||||
|
||||
}
|
|
@ -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);
|
||||
|
|
|
@ -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
61
lib/models/bucket.dart
Normal 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(),
|
||||
};
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
34
lib/models/taskAttachment.dart
Normal file
34
lib/models/taskAttachment.dart
Normal 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(),
|
||||
};
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -21,7 +21,7 @@ const vButtonShadow = Color(0xFFb2d9ff);
|
|||
|
||||
const vLabelLight = Color(0xFFf2f2f2);
|
||||
const vLabelDark = Color(0xFF4a4a4a);
|
||||
const vLabelDefaultColor = vGreen;
|
||||
const vLabelDefaultColor = Color(0xFFe8e8e8);
|
||||
|
||||
///////////
|
||||
// Paddings
|
||||
|
|
|
@ -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();
|
||||
}(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
21
lib/utils/calculate_item_position.dart
Normal file
21
lib/utils/calculate_item_position.dart
Normal 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;
|
||||
}
|
51
lib/utils/checkboxes_in_text.dart
Normal file
51
lib/utils/checkboxes_in_text.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
30
pubspec.lock
30
pubspec.lock
|
@ -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:
|
||||
|
|
|
@ -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:
|
||||
|
|
Loading…
Reference in New Issue
Block a user