mirror of
https://github.com/go-vikunja/app
synced 2024-06-05 03:59:48 +00:00
662 lines
24 KiB
Dart
662 lines
24 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_typeahead/flutter_typeahead.dart';
|
|
import 'package:flutter_colorpicker/flutter_colorpicker.dart';
|
|
import 'package:vikunja_app/components/datetimePicker.dart';
|
|
import 'package:vikunja_app/components/label.dart';
|
|
import 'package:vikunja_app/global.dart';
|
|
import 'package:vikunja_app/models/label.dart';
|
|
import 'package:vikunja_app/models/task.dart';
|
|
import 'package:vikunja_app/utils/repeat_after_parse.dart';
|
|
import 'package:vikunja_app/utils/priority.dart';
|
|
|
|
import '../../stores/project_store.dart';
|
|
|
|
class TaskEditPage extends StatefulWidget {
|
|
final Task task;
|
|
final ProjectProvider taskState;
|
|
|
|
TaskEditPage({
|
|
required this.task,
|
|
required this.taskState,
|
|
}) : super(key: Key(task.toString()));
|
|
|
|
@override
|
|
State<StatefulWidget> createState() => _TaskEditPageState();
|
|
}
|
|
|
|
class _TaskEditPageState extends State<TaskEditPage> {
|
|
final _formKey = GlobalKey<FormState>();
|
|
final _listKey = GlobalKey();
|
|
bool _loading = false;
|
|
bool _changed = false;
|
|
|
|
int? _priority;
|
|
DateTime? _dueDate, _startDate, _endDate;
|
|
late final List<DateTime> _reminderDates;
|
|
String? _title, _description, _repeatAfterType;
|
|
Duration? _repeatAfter;
|
|
late final List<Label> _labels;
|
|
// we use this to find the label object after a user taps on the suggestion, because the typeahead only uses strings, not full objects.
|
|
List<Label>? _suggestedLabels;
|
|
final _reminderInputs = <Widget>[];
|
|
final _labelTypeAheadController = TextEditingController();
|
|
Color? _color;
|
|
Color? _pickerColor;
|
|
bool _resetColor = false;
|
|
|
|
@override
|
|
void initState() {
|
|
_repeatAfter = widget.task.repeatAfter;
|
|
if(_repeatAfterType == null)
|
|
_repeatAfterType = getRepeatAfterTypeFromDuration(_repeatAfter);
|
|
|
|
_reminderDates = widget.task.reminderDates;
|
|
for (var i = 0; i < _reminderDates.length; i++) {
|
|
_reminderInputs.add(VikunjaDateTimePicker(
|
|
initialValue: _reminderDates[i],
|
|
label: 'Reminder',
|
|
onSaved: (reminder) {
|
|
_reminderDates[i] = reminder ?? DateTime(0);
|
|
return null;
|
|
},
|
|
));
|
|
}
|
|
|
|
_labels = widget.task.labels;
|
|
_priority = widget.task.priority;
|
|
|
|
super.initState();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext ctx) {
|
|
return WillPopScope(
|
|
onWillPop: () {
|
|
if (_changed) {
|
|
return (_showConfirmationDialog());
|
|
}
|
|
return new Future(() => true);
|
|
},
|
|
child: GestureDetector(
|
|
onTap: () => FocusScope.of(context).unfocus(),
|
|
child: Scaffold(
|
|
appBar: AppBar(
|
|
title: Text('Edit Task'),
|
|
actions: [
|
|
IconButton(
|
|
icon: Icon(Icons.delete),
|
|
onPressed: () {showDialog(context: context, builder: (BuildContext context) {
|
|
return AlertDialog(
|
|
title: Text('Delete Task'),
|
|
content: Text('Are you sure you want to delete this task?'),
|
|
actions: [
|
|
TextButton(
|
|
child: Text('Cancel'),
|
|
onPressed: () => Navigator.of(context).pop(),
|
|
),
|
|
TextButton(
|
|
child: Text('Delete'),
|
|
onPressed: () {
|
|
_delete(widget.task.id);
|
|
Navigator.of(context).pop();
|
|
},
|
|
),
|
|
],
|
|
);
|
|
});},
|
|
),
|
|
],
|
|
),
|
|
body: Builder(
|
|
builder: (BuildContext context) => SafeArea(
|
|
child: Form(
|
|
key: _formKey,
|
|
child: ListView(
|
|
key: _listKey,
|
|
padding: const EdgeInsets.all(16.0),
|
|
children: <Widget>[
|
|
Padding(
|
|
padding: EdgeInsets.symmetric(vertical: 10.0),
|
|
child: TextFormField(
|
|
maxLines: null,
|
|
keyboardType: TextInputType.multiline,
|
|
initialValue: widget.task.title,
|
|
onSaved: (title) => _title = title,
|
|
onChanged: (_) => _changed = true,
|
|
validator: (title) {
|
|
//if (title.length < 3 || title.length > 250) {
|
|
// return 'The title needs to have between 3 and 250 characters.';
|
|
//}
|
|
return null;
|
|
},
|
|
decoration: new InputDecoration(
|
|
labelText: 'Title',
|
|
border: OutlineInputBorder(),
|
|
),
|
|
),
|
|
),
|
|
Padding(
|
|
padding: EdgeInsets.symmetric(vertical: 10.0),
|
|
child: TextFormField(
|
|
maxLines: null,
|
|
keyboardType: TextInputType.multiline,
|
|
initialValue: widget.task.description,
|
|
onSaved: (description) => _description = description,
|
|
onChanged: (_) => _changed = true,
|
|
validator: (description) {
|
|
if (description == null) return null;
|
|
if (description.length > 1000) {
|
|
return 'The description can have a maximum of 1000 characters.';
|
|
}
|
|
return null;
|
|
},
|
|
decoration: new InputDecoration(
|
|
labelText: 'Description',
|
|
border: OutlineInputBorder(),
|
|
),
|
|
),
|
|
),
|
|
VikunjaDateTimePicker(
|
|
icon: Icon(Icons.access_time),
|
|
label: 'Due Date',
|
|
initialValue: widget.task.dueDate,
|
|
onSaved: (duedate) => _dueDate = duedate,
|
|
onChanged: (_) => _changed = true,
|
|
),
|
|
VikunjaDateTimePicker(
|
|
label: 'Start Date',
|
|
initialValue: widget.task.startDate,
|
|
onSaved: (startDate) => _startDate = startDate,
|
|
onChanged: (_) => _changed = true,
|
|
),
|
|
VikunjaDateTimePicker(
|
|
label: 'End Date',
|
|
initialValue: widget.task.endDate,
|
|
onSaved: (endDate) => _endDate = endDate,
|
|
onChanged: (_) => _changed = true,
|
|
),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
flex: 2,
|
|
child: TextFormField(
|
|
keyboardType: TextInputType.number,
|
|
initialValue: getRepeatAfterValueFromDuration(
|
|
_repeatAfter)?.toString(),
|
|
onSaved: (repeatAfter) => _repeatAfter =
|
|
getDurationFromType(
|
|
repeatAfter, _repeatAfterType),
|
|
onChanged: (_) => _changed = true,
|
|
decoration: new InputDecoration(
|
|
labelText: 'Repeat after',
|
|
border: InputBorder.none,
|
|
icon: Icon(Icons.repeat),
|
|
),
|
|
),
|
|
),
|
|
Expanded(
|
|
child: DropdownButton<String>(
|
|
isExpanded: true,
|
|
isDense: true,
|
|
value: _repeatAfterType ??
|
|
getRepeatAfterTypeFromDuration(
|
|
_repeatAfter),
|
|
onChanged: (String? newValue) {
|
|
setState(() {
|
|
_repeatAfterType = newValue;
|
|
});
|
|
},
|
|
items: <String>[
|
|
'Hours',
|
|
'Days',
|
|
'Weeks',
|
|
'Months',
|
|
'Years'
|
|
].map<DropdownMenuItem<String>>((String value) {
|
|
return DropdownMenuItem<String>(
|
|
value: value,
|
|
child: Text(value),
|
|
);
|
|
}).toList(),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
Column(
|
|
children: _reminderInputs,
|
|
),
|
|
GestureDetector(
|
|
child: Padding(
|
|
padding: EdgeInsets.symmetric(vertical: 10),
|
|
child: Row(
|
|
children: <Widget>[
|
|
Padding(
|
|
padding: EdgeInsets.only(right: 15, left: 2),
|
|
child: Icon(
|
|
Icons.alarm_add,
|
|
color: Colors.grey,
|
|
)),
|
|
Text(
|
|
'Add a reminder',
|
|
style: TextStyle(
|
|
color: Colors.grey,
|
|
fontSize: 16,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
onTap: () {
|
|
// We add a new entry every time we add a new input, to make sure all inputs have a place where they can put their value.
|
|
_reminderDates.add(DateTime(0));
|
|
var currentIndex = _reminderDates.length - 1;
|
|
|
|
// FIXME: Why does putting this into a row fails?
|
|
setState(() => _reminderInputs.add(
|
|
VikunjaDateTimePicker(
|
|
label: 'Reminder',
|
|
onSaved: (reminder) =>
|
|
_reminderDates[currentIndex] =
|
|
reminder ?? DateTime(0),
|
|
onChanged: (_) => _changed = true,
|
|
initialValue: DateTime.now(),
|
|
),
|
|
));
|
|
}),
|
|
InputDecorator(
|
|
isEmpty: _priority == null,
|
|
decoration: InputDecoration(
|
|
icon: const Icon(Icons.flag),
|
|
labelText: 'Priority',
|
|
border: InputBorder.none,
|
|
),
|
|
child: new DropdownButton<String>(
|
|
value: priorityToString(_priority),
|
|
isExpanded: true,
|
|
isDense: true,
|
|
onChanged: (String? newValue) {
|
|
setState(() {
|
|
_priority = priorityFromString(newValue);
|
|
});
|
|
},
|
|
items: [
|
|
'Unset',
|
|
'Low',
|
|
'Medium',
|
|
'High',
|
|
'Urgent',
|
|
'DO NOW'
|
|
].map((String value) {
|
|
return new DropdownMenuItem(
|
|
value: value,
|
|
child: new Text(value),
|
|
);
|
|
}).toList(),
|
|
),
|
|
),
|
|
Row(
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.only(right: 15, left: 2),
|
|
child: Icon(
|
|
Icons.label,
|
|
color: Colors.grey,
|
|
),
|
|
),
|
|
Wrap(
|
|
spacing: 10,
|
|
children: _labels.map((Label label) {
|
|
return LabelComponent(
|
|
label: label,
|
|
onDelete: () {
|
|
_removeLabel(label);
|
|
},
|
|
);
|
|
}).toList()),
|
|
],
|
|
),
|
|
Row(
|
|
children: <Widget>[
|
|
Padding(
|
|
padding: EdgeInsets.only(
|
|
right: 15,
|
|
left: 2.0 + (IconTheme.of(context).size??0))),
|
|
Container(
|
|
width: MediaQuery.of(context).size.width - 80 - ((IconTheme.of(context).size ?? 0) * 2),
|
|
child: TypeAheadField(
|
|
builder: (builder, controller, focusnode) {
|
|
return TextFormField(
|
|
controller: _labelTypeAheadController,
|
|
focusNode: focusnode,
|
|
decoration: InputDecoration(
|
|
labelText: 'Add a new label',
|
|
border: InputBorder.none,
|
|
),
|
|
);
|
|
},
|
|
suggestionsCallback: (pattern) =>
|
|
_searchLabel(pattern),
|
|
itemBuilder: (context, suggestion) {
|
|
return new ListTile(
|
|
title: Text(suggestion.toString()));
|
|
},
|
|
//transitionBuilder:
|
|
// (context, suggestionsBox, controller) {
|
|
// return suggestionsBox;
|
|
//},
|
|
onSelected: (suggestion) {
|
|
_addLabel(suggestion.toString());
|
|
},
|
|
),
|
|
),
|
|
IconButton(
|
|
onPressed: () => _createAndAddLabel(
|
|
_labelTypeAheadController.text),
|
|
icon: Icon(Icons.add),
|
|
)
|
|
],
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 15),
|
|
child: Row(
|
|
children: <Widget>[
|
|
Padding(
|
|
padding: const EdgeInsets.only(right: 15, left: 2),
|
|
child: Icon(
|
|
Icons.palette,
|
|
color: Colors.grey,
|
|
),
|
|
),
|
|
ElevatedButton(
|
|
child: Text(
|
|
'Set Color',
|
|
style: (_resetColor ||
|
|
(_color ?? widget.task.color) == null)
|
|
? null
|
|
: TextStyle(
|
|
color: (_color ?? widget.task.color)!
|
|
.computeLuminance() >
|
|
0.5
|
|
? Colors.black
|
|
: Colors.white,
|
|
),
|
|
),
|
|
style: _resetColor
|
|
? null
|
|
: ButtonStyle(
|
|
backgroundColor:
|
|
MaterialStateProperty.resolveWith(
|
|
(_) => _color ?? widget.task.color),
|
|
),
|
|
onPressed: _onColorEdit,
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.only(left: 15),
|
|
child: () {
|
|
String? colorString = (_resetColor
|
|
? null
|
|
: (_color ?? widget.task.color))
|
|
?.toString();
|
|
colorString = colorString
|
|
?.substring(10, colorString.length - 1)
|
|
.toUpperCase();
|
|
colorString = colorString != null
|
|
? '#$colorString'
|
|
: 'None';
|
|
return Text(
|
|
'$colorString',
|
|
style: TextStyle(
|
|
color: Colors.grey,
|
|
fontStyle: FontStyle.italic,
|
|
),
|
|
);
|
|
}(),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
floatingActionButton: FloatingActionButton(
|
|
onPressed: !_loading
|
|
? () {
|
|
if (_formKey.currentState!.validate()) {
|
|
Form.of(_listKey.currentContext!)!.save();
|
|
_saveTask(_listKey.currentContext!);
|
|
}
|
|
}
|
|
: null,
|
|
child: Icon(Icons.save),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
_saveTask(BuildContext context) async {
|
|
setState(() => _loading = true);
|
|
|
|
// Removes all reminders with no value set.
|
|
_reminderDates.removeWhere((d) => d == DateTime(0));
|
|
|
|
final updatedTask = widget.task.copyWith(
|
|
title: _title,
|
|
description: _description,
|
|
reminderDates: _reminderDates,
|
|
priority: _priority,
|
|
labels: _labels,
|
|
repeatAfter: _repeatAfter,
|
|
)
|
|
..dueDate = _dueDate
|
|
..startDate = _startDate
|
|
..endDate = _endDate
|
|
..color = _resetColor ? null : (_color ?? widget.task.color)
|
|
..repeatAfter = _repeatAfter;
|
|
|
|
// update the labels
|
|
await VikunjaGlobal.of(context)
|
|
.labelTaskBulkService
|
|
.update(updatedTask, _labels)
|
|
.catchError((err) {
|
|
setState(() => _loading = false);
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text('Something went wrong: ' + err.toString()),
|
|
),
|
|
);
|
|
});
|
|
|
|
widget.taskState
|
|
.updateTask(
|
|
context: context,
|
|
task: updatedTask,
|
|
)
|
|
.then((task) {
|
|
setState(() {
|
|
_loading = false;
|
|
_changed = false;
|
|
});
|
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
|
content: Text('The task was updated successfully!'),
|
|
));
|
|
Navigator.of(context).pop(task);
|
|
}).catchError((err) {
|
|
setState(() => _loading = false);
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text('Something went wrong: ' + err.toString()),
|
|
action: SnackBarAction(
|
|
label: 'CLOSE',
|
|
onPressed: ScaffoldMessenger.of(context).hideCurrentSnackBar),
|
|
),
|
|
);
|
|
});
|
|
}
|
|
|
|
_removeLabel(Label label) {
|
|
setState(() {
|
|
_labels.removeWhere((l) => l.id == label.id);
|
|
});
|
|
}
|
|
|
|
_delete(int taskId) {
|
|
VikunjaGlobal.of(context).taskService.delete(taskId);
|
|
Navigator.pop(context);
|
|
}
|
|
|
|
_searchLabel(String query) {
|
|
return VikunjaGlobal.of(context)
|
|
.labelService
|
|
.getAll(query: query)
|
|
.then((labels) {
|
|
// Only show those labels which aren't already added to the task
|
|
if (labels == null) return [];
|
|
labels.removeWhere((labelToRemove) => _labels.contains(labelToRemove));
|
|
_suggestedLabels = labels;
|
|
List<String?> labelText = labels.map((label) => label.title).toList();
|
|
return labelText;
|
|
});
|
|
}
|
|
|
|
_addLabel(String labelTitle) {
|
|
// FIXME: This is not an optimal solution...
|
|
bool found = false;
|
|
_suggestedLabels?.forEach((label) {
|
|
if (label.title == labelTitle) {
|
|
_labels.add(label);
|
|
found = true;
|
|
}
|
|
});
|
|
|
|
if (found) {
|
|
_labelTypeAheadController.clear();
|
|
}
|
|
setState(() {});
|
|
}
|
|
|
|
void _createAndAddLabel(String labelTitle) {
|
|
// Only add a label if there are none to add
|
|
if (labelTitle.isEmpty || (_suggestedLabels?.isNotEmpty ?? false)) {
|
|
return;
|
|
}
|
|
|
|
final currentUser = VikunjaGlobal.of(context).currentUser;
|
|
if (currentUser == null) {
|
|
return;
|
|
}
|
|
|
|
final newLabel = Label(
|
|
title: labelTitle,
|
|
createdBy: currentUser,
|
|
);
|
|
VikunjaGlobal.of(context)
|
|
.labelService
|
|
.create(newLabel)
|
|
.then((createdLabel) {
|
|
if (createdLabel == null) return null;
|
|
setState(() {
|
|
_labels.add(createdLabel);
|
|
_labelTypeAheadController.clear();
|
|
});
|
|
});
|
|
}
|
|
|
|
|
|
|
|
_onColorEdit() {
|
|
_pickerColor = _resetColor || (_color ?? widget.task.color) == null
|
|
? Colors.black
|
|
: _color ?? widget.task.color;
|
|
showDialog(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
title: const Text('Task Color'),
|
|
content: SingleChildScrollView(
|
|
child: ColorPicker(
|
|
pickerColor: _pickerColor!,
|
|
enableAlpha: false,
|
|
labelTypes: const [ColorLabelType.hsl, ColorLabelType.rgb],
|
|
paletteType: PaletteType.hslWithLightness,
|
|
hexInputBar: true,
|
|
onColorChanged: (color) => setState(() => _pickerColor = color),
|
|
),
|
|
),
|
|
actions: <TextButton>[
|
|
TextButton(
|
|
child: Text('CANCEL'),
|
|
onPressed: () => Navigator.of(context).pop(),
|
|
),
|
|
TextButton(
|
|
child: Text('RESET'),
|
|
onPressed: () {
|
|
setState(() {
|
|
_color = null;
|
|
_resetColor = true;
|
|
_changed = _color != widget.task.color;
|
|
});
|
|
Navigator.of(context).pop();
|
|
},
|
|
),
|
|
TextButton(
|
|
child: Text('OK'),
|
|
onPressed: () {
|
|
if (_pickerColor != Colors.black)
|
|
setState(() {
|
|
_color = _pickerColor;
|
|
_resetColor = false;
|
|
_changed = _color != widget.task.color;
|
|
});
|
|
else
|
|
setState(() {
|
|
_color = null;
|
|
_resetColor = true;
|
|
_changed = _color != widget.task.color;
|
|
});
|
|
Navigator.of(context).pop();
|
|
},
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<bool> _showConfirmationDialog() async {
|
|
return await showDialog<bool>(
|
|
context: context,
|
|
barrierDismissible: false,
|
|
builder: (BuildContext context) {
|
|
return AlertDialog(
|
|
title: Text('You have unsaved changes!'),
|
|
content: SingleChildScrollView(
|
|
child: Column(
|
|
children: <Widget>[
|
|
Text('Would you like to dismiss those changes?'),
|
|
],
|
|
),
|
|
),
|
|
actions: <Widget>[
|
|
TextButton(
|
|
child: Text('Dismiss'),
|
|
onPressed: () {
|
|
Navigator.pop(context);
|
|
// make sure the list is refreshed
|
|
Navigator.pop(context);
|
|
},
|
|
),
|
|
TextButton(
|
|
child: Text('Keep editing'),
|
|
onPressed: () {
|
|
Navigator.pop(context);
|
|
},
|
|
),
|
|
],
|
|
);
|
|
},
|
|
) ??
|
|
false;
|
|
}
|
|
}
|