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 _bucketProps = {}; KanbanClass(this.context, this.notify, this._onViewTapped, this._addItemDialog, this._list) { taskState = Provider.of(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); print(_list.doneBucketId); 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(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 _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 _setDoneBucket(BuildContext context, int bucketId) async { //setState(() {}); _list = (await VikunjaGlobal.of(context) .projectService .update(_list.copyWith(doneBucketId: bucketId)))!; notify(); } Future _addBucket(String title, BuildContext context) async { final currentUser = VikunjaGlobal.of(context).currentUser; if (currentUser == null) { return; } await Provider.of(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 _updateBucket(BuildContext context, Bucket bucket) { return Provider.of(context, listen: false) .updateBucket( context: context, bucket: bucket, ) .then((_) { ScaffoldMessenger.of(context).showSnackBar(SnackBar( content: Text('\'${bucket.title}\' bucket updated successfully!'), )); notify(); //setState(() {}); }); } Future _deleteBucket(BuildContext context, Bucket bucket) async { await Provider.of(context, listen: false).deleteBucket( context: context, listId: bucket.projectId, bucketId: bucket.id, ); ScaffoldMessenger.of(context).showSnackBar(SnackBar( content: Row( children: [ 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: [ CustomScrollView( controller: _bucketProps[bucket.id]!.controller, slivers: [ 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.id == _list.doneBucketId ? Icon( Icons.done_all, color: Colors.green, ) : null, title: Row( children: [ 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: [ Icon(Icons.drag_handle), PopupMenuButton( child: Icon(Icons.more_vert), onSelected: (item) { switch (item) { case BucketMenu.limit: showDialog( context: context, builder: (_) => BucketLimitDialog( bucket: bucket, ), ).then((limit) { if (limit != null) { bucket.limit = limit; _updateBucket(context, bucket); } }); break; case BucketMenu.done: //bucket.isDoneBucket = !(bucket.id == _list.doneBucketId); _list = _list.copyWith(doneBucketId: bucket.id); _setDoneBucket(context, bucket.id); notify(); //_updateBucket(context, bucket); break; case BucketMenu.delete: _deleteBucket(context, bucket); } }, itemBuilder: (context) { final bool enableDelete = taskState!.buckets.length > 1; return >[ PopupMenuItem( value: BucketMenu.limit, child: Text('Limit: ${bucket.limit}'), ), PopupMenuItem( value: BucketMenu.done, child: Row( children: [ Padding( padding: const EdgeInsets.only(right: 4), child: Icon( Icons.done_all, color: bucket.id == _list.doneBucketId ? Colors.green : null, ), ), Text('Done Bucket'), ], ), ), const PopupMenuDivider(), PopupMenuItem( value: BucketMenu.delete, enabled: enableDelete, child: Row( children: [ 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: [ Column( children: [ 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( onWillAccept: (data) { /*setState(() =>*/ _bucketProps[bucket.id]! .taskDropSize = data?.size; //); notify(); return true; }, onAccept: (data) { Provider.of(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 loadBucketsForPage(int page) { return Provider.of(context, listen: false) .loadBuckets(context: context, listId: _list.id, page: page); } }