1
0
mirror of https://github.com/go-vikunja/app synced 2024-05-31 17:57:14 +00:00

move tasks around buckets, some cleanup

This commit is contained in:
Paul Nettleton 2022-08-02 00:23:52 -05:00
parent 0762a7ae14
commit 1315d7812c
9 changed files with 543 additions and 197 deletions

View File

@ -1,47 +1,74 @@
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})
: assert(task != null),
super(key: key);
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(this.task);
State<BucketTaskCard> createState() => _BucketTaskCardState();
}
class _BucketTaskCardState extends State<BucketTaskCard> with AutomaticKeepAliveClientMixin {
Task _currentTask;
_BucketTaskCardState(this._currentTask)
: assert(_currentTask != null);
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;
final chipConstraints = BoxConstraints(maxHeight: chipHeight);
const chipConstraints = BoxConstraints(maxHeight: chipHeight);
final theme = Theme.of(context);
final numRow = Row(
children: <Widget>[
Text(
'#${_currentTask.id}',
'#${widget.task.id}',
style: theme.textTheme.subtitle2.copyWith(
color: Colors.grey,
),
),
],
);
if (_currentTask.done) {
if (widget.task.done) {
numRow.children.insert(0, Container(
constraints: chipConstraints,
padding: EdgeInsets.only(right: 4),
@ -63,16 +90,16 @@ class _BucketTaskCardState extends State<BucketTaskCard> with AutomaticKeepAlive
children: <Widget>[
Expanded(
child: Text(
_currentTask.title,
widget.task.title,
style: theme.textTheme.titleMedium.copyWith(
color: _currentTask.textColor,
color: widget.task.textColor,
),
),
),
],
);
final duration = _currentTask.dueDate.difference(DateTime.now());
if (_currentTask.dueDate.year > 2) {
final duration = widget.task.dueDate.difference(DateTime.now());
if (widget.task.dueDate.year > 2) {
titleRow.children.add(Container(
constraints: chipConstraints,
padding: EdgeInsets.only(left: 4),
@ -97,8 +124,8 @@ class _BucketTaskCardState extends State<BucketTaskCard> with AutomaticKeepAlive
spacing: 4,
runSpacing: 4,
);
_currentTask.labels?.sort((a, b) => a.title.compareTo(b.title));
_currentTask.labels?.asMap()?.forEach((i, label) {
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(
@ -107,9 +134,9 @@ class _BucketTaskCardState extends State<BucketTaskCard> with AutomaticKeepAlive
backgroundColor: label.color,
));
});
if (_currentTask.description.isNotEmpty) {
final uncompletedTaskCount = '* [ ]'.allMatches(_currentTask.description).length;
final completedTaskCount = '* [x]'.allMatches(_currentTask.description).length;
if (widget.task.description.isNotEmpty) {
final uncompletedTaskCount = '* [ ]'.allMatches(widget.task.description).length;
final completedTaskCount = '* [x]'.allMatches(widget.task.description).length;
final taskCount = uncompletedTaskCount + completedTaskCount;
if (taskCount > 0) {
final iconSize = (theme.textTheme.labelLarge.fontSize ?? 14) + 2;
@ -129,7 +156,7 @@ class _BucketTaskCardState extends State<BucketTaskCard> with AutomaticKeepAlive
));
}
}
if (_currentTask.attachments != null && _currentTask.attachments.isNotEmpty) {
if (widget.task.attachments != null && widget.task.attachments.isNotEmpty) {
labelRow.children.add(Chip(
label: Transform.rotate(
angle: -pi / 4.0,
@ -137,15 +164,15 @@ class _BucketTaskCardState extends State<BucketTaskCard> with AutomaticKeepAlive
),
));
}
if (_currentTask.description.isNotEmpty) {
if (widget.task.description.isNotEmpty) {
labelRow.children.add(Chip(
label: Icon(Icons.notes),
));
}
final rowConstraints = BoxConstraints(minHeight: chipHeight);
return Card(
color: _currentTask.color,
final card = Card(
color: widget.task.color,
child: InkWell(
child: Theme(
data: Theme.of(context).copyWith(
@ -153,7 +180,7 @@ class _BucketTaskCardState extends State<BucketTaskCard> with AutomaticKeepAlive
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
child: Padding(
padding: EdgeInsets.all(4),
padding: const EdgeInsets.all(4),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
@ -168,7 +195,7 @@ class _BucketTaskCardState extends State<BucketTaskCard> with AutomaticKeepAlive
),
Padding(
padding: labelRow.children.isNotEmpty
? EdgeInsets.only(top: 8) : EdgeInsets.zero,
? const EdgeInsets.only(top: 8) : EdgeInsets.zero,
child: labelRow,
),
],
@ -179,17 +206,122 @@ class _BucketTaskCardState extends State<BucketTaskCard> with AutomaticKeepAlive
FocusScope.of(context).unfocus();
Navigator.push<Task>(
context,
MaterialPageRoute(builder: (context) => TaskEditPage(
task: _currentTask,
)),
).then((task) => setState(() {
if (task != null) _currentTask = task;
}));
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.white,
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 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) {
setState(() {
_dropLocation = DropLocation.above;
_dropData = data;
});
return true;
},
onAccept: dragTargetOnAccept,
onLeave: dragTargetOnLeave,
builder: (_, __, ___) => SizedBox.expand(),
),
),
SizedBox(
height: (_cardSize.height / 2) + (dropBelow ? dropBoxSize.height : 0),
child: DragTarget<TaskData>(
onWillAccept: (data) {
setState(() {
_dropLocation = DropLocation.below;
_dropData = data;
});
return true;
},
onAccept: dragTargetOnAccept,
onLeave: dragTargetOnLeave,
builder: (_, __, ___) => SizedBox.expand(),
),
),
],
),
],
),
);
}(),
);
}
void _updateCardSize(BuildContext context) {
SchedulerBinding.instance.addPostFrameCallback((_) {
if (mounted) setState(() {
_cardSize = context.size;
});
});
}
@override
bool get wantKeepAlive => _currentTask != widget.task;
}
bool get wantKeepAlive => _dragging;
}

