basic kanban view

This commit is contained in:
Paul Nettleton 2022-07-15 09:25:16 -05:00
parent 4a4ad2c9b3
commit ad665e68cc
9 changed files with 367 additions and 33 deletions

View File

@ -0,0 +1,53 @@
import 'package:vikunja_app/api/client.dart';
import 'package:vikunja_app/api/response.dart';
import 'package:vikunja_app/api/service.dart';
import 'package:vikunja_app/models/bucket.dart';
import 'package:vikunja_app/service/services.dart';
class BucketAPIService extends APIService implements BucketService {
BucketAPIService(Client client) : super(client);
@override
Future<Bucket> add(int listId, Bucket bucket) {
return client
.put('/lists/$listId/buckets', body: bucket.toJSON())
.then((response) => Bucket.fromJSON(response.body));
}
@override
Future delete(int listId, int bucketId) {
return client
.delete('/lists/$listId/buckets/$bucketId');
}
@override
Future<Bucket> get(int listId, int bucketId) {
// Might not exist in the API, it isn't in the docs
return client
.get('/lists/$listId/buckets/$bucketId')
.then((response) => Bucket.fromJSON(response.body));
}
@override
Future<Response> getAllByList(int listId,
[Map<String, List<String>> queryParameters]) {
return client
.get('/lists/$listId/buckets', queryParameters)
.then((response) => new Response(
convertList(response.body, (result) => Bucket.fromJSON(result)),
response.statusCode,
response.headers
));
}
@override
// TODO: implement maxPages
int get maxPages => maxPages;
@override
Future<Bucket> update(Bucket bucket) {
return client
.post('/lists/${bucket.listId}/buckets/${bucket.id}', body: bucket.toJSON())
.then((response) => Bucket.fromJSON(response.body));
}
}

View File

@ -0,0 +1,54 @@
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 onEdit;
final Function onAddTask;
const BucketListView({Key key, @required this.bucket, this.onEdit, 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 Container(
width: MediaQuery.of(context).size.width * 0.8,
child: ListView.builder(
padding: EdgeInsets.symmetric(horizontal: 10),
itemBuilder: (context, i) {
if (i == 0) {
return Text(_currentBucket.title);
}
final index = i - 1;
if (_currentBucket.tasks == null || index >= _currentBucket.tasks.length)
return null;
return index < _currentBucket.tasks.length
? _buildBucketTaskTile(_currentBucket.tasks[index])
: null;
},
),
);
}
BucketTaskCard _buildBucketTaskTile(Task task) {
return BucketTaskCard(
task: task,
);
}
}

View File

@ -0,0 +1,60 @@
import 'package:flutter/material.dart';
import 'package:vikunja_app/models/task.dart';
import 'package:vikunja_app/theme/constants.dart';
class BucketTaskCard extends StatefulWidget {
final Task task;
const BucketTaskCard({Key key, @required this.task})
: assert(task != null),
super(key: key);
@override
State<BucketTaskCard> createState() => _BucketTaskCardState(this.task);
}
class _BucketTaskCardState extends State<BucketTaskCard> {
Task _currentTask;
_BucketTaskCardState(this._currentTask)
: assert(_currentTask != null);
@override
Widget build(BuildContext context) {
final numRow = Row(
children: <Widget>[
Text('#${_currentTask.id}'),
],
);
if (_currentTask.done) {
numRow.children.insert(0, Chip(
label: Text('Done'),
labelStyle: TextStyle(
fontWeight: FontWeight.bold,
color: Theme
.of(context)
.brightness == Brightness.dark
? Colors.black : Colors.white,
),
backgroundColor: vGreen,
));
}
final titleRow = Row(
children: <Widget>[
Text(_currentTask.title),
],
);
// TODO: add due date
final labelRow = Row();
// TODO: add labels, checklist completion, attachment icon, description icon
return Card(
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[numRow, titleRow, labelRow],
),
);
}
}

View File

