app-mirror-github/lib/pages/list/list.dart

734 lines
27 KiB
Dart

import 'dart:async';
import 'dart:math';
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:provider/provider.dart';
import 'package:dotted_border/dotted_border.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/components/BucketTaskCard.dart';
import 'package:vikunja_app/global.dart';
import 'package:vikunja_app/models/list.dart';
import 'package:vikunja_app/models/task.dart';
import 'package:vikunja_app/models/bucket.dart';
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';
import 'package:vikunja_app/utils/calculate_item_position.dart';
enum BucketMenu {limit, done, delete}
class BucketProps {
final ScrollController controller = ScrollController();
final TextEditingController titleController = TextEditingController();
bool scrollable = false;
bool portrait = true;
int bucketLength = 0;
Size? taskDropSize;
}
class ListPage extends StatefulWidget {
final TaskList taskList;
//ListPage({this.taskList}) : super(key: Key(taskList.id.toString()));
ListPage({required this.taskList}) : super(key: Key(Random().nextInt(100000).toString()));
@override
_ListPageState createState() => _ListPageState();
}
class _ListPageState extends State<ListPage> {
final _keyboardController = KeyboardVisibilityController();
int _viewIndex = 0;
TaskList? _list;
List<Task> _loadingTasks = [];
int _currentPage = 1;
bool _loading = true;
bool displayDoneTasks = false;
ListProvider? taskState;
PageController? _pageController;
Map<int, BucketProps> _bucketProps = {};
int? _draggedBucketIndex;
Duration _lastTaskDragUpdateAction = Duration.zero;
@override
void initState() {
_list = widget.taskList;
_keyboardController.onChange.listen((visible) {
if (!visible && mounted) FocusScope.of(context).unfocus();
});
super.initState();
Future.delayed(Duration.zero, (){
_loadList();
});
}
@override
Widget build(BuildContext context) {
taskState = Provider.of<ListProvider>(context);
return GestureDetector(
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: _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,
),
),
);
}
void _onViewTapped(int index) {
_loadList().then((_) {
_currentPage = 1;
setState(() {
_viewIndex = index;
});
});
}
ListView _listView(BuildContext context) {
return ListView.builder(
padding: EdgeInsets.symmetric(vertical: 8.0),
itemCount: taskState!.tasks.length * 2,
itemBuilder: (context, i) {
if (i.isOdd) return Divider();
if (_loadingTasks.isNotEmpty) {
final loadingTask = _loadingTasks.removeLast();
return _buildLoadingTile(loadingTask);
}
final index = i ~/ 2;
if (taskState!.maxPages == _currentPage &&
index == taskState!.tasks.length)
throw Exception("Check itemCount attribute");
if (index >= taskState!.tasks.length &&
_currentPage < taskState!.maxPages) {
_currentPage++;
_loadTasksForPage(_currentPage);
}
return _buildTile(taskState!.tasks[index]);
}
);
}
Widget _kanbanView(BuildContext context) {
final deviceData = MediaQuery.of(context);
final portrait = deviceData.orientation == Orientation.portrait;
final bucketFraction = portrait ? 0.8 : 0.4;
final bucketWidth = deviceData.size.width * bucketFraction;
if (_pageController == null) _pageController = PageController(viewportFraction: bucketFraction);
else if (_pageController!.viewportFraction != bucketFraction)
_pageController = PageController(viewportFraction: bucketFraction);
return ReorderableListView.builder(
scrollDirection: Axis.horizontal,
scrollController: _pageController,
physics: PageScrollPhysics(),
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
itemCount: taskState?.buckets.length ?? 0,
buildDefaultDragHandles: false,
itemBuilder: (context, index) {
if (index > (taskState!.buckets.length)) throw Exception("Check itemCount attribute");
return ReorderableDelayedDragStartListener(
key: ValueKey<int>(index),
index: index,
enabled: taskState!.buckets.length > 1 && !taskState!.taskDragging,
child: SizedBox(
width: bucketWidth,
child: _buildBucketTile(taskState!.buckets[index], portrait),
),
);
},
proxyDecorator: (child, index, animation) {
return AnimatedBuilder(
animation: animation,
child: child,
builder: (context, child) {
return Transform.scale(
scale: lerpDouble(1.0, 0.75, Curves.easeInOut.transform(animation.value)),
child: child,
);
},
);
},
footer: _draggedBucketIndex != null ? null : SizedBox(
width: deviceData.size.width * (1 - bucketFraction) * (portrait ? 1 : 2),
child: Align(
alignment: Alignment.topLeft,
child: Padding(
padding: EdgeInsets.only(
top: portrait ? 14 : 5,
),
child: RotatedBox(
quarterTurns: portrait ? 1 : 0,
child: ElevatedButton.icon(
onPressed: () => _addBucketDialog(context),
label: Text('Create Bucket'),
icon: Icon(Icons.add),
),
),
),
),
),
onReorderStart: (oldIndex) {
FocusScope.of(context).unfocus();
setState(() => _draggedBucketIndex = oldIndex);
},
onReorder: (_, __) {},
onReorderEnd: (newIndex) async {
bool indexUpdated = false;
if (newIndex > _draggedBucketIndex!) {
newIndex -= 1;
indexUpdated = true;
}
final movedBucket = taskState!.buckets.removeAt(_draggedBucketIndex!);
if (newIndex >= taskState!.buckets.length) {
taskState!.buckets.add(movedBucket);
} else {
taskState!.buckets.insert(newIndex, movedBucket);
}
taskState!.buckets[newIndex].position = calculateItemPosition(
positionBefore: newIndex != 0
? taskState!.buckets[newIndex - 1].position : null,
positionAfter: newIndex < taskState!.buckets.length - 1
? taskState!.buckets[newIndex + 1].position : null,
);
await _updateBucket(context, taskState!.buckets[newIndex]);
// make sure the first 2 buckets don't have 0 position
if (newIndex == 0 && taskState!.buckets.length > 1 && taskState!.buckets[1].position == 0) {
taskState!.buckets[1].position = calculateItemPosition(
positionBefore: taskState!.buckets[0].position,
positionAfter: 1 < taskState!.buckets.length - 1
? taskState!.buckets[2].position : null,
);
_updateBucket(context, taskState!.buckets[1]);
}
if (indexUpdated && portrait) _pageController!.animateToPage(
newIndex - 1,
duration: Duration(milliseconds: 100),
curve: Curves.easeInOut,
);
setState(() => _draggedBucketIndex = null);
},
);
}
Widget _buildTile(Task task) {
return ListenableProvider.value(
value: taskState,
child: TaskTile(
task: task,
loading: false,
onEdit: () {},
onMarkedAsDone: (done) {
Provider.of<ListProvider>(context, listen: false).updateTask(
context: context,
task: task.copyWith(done: done),
);
},
),
);
}
Widget _buildBucketTile(Bucket bucket, bool portrait) {
final theme = Theme.of(context);
const bucketTitleHeight = 56.0;
final addTaskButton = ElevatedButton.icon(
icon: Icon(Icons.add),
label: Text('Add Task'),
onPressed: bucket.limit == 0 || bucket.tasks.length < bucket.limit
? () {
FocusScope.of(context).unfocus();
_addItemDialog(context, bucket);
}
: null,
);
if (_bucketProps[bucket.id] == null)
_bucketProps[bucket.id] = BucketProps();
if (_bucketProps[bucket.id]!.bucketLength != (bucket.tasks.length)
|| _bucketProps[bucket.id]!.portrait != portrait)
SchedulerBinding.instance.addPostFrameCallback((_) {
if (_bucketProps[bucket.id]!.controller.hasClients) setState(() {
_bucketProps[bucket.id]!.bucketLength = bucket.tasks.length;
_bucketProps[bucket.id]!.scrollable = _bucketProps[bucket.id]!.controller.position.maxScrollExtent > 0;
_bucketProps[bucket.id]!.portrait = portrait;
});
});
if (_bucketProps[bucket.id]!.titleController.text.isEmpty)
_bucketProps[bucket.id]!.titleController.text = bucket.title;
return Stack(
children: <Widget>[
CustomScrollView(
controller: _bucketProps[bucket.id]!.controller,
slivers: <Widget>[
SliverBucketPersistentHeader(
minExtent: bucketTitleHeight,
maxExtent: bucketTitleHeight,
child: Material(
color: theme.scaffoldBackgroundColor,
child: ListTile(
minLeadingWidth: 15,
horizontalTitleGap: 4,
contentPadding: const EdgeInsets.only(left: 16, right: 10),
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) Padding(
padding: const EdgeInsets.only(right: 2),
child: Text(
'${bucket.tasks.length}/${bucket.limit}',
style: (theme.textTheme.titleMedium ?? TextStyle(fontSize: 16)).copyWith(
color: bucket.limit != 0 && bucket.tasks.length >= bucket.limit
? Colors.red : null,
),
),
),
],
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Icon(Icons.drag_handle),
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) {
final bool enableDelete = taskState!.buckets.length > 1;
return <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,
enabled: enableDelete,
child: Row(
children: <Widget>[
Icon(
Icons.delete,
color: enableDelete ? Colors.red : null,
),
Text(
'Delete',
style: enableDelete ? TextStyle(color: Colors.red) : null,
),
],
),
),
];
},
),
],
),
),
),
),
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 8),
sliver: ListenableProvider.value(
value: taskState,
child: SliverBucketList(
bucket: bucket,
onTaskDragUpdate: (details) { // scroll when dragging a task
if (details.sourceTimeStamp! - _lastTaskDragUpdateAction > const Duration(milliseconds: 600)) {
final screenSize = MediaQuery.of(context).size;
const scrollDuration = Duration(milliseconds: 250);
const scrollCurve = Curves.easeInOut;
final updateAction = () => setState(() => _lastTaskDragUpdateAction = details.sourceTimeStamp!);
if (details.globalPosition.dx < screenSize.width * 0.1) { // scroll left
if (_pageController!.position.extentBefore != 0)
_pageController!.previousPage(duration: scrollDuration, curve: scrollCurve);
updateAction();
} else if (details.globalPosition.dx > screenSize.width * 0.9) { // scroll right
if (_pageController!.position.extentAfter != 0)
_pageController!.nextPage(duration: scrollDuration, curve: scrollCurve);
updateAction();
} else {
final viewingBucket = taskState!.buckets[_pageController!.page!.floor()];
final bucketController = _bucketProps[viewingBucket.id]!.controller;
if (details.globalPosition.dy < screenSize.height * 0.2) { // scroll up
if (bucketController.position.extentBefore != 0)
bucketController.animateTo(bucketController.offset - 80,
duration: scrollDuration, curve: scrollCurve);
updateAction();
} else if (details.globalPosition.dy > screenSize.height * 0.8) { // scroll down
if (bucketController.position.extentAfter != 0)
bucketController.animateTo(bucketController.offset + 80,
duration: scrollDuration, curve: scrollCurve);
updateAction();
}
}
}
},
),
),
),
SliverVisibility(
visible: !_bucketProps[bucket.id]!.scrollable,
maintainState: true,
maintainAnimation: true,
maintainSize: true,
sliver: SliverFillRemaining(
hasScrollBody: false,
child: Stack(
children: <Widget>[
Column(
children: <Widget>[
if (_bucketProps[bucket.id]!.taskDropSize != null) DottedBorder(
color: Colors.grey,
child: SizedBox.fromSize(size: _bucketProps[bucket.id]!.taskDropSize),
),
Align(
alignment: Alignment.topCenter,
child: addTaskButton,
),
],
),
// DragTarget to drop tasks in empty buckets
if (bucket.tasks.length == 0) DragTarget<TaskData>(
onWillAccept: (data) {
setState(() => _bucketProps[bucket.id]!.taskDropSize = data?.size);
return true;
},
onAccept: (data) {
Provider.of<ListProvider>(context, listen: false).moveTaskToBucket(
context: context,
task: data.task,
newBucketId: bucket.id,
index: 0,
).then((_) => ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text('${data.task.title} was moved to ${bucket.title} successfully!'),
)));
setState(() => _bucketProps[bucket.id]!.taskDropSize = null);
},
onLeave: (_) => setState(() => _bucketProps[bucket.id]!.taskDropSize = null),
builder: (_, __, ___) => SizedBox.expand(),
),
],
),
),
),
],
),
if (_bucketProps[bucket.id]!.scrollable) Align(
alignment: Alignment.bottomCenter,
child: addTaskButton,
),
],
);
}
Future<void> updateDisplayDoneTasks() {
return VikunjaGlobal.of(context).listService.getDisplayDoneTasks(_list!.id)
.then((value) {displayDoneTasks = value == "1";});
}
TaskTile _buildLoadingTile(Task task) {
return TaskTile(
task: task,
loading: true,
onEdit: () => Navigator.push(
context,
MaterialPageRoute(
builder: (context) => TaskEditPage(
task: task,
taskState: taskState!,
),
),
),
);
}
Future<void> _loadList() async {
updateDisplayDoneTasks().then((value) async {
switch (_viewIndex) {
case 0:
_loadTasksForPage(1);
break;
case 1:
await _loadBucketsForPage(1);
// load all buckets to get length for RecordableListView
while (_currentPage < taskState!.maxPages) {
_currentPage++;
await _loadBucketsForPage(_currentPage);
}
break;
default:
_loadTasksForPage(1);
}
});
}
Future<void> _loadTasksForPage(int page) {
return Provider.of<ListProvider>(context, listen: false).loadTasks(
context: context,
listId: _list!.id,
page: page,
displayDoneTasks: displayDoneTasks
);
}
Future<void> _loadBucketsForPage(int page) {
return Provider.of<ListProvider>(context, listen: false).loadBuckets(
context: context,
listId: _list!.id,
page: page
);
}
Future<void> _addItemDialog(BuildContext context, [Bucket? bucket]) {
return showDialog(
context: context,
builder: (_) => AddDialog(
onAdd: (title) => _addItem(title, context, bucket),
decoration: InputDecoration(
labelText: (bucket != null ? '${bucket.title}: ' : '') + 'New Task Name',
hintText: 'eg. Milk',
),
),
);
}
Future<void> _addItem(String title, BuildContext context, [Bucket? bucket]) async {
final currentUser = VikunjaGlobal.of(context).currentUser;
if (currentUser == null) {
return;
}
final newTask = Task(
title: title,
createdBy: currentUser,
done: false,
bucketId: bucket?.id,
listId: _list!.id,
);
setState(() => _loadingTasks.add(newTask));
return Provider.of<ListProvider>(context, listen: false)
.addTask(
context: context,
newTask: newTask,
listId: _list!.id,
)
.then((_) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text('The task was added successfully' + (bucket != null ? ' to ${bucket.title}' : '') + '!'),
));
setState(() {
_loadingTasks.remove(newTask);
});
});
}
Future<void> _addBucketDialog(BuildContext context) {
FocusScope.of(context).unfocus();
return showDialog(
context: context,
builder: (_) => AddDialog(
onAdd: (title) => _addBucket(title, context),
decoration: InputDecoration(
labelText: 'New Bucket Name',
hintText: 'eg. To Do',
),
)
);
}
Future<void> _addBucket(String title, BuildContext context) async {
final currentUser = VikunjaGlobal.of(context).currentUser;
if (currentUser == null) {
return;
}
await Provider.of<ListProvider>(context, listen: false).addBucket(
context: context,
newBucket: Bucket(
title: title,
createdBy: currentUser,
listId: _list!.id,
limit: 0,
),
listId: _list!.id,
);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text('The bucket was added successfully!'),
));
setState(() {});
}
Future<void> _updateBucket(BuildContext context, Bucket bucket) {
return 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(() {});
});
}
Future<void> _deleteBucket(BuildContext context, Bucket bucket) async {
await Provider.of<ListProvider>(context, listen: false).deleteBucket(
context: context,
listId: bucket.listId,
bucketId: bucket.id,
);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Row(
children: <Widget>[
Text('${bucket.title} was deleted.'),
Icon(Icons.delete),
],
),
));
_onViewTapped(1);
}
}