View File

@ -1,27 +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 Function onLast;
final DragUpdateCallback onTaskDragUpdate;
const SliverBucketList({Key key, @required this.bucket, this.onLast})
: assert(bucket != null),
super(key: key);
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
? BucketTaskCard(task: bucket.tasks[index])
: () {
if (onLast != null) onLast();
return null;
}();
return index >= bucket.tasks.length ? null : BucketTaskCard(
key: ObjectKey(bucket.tasks[index]),
task: bucket.tasks[index],
index: index,
onDragUpdate: onTaskDragUpdate,
onAccept: (task, index) {
_moveTaskToBucket(context, task, index);
},
);
}),
);
}
Future<void> _moveTaskToBucket(BuildContext context, Task task, int index) {
return Provider.of<ListProvider>(context, listen: false).moveTaskToBucket(
context: context,
task: task,
newBucketId: bucket.id,
index: index,
).then((_) => ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text('${task.title} was moved to ${bucket.title} successfully!'),
)));
}
}

View File

@ -1,11 +1,11 @@
import 'dart:async';
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;
@ -84,8 +84,9 @@ class TaskTileState extends State<TaskTile> with AutomaticKeepAliveClientMixin {
Navigator.push<Task>(
context,
MaterialPageRoute(
builder: (context) => TaskEditPage(
builder: (buildContext) => TaskEditPage(
task: _currentTask,
taskState: Provider.of<ListProvider>(context),
),
),
).then((task) => setState(() {
@ -109,9 +110,12 @@ class TaskTileState extends State<TaskTile> with AutomaticKeepAliveClientMixin {
}
Future<Task> _updateTask(Task task, bool checked) {
return VikunjaGlobal.of(context).taskService.update(task.copyWith(
done: checked,
));
return Provider.of<ListProvider>(context, listen: false).updateTask(
context: context,
task: task.copyWith(
done: checked,
),
);
}
@override

View File

@ -44,7 +44,7 @@ class Bucket {
tasks = (json['tasks'] as List<dynamic>)
?.map((task) => Task.fromJson(task))
?.cast<Task>()
?.toList();
?.toList() ?? <Task>[];
toJSON() => {
'id': id,

View File

@ -5,12 +5,14 @@ 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';
@ -22,11 +24,12 @@ import 'package:vikunja_app/stores/list_store.dart';
enum BucketMenu {limit, done, delete}
class BucketProps {
final ValueKey<int> key;
bool scrollable = false;
final ScrollController controller = ScrollController();
final TextEditingController titleController = TextEditingController();
BucketProps(this.key);
bool scrollable = false;
bool portrait = true;
int bucketLength = 0;
Size taskDropSize;
}
class ListPage extends StatefulWidget {
@ -40,7 +43,7 @@ class ListPage extends StatefulWidget {
}
class _ListPageState extends State<ListPage> {
final _globalKey = GlobalKey();
final _keyboardController = KeyboardVisibilityController();
int _viewIndex = 0;
TaskList _list;
List<Task> _loadingTasks = [];
@ -51,6 +54,7 @@ class _ListPageState extends State<ListPage> {
PageController _pageController;
Map<int, BucketProps> _bucketProps = {};
int _draggedBucketIndex;
Duration _lastTaskDragUpdateAction = Duration.zero;
@override
void initState() {
@ -59,6 +63,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();
@ -68,13 +75,7 @@ class _ListPageState extends State<ListPage> {
@override
Widget build(BuildContext context) {
taskState = Provider.of<ListProvider>(context);
KeyboardVisibilityController().onChange.listen((visible) {
if (!visible) try {
FocusScope.of(_globalKey.currentContext ?? context).unfocus();
} catch (e) {}
});
return GestureDetector(
key: _globalKey,
onTap: () => FocusScope.of(context).unfocus(),
child: Scaffold(
appBar: AppBar(
@ -190,25 +191,31 @@ class _ListPageState extends State<ListPage> {
Widget _kanbanView(BuildContext context) {
final deviceData = MediaQuery.of(context);
final bucketWidth = deviceData.size.width
* (deviceData.orientation == Orientation.portrait ? 0.8 : 0.4);
if (_pageController == null) _pageController = PageController(viewportFraction: 0.8);
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,
itemExtent: bucketWidth,
cacheExtent: bucketWidth,
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,
child: _buildBucketTile(taskState.buckets[index]),
enabled: taskState.buckets.length > 1 && !taskState.taskDragging,
child: SizedBox(
width: bucketWidth,
child: _buildBucketTile(taskState.buckets[index], portrait),
),
);
},
proxyDecorator: (child, index, animation) {
@ -224,26 +231,29 @@ class _ListPageState extends State<ListPage> {
);
},
footer: _draggedBucketIndex != null ? null : SizedBox(
width: bucketWidth,
child: Column(
children: [
ListTile(
title: Align(
alignment: Alignment.centerLeft,
child: ElevatedButton.icon(
onPressed: () => _addBucketDialog(context),
label: Text('Create Bucket'),
//style: ButtonStyle(alignment: Alignment.centerLeft),
icon: Icon(Icons.add),
),
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),
),
),
Spacer(),
],
),
),
),
onReorderStart: (oldIndex) => setState(() => _draggedBucketIndex = oldIndex),
onReorder: (oldIndex, newIndex) {},
onReorderStart: (oldIndex) {
FocusScope.of(context).unfocus();
setState(() => _draggedBucketIndex = oldIndex);
},
onReorder: (_, __) {},
onReorderEnd: (newIndex) => setState(() {
bool indexUpdated = false;
if (newIndex > _draggedBucketIndex) {
@ -257,78 +267,79 @@ class _ListPageState extends State<ListPage> {
newIndex = 1;
}
taskState.buckets[newIndex].position = newIndex == taskState.buckets.length - 1
? taskState.buckets[newIndex - 1].position + 1
? taskState.buckets[newIndex - 1].position + pow(2.0, 16.0)
: (taskState.buckets[newIndex - 1].position
+ taskState.buckets[newIndex + 1].position) / 2.0;
_updateBucket(context, taskState.buckets[newIndex]);
_draggedBucketIndex = null;
if (indexUpdated)
_pageController.jumpToPage((newIndex
/ (deviceData.orientation == Orientation.portrait ? 1 : 2)).floor());
if (indexUpdated && portrait) _pageController.animateToPage(
newIndex - 1,
duration: Duration(milliseconds: 100),
curve: Curves.easeInOut,
);
}),
);
}
TaskTile _buildTile(Task task) {
return TaskTile(
task: task,
loading: false,
onEdit: () {
/*Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
TaskEditPage(
task: task,
),
),
);*/
},
onMarkedAsDone: (done) {
Provider.of<ListProvider>(context, listen: false).updateTask(
context: context,
id: task.id,
done: done,
);
},
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) {
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.tasks?.length ?? -1) < bucket.limit
? () => _addItemDialog(context, bucket)
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(ValueKey<int>(bucket.id));
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((_) {
setState(() {
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(
key: _bucketProps[bucket.id].key,
children: <Widget>[
CustomScrollView(
controller: _bucketProps[bucket.id].controller,
slivers: <Widget>[
SliverBucketPersistentHeader(
minExtent: 56,
maxExtent: 56,
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,
@ -357,75 +368,87 @@ class _ListPageState extends State<ListPage> {
},
),
),
if (bucket.limit != 0)
Text(
'${bucket.tasks?.length ?? 0}/${bucket.limit}',
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.tasks?.length ?? -1) >= bucket.limit
color: bucket.limit != 0 && bucket.tasks.length >= bucket.limit
? Colors.red : null,
),
),
),
],
),
trailing: 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;
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.done:
bucket.isDoneBucket = !bucket.isDoneBucket;
_updateBucket(context, bucket);
break;
case BucketMenu.delete:
_deleteBucket(context, bucket);
}
},
itemBuilder: (context) => <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,
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'),
],
),
),
Text('Done Bucket'),
],
),
),
const PopupMenuDivider(),
PopupMenuItem<BucketMenu>(
value: BucketMenu.delete,
child: Row(
children: <Widget>[
Icon(
Icons.delete,
color: Colors.red,
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,
),
],
),
),
Text(
'Delete',
style: TextStyle(color: Colors.red),
),
],
),
];
},
),
],
),
@ -434,8 +457,42 @@ class _ListPageState extends State<ListPage> {
),
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 8),
sliver: SliverBucketList(
bucket: bucket,
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(
@ -445,9 +502,41 @@ class _ListPageState extends State<ListPage> {
maintainSize: true,
sliver: SliverFillRemaining(
hasScrollBody: false,
child: Align(
alignment: Alignment.topCenter,
child: addTaskButton,
child: Stack(
children: <Widget>[
Column(
children: <Widget>[
if (_bucketProps[bucket.id].taskDropSize != null) DottedBorder(
color: Colors.white,
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(),
),
],
),
),
),
@ -475,6 +564,7 @@ class _ListPageState extends State<ListPage> {
MaterialPageRoute(
builder: (context) => TaskEditPage(
task: task,
taskState: taskState,
),
),
),
@ -602,8 +692,9 @@ class _ListPageState extends State<ListPage> {
}
_deleteBucket(BuildContext context, Bucket bucket) {
if ((bucket.tasks?.length ?? 0) > 0) {
int defaultBucketId = taskState.buckets[0]?.id ?? 100;
// Move bucket's tasks to default bucket (the one with the lowest id)
if (bucket.tasks.length > 0) {
int defaultBucketId = taskState.buckets[0].id;
taskState.buckets.forEach((b) {
if (b.id < defaultBucketId)
defaultBucketId = b.id;

View File

@ -8,12 +8,19 @@ import 'package:vikunja_app/components/label.dart';
import 'package:vikunja_app/global.dart';
import 'package:vikunja_app/models/label.dart';
import 'package:vikunja_app/models/task.dart';
import 'package:vikunja_app/stores/list_store.dart';
import 'package:vikunja_app/utils/repeat_after_parse.dart';
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();
@ -381,7 +388,10 @@ class _TaskEditPageState extends State<TaskEditPage> {
);
});
VikunjaGlobal.of(context).taskService.update(updatedTask).then((result) {
widget.taskState.updateTask(
context: context,
task: updatedTask,
).then((result) {
setState(() { _loading = false; _changed = false;});
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text('The task was updated successfully!'),

View File

@ -1,3 +1,5 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:vikunja_app/models/task.dart';
import 'package:vikunja_app/models/bucket.dart';
@ -5,6 +7,7 @@ import 'package:vikunja_app/global.dart';
class ListProvider with ChangeNotifier {
bool _isLoading = false;
bool _taskDragging = false;
int _maxPages = 0;
// TODO: Streams
@ -13,6 +16,13 @@ class ListProvider with ChangeNotifier {
bool get isLoading => _isLoading;
bool get taskDragging => _taskDragging;
set taskDragging(bool value) {
_taskDragging = value;
notifyListeners();
}
int get maxPages => _maxPages;
set tasks(List<Task> tasks) {
@ -117,14 +127,8 @@ class ListProvider with ChangeNotifier {
});
}
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) {
@ -132,7 +136,13 @@ 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;
});
}
@ -161,4 +171,60 @@ class ListProvider with ChangeNotifier {
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);
double kanbanPosition;
if (_buckets[newBucketIndex].tasks.length == 1) // only task
kanbanPosition = 0.0;
else if (index == 0) // first task
kanbanPosition = _buckets[newBucketIndex].tasks[1].kanbanPosition / 2.0;
else if (index == _buckets[newBucketIndex].tasks.length - 1) // last task
kanbanPosition = _buckets[newBucketIndex].tasks[index - 1].kanbanPosition + pow(2.0, 16.0);
else // in the middle
kanbanPosition = (_buckets[newBucketIndex].tasks[index - 1].kanbanPosition
+ _buckets[newBucketIndex].tasks[index + 1].kanbanPosition) / 2.0;
task = await VikunjaGlobal.of(context).taskService.update(task.copyWith(
bucketId: newBucketId ?? task.bucketId,
kanbanPosition: kanbanPosition,
));
_buckets[newBucketIndex].tasks[index] = task;
// make sure first 2 tasks don't have 0 kanbanPosition
Task secondTask;
if (index == 0 && _buckets[newBucketIndex].tasks.length > 1
&& _buckets[newBucketIndex].tasks[1].kanbanPosition == 0) {
if (_buckets[newBucketIndex].tasks.length == 2) // last task
kanbanPosition = _buckets[newBucketIndex].tasks[0].kanbanPosition + pow(2.0, 16.0);
else // in the middle
kanbanPosition = (_buckets[newBucketIndex].tasks[0].kanbanPosition
+ _buckets[newBucketIndex].tasks[2].kanbanPosition) / 2.0;
secondTask = await VikunjaGlobal.of(context).taskService.update(
_buckets[newBucketIndex].tasks[1].copyWith(
kanbanPosition: kanbanPosition,
));
_buckets[newBucketIndex].tasks[1] = secondTask;
}
if (_tasks.isNotEmpty) {
_tasks[_tasks.indexWhere((t) => t.id == task.id)] = task;
if (secondTask != null) _tasks[_tasks.indexWhere((t) => t.id == secondTask.id)] = secondTask;
}
_buckets[newBucketIndex].tasks[_buckets[newBucketIndex].tasks.indexWhere((t) => t.id == task.id)] = task;
_buckets[newBucketIndex].tasks.sort((a, b) => a.kanbanPosition.compareTo(b.kanbanPosition));
notifyListeners();
}
}

View File

@ -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:
@ -436,6 +443,20 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "1.8.1"
path_drawing:
dependency: transitive
description:
name: path_drawing
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.0"
path_parsing:
dependency: transitive
description:
name: path_parsing
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.0"
petitparser:
dependency: "direct main"
description:

View File

@ -26,6 +26,7 @@ dependencies:
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: