Kanban
This commit is contained in:
parent
a3a72e8b79
commit
1bb3455d2a
|
|
@ -0,0 +1,244 @@
|
|||
import 'dart:async';
|
||||
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/ui/task/kanban/kanban_view.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/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 KanbanTaskCard extends StatefulWidget {
|
||||
const KanbanTaskCard({
|
||||
@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
|
||||
_KanbanTaskCardState createState() => _KanbanTaskCardState();
|
||||
}
|
||||
|
||||
class _KanbanTaskCardState extends State<KanbanTaskCard> {
|
||||
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;
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,123 @@
|
|||
import 'dart:async';
|
||||
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/data/models/models.dart';
|
||||
import 'package:invoiceninja_flutter/ui/app/forms/decorated_form_field.dart';
|
||||
import 'package:invoiceninja_flutter/utils/completers.dart';
|
||||
import 'package:invoiceninja_flutter/utils/localization.dart';
|
||||
|
||||
class KanbanStatusCard extends StatefulWidget {
|
||||
const KanbanStatusCard({
|
||||
@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
|
||||
_KanbanStatusCardState createState() => _KanbanStatusCardState();
|
||||
}
|
||||
|
||||
class _KanbanStatusCardState extends State<KanbanStatusCard> {
|
||||
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;
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,278 @@
|
|||
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:invoiceninja_flutter/ui/task/kanban/kanban_card.dart';
|
||||
import 'package:invoiceninja_flutter/ui/task/kanban/kanban_status.dart';
|
||||
import 'package:invoiceninja_flutter/ui/task/kanban/kanban_view_vm.dart';
|
||||
import 'package:invoiceninja_flutter/data/models/models.dart';
|
||||
import 'package:invoiceninja_flutter/utils/completers.dart';
|
||||
import 'package:invoiceninja_flutter/utils/formatting.dart';
|
||||
import 'package:invoiceninja_flutter/utils/localization.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: KanbanStatusCard(
|
||||
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()
|
||||
: KanbanTaskCard(
|
||||
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(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_redux/flutter_redux.dart';
|
||||
import 'package:invoiceninja_flutter/data/models/models.dart';
|
||||
|
|
@ -9,7 +8,7 @@ import 'package:invoiceninja_flutter/redux/auth/auth_reducer.dart';
|
|||
import 'package:invoiceninja_flutter/redux/task/task_actions.dart';
|
||||
import 'package:invoiceninja_flutter/redux/task/task_selectors.dart';
|
||||
import 'package:invoiceninja_flutter/redux/task_status/task_status_actions.dart';
|
||||
import 'package:invoiceninja_flutter/ui/task/kanban_view.dart';
|
||||
import 'package:invoiceninja_flutter/ui/task/kanban/kanban_view.dart';
|
||||
import 'package:invoiceninja_flutter/utils/completers.dart';
|
||||
import 'package:invoiceninja_flutter/utils/localization.dart';
|
||||
import 'package:redux/redux.dart';
|
||||
|
|
@ -1,628 +0,0 @@
|
|||
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;
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -6,7 +6,7 @@ import 'package:invoiceninja_flutter/redux/app/app_actions.dart';
|
|||
import 'package:invoiceninja_flutter/redux/settings/settings_actions.dart';
|
||||
import 'package:invoiceninja_flutter/ui/app/list_scaffold.dart';
|
||||
import 'package:invoiceninja_flutter/ui/app/list_filter.dart';
|
||||
import 'package:invoiceninja_flutter/ui/task/kanban_view_vm.dart';
|
||||
import 'package:invoiceninja_flutter/ui/task/kanban/kanban_view_vm.dart';
|
||||
import 'package:invoiceninja_flutter/ui/task/task_presenter.dart';
|
||||
import 'package:invoiceninja_flutter/ui/task/task_screen_vm.dart';
|
||||
import 'package:invoiceninja_flutter/utils/icons.dart';
|
||||
|
|
|
|||
Loading…
Reference in New Issue