Merge branch 'Namespace-Project_Migration'
This commit is contained in:
commit
b7246cf433
|
@ -8,9 +8,9 @@ class BucketAPIService extends APIService implements BucketService {
|
|||
BucketAPIService(Client client) : super(client);
|
||||
|
||||
@override
|
||||
Future<Bucket?> add(int listId, Bucket bucket) {
|
||||
Future<Bucket?> add(int projectId, Bucket bucket) {
|
||||
return client
|
||||
.put('/lists/$listId/buckets', body: bucket.toJSON())
|
||||
.put('/projects/$projectId/buckets', body: bucket.toJSON())
|
||||
.then((response) {
|
||||
if (response == null) return null;
|
||||
return Bucket.fromJSON(response.body);
|
||||
|
@ -18,9 +18,9 @@ class BucketAPIService extends APIService implements BucketService {
|
|||
}
|
||||
|
||||
@override
|
||||
Future delete(int listId, int bucketId) {
|
||||
Future delete(int projectId, int bucketId) {
|
||||
return client
|
||||
.delete('/lists/$listId/buckets/$bucketId');
|
||||
.delete('/projects/$projectId/buckets/$bucketId');
|
||||
}
|
||||
|
||||
/* Not implemented in the Vikunja API
|
||||
|
@ -33,10 +33,10 @@ class BucketAPIService extends APIService implements BucketService {
|
|||
*/
|
||||
|
||||
@override
|
||||
Future<Response?> getAllByList(int listId,
|
||||
Future<Response?> getAllByList(int projectId,
|
||||
[Map<String, List<String>>? queryParameters]) {
|
||||
return client
|
||||
.get('/lists/$listId/buckets', queryParameters)
|
||||
.get('/projects/$projectId/buckets', queryParameters)
|
||||
.then((response) => response != null ? new Response(
|
||||
convertList(response.body, (result) => Bucket.fromJSON(result)),
|
||||
response.statusCode,
|
||||
|
@ -51,7 +51,7 @@ class BucketAPIService extends APIService implements BucketService {
|
|||
@override
|
||||
Future<Bucket?> update(Bucket bucket) {
|
||||
return client
|
||||
.post('/lists/${bucket.listId}/buckets/${bucket.id}', body: bucket.toJSON())
|
||||
.post('/projects/${bucket.projectId}/buckets/${bucket.id}', body: bucket.toJSON())
|
||||
.then((response) {
|
||||
if (response == null) return null;
|
||||
return Bucket.fromJSON(response.body);
|
||||
|
|
|
@ -79,7 +79,19 @@ class Client {
|
|||
|
||||
Future<Response?> get(String url,
|
||||
[Map<String, List<String>>? queryParameters]) {
|
||||
return http.get('${this.base}$url'.toUri()!, headers: _headers)
|
||||
Uri uri = Uri.tryParse('${this.base}$url')!;
|
||||
// why are we doing it like this? because Uri doesnt have setters. wtf.
|
||||
uri = Uri(
|
||||
scheme: uri.scheme,
|
||||
userInfo: uri.userInfo,
|
||||
host: uri.host,
|
||||
port: uri.port,
|
||||
path: uri.path,
|
||||
queryParameters: {...uri.queryParameters, ...?queryParameters},
|
||||
fragment: uri.fragment
|
||||
);
|
||||
|
||||
return http.get(uri, headers: _headers)
|
||||
.then(_handleResponse).onError((error, stackTrace) =>
|
||||
_handleError(error, stackTrace));
|
||||
}
|
||||
|
|
|
@ -0,0 +1,86 @@
|
|||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:vikunja_app/api/service.dart';
|
||||
import 'package:vikunja_app/models/project.dart';
|
||||
import 'package:vikunja_app/service/services.dart';
|
||||
|
||||
class ProjectAPIService extends APIService implements ProjectService {
|
||||
FlutterSecureStorage _storage;
|
||||
|
||||
ProjectAPIService(client, storage) : _storage = storage, super(client);
|
||||
|
||||
@override
|
||||
Future<Project?> create(Project p) {
|
||||
// TODO: implement create
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
@override
|
||||
Future delete(int projectId) {
|
||||
return client.delete('/projects/$projectId').then((_) {});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Project?> get(int projectId) {
|
||||
return client.get('/projects/$projectId').then((response) {
|
||||
if (response == null) return null;
|
||||
final map = response.body;
|
||||
/*if (map.containsKey('id')) {
|
||||
return client
|
||||
.get("/lists/$projectId/tasks")
|
||||
.then((tasks) {
|
||||
map['tasks'] = tasks?.body;
|
||||
return Project.fromJson(map);
|
||||
});
|
||||
}*/
|
||||
return Project.fromJson(map);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<Project>?> getAll() {
|
||||
// TODO: implement getAll
|
||||
return client.get('/projects').then((response) {
|
||||
if (response == null) return null;
|
||||
return convertList(response.body, (result) => Project.fromJson(result));
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Project?> update(Project p) {
|
||||
return client
|
||||
.post('/projects/${p.id}', body: p.toJSON())
|
||||
.then((response) {
|
||||
if (response == null) return null;
|
||||
return Project.fromJson(response.body);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> getDisplayDoneTasks(int listId) {
|
||||
return _storage.read(key: "display_done_tasks_list_$listId").then((value)
|
||||
{
|
||||
if(value == null) {
|
||||
// TODO: implement default value
|
||||
setDisplayDoneTasks(listId, "1");
|
||||
return Future.value("1");
|
||||
}
|
||||
return value;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void setDisplayDoneTasks(int listId, String value) {
|
||||
_storage.write(key: "display_done_tasks_list_$listId", value: value);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String?> getDefaultList() {
|
||||
return _storage.read(key: "default_list_id");
|
||||
}
|
||||
|
||||
@override
|
||||
void setDefaultList(int? listId) {
|
||||
_storage.write(key: "default_list_id", value: listId.toString());
|
||||
}
|
||||
|
||||
}
|
|
@ -11,9 +11,9 @@ class TaskAPIService extends APIService implements TaskService {
|
|||
TaskAPIService(Client client) : super(client);
|
||||
|
||||
@override
|
||||
Future<Task?> add(int listId, Task task) {
|
||||
Future<Task?> add(int projectId, Task task) {
|
||||
return client
|
||||
.put('/lists/$listId', body: task.toJSON())
|
||||
.put('/projects/$projectId', body: task.toJSON())
|
||||
.then((response) {
|
||||
if (response == null) return null;
|
||||
return Task.fromJson(response.body);
|
||||
|
@ -23,7 +23,7 @@ class TaskAPIService extends APIService implements TaskService {
|
|||
@override
|
||||
Future<Task?> get(int listId) {
|
||||
return client
|
||||
.get('/list/$listId/tasks')
|
||||
.get('/project/$listId/tasks')
|
||||
.then((response) {
|
||||
if (response == null) return null;
|
||||
return Task.fromJson(response.body);
|
||||
|
@ -75,10 +75,10 @@ class TaskAPIService extends APIService implements TaskService {
|
|||
}
|
||||
|
||||
@override
|
||||
Future<Response?> getAllByList(int listId,
|
||||
Future<Response?> getAllByProject(int projectId,
|
||||
[Map<String, List<String>>? queryParameters]) {
|
||||
return client
|
||||
.get('/lists/$listId/tasks', queryParameters).then(
|
||||
.get('/projects/$projectId/tasks', queryParameters).then(
|
||||
(response) {
|
||||
return response != null ?
|
||||
new Response(
|
||||
|
@ -92,7 +92,7 @@ class TaskAPIService extends APIService implements TaskService {
|
|||
Future<List<Task>?> getByOptions(TaskServiceOptions options) {
|
||||
String optionString = options.getOptions();
|
||||
return client
|
||||
.get('/tasks/all?$optionString')
|
||||
.get('/tasks/all$optionString')
|
||||
.then((response) {
|
||||
if (response == null) return null;
|
||||
return convertList(response.body, (result) => Task.fromJson(result));
|
||||
|
|
|
@ -6,10 +6,11 @@ import 'package:dotted_border/dotted_border.dart';
|
|||
import 'package:provider/provider.dart';
|
||||
import 'package:vikunja_app/models/task.dart';
|
||||
import 'package:vikunja_app/pages/list/task_edit.dart';
|
||||
import 'package:vikunja_app/stores/list_store.dart';
|
||||
import 'package:vikunja_app/utils/misc.dart';
|
||||
import 'package:vikunja_app/theme/constants.dart';
|
||||
|
||||
import '../stores/project_store.dart';
|
||||
|
||||
enum DropLocation {above, below, none}
|
||||
|
||||
class TaskData {
|
||||
|
@ -47,7 +48,7 @@ class _BucketTaskCardState extends State<BucketTaskCard> with AutomaticKeepAlive
|
|||
super.build(context);
|
||||
if (_cardSize == null) _updateCardSize(context);
|
||||
|
||||
final taskState = Provider.of<ListProvider>(context);
|
||||
final taskState = Provider.of<ProjectProvider>(context);
|
||||
final bucket = taskState.buckets[taskState.buckets.indexWhere((b) => b.id == widget.task.bucketId)];
|
||||
// default chip height: 32
|
||||
const double chipHeight = 28;
|
||||
|
|
|
@ -8,8 +8,9 @@ import 'package:provider/provider.dart';
|
|||
import '../global.dart';
|
||||
import '../models/bucket.dart';
|
||||
import '../models/list.dart';
|
||||
import '../models/project.dart';
|
||||
import '../pages/list/list.dart';
|
||||
import '../stores/list_store.dart';
|
||||
import '../stores/project_store.dart';
|
||||
import '../utils/calculate_item_position.dart';
|
||||
import 'AddDialog.dart';
|
||||
import 'BucketLimitDialog.dart';
|
||||
|
@ -19,19 +20,19 @@ import 'SliverBucketPersistentHeader.dart';
|
|||
|
||||
class KanbanClass {
|
||||
PageController? _pageController;
|
||||
ListProvider? taskState;
|
||||
ProjectProvider? taskState;
|
||||
int? _draggedBucketIndex;
|
||||
BuildContext context;
|
||||
Function _onViewTapped, _addItemDialog, notify;
|
||||
Duration _lastTaskDragUpdateAction = Duration.zero;
|
||||
|
||||
|
||||
TaskList _list;
|
||||
Project _list;
|
||||
Map<int, BucketProps> _bucketProps = {};
|
||||
|
||||
|
||||
KanbanClass(this.context, this.notify, this._onViewTapped, this._addItemDialog, this._list) {
|
||||
taskState = Provider.of<ListProvider>(context);
|
||||
taskState = Provider.of<ProjectProvider>(context);
|
||||
}
|
||||
|
||||
|
||||
|
@ -177,12 +178,12 @@ class KanbanClass {
|
|||
return;
|
||||
}
|
||||
|
||||
await Provider.of<ListProvider>(context, listen: false).addBucket(
|
||||
await Provider.of<ProjectProvider>(context, listen: false).addBucket(
|
||||
context: context,
|
||||
newBucket: Bucket(
|
||||
title: title,
|
||||
createdBy: currentUser,
|
||||
listId: _list.id,
|
||||
projectId: _list.id,
|
||||
limit: 0,
|
||||
),
|
||||
listId: _list.id,
|
||||
|
@ -196,7 +197,7 @@ class KanbanClass {
|
|||
}
|
||||
|
||||
Future<void> _updateBucket(BuildContext context, Bucket bucket) {
|
||||
return Provider.of<ListProvider>(context, listen: false)
|
||||
return Provider.of<ProjectProvider>(context, listen: false)
|
||||
.updateBucket(
|
||||
context: context,
|
||||
bucket: bucket,
|
||||
|
@ -211,9 +212,9 @@ class KanbanClass {
|
|||
}
|
||||
|
||||
Future<void> _deleteBucket(BuildContext context, Bucket bucket) async {
|
||||
await Provider.of<ListProvider>(context, listen: false).deleteBucket(
|
||||
await Provider.of<ProjectProvider>(context, listen: false).deleteBucket(
|
||||
context: context,
|
||||
listId: bucket.listId,
|
||||
listId: bucket.projectId,
|
||||
bucketId: bucket.id,
|
||||
);
|
||||
|
||||
|
@ -499,7 +500,7 @@ class KanbanClass {
|
|||
return true;
|
||||
},
|
||||
onAccept: (data) {
|
||||
Provider.of<ListProvider>(context, listen: false)
|
||||
Provider.of<ProjectProvider>(context, listen: false)
|
||||
.moveTaskToBucket(
|
||||
context: context,
|
||||
task: data.task,
|
||||
|
@ -539,7 +540,7 @@ class KanbanClass {
|
|||
}
|
||||
|
||||
Future<void> loadBucketsForPage(int page) {
|
||||
return Provider.of<ListProvider>(context, listen: false).loadBuckets(
|
||||
return Provider.of<ProjectProvider>(context, listen: false).loadBuckets(
|
||||
context: context,
|
||||
listId: _list.id,
|
||||
page: page
|
||||
|
|
|
@ -3,7 +3,8 @@ import 'package:provider/provider.dart';
|
|||
import 'package:vikunja_app/components/BucketTaskCard.dart';
|
||||
import 'package:vikunja_app/models/bucket.dart';
|
||||
import 'package:vikunja_app/models/task.dart';
|
||||
import 'package:vikunja_app/stores/list_store.dart';
|
||||
|
||||
import '../stores/project_store.dart';
|
||||
|
||||
class SliverBucketList extends StatelessWidget {
|
||||
final Bucket bucket;
|
||||
|
@ -33,7 +34,7 @@ class SliverBucketList extends StatelessWidget {
|
|||
}
|
||||
|
||||
Future<void> _moveTaskToBucket(BuildContext context, Task task, int index) async {
|
||||
await Provider.of<ListProvider>(context, listen: false).moveTaskToBucket(
|
||||
await Provider.of<ProjectProvider>(context, listen: false).moveTaskToBucket(
|
||||
context: context,
|
||||
task: task,
|
||||
newBucketId: bucket.id,
|
||||
|
|
|
@ -5,7 +5,8 @@ import 'package:provider/provider.dart';
|
|||
import 'package:vikunja_app/models/task.dart';
|
||||
import 'package:vikunja_app/utils/misc.dart';
|
||||
import 'package:vikunja_app/pages/list/task_edit.dart';
|
||||
import 'package:vikunja_app/stores/list_store.dart';
|
||||
|
||||
import '../stores/project_store.dart';
|
||||
|
||||
class TaskTile extends StatefulWidget {
|
||||
final Task task;
|
||||
|
@ -41,7 +42,7 @@ class TaskTileState extends State<TaskTile> with AutomaticKeepAliveClientMixin {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context);
|
||||
final taskState = Provider.of<ListProvider>(context);
|
||||
final taskState = Provider.of<ProjectProvider>(context);
|
||||
Duration? durationUntilDue = _currentTask.dueDate?.difference(DateTime.now());
|
||||
if (_currentTask.loading) {
|
||||
return ListTile(
|
||||
|
@ -119,7 +120,7 @@ class TaskTileState extends State<TaskTile> with AutomaticKeepAliveClientMixin {
|
|||
}
|
||||
|
||||
Future<Task?> _updateTask(Task task, bool checked) {
|
||||
return Provider.of<ListProvider>(context, listen: false).updateTask(
|
||||
return Provider.of<ProjectProvider>(context, listen: false).updateTask(
|
||||
context: context,
|
||||
task: task.copyWith(
|
||||
done: checked,
|
||||
|
|
|
@ -20,6 +20,7 @@ import 'package:vikunja_app/service/services.dart';
|
|||
import 'package:timezone/data/latest_all.dart' as tz;
|
||||
import 'package:workmanager/workmanager.dart';
|
||||
|
||||
import 'api/project.dart';
|
||||
import 'main.dart';
|
||||
|
||||
|
||||
|
@ -68,6 +69,8 @@ class VikunjaGlobalState extends State<VikunjaGlobal> {
|
|||
|
||||
NamespaceService get namespaceService => new NamespaceAPIService(client);
|
||||
|
||||
ProjectService get projectService => new ProjectAPIService(client, _storage);
|
||||
|
||||
TaskService get taskService => new TaskAPIService(client);
|
||||
|
||||
BucketService get bucketService => new BucketAPIService(client);
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:dynamic_color/dynamic_color.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
|
@ -96,25 +97,41 @@ class VikunjaApp extends StatelessWidget {
|
|||
|
||||
return new ValueListenableBuilder(valueListenable: updateTheme, builder: (_,mode,__) {
|
||||
updateTheme.value = false;
|
||||
FlutterThemeMode themeMode = FlutterThemeMode.system;
|
||||
Future<ThemeData> theme = manager.getThemeMode().then((value) {
|
||||
if (value == ThemeMode.dark) {
|
||||
return buildVikunjaDarkTheme();
|
||||
} else {
|
||||
return buildVikunjaTheme();
|
||||
themeMode = value;
|
||||
switch(value) {
|
||||
case FlutterThemeMode.dark:
|
||||
return buildVikunjaDarkTheme();
|
||||
case FlutterThemeMode.materialYouLight:
|
||||
return buildVikunjaMaterialLightTheme();
|
||||
case FlutterThemeMode.materialYouDark:
|
||||
return buildVikunjaMaterialDarkTheme();
|
||||
default:
|
||||
return buildVikunjaTheme();
|
||||
}
|
||||
|
||||
});
|
||||
return FutureBuilder<ThemeData>(
|
||||
future: theme,
|
||||
builder: (BuildContext context, AsyncSnapshot<ThemeData> data) {
|
||||
if(data.hasData) {
|
||||
return new MaterialApp(
|
||||
return new DynamicColorBuilder(builder: (lightTheme, darkTheme)
|
||||
{
|
||||
ThemeData? themeData = data.data;
|
||||
if(themeMode == FlutterThemeMode.materialYouLight)
|
||||
themeData = themeData?.copyWith(colorScheme: lightTheme);
|
||||
else if(themeMode == FlutterThemeMode.materialYouDark)
|
||||
themeData = themeData?.copyWith(colorScheme: darkTheme);
|
||||
return MaterialApp(
|
||||
title: 'Vikunja',
|
||||
theme: data.data,
|
||||
theme: themeData,
|
||||
scaffoldMessengerKey: globalSnackbarKey,
|
||||
navigatorKey: navkey,
|
||||
// <= this
|
||||
home: this.home,
|
||||
);
|
||||
});
|
||||
} else {
|
||||
return Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
|
|
@ -9,6 +9,8 @@ import 'package:flutter_local_notifications/flutter_local_notifications.dart'as
|
|||
import 'package:rxdart/subjects.dart' as rxSub;
|
||||
import 'package:vikunja_app/service/services.dart';
|
||||
|
||||
import '../models/task.dart';
|
||||
|
||||
class NotificationClass {
|
||||
final int? id;
|
||||
final String? title;
|
||||
|
@ -120,30 +122,18 @@ class NotificationClass {
|
|||
}
|
||||
|
||||
|
||||
Future<void> scheduleDueNotifications(TaskService taskService) async {
|
||||
final tasks = await taskService.getByOptions(new TaskServiceOptions(newOptions: [
|
||||
TaskServiceOption<TaskServiceOptionFilterBy>("filter_by", [
|
||||
TaskServiceOptionFilterBy.done,
|
||||
TaskServiceOptionFilterBy.due_date
|
||||
]),
|
||||
TaskServiceOption<TaskServiceOptionFilterComparator>(
|
||||
"filter_comparator", [
|
||||
TaskServiceOptionFilterComparator.equals,
|
||||
TaskServiceOptionFilterComparator.greater
|
||||
]),
|
||||
TaskServiceOption<TaskServiceOptionFilterConcat>(
|
||||
"filter_concat", TaskServiceOptionFilterConcat.and),
|
||||
TaskServiceOption<TaskServiceOptionFilterValue>("filter_value", [
|
||||
TaskServiceOptionFilterValue.enum_false,
|
||||
DateTime.now().toUtc().toIso8601String()
|
||||
]),
|
||||
]));
|
||||
Future<void> scheduleDueNotifications(TaskService taskService,
|
||||
{List<Task>? tasks}) async {
|
||||
if (tasks == null)
|
||||
tasks = await taskService.getAll();
|
||||
if (tasks == null) {
|
||||
print("did not receive tasks on notification update");
|
||||
return;
|
||||
}
|
||||
await notificationsPlugin.cancelAll();
|
||||
for (final task in tasks) {
|
||||
if(task.done)
|
||||
continue;
|
||||
for (final reminder in task.reminderDates) {
|
||||
scheduleNotification(
|
||||
"Reminder",
|
||||
|
|
|
@ -5,7 +5,7 @@ import 'package:vikunja_app/models/user.dart';
|
|||
|
||||
@JsonSerializable()
|
||||
class Bucket {
|
||||
int id, listId, limit;
|
||||
int id, projectId, limit;
|
||||
String title;
|
||||
double? position;
|
||||
final DateTime created, updated;
|
||||
|
@ -15,7 +15,7 @@ class Bucket {
|
|||
|
||||
Bucket({
|
||||
this.id = 0,
|
||||
required this.listId,
|
||||
required this.projectId,
|
||||
required this.title,
|
||||
this.position,
|
||||
required this.limit,
|
||||
|
@ -30,7 +30,7 @@ class Bucket {
|
|||
|
||||
Bucket.fromJSON(Map<String, dynamic> json)
|
||||
: id = json['id'],
|
||||
listId = json['list_id'],
|
||||
projectId = json['project_id'],
|
||||
title = json['title'],
|
||||
position = json['position'] is int
|
||||
? json['position'].toDouble()
|
||||
|
@ -48,7 +48,7 @@ class Bucket {
|
|||
|
||||
toJSON() => {
|
||||
'id': id,
|
||||
'list_id': listId,
|
||||
'list_id': projectId,
|
||||
'title': title,
|
||||
'position': position,
|
||||
'limit': limit,
|
||||
|
|
|
@ -0,0 +1,91 @@
|
|||
import 'dart:ui';
|
||||
|
||||
import 'package:vikunja_app/models/user.dart';
|
||||
|
||||
class Project {
|
||||
final int id;
|
||||
final double position;
|
||||
final User? owner;
|
||||
final int parentProjectId;
|
||||
final String description;
|
||||
final String title;
|
||||
final DateTime created, updated;
|
||||
final Color? color;
|
||||
final bool isArchived, isFavourite;
|
||||
|
||||
Iterable<Project>? subprojects;
|
||||
|
||||
Project(
|
||||
{
|
||||
this.id = 0,
|
||||
this.owner,
|
||||
this.parentProjectId = 0,
|
||||
this.description = '',
|
||||
this.position = 0,
|
||||
this.color,
|
||||
this.isArchived = false,
|
||||
this.isFavourite = false,
|
||||
required this.title,
|
||||
created,
|
||||
updated}) :
|
||||
this.created = created ?? DateTime.now(),
|
||||
this.updated = updated ?? DateTime.now();
|
||||
|
||||
Project.fromJson(Map<String, dynamic> json)
|
||||
: title = json['title'],
|
||||
description = json['description'],
|
||||
id = json['id'],
|
||||
position = json['position'].toDouble(),
|
||||
isArchived = json['is_archived'],
|
||||
isFavourite = json['is_archived'],
|
||||
parentProjectId = json['parent_project_id'],
|
||||
created = DateTime.parse(json['created']),
|
||||
updated = DateTime.parse(json['updated']),
|
||||
color = json['hex_color'] != ''
|
||||
? Color(int.parse(json['hex_color'], radix: 16) + 0xFF000000)
|
||||
: null,
|
||||
owner = json['owner'] != null ? User.fromJson(json['owner']) : null;
|
||||
|
||||
Map<String, dynamic> toJSON() => {
|
||||
'id': id,
|
||||
'created': created.toUtc().toIso8601String(),
|
||||
'updated': updated.toUtc().toIso8601String(),
|
||||
'title': title,
|
||||
'owner': owner?.toJSON(),
|
||||
'description': description,
|
||||
'parent_project_id': parentProjectId,
|
||||
'hex_color': color?.value.toRadixString(16).padLeft(8, '0').substring(2),
|
||||
'is_archived': isArchived,
|
||||
'is_favourite': isFavourite,
|
||||
'position': position
|
||||
};
|
||||
|
||||
Project copyWith({
|
||||
int? id,
|
||||
DateTime? created,
|
||||
DateTime? updated,
|
||||
String? title,
|
||||
User? owner,
|
||||
String? description,
|
||||
int? parentProjectId,
|
||||
Color? color,
|
||||
bool? isArchived,
|
||||
bool? isFavourite,
|
||||
double? position,
|
||||
|
||||
}) {
|
||||
return Project(
|
||||
id: id ?? this.id,
|
||||
created: created ?? this.created,
|
||||
updated: updated ?? this.updated,
|
||||
title: title ?? this.title,
|
||||
owner: owner ?? this.owner,
|
||||
description: description ?? this.description,
|
||||
parentProjectId: parentProjectId ?? this.parentProjectId,
|
||||
color: color ?? this.color,
|
||||
isArchived: isArchived ?? this.isArchived,
|
||||
isFavourite: isFavourite ?? this.isFavourite,
|
||||
position: position ?? this.position,
|
||||
);
|
||||
}
|
||||
}
|
|
@ -10,7 +10,8 @@ import 'package:vikunja_app/utils/checkboxes_in_text.dart';
|
|||
class Task {
|
||||
final int id;
|
||||
final int? parentTaskId, priority, bucketId;
|
||||
final int? listId;
|
||||
//final int? listId;
|
||||
final int? projectId;
|
||||
final DateTime created, updated;
|
||||
DateTime? dueDate, startDate, endDate;
|
||||
final List<DateTime> reminderDates;
|
||||
|
@ -50,7 +51,8 @@ class Task {
|
|||
DateTime? created,
|
||||
DateTime? updated,
|
||||
required this.createdBy,
|
||||
required this.listId,
|
||||
//required this.listId,
|
||||
required this.projectId,
|
||||
this.bucketId,
|
||||
}) : this.created = created ?? DateTime.now(),
|
||||
this.updated = updated ?? DateTime.now();
|
||||
|
@ -105,7 +107,8 @@ class Task {
|
|||
: [],
|
||||
updated = DateTime.parse(json['updated']),
|
||||
created = DateTime.parse(json['created']),
|
||||
listId = json['list_id'],
|
||||
//listId = json['list_id'],
|
||||
projectId = json['project_id'],
|
||||
bucketId = json['bucket_id'],
|
||||
createdBy = User.fromJson(json['created_by']);
|
||||
|
||||
|
@ -163,7 +166,8 @@ class Task {
|
|||
id: id ?? this.id,
|
||||
parentTaskId: parentTaskId ?? this.parentTaskId,
|
||||
priority: priority ?? this.priority,
|
||||
listId: listId ?? this.listId,
|
||||
//listId: listId ?? this.listId,
|
||||
projectId: projectId ?? this.projectId,
|
||||
bucketId: bucketId ?? this.bucketId,
|
||||
created: created ?? this.created,
|
||||
updated: updated ?? this.updated,
|
||||
|
|
|
@ -7,14 +7,17 @@ import 'package:provider/provider.dart';
|
|||
|
||||
import 'package:vikunja_app/components/AddDialog.dart';
|
||||
import 'package:vikunja_app/components/ErrorDialog.dart';
|
||||
import 'package:vikunja_app/models/project.dart';
|
||||
import 'package:vikunja_app/pages/namespace/namespace.dart';
|
||||
import 'package:vikunja_app/pages/namespace/namespace_edit.dart';
|
||||
import 'package:vikunja_app/pages/landing_page.dart';
|
||||
import 'package:vikunja_app/global.dart';
|
||||
import 'package:vikunja_app/models/namespace.dart';
|
||||
import 'package:vikunja_app/pages/namespace/overview.dart';
|
||||
import 'package:vikunja_app/pages/project/overview.dart';
|
||||
import 'package:vikunja_app/pages/settings.dart';
|
||||
import 'package:vikunja_app/stores/list_store.dart';
|
||||
|
||||
import '../stores/project_store.dart';
|
||||
|
||||
class HomePage extends StatefulWidget {
|
||||
@override
|
||||
|
@ -27,18 +30,18 @@ class HomePageState extends State<HomePage> {
|
|||
|
||||
|
||||
List<Widget> widgets = [
|
||||
ChangeNotifierProvider<ListProvider>(
|
||||
create: (_) => new ListProvider(),
|
||||
ChangeNotifierProvider<ProjectProvider>(
|
||||
create: (_) => new ProjectProvider(),
|
||||
child: LandingPage(),
|
||||
),
|
||||
NamespaceOverviewPage(),
|
||||
ProjectOverviewPage(),
|
||||
SettingsPage()
|
||||
];
|
||||
|
||||
List<BottomNavigationBarItem> navbarItems = [
|
||||
BottomNavigationBarItem(icon: Icon(Icons.home), label: "Home"),
|
||||
BottomNavigationBarItem(icon: Icon(Icons.list), label: "Namespaces"),
|
||||
BottomNavigationBarItem(icon: Icon(Icons.settings), label: "Settings"),
|
||||
List<NavigationDestination> navbarItems = [
|
||||
NavigationDestination(icon: Icon(Icons.home), label: "Home"),
|
||||
NavigationDestination(icon: Icon(Icons.list), label: "Projects"),
|
||||
NavigationDestination(icon: Icon(Icons.settings), label: "Settings"),
|
||||
];
|
||||
|
||||
@override
|
||||
|
@ -48,10 +51,10 @@ class HomePageState extends State<HomePage> {
|
|||
drawerItem = _getDrawerItemWidget(_selectedDrawerIndex);
|
||||
|
||||
return new Scaffold(
|
||||
bottomNavigationBar: BottomNavigationBar(
|
||||
items: navbarItems,
|
||||
currentIndex: _selectedDrawerIndex,
|
||||
onTap: (index) {
|
||||
bottomNavigationBar: NavigationBar(
|
||||
destinations: navbarItems,
|
||||
selectedIndex: _selectedDrawerIndex,
|
||||
onDestinationSelected: (index) {
|
||||
setState(() {
|
||||
_selectedDrawerIndex = index;
|
||||
});
|
||||
|
|
|
@ -19,11 +19,8 @@ class HomeScreenWidget extends StatefulWidget {
|
|||
// TODO: implement createState
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
class LandingPage extends HomeScreenWidget {
|
||||
LandingPage({Key? key}) : super(key: key);
|
||||
|
||||
|
@ -31,11 +28,11 @@ class LandingPage extends HomeScreenWidget {
|
|||
State<StatefulWidget> createState() => LandingPageState();
|
||||
}
|
||||
|
||||
|
||||
class LandingPageState extends State<LandingPage>
|
||||
with AfterLayoutMixin<LandingPage> {
|
||||
int? defaultList;
|
||||
List<Task> _list = [];
|
||||
bool onlyDueDate = true;
|
||||
List<Task> _tasks = [];
|
||||
PageStatus landingPageStatus = PageStatus.built;
|
||||
static const platform = const MethodChannel('vikunja');
|
||||
|
||||
|
@ -56,6 +53,7 @@ class LandingPageState extends State<LandingPage>
|
|||
} catch (e) {
|
||||
log(e.toString());
|
||||
}
|
||||
VikunjaGlobal.of(context).settingsManager.getLandingPageOnlyDueDateTasks().then((value) => onlyDueDate = value);
|
||||
}));
|
||||
super.initState();
|
||||
}
|
||||
|
@ -105,10 +103,8 @@ class LandingPageState extends State<LandingPage>
|
|||
]);
|
||||
break;
|
||||
case PageStatus.empty:
|
||||
body = new Stack(children: [
|
||||
ListView(),
|
||||
Center(child: Text("This view is empty"))
|
||||
]);
|
||||
body = new Stack(
|
||||
children: [ListView(), Center(child: Text("This view is empty"))]);
|
||||
break;
|
||||
case PageStatus.success:
|
||||
body = ListView(
|
||||
|
@ -121,19 +117,44 @@ class LandingPageState extends State<LandingPage>
|
|||
break;
|
||||
}
|
||||
return new Scaffold(
|
||||
body:
|
||||
RefreshIndicator(onRefresh: () => _loadList(context), child: body),
|
||||
floatingActionButton: Builder(
|
||||
builder: (context) => FloatingActionButton(
|
||||
onPressed: () {
|
||||
_addItemDialog(context);
|
||||
},
|
||||
child: const Icon(Icons.add),
|
||||
)),
|
||||
body: RefreshIndicator(onRefresh: () => _loadList(context), child: body),
|
||||
floatingActionButton: Builder(
|
||||
builder: (context) => FloatingActionButton(
|
||||
onPressed: () {
|
||||
_addItemDialog(context);
|
||||
},
|
||||
child: const Icon(Icons.add),
|
||||
)),
|
||||
appBar: AppBar(
|
||||
title: Text("Vikunja"),
|
||||
actions: [
|
||||
PopupMenuButton(itemBuilder: (BuildContext context) {
|
||||
return [
|
||||
PopupMenuItem(
|
||||
child:
|
||||
InkWell(
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
bool newval = !onlyDueDate;
|
||||
VikunjaGlobal.of(context).settingsManager.setLandingPageOnlyDueDateTasks(newval).then((value) {
|
||||
setState(() {
|
||||
onlyDueDate = newval;
|
||||
_loadList(context);
|
||||
});
|
||||
});
|
||||
},
|
||||
child:
|
||||
Row(mainAxisAlignment: MainAxisAlignment.end, children: [
|
||||
Text("Only show tasks with due date"),
|
||||
Checkbox(
|
||||
value: onlyDueDate,
|
||||
onChanged: (bool? value) { },
|
||||
)
|
||||
])))
|
||||
];
|
||||
}),
|
||||
],
|
||||
),
|
||||
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -165,7 +186,7 @@ class LandingPageState extends State<LandingPage>
|
|||
title: title,
|
||||
dueDate: dueDate,
|
||||
createdBy: globalState.currentUser!,
|
||||
listId: defaultList!,
|
||||
projectId: defaultList!,
|
||||
),
|
||||
);
|
||||
|
||||
|
@ -176,7 +197,7 @@ class LandingPageState extends State<LandingPage>
|
|||
}
|
||||
|
||||
List<Widget> _listTasks(BuildContext context) {
|
||||
var tasks = (_list.map((task) => _buildTile(task, context))).toList();
|
||||
var tasks = (_tasks.map((task) => _buildTile(task, context))).toList();
|
||||
//tasks.addAll(_loadingTasks.map(_buildLoadingTile));
|
||||
return tasks;
|
||||
}
|
||||
|
@ -193,33 +214,51 @@ class LandingPageState extends State<LandingPage>
|
|||
}
|
||||
|
||||
Future<void> _loadList(BuildContext context) {
|
||||
log("reloading list");
|
||||
_list = [];
|
||||
_tasks = [];
|
||||
landingPageStatus = PageStatus.loading;
|
||||
// FIXME: loads and reschedules tasks each time list is updated
|
||||
VikunjaGlobal.of(context).notifications.scheduleDueNotifications(VikunjaGlobal.of(context).taskService);
|
||||
VikunjaGlobal.of(context)
|
||||
.notifications
|
||||
.scheduleDueNotifications(VikunjaGlobal.of(context).taskService);
|
||||
return VikunjaGlobal.of(context)
|
||||
.taskService
|
||||
.getByOptions(TaskServiceOptions())
|
||||
.then<Future<void>?>((taskList) {
|
||||
if (taskList != null && taskList.isEmpty) {
|
||||
setState(() {
|
||||
landingPageStatus = PageStatus.empty;
|
||||
});
|
||||
return null;
|
||||
}
|
||||
return VikunjaGlobal.of(context).listService.getAll().then((lists) {
|
||||
//taskList.forEach((task) {task.list = lists.firstWhere((element) => element.id == task.list_id);});
|
||||
setState(() {
|
||||
if (taskList != null) {
|
||||
_list = taskList;
|
||||
landingPageStatus = PageStatus.success;
|
||||
} else {
|
||||
landingPageStatus = PageStatus.error;
|
||||
}
|
||||
});
|
||||
return null;
|
||||
.settingsManager
|
||||
.getLandingPageOnlyDueDateTasks()
|
||||
.then((showOnlyDueDateTasks) {
|
||||
return VikunjaGlobal
|
||||
.of(context)
|
||||
.taskService
|
||||
.getByOptions(TaskServiceOptions(
|
||||
newOptions: [
|
||||
TaskServiceOption<TaskServiceOptionFilterBy>("filter_by", "done"),
|
||||
TaskServiceOption<TaskServiceOptionFilterValue>("filter_value", "false"),
|
||||
],
|
||||
clearOther: true
|
||||
))
|
||||
.then<Future<void>?>((taskList) => _handleTaskList(taskList, showOnlyDueDateTasks));
|
||||
|
||||
}).onError((error, stackTrace) {print("error");});
|
||||
}
|
||||
|
||||
Future<void> _handleTaskList(List<Task>? taskList, bool showOnlyDueDateTasks) {
|
||||
if(showOnlyDueDateTasks)
|
||||
taskList?.removeWhere((element) => element.dueDate == null || element.dueDate!.year == 0001);
|
||||
taskList?.forEach((element) {print(element.dueDate);});
|
||||
|
||||
if (taskList != null && taskList.isEmpty) {
|
||||
setState(() {
|
||||
landingPageStatus = PageStatus.empty;
|
||||
});
|
||||
return Future.value();
|
||||
}
|
||||
//taskList.forEach((task) {task.list = lists.firstWhere((element) => element.id == task.list_id);});
|
||||
setState(() {
|
||||
if (taskList != null) {
|
||||
_tasks = taskList;
|
||||
landingPageStatus = PageStatus.success;
|
||||
} else {
|
||||
landingPageStatus = PageStatus.error;
|
||||
}
|
||||
});
|
||||
return Future.value();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -65,8 +65,8 @@ class _ListPageState extends State<ListPage> {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
taskState = Provider.of<ListProvider>(context);
|
||||
_kanban = KanbanClass(
|
||||
context, nullSetState, _onViewTapped, _addItemDialog, _list);
|
||||
//_kanban = KanbanClass(
|
||||
// context, nullSetState, _onViewTapped, _addItemDialog, _list);
|
||||
|
||||
Widget body;
|
||||
|
||||
|
@ -242,16 +242,8 @@ class _ListPageState extends State<ListPage> {
|
|||
TaskTile _buildLoadingTile(Task task) {
|
||||
return TaskTile(
|
||||
task: task,
|
||||
loading: true,
|
||||
onEdit: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => TaskEditPage(
|
||||
task: task,
|
||||
taskState: taskState,
|
||||
),
|
||||
),
|
||||
),
|
||||
loading: true, onEdit: () {},
|
||||
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -314,7 +306,7 @@ class _ListPageState extends State<ListPage> {
|
|||
createdBy: currentUser,
|
||||
done: false,
|
||||
bucketId: bucket?.id,
|
||||
listId: _list.id,
|
||||
projectId: _list.id,
|
||||
);
|
||||
setState(() => _loadingTasks.add(newTask));
|
||||
return Provider.of<ListProvider>(context, listen: false)
|
||||
|
|
|
@ -6,12 +6,13 @@ import 'package:vikunja_app/components/label.dart';
|
|||
import 'package:vikunja_app/global.dart';
|
||||
import 'package:vikunja_app/models/label.dart';
|
||||
import 'package:vikunja_app/models/task.dart';
|
||||
import 'package:vikunja_app/stores/list_store.dart';
|
||||
import 'package:vikunja_app/utils/repeat_after_parse.dart';
|
||||
|
||||
import '../../stores/project_store.dart';
|
||||
|
||||
class TaskEditPage extends StatefulWidget {
|
||||
final Task task;
|
||||
final ListProvider taskState;
|
||||
final ProjectProvider taskState;
|
||||
|
||||
TaskEditPage({
|
||||
required this.task,
|
||||
|
|
|
@ -0,0 +1,169 @@
|
|||
|
||||
import 'package:after_layout/after_layout.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:vikunja_app/pages/project/project_task_list.dart';
|
||||
|
||||
import '../../components/AddDialog.dart';
|
||||
import '../../components/ErrorDialog.dart';
|
||||
import '../../global.dart';
|
||||
import '../../models/project.dart';
|
||||
|
||||
class ProjectOverviewPage extends StatefulWidget {
|
||||
@override
|
||||
_ProjectOverviewPageState createState() => new _ProjectOverviewPageState();
|
||||
}
|
||||
|
||||
class _ProjectOverviewPageState extends State<ProjectOverviewPage>
|
||||
with AfterLayoutMixin<ProjectOverviewPage> {
|
||||
List<Project> _projects = [];
|
||||
int _selectedDrawerIndex = -2, _previousDrawerIndex = -2;
|
||||
bool _loading = true;
|
||||
|
||||
Project? get _currentProject =>
|
||||
_selectedDrawerIndex >= -1 && _selectedDrawerIndex < _projects.length
|
||||
? _projects[_selectedDrawerIndex]
|
||||
: null;
|
||||
|
||||
@override
|
||||
void afterFirstLayout(BuildContext context) {
|
||||
_loadProjects();
|
||||
}
|
||||
|
||||
List<int> expandedList = [];
|
||||
|
||||
Widget createProjectTile(Project project, int level) {
|
||||
EdgeInsets insets = EdgeInsets.fromLTRB(level * 10 + 10, 0, 0, 0);
|
||||
|
||||
bool expanded = expandedList.contains(project.id);
|
||||
Widget icon;
|
||||
|
||||
List<Widget>? children = addProjectChildren(project, level+1);
|
||||
bool no_children = children.length == 0;
|
||||
if(no_children) {
|
||||
icon = Icon(Icons.list);
|
||||
} else {
|
||||
if (expanded) {
|
||||
icon = Icon(Icons.arrow_drop_down_sharp);
|
||||
} else {
|
||||
children = null;
|
||||
icon = Icon(Icons.arrow_right_sharp);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return Column(children: [
|
||||
ListTile(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
openList(context, project);
|
||||
});
|
||||
},
|
||||
contentPadding: insets,
|
||||
leading: IconButton(
|
||||
disabledColor: Theme.of(context).unselectedWidgetColor,
|
||||
icon: icon,
|
||||
onPressed: !no_children ? () {
|
||||
setState(() {
|
||||
if (expanded)
|
||||
expandedList.remove(project.id);
|
||||
else
|
||||
expandedList.add(project.id);
|
||||
});
|
||||
} : null,
|
||||
),
|
||||
title: new Text(project.title),
|
||||
//onTap: () => _onSelectItem(i),
|
||||
),
|
||||
...?children
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
List<Widget> addProjectChildren(Project project, level) {
|
||||
Iterable<Project> children =
|
||||
_projects.where((element) => element.parentProjectId == project.id);
|
||||
project.subprojects = children;
|
||||
List<Widget> widgets = [];
|
||||
children.forEach((element) {
|
||||
widgets.add(createProjectTile(element, level + 1));
|
||||
});
|
||||
return widgets;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
List<Widget> projectList = <Widget>[];
|
||||
_projects.asMap().forEach((i, project) {
|
||||
if (project.parentProjectId != 0) return;
|
||||
projectList.add(createProjectTile(project, 0));
|
||||
});
|
||||
|
||||
if (_selectedDrawerIndex > -1) {
|
||||
return new WillPopScope(
|
||||
child: ListPage(project: _projects[_selectedDrawerIndex]),
|
||||
onWillPop: () async {
|
||||
setState(() {
|
||||
_selectedDrawerIndex = -2;
|
||||
});
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
body: this._loading
|
||||
? Center(child: CircularProgressIndicator())
|
||||
: RefreshIndicator(
|
||||
child: ListView(
|
||||
padding: EdgeInsets.zero,
|
||||
children:
|
||||
ListTile.divideTiles(context: context, tiles: projectList)
|
||||
.toList()),
|
||||
onRefresh: _loadProjects,
|
||||
),
|
||||
|
||||
appBar: AppBar(
|
||||
title: Text("Projects"),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _loadProjects() {
|
||||
return VikunjaGlobal.of(context).projectService.getAll().then((result) {
|
||||
setState(() {
|
||||
_loading = false;
|
||||
if (result != null) _projects = result;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
_addProjectDialog(BuildContext context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (_) => AddDialog(
|
||||
onAdd: (name) => _addProject(name, context),
|
||||
decoration: new InputDecoration(
|
||||
labelText: 'Project', hintText: 'eg. Personal Project'),
|
||||
));
|
||||
}
|
||||
|
||||
_addProject(String name, BuildContext context) {
|
||||
final currentUser = VikunjaGlobal.of(context).currentUser;
|
||||
if (currentUser == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
VikunjaGlobal.of(context)
|
||||
.projectService
|
||||
.create(Project(title: name, owner: currentUser))
|
||||
.then((_) {
|
||||
_loadProjects();
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text('The project was created successfully!'),
|
||||
));
|
||||
}).catchError((error) => showDialog(
|
||||
context: context, builder: (context) => ErrorDialog(error: error)));
|
||||
}
|
||||
}
|
|
@ -0,0 +1,173 @@
|
|||
import 'dart:ffi';
|
||||
import 'dart:developer';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:vikunja_app/global.dart';
|
||||
import 'package:vikunja_app/theme/button.dart';
|
||||
import 'package:vikunja_app/theme/buttonText.dart';
|
||||
|
||||
import '../../models/project.dart';
|
||||
|
||||
class ProjectEditPage extends StatefulWidget {
|
||||
final Project project;
|
||||
|
||||
ProjectEditPage({required this.project}) : super(key: Key(project.toString()));
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() => _ProjectEditPageState();
|
||||
}
|
||||
|
||||
class _ProjectEditPageState extends State<ProjectEditPage> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
bool _loading = false;
|
||||
String _title = '', _description = '';
|
||||
bool? displayDoneTasks;
|
||||
late int listId;
|
||||
|
||||
@override
|
||||
void initState(){
|
||||
listId = widget.project.id;
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext ctx) {
|
||||
if(displayDoneTasks == null)
|
||||
VikunjaGlobal.of(context).projectService.getDisplayDoneTasks(listId).then(
|
||||
(value) => setState(() => displayDoneTasks = value == "1"));
|
||||
else
|
||||
log("Display done tasks: " + displayDoneTasks.toString());
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('Edit Project'),
|
||||
),
|
||||
body: Builder(
|
||||
builder: (BuildContext context) => SafeArea(
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: ListView(
|
||||
//reverse: true,
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
children: <Widget>[
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 10.0),
|
||||
child: TextFormField(
|
||||
maxLines: null,
|
||||
keyboardType: TextInputType.multiline,
|
||||
initialValue: widget.project.title,
|
||||
onSaved: (title) => _title = title ?? '',
|
||||
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.project.description,
|
||||
onSaved: (description) => _description = description ?? '',
|
||||
validator: (description) {
|
||||
if(description == null)
|
||||
return null;
|
||||
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: CheckboxListTile(
|
||||
value: displayDoneTasks ?? false,
|
||||
title: Text("Show done tasks"),
|
||||
onChanged: (value) {
|
||||
value ??= false;
|
||||
VikunjaGlobal.of(context).listService.setDisplayDoneTasks(listId, value ? "1" : "0");
|
||||
setState(() => displayDoneTasks = value);
|
||||
},
|
||||
),
|
||||
),
|
||||
Builder(
|
||||
builder: (context) => Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 10.0),
|
||||
child: FancyButton(
|
||||
onPressed: !_loading
|
||||
? () {
|
||||
if (_formKey.currentState!.validate()) {
|
||||
Form.of(context)?.save();
|
||||
_saveList(context);
|
||||
}
|
||||
}
|
||||
: () {},
|
||||
child: _loading
|
||||
? CircularProgressIndicator()
|
||||
: VikunjaButtonText('Save'),
|
||||
))),
|
||||
/*ExpansionTile(
|
||||
title: Text("Sharing"),
|
||||
children: [
|
||||
TypeAheadFormField(
|
||||
onSuggestionSelected: (suggestion) {},
|
||||
itemBuilder: (BuildContext context, Object? itemData) {
|
||||
return Card(
|
||||
child: Container(
|
||||
padding: EdgeInsets.all(10),
|
||||
child: Text(itemData.toString())),
|
||||
);},
|
||||
suggestionsCallback: (String pattern) {
|
||||
List<String> matches = <String>[];
|
||||
matches.addAll(["test", "test2", "test3"]);
|
||||
matches.retainWhere((s){
|
||||
return s.toLowerCase().contains(pattern.toLowerCase());
|
||||
});
|
||||
return matches;
|
||||
},)
|
||||
],
|
||||
)*/
|
||||
]
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
_saveList(BuildContext context) async {
|
||||
setState(() => _loading = true);
|
||||
// FIXME: is there a way we can update the list without creating a new list object?
|
||||
// aka updating the existing list we got from context (setters?)
|
||||
Project newProject = widget.project.copyWith(
|
||||
title: _title,
|
||||
description: _description
|
||||
);
|
||||
VikunjaGlobal.of(context).projectService.update(newProject).then((_) {
|
||||
setState(() => _loading = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text('The project was updated successfully!'),
|
||||
));
|
||||
}).catchError((err) {
|
||||
setState(() => _loading = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Something went wrong: ' + err.toString()),
|
||||
action: SnackBarAction(
|
||||
label: 'CLOSE',
|
||||
onPressed: ScaffoldMessenger.of(context).hideCurrentSnackBar),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
|
@ -0,0 +1,391 @@
|
|||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.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/KanbanWidget.dart';
|
||||
import 'package:vikunja_app/components/TaskTile.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/pages/project/project_edit.dart';
|
||||
|
||||
import '../../components/pagestatus.dart';
|
||||
import '../../models/project.dart';
|
||||
import '../../stores/project_store.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 Project project;
|
||||
|
||||
//ListPage({this.taskList}) : super(key: Key(taskList.id.toString()));
|
||||
ListPage({required this.project})
|
||||
: super(key: Key(Random().nextInt(100000).toString()));
|
||||
|
||||
@override
|
||||
_ListPageState createState() => _ListPageState();
|
||||
}
|
||||
|
||||
class _ListPageState extends State<ListPage> {
|
||||
final _keyboardController = KeyboardVisibilityController();
|
||||
int _viewIndex = 0;
|
||||
late Project _project;
|
||||
List<Task> _loadingTasks = [];
|
||||
int _currentPage = 1;
|
||||
bool displayDoneTasks = false;
|
||||
late ProjectProvider taskState;
|
||||
late KanbanClass _kanban;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
_project = widget.project;
|
||||
_keyboardController.onChange.listen((visible) {
|
||||
if (!visible && mounted) FocusScope.of(context).unfocus();
|
||||
});
|
||||
super.initState();
|
||||
}
|
||||
|
||||
void nullSetState() {
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
taskState = Provider.of<ProjectProvider>(context);
|
||||
_kanban = KanbanClass(
|
||||
context, nullSetState, _onViewTapped, _addItemDialog, _project);
|
||||
|
||||
Widget body;
|
||||
|
||||
switch (taskState.pageStatus) {
|
||||
case PageStatus.built:
|
||||
Future.delayed(Duration.zero, _loadList);
|
||||
body = new Stack(children: [
|
||||
ListView(),
|
||||
Center(
|
||||
child: CircularProgressIndicator(),
|
||||
)
|
||||
]);
|
||||
break;
|
||||
case PageStatus.loading:
|
||||
body = new Stack(children: [
|
||||
ListView(),
|
||||
Center(
|
||||
child: CircularProgressIndicator(),
|
||||
)
|
||||
]);
|
||||
break;
|
||||
case PageStatus.error:
|
||||
body = new Stack(children: [
|
||||
ListView(),
|
||||
Center(child: Text("There was an error loading this view"))
|
||||
]);
|
||||
break;
|
||||
case PageStatus.success:
|
||||
body = taskState.tasks.length > 0 || taskState.buckets.length > 0 || _project.subprojects!.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 _kanban.kanbanView();
|
||||
default:
|
||||
return _listView(context);
|
||||
}
|
||||
}(),
|
||||
),
|
||||
)
|
||||
: Stack(children: [
|
||||
ListView(),
|
||||
Center(child: Text('This list is empty.'))
|
||||
]);
|
||||
break;
|
||||
case PageStatus.empty:
|
||||
body = new Stack(children: [
|
||||
ListView(),
|
||||
Center(child: Text("This view is empty"))
|
||||
]);
|
||||
break;
|
||||
}
|
||||
|
||||
return new Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(_project.title),
|
||||
actions: <Widget>[
|
||||
IconButton(
|
||||
icon: Icon(Icons.edit),
|
||||
onPressed: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => ProjectEditPage(
|
||||
project: _project,
|
||||
),
|
||||
)).whenComplete(() => _loadList()),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: RefreshIndicator(onRefresh: () => _loadList(), child: body),
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildSubProjectSelector() {
|
||||
return Container(
|
||||
height: 80,
|
||||
padding: EdgeInsets.fromLTRB(10, 0, 10, 0),
|
||||
child:
|
||||
ListView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
//mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
...?widget.project.subprojects?.map((elem) =>
|
||||
InkWell(
|
||||
onTap: () {openList(context, elem);},
|
||||
child:
|
||||
Container(
|
||||
alignment: Alignment.center,
|
||||
height: 20,
|
||||
width: 100,
|
||||
child:
|
||||
Text(elem.title, overflow: TextOverflow.ellipsis,softWrap: false,)))
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _onViewTapped(int index) {
|
||||
_loadList().then((_) {
|
||||
_currentPage = 1;
|
||||
setState(() {
|
||||
_viewIndex = index;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Widget _listView(BuildContext context) {
|
||||
List<Widget> children = [];
|
||||
if(widget.project.subprojects?.length != 0) {
|
||||
children.add(Padding(child: Text("Projects", style: TextStyle(fontWeight: FontWeight.bold),), padding: EdgeInsets.fromLTRB(0, 10, 0, 0),));
|
||||
children.add(buildSubProjectSelector());
|
||||
|
||||
}
|
||||
if(taskState.tasks.length != 0) {
|
||||
children.add(Padding(child: Text("Tasks", style: TextStyle(fontWeight: FontWeight.bold),), padding: EdgeInsets.fromLTRB(0, 10, 0, 0),));
|
||||
children.add(Divider());
|
||||
children.add(Expanded(child:
|
||||
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]);
|
||||
})));
|
||||
}
|
||||
|
||||
return Column(children: children);
|
||||
}
|
||||
|
||||
Widget _buildTile(Task task) {
|
||||
return ListenableProvider.value(
|
||||
value: taskState,
|
||||
child: TaskTile(
|
||||
task: task,
|
||||
loading: false,
|
||||
onEdit: () {},
|
||||
onMarkedAsDone: (done) {
|
||||
Provider.of<ProjectProvider>(context, listen: false).updateTask(
|
||||
context: context,
|
||||
task: task.copyWith(done: done),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> updateDisplayDoneTasks() {
|
||||
return VikunjaGlobal.of(context)
|
||||
.projectService
|
||||
.getDisplayDoneTasks(_project.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 {
|
||||
taskState.pageStatus = (PageStatus.loading);
|
||||
|
||||
updateDisplayDoneTasks().then((value) async {
|
||||
switch (_viewIndex) {
|
||||
case 0:
|
||||
_loadTasksForPage(1);
|
||||
break;
|
||||
case 1:
|
||||
await _kanban
|
||||
.loadBucketsForPage(1);
|
||||
// load all buckets to get length for RecordableListView
|
||||
while (_currentPage < taskState.maxPages) {
|
||||
_currentPage++;
|
||||
await _kanban
|
||||
.loadBucketsForPage(_currentPage);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
_loadTasksForPage(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _loadTasksForPage(int page) {
|
||||
return Provider.of<ProjectProvider>(context, listen: false)
|
||||
.loadTasks(
|
||||
context: context,
|
||||
listId: _project.id,
|
||||
page: page,
|
||||
displayDoneTasks: displayDoneTasks);
|
||||
}
|
||||
|
||||
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,
|
||||
projectId: _project.id,
|
||||
);
|
||||
setState(() => _loadingTasks.add(newTask));
|
||||
return Provider.of<ProjectProvider>(context, listen: false)
|
||||
.addTask(
|
||||
context: context,
|
||||
newTask: newTask,
|
||||
listId: _project.id,
|
||||
)
|
||||
.then((_) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text('The task was added successfully' +
|
||||
(bucket != null ? ' to \'${bucket.title}\'' : '') +
|
||||
'!'),
|
||||
));
|
||||
setState(() {
|
||||
_loadingTasks.remove(newTask);
|
||||
_loadList();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
openList(BuildContext context, Project project) {
|
||||
Navigator.of(context).push(MaterialPageRoute(
|
||||
builder: (context) => ChangeNotifierProvider<ProjectProvider>(
|
||||
create: (_) => new ProjectProvider(),
|
||||
child: ListPage(
|
||||
project: project,
|
||||
),
|
||||
),
|
||||
// ListPage(taskList: list)
|
||||
));
|
||||
}
|
|
@ -5,6 +5,8 @@ import 'package:vikunja_app/global.dart';
|
|||
import 'package:vikunja_app/models/list.dart';
|
||||
|
||||
import '../main.dart';
|
||||
import '../models/project.dart';
|
||||
import '../service/services.dart';
|
||||
|
||||
|
||||
class SettingsPage extends StatefulWidget {
|
||||
|
@ -13,27 +15,27 @@ class SettingsPage extends StatefulWidget {
|
|||
}
|
||||
|
||||
class SettingsPageState extends State<SettingsPage> {
|
||||
List<TaskList>? taskListList;
|
||||
int? defaultList;
|
||||
List<Project>? projectList;
|
||||
int? defaultProject;
|
||||
bool? ignoreCertificates;
|
||||
bool? getVersionNotifications;
|
||||
String? versionTag, newestVersionTag;
|
||||
late TextEditingController durationTextController;
|
||||
bool initialized = false;
|
||||
ThemeMode? themeMode;
|
||||
FlutterThemeMode? themeMode;
|
||||
|
||||
|
||||
void init() {
|
||||
durationTextController = TextEditingController();
|
||||
|
||||
VikunjaGlobal.of(context)
|
||||
.listService
|
||||
.projectService
|
||||
.getAll()
|
||||
.then((value) => setState(() => taskListList = value));
|
||||
.then((value) => setState(() => projectList = value));
|
||||
|
||||
VikunjaGlobal.of(context).listService.getDefaultList().then((value) =>
|
||||
VikunjaGlobal.of(context).projectService.getDefaultList().then((value) =>
|
||||
setState(
|
||||
() => defaultList = value == null ? null : int.tryParse(value)));
|
||||
() => defaultProject = value == null ? null : int.tryParse(value)));
|
||||
|
||||
VikunjaGlobal.of(context).settingsManager.getIgnoreCertificates().then(
|
||||
(value) =>
|
||||
|
@ -84,7 +86,7 @@ class SettingsPageState extends State<SettingsPage> {
|
|||
Theme.of(context).primaryColor, BlendMode.multiply)),
|
||||
),
|
||||
),
|
||||
taskListList != null
|
||||
projectList != null
|
||||
? ListTile(
|
||||
title: Text("Default List"),
|
||||
trailing: DropdownButton<int>(
|
||||
|
@ -93,14 +95,14 @@ class SettingsPageState extends State<SettingsPage> {
|
|||
child: Text("None"),
|
||||
value: null,
|
||||
),
|
||||
...taskListList!
|
||||
...projectList!
|
||||
.map((e) => DropdownMenuItem(
|
||||
child: Text(e.title), value: e.id))
|
||||
.toList()
|
||||
],
|
||||
value: defaultList,
|
||||
value: defaultProject,
|
||||
onChanged: (int? value) {
|
||||
setState(() => defaultList = value);
|
||||
setState(() => defaultProject = value);
|
||||
VikunjaGlobal.of(context)
|
||||
.listService
|
||||
.setDefaultList(value);
|
||||
|
@ -113,23 +115,31 @@ class SettingsPageState extends State<SettingsPage> {
|
|||
Divider(),
|
||||
ListTile(
|
||||
title: Text("Theme"),
|
||||
trailing: DropdownButton<ThemeMode>(
|
||||
trailing: DropdownButton<FlutterThemeMode>(
|
||||
items: [
|
||||
DropdownMenuItem(
|
||||
child: Text("System"),
|
||||
value: ThemeMode.system,
|
||||
value: FlutterThemeMode.system,
|
||||
),
|
||||
DropdownMenuItem(
|
||||
child: Text("Light"),
|
||||
value: ThemeMode.light,
|
||||
value: FlutterThemeMode.light,
|
||||
),
|
||||
DropdownMenuItem(
|
||||
child: Text("Dark"),
|
||||
value: ThemeMode.dark,
|
||||
value: FlutterThemeMode.dark,
|
||||
),
|
||||
DropdownMenuItem(
|
||||
child: Text("Material You Light"),
|
||||
value: FlutterThemeMode.materialYouLight,
|
||||
),
|
||||
DropdownMenuItem(
|
||||
child: Text("Material You Dark"),
|
||||
value: FlutterThemeMode.materialYouDark,
|
||||
),
|
||||
],
|
||||
value: themeMode,
|
||||
onChanged: (ThemeMode? value) {
|
||||
onChanged: (FlutterThemeMode? value) {
|
||||
VikunjaGlobal.of(context)
|
||||
.settingsManager
|
||||
.setThemeMode(value!);
|
||||
|
|
|
@ -46,7 +46,7 @@ var _tasks = {
|
|||
created: DateTime.now(),
|
||||
description: 'A descriptive task',
|
||||
done: false,
|
||||
listId: 1,
|
||||
projectId: 1,
|
||||
)
|
||||
};
|
||||
|
||||
|
@ -170,12 +170,6 @@ class MockedTaskService implements TaskService {
|
|||
return Future.value(task);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Response> getAllByList(int listId,
|
||||
[Map<String, List<String>>? queryParameters]) {
|
||||
return Future.value(new Response(_tasks.values.toList(), 200, {}));
|
||||
}
|
||||
|
||||
@override
|
||||
int get maxPages => 1;
|
||||
Future<Task> get(int taskId) {
|
||||
|
@ -194,6 +188,12 @@ class MockedTaskService implements TaskService {
|
|||
// TODO: implement getAll
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Response?> getAllByProject(int projectId, [Map<String, List<String>>? queryParameters]) {
|
||||
// TODO: implement getAllByProject
|
||||
return Future.value(new Response(_tasks.values.toList(), 200, {}));
|
||||
}
|
||||
}
|
||||
|
||||
class MockedUserService implements UserService {
|
||||
|
|
|
@ -12,6 +12,7 @@ import 'package:vikunja_app/models/task.dart';
|
|||
import 'package:vikunja_app/models/user.dart';
|
||||
import 'package:vikunja_app/models/bucket.dart';
|
||||
|
||||
import '../models/project.dart';
|
||||
import '../models/server.dart';
|
||||
|
||||
enum TaskServiceOptionSortBy {
|
||||
|
@ -72,9 +73,10 @@ class TaskServiceOption<T> {
|
|||
}
|
||||
}
|
||||
|
||||
List<TaskServiceOption> defaultOptions = [
|
||||
final List<TaskServiceOption> defaultOptions = [
|
||||
TaskServiceOption<TaskServiceOptionSortBy>("sort_by",
|
||||
[TaskServiceOptionSortBy.due_date, TaskServiceOptionSortBy.id]),
|
||||
[TaskServiceOptionSortBy.due_date,
|
||||
TaskServiceOptionSortBy.id]),
|
||||
TaskServiceOption<TaskServiceOptionOrderBy>(
|
||||
"order_by", TaskServiceOptionOrderBy.asc),
|
||||
TaskServiceOption<TaskServiceOptionFilterBy>("filter_by", [
|
||||
|
@ -83,7 +85,7 @@ List<TaskServiceOption> defaultOptions = [
|
|||
]),
|
||||
TaskServiceOption<TaskServiceOptionFilterValue>("filter_value", [
|
||||
TaskServiceOptionFilterValue.enum_false,
|
||||
'0001-01-02T00:00:00.000Z'
|
||||
'1970-01-01T00:00:00.000Z'
|
||||
]),
|
||||
TaskServiceOption<TaskServiceOptionFilterComparator>(
|
||||
"filter_comparator", [
|
||||
|
@ -95,15 +97,20 @@ List<TaskServiceOption> defaultOptions = [
|
|||
];
|
||||
|
||||
class TaskServiceOptions {
|
||||
List<TaskServiceOption>? options;
|
||||
List<TaskServiceOption> options = [];
|
||||
|
||||
TaskServiceOptions({List<TaskServiceOption>? newOptions}) {
|
||||
options = [...defaultOptions];
|
||||
TaskServiceOptions({List<TaskServiceOption>? newOptions, bool clearOther = false}) {
|
||||
if(!clearOther)
|
||||
options = new List<TaskServiceOption>.from(defaultOptions);
|
||||
if (newOptions != null) {
|
||||
for (TaskServiceOption custom_option in newOptions) {
|
||||
int index = options!.indexWhere((element) => element.name == custom_option.name);
|
||||
options!.removeAt(index);
|
||||
options!.insert(index, custom_option);
|
||||
int index = options.indexWhere((element) => element.name == custom_option.name);
|
||||
if(index > -1) {
|
||||
options.removeAt(index);
|
||||
} else {
|
||||
index = options.length;
|
||||
}
|
||||
options.insert(index, custom_option);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -111,8 +118,8 @@ class TaskServiceOptions {
|
|||
|
||||
String getOptions() {
|
||||
String result = '';
|
||||
if (options == null) return '';
|
||||
for (TaskServiceOption option in options!) {
|
||||
if (options.length == 0) return '';
|
||||
for (TaskServiceOption option in options) {
|
||||
dynamic value = option.getValue();
|
||||
if (value is List) {
|
||||
for (dynamic valueEntry in value) {
|
||||
|
@ -123,11 +130,28 @@ class TaskServiceOptions {
|
|||
}
|
||||
}
|
||||
|
||||
if (result.startsWith('&')) result.substring(1);
|
||||
if (result.startsWith('&')) result = result.substring(1);
|
||||
result = "?" + result;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
abstract class ProjectService {
|
||||
Future<List<Project>?> getAll();
|
||||
|
||||
Future<Project?> get(int projectId);
|
||||
Future<Project?> create(Project p);
|
||||
Future<Project?> update(Project p);
|
||||
Future delete(int projectId);
|
||||
|
||||
|
||||
Future<String?> getDisplayDoneTasks(int listId);
|
||||
void setDisplayDoneTasks(int listId, String value);
|
||||
Future<String?> getDefaultList();
|
||||
void setDefaultList(int? listId);
|
||||
}
|
||||
|
||||
|
||||
abstract class NamespaceService {
|
||||
Future<List<Namespace>?> getAll();
|
||||
|
||||
|
@ -173,7 +197,7 @@ abstract class TaskService {
|
|||
|
||||
Future<List<Task>?> getAll();
|
||||
|
||||
Future<Response?> getAllByList(int listId,
|
||||
Future<Response?> getAllByProject(int projectId,
|
||||
[Map<String, List<String>> queryParameters]);
|
||||
|
||||
Future<List<Task>?> getByOptions(TaskServiceOptions options);
|
||||
|
@ -242,6 +266,7 @@ class SettingsManager {
|
|||
"workmanager-duration": "0",
|
||||
"recent-servers": "[\"https://try.vikunja.io\"]",
|
||||
"theme_mode": "system",
|
||||
"landing-page-due-date-tasks": "1"
|
||||
};
|
||||
|
||||
void applydefaults() {
|
||||
|
@ -266,6 +291,13 @@ class SettingsManager {
|
|||
_storage.write(key: "ignore-certificates", value: value ? "1" : "0");
|
||||
}
|
||||
|
||||
Future<bool> getLandingPageOnlyDueDateTasks() {
|
||||
return _storage.read(key: "landing-page-due-date-tasks").then((value) => value == "1");
|
||||
}
|
||||
Future<void> setLandingPageOnlyDueDateTasks(bool value) {
|
||||
return _storage.write(key: "landing-page-due-date-tasks", value: value ? "1" : "0");
|
||||
}
|
||||
|
||||
|
||||
Future<String?> getVersionNotifications() {
|
||||
return _storage.read(key: "get-version-notifications");
|
||||
|
@ -292,24 +324,36 @@ class SettingsManager {
|
|||
return _storage.write(key: "recent-servers", value: jsonEncode(server));
|
||||
}
|
||||
|
||||
Future<ThemeMode> getThemeMode() async {
|
||||
Future<FlutterThemeMode> getThemeMode() async {
|
||||
String? theme_mode = await _storage.read(key: "theme_mode");
|
||||
if(theme_mode == null)
|
||||
setThemeMode(ThemeMode.system);
|
||||
setThemeMode(FlutterThemeMode.system);
|
||||
switch(theme_mode) {
|
||||
case "system":
|
||||
return ThemeMode.system;
|
||||
return FlutterThemeMode.system;
|
||||
case "light":
|
||||
return ThemeMode.light;
|
||||
return FlutterThemeMode.light;
|
||||
case "dark":
|
||||
return ThemeMode.dark;
|
||||
return FlutterThemeMode.dark;
|
||||
case "materialYouLight":
|
||||
return FlutterThemeMode.materialYouLight;
|
||||
case "materialYouDark":
|
||||
return FlutterThemeMode.materialYouDark;
|
||||
default:
|
||||
return ThemeMode.system;
|
||||
return FlutterThemeMode.system;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setThemeMode(ThemeMode newMode) async {
|
||||
Future<void> setThemeMode(FlutterThemeMode newMode) async {
|
||||
await _storage.write(key: "theme_mode", value: newMode.toString().split('.').last);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
enum FlutterThemeMode {
|
||||
system,
|
||||
light,
|
||||
dark,
|
||||
materialYouLight,
|
||||
materialYouDark,
|
||||
}
|
|
@ -1,3 +1,4 @@
|
|||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:vikunja_app/models/task.dart';
|
||||
import 'package:vikunja_app/models/bucket.dart';
|
||||
|
@ -65,6 +66,8 @@ class ListProvider with ChangeNotifier {
|
|||
"filter_value": ["false"]
|
||||
});
|
||||
}
|
||||
return Future.value();
|
||||
/*
|
||||
return VikunjaGlobal.of(context).taskService.getAllByList(listId, queryParams).then((response) {
|
||||
if(response == null) {
|
||||
pageStatus = PageStatus.error;
|
||||
|
@ -75,7 +78,7 @@ class ListProvider with ChangeNotifier {
|
|||
}
|
||||
_tasks.addAll(response.body);
|
||||
pageStatus = PageStatus.success;
|
||||
});
|
||||
});*/
|
||||
}
|
||||
|
||||
Future<void> loadBuckets({required BuildContext context, required int listId, int page = 1}) {
|
||||
|
@ -112,7 +115,7 @@ class ListProvider with ChangeNotifier {
|
|||
title: title,
|
||||
createdBy: globalState.currentUser!,
|
||||
done: false,
|
||||
listId: listId,
|
||||
projectId: listId,
|
||||
);
|
||||
pageStatus = PageStatus.loading;
|
||||
|
||||
|
|
|
@ -0,0 +1,251 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:vikunja_app/models/task.dart';
|
||||
import 'package:vikunja_app/models/bucket.dart';
|
||||
import 'package:vikunja_app/utils/calculate_item_position.dart';
|
||||
import 'package:vikunja_app/global.dart';
|
||||
|
||||
import '../components/pagestatus.dart';
|
||||
|
||||
class ProjectProvider with ChangeNotifier {
|
||||
bool _taskDragging = false;
|
||||
int _maxPages = 0;
|
||||
|
||||
// TODO: Streams
|
||||
List<Task> _tasks = [];
|
||||
List<Bucket> _buckets = [];
|
||||
|
||||
|
||||
bool get taskDragging => _taskDragging;
|
||||
|
||||
set taskDragging(bool value) {
|
||||
_taskDragging = value;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
int get maxPages => _maxPages;
|
||||
|
||||
set tasks(List<Task> tasks) {
|
||||
_tasks = tasks;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
List<Task> get tasks => _tasks;
|
||||
|
||||
set buckets(List<Bucket> buckets) {
|
||||
_buckets = buckets;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
List<Bucket> get buckets => _buckets;
|
||||
|
||||
|
||||
PageStatus _pageStatus = PageStatus.built;
|
||||
|
||||
PageStatus get pageStatus => _pageStatus;
|
||||
|
||||
set pageStatus(PageStatus ps) {
|
||||
_pageStatus = ps;
|
||||
print("new PageStatus: ${ps.toString()}");
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> loadTasks({required BuildContext context, required int listId, int page = 1, bool displayDoneTasks = true}) {
|
||||
_tasks = [];
|
||||
notifyListeners();
|
||||
|
||||
Map<String, List<String>> queryParams = {
|
||||
"sort_by": ["done", "id"],
|
||||
"order_by": ["asc", "desc"],
|
||||
"page": [page.toString()]
|
||||
};
|
||||
|
||||
if(!displayDoneTasks) {
|
||||
queryParams.addAll({
|
||||
"filter_by": ["done"],
|
||||
"filter_value": ["false"],
|
||||
"sort_by": ["done"],
|
||||
});
|
||||
}
|
||||
return VikunjaGlobal.of(context).taskService.getAllByProject(listId, queryParams).then((response) {
|
||||
if(response == null) {
|
||||
pageStatus = PageStatus.error;
|
||||
return;
|
||||
}
|
||||
if (response.headers["x-pagination-total-pages"] != null) {
|
||||
_maxPages = int.parse(response.headers["x-pagination-total-pages"]!);
|
||||
}
|
||||
_tasks.addAll(response.body);
|
||||
pageStatus = PageStatus.success;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> loadBuckets({required BuildContext context, required int listId, int page = 1}) {
|
||||
_buckets = [];
|
||||
pageStatus = PageStatus.loading;
|
||||
notifyListeners();
|
||||
|
||||
Map<String, List<String>> queryParams = {
|
||||
"page": [page.toString()]
|
||||
};
|
||||
|
||||
return VikunjaGlobal.of(context).bucketService.getAllByList(listId, queryParams).then((response) {
|
||||
if(response == null) {
|
||||
pageStatus = PageStatus.error;
|
||||
return;
|
||||
}
|
||||
if (response.headers["x-pagination-total-pages"] != null) {
|
||||
_maxPages = int.parse(response.headers["x-pagination-total-pages"]!);
|
||||
}
|
||||
_buckets.addAll(response.body);
|
||||
|
||||
pageStatus = PageStatus.success;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> addTaskByTitle(
|
||||
{required BuildContext context, required String title, required int projectId}) async{
|
||||
final globalState = VikunjaGlobal.of(context);
|
||||
if (globalState.currentUser == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final newTask = Task(
|
||||
title: title,
|
||||
createdBy: globalState.currentUser!,
|
||||
done: false,
|
||||
projectId: projectId,
|
||||
);
|
||||
pageStatus = PageStatus.loading;
|
||||
|
||||
return globalState.taskService.add(projectId, newTask).then((task) {
|
||||
if(task != null)
|
||||
_tasks.insert(0, task);
|
||||
pageStatus = PageStatus.success;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> addTask({required BuildContext context, required Task newTask, required int listId}) {
|
||||
var globalState = VikunjaGlobal.of(context);
|
||||
if (newTask.bucketId == null) pageStatus = PageStatus.loading;
|
||||
notifyListeners();
|
||||
|
||||
return globalState.taskService.add(listId, newTask).then((task) {
|
||||
if (task == null) {
|
||||
pageStatus = PageStatus.error;
|
||||
return;
|
||||
}
|
||||
if (_tasks.isNotEmpty)
|
||||
_tasks.insert(0, task);
|
||||
if (_buckets.isNotEmpty) {
|
||||
final bucket = _buckets[_buckets.indexWhere((b) => task.bucketId == b.id)];
|
||||
bucket.tasks.add(task);
|
||||
}
|
||||
pageStatus = PageStatus.success;
|
||||
});
|
||||
}
|
||||
|
||||
Future<Task?> updateTask({required BuildContext context, required Task task}) {
|
||||
return VikunjaGlobal.of(context).taskService.update(task).then((task) {
|
||||
// FIXME: This is ugly. We should use a redux to not have to do these kind of things.
|
||||
// This is enough for now (it works™) but we should definitely fix it later.
|
||||
if(task == null)
|
||||
return null;
|
||||
_tasks.asMap().forEach((i, t) {
|
||||
if (task.id == t.id) {
|
||||
_tasks[i] = task;
|
||||
}
|
||||
});
|
||||
_buckets.asMap().forEach((i, b) => b.tasks.asMap().forEach((v, t) {
|
||||
if (task.id == t.id){
|
||||
_buckets[i].tasks[v] = task;
|
||||
}
|
||||
}));
|
||||
notifyListeners();
|
||||
return task;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> addBucket({required BuildContext context, required Bucket newBucket, required int listId}) {
|
||||
notifyListeners();
|
||||
return VikunjaGlobal.of(context).bucketService.add(listId, newBucket)
|
||||
.then((bucket) {
|
||||
if(bucket == null)
|
||||
return null;
|
||||
_buckets.add(bucket);
|
||||
notifyListeners();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> updateBucket({required BuildContext context, required Bucket bucket}) {
|
||||
return VikunjaGlobal.of(context).bucketService.update(bucket)
|
||||
.then((rBucket) {
|
||||
if(rBucket == null)
|
||||
return null;
|
||||
_buckets[_buckets.indexWhere((b) => rBucket.id == b.id)] = rBucket;
|
||||
_buckets.sort((a, b) => a.position!.compareTo(b.position!));
|
||||
notifyListeners();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> deleteBucket({required BuildContext context, required int listId, required int bucketId}) {
|
||||
return VikunjaGlobal.of(context).bucketService.delete(listId, bucketId)
|
||||
.then((_) {
|
||||
_buckets.removeWhere((bucket) => bucket.id == bucketId);
|
||||
notifyListeners();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> moveTaskToBucket({required BuildContext context, required Task? task, int? newBucketId, required int index}) async {
|
||||
if(task == null)
|
||||
throw Exception("Task to be moved may not be null");
|
||||
final sameBucket = task.bucketId == newBucketId;
|
||||
final newBucketIndex = _buckets.indexWhere((b) => b.id == newBucketId);
|
||||
if (sameBucket && index > _buckets[newBucketIndex].tasks.indexWhere((t) => t.id == task?.id)) index--;
|
||||
|
||||
_buckets[_buckets.indexWhere((b) => b.id == task?.bucketId)].tasks.remove(task);
|
||||
if (index >= _buckets[newBucketIndex].tasks.length)
|
||||
_buckets[newBucketIndex].tasks.add(task);
|
||||
else
|
||||
_buckets[newBucketIndex].tasks.insert(index, task);
|
||||
|
||||
task = await VikunjaGlobal.of(context).taskService.update(task.copyWith(
|
||||
bucketId: newBucketId,
|
||||
kanbanPosition: calculateItemPosition(
|
||||
positionBefore: index != 0
|
||||
? _buckets[newBucketIndex].tasks[index - 1].kanbanPosition : null,
|
||||
positionAfter: index < _buckets[newBucketIndex].tasks.length - 1
|
||||
? _buckets[newBucketIndex].tasks[index + 1].kanbanPosition : null,
|
||||
),
|
||||
));
|
||||
if(task == null)
|
||||
return;
|
||||
_buckets[newBucketIndex].tasks[index] = task;
|
||||
|
||||
// make sure the first 2 tasks don't have 0 kanbanPosition
|
||||
Task? secondTask;
|
||||
if (index == 0 && _buckets[newBucketIndex].tasks.length > 1
|
||||
&& _buckets[newBucketIndex].tasks[1].kanbanPosition == 0) {
|
||||
secondTask = await VikunjaGlobal.of(context).taskService.update(
|
||||
_buckets[newBucketIndex].tasks[1].copyWith(
|
||||
kanbanPosition: calculateItemPosition(
|
||||
positionBefore: task.kanbanPosition,
|
||||
positionAfter: 1 < _buckets[newBucketIndex].tasks.length - 1
|
||||
? _buckets[newBucketIndex].tasks[2].kanbanPosition : null,
|
||||
),
|
||||
));
|
||||
if(secondTask != null)
|
||||
_buckets[newBucketIndex].tasks[1] = secondTask;
|
||||
}
|
||||
|
||||
if (_tasks.isNotEmpty) {
|
||||
_tasks[_tasks.indexWhere((t) => t.id == task?.id)] = task;
|
||||
if (secondTask != null)
|
||||
_tasks[_tasks.indexWhere((t) => t.id == secondTask!.id)] = secondTask;
|
||||
}
|
||||
|
||||
_buckets[newBucketIndex].tasks[_buckets[newBucketIndex].tasks.indexWhere((t) => t.id == task?.id)] = task;
|
||||
_buckets[newBucketIndex].tasks.sort((a, b) => a.kanbanPosition!.compareTo(b.kanbanPosition!));
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
|
@ -17,6 +17,11 @@ class FancyButton extends StatelessWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ElevatedButton(onPressed: onPressed,
|
||||
child: SizedBox(
|
||||
width: width,
|
||||
child: Center(child: child),
|
||||
),);
|
||||
return Padding(
|
||||
padding: vStandardVerticalPadding,
|
||||
child: Container(
|
||||
|
@ -33,7 +38,7 @@ class FancyButton extends StatelessWidget {
|
|||
]),
|
||||
child: Material(
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
color: vButtonColor,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
child: InkWell(
|
||||
onTap: onPressed,
|
||||
child: Center(
|
||||
|
|
|
@ -11,9 +11,10 @@ class VikunjaButtonText extends StatelessWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Text(text);
|
||||
return Text(
|
||||
text,
|
||||
style: TextStyle(color: vButtonTextColor, fontWeight: FontWeight.w600),
|
||||
style: TextStyle(color: Theme.of(context).primaryTextTheme.labelMedium?.color, fontWeight: FontWeight.w600),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,8 +6,20 @@ import 'package:vikunja_app/theme/constants.dart';
|
|||
ThemeData buildVikunjaTheme() => _buildVikunjaTheme(ThemeData.light());
|
||||
ThemeData buildVikunjaDarkTheme() => _buildVikunjaTheme(ThemeData.dark(), isDark: true);
|
||||
|
||||
ThemeData buildVikunjaMaterialLightTheme() {
|
||||
return ThemeData.light().copyWith(
|
||||
useMaterial3: true,
|
||||
);
|
||||
}
|
||||
ThemeData buildVikunjaMaterialDarkTheme() {
|
||||
return ThemeData.dark().copyWith(
|
||||
useMaterial3: true,
|
||||
);
|
||||
}
|
||||
|
||||
ThemeData _buildVikunjaTheme(ThemeData base, {bool isDark = false}) {
|
||||
return base.copyWith(
|
||||
useMaterial3: true,
|
||||
errorColor: vRed,
|
||||
primaryColor: vPrimaryDark,
|
||||
primaryColorLight: vPrimary,
|
||||
|
@ -31,7 +43,20 @@ ThemeData _buildVikunjaTheme(ThemeData base, {bool isDark = false}) {
|
|||
vWhite, // This does not work, looks like a bug in Flutter: https://github.com/flutter/flutter/issues/19623
|
||||
),
|
||||
),
|
||||
bottomNavigationBarTheme: base.bottomNavigationBarTheme.copyWith(
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
enabledBorder: UnderlineInputBorder(
|
||||
borderSide: const BorderSide(color: Colors.grey, width: 1)
|
||||
),
|
||||
|
||||
),
|
||||
|
||||
dividerTheme: DividerThemeData(
|
||||
color: () {
|
||||
return isDark ? Colors.white10 : Colors.black12;
|
||||
}(),
|
||||
),
|
||||
navigationBarTheme: base.navigationBarTheme.copyWith(
|
||||
indicatorColor: vPrimary,
|
||||
// Make bottomNavigationBar backgroundColor darker to provide more separation
|
||||
backgroundColor: () {
|
||||
final _hslColor = HSLColor.fromColor(
|
||||
|
|
10
pubspec.lock
10
pubspec.lock
|
@ -177,6 +177,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.0+3"
|
||||
dynamic_color:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: dynamic_color
|
||||
sha256: de4798a7069121aee12d5895315680258415de9b00e717723a1bd73d58f0126d
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.6.6"
|
||||
fake_async:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -1023,4 +1031,4 @@ packages:
|
|||
version: "3.1.2"
|
||||
sdks:
|
||||
dart: ">=3.0.0-0 <4.0.0"
|
||||
flutter: ">=3.3.0"
|
||||
flutter: ">=3.4.0-17.0.pre"
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
name: vikunja_app
|
||||
description: Vikunja as Flutter cross platform app
|
||||
|
||||
version: 0.1.0-beta
|
||||
version: 0.1.1-beta
|
||||
|
||||
environment:
|
||||
sdk: ">=2.18.0 <3.0.0"
|
||||
|
@ -31,6 +31,7 @@ dependencies:
|
|||
url_launcher: ^6.1.7
|
||||
workmanager: ^0.5.1
|
||||
permission_handler: ^10.2.0
|
||||
dynamic_color: ^1.6.6
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
|
Loading…
Reference in New Issue