mirror of
https://github.com/go-vikunja/app
synced 2024-06-02 18:49:47 +00:00
moved kanban widget to own class to declutter list.dart
This commit is contained in:
parent
b8a83bf7fa
commit
e8f1edfdbe
551
lib/components/KanbanWidget.dart
Normal file
551
lib/components/KanbanWidget.dart
Normal file
|
@ -0,0 +1,551 @@
|
|||
import 'dart:ui';
|
||||
|
||||
import 'package:dotted_border/dotted_border.dart';
|
||||
import 'package:flutter/cupertino.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 '../pages/list/list.dart';
|
||||
import '../stores/list_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;
|
||||
ListProvider? taskState;
|
||||
int? _draggedBucketIndex;
|
||||
BuildContext context;
|
||||
Function _onViewTapped, _addItemDialog, notify;
|
||||
Duration _lastTaskDragUpdateAction = Duration.zero;
|
||||
|
||||
|
||||
TaskList _list;
|
||||
Map<int, BucketProps> _bucketProps = {};
|
||||
|
||||
|
||||
KanbanClass(this.context, this.notify, this._onViewTapped, this._addItemDialog, this._list) {
|
||||
taskState = Provider.of<ListProvider>(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<ListProvider>(context, listen: false).addBucket(
|
||||
context: context,
|
||||
newBucket: Bucket(
|
||||
title: title,
|
||||
createdBy: currentUser,
|
||||
listId: _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<ListProvider>(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<ListProvider>(context, listen: false).deleteBucket(
|
||||
context: context,
|
||||
listId: bucket.listId,
|
||||
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<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;//);
|
||||
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<ListProvider>(context, listen: false).loadBuckets(
|
||||
context: context,
|
||||
listId: _list.id,
|
||||
page: page
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
}
|
|
@ -8,6 +8,7 @@ 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/KanbanWidget.dart';
|
||||
import 'package:vikunja_app/components/TaskTile.dart';
|
||||
import 'package:vikunja_app/components/SliverBucketList.dart';
|
||||
import 'package:vikunja_app/components/SliverBucketPersistentHeader.dart';
|
||||
|
@ -22,6 +23,8 @@ 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';
|
||||
|
||||
import '../../components/pagestatus.dart';
|
||||
|
||||
enum BucketMenu {limit, done, delete}
|
||||
|
||||
class BucketProps {
|
||||
|
@ -46,16 +49,17 @@ class ListPage extends StatefulWidget {
|
|||
class _ListPageState extends State<ListPage> {
|
||||
final _keyboardController = KeyboardVisibilityController();
|
||||
int _viewIndex = 0;
|
||||
TaskList? _list;
|
||||
late TaskList _list;
|
||||
List<Task> _loadingTasks = [];
|
||||
int _currentPage = 1;
|
||||
bool _loading = true;
|
||||
bool displayDoneTasks = false;
|
||||
ListProvider? taskState;
|
||||
PageController? _pageController;
|
||||
Map<int, BucketProps> _bucketProps = {};
|
||||
int? _draggedBucketIndex;
|
||||
Duration _lastTaskDragUpdateAction = Duration.zero;
|
||||
late KanbanClass _kanban;
|
||||
|
||||
|
||||
PageStatus pagestatus = PageStatus.built;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
|
@ -69,14 +73,20 @@ class _ListPageState extends State<ListPage> {
|
|||
});
|
||||
}
|
||||
|
||||
void nullSetState() {
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
taskState = Provider.of<ListProvider>(context);
|
||||
_kanban = KanbanClass(context, nullSetState, _onViewTapped, _addItemDialog, _list);
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () => FocusScope.of(context).unfocus(),
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(_list?.title ?? ""),
|
||||
title: Text(_list.title),
|
||||
actions: <Widget>[
|
||||
IconButton(
|
||||
icon: Icon(Icons.edit),
|
||||
|
@ -84,7 +94,7 @@ class _ListPageState extends State<ListPage> {
|
|||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => ListEditPage(
|
||||
list: _list!,
|
||||
list: _list,
|
||||
),
|
||||
)).whenComplete(() => _loadList()),
|
||||
),
|
||||
|
@ -112,7 +122,7 @@ class _ListPageState extends State<ListPage> {
|
|||
case 0:
|
||||
return _listView(context);
|
||||
case 1:
|
||||
return _kanbanView(context);
|
||||
return _kanban.kanbanView();
|
||||
default:
|
||||
return _listView(context);
|
||||
}
|
||||
|
@ -185,114 +195,6 @@ class _ListPageState extends State<ListPage> {
|
|||
);
|
||||
}
|
||||
|
||||
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 ?? 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();
|
||||
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,
|
||||
|
@ -310,260 +212,8 @@ class _ListPageState extends State<ListPage> {
|
|||
);
|
||||
}
|
||||
|
||||
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 ?? 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!);
|
||||
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,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> updateDisplayDoneTasks() {
|
||||
return VikunjaGlobal.of(context).listService.getDisplayDoneTasks(_list!.id)
|
||||
return VikunjaGlobal.of(context).listService.getDisplayDoneTasks(_list.id)
|
||||
.then((value) {displayDoneTasks = value == "1";});
|
||||
}
|
||||
|
||||
|
@ -584,17 +234,20 @@ class _ListPageState extends State<ListPage> {
|
|||
}
|
||||
|
||||
Future<void> _loadList() async {
|
||||
setState(() {
|
||||
pagestatus = PageStatus.loading;
|
||||
});
|
||||
updateDisplayDoneTasks().then((value) async {
|
||||
switch (_viewIndex) {
|
||||
case 0:
|
||||
_loadTasksForPage(1);
|
||||
break;
|
||||
case 1:
|
||||
await _loadBucketsForPage(1);
|
||||
await _kanban.loadBucketsForPage(1);
|
||||
// load all buckets to get length for RecordableListView
|
||||
while (_currentPage < taskState!.maxPages) {
|
||||
_currentPage++;
|
||||
await _loadBucketsForPage(_currentPage);
|
||||
await _kanban.loadBucketsForPage(_currentPage);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
|
@ -606,20 +259,12 @@ class _ListPageState extends State<ListPage> {
|
|||
Future<void> _loadTasksForPage(int page) {
|
||||
return Provider.of<ListProvider>(context, listen: false).loadTasks(
|
||||
context: context,
|
||||
listId: _list!.id,
|
||||
listId: _list.id,
|
||||
page: page,
|
||||
displayDoneTasks: displayDoneTasks
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
|
@ -644,14 +289,14 @@ class _ListPageState extends State<ListPage> {
|
|||
createdBy: currentUser,
|
||||
done: false,
|
||||
bucketId: bucket?.id,
|
||||
listId: _list!.id,
|
||||
listId: _list.id,
|
||||
);
|
||||
setState(() => _loadingTasks.add(newTask));
|
||||
return Provider.of<ListProvider>(context, listen: false)
|
||||
.addTask(
|
||||
context: context,
|
||||
newTask: newTask,
|
||||
listId: _list!.id,
|
||||
listId: _list.id,
|
||||
)
|
||||
.then((_) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
|
@ -663,71 +308,4 @@ class _ListPageState extends State<ListPage> {
|
|||
});
|
||||
}
|
||||
|
||||
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<ListProvider>(context, listen: false).addBucket(
|
||||
context: context,
|
||||
newBucket: Bucket(
|
||||
title: title,
|
||||
createdBy: currentUser,
|
||||
listId: _list!.id,
|
||||
limit: 0,
|
||||
),
|
||||
listId: _list!.id,
|
||||
);
|
||||
|
||||
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,
|
||||
);
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Row(
|
||||
children: <Widget>[
|
||||
Text('\'${bucket.title}\' was deleted.'),
|
||||
Icon(Icons.delete),
|
||||
],
|
||||
),
|
||||
));
|
||||
|
||||
_onViewTapped(1);
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue
Block a user