mirror of
https://github.com/go-vikunja/app
synced 2024-06-01 02:06:51 +00:00
552 lines
21 KiB
Dart
552 lines
21 KiB
Dart
import 'dart:ui';
|
|
|
|
import 'package:dotted_border/dotted_border.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/scheduler.dart';
|
|
import 'package:provider/provider.dart';
|
|
|
|
import '../global.dart';
|
|
import '../models/bucket.dart';
|
|
import '../models/list.dart';
|
|
import '../models/project.dart';
|
|
import '../pages/list/list.dart';
|
|
import '../stores/project_store.dart';
|
|
import '../utils/calculate_item_position.dart';
|
|
import 'AddDialog.dart';
|
|
import 'BucketLimitDialog.dart';
|
|
import 'BucketTaskCard.dart';
|
|
import 'SliverBucketList.dart';
|
|
import 'SliverBucketPersistentHeader.dart';
|
|
|
|
class KanbanClass {
|
|
PageController? _pageController;
|
|
ProjectProvider? taskState;
|
|
int? _draggedBucketIndex;
|
|
BuildContext context;
|
|
Function _onViewTapped, _addItemDialog, notify;
|
|
Duration _lastTaskDragUpdateAction = Duration.zero;
|
|
|
|
|
|
Project _list;
|
|
Map<int, BucketProps> _bucketProps = {};
|
|
|
|
|
|
KanbanClass(this.context, this.notify, this._onViewTapped, this._addItemDialog, this._list) {
|
|
taskState = Provider.of<ProjectProvider>(context);
|
|
}
|
|
|
|
|
|
Widget kanbanView() {
|
|
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!.viewportFraction != bucketFraction)
|
|
_pageController = PageController(viewportFraction: bucketFraction);
|
|
|
|
|
|
return ReorderableListView.builder(
|
|
scrollDirection: Axis.horizontal,
|
|
scrollController: _pageController,
|
|
physics: PageScrollPhysics(),
|
|
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
|
|
itemCount: taskState?.buckets.length ?? 0,
|
|
buildDefaultDragHandles: false,
|
|
itemBuilder: (context, index) {
|
|
if (index > (taskState!.buckets.length))
|
|
throw Exception("Check itemCount attribute");
|
|
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),
|
|
),
|
|
);
|
|
},
|
|
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();
|
|
_draggedBucketIndex = oldIndex;
|
|
notify();
|
|
// 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,
|
|
);
|
|
_draggedBucketIndex = null;
|
|
notify();
|
|
// setState(() => _draggedBucketIndex = null);
|
|
},
|
|
);
|
|
}
|
|
|
|
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) async {
|
|
final currentUser = VikunjaGlobal.of(context).currentUser;
|
|
if (currentUser == null) {
|
|
return;
|
|
}
|
|
|
|
await Provider.of<ProjectProvider>(context, listen: false).addBucket(
|
|
context: context,
|
|
newBucket: Bucket(
|
|
title: title,
|
|
createdBy: currentUser,
|
|
projectId: _list.id,
|
|
limit: 0,
|
|
),
|
|
listId: _list.id,
|
|
);
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
|
content: Text('The bucket was added successfully!'),
|
|
));
|
|
notify();
|
|
//setState(() {});
|
|
}
|
|
|
|
Future<void> _updateBucket(BuildContext context, Bucket bucket) {
|
|
return Provider.of<ProjectProvider>(context, listen: false)
|
|
.updateBucket(
|
|
context: context,
|
|
bucket: bucket,
|
|
)
|
|
.then((_) {
|
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
|
content: Text('\'${bucket.title}\' bucket updated successfully!'),
|
|
));
|
|
notify();
|
|
//setState(() {});
|
|
});
|
|
}
|
|
|
|
Future<void> _deleteBucket(BuildContext context, Bucket bucket) async {
|
|
await Provider.of<ProjectProvider>(context, listen: false).deleteBucket(
|
|
context: context,
|
|
listId: bucket.projectId,
|
|
bucketId: bucket.id,
|
|
);
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
|
content: Row(
|
|
children: <Widget>[
|
|
Text('\'${bucket.title}\' was deleted.'),
|
|
Icon(Icons.delete),
|
|
],
|
|
),
|
|
));
|
|
|
|
_onViewTapped(1);
|
|
}
|
|
|
|
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;
|
|
//});
|
|
notify();
|
|
|
|
});
|
|
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 ??
|
|
TextStyle(fontSize: 16))
|
|
.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 bool 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!;
|
|
notify();
|
|
};//);
|
|
|
|
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;//);
|
|
notify();
|
|
return true;
|
|
},
|
|
onAccept: (data) {
|
|
Provider.of<ProjectProvider>(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;//);
|
|
notify();
|
|
},
|
|
onLeave: (_) {
|
|
//setState(() =>
|
|
_bucketProps[bucket.id]!.taskDropSize = null;//)
|
|
notify();
|
|
},
|
|
builder: (_, __, ___) => SizedBox.expand(),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
if (_bucketProps[bucket.id]!.scrollable)
|
|
Align(
|
|
alignment: Alignment.bottomCenter,
|
|
child: addTaskButton,
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Future<void> loadBucketsForPage(int page) {
|
|
return Provider.of<ProjectProvider>(context, listen: false).loadBuckets(
|
|
context: context,
|
|
listId: _list.id,
|
|
page: page
|
|
);
|
|
}
|
|
|
|
|
|
}
|