import 'dart:async'; import 'package:boardview/board_item.dart'; import 'package:boardview/board_list.dart'; import 'package:boardview/boardview.dart'; import 'package:boardview/boardview_controller.dart'; import 'package:flutter/material.dart'; import 'package:flutter_redux/flutter_redux.dart'; import 'package:invoiceninja_flutter/redux/app/app_state.dart'; import 'package:invoiceninja_flutter/ui/app/buttons/app_text_button.dart'; import 'package:invoiceninja_flutter/ui/app/live_text.dart'; import 'package:invoiceninja_flutter/utils/app_context.dart'; import 'package:invoiceninja_flutter/data/models/models.dart'; import 'package:invoiceninja_flutter/redux/app/app_actions.dart'; import 'package:invoiceninja_flutter/ui/app/forms/decorated_form_field.dart'; import 'package:invoiceninja_flutter/ui/task/kanban_view_vm.dart'; import 'package:invoiceninja_flutter/utils/colors.dart'; import 'package:invoiceninja_flutter/utils/completers.dart'; import 'package:invoiceninja_flutter/utils/formatting.dart'; import 'package:invoiceninja_flutter/utils/localization.dart'; import 'package:material_design_icons_flutter/material_design_icons_flutter.dart'; class KanbanView extends StatefulWidget { const KanbanView({ Key key, @required this.viewModel, }) : super(key: key); final KanbanVM viewModel; @override _KanbanViewState createState() => _KanbanViewState(); } class _KanbanViewState extends State { final _boardViewController = new BoardViewController(); List _statuses; Map> _tasks; bool isDragging = false; @override void initState() { super.initState(); _initBoard(); } void _initBoard() { print('## INIT BOARD'); final viewModel = widget.viewModel; final state = viewModel.state; _statuses = state.taskStatusState.list .where((statusId) => state.taskStatusState.get(statusId).isActive) .toList(); _statuses.sort((statusIdA, statusIdB) { final statusA = state.taskStatusState.get(statusIdA); final statusB = state.taskStatusState.get(statusIdB); if (statusA.statusOrder == statusB.statusOrder) { return statusB.updatedAt.compareTo(statusA.updatedAt); } else { return (statusA.statusOrder ?? 99999) .compareTo(statusB.statusOrder ?? 99999); } }); _statuses = ['', ..._statuses]; _tasks = {}; viewModel.taskList.forEach((taskId) { final task = state.taskState.map[taskId]; final status = state.taskStatusState.get(task.statusId); final statusId = status.isNew ? '' : status.id; if (!_tasks.containsKey(statusId)) { _tasks[statusId] = []; } _tasks[statusId].add(task.id); }); _tasks.forEach((key, value) { _tasks[key].sort((taskIdA, taskIdB) { final taskA = state.taskState.get(taskIdA); final taskB = state.taskState.get(taskIdB); if (taskA.statusOrder == taskB.statusOrder) { return taskB.updatedAt.compareTo(taskA.updatedAt); } else { return (taskA.statusOrder ?? 99999) .compareTo(taskB.statusOrder ?? 99999); } }); }); } void _onBoardChanged() { final localization = AppLocalization.of(context); final completer = snackBarCompleter(context, localization.updatedTaskStatus); completer.future.catchError((Object error) { _initBoard(); }); // remove 'unassigned' status final statusIds = _statuses.where((statusId) => statusId.isNotEmpty).toList(); widget.viewModel.onBoardChanged(completer, statusIds, _tasks); } @override Widget build(BuildContext context) { final state = widget.viewModel.state; final color = state.prefState.enableDarkMode ? Theme.of(context).cardColor : Colors.grey.shade300; final boardList = _statuses.map((statusId) { final status = state.taskStatusState.get(statusId); final hasNewTask = _tasks[statusId]?.any((taskId) => parseDouble(taskId) < 0) ?? false; final hasCorectOrder = statusId.isEmpty || status.statusOrder == _statuses.indexOf(status.id) - 1; return BoardList( draggable: status.isOld && hasCorectOrder, backgroundColor: color, headerBackgroundColor: color, onDropList: (endIndex, startIndex) { if (endIndex == startIndex) { return; } setState(() { final status = _statuses[startIndex]; _statuses.removeAt(startIndex); _statuses = [ ..._statuses.sublist(0, endIndex), status, ..._statuses.sublist(endIndex), ]; }); _onBoardChanged(); }, header: [ Expanded( child: _StatusCard( status: status, isSaving: !hasCorectOrder, onSavePressed: (completer, name) { final statusOrder = _statuses.indexOf(statusId); widget.viewModel.onSaveStatusPressed( completer, statusId, name, statusOrder); }, onCancelPressed: () { if (status.isNew) { _statuses.remove(status.id); } }, ), ), ], footer: Align( alignment: Alignment.centerLeft, child: Padding( padding: const EdgeInsets.only(left: 8, top: 2, bottom: 4), child: hasNewTask ? SizedBox() : TextButton( child: Text(AppLocalization.of(context).newTask), onPressed: () { final task = TaskEntity(state: widget.viewModel.state) .rebuild((b) => b..statusId = status.id); setState(() { if (!_tasks.containsKey(status.id)) { _tasks[status.id] = []; } _tasks[status.id].add(task.id); }); }, ), ), ), items: (_tasks[status.id] ?? []) .map((taskId) => widget.viewModel.state.taskState.get(taskId)) .map( (task) { final isVisible = widget.viewModel.filteredTaskList.contains(task.id) || task.isNew; return BoardItem( draggable: task.isOld, item: !isVisible ? SizedBox() : _TaskCard( task: task, isDragging: isDragging, isSaving: (task.statusOrder != _tasks[status.id].indexOf(task.id)) || task.statusId != statusId, onSavePressed: (completer, description) { final statusOrder = _tasks[status.id].indexOf(task.id); widget.viewModel.onSaveTaskPressed( completer, task.id, status.id, description, statusOrder, ); }, onCancelPressed: () { if (task.isNew) { setState(() { _tasks[status.id].remove(task.id); }); } }, ), onStartDragItem: (listIndex, itemIndex, state) { print('## START DRAG'); setState(() => isDragging = true); }, /* onDragItem: (oldListIndex, oldItemIndex, newListIndex, newItemIndex, state) { setState(() => _isDragging = true); }, */ onDropItem: ( int listIndex, int itemIndex, int oldListIndex, int oldItemIndex, BoardItemState state, ) { print('## STOP DRAG'); setState(() => isDragging = false); if (listIndex == oldListIndex && itemIndex == oldItemIndex) { return; } final oldStatusId = _statuses[oldListIndex]; final newStatusId = _statuses[listIndex]; final taskId = _tasks[status.id][oldItemIndex]; setState(() { if (_tasks.containsKey(oldStatusId) && _tasks[oldStatusId].contains(taskId)) { _tasks[oldStatusId].remove(taskId); } if (!_tasks.containsKey(newStatusId)) { _tasks[newStatusId] = []; } _tasks[newStatusId] = [ ..._tasks[newStatusId].sublist(0, itemIndex), taskId, ..._tasks[newStatusId].sublist(itemIndex), ]; }); _onBoardChanged(); }, ); }, ).toList(), ); }).toList(); return Padding( padding: const EdgeInsets.symmetric(vertical: 8), child: Stack( alignment: Alignment.topCenter, children: [ BoardView( boardViewController: _boardViewController, lists: boardList, dragDelay: 1, ), if (state.isLoading || state.isSaving) LinearProgressIndicator(), ], ), ); } } class _TaskCard extends StatefulWidget { const _TaskCard({ @required this.task, @required this.onSavePressed, @required this.onCancelPressed, @required this.isSaving, @required this.isDragging, }); final TaskEntity task; final Function(Completer, String) onSavePressed; final Function() onCancelPressed; final bool isSaving; final bool isDragging; @override __TaskCardState createState() => __TaskCardState(); } class __TaskCardState extends State<_TaskCard> { bool _isEditing = false; bool _isHovered = false; String _description = ''; @override void initState() { super.initState(); final task = widget.task; _description = task.description; _isEditing = task.isNew; } @override Widget build(BuildContext context) { final localization = AppLocalization.of(context); final store = StoreProvider.of(context); final state = store.state; final task = widget.task; final project = state.projectState.get(task.projectId); final client = state.clientState.get(task.clientId); var color = Colors.grey; if (task.projectId.isNotEmpty) { final projectIndex = state.projectState.list.indexOf(task.projectId); color = getColorByIndex(projectIndex); } final isDragging = context.findAncestorStateOfType<_KanbanViewState>().isDragging; if (_isEditing && !widget.isDragging) { return Card( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 8), child: Column( children: [ DecoratedFormField( autofocus: true, initialValue: _description, minLines: 3, maxLines: 10, onChanged: (value) => _description = value, ), SizedBox(height: 8), Row( mainAxisAlignment: MainAxisAlignment.end, children: [ AppTextButton( onPressed: () { setState(() { _isEditing = false; if (task.isNew) { widget.onCancelPressed(); } }); }, label: localization.cancel, ), Padding( padding: const EdgeInsets.only(left: 8), child: ElevatedButton( onPressed: () { final completer = snackBarCompleter( context, localization.updatedTask); completer.future.then((value) { setState(() { _isEditing = false; }); }); widget.onSavePressed(completer, _description.trim()); }, child: Text(localization.save), ), ), ], ) ], ), ), ); } return MouseRegion( onEnter: (event) => setState(() => _isHovered = true), onExit: (event) => setState(() => _isHovered = false), child: InkWell( child: Opacity( opacity: widget.isSaving ? .5 : 1, child: Card( color: Theme.of(context).backgroundColor, child: Padding( padding: const EdgeInsets.all(8), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(task.description, maxLines: 3), SizedBox(height: 8), if (_isHovered && !isDragging) Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ Expanded( child: InkWell( child: Center( child: Text( localization.viewTask, style: TextStyle(fontSize: 12), )), onTap: task.isNew ? null : () { if (state.taskUIState.selectedId == task.id) { viewEntityById( appContext: context.getAppContext(), entityId: '', entityType: EntityType.task, showError: false); } else { viewEntity( appContext: context.getAppContext(), entity: task); } }, ), ), Expanded( child: InkWell( onTap: () { handleEntityAction( context.getAppContext(), task, task.isRunning ? EntityAction.stop : EntityAction.start); }, child: Center( child: Text( task.isRunning ? localization.stopTask : task.getTaskTimes().isEmpty ? localization.startTask : localization.resumeTask, style: TextStyle(fontSize: 12)), ), ), ), ], ) else Row( children: [ LiveText( () { return formatDuration(task.calculateDuration()) + (client.isOld ? ' • ' + client.displayName : '') + (project.isOld ? ' • ' + project.name : ''); }, style: TextStyle( fontSize: 12, fontWeight: FontWeight.w300, ), ), Spacer(), if (task.documents.isNotEmpty) Padding( padding: const EdgeInsets.only(left: 8), child: Icon( MdiIcons.paperclip, size: 16, ), ), if (task.isRunning) Padding( padding: const EdgeInsets.only(left: 8), child: Icon( Icons.play_arrow, size: 16, color: state.accentColor, ), ), SizedBox( width: 8, ), Icon( MdiIcons.briefcaseOutline, color: color, size: 16, ), ], ), ], ), ), ), ), onTap: () { setState(() { _isEditing = true; }); }, ), ); } } class _StatusCard extends StatefulWidget { const _StatusCard({ @required this.status, @required this.onSavePressed, @required this.onCancelPressed, @required this.isSaving, }); final TaskStatusEntity status; final Function(Completer, String) onSavePressed; final Function() onCancelPressed; final bool isSaving; @override __StatusCardState createState() => __StatusCardState(); } class __StatusCardState extends State<_StatusCard> { bool _isEditing = false; String _name = ''; @override void initState() { super.initState(); final status = widget.status; _name = status.name; } void _onSavePressed() { final localization = AppLocalization.of(context); final completer = snackBarCompleter( context, localization.updatedTaskStatus); completer.future.then((value) { setState(() { _isEditing = false; }); }); widget.onSavePressed(completer, _name.trim()); } @override Widget build(BuildContext context) { final localization = AppLocalization.of(context); final status = widget.status; final state = StoreProvider.of(context).state; final color = state.prefState.enableDarkMode ? Theme.of(context).cardColor : Colors.grey.shade300; if (_isEditing) { return Padding( padding: const EdgeInsets.all(8), child: Column( children: [ DecoratedFormField( autofocus: true, initialValue: _name, minLines: 1, maxLines: 1, onChanged: (value) => _name = value, onSavePressed: (context) => _onSavePressed(), ), SizedBox(height: 8), Row( mainAxisAlignment: MainAxisAlignment.end, children: [ AppTextButton( onPressed: () { setState(() { _isEditing = false; if (widget.status.isNew) { widget.onCancelPressed(); } }); }, label: localization.cancel, ), Padding( padding: const EdgeInsets.only(left: 8), child: ElevatedButton( child: Text(localization.save), onPressed: _onSavePressed, ), ), ], ) ], ), ); } return InkWell( child: Padding( padding: const EdgeInsets.all(8), child: Opacity( opacity: widget.isSaving ? .5 : 1, child: Text( status.isNew ? localization.unassigned : status.name, style: TextStyle(fontWeight: FontWeight.w600), ), ), ), onTap: status.isNew ? null : () { setState(() { _isEditing = true; }); }, ); } }