import 'dart:math'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:dotted_border/dotted_border.dart'; import 'package:provider/provider.dart'; import 'package:vikunja_app/models/task.dart'; import 'package:vikunja_app/pages/list/task_edit.dart'; import 'package:vikunja_app/stores/list_store.dart'; import 'package:vikunja_app/utils/misc.dart'; import 'package:vikunja_app/theme/constants.dart'; enum DropLocation {above, below, none} class TaskData { final Task task; final Size? size; TaskData(this.task, this.size); } class BucketTaskCard extends StatefulWidget { final Task task; final int index; final DragUpdateCallback onDragUpdate; final void Function(Task, int) onAccept; const BucketTaskCard({ Key? key, required this.task, required this.index, required this.onDragUpdate, required this.onAccept, }) : super(key: key); @override State createState() => _BucketTaskCardState(); } class _BucketTaskCardState extends State with AutomaticKeepAliveClientMixin { Size? _cardSize; bool _dragging = false; DropLocation _dropLocation = DropLocation.none; TaskData? _dropData; @override Widget build(BuildContext context) { super.build(context); if (_cardSize == null) _updateCardSize(context); final taskState = Provider.of(context); final bucket = taskState.buckets[taskState.buckets.indexWhere((b) => b.id == widget.task.bucketId)]; // default chip height: 32 const double chipHeight = 28; const chipConstraints = BoxConstraints(maxHeight: chipHeight); final theme = Theme.of(context); final identifierRow = Row( children: [ Text( (widget.task.identifier?.isNotEmpty ?? false) ? '#${widget.task.identifier!.substring(1)}' : '${widget.task.id}', style: (theme.textTheme.subtitle2 ?? TextStyle()).copyWith( color: Colors.grey, ), ), ], ); if (widget.task.done) { identifierRow.children.insert(0, Container( constraints: chipConstraints, padding: EdgeInsets.only(right: 4), child: FittedBox( child: Chip( label: Text('Done'), labelStyle: (theme.textTheme.labelLarge ?? TextStyle()).copyWith( fontWeight: FontWeight.bold, color: theme.brightness == Brightness.dark ? Colors.black : Colors.white, ), backgroundColor: vGreen, ), ), )); } final titleRow = Row( children: [ Expanded( child: Text( widget.task.title, style: (theme.textTheme.titleMedium ?? TextStyle(fontSize: 16)).copyWith( color: widget.task.textColor, ), ), ), ], ); if (widget.task.hasDueDate) { final duration = widget.task.dueDate!.difference(DateTime.now()); final pastDue = duration.isNegative && !widget.task.done; titleRow.children.add(Container( constraints: chipConstraints, padding: EdgeInsets.only(left: 4), child: FittedBox( child: Chip( avatar: Icon( Icons.calendar_month, color: pastDue ? Colors.red : null, ), label: Text(durationToHumanReadable(duration)), labelStyle: (theme.textTheme.labelLarge ?? TextStyle()).copyWith( color: pastDue ? Colors.red : null, ), backgroundColor: pastDue ? Colors.red.withAlpha(20) : null, ), ), )); } final labelRow = Wrap( children: [], spacing: 4, runSpacing: 4, ); widget.task.labels.sort((a, b) => a.title.compareTo(b.title)); widget.task.labels.asMap().forEach((i, label) { labelRow.children.add(Chip( label: Text(label.title), labelStyle: theme.textTheme.labelLarge?.copyWith( color: label.textColor, ), backgroundColor: label.color, )); }); if (widget.task.hasCheckboxes) { final checkboxStatistics = widget.task.checkboxStatistics; final iconSize = (theme.textTheme.labelLarge?.fontSize ?? 14) + 2; labelRow.children.add(Chip( avatar: Container( constraints: BoxConstraints(maxHeight: iconSize, maxWidth: iconSize), child: CircularProgressIndicator( value: checkboxStatistics.checked / checkboxStatistics.total, backgroundColor: Colors.grey, ), ), label: Text( (checkboxStatistics.checked == checkboxStatistics.total ? '' : '${checkboxStatistics.checked} of ') + '${checkboxStatistics.total} tasks' ), )); } if (widget.task.attachments.isNotEmpty) { labelRow.children.add(Chip( label: Transform.rotate( angle: -pi / 4.0, child: Icon(Icons.attachment), ), )); } if (widget.task.description.isNotEmpty) { labelRow.children.add(Chip( label: Icon(Icons.notes), )); } final rowConstraints = BoxConstraints(minHeight: chipHeight); final card = Card( color: widget.task.color, child: InkWell( child: Theme( data: Theme.of(context).copyWith( // Remove enforced margins materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, ), child: Padding( padding: const EdgeInsets.all(4), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Container( constraints: rowConstraints, child: identifierRow, ), Padding( padding: EdgeInsets.only(top: 4, bottom: labelRow.children.isNotEmpty ? 8 : 0), child: Container( constraints: rowConstraints, child: titleRow, ), ), labelRow, ], ), ), ), onTap: () { FocusScope.of(context).unfocus(); Navigator.push( context, MaterialPageRoute( builder: (context) => TaskEditPage( task: widget.task, taskState: taskState, ), ), ); }, ), ); return LongPressDraggable( 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 cardSize = _cardSize!; final dropBoxSize = _dropData?.size ?? cardSize; final dropBox = DottedBorder( color: Colors.grey, child: SizedBox.fromSize(size: dropBoxSize), ); final dropAbove = taskState.taskDragging && _dropLocation == DropLocation.above; final dropBelow = taskState.taskDragging && _dropLocation == DropLocation.below; final DragTargetLeave dragTargetOnLeave = (data) => setState(() { _dropLocation = DropLocation.none; _dropData = null; }); final dragTargetOnWillAccept = (TaskData data, DropLocation dropLocation) { if (data.task.bucketId != bucket.id) if (bucket.limit != 0 && bucket.tasks.length >= bucket.limit) return false; setState(() { _dropLocation = dropLocation; _dropData = data; }); return true; }; final DragTargetAccept 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: [ Column( children: [ if (dropAbove) dropBox, card, if (dropBelow) dropBox, ], ), Column( children: [ SizedBox( height: (cardSize.height / 2) + (dropAbove ? dropBoxSize.height : 0), child: DragTarget( onWillAccept: (data) => dragTargetOnWillAccept(data!, DropLocation.above), onAccept: dragTargetOnAccept, onLeave: dragTargetOnLeave, builder: (_, __, ___) => SizedBox.expand(), ), ), SizedBox( height: (cardSize.height / 2) + (dropBelow ? dropBoxSize.height : 0), child: DragTarget( onWillAccept: (data) => dragTargetOnWillAccept(data!, DropLocation.below), onAccept: dragTargetOnAccept, onLeave: dragTargetOnLeave, builder: (_, __, ___) => SizedBox.expand(), ), ), ], ), ], ), ); }(), ); } void _updateCardSize(BuildContext context) { SchedulerBinding.instance.addPostFrameCallback((_) { if (mounted) setState(() { _cardSize = context.size; }); }); } @override bool get wantKeepAlive => _dragging; }