mirror of
https://github.com/go-vikunja/app
synced 2024-11-14 16:50:45 +00:00
converted to slivers, added bucket reordering, small theme changes
This commit is contained in:
parent
50eccce18d
commit
5d6120a7ac
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
@ -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(),
|
||||
|
27
lib/components/SliverBucketList.dart
Normal file
27
lib/components/SliverBucketList.dart
Normal 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;
|
||||
}();
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
47
lib/components/SliverBucketPersistentHeader.dart
Normal file
47
lib/components/SliverBucketPersistentHeader.dart
Normal 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;
|
||||
}
|
||||
|
||||
}
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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();
|
||||
});
|
||||
}
|
||||
|
@ -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,
|
||||
|
Loading…
Reference in New Issue
Block a user