1
0
mirror of https://github.com/go-vikunja/app synced 2024-06-01 02:06:51 +00:00

converted to slivers, added bucket reordering, small theme changes

This commit is contained in:
Paul Nettleton 2022-07-26 19:15:17 -05:00
parent 50eccce18d
commit 5d6120a7ac
7 changed files with 259 additions and 121 deletions

View File

@ -1,51 +0,0 @@
import 'package:flutter/material.dart';
import 'package:vikunja_app/components/BucketTaskCard.dart';
import 'package:vikunja_app/models/bucket.dart';
import 'package:vikunja_app/models/task.dart';
class BucketListView extends StatefulWidget {
final Bucket bucket;
final Function onAddTask;
const BucketListView({Key key, @required this.bucket, this.onAddTask})
: assert(bucket != null),
super(key: key);
@override
State<BucketListView> createState() => _BucketListViewState(this.bucket);
}
class _BucketListViewState extends State<BucketListView> {
Bucket _currentBucket;
_BucketListViewState(this._currentBucket)
: assert(_currentBucket != null);
@override
Widget build(BuildContext context) {
return ListView.builder(
padding: EdgeInsets.symmetric(horizontal: 10),
itemBuilder: (context, i) {
if (_currentBucket.tasks == null || i >= _currentBucket.tasks.length) {
if (i == 0 || i == _currentBucket.tasks?.length)
return TextButton.icon(
onPressed: widget.onAddTask,
label: Text('Add Task'),
icon: Icon(Icons.add),
);
return null;
}
return i < _currentBucket.tasks.length
? _buildBucketTaskTile(_currentBucket.tasks[i])
: null;
},
);
}
BucketTaskCard _buildBucketTaskTile(Task task) {
return BucketTaskCard(
task: task,
);
}
}

View File

