mirror of
https://github.com/go-vikunja/app
synced 2024-06-03 19:19:46 +00:00
buckets edit title, isDoneBucket, & limit; bucket deletion
This commit is contained in:
parent
ad30897bb3
commit
0762a7ae14
77
lib/components/BucketLimitDialog.dart
Normal file
77
lib/components/BucketLimitDialog.dart
Normal file
|
@ -0,0 +1,77 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:vikunja_app/models/bucket.dart';
|
||||
|
||||
class BucketLimitDialog extends StatefulWidget {
|
||||
final Bucket bucket;
|
||||
|
||||
const BucketLimitDialog({Key key, @required this.bucket}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<BucketLimitDialog> createState() => _BucketLimitDialogState();
|
||||
}
|
||||
|
||||
class _BucketLimitDialogState extends State<BucketLimitDialog> {
|
||||
final _controller = TextEditingController();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_controller.text.isEmpty) _controller.text = '${widget.bucket.limit}';
|
||||
return AlertDialog(
|
||||
title: Text('Limit for ${widget.bucket.title}'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _controller,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Limit: ',
|
||||
helperText: 'Set limit of 0 for no limit.',
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: <TextInputFormatter>[
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
],
|
||||
onSubmitted: (text) => Navigator.of(context).pop(int.parse(text)),
|
||||
),
|
||||
),
|
||||
Column(
|
||||
children: <Widget>[
|
||||
IconButton(
|
||||
onPressed: () => _controller.text = '${int.parse(_controller.text) + 1}',
|
||||
icon: Icon(Icons.expand_less),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
final limit = int.parse(_controller.text);
|
||||
_controller.text = '${limit == 0 ? 0 : (limit - 1)}';
|
||||
},
|
||||
icon: Icon(Icons.expand_more),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: <TextButton>[
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(0),
|
||||
child: Text('Remove Limit'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(int.parse(_controller.text)),
|
||||
child: Text('Done'),
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
|
@ -175,14 +175,17 @@ class _BucketTaskCardState extends State<BucketTaskCard> with AutomaticKeepAlive
|
|||
),
|
||||
),
|
||||
),
|
||||
onTap: () => Navigator.push<Task>(
|
||||
context,
|
||||
MaterialPageRoute(builder: (context) => TaskEditPage(
|
||||
task: _currentTask,
|
||||
)),
|
||||
).then((task) => setState(() {
|
||||
if (task != null) _currentTask = task;
|
||||
})),
|
||||
onTap: () {
|
||||
FocusScope.of(context).unfocus();
|
||||
Navigator.push<Task>(
|
||||
context,
|
||||
MaterialPageRoute(builder: (context) => TaskEditPage(
|
||||
task: _currentTask,
|
||||
)),
|
||||
).then((task) => setState(() {
|
||||
if (task != null) _currentTask = task;
|
||||
}));
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
@ -14,13 +14,14 @@ class Task {
|
|||
String title, description;
|
||||
bool done;
|
||||
Color color;
|
||||
double kanbanPosition;
|
||||
User createdBy;
|
||||
Duration repeatAfter;
|
||||
List<Task> subtasks;
|
||||
List<Label> labels;
|
||||
List<TaskAttachment> attachments;
|
||||
bool loading = false;
|
||||
// TODO: add kanbanPosition, position(?)
|
||||
// TODO: add position(?)
|
||||
|
||||
Task(
|
||||
{@required this.id,
|
||||
|
@ -35,6 +36,7 @@ class Task {
|
|||
this.priority,
|
||||
this.repeatAfter,
|
||||
this.color,
|
||||
this.kanbanPosition,
|
||||
this.subtasks,
|
||||
this.labels,
|
||||
this.attachments,
|
||||
|
@ -62,6 +64,9 @@ class Task {
|
|||
color = json['hex_color'] == ''
|
||||
? null
|
||||
: new Color(int.parse(json['hex_color'], radix: 16) + 0xFF000000),
|
||||
kanbanPosition = json['kanban_position'] is int
|
||||
? json['kanban_position'].toDouble()
|
||||
: json['kanban_position'],
|
||||
labels = (json['labels'] as List<dynamic>)
|
||||
?.map((label) => Label.fromJson(label))
|
||||
?.cast<Label>()
|
||||
|
@ -95,6 +100,7 @@ class Task {
|
|||
'priority': priority,
|
||||
'repeat_after': repeatAfter?.inSeconds,
|
||||
'hex_color': color?.value?.toRadixString(16)?.padLeft(8, '0')?.substring(2),
|
||||
'kanban_position': kanbanPosition,
|
||||
'labels': labels?.map((label) => label.toJSON())?.toList(),
|
||||
'subtasks': subtasks?.map((subtask) => subtask.toJSON())?.toList(),
|
||||
'attachments': attachments?.map((attachment) => attachment.toJSON())?.toList(),
|
||||
|
@ -116,6 +122,7 @@ class Task {
|
|||
bool done,
|
||||
Color color,
|
||||
bool resetColor,
|
||||
double kanbanPosition,
|
||||
User createdBy,
|
||||
Duration repeatAfter,
|
||||
List<Task> subtasks,
|
||||
|
@ -137,7 +144,8 @@ class Task {
|
|||
title: title ?? this.title,
|
||||
description: description ?? this.description,
|
||||
done: done ?? this.done,
|
||||
color: (resetColor ?? false) ? null : color ?? this.color,
|
||||
color: (resetColor ?? false) ? null : (color ?? this.color),
|
||||
kanbanPosition: kanbanPosition ?? this.kanbanPosition,
|
||||
createdBy: createdBy ?? this.createdBy,
|
||||
repeatAfter: repeatAfter ?? this.repeatAfter,
|
||||
subtasks: subtasks ?? this.subtasks,
|
||||
|
|
|
@ -5,10 +5,12 @@ import 'dart:ui';
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:flutter_keyboard_visibility/flutter_keyboard_visibility.dart';
|
||||
import 'package:vikunja_app/components/AddDialog.dart';
|
||||
import 'package:vikunja_app/components/TaskTile.dart';
|
||||
import 'package:vikunja_app/components/SliverBucketList.dart';
|
||||
import 'package:vikunja_app/components/SliverBucketPersistentHeader.dart';
|
||||
import 'package:vikunja_app/components/BucketLimitDialog.dart';
|
||||
import 'package:vikunja_app/global.dart';
|
||||
import 'package:vikunja_app/models/list.dart';
|
||||
import 'package:vikunja_app/models/task.dart';
|
||||
|
@ -17,6 +19,16 @@ import 'package:vikunja_app/pages/list/list_edit.dart';
|
|||
import 'package:vikunja_app/pages/list/task_edit.dart';
|
||||
import 'package:vikunja_app/stores/list_store.dart';
|
||||
|
||||
enum BucketMenu {limit, done, delete}
|
||||
|
||||
class BucketProps {
|
||||
final ValueKey<int> key;
|
||||
bool scrollable = false;
|
||||
final ScrollController controller = ScrollController();
|
||||
final TextEditingController titleController = TextEditingController();
|
||||
BucketProps(this.key);
|
||||
}
|
||||
|
||||
class ListPage extends StatefulWidget {
|
||||
final TaskList taskList;
|
||||
|
||||
|
@ -28,6 +40,7 @@ class ListPage extends StatefulWidget {
|
|||
}
|
||||
|
||||
class _ListPageState extends State<ListPage> {
|
||||
final _globalKey = GlobalKey();
|
||||
int _viewIndex = 0;
|
||||
TaskList _list;
|
||||
List<Task> _loadingTasks = [];
|
||||
|
@ -36,9 +49,7 @@ class _ListPageState extends State<ListPage> {
|
|||
bool displayDoneTasks;
|
||||
ListProvider taskState;
|
||||
PageController _pageController;
|
||||
Map<int, ValueKey<int>> _bucketKeys = {};
|
||||
Map<int, bool> _bucketScrollable = {};
|
||||
Map<int, ScrollController> _controllers = {};
|
||||
Map<int, BucketProps> _bucketProps = {};
|
||||
int _draggedBucketIndex;
|
||||
|
||||
@override
|
||||
|
@ -57,74 +68,83 @@ class _ListPageState extends State<ListPage> {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
taskState = Provider.of<ListProvider>(context);
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(_list.title),
|
||||
actions: <Widget>[
|
||||
IconButton(
|
||||
icon: Icon(Icons.edit),
|
||||
onPressed: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => ListEditPage(
|
||||
list: _list,
|
||||
),
|
||||
)).whenComplete(() => _loadList()),
|
||||
),
|
||||
],
|
||||
),
|
||||
// TODO: it brakes the flow with _loadingTasks and conflicts with the provider
|
||||
body: !taskState.isLoading
|
||||
? RefreshIndicator(
|
||||
child: taskState.tasks.length > 0 || taskState.buckets.length > 0
|
||||
? ListenableProvider.value(
|
||||
value: taskState,
|
||||
child: Theme(
|
||||
data: (ThemeData base) {
|
||||
return base.copyWith(
|
||||
chipTheme: base.chipTheme.copyWith(
|
||||
labelPadding: EdgeInsets.symmetric(horizontal: 2),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(5)),
|
||||
KeyboardVisibilityController().onChange.listen((visible) {
|
||||
if (!visible) try {
|
||||
FocusScope.of(_globalKey.currentContext ?? context).unfocus();
|
||||
} catch (e) {}
|
||||
});
|
||||
return GestureDetector(
|
||||
key: _globalKey,
|
||||
onTap: () => FocusScope.of(context).unfocus(),
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(_list.title),
|
||||
actions: <Widget>[
|
||||
IconButton(
|
||||
icon: Icon(Icons.edit),
|
||||
onPressed: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => ListEditPage(
|
||||
list: _list,
|
||||
),
|
||||
)).whenComplete(() => _loadList()),
|
||||
),
|
||||
],
|
||||
),
|
||||
// TODO: it brakes the flow with _loadingTasks and conflicts with the provider
|
||||
body: !taskState.isLoading
|
||||
? RefreshIndicator(
|
||||
child: taskState.tasks.length > 0 || taskState.buckets.length > 0
|
||||
? ListenableProvider.value(
|
||||
value: taskState,
|
||||
child: Theme(
|
||||
data: (ThemeData base) {
|
||||
return base.copyWith(
|
||||
chipTheme: base.chipTheme.copyWith(
|
||||
labelPadding: EdgeInsets.symmetric(horizontal: 2),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(5)),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}(Theme.of(context)),
|
||||
child: () {
|
||||
switch (_viewIndex) {
|
||||
case 0:
|
||||
return _listView(context);
|
||||
case 1:
|
||||
return _kanbanView(context);
|
||||
default:
|
||||
return _listView(context);
|
||||
}
|
||||
}(),
|
||||
),
|
||||
)
|
||||
: Center(child: Text('This list is empty.')),
|
||||
onRefresh: _loadList,
|
||||
)
|
||||
: Center(child: CircularProgressIndicator()),
|
||||
floatingActionButton: Builder(
|
||||
builder: (context) => FloatingActionButton(
|
||||
onPressed: () => _addItemDialog(context), child: Icon(Icons.add)),
|
||||
),
|
||||
bottomNavigationBar: BottomNavigationBar(
|
||||
items: const <BottomNavigationBarItem>[
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(Icons.view_list),
|
||||
label: 'List',
|
||||
tooltip: 'List',
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(Icons.view_kanban),
|
||||
label: 'Kanban',
|
||||
tooltip: 'Kanban',
|
||||
),
|
||||
],
|
||||
currentIndex: _viewIndex,
|
||||
onTap: _onViewTapped,
|
||||
);
|
||||
}(Theme.of(context)),
|
||||
child: () {
|
||||
switch (_viewIndex) {
|
||||
case 0:
|
||||
return _listView(context);
|
||||
case 1:
|
||||
return _kanbanView(context);
|
||||
default:
|
||||
return _listView(context);
|
||||
}
|
||||
}(),
|
||||
),
|
||||
)
|
||||
: Center(child: Text('This list is empty.')),
|
||||
onRefresh: _loadList,
|
||||
)
|
||||
: Center(child: CircularProgressIndicator()),
|
||||
floatingActionButton: _viewIndex == 1 ? null : Builder(
|
||||
builder: (context) => FloatingActionButton(
|
||||
onPressed: () => _addItemDialog(context), child: Icon(Icons.add)),
|
||||
),
|
||||
bottomNavigationBar: BottomNavigationBar(
|
||||
items: const <BottomNavigationBarItem>[
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(Icons.view_list),
|
||||
label: 'List',
|
||||
tooltip: 'List',
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(Icons.view_kanban),
|
||||
label: 'Kanban',
|
||||
tooltip: 'Kanban',
|
||||
),
|
||||
],
|
||||
currentIndex: _viewIndex,
|
||||
onTap: _onViewTapped,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
@ -177,6 +197,7 @@ class _ListPageState extends State<ListPage> {
|
|||
scrollDirection: Axis.horizontal,
|
||||
scrollController: _pageController,
|
||||
physics: PageScrollPhysics(),
|
||||
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
|
||||
itemCount: taskState.buckets.length,
|
||||
itemExtent: bucketWidth,
|
||||
cacheExtent: bucketWidth,
|
||||
|
@ -278,22 +299,27 @@ class _ListPageState extends State<ListPage> {
|
|||
final addTaskButton = ElevatedButton.icon(
|
||||
icon: Icon(Icons.add),
|
||||
label: Text('Add Task'),
|
||||
onPressed: () => _addItemDialog(context, bucket),
|
||||
onPressed: (bucket.tasks?.length ?? -1) < bucket.limit
|
||||
? () => _addItemDialog(context, bucket)
|
||||
: null,
|
||||
);
|
||||
|
||||
if (_controllers[bucket.id] == null) {
|
||||
_controllers[bucket.id] = ScrollController();
|
||||
}
|
||||
if (_bucketKeys[bucket.id] == null) {
|
||||
if (_bucketKeys[bucket.id] == null)
|
||||
_bucketKeys[bucket.id] = ValueKey<int>(bucket.id);
|
||||
if (_bucketProps[bucket.id] == null) {
|
||||
_bucketProps[bucket.id] = BucketProps(ValueKey<int>(bucket.id));
|
||||
SchedulerBinding.instance.addPostFrameCallback((_) {
|
||||
setState(() {
|
||||
_bucketProps[bucket.id].scrollable = _bucketProps[bucket.id].controller.position.maxScrollExtent > 0;
|
||||
});
|
||||
});
|
||||
}
|
||||
if (_bucketProps[bucket.id].titleController.text.isEmpty)
|
||||
_bucketProps[bucket.id].titleController.text = bucket.title;
|
||||
|
||||
return Stack(
|
||||
key: _bucketKeys[bucket.id],
|
||||
key: _bucketProps[bucket.id].key,
|
||||
children: <Widget>[
|
||||
CustomScrollView(
|
||||
controller: _controllers[bucket.id],
|
||||
controller: _bucketProps[bucket.id].controller,
|
||||
slivers: <Widget>[
|
||||
SliverBucketPersistentHeader(
|
||||
minExtent: 56,
|
||||
|
@ -301,11 +327,108 @@ class _ListPageState extends State<ListPage> {
|
|||
child: Material(
|
||||
color: theme.scaffoldBackgroundColor,
|
||||
child: ListTile(
|
||||
title: Text(
|
||||
bucket.title,
|
||||
style: theme.textTheme.titleLarge,
|
||||
minLeadingWidth: 15,
|
||||
horizontalTitleGap: 4,
|
||||
leading: bucket.isDoneBucket ? Icon(
|
||||
Icons.done_all,
|
||||
color: Colors.green,
|
||||
) : null,
|
||||
title: Row(
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _bucketProps[bucket.id].titleController,
|
||||
decoration: const InputDecoration.collapsed(
|
||||
hintText: 'Bucket Title',
|
||||
),
|
||||
style: theme.textTheme.titleLarge,
|
||||
onSubmitted: (title) {
|
||||
if (title.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text(
|
||||
'Bucket title cannot be empty!',
|
||||
style: TextStyle(color: Colors.red),
|
||||
),
|
||||
));
|
||||
return;
|
||||
}
|
||||
bucket.title = title;
|
||||
_updateBucket(context, bucket);
|
||||
},
|
||||
),
|
||||
),
|
||||
if (bucket.limit != 0)
|
||||
Text(
|
||||
'${bucket.tasks?.length ?? 0}/${bucket.limit}',
|
||||
style: theme.textTheme.titleMedium.copyWith(
|
||||
color: (bucket.tasks?.length ?? -1) >= bucket.limit
|
||||
? Colors.red : null,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
trailing: PopupMenuButton<BucketMenu>(
|
||||
child: Icon(Icons.more_vert),
|
||||
onSelected: (item) {
|
||||
switch (item) {
|
||||
case BucketMenu.limit:
|
||||
showDialog<int>(context: context,
|
||||
builder: (_) => BucketLimitDialog(
|
||||
bucket: bucket,
|
||||
),
|
||||
).then((limit) {
|
||||
if (limit != null) {
|
||||
bucket.limit = limit;
|
||||
_updateBucket(context, bucket);
|
||||
}
|
||||
});
|
||||
break;
|
||||
case BucketMenu.done:
|
||||
bucket.isDoneBucket = !bucket.isDoneBucket;
|
||||
_updateBucket(context, bucket);
|
||||
break;
|
||||
case BucketMenu.delete:
|
||||
_deleteBucket(context, bucket);
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) => <PopupMenuEntry<BucketMenu>>[
|
||||
PopupMenuItem<BucketMenu>(
|
||||
value: BucketMenu.limit,
|
||||
child: Text('Limit: ${bucket.limit}'),
|
||||
),
|
||||
PopupMenuItem<BucketMenu>(
|
||||
value: BucketMenu.done,
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 4),
|
||||
child: Icon(
|
||||
Icons.done_all,
|
||||
color: bucket.isDoneBucket ? Colors.green : null,
|
||||
),
|
||||
),
|
||||
Text('Done Bucket'),
|
||||
],
|
||||
),
|
||||
),
|
||||
const PopupMenuDivider(),
|
||||
PopupMenuItem<BucketMenu>(
|
||||
value: BucketMenu.delete,
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
Icon(
|
||||
Icons.delete,
|
||||
color: Colors.red,
|
||||
),
|
||||
Text(
|
||||
'Delete',
|
||||
style: TextStyle(color: Colors.red),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
trailing: Icon(Icons.more_vert),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
@ -313,19 +436,10 @@ class _ListPageState extends State<ListPage> {
|
|||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
sliver: SliverBucketList(
|
||||
bucket: bucket,
|
||||
onLast: () {
|
||||
if (_bucketScrollable[bucket.id] == null) {
|
||||
SchedulerBinding.instance.addPostFrameCallback((_) {
|
||||
setState(() {
|
||||
_bucketScrollable[bucket.id] = _controllers[bucket.id].position.maxScrollExtent > 0;
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
SliverVisibility(
|
||||
visible: !(_bucketScrollable[bucket.id] ?? false),
|
||||
visible: !_bucketProps[bucket.id].scrollable,
|
||||
maintainState: true,
|
||||
maintainAnimation: true,
|
||||
maintainSize: true,
|
||||
|
@ -339,7 +453,7 @@ class _ListPageState extends State<ListPage> {
|
|||
),
|
||||
],
|
||||
),
|
||||
if (_bucketScrollable[bucket.id] ?? false) Align(
|
||||
if (_bucketProps[bucket.id].scrollable) Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: addTaskButton,
|
||||
),
|
||||
|
@ -444,6 +558,7 @@ class _ListPageState extends State<ListPage> {
|
|||
}
|
||||
|
||||
_addBucketDialog(BuildContext context) {
|
||||
FocusScope.of(context).unfocus();
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (_) => AddDialog(
|
||||
|
@ -478,6 +593,43 @@ class _ListPageState extends State<ListPage> {
|
|||
await Provider.of<ListProvider>(context, listen: false).updateBucket(
|
||||
context: context,
|
||||
bucket: bucket,
|
||||
);
|
||||
).then((_) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text('${bucket.title} bucket updated successfully!'),
|
||||
));
|
||||
setState(() {});
|
||||
});
|
||||
}
|
||||
|
||||
_deleteBucket(BuildContext context, Bucket bucket) {
|
||||
if ((bucket.tasks?.length ?? 0) > 0) {
|
||||
int defaultBucketId = taskState.buckets[0]?.id ?? 100;
|
||||
taskState.buckets.forEach((b) {
|
||||
if (b.id < defaultBucketId)
|
||||
defaultBucketId = b.id;
|
||||
});
|
||||
final defaultBucketIndex = taskState.buckets.indexWhere((b) => b.id == defaultBucketId);
|
||||
bucket.tasks.forEach((task) {
|
||||
taskState.buckets[defaultBucketIndex].tasks.add(task.copyWith(
|
||||
bucketId: defaultBucketId,
|
||||
kanbanPosition: taskState.buckets[defaultBucketIndex].tasks.last.kanbanPosition + 1.0,
|
||||
));
|
||||
});
|
||||
}
|
||||
Provider.of<ListProvider>(context, listen: false).deleteBucket(
|
||||
context: context,
|
||||
listId: bucket.listId,
|
||||
bucketId: bucket.id,
|
||||
).then((_) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Row(
|
||||
children: <Widget>[
|
||||
Text('${bucket.title} was deleted.'),
|
||||
Icon(Icons.delete),
|
||||
],
|
||||
),
|
||||
));
|
||||
setState(() {});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -65,282 +65,285 @@ class _TaskEditPageState extends State<TaskEditPage> {
|
|||
}
|
||||
return new Future(() => true);
|
||||
},
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('Edit Task'),
|
||||
),
|
||||
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(),
|
||||
child: GestureDetector(
|
||||
onTap: () => FocusScope.of(context).unfocus(),
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('Edit Task'),
|
||||
),
|
||||
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.length > 1000) {
|
||||
return 'The description can have a maximum of 1000 characters.';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
decoration: new InputDecoration(
|
||||
labelText: 'Description',
|
||||
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.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(
|
||||
widget.task.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(
|
||||
widget.task.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(null);
|
||||
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,
|
||||
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(
|
||||
widget.task.repeatAfter)?.toString(),
|
||||
onSaved: (repeatAfter) => _repeatAfter =
|
||||
getDurationFromType(repeatAfter, _repeatAfterType),
|
||||
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(),
|
||||
),
|
||||
),
|
||||
Wrap(
|
||||
spacing: 10,
|
||||
children: _labels.map((Label label) {
|
||||
return LabelComponent(
|
||||
label: label,
|
||||
onDelete: () {
|
||||
_removeLabel(label);
|
||||
},
|
||||
);
|
||||
}).toList()),
|
||||
Row(
|
||||
children: <Widget>[
|
||||
Container(
|
||||
width: MediaQuery.of(context).size.width - 80,
|
||||
child: TypeAheadFormField(
|
||||
textFieldConfiguration: TextFieldConfiguration(
|
||||
controller: _labelTypeAheadController,
|
||||
decoration:
|
||||
InputDecoration(labelText: 'Add a new label')),
|
||||
suggestionsCallback: (pattern) => _searchLabel(pattern),
|
||||
itemBuilder: (context, suggestion) {
|
||||
return Text(suggestion);
|
||||
},
|
||||
transitionBuilder: (context, suggestionsBox, controller) {
|
||||
return suggestionsBox;
|
||||
},
|
||||
onSuggestionSelected: (suggestion) {
|
||||
_addLabel(suggestion);
|
||||
},
|
||||
),
|
||||
),
|
||||
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(
|
||||
'Color',
|
||||
style: _resetColor || (_color ?? widget.task.color) == null ? null : TextStyle(
|
||||
color: (_color ?? widget.task.color)
|
||||
.computeLuminance() > 0.5 ? Colors.black : Colors.white,
|
||||
decoration: new InputDecoration(
|
||||
labelText: 'Repeat after',
|
||||
border: InputBorder.none,
|
||||
icon: Icon(Icons.repeat),
|
||||
),
|
||||
),
|
||||
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,
|
||||
),
|
||||
);
|
||||
}(),
|
||||
Expanded(
|
||||
child: DropdownButton<String>(
|
||||
isExpanded: true,
|
||||
isDense: true,
|
||||
value: _repeatAfterType ??
|
||||
getRepeatAfterTypeFromDuration(
|
||||
widget.task.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(null);
|
||||
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,
|
||||
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(),
|
||||
),
|
||||
),
|
||||
Wrap(
|
||||
spacing: 10,
|
||||
children: _labels.map((Label label) {
|
||||
return LabelComponent(
|
||||
label: label,
|
||||
onDelete: () {
|
||||
_removeLabel(label);
|
||||
},
|
||||
);
|
||||
}).toList()),
|
||||
Row(
|
||||
children: <Widget>[
|
||||
Container(
|
||||
width: MediaQuery.of(context).size.width - 80,
|
||||
child: TypeAheadFormField(
|
||||
textFieldConfiguration: TextFieldConfiguration(
|
||||
controller: _labelTypeAheadController,
|
||||
decoration:
|
||||
InputDecoration(labelText: 'Add a new label')),
|
||||
suggestionsCallback: (pattern) => _searchLabel(pattern),
|
||||
itemBuilder: (context, suggestion) {
|
||||
return Text(suggestion);
|
||||
},
|
||||
transitionBuilder: (context, suggestionsBox, controller) {
|
||||
return suggestionsBox;
|
||||
},
|
||||
onSuggestionSelected: (suggestion) {
|
||||
_addLabel(suggestion);
|
||||
},
|
||||
),
|
||||
),
|
||||
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(
|
||||
'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),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: !_loading ? () {
|
||||
if (_formKey.currentState.validate()) {
|
||||
Form.of(_listKey.currentContext).save();
|
||||
_saveTask(_listKey.currentContext);
|
||||
}
|
||||
} : null,
|
||||
child: Icon(Icons.save),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
|
@ -98,7 +98,7 @@ class ListProvider with ChangeNotifier {
|
|||
|
||||
Future<void> addTask({BuildContext context, Task newTask, int listId}) {
|
||||
var globalState = VikunjaGlobal.of(context);
|
||||
_isLoading = true;
|
||||
if (newTask.bucketId == null) _isLoading = true;
|
||||
notifyListeners();
|
||||
|
||||
return globalState.taskService.add(listId, newTask).then((task) {
|
||||
|
@ -137,12 +137,10 @@ class ListProvider with ChangeNotifier {
|
|||
}
|
||||
|
||||
Future<void> addBucket({BuildContext context, Bucket newBucket, int listId}) {
|
||||
_isLoading = true;
|
||||
notifyListeners();
|
||||
return VikunjaGlobal.of(context).bucketService.add(listId, newBucket)
|
||||
.then((bucket) {
|
||||
_buckets.add(bucket);
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
});
|
||||
}
|
||||
|
@ -151,6 +149,15 @@ class ListProvider with ChangeNotifier {
|
|||
return VikunjaGlobal.of(context).bucketService.update(bucket)
|
||||
.then((rBucket) {
|
||||
_buckets[_buckets.indexWhere((b) => rBucket.id == b.id)] = rBucket;
|
||||
_buckets.sort((a, b) => a.position.compareTo(b.position));
|
||||
notifyListeners();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> deleteBucket({BuildContext context, int listId, int bucketId}) {
|
||||
return VikunjaGlobal.of(context).bucketService.delete(listId, bucketId)
|
||||
.then((_) {
|
||||
_buckets.removeWhere((bucket) => bucket.id == bucketId);
|
||||
notifyListeners();
|
||||
});
|
||||
}
|
||||
|
|
|
@ -182,7 +182,7 @@ packages:
|
|||
source: hosted
|
||||
version: "1.0.3"
|
||||
flutter_keyboard_visibility:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_keyboard_visibility
|
||||
url: "https://pub.dartlang.org"
|
||||
|
|
|
@ -25,6 +25,7 @@ dependencies:
|
|||
provider: ^6.0.3
|
||||
webview_flutter: ^3.0.4
|
||||
flutter_colorpicker: ^1.0.3
|
||||
flutter_keyboard_visibility: ^5.3.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
|
Loading…
Reference in New Issue
Block a user