@ -4,6 +4,7 @@ import 'dart:developer' as dev;
import 'package:flutter/material.dart';
import 'package:flutter_native_timezone/flutter_native_timezone.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:vikunja_app/api/bucket_implementation.dart';
import 'package:vikunja_app/api/client.dart';
import 'package:vikunja_app/api/label_task.dart';
import 'package:vikunja_app/api/label_task_bulk.dart';
@ -65,6 +66,8 @@ class VikunjaGlobalState extends State<VikunjaGlobal> {
TaskService get taskService => new TaskAPIService(client);
BucketService get bucketService => new BucketAPIService(client);
ListService get listService => new ListAPIService(client, _storage);
notifs.FlutterLocalNotificationsPlugin get notificationsPlugin => new notifs.FlutterLocalNotificationsPlugin();

61
lib/models/bucket.dart Normal file
View File

@ -0,0 +1,61 @@
import 'package:meta/meta.dart';
import 'package:json_annotation/json_annotation.dart';
import 'package:vikunja_app/models/task.dart';
import 'package:vikunja_app/models/user.dart';
@JsonSerializable()
class Bucket {
int id, listId, limit;
double position;
String title;
DateTime created, updated;
User createdBy;
bool isDoneBucket;
List<Task> tasks;
Bucket({
@required this.id,
@required this.listId,
this.title,
this.position,
this.limit,
this.isDoneBucket,
this.created,
this.updated,
this.createdBy,
this.tasks,
});
Bucket.fromJSON(Map<String, dynamic> json)
: id = json['id'],
listId = json['list_id'],
title = json['title'],
position = json['position'] is int
? json['position'].toDouble()
: json['position'],
limit = json['limit'],
isDoneBucket = json['is_done_bucket'],
created = DateTime.parse(json['created']),
updated = DateTime.parse(json['updated']),
createdBy = json['created_by'] == null
? null
: User.fromJson(json['created_by']),
tasks = (json['tasks'] as List<dynamic>)
?.map((task) => Task.fromJson(task))
?.cast<Task>()
?.toList();
toJSON() => {
'id': id,
'list_id': listId,
'title': title,
'position': position,
'limit': limit,
'is_done_bucket': isDoneBucket ?? false,
'created': created?.toUtc()?.toIso8601String(),
'updated': updated?.toUtc()?.toIso8601String(),
'createdBy': createdBy?.toJSON(),
'tasks': tasks?.map((task) => task.toJSON())?.toList(),
};
}

View File

@ -5,9 +5,11 @@ import 'package:flutter/material.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/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';
@ -66,35 +68,19 @@ class _ListPageState extends State<ListPage> {
// TODO: it brakes the flow with _loadingTasks and conflicts with the provider
body: !taskState.isLoading
? RefreshIndicator(
child: taskState.tasks.length > 0
child: taskState.tasks.length > 0 || taskState.buckets.length > 0
? ListenableProvider.value(
value: taskState,
child: ListView.builder(
padding: EdgeInsets.symmetric(vertical: 8.0),
itemBuilder: (context, i) {
if (i.isOdd) return Divider();
if (_loadingTasks.isNotEmpty) {
final loadingTask = _loadingTasks.removeLast();
return _buildLoadingTile(loadingTask);
}
final index = i ~/ 2;
// This handles the case if there are no more elements in the list left which can be provided by the api
if (taskState.maxPages == _currentPage &&
index == taskState.tasks.length)
return null;
if (index >= taskState.tasks.length &&
_currentPage < taskState.maxPages) {
_currentPage++;
_loadTasksForPage(_currentPage);
}
return index < taskState.tasks.length
? _buildTile(taskState.tasks[index])
: null;
}),
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,
@ -124,11 +110,63 @@ class _ListPageState extends State<ListPage> {
}
void _onViewTapped(int index) {
setState(() {
_viewIndex = index;
_loadList().then((_) {
_currentPage = 1;
setState(() {
_viewIndex = index;
});
});
}
ListView _listView(BuildContext context) {
return ListView.builder(
padding: EdgeInsets.symmetric(vertical: 8.0),
itemBuilder: (context, i) {
if (i.isOdd) return Divider();
if (_loadingTasks.isNotEmpty) {
final loadingTask = _loadingTasks.removeLast();
return _buildLoadingTile(loadingTask);
}
final index = i ~/ 2;
// This handles the case if there are no more elements in the list left which can be provided by the api
if (taskState.maxPages == _currentPage &&
index == taskState.tasks.length)
return null;
if (index >= taskState.tasks.length &&
_currentPage < taskState.maxPages) {
_currentPage++;
_loadTasksForPage(_currentPage);
}
return index < taskState.tasks.length
? _buildTile(taskState.tasks[index])
: null;
}
);
}
ListView _kanbanView(BuildContext context) {
return ListView.builder(
scrollDirection: Axis.horizontal,
padding: EdgeInsets.symmetric(vertical: 10),
itemBuilder: (context, i) {
if (taskState.maxPages == _currentPage && i == taskState.buckets.length)
return null;
if (i >= taskState.buckets.length && _currentPage < taskState.maxPages) {
_currentPage++;
_loadBucketsForPage(_currentPage);
}
return i < taskState.buckets.length
? _buildBucketTile(taskState.buckets[i])
: null;
},
);
}
TaskTile _buildTile(Task task) {
return TaskTile(
task: task,
@ -154,6 +192,12 @@ class _ListPageState extends State<ListPage> {
);
}
BucketListView _buildBucketTile(Bucket bucket) {
return BucketListView(
bucket: bucket,
);
}
Future<void> updateDisplayDoneTasks() {
return VikunjaGlobal.of(context).listService.getDisplayDoneTasks(_list.id)
.then((value) {displayDoneTasks = value == "1";});
@ -175,7 +219,18 @@ class _ListPageState extends State<ListPage> {
}
Future<void> _loadList() async {
updateDisplayDoneTasks().then((value) => _loadTasksForPage(1));
updateDisplayDoneTasks().then((value) {
switch (_viewIndex) {
case 0:
_loadTasksForPage(1);
break;
case 1:
_loadBucketsForPage(1);
break;
default:
_loadTasksForPage(1);
}
});
}
void _loadTasksForPage(int page) {
@ -187,6 +242,14 @@ class _ListPageState extends State<ListPage> {
);
}
void _loadBucketsForPage(int page) {
Provider.of<ListProvider>(context, listen: false).loadBuckets(
context: context,
listId: _list.id,
page: page
);
}
_addItemDialog(BuildContext context) {
showDialog(
context: context,

View File

@ -8,6 +8,7 @@ import 'package:vikunja_app/models/list.dart';
import 'package:vikunja_app/models/namespace.dart';
import 'package:vikunja_app/models/task.dart';
import 'package:vikunja_app/models/user.dart';
import 'package:vikunja_app/models/bucket.dart';
import '../models/server.dart';
@ -112,6 +113,17 @@ abstract class TaskService {
int get maxPages;
}
abstract class BucketService {
Future<Bucket> get(int listId, int bucketId);
Future<Bucket> update(Bucket bucket);
Future delete(int listId, int bucketId);
Future<Bucket> add(int listId, Bucket bucket);
Future<Response> getAllByList(int listId,
[Map<String, List<String>> queryParameters]);
int get maxPages;
}
abstract class UserService {
Future<UserTokenPair> login(String username, password, {bool rememberMe = false, String totp});
Future<UserTokenPair> register(String username, email, password);

View File

@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:vikunja_app/models/task.dart';
import 'package:vikunja_app/models/bucket.dart';
import 'package:vikunja_app/global.dart';
class ListProvider with ChangeNotifier {
@ -8,6 +9,7 @@ class ListProvider with ChangeNotifier {
// TODO: Streams
List<Task> _tasks = [];
List<Bucket> _buckets = [];
bool get isLoading => _isLoading;
@ -20,6 +22,13 @@ class ListProvider with ChangeNotifier {
List<Task> get tasks => _tasks;
set buckets(List<Bucket> buckets) {
_buckets = buckets;
notifyListeners();
}
List<Bucket> get buckets => _buckets;
void loadTasks({BuildContext context, int listId, int page = 1, bool displayDoneTasks = true}) {
_tasks = [];
_isLoading = true;
@ -48,6 +57,26 @@ class ListProvider with ChangeNotifier {
});
}
void loadBuckets({BuildContext context, int listId, int page = 1}) {
_buckets = [];
_isLoading = true;
notifyListeners();
Map<String, List<String>> queryParams = {
"page": [page.toString()]
};
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"]);
}
_buckets.addAll(response.body);
_isLoading = false;
notifyListeners();
});
}
Future<void> addTaskByTitle(
{BuildContext context, String title, int listId}) {
var globalState = VikunjaGlobal.of(context);

View File

@ -38,9 +38,8 @@ ThemeData _buildVikunjaTheme(ThemeData base) {
// Make bottomNavigationBar backgroundColor darker to provide more separation
backgroundColor: () {
final _hslColor = HSLColor.fromColor(
base.bottomNavigationBarTheme.backgroundColor != null
? base.bottomNavigationBarTheme.backgroundColor
: base.scaffoldBackgroundColor
base.bottomNavigationBarTheme.backgroundColor
?? base.scaffoldBackgroundColor
);
return _hslColor.withLightness(max(_hslColor.lightness - 0.03, 0)).toColor();
}(),