@ -28,12 +28,13 @@ class _BucketTaskCardState extends State<BucketTaskCard> {
// default chip height: 32
const double chipHeight = 28;
final chipConstraints = BoxConstraints(maxHeight: chipHeight);
final theme = Theme.of(context);
final numRow = Row(
children: <Widget>[
Text(
'#${_currentTask.id}',
style: TextStyle(
style: theme.textTheme.subtitle2.copyWith(
color: Colors.grey,
),
),
@ -46,9 +47,9 @@ class _BucketTaskCardState extends State<BucketTaskCard> {
child: FittedBox(
child: Chip(
label: Text('Done'),
labelStyle: TextStyle(
labelStyle: theme.textTheme.labelLarge.copyWith(
fontWeight: FontWeight.bold,
color: Theme.of(context).brightness == Brightness.dark
color: theme.brightness == Brightness.dark
? Colors.black : Colors.white,
),
backgroundColor: vGreen,
@ -62,8 +63,7 @@ class _BucketTaskCardState extends State<BucketTaskCard> {
Expanded(
child: Text(
_currentTask.title,
style: TextStyle(
fontSize: 16,
style: theme.textTheme.titleMedium.copyWith(
color: _currentTask.textColor,
),
),
@ -82,7 +82,9 @@ class _BucketTaskCardState extends State<BucketTaskCard> {
color: duration.isNegative ? Colors.red : null,
),
label: Text(durationToHumanReadable(duration)),
labelStyle: duration.isNegative ? TextStyle(color: Colors.red) : null,
labelStyle: theme.textTheme.labelLarge.copyWith(
color: duration.isNegative ? Colors.red : null,
),
backgroundColor: duration.isNegative ? Colors.red.withAlpha(20) : null,
),
),
@ -98,7 +100,9 @@ class _BucketTaskCardState extends State<BucketTaskCard> {
_currentTask.labels?.asMap()?.forEach((i, label) {
labelRow.children.add(Chip(
label: Text(label.title),
labelStyle: TextStyle(color: label.textColor),
labelStyle: theme.textTheme.labelLarge.copyWith(
color: label.textColor,
),
backgroundColor: label.color,
));
});
@ -107,9 +111,10 @@ class _BucketTaskCardState extends State<BucketTaskCard> {
final completedTaskCount = '* [x]'.allMatches(_currentTask.description).length;
final taskCount = uncompletedTaskCount + completedTaskCount;
if (taskCount > 0) {
final iconSize = (theme.textTheme.labelLarge.fontSize ?? 14) + 2;
labelRow.children.add(Chip(
avatar: Container(
constraints: BoxConstraints(maxHeight: 16, maxWidth: 16),
constraints: BoxConstraints(maxHeight: iconSize, maxWidth: iconSize),
child: CircularProgressIndicator(
value: uncompletedTaskCount == 0
? 1 : uncompletedTaskCount.toDouble() / taskCount.toDouble(),

View File

@ -0,0 +1,27 @@
import 'package:flutter/material.dart';
import 'package:vikunja_app/components/BucketTaskCard.dart';
import 'package:vikunja_app/models/bucket.dart';
class SliverBucketList extends StatelessWidget {
final Bucket bucket;
final Function onLast;
const SliverBucketList({Key key, @required this.bucket, this.onLast})
: assert(bucket != null),
super(key: key);
@override
Widget build(BuildContext context) {
return SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
if (bucket.tasks == null) return null;
return index < bucket.tasks.length
? BucketTaskCard(task: bucket.tasks[index])
: () {
if (onLast != null) onLast();
return null;
}();
}),
);
}
}

View File

@ -0,0 +1,47 @@
import 'package:flutter/material.dart';
class SliverBucketPersistentHeader extends StatelessWidget {
final Widget child;
final double minExtent;
final double maxExtent;
const SliverBucketPersistentHeader({
Key key,
@required this.child,
this.minExtent = 10.0,
this.maxExtent = 10.0,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return SliverPersistentHeader(
pinned: true,
delegate: _SliverBucketPersistentHeaderDelegate(child, minExtent, maxExtent),
);
}
}
class _SliverBucketPersistentHeaderDelegate extends SliverPersistentHeaderDelegate {
final Widget child;
final double min;
final double max;
_SliverBucketPersistentHeaderDelegate(this.child, this.min, this.max);
@override
Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) {
return child;
}
@override
double get maxExtent => max;
@override
double get minExtent => min;
@override
bool shouldRebuild(covariant _SliverBucketPersistentHeaderDelegate oldDelegate) {
return oldDelegate.child != child || oldDelegate.min != min || oldDelegate.max != max;
}
}

View File

@ -1,11 +1,14 @@
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:vikunja_app/components/AddDialog.dart';
import 'package:vikunja_app/components/TaskTile.dart';
import 'package:vikunja_app/components/BucketListView.dart';
import 'package:vikunja_app/components/SliverBucketList.dart';
import 'package:vikunja_app/components/SliverBucketPersistentHeader.dart';
import 'package:vikunja_app/global.dart';
import 'package:vikunja_app/models/list.dart';
import 'package:vikunja_app/models/task.dart';
@ -32,6 +35,11 @@ class _ListPageState extends State<ListPage> {
bool _loading = true;
bool displayDoneTasks;
ListProvider taskState;
PageController _pageController;
Map<int, ValueKey<int>> _bucketKeys = {};
Map<int, bool> _bucketScrollable = {};
Map<int, ScrollController> _controllers = {};
int _draggedBucketIndex;
@override
void initState() {
@ -160,24 +168,79 @@ class _ListPageState extends State<ListPage> {
);
}
ListView _kanbanView(BuildContext buildContext) {
return ListView.builder(
Widget _kanbanView(BuildContext context) {
final deviceData = MediaQuery.of(context);
final bucketWidth = deviceData.size.width
* (deviceData.orientation == Orientation.portrait ? 0.8 : 0.4);
if (_pageController == null) _pageController = PageController(viewportFraction: 0.8);
return ReorderableListView.builder(
scrollDirection: Axis.horizontal,
itemBuilder: (context, i) {
if (taskState.maxPages == _currentPage && i >= taskState.buckets.length) {
if (i == taskState.buckets.length)
return _buildBucketTile();
return null;
}
if (i >= taskState.buckets.length && _currentPage < taskState.maxPages) {
_currentPage++;
_loadBucketsForPage(_currentPage);
}
return i < taskState.buckets.length
? _buildBucketTile(taskState.buckets[i])
: null;
scrollController: _pageController,
physics: PageScrollPhysics(),
itemCount: taskState.buckets.length,
itemExtent: bucketWidth,
cacheExtent: bucketWidth,
buildDefaultDragHandles: false,
itemBuilder: (context, index) {
if (index > taskState.buckets.length) return null;
return ReorderableDelayedDragStartListener(
key: ValueKey<int>(index),
index: index,
enabled: taskState.buckets.length > 1,
child: _buildBucketTile(taskState.buckets[index]),
);
},
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: bucketWidth,
child: Column(
children: [
ListTile(
title: Align(
alignment: Alignment.centerLeft,
child: ElevatedButton.icon(
onPressed: () => _addBucketDialog(context),
label: Text('Create Bucket'),
//style: ButtonStyle(alignment: Alignment.centerLeft),
icon: Icon(Icons.add),
),
),
),
Spacer(),
],
),
),
onReorderStart: (oldIndex) => setState(() => _draggedBucketIndex = oldIndex),
onReorder: (oldIndex, newIndex) {},
onReorderEnd: (newIndex) => setState(() {
if (newIndex > _draggedBucketIndex) newIndex -= 1;
taskState.buckets.insert(newIndex, taskState.buckets.removeAt(_draggedBucketIndex));
bool indexUpdated = false;
if (newIndex == 0) {
taskState.buckets[0].position = 0;
_updateBucket(context, taskState.buckets[0]);
newIndex = 1;
indexUpdated = true;
}
taskState.buckets[newIndex].position = newIndex == taskState.buckets.length - 1
? taskState.buckets[newIndex - 1].position + 1
: (taskState.buckets[newIndex - 1].position
+ taskState.buckets[newIndex + 1].position) / 2.0;
_updateBucket(context, taskState.buckets[newIndex]);
_draggedBucketIndex = null;
_pageController.jumpToPage(indexUpdated ? 0 : newIndex);
}),
);
}
@ -206,47 +269,77 @@ class _ListPageState extends State<ListPage> {
);
}
Container _buildBucketTile([Bucket bucket]) {
final deviceData = MediaQuery.of(context);
return Container(
width: deviceData.size.width
* (deviceData.orientation == Orientation.portrait ? 0.8 : 0.4),
child: Column(
children: () {
if (bucket != null) {
return <Widget>[
ListTile(
title: Text(
bucket.title,
style: TextStyle(
fontWeight: FontWeight.w600,
fontSize: 18,
Widget _buildBucketTile(Bucket bucket) {
final theme = Theme.of(context);
final addTaskButton = ElevatedButton.icon(
icon: Icon(Icons.add),
label: Text('Add Task'),
onPressed: () => _addItemDialog(context, bucket),
);
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);
}
return Stack(
key: _bucketKeys[bucket.id],
children: <Widget>[
CustomScrollView(
controller: _controllers[bucket.id],
slivers: <Widget>[
SliverBucketPersistentHeader(
minExtent: 56,
maxExtent: 56,
child: Material(
color: theme.scaffoldBackgroundColor,
child: ListTile(
title: Text(
bucket.title,
style: theme.textTheme.titleLarge,
),
),
trailing: Icon(Icons.more_vert),
),
Expanded(
child: BucketListView(
bucket: bucket,
onAddTask: () => _addItemDialog(context, bucket),
trailing: Icon(Icons.more_vert),
),
),
];
} else {
return <Widget>[
ListTile(
title: TextButton.icon(
onPressed: () => _addBucketDialog(context),
label: Text('Create Bucket'),
style: ButtonStyle(alignment: Alignment.centerLeft),
icon: Icon(Icons.add),
),
SliverPadding(
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),
maintainState: true,
maintainAnimation: true,
maintainSize: true,
sliver: SliverFillRemaining(
hasScrollBody: false,
child: Align(
alignment: Alignment.topCenter,
child: addTaskButton,
),
),
Spacer(),
];
}
}(),
),
),
],
),
if (_bucketScrollable[bucket.id] ?? false) Align(
alignment: Alignment.bottomCenter,
child: addTaskButton,
),
],
);
}
@ -271,13 +364,18 @@ class _ListPageState extends State<ListPage> {
}
Future<void> _loadList() async {
updateDisplayDoneTasks().then((value) {
updateDisplayDoneTasks().then((value) async {
switch (_viewIndex) {
case 0:
_loadTasksForPage(1);
break;
case 1:
_loadBucketsForPage(1);
await _loadBucketsForPage(1);
// load all buckets to get length for RecordableListView
while (_currentPage < taskState.maxPages) {
_currentPage++;
await _loadBucketsForPage(_currentPage);
}
break;
default:
_loadTasksForPage(1);
@ -294,8 +392,8 @@ class _ListPageState extends State<ListPage> {
);
}
void _loadBucketsForPage(int page) {
Provider.of<ListProvider>(context, listen: false).loadBuckets(
Future<void> _loadBucketsForPage(int page) {
return Provider.of<ListProvider>(context, listen: false).loadBuckets(
context: context,
listId: _list.id,
page: page
@ -371,4 +469,11 @@ class _ListPageState extends State<ListPage> {
setState(() {});
});
}
_updateBucket(BuildContext context, Bucket bucket) async {
await Provider.of<ListProvider>(context, listen: false).updateBucket(
context: context,
bucket: bucket,
);
}
}

View File

@ -57,7 +57,7 @@ class ListProvider with ChangeNotifier {
});
}
void loadBuckets({BuildContext context, int listId, int page = 1}) {
Future<void> loadBuckets({BuildContext context, int listId, int page = 1}) {
_buckets = [];
_isLoading = true;
notifyListeners();
@ -66,7 +66,7 @@ class ListProvider with ChangeNotifier {
"page": [page.toString()]
};
VikunjaGlobal.of(context).bucketService.getAllByList(listId, queryParams).then((response) {
return VikunjaGlobal.of(context).bucketService.getAllByList(listId, queryParams).then((response) {
if (response.headers["x-pagination-total-pages"] != null) {
_maxPages = int.parse(response.headers["x-pagination-total-pages"]);
}
@ -148,12 +148,9 @@ class ListProvider with ChangeNotifier {
}
Future<void> updateBucket({BuildContext context, Bucket bucket}) {
_isLoading = true;
notifyListeners();
return VikunjaGlobal.of(context).bucketService.update(bucket)
.then((rBucket) {
_buckets[_buckets.indexWhere((b) => rBucket.id == b.id)] = rBucket;
_isLoading = false;
notifyListeners();
});
}

View File

@ -12,11 +12,19 @@ ThemeData _buildVikunjaTheme(ThemeData base) {
primaryColor: vPrimaryDark,
primaryColorLight: vPrimary,
primaryColorDark: vBlueDark,
colorScheme: base.colorScheme.copyWith(
primary: vPrimaryDark,
secondary: vPrimary,
),
floatingActionButtonTheme: base.floatingActionButtonTheme.copyWith(
foregroundColor: vWhite,
),
buttonTheme: base.buttonTheme.copyWith(
buttonColor: vPrimary,
textTheme: ButtonTextTheme.normal,
colorScheme: base.buttonTheme.colorScheme.copyWith(
// Why does this not work?
// ButtonTheme seems to be obsolete see: https://api.flutter.dev/flutter/material/ButtonThemeData-class.html
onSurface: vWhite,
onSecondary: vWhite,
background: vBlue,