629 lines
21 KiB
Dart
629 lines
21 KiB
Dart
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<KanbanView> {
|
|
final _boardViewController = new BoardViewController();
|
|
|
|
List<String> _statuses;
|
|
Map<String, List<String>> _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<Null>(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<TaskEntity>, 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<AppState>(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<TaskEntity>(
|
|
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<TaskStatusEntity>, 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<TaskStatusEntity>(
|
|
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<AppState>(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;
|
|
});
|
|
},
|
|
);
|
|
}
|
|
}
|