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

Compare commits

...

78 Commits

Author SHA1 Message Date
85f79e4e5a Merge branch 'main' of https://github.com/go-vikunja/app 2024-04-29 16:57:47 +02:00
d30a3cf5f2 chore: continual of prev. commit, deleting all references to lists and namespaces 2024-04-29 16:52:22 +02:00
4e78b5615e chore: removed all references to namespaces and lists (old names for what projects are now) 2024-04-29 16:45:42 +02:00
9961447788
Merge pull request #70 from denysvitali/feature/add-web-support
Add web support
2024-04-29 14:06:07 +02:00
828a57a642
Merge pull request #72 from denysvitali/feature/support-user-cas
Support User CAs
2024-04-29 14:03:18 +02:00
Denys Vitali
d83114e9aa
fix: go-vikunja/app#66
feat: add policy to AndroidManifest
2024-04-06 18:55:51 +02:00
Denys Vitali
0e29b6620d
feat: add web support 2024-04-06 18:54:01 +02:00
Denys Vitali
1d538d6816
revert: use minSdk=21 2024-04-06 18:46:26 +02:00
Denys Vitali
5ab6a59b05
refactor: update dependencies 2024-04-06 18:44:35 +02:00
Denys Vitali
056b2d72c9
chore: format code with dart format (#71)
This PR formats all code with dart format and adds a step to the CI so that it will be checked on every push and PR.
2024-04-05 22:36:56 +02:00
43b9fe6d8f pushed version tag 2024-02-16 19:11:46 +01:00
1ef9ed4e67 added fields to task view, changed icon of task editing. 2024-02-16 19:11:16 +01:00
aa0f56231f added bottomsheet for task preview 2024-02-15 22:32:17 +01:00
092386db2e fixed description not being visible in light mode 2024-02-15 10:48:56 +01:00
c2eb96c6e1 disable tarring of output 2024-02-12 23:30:56 +01:00
2fbd8ed27e disable tarring of output 2024-02-12 23:21:23 +01:00
5ee93a64be added artifact upload to gh actions 2024-02-12 23:12:33 +01:00
92c5ff5a44 added proper sorting of tasks on landing page 2024-02-12 22:28:42 +01:00
bd1ada1b9c fixed notifications for A14 2024-02-12 22:25:22 +01:00
515be2e88f respects landing page filter 2024-02-12 07:00:06 +01:00
f91509502a added background token refresh every 12 hours to avoid expiration
pushed version tag
2024-02-11 23:30:45 +01:00
13b7eb7368 removed task description from tasktile. 2024-02-11 23:01:12 +01:00
b0d60e1c12 fixed kanban by moving is_default_bucket from bucket to list 2024-01-06 01:06:47 +01:00
ae34f6be7b version bump 2024-01-06 00:06:48 +01:00
b2944d5cad removed old screenshots 2024-01-06 00:06:07 +01:00
dfa4dcae27 added taskattachmentfile class, added attachment list with download option 2024-01-06 00:04:33 +01:00
db6b94bb98 added appropriate permissions to write files to downloads 2024-01-06 00:03:52 +01:00
1ba7a443e9 fixed webview login 2024-01-05 23:09:51 +01:00
d5b32c7609 fix exception if default project id does not exist 2024-01-05 23:07:41 +01:00
62aeeedca6 added html renderer to task description in overview. 2024-01-05 22:29:34 +01:00
11db2bb9ad removed relics of old default project id on landing page 2024-01-05 21:38:40 +01:00
8b7e6a249b upgraded typeahead controller to fix bug introduced with new flutter version, upgraded to flutter version 2023-12-24 00:00:29 +01:00
ccef93c37e added project id to tojson function 2023-12-23 23:04:27 +01:00
2b505ceaf4 load default project from server 2023-12-23 22:38:16 +01:00
045bc6f668 Merge branch 'main' of https://github.com/go-vikunja/app 2023-12-03 21:21:35 +01:00
7db2cc1e67 fixed typo, removed unnecessary print statements 2023-12-03 21:21:28 +01:00
eb2e3d1cf1
fix: create tasks endpoint 2023-11-29 22:41:26 +01:00
2b86e98812 updated dependencies 2023-11-28 17:08:45 +01:00
0eb941bfb1 fixed uri escaping [] characters when loading tasks 2023-11-28 17:07:29 +01:00
347bd6cd66 added priority to task subtitle 2023-11-28 17:05:30 +01:00
b7246cf433 Merge branch 'Namespace-Project_Migration' 2023-07-24 00:13:02 +02:00
7b6da4970c removed add project button, pushed version tag 2023-07-24 00:12:42 +02:00
c4885b4d41 changed how options are used, fixed "only show tasks with due date" 2023-07-24 00:00:37 +02:00
2262eceaad
Update README.md 2023-07-23 22:53:32 +02:00
6b276e511d added option to select what to see on landing page 2023-07-23 22:34:10 +02:00
c7a556311d fixed new button width 2023-07-23 22:08:09 +02:00
c3a8172739 added material you, changed button style 2023-07-23 21:56:34 +02:00
33242c2bfb migrated to material3, added option for other themes for later 2023-07-23 17:25:58 +02:00
9c5ad58299 moving from lists and namespaces to projects. 2023-07-23 01:50:55 +02:00
f19eda317e Merge branch 'main' into Namespace-Project_Migration 2023-07-23 00:11:10 +02:00
04c2657ab1 changed build on push and pull request for all branches 2023-07-23 00:06:09 +02:00
7c7d6be9df added subproject view to project home page 2023-07-23 00:04:13 +02:00
1c523d929c added project list page with expandable sublists 2023-07-22 23:31:28 +02:00
6f32e1ff38 renamed namespaces to projects, added basic project views 2023-07-22 22:55:25 +02:00
2893a4e7f9 moved from list to projects 2023-07-22 22:54:50 +02:00
16fa80f8df added basic project classes 2023-07-22 22:54:04 +02:00
2a7a8755af pushed version tag 2023-05-25 00:07:19 +02:00
f29feb6340 readded namespace edit 2023-05-25 00:00:43 +02:00
4f79c083c6 fixed reverse list bug 2023-05-24 23:55:33 +02:00
d5a45be90a added sharing test to list edit 2023-05-24 23:24:50 +02:00
3a996f91f0 added confirmation dialog to delete 2023-05-17 12:56:32 +02:00
d24b0cdd6e added manual theme selection 2023-05-17 12:52:26 +02:00
b126f54542 fixed settings overflow
added option to disable snackbar for login
added option to customize theme based on dark mode status
2023-05-17 12:28:55 +02:00
6176af2acb fixed logout 2023-05-17 01:22:59 +02:00
b917b27a83 changed how appbars are handled so namespaces can have their names as the appbar title 2023-05-16 22:13:02 +02:00
69a8608390 added delete to task page 2023-05-16 22:12:11 +02:00
2a78ccaae4 added User-Agent header 2023-05-16 22:11:45 +02:00
1f23c15742 fixed logout from settings 2023-05-16 21:09:35 +02:00
8cd827d572 fixed behaviour when totp popup is dismissed 2023-05-16 21:09:22 +02:00
bf28b57525 added proper error management 2023-05-16 21:08:58 +02:00
0ab24284f6 fixed refresh on landing page not reaching down 2023-05-16 21:08:11 +02:00
7cbc0361bb removed drawer, moved drawer info to settings 2023-05-16 00:26:01 +02:00
c2d976ac89 Merge branch 'main' into bottom_navigation_bar 2023-05-15 23:23:54 +02:00
5ecd6fa2d9 optimized notification scheduling 2023-05-15 23:23:28 +02:00
b1cd3c8862 fixed loading of empty view 2023-05-15 18:24:39 +02:00
73bbfed454 fixed notifications not working 2023-05-15 18:14:47 +02:00
c07e5d7dc2 returned avatar loading
removed comment
2023-05-14 01:49:08 +02:00
8a499960c1 added bottom navigation bar 2023-05-14 01:28:30 +02:00
87 changed files with 3756 additions and 2333 deletions

View File

@ -1,10 +1,6 @@
name: Flutter Build Unsigned Debug
on:
push:
branches:
- main
pull_request:
on: [push, pull_request]
jobs:
build-app:
@ -37,3 +33,9 @@ jobs:
- name: Build Debug Build
run: flutter build apk --debug --flavor core
- name: Upload Artifact
uses: actions/upload-artifact@v4
with:
name: app-core-debug.apk
path: build/app/outputs/flutter-apk/app-core-debug.apk
compression-level: 0

20
.github/workflows/flutter-format.yml vendored Normal file
View File

@ -0,0 +1,20 @@
name: Flutter Check Format
on:
pull_request: {}
push: {}
jobs:
release:
name: Build
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v1
- name: Setup Flutter
uses: subosito/flutter-action@v1
with:
channel: stable
- name: Check Dart Format
run: dart format --set-exit-if-changed .

View File

@ -4,5 +4,27 @@
# This file should be version controlled and should not be manually edited.
version:
revision: 3b309bda072a6b326e8aa4591a5836af600923ce
channel: beta
revision: "300451adae589accbece3490f4396f10bdf15e6e"
channel: "stable"
project_type: app
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: 300451adae589accbece3490f4396f10bdf15e6e
base_revision: 300451adae589accbece3490f4396f10bdf15e6e
- platform: web
create_revision: 300451adae589accbece3490f4396f10bdf15e6e
base_revision: 300451adae589accbece3490f4396f10bdf15e6e
# User provided section
# List of Local paths (relative to this file) that should be
# ignored by the migrate tool.
#
# Files that are not part of the templates will be ignored by default.
unmanaged_files:
- 'lib/main.dart'
- 'ios/Runner.xcodeproj/project.pbxproj'

29
.vscode/launch.json vendored
View File

@ -1,14 +1,19 @@
{
"configurations": [
{
"name": "Flutter",
"request": "launch",
"type": "dart",
"flutterMode": "debug",
"args": [
"--flavor",
"main"
],
}
]
"configurations": [
{
"name": "Flutter (Chromium)",
"type": "dart",
"request": "launch",
"program": "lib/main.dart",
"args": ["-d", "chrome", "--flavor", "main"],
"deviceId": "chrome",
},
{
"name": "Flutter",
"request": "launch",
"type": "dart",
"flutterMode": "debug",
"args": ["--flavor", "main"]
}
]
}

View File

@ -1,4 +1,6 @@
# Vikunja Cross-Platform App
![GitHub release (latest by SemVer including pre-releases)](https://img.shields.io/github/downloads-pre/go-vikunja/app/latest/total)
![Matrix](https://img.shields.io/matrix/vikunja%3Amatrix.org)
Download from [Releases](https://github.com/go-vikunja/app/releases/latest)

View File

@ -32,7 +32,7 @@ if (keystorePropertiesFile.exists()) {
}
android {
compileSdkVersion 33
compileSdkVersion 34
sourceSets {
main.java.srcDirs += 'src/main/kotlin'
@ -41,8 +41,8 @@ android {
defaultConfig {
applicationId "io.vikunja.app"
minSdkVersion 19
targetSdkVersion 33
minSdkVersion 21
targetSdkVersion 34
testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
versionCode flutterVersionCode.toInteger()
versionName flutterVersionName

View File

@ -8,6 +8,9 @@
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
<uses-permission android:name="android.permission.USE_EXACT_ALARM" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- io.flutter.app.FlutterApplication is an android.app.Application that
calls FlutterMain.startInitialization(this); in its onCreate method.
@ -16,8 +19,11 @@
FlutterApplication and put your custom class here. -->
<application
android:label="Vikunja"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:usesCleartextTraffic="true">
android:networkSecurityConfig="@xml/network_security_config"
>
<meta-data android:name="io.flutter.network-policy" android:resource="@xml/network_security_config"/>
<activity
android:name=".MainActivity"
android:launchMode="singleTop"
@ -82,6 +88,24 @@
android:name="android.service.quicksettings.ACTIVE_TILE"
android:value="true" />
</service>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/provider_paths" />
</provider>
<provider
android:name="vn.hunghd.flutterdownloader.DownloadedFileProvider"
android:authorities="io.vikunja.app.flutter_downloader.provider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/provider_paths" />
</provider>
</application>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
</manifest>
</manifest>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<external-path
name="external_files"
path="/storage/emulated/0/Download" />
</paths>

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="true">
<trust-anchors>
<certificates src="system" />
<certificates src="user" />
</trust-anchors>
</base-config>
</network-security-config>

View File

@ -1,5 +1,5 @@
buildscript {
ext.kotlin_version = '1.7.20'
ext.kotlin_version = '1.9.23'
repositories {
google()
mavenCentral()

0
android/gradlew vendored Normal file → Executable file
View File

View File

@ -8,19 +8,18 @@ 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);
});
if (response == null) return null;
return Bucket.fromJSON(response.body);
});
}
@override
Future delete(int listId, int bucketId) {
return client
.delete('/lists/$listId/buckets/$bucketId');
Future delete(int projectId, int bucketId) {
return client.delete('/projects/$projectId/buckets/$bucketId');
}
/* Not implemented in the Vikunja API
@ -33,15 +32,15 @@ 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)
.then((response) => response != null ? new Response(
convertList(response.body, (result) => Bucket.fromJSON(result)),
response.statusCode,
response.headers
) : null);
return client.get('/projects/$projectId/buckets', queryParameters).then(
(response) => response != null
? new Response(
convertList(response.body, (result) => Bucket.fromJSON(result)),
response.statusCode,
response.headers)
: null);
}
@override
@ -51,10 +50,11 @@ 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);
});
if (response == null) return null;
return Bucket.fromJSON(response.body);
});
}
}
}

View File

@ -1,121 +1,169 @@
import 'dart:async';
import 'dart:convert';
import 'dart:core';
import 'dart:io';
import 'package:cronet_http/cronet_http.dart' as cronet_http;
import 'package:cupertino_http/cupertino_http.dart' as cupertino_http;
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:http/io_client.dart' as io_client;
import 'package:vikunja_app/api/response.dart';
import 'package:vikunja_app/components/string_extension.dart';
import 'package:vikunja_app/global.dart';
import '../main.dart';
class Client {
GlobalKey<ScaffoldMessengerState>? global;
GlobalKey<ScaffoldMessengerState>? global_scaffold_key;
final JsonDecoder _decoder = new JsonDecoder();
final JsonEncoder _encoder = new JsonEncoder();
String _token = '';
String _base = '';
bool authenticated = false;
bool ignoreCertificates = false;
bool showSnackBar = true;
String get base => _base;
String get token => _token;
String? post_body;
//HttpClient client = new HttpClient();
bool operator ==(dynamic otherClient) {
@override
bool operator ==(Object otherClient) {
if (otherClient is! Client) return false;
return otherClient._token == _token;
}
Client(this.global,
{String? token, String? base, bool authenticated = false}) {
configure(token: token, base: base, authenticated: authenticated);
Client(
this.global_scaffold_key, {
String? token,
String? base,
bool authenticated = false,
}) {
configure(
token: token,
base: base,
authenticated: authenticated,
);
}
void reload_ignore_certs(bool? val) {
http.Client get httpClient {
if (Platform.isAndroid) {
final engine = cronet_http.CronetEngine.build(
cacheMode: cronet_http.CacheMode.memory, cacheMaxSize: 1000000);
return cronet_http.CronetClient.fromCronetEngine(engine);
}
if (Platform.isIOS || Platform.isMacOS) {
final config =
cupertino_http.URLSessionConfiguration.ephemeralSessionConfiguration()
..cache =
cupertino_http.URLCache.withCapacity(memoryCapacity: 1000000);
return cupertino_http.CupertinoClient.fromSessionConfiguration(config);
}
return io_client.IOClient();
}
void reloadIgnoreCerts(bool? val) {
ignoreCertificates = val ?? false;
HttpOverrides.global = new IgnoreCertHttpOverrides(ignoreCertificates);
if(global == null || global!.currentContext == null) return;
VikunjaGlobal
.of(global!.currentContext!)
if (global_scaffold_key == null ||
global_scaffold_key!.currentContext == null) return;
VikunjaGlobal.of(global_scaffold_key!.currentContext!)
.settingsManager
.setIgnoreCertificates(ignoreCertificates);
}
get _headers =>
{
get _headers => {
'Authorization': _token != '' ? 'Bearer $_token' : '',
'Content-Type': 'application/json'
'Content-Type': 'application/json',
'User-Agent': 'Vikunja Mobile App',
};
get headers => _headers;
@override
int get hashCode => _token.hashCode;
void configure({String? token, String? base, bool? authenticated}) {
if (token != null)
_token = token;
void configure({
String? token,
String? base,
bool? authenticated,
}) {
if (token != null) _token = token;
if (base != null) {
base = base.replaceAll(" ", "");
if(base.endsWith("/"))
base = base.substring(0,base.length-1);
if (base.endsWith("/")) base = base.substring(0, base.length - 1);
_base = base.endsWith('/api/v1') ? base : '$base/api/v1';
}
if (authenticated != null)
this.authenticated = authenticated;
if (authenticated != null) this.authenticated = authenticated;
}
void reset() {
_token = _base = '';
authenticated = false;
}
Future<Response?> get(String url,
[Map<String, List<String>>? queryParameters]) {
return http.get('${this.base}$url'.toUri()!, headers: _headers)
.then(_handleResponse).catchError((Object? obj) {print(obj);});
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},
queryParameters: queryParameters,
fragment: uri.fragment);
return httpClient
.get(uri, headers: _headers)
.then(_handleResponse)
.onError((error, stackTrace) => _handleError(error, stackTrace));
}
Future<Response?> delete(String url) {
return http
return httpClient
.delete(
'${this.base}$url'.toUri()!,
headers: _headers,
)
.then(_handleResponse).onError((error, stackTrace) =>
_handleError(error, stackTrace));
'${this.base}$url'.toUri()!,
headers: _headers,
)
.then(_handleResponse)
.onError((error, stackTrace) => _handleError(error, stackTrace));
}
Future<Response?> post(String url, {dynamic body}) {
return http
return httpClient
.post(
'${this.base}$url'.toUri()!,
headers: _headers,
body: _encoder.convert(body),
)
.then(_handleResponse);
'${this.base}$url'.toUri()!,
headers: _headers,
body: _encoder.convert(body),
)
.then(_handleResponse)
.onError((error, stackTrace) => _handleError(error, stackTrace));
}
Future<Response?> put(String url, {dynamic body}) {
return http
return httpClient
.put(
'${this.base}$url'.toUri()!,
headers: _headers,
body: _encoder.convert(body),
)
.then(_handleResponse);
'${this.base}$url'.toUri()!,
headers: _headers,
body: _encoder.convert(body),
)
.then(_handleResponse)
.onError((error, stackTrace) => _handleError(error, stackTrace));
}
Response? _handleError(Object? e, StackTrace? st) {
if(global == null) return null;
if (global_scaffold_key == null) return null;
SnackBar snackBar = SnackBar(
content: Text("Error on request: " + e.toString()),
action: SnackBarAction(label: "Clear", onPressed: () => global!.currentState?.clearSnackBars()),);
global!.currentState?.showSnackBar(snackBar);
action: SnackBarAction(
label: "Clear",
onPressed: () => global_scaffold_key!.currentState?.clearSnackBars()),
);
global_scaffold_key!.currentState?.showSnackBar(snackBar);
return null;
}
Map<String, String> headersToMap(HttpHeaders headers) {
@ -126,65 +174,43 @@ class Client {
return map;
}
/*
Future<Response> _handleResponseF(HttpClientRequest request) {
_headers.forEach((k, v) => request.headers.set(k, v));
if(post_body != "") {
request.write(post_body);
post_body = "";
}
return request.close().then((response) {
final completer = Completer<String>();
final contents = StringBuffer();
response.transform(utf8.decoder).listen((data) {
contents.write(data);
}, onDone: () => completer.complete(contents.toString()));
return completer.future.then((body) {
Response res = Response(json.decode(body), response.statusCode, headersToMap(response.headers));
_handleResponseErrors(res);
return res;
});
});
//return Response(body, statusCode, headers)
}*/
void _handleResponseErrors(http.Response response) {
if(response.statusCode == 412)
return;
if (response.statusCode < 200 ||
response.statusCode >= 400) {
Error? _handleResponseErrors(http.Response response) {
if (response.statusCode < 200 || response.statusCode >= 400) {
Map<String, dynamic> error;
error = _decoder.convert(response.body);
if (response.statusCode ~/ 100 == 4) {
/*throw new InvalidRequestApiException(
response.statusCode,
"",
error["message"] ?? "Unknown Error");
*/
}
final SnackBar snackBar = SnackBar(
content: Text(
"Error code " + response.statusCode.toString() + " received."),
action: SnackBarAction(
label: ("Show Details"),
onPressed: () {
Builder(
builder: (BuildContext context) =>
Dialog(
child: Text(error["message"]),
)
);
},
),
content:
Text("Error code " + response.statusCode.toString() + " received."),
action: globalNavigatorKey.currentContext == null
? null
: SnackBarAction(
label: ("Details"),
onPressed: () {
showDialog(
context: globalNavigatorKey.currentContext!,
builder: (BuildContext context) => AlertDialog(
title: Text("Error ${response.statusCode}"),
content: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
"Message: ${error["message"]}",
textAlign: TextAlign.start,
),
Text("Url: ${response.request!.url.toString()}"),
],
)));
},
),
);
if(global != null)
global!.currentState?.showSnackBar(snackBar);
if (global_scaffold_key != null && showSnackBar)
global_scaffold_key!.currentState?.showSnackBar(snackBar);
else
print("error on request: ${error["message"]}");
}
return null;
}
Response? _handleResponse(http.Response response) {

View File

@ -14,7 +14,7 @@ class LabelTaskAPIService extends APIService implements LabelTaskService {
.then((response) {
if (response == null) return null;
return Label.fromJson(response.body);
});
});
}
@override
@ -22,9 +22,9 @@ class LabelTaskAPIService extends APIService implements LabelTaskService {
return client
.delete('/tasks/${lt.task!.id}/labels/${lt.label.id}')
.then((response) {
if (response == null) return null;
return Label.fromJson(response.body);
});
if (response == null) return null;
return Label.fromJson(response.body);
});
}
@override
@ -32,10 +32,9 @@ class LabelTaskAPIService extends APIService implements LabelTaskService {
String? params =
query == null ? null : '?s=' + Uri.encodeQueryComponent(query);
return client.get('/tasks/${lt.task!.id}/labels$params').then(
(label) {
if (label == null) return null;
return convertList(label, (result) => Label.fromJson(result));
});
return client.get('/tasks/${lt.task!.id}/labels$params').then((label) {
if (label == null) return null;
return convertList(label, (result) => Label.fromJson(result));
});
}
}

View File

@ -11,15 +11,14 @@ class LabelTaskBulkAPIService extends APIService
@override
Future<List<Label>?> update(Task task, List<Label>? labels) {
if(labels == null)
labels = [];
if (labels == null) labels = [];
return client
.post('/tasks/${task.id}/labels/bulk',
body: LabelTaskBulk(labels: labels).toJSON())
.then((response) {
if (response == null) return null;
return convertList(
response.body['labels'], (result) => Label.fromJson(result));
});
if (response == null) return null;
return convertList(
response.body['labels'], (result) => Label.fromJson(result));
});
}
}

View File

@ -8,52 +8,43 @@ class LabelAPIService extends APIService implements LabelService {
@override
Future<Label?> create(Label label) {
return client
.put('/labels', body: label.toJSON())
.then((response) {
if (response == null) return null;
return Label.fromJson(response.body);
});
return client.put('/labels', body: label.toJSON()).then((response) {
if (response == null) return null;
return Label.fromJson(response.body);
});
}
@override
Future<Label?> delete(Label label) {
return client
.delete('/labels/${label.id}')
.then((response) {
if (response == null) return null;
return Label.fromJson(response.body);
});
return client.delete('/labels/${label.id}').then((response) {
if (response == null) return null;
return Label.fromJson(response.body);
});
}
@override
Future<Label?> get(int labelID) {
return client
.get('/labels/$labelID')
.then((response) {
if (response == null) return null;
return Label.fromJson(response.body);
});
return client.get('/labels/$labelID').then((response) {
if (response == null) return null;
return Label.fromJson(response.body);
});
}
@override
Future<List<Label>?> getAll({String? query}) {
String params =
query == null ? '' : '?s=' + Uri.encodeQueryComponent(query);
return client.get('/labels$params').then(
(response) {
if (response == null) return null;
return convertList(response.body, (result) => Label.fromJson(result));
});
return client.get('/labels$params').then((response) {
if (response == null) return null;
return convertList(response.body, (result) => Label.fromJson(result));
});
}
@override
Future<Label?> update(Label label) {
return client
.post('/labels/${label.id}', body: label)
.then((response) {
if (response == null) return null;
return Label.fromJson(response.body);
});
return client.post('/labels/${label.id}', body: label).then((response) {
if (response == null) return null;
return Label.fromJson(response.body);
});
}
}

View File

@ -1,111 +0,0 @@
import 'dart:async';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:vikunja_app/api/client.dart';
import 'package:vikunja_app/api/service.dart';
import 'package:vikunja_app/models/list.dart';
import 'package:vikunja_app/service/services.dart';
class ListAPIService extends APIService implements ListService {
FlutterSecureStorage _storage;
ListAPIService(Client client, FlutterSecureStorage storage) : _storage = storage, super(client);
@override
Future<TaskList?> create(namespaceId, TaskList tl) {
tl.namespaceId = namespaceId;
return client
.put('/namespaces/$namespaceId/lists', body: tl.toJSON())
.then((response) {
if (response == null) return null;
return TaskList.fromJson(response.body);
});
}
@override
Future delete(int listId) {
return client.delete('/lists/$listId').then((_) {});
}
@override
Future<TaskList?> get(int listId) {
return client.get('/lists/$listId').then((response) {
if (response == null) return null;
final map = response.body;
if (map.containsKey('id')) {
return client
.get("/lists/$listId/tasks")
.then((tasks) {
map['tasks'] = tasks?.body;
return TaskList.fromJson(map);
});
}
return TaskList.fromJson(map);
});
}
@override
Future<List<TaskList>?> getAll() {
return client.get('/lists').then(
(list) {
if (list == null || list.statusCode != 200) return null;
if (list.body.toString().isEmpty)
return Future.value([]);
print(list.statusCode);
return convertList(list.body, (result) => TaskList.fromJson(result));});
}
@override
Future<List<TaskList>?> getByNamespace(int namespaceId) {
// TODO there needs to be a better way for this. /namespaces/-2/lists should
// return favorite lists
if(namespaceId == -2) {
// Favourites.
return getAll().then((value) {
if (value == null) return null;
value.removeWhere((element) => !element.isFavorite); return value;});
}
return client.get('/namespaces/$namespaceId/lists').then(
(list) {
if (list == null || list.statusCode != 200) return null;
return convertList(list.body, (result) => TaskList.fromJson(result));
});
}
@override
Future<TaskList?> update(TaskList tl) {
return client
.post('/lists/${tl.id}', body: tl.toJSON())
.then((response) {
if (response == null) return null;
return TaskList.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());
}
}

View File

@ -1,49 +0,0 @@
import 'dart:async';
import 'dart:developer';
import 'package:vikunja_app/api/client.dart';
import 'package:vikunja_app/api/service.dart';
import 'package:vikunja_app/models/namespace.dart';
import 'package:vikunja_app/service/services.dart';
class NamespaceAPIService extends APIService implements NamespaceService {
NamespaceAPIService(Client client) : super(client);
@override
Future<Namespace?> create(Namespace ns) {
return client.put('/namespaces', body: ns.toJSON()).then((response) {
if (response == null) return null;
return Namespace.fromJson(response.body);
});
}
@override
Future delete(int namespaceId) {
return client.delete('/namespaces/$namespaceId');
}
@override
Future<Namespace?> get(int namespaceId) {
return client.get('/namespaces/$namespaceId').then((response) {
if (response == null) return null;
return Namespace.fromJson(response.body);
});
}
@override
Future<List<Namespace>?> getAll() {
return client.get('/namespaces').then((response) {
if (response == null) return null;
return convertList(response.body, (result) => Namespace.fromJson(result));
});
}
@override
Future<Namespace?> update(Namespace ns) {
return client
.post('/namespaces/${ns.id}', body: ns.toJSON())
.then((response) {
if (response == null) return null;
return Namespace.fromJson(response.body);
});
}
}

84
lib/api/project.dart Normal file
View File

@ -0,0 +1,84 @@
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());
}
}

View File

@ -1,10 +1,16 @@
// This is a wrapper class to be able to return the headers up to the provider
// to properly handle things like pagination with it.
class Error {
Error(this.message);
final String message;
}
class Response {
Response(this.body, this.statusCode, this.headers, {this.error = false});
Response(this.body, this.statusCode, this.headers, {this.error});
final dynamic body;
final int statusCode;
final Map<String, String> headers;
final bool error;
final Error? error;
}

View File

@ -10,8 +10,7 @@ class ServerAPIService extends APIService implements ServerService {
@override
Future<Server?> getInfo() {
return client.get('/info').then((value) {
if(value == null)
return null;
if (value == null) return null;
return Server.fromJson(value.body);
});
}

View File

@ -11,29 +11,26 @@ 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/tasks', body: task.toJSON())
.then((response) {
if (response == null) return null;
return Task.fromJson(response.body);
});
if (response == null) return null;
return Task.fromJson(response.body);
});
}
@override
Future<Task?> get(int listId) {
return client
.get('/list/$listId/tasks')
.then((response) {
if (response == null) return null;
return Task.fromJson(response.body);
});
return client.get('/project/$listId/tasks').then((response) {
if (response == null) return null;
return Task.fromJson(response.body);
});
}
@override
Future delete(int taskId) {
return client
.delete('/tasks/$taskId');
return client.delete('/tasks/$taskId');
}
@override
@ -41,66 +38,69 @@ class TaskAPIService extends APIService implements TaskService {
return client
.post('/tasks/${task.id}', body: task.toJSON())
.then((response) {
if (response == null) return null;
return Task.fromJson(response.body);
});
}
@override
Future<List<Task>?> getAll() {
return client
.get('/tasks/all')
.then((response) {
int page_n = 0;
if (response == null) return null;
if (response.headers["x-pagination-total-pages"] != null) {
page_n = int.parse(response.headers["x-pagination-total-pages"]!);
} else {
return Future.value(response.body);
}
List<Future<void>> futureList = [];
List<Task> taskList = [];
for(int i = 0; i < page_n; i++) {
Map<String, List<String>> paramMap = {
"page": [i.toString()]
};
futureList.add(client.get('/tasks/all', paramMap).then((pageResponse) {convertList(pageResponse?.body, (result) {taskList.add(Task.fromJson(result));});}));
}
return Future.wait(futureList).then((value) {
return taskList;
});
if (response == null) return null;
return Task.fromJson(response.body);
});
}
@override
Future<Response?> getAllByList(int listId,
Future<List<Task>?> getAll() {
return client.get('/tasks/all').then((response) {
int page_n = 0;
if (response == null) return null;
if (response.headers["x-pagination-total-pages"] != null) {
page_n = int.parse(response.headers["x-pagination-total-pages"]!);
} else {
return Future.value(response.body);
}
List<Future<void>> futureList = [];
List<Task> taskList = [];
for (int i = 0; i < page_n; i++) {
Map<String, List<String>> paramMap = {
"page": [i.toString()]
};
futureList.add(client.get('/tasks/all', paramMap).then((pageResponse) {
convertList(pageResponse?.body, (result) {
taskList.add(Task.fromJson(result));
});
}));
}
return Future.wait(futureList).then((value) {
return taskList;
});
});
}
@override
Future<Response?> getAllByProject(int projectId,
[Map<String, List<String>>? queryParameters]) {
return client
.get('/lists/$listId/tasks', queryParameters).then(
(response) {
return response != null ?
new Response(
convertList(response.body, (result) => Task.fromJson(result)),
response.statusCode,
response.headers) : null;
});
.get('/projects/$projectId/tasks', queryParameters)
.then((response) {
return response != null
? new Response(
convertList(response.body, (result) => Task.fromJson(result)),
response.statusCode,
response.headers)
: null;
});
}
@override
Future<List<Task>?> getByOptions(TaskServiceOptions options) {
String optionString = options.getOptions();
return client
.get('/tasks/all?$optionString')
.then((response) {
if (response == null) return null;
return convertList(response.body, (result) => Task.fromJson(result));
Map<String, List<String>> optionsMap = options.getOptions();
//optionString = "?sort_by[]=due_date&sort_by[]=id&order_by[]=asc&order_by[]=desc&filter_by[]=done&filter_value[]=false&filter_comparator[]=equals&filter_concat=and&filter_include_nulls=false&page=1";
//print(optionString);
return client.get('/tasks/all', optionsMap).then((response) {
if (response == null) return null;
return convertList(response.body, (result) => Task.fromJson(result));
});
}
@override
// TODO: implement maxPages
int get maxPages => maxPages;
}

View File

@ -9,21 +9,23 @@ class UserAPIService extends APIService implements UserService {
UserAPIService(Client client) : super(client);
@override
Future<UserTokenPair> login(String username, password, {bool rememberMe = false, String? totp}) async {
Future<UserTokenPair> login(String username, password,
{bool rememberMe = false, String? totp}) async {
var body = {
'long_token': rememberMe,
'password': password,
'username': username,
};
if(totp != null) {
if (totp != null) {
body['totp_passcode'] = totp;
}
}
var response = await client.post('/login', body: body);
var token = response?.body["token"];
if(token == null || response == null || response.error)
if (token == null || response == null || response.error != null)
return Future.value(UserTokenPair(null, null,
error: response != null ? response.body["code"] : 0,
errorString: response != null ? response.body["message"] : "Login error"));
errorString:
response != null ? response.body["message"] : "Login error"));
client.configure(token: token);
return UserAPIService(client)
.getCurrentUser()
@ -44,4 +46,20 @@ class UserAPIService extends APIService implements UserService {
Future<User> getCurrentUser() {
return client.get('/user').then((map) => User.fromJson(map?.body));
}
@override
Future<UserSettings?> setCurrentUserSettings(
UserSettings userSettings) async {
return client
.post('/user/settings/general', body: userSettings.toJson())
.then((response) {
if (response == null) return null;
return userSettings;
});
}
@override
Future<String?> getToken() {
return client.post('/user/token').then((value) => value?.body["token"]);
}
}

View File

@ -5,7 +5,6 @@ import 'dart:convert';
import 'package:http/http.dart';
import 'package:url_launcher/url_launcher.dart';
class VersionChecker {
GlobalKey<ScaffoldMessengerState> snackbarKey;
VersionChecker(this.snackbarKey);
@ -47,7 +46,8 @@ class VersionChecker {
content: Text("New version available: $latest"),
action: SnackBarAction(
label: "View on Github",
onPressed: () => launchUrl(Uri.parse(repo), mode: LaunchMode.externalApplication)),
onPressed: () => launchUrl(Uri.parse(repo),
mode: LaunchMode.externalApplication)),
);
snackbarKey.currentState?.showSnackBar(snackBar);
}

View File

@ -4,7 +4,8 @@ import 'package:vikunja_app/global.dart';
import 'dart:developer';
import '../models/task.dart';
enum NewTaskDue {day,week, month, custom}
enum NewTaskDue { day, week, month, custom }
// TODO: add to enum above
Map<NewTaskDue, Duration> newTaskDueToDuration = {
NewTaskDue.day: Duration(days: 1),
@ -16,11 +17,11 @@ class AddDialog extends StatefulWidget {
final ValueChanged<String>? onAdd;
final void Function(String title, DateTime? dueDate)? onAddTask;
final InputDecoration? decoration;
const AddDialog({Key? key, this.onAdd, this.decoration, this.onAddTask}) : super(key: key);
const AddDialog({Key? key, this.onAdd, this.decoration, this.onAddTask})
: super(key: key);
@override
State<StatefulWidget> createState() => AddDialogState();
}
class AddDialogState extends State<AddDialog> {
@ -30,13 +31,11 @@ class AddDialogState extends State<AddDialog> {
@override
Widget build(BuildContext context) {
if(newTaskDue != NewTaskDue.custom)
if (newTaskDue != NewTaskDue.custom)
customDueDate = DateTime.now().add(newTaskDueToDuration[newTaskDue]!);
return new AlertDialog(
contentPadding: const EdgeInsets.all(16.0),
content: new Column(
mainAxisSize: MainAxisSize.min,
children: [
content: new Column(mainAxisSize: MainAxisSize.min, children: [
Row(children: <Widget>[
Expanded(
child: new TextField(
@ -46,14 +45,24 @@ class AddDialogState extends State<AddDialog> {
),
),
]),
widget.onAddTask != null ? taskDueList("1 Day", NewTaskDue.day) : new Container(),
widget.onAddTask != null ? taskDueList("1 Week", NewTaskDue.week) : new Container(),
widget.onAddTask != null ? taskDueList("1 Month", NewTaskDue.month) : new Container(),
widget.onAddTask != null ? VikunjaDateTimePicker(
label: "Enter exact time",
onChanged: (value) {setState(() => newTaskDue = NewTaskDue.custom); customDueDate = value;},
) : new Container(),
widget.onAddTask != null
? taskDueList("1 Day", NewTaskDue.day)
: new Container(),
widget.onAddTask != null
? taskDueList("1 Week", NewTaskDue.week)
: new Container(),
widget.onAddTask != null
? taskDueList("1 Month", NewTaskDue.month)
: new Container(),
widget.onAddTask != null
? VikunjaDateTimePicker(
label: "Enter exact time",
onChanged: (value) {
setState(() => newTaskDue = NewTaskDue.custom);
customDueDate = value;
},
)
: new Container(),
//],)
]),
actions: <Widget>[
@ -66,7 +75,7 @@ class AddDialogState extends State<AddDialog> {
onPressed: () {
if (widget.onAdd != null && textController.text.isNotEmpty)
widget.onAdd!(textController.text);
if(widget.onAddTask != null && textController.text.isNotEmpty) {
if (widget.onAddTask != null && textController.text.isNotEmpty) {
widget.onAddTask!(textController.text, customDueDate);
}
Navigator.pop(context);
@ -78,9 +87,15 @@ class AddDialogState extends State<AddDialog> {
Widget taskDueList(String name, NewTaskDue thisNewTaskDue) {
return Row(children: [
Checkbox(value: newTaskDue == thisNewTaskDue, onChanged: (value) {
newTaskDue = thisNewTaskDue;
setState(() => customDueDate = DateTime.now().add(newTaskDueToDuration[thisNewTaskDue]!));}, shape: CircleBorder(),),
Checkbox(
value: newTaskDue == thisNewTaskDue,
onChanged: (value) {
newTaskDue = thisNewTaskDue;
setState(() => customDueDate =
DateTime.now().add(newTaskDueToDuration[thisNewTaskDue]!));
},
shape: CircleBorder(),
),
Text(name),
]);
}

View File

@ -36,13 +36,15 @@ class _BucketLimitDialogState extends State<BucketLimitDialog> {
inputFormatters: <TextInputFormatter>[
FilteringTextInputFormatter.digitsOnly,
],
onSubmitted: (text) => Navigator.of(context).pop(int.parse(text)),
onSubmitted: (text) =>
Navigator.of(context).pop(int.parse(text)),
),
),
Column(
children: <Widget>[
IconButton(
onPressed: () => _controller.text = '${int.parse(_controller.text) + 1}',
onPressed: () =>
_controller.text = '${int.parse(_controller.text) + 1}',
icon: Icon(Icons.expand_less),
),
IconButton(
@ -68,7 +70,8 @@ class _BucketLimitDialogState extends State<BucketLimitDialog> {
child: Text('Remove Limit'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(int.parse(_controller.text)),
onPressed: () =>
Navigator.of(context).pop(int.parse(_controller.text)),
child: Text('Done'),
)
],

View File

@ -5,12 +5,13 @@ import 'package:flutter/scheduler.dart';
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/pages/project/task_edit.dart';
import 'package:vikunja_app/utils/misc.dart';
import 'package:vikunja_app/theme/constants.dart';
enum DropLocation {above, below, none}
import '../stores/project_store.dart';
enum DropLocation { above, below, none }
class TaskData {
final Task task;
@ -36,7 +37,8 @@ class BucketTaskCard extends StatefulWidget {
State<BucketTaskCard> createState() => _BucketTaskCardState();
}
class _BucketTaskCardState extends State<BucketTaskCard> with AutomaticKeepAliveClientMixin {
class _BucketTaskCardState extends State<BucketTaskCard>
with AutomaticKeepAliveClientMixin {
Size? _cardSize;
bool _dragging = false;
DropLocation _dropLocation = DropLocation.none;
@ -47,8 +49,9 @@ class _BucketTaskCardState extends State<BucketTaskCard> with AutomaticKeepAlive
super.build(context);
if (_cardSize == null) _updateCardSize(context);
final taskState = Provider.of<ListProvider>(context);
final bucket = taskState.buckets[taskState.buckets.indexWhere((b) => b.id == widget.task.bucketId)];
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;
const chipConstraints = BoxConstraints(maxHeight: chipHeight);
@ -58,7 +61,8 @@ class _BucketTaskCardState extends State<BucketTaskCard> with AutomaticKeepAlive
children: <Widget>[
Text(
widget.task.identifier.isNotEmpty
? '#${widget.task.identifier}' : '${widget.task.id}',
? '#${widget.task.identifier}'
: '${widget.task.id}',
style: (theme.textTheme.subtitle2 ?? TextStyle()).copyWith(
color: Colors.grey,
),
@ -66,21 +70,25 @@ class _BucketTaskCardState extends State<BucketTaskCard> with AutomaticKeepAlive
],
);
if (widget.task.done) {
identifierRow.children.insert(0, Container(
constraints: chipConstraints,
padding: EdgeInsets.only(right: 4),
child: FittedBox(
child: Chip(
label: Text('Done'),
labelStyle: (theme.textTheme.labelLarge ?? TextStyle()).copyWith(
fontWeight: FontWeight.bold,
color: theme.brightness == Brightness.dark
? Colors.black : Colors.white,
identifierRow.children.insert(
0,
Container(
constraints: chipConstraints,
padding: EdgeInsets.only(right: 4),
child: FittedBox(
child: Chip(
label: Text('Done'),
labelStyle:
(theme.textTheme.labelLarge ?? TextStyle()).copyWith(
fontWeight: FontWeight.bold,
color: theme.brightness == Brightness.dark
? Colors.black
: Colors.white,
),
backgroundColor: vGreen,
),
),
backgroundColor: vGreen,
),
),
));
));
}
final titleRow = Row(
@ -88,9 +96,11 @@ class _BucketTaskCardState extends State<BucketTaskCard> with AutomaticKeepAlive
Expanded(
child: Text(
widget.task.title,
style: (theme.textTheme.titleMedium ?? TextStyle(fontSize: 16)).copyWith(
style: (theme.textTheme.titleMedium ?? TextStyle(fontSize: 16))
.copyWith(
color: theme.brightness == Brightness.dark
? Colors.white : Colors.black,
? Colors.white
: Colors.black,
),
),
),
@ -144,10 +154,10 @@ class _BucketTaskCardState extends State<BucketTaskCard> with AutomaticKeepAlive
backgroundColor: Colors.grey,
),
),
label: Text(
(checkboxStatistics.checked == checkboxStatistics.total ? '' : '${checkboxStatistics.checked} of ')
+ '${checkboxStatistics.total} tasks'
),
label: Text((checkboxStatistics.checked == checkboxStatistics.total
? ''
: '${checkboxStatistics.checked} of ') +
'${checkboxStatistics.total} tasks'),
));
}
if (widget.task.attachments.isNotEmpty) {
@ -184,7 +194,8 @@ class _BucketTaskCardState extends State<BucketTaskCard> with AutomaticKeepAlive
child: identifierRow,
),
Padding(
padding: EdgeInsets.only(top: 4, bottom: labelRow.children.isNotEmpty ? 8 : 0),
padding: EdgeInsets.only(
top: 4, bottom: labelRow.children.isNotEmpty ? 8 : 0),
child: Container(
constraints: rowConstraints,
child: titleRow,
@ -212,7 +223,9 @@ class _BucketTaskCardState extends State<BucketTaskCard> with AutomaticKeepAlive
return LongPressDraggable<TaskData>(
data: TaskData(widget.task, _cardSize),
maxSimultaneousDrags: taskState.taskDragging ? 0 : 1, // only one task can be dragged at a time
maxSimultaneousDrags: taskState.taskDragging
? 0
: 1, // only one task can be dragged at a time
onDragStarted: () {
taskState.taskDragging = true;
setState(() => _dragging = true);
@ -222,14 +235,16 @@ class _BucketTaskCardState extends State<BucketTaskCard> with AutomaticKeepAlive
taskState.taskDragging = false;
setState(() => _dragging = false);
},
feedback: (_cardSize == null) ? SizedBox.shrink() : SizedBox.fromSize(
size: _cardSize,
child: Card(
color: card.color,
child: (card.child as InkWell).child,
elevation: (card.elevation ?? 0) + 5,
),
),
feedback: (_cardSize == null)
? SizedBox.shrink()
: SizedBox.fromSize(
size: _cardSize,
child: Card(
color: card.color,
child: (card.child as InkWell).child,
elevation: (card.elevation ?? 0) + 5,
),
),
childWhenDragging: SizedBox.shrink(),
child: () {
if (_dragging || _cardSize == null) return card;
@ -240,16 +255,19 @@ class _BucketTaskCardState extends State<BucketTaskCard> with AutomaticKeepAlive
color: Colors.grey,
child: SizedBox.fromSize(size: dropBoxSize),
);
final dropAbove = taskState.taskDragging && _dropLocation == DropLocation.above;
final dropBelow = taskState.taskDragging && _dropLocation == DropLocation.below;
final DragTargetLeave<TaskData> dragTargetOnLeave = (data) => setState(() {
_dropLocation = DropLocation.none;
_dropData = null;
});
final dragTargetOnWillAccept = (TaskData data, DropLocation dropLocation) {
if (data.task.bucketId != bucket.id)
if (bucket.limit != 0 && bucket.tasks.length >= bucket.limit)
return false;
final dropAbove =
taskState.taskDragging && _dropLocation == DropLocation.above;
final dropBelow =
taskState.taskDragging && _dropLocation == DropLocation.below;
final DragTargetLeave<TaskData> dragTargetOnLeave =
(data) => setState(() {
_dropLocation = DropLocation.none;
_dropData = null;
});
final dragTargetOnWillAccept =
(TaskData data, DropLocation dropLocation) {
if (data.task.bucketId != bucket.id) if (bucket.limit != 0 &&
bucket.tasks.length >= bucket.limit) return false;
setState(() {
_dropLocation = dropLocation;
_dropData = data;
@ -258,7 +276,8 @@ class _BucketTaskCardState extends State<BucketTaskCard> with AutomaticKeepAlive
};
final DragTargetAccept<TaskData> dragTargetOnAccept = (data) {
final index = bucket.tasks.indexOf(widget.task);
widget.onAccept(data.task, _dropLocation == DropLocation.above ? index : index + 1);
widget.onAccept(data.task,
_dropLocation == DropLocation.above ? index : index + 1);
setState(() {
_dropLocation = DropLocation.none;
_dropData = null;
@ -267,7 +286,8 @@ class _BucketTaskCardState extends State<BucketTaskCard> with AutomaticKeepAlive
return SizedBox(
width: cardSize.width,
height: cardSize.height + (dropAbove || dropBelow ? dropBoxSize.height + 4 : 0),
height: cardSize.height +
(dropAbove || dropBelow ? dropBoxSize.height + 4 : 0),
child: Stack(
children: <Widget>[
Column(
@ -280,18 +300,22 @@ class _BucketTaskCardState extends State<BucketTaskCard> with AutomaticKeepAlive
Column(
children: <SizedBox>[
SizedBox(
height: (cardSize.height / 2) + (dropAbove ? dropBoxSize.height : 0),
height: (cardSize.height / 2) +
(dropAbove ? dropBoxSize.height : 0),
child: DragTarget<TaskData>(
onWillAccept: (data) => dragTargetOnWillAccept(data!, DropLocation.above),
onWillAccept: (data) =>
dragTargetOnWillAccept(data!, DropLocation.above),
onAccept: dragTargetOnAccept,
onLeave: dragTargetOnLeave,
builder: (_, __, ___) => SizedBox.expand(),
),
),
SizedBox(
height: (cardSize.height / 2) + (dropBelow ? dropBoxSize.height : 0),
height: (cardSize.height / 2) +
(dropBelow ? dropBoxSize.height : 0),
child: DragTarget<TaskData>(
onWillAccept: (data) => dragTargetOnWillAccept(data!, DropLocation.below),
onWillAccept: (data) =>
dragTargetOnWillAccept(data!, DropLocation.below),
onAccept: dragTargetOnAccept,
onLeave: dragTargetOnLeave,
builder: (_, __, ___) => SizedBox.expand(),
@ -308,12 +332,13 @@ class _BucketTaskCardState extends State<BucketTaskCard> with AutomaticKeepAlive
void _updateCardSize(BuildContext context) {
SchedulerBinding.instance.addPostFrameCallback((_) {
if (mounted) setState(() {
_cardSize = context.size;
});
if (mounted)
setState(() {
_cardSize = context.size;
});
});
}
@override
bool get wantKeepAlive => _dragging;
}
}

View File

@ -7,9 +7,9 @@ import 'package:provider/provider.dart';
import '../global.dart';
import '../models/bucket.dart';
import '../models/list.dart';
import '../pages/list/list.dart';
import '../stores/list_store.dart';
import '../models/project.dart';
import '../pages/project/project_task_list.dart';
import '../stores/project_store.dart';
import '../utils/calculate_item_position.dart';
import 'AddDialog.dart';
import 'BucketLimitDialog.dart';
@ -19,31 +19,31 @@ 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);
KanbanClass(this.context, this.notify, this._onViewTapped,
this._addItemDialog, this._list) {
taskState = Provider.of<ProjectProvider>(context);
}
Widget kanbanView() {
final deviceData = MediaQuery.of(context);
final portrait = deviceData.orientation == Orientation.portrait;
final bucketFraction = portrait ? 0.8 : 0.4;
final bucketWidth = deviceData.size.width * bucketFraction;
if (_pageController == null || _pageController!.viewportFraction != bucketFraction)
if (_pageController == null ||
_pageController!.viewportFraction != bucketFraction)
_pageController = PageController(viewportFraction: bucketFraction);
print(_list.doneBucketId);
return ReorderableListView.builder(
scrollDirection: Axis.horizontal,
@ -170,19 +170,26 @@ class KanbanClass {
));
}
Future<void> _addBucket(
String title, BuildContext context) async {
Future<void> _setDoneBucket(BuildContext context, int bucketId) async {
//setState(() {});
_list = (await VikunjaGlobal.of(context)
.projectService
.update(_list.copyWith(doneBucketId: bucketId)))!;
notify();
}
Future<void> _addBucket(String title, BuildContext context) async {
final currentUser = VikunjaGlobal.of(context).currentUser;
if (currentUser == null) {
return;
}
await Provider.of<ListProvider>(context, listen: false).addBucket(
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 +203,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 +218,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,
);
@ -250,14 +257,12 @@ class KanbanClass {
SchedulerBinding.instance.addPostFrameCallback((_) {
if (_bucketProps[bucket.id]!.controller.hasClients)
//setState(() {
_bucketProps[bucket.id]!.bucketLength = bucket.tasks.length;
_bucketProps[bucket.id]!.scrollable =
_bucketProps[bucket.id]!.controller.position.maxScrollExtent >
0;
_bucketProps[bucket.id]!.portrait = portrait;
//});
_bucketProps[bucket.id]!.bucketLength = bucket.tasks.length;
_bucketProps[bucket.id]!.scrollable =
_bucketProps[bucket.id]!.controller.position.maxScrollExtent > 0;
_bucketProps[bucket.id]!.portrait = portrait;
//});
notify();
});
if (_bucketProps[bucket.id]!.titleController.text.isEmpty)
_bucketProps[bucket.id]!.titleController.text = bucket.title;
@ -276,7 +281,7 @@ class KanbanClass {
minLeadingWidth: 15,
horizontalTitleGap: 4,
contentPadding: const EdgeInsets.only(left: 16, right: 10),
leading: bucket.isDoneBucket
leading: bucket.id == _list.doneBucketId
? Icon(
Icons.done_all,
color: Colors.green,
@ -346,8 +351,11 @@ class KanbanClass {
});
break;
case BucketMenu.done:
bucket.isDoneBucket = !bucket.isDoneBucket;
_updateBucket(context, bucket);
//bucket.isDoneBucket = !(bucket.id == _list.doneBucketId);
_list = _list.copyWith(doneBucketId: bucket.id);
_setDoneBucket(context, bucket.id);
notify();
//_updateBucket(context, bucket);
break;
case BucketMenu.delete:
_deleteBucket(context, bucket);
@ -369,7 +377,7 @@ class KanbanClass {
padding: const EdgeInsets.only(right: 4),
child: Icon(
Icons.done_all,
color: bucket.isDoneBucket
color: bucket.id == _list.doneBucketId
? Colors.green
: null,
),
@ -418,10 +426,11 @@ class KanbanClass {
final screenSize = MediaQuery.of(context).size;
const scrollDuration = Duration(milliseconds: 250);
const scrollCurve = Curves.easeInOut;
final updateAction = () { //setState(() =>
final updateAction = () {
//setState(() =>
_lastTaskDragUpdateAction = details.sourceTimeStamp!;
notify();
};//);
}; //);
if (details.globalPosition.dx < screenSize.width * 0.1) {
// scroll left
@ -493,13 +502,13 @@ class KanbanClass {
if (bucket.tasks.length == 0)
DragTarget<TaskData>(
onWillAccept: (data) {
/*setState(() =>*/ _bucketProps[bucket.id]!.taskDropSize =
data?.size;//);
/*setState(() =>*/ _bucketProps[bucket.id]!
.taskDropSize = data?.size; //);
notify();
return true;
},
onAccept: (data) {
Provider.of<ListProvider>(context, listen: false)
Provider.of<ProjectProvider>(context, listen: false)
.moveTaskToBucket(
context: context,
task: data.task,
@ -513,12 +522,12 @@ class KanbanClass {
)));
//setState(() =>
_bucketProps[bucket.id]!.taskDropSize = null;//);
_bucketProps[bucket.id]!.taskDropSize = null; //);
notify();
},
onLeave: (_) {
//setState(() =>
_bucketProps[bucket.id]!.taskDropSize = null;//)
_bucketProps[bucket.id]!.taskDropSize = null; //)
notify();
},
builder: (_, __, ___) => SizedBox.expand(),
@ -539,12 +548,7 @@ class KanbanClass {
}
Future<void> loadBucketsForPage(int page) {
return Provider.of<ListProvider>(context, listen: false).loadBuckets(
context: context,
listId: _list.id,
page: page
);
return Provider.of<ProjectProvider>(context, listen: false)
.loadBuckets(context: context, listId: _list.id, page: page);
}
}

View File

@ -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;
@ -19,21 +20,24 @@ class SliverBucketList extends StatelessWidget {
Widget build(BuildContext context) {
return SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
return index >= bucket.tasks.length ? null : BucketTaskCard(
key: ObjectKey(bucket.tasks[index]),
task: bucket.tasks[index],
index: index,
onDragUpdate: onTaskDragUpdate,
onAccept: (task, index) {
_moveTaskToBucket(context, task, index);
},
);
return index >= bucket.tasks.length
? null
: BucketTaskCard(
key: ObjectKey(bucket.tasks[index]),
task: bucket.tasks[index],
index: index,
onDragUpdate: onTaskDragUpdate,
onAccept: (task, index) {
_moveTaskToBucket(context, task, index);
},
);
}),
);
}
Future<void> _moveTaskToBucket(BuildContext context, Task task, int index) async {
await Provider.of<ListProvider>(context, listen: false).moveTaskToBucket(
Future<void> _moveTaskToBucket(
BuildContext context, Task task, int index) async {
await Provider.of<ProjectProvider>(context, listen: false).moveTaskToBucket(
context: context,
task: task,
newBucketId: bucket.id,
@ -41,7 +45,8 @@ class SliverBucketList extends StatelessWidget {
);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text('\'${task.title}\' was moved to \'${bucket.title}\' successfully!'),
content: Text(
'\'${task.title}\' was moved to \'${bucket.title}\' successfully!'),
));
}
}

View File

@ -16,12 +16,14 @@ class SliverBucketPersistentHeader extends StatelessWidget {
Widget build(BuildContext context) {
return SliverPersistentHeader(
pinned: true,
delegate: _SliverBucketPersistentHeaderDelegate(child, minExtent, maxExtent),
delegate:
_SliverBucketPersistentHeaderDelegate(child, minExtent, maxExtent),
);
}
}
class _SliverBucketPersistentHeaderDelegate extends SliverPersistentHeaderDelegate {
class _SliverBucketPersistentHeaderDelegate
extends SliverPersistentHeaderDelegate {
final Widget child;
final double min;
final double max;
@ -29,7 +31,8 @@ class _SliverBucketPersistentHeaderDelegate extends SliverPersistentHeaderDelega
_SliverBucketPersistentHeaderDelegate(this.child, this.min, this.max);
@override
Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) {
Widget build(
BuildContext context, double shrinkOffset, bool overlapsContent) {
return child;
}
@ -40,8 +43,10 @@ class _SliverBucketPersistentHeaderDelegate extends SliverPersistentHeaderDelega
double get minExtent => min;
@override
bool shouldRebuild(covariant _SliverBucketPersistentHeaderDelegate oldDelegate) {
return oldDelegate.child != child || oldDelegate.min != min || oldDelegate.max != max;
bool shouldRebuild(
covariant _SliverBucketPersistentHeaderDelegate oldDelegate) {
return oldDelegate.child != child ||
oldDelegate.min != min ||
oldDelegate.max != max;
}
}

View File

@ -0,0 +1,157 @@
import 'package:datetime_picker_formfield/datetime_picker_formfield.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_widget_from_html/flutter_widget_from_html.dart';
import 'package:provider/provider.dart';
import 'package:vikunja_app/utils/priority.dart';
import '../models/label.dart';
import '../models/task.dart';
import '../pages/project/task_edit.dart';
import '../stores/project_store.dart';
import '../theme/constants.dart';
import 'label.dart';
class TaskBottomSheet extends StatefulWidget {
final Task task;
final bool showInfo;
final bool loading;
final Function onEdit;
final ValueSetter<bool>? onMarkedAsDone;
final ProjectProvider taskState;
const TaskBottomSheet({
Key? key,
required this.task,
required this.onEdit,
required this.taskState,
this.loading = false,
this.showInfo = false,
this.onMarkedAsDone,
}) : super(key: key);
/*
@override
TaskTileState createState() {
return new TaskTileState(this.task, this.loading);
}
*/
@override
TaskBottomSheetState createState() => TaskBottomSheetState(this.task);
}
class TaskBottomSheetState extends State<TaskBottomSheet> {
Task _currentTask;
TaskBottomSheetState(this._currentTask);
@override
Widget build(BuildContext context) {
ThemeData theme = Theme.of(context);
return Container(
height: MediaQuery.of(context).size.height * 0.9,
child: Padding(
padding: EdgeInsets.fromLTRB(20, 10, 10, 20),
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
mainAxisSize: MainAxisSize.max,
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
// Title and edit button
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(_currentTask.title,
style: theme.textTheme.headlineLarge),
IconButton(
onPressed: () {
Navigator.push<Task>(
context,
MaterialPageRoute(
builder: (buildContext) => TaskEditPage(
task: _currentTask,
taskState: widget.taskState,
),
),
)
.then((task) => setState(() {
if (task != null) _currentTask = task;
}))
.whenComplete(() => widget.onEdit());
},
icon: Icon(Icons.edit)),
],
),
Wrap(
spacing: 10,
children: _currentTask.labels.map((Label label) {
return LabelComponent(
label: label,
);
}).toList()),
// description with html rendering
Text("Description", style: theme.textTheme.headlineSmall),
Padding(
padding: EdgeInsets.fromLTRB(10, 0, 0, 0),
child: HtmlWidget(_currentTask.description.isNotEmpty
? _currentTask.description
: "No description"),
),
// Due date
Row(
children: [
Icon(Icons.access_time),
Padding(padding: EdgeInsets.fromLTRB(10, 0, 0, 0)),
Text(_currentTask.dueDate != null
? vDateFormatShort.format(_currentTask.dueDate!.toLocal())
: "No due date"),
],
),
// start date
Row(
children: [
Icon(Icons.play_arrow_rounded),
Padding(padding: EdgeInsets.fromLTRB(10, 0, 0, 0)),
Text(_currentTask.startDate != null
? vDateFormatShort
.format(_currentTask.startDate!.toLocal())
: "No start date"),
],
),
// end date
Row(
children: [
Icon(Icons.stop_rounded),
Padding(padding: EdgeInsets.fromLTRB(10, 0, 0, 0)),
Text(_currentTask.endDate != null
? vDateFormatShort.format(_currentTask.endDate!.toLocal())
: "No end date"),
],
),
// priority
Row(
children: [
Icon(Icons.priority_high),
Padding(padding: EdgeInsets.fromLTRB(10, 0, 0, 0)),
Text(_currentTask.priority != null
? priorityToString(_currentTask.priority)
: "No priority"),
],
),
// progress
Row(
children: [
Icon(Icons.percent),
Padding(padding: EdgeInsets.fromLTRB(10, 0, 0, 0)),
Text(_currentTask.percent_done != null
? (_currentTask.percent_done! * 100).toInt().toString() +
"%"
: "Unset"),
],
),
],
),
));
}
}

View File

@ -1,11 +1,15 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_widget_from_html/flutter_widget_from_html.dart';
import 'package:provider/provider.dart';
import 'package:vikunja_app/components/TaskBottomSheet.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 'package:vikunja_app/pages/project/task_edit.dart';
import 'package:vikunja_app/utils/priority.dart';
import '../stores/project_store.dart';
class TaskTile extends StatefulWidget {
final Task task;
@ -29,10 +33,40 @@ class TaskTile extends StatefulWidget {
}
*/
@override
@override
TaskTileState createState() => TaskTileState(this.task);
}
Widget? _buildTaskSubtitle(Task? task, bool showInfo, BuildContext context) {
Duration? durationUntilDue = task?.dueDate?.difference(DateTime.now());
if (task == null) return null;
List<TextSpan> texts = [];
if (showInfo && task.hasDueDate) {
texts.add(TextSpan(
text: "Due " + durationToHumanReadable(durationUntilDue!),
style: durationUntilDue.isNegative
? TextStyle(color: Colors.red)
: Theme.of(context).textTheme.bodyMedium));
}
if (task.priority != null && task.priority != 0) {
texts.add(TextSpan(
text: " !" + priorityToString(task.priority),
style: TextStyle(color: Colors.orange)));
}
//if(texts.isEmpty && task.description.isNotEmpty) {
// return HtmlWidget(task.description);
// }
if (texts.isNotEmpty) {
return RichText(text: TextSpan(children: texts));
}
return null;
}
class TaskTileState extends State<TaskTile> with AutomaticKeepAliveClientMixin {
Task _currentTask;
@ -41,8 +75,7 @@ class TaskTileState extends State<TaskTile> with AutomaticKeepAliveClientMixin {
@override
Widget build(BuildContext context) {
super.build(context);
final taskState = Provider.of<ListProvider>(context);
Duration? durationUntilDue = _currentTask.dueDate?.difference(DateTime.now());
final taskState = Provider.of<ProjectProvider>(context);
if (_currentTask.loading) {
return ListTile(
leading: Padding(
@ -55,53 +88,76 @@ class TaskTileState extends State<TaskTile> with AutomaticKeepAliveClientMixin {
)),
),
title: Text(_currentTask.title),
subtitle:
_currentTask.description.isEmpty
? null
: Text(_currentTask.description),
subtitle: _currentTask.description.isEmpty
? null
: HtmlWidget(_currentTask.description),
trailing: IconButton(
icon: Icon(Icons.settings), onPressed: () { },
),
icon: Icon(Icons.edit),
onPressed: () {},
),
);
}
return CheckboxListTile(
title: widget.showInfo ?
RichText(
text: TextSpan(
text: null,
children: <TextSpan> [
// TODO: get list name of task
//TextSpan(text: widget.task.list.title+" - ", style: TextStyle(color: Colors.grey)),
TextSpan(text: widget.task.title),
],
style: TextStyle(
color: Theme.of(context).brightness == Brightness.dark ? Colors.white : Colors.black,
),
)
) : Text(_currentTask.title),
controlAffinity: ListTileControlAffinity.leading,
value: _currentTask.done,
subtitle: widget.showInfo && _currentTask.hasDueDate ?
Text("Due " + durationToHumanReadable(durationUntilDue!), style: durationUntilDue.isNegative ? TextStyle(color: Colors.red) : null,)
: _currentTask.description.isEmpty
? null
: Text(_currentTask.description),
secondary:
IconButton(icon: Icon(Icons.settings), onPressed: () {
Navigator.push<Task>(
context,
MaterialPageRoute(
builder: (buildContext) => TaskEditPage(
task: _currentTask,
taskState: taskState,
return IntrinsicHeight(
child: Row(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
Container(
width: 4.0, // Adjust the width of the red line
color: widget.task.color,
//margin: EdgeInsets.only(left: 10.0),
),
Flexible(
child: ListTile(
onTap: () {
showModalBottomSheet<void>(
context: context,
builder: (BuildContext context) {
return TaskBottomSheet(
task: widget.task,
onEdit: widget.onEdit,
taskState: taskState);
});
},
title: widget.showInfo
? RichText(
text: TextSpan(
text: null,
children: <TextSpan>[
// TODO: get list name of task
//TextSpan(text: widget.task.list.title+" - ", style: TextStyle(color: Colors.grey)),
TextSpan(text: widget.task.title),
],
style: TextStyle(
color: Theme.of(context).brightness == Brightness.dark
? Colors.white
: Colors.black,
),
),
).then((task) => setState(() {
if (task != null) _currentTask = task;
})).whenComplete(() => widget.onEdit());
}),
onChanged: _change,
);
))
: Text(_currentTask.title),
subtitle: _buildTaskSubtitle(widget.task, widget.showInfo, context),
leading: Checkbox(
value: _currentTask.done,
onChanged: (bool? newValue) {
_change(newValue);
},
),
trailing: IconButton(
icon: Icon(Icons.edit),
onPressed: () {
Navigator.push<Task>(
context,
MaterialPageRoute(
builder: (buildContext) => TaskEditPage(
task: _currentTask,
taskState: taskState,
),
),
)
.then((task) => setState(() {
if (task != null) _currentTask = task;
}))
.whenComplete(() => widget.onEdit());
}),
))
]));
}
void _change(bool? value) async {
@ -111,15 +167,14 @@ class TaskTileState extends State<TaskTile> with AutomaticKeepAliveClientMixin {
});
Task? newTask = await _updateTask(_currentTask, value);
setState(() {
if(newTask != null)
this._currentTask = newTask;
if (newTask != null) this._currentTask = newTask;
this._currentTask.loading = false;
});
widget.onEdit();
}
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,

View File

@ -0,0 +1 @@

View File

@ -40,8 +40,7 @@ class VikunjaDateTimePicker extends StatelessWidget {
onSaved: onSaved,
onChanged: onChanged,
onShowPicker: (context, currentValue) {
if(currentValue == null)
currentValue = DateTime.now();
if (currentValue == null) currentValue = DateTime.now();
return _showDatePickerFuture(context, currentValue);
},
);
@ -51,26 +50,22 @@ class VikunjaDateTimePicker extends StatelessWidget {
return showDialog(
context: context,
builder: (_) => DatePickerDialog(
initialDate: currentValue.year <= 1
? DateTime.now()
: currentValue,
firstDate: DateTime(1900),
lastDate: DateTime(2100),
initialCalendarMode: DatePickerMode.day,
)).then((date) {
if(date == null)
return null;
return showDialog(
initialDate:
currentValue.year <= 1 ? DateTime.now() : currentValue,
firstDate: DateTime(1900),
lastDate: DateTime(2100),
initialCalendarMode: DatePickerMode.day,
)).then((date) {
if (date == null) return null;
return showDialog(
context: context,
builder: (_) =>
TimePickerDialog(
builder: (_) => TimePickerDialog(
initialTime: TimeOfDay.fromDateTime(currentValue),
)
).then((time) {
if(time == null)
return null;
return DateTime(date.year,date.month, date.day,time.hour,time.minute);
});
)).then((time) {
if (time == null) return null;
return DateTime(
date.year, date.month, date.day, time.hour, time.minute);
});
});
}
}

View File

@ -3,9 +3,9 @@ import 'package:vikunja_app/models/label.dart';
class LabelComponent extends StatelessWidget {
final Label label;
final VoidCallback onDelete;
final VoidCallback? onDelete;
const LabelComponent({Key? key, required this.label, required this.onDelete})
const LabelComponent({Key? key, required this.label, this.onDelete})
: super(key: key);
@override

View File

@ -1 +1 @@
enum PageStatus { built, loading, success, error }
enum PageStatus { built, loading, success, error, empty }

View File

@ -1,5 +1,6 @@
import 'dart:developer' as dev;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:vikunja_app/api/bucket_implementation.dart';
@ -7,8 +8,6 @@ import 'package:vikunja_app/api/client.dart';
import 'package:vikunja_app/api/label_task.dart';
import 'package:vikunja_app/api/label_task_bulk.dart';
import 'package:vikunja_app/api/labels.dart';
import 'package:vikunja_app/api/list_implementation.dart';
import 'package:vikunja_app/api/namespace_implementation.dart';
import 'package:vikunja_app/api/server_implementation.dart';
import 'package:vikunja_app/api/task_implementation.dart';
import 'package:vikunja_app/api/user_implementation.dart';
@ -20,6 +19,8 @@ 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';
class VikunjaGlobal extends StatefulWidget {
final Widget child;
@ -32,7 +33,7 @@ class VikunjaGlobal extends StatefulWidget {
static VikunjaGlobalState of(BuildContext context) {
var widget =
context.dependOnInheritedWidgetOfExactType<_VikunjaGlobalInherited>();
context.dependOnInheritedWidgetOfExactType<VikunjaGlobalInherited>();
return widget!.data;
}
}
@ -47,13 +48,11 @@ class VikunjaGlobalState extends State<VikunjaGlobal> {
UserService? _newUserService;
NotificationClass _notificationClass = NotificationClass();
User? get currentUser => _currentUser;
Client get client => _client;
final GlobalKey<ScaffoldMessengerState> snackbarKey =
GlobalKey<ScaffoldMessengerState>();
GlobalKey<ScaffoldMessengerState> get snackbarKey => globalSnackbarKey;
UserManager get userManager => new UserManager(_storage);
@ -65,19 +64,16 @@ class VikunjaGlobalState extends State<VikunjaGlobal> {
VersionChecker get versionChecker => new VersionChecker(snackbarKey);
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);
ListService get listService => new ListAPIService(client, _storage);
TaskServiceOptions get taskServiceOptions => new TaskServiceOptions();
NotificationClass get notifications => _notificationClass;
LabelService get labelService => new LabelAPIService(client);
LabelTaskService get labelTaskService => new LabelTaskAPIService(client);
@ -85,18 +81,29 @@ class VikunjaGlobalState extends State<VikunjaGlobal> {
LabelTaskBulkAPIService get labelTaskBulkService =>
new LabelTaskBulkAPIService(client);
late String currentTimeZone;
void updateWorkmanagerDuration() {
if (kIsWeb) {
return;
}
Workmanager().cancelAll().then((value) {
settingsManager.getWorkmanagerDuration().then((duration) =>
{
if(duration.inMinutes > 0) {
Workmanager().registerPeriodicTask(
"update-tasks", "update-tasks", frequency: duration, constraints: Constraints(networkType: NetworkType.connected),
initialDelay: Duration(seconds: 15), inputData: {"client_token": client.token, "client_base": client.base})
settingsManager.getWorkmanagerDuration().then((duration) {
if (duration.inMinutes > 0) {
Workmanager().registerPeriodicTask("update-tasks", "update-tasks",
frequency: duration,
constraints: Constraints(networkType: NetworkType.connected),
initialDelay: Duration(seconds: 15),
inputData: {
"client_token": client.token,
"client_base": client.base
});
}
Workmanager().registerPeriodicTask("refresh-token", "refresh-token",
frequency: Duration(hours: 12),
constraints: Constraints(networkType: NetworkType.connected),
initialDelay: Duration(seconds: 15));
});
});
}
@ -105,13 +112,15 @@ class VikunjaGlobalState extends State<VikunjaGlobal> {
void initState() {
super.initState();
_client = Client(snackbarKey);
settingsManager.getIgnoreCertificates().then((value) => client.reload_ignore_certs(value == "1"));
settingsManager
.getIgnoreCertificates()
.then((value) => client.reloadIgnoreCerts(value == "1"));
_newUserService = UserAPIService(client);
_loadCurrentUser();
tz.initializeTimeZones();
notifications.notificationInitializer();
settingsManager.getVersionNotifications().then((value) {
if(value == "1") {
if (value == "1") {
versionChecker.postVersionCheckSnackbar();
}
});
@ -144,20 +153,18 @@ class VikunjaGlobalState extends State<VikunjaGlobal> {
});
}
void logoutUser(BuildContext context) async {
// _storage.deleteAll().then((_) {
var userId = await _storage.read(key: "currentUser");
_storage.delete(key: userId!); //delete token
_storage.delete(key: "${userId}_base");
Navigator.pop(context);
setState(() {
client.reset();
_currentUser = null;
});
/* }).catchError((err) {
var userId = await _storage.read(key: "currentUser");
await _storage.delete(key: userId!); //delete token
await _storage.delete(key: "${userId}_base");
setState(() {
client.reset();
_currentUser = null;
});
/* }).catchError((err) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text('An error occured while logging out!'),
content: Text('An error occurred while logging out!'),
));
});*/
}
@ -182,8 +189,14 @@ class VikunjaGlobalState extends State<VikunjaGlobal> {
User loadedCurrentUser;
try {
loadedCurrentUser = await UserAPIService(client).getCurrentUser();
// load new token from server to avoid expiration
String? newToken = await newUserService?.getToken();
if (newToken != null) {
_storage.write(key: currentUser, value: newToken);
client.configure(token: newToken);
}
} on ApiException catch (e) {
dev.log("Error code: " + e.errorCode.toString(),level: 1000);
dev.log("Error code: " + e.errorCode.toString(), level: 1000);
if (e.errorCode ~/ 100 == 4) {
client.authenticated = false;
if (e.errorCode == 401) {
@ -213,10 +226,10 @@ class VikunjaGlobalState extends State<VikunjaGlobal> {
if (_loading) {
return new Center(child: new CircularProgressIndicator());
}
if(client.authenticated) {
if (client.authenticated) {
notifications.scheduleDueNotifications(taskService);
}
return new _VikunjaGlobalInherited(
return new VikunjaGlobalInherited(
data: this,
key: UniqueKey(),
child: !client.authenticated ? widget.login : widget.child,
@ -224,14 +237,14 @@ class VikunjaGlobalState extends State<VikunjaGlobal> {
}
}
class _VikunjaGlobalInherited extends InheritedWidget {
class VikunjaGlobalInherited extends InheritedWidget {
final VikunjaGlobalState data;
_VikunjaGlobalInherited({Key? key, required this.data, required Widget child})
VikunjaGlobalInherited({Key? key, required this.data, required Widget child})
: super(key: key, child: child);
@override
bool updateShouldNotify(_VikunjaGlobalInherited oldWidget) {
bool updateShouldNotify(VikunjaGlobalInherited oldWidget) {
return (data.currentUser != null &&
data.currentUser!.id != oldWidget.data.currentUser!.id) ||
data.client != oldWidget.data.client;

View File

@ -1,5 +1,7 @@
import 'dart:io';
import 'package:dynamic_color/dynamic_color.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:permission_handler/permission_handler.dart';
@ -12,7 +14,9 @@ import 'package:vikunja_app/pages/home.dart';
import 'package:vikunja_app/pages/user/login.dart';
import 'package:vikunja_app/theme/theme.dart';
import 'package:timezone/data/latest_all.dart' as tz;
import 'package:flutter_downloader/flutter_downloader.dart';
import 'api/user_implementation.dart';
import 'managers/notifications.dart';
class IgnoreCertHttpOverrides extends HttpOverrides {
@ -31,8 +35,12 @@ class IgnoreCertHttpOverrides extends HttpOverrides {
@pragma('vm:entry-point')
void callbackDispatcher() {
Workmanager().executeTask((task, inputData) {
print("Native called background task: $task"); //simpleTask will be emitted here.
if (kIsWeb) {
return;
}
Workmanager().executeTask((task, inputData) async {
print(
"Native called background task: $task"); //simpleTask will be emitted here.
if (task == "update-tasks" && inputData != null) {
Client client = Client(null,
token: inputData["client_token"],
@ -44,7 +52,7 @@ void callbackDispatcher() {
.getIgnoreCertificates()
.then((value) async {
print("ignoring: $value");
client.reload_ignore_certs(value == "1");
client.reloadIgnoreCerts(value == "1");
TaskAPIService taskService = TaskAPIService(client);
NotificationClass nc = NotificationClass();
@ -53,12 +61,37 @@ void callbackDispatcher() {
.scheduleDueNotifications(taskService)
.then((value) => Future.value(true));
});
} else if (task == "refresh-token") {
print("running refresh from workmanager");
final FlutterSecureStorage _storage = new FlutterSecureStorage();
var currentUser = await _storage.read(key: 'currentUser');
if (currentUser == null) {
return Future.value(true);
}
var token = await _storage.read(key: currentUser);
var base = await _storage.read(key: '${currentUser}_base');
if (token == null || base == null) {
return Future.value(true);
}
Client client = Client(null);
client.configure(token: token, base: base, authenticated: true);
// load new token from server to avoid expiration
String? newToken = await UserAPIService(client).getToken();
if (newToken != null) {
_storage.write(key: currentUser, value: newToken);
}
return Future.value(true);
} else {
return Future.value(true);
}
});
}
final globalSnackbarKey = GlobalKey<ScaffoldMessengerState>();
final globalNavigatorKey = GlobalKey<NavigatorState>();
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await Permission.notification.isDenied.then((value) {
@ -66,32 +99,91 @@ void main() async {
Permission.notification.request();
}
});
Workmanager().initialize(callbackDispatcher, isInDebugMode: false);
try {
if (!kIsWeb) {
await FlutterDownloader.initialize();
}
} catch (e) {
print("Failed to initialize downloader: $e");
}
try {
if (!kIsWeb) {
Workmanager().initialize(callbackDispatcher, isInDebugMode: false);
}
} catch (e) {
print("Failed to initialize workmanager: $e");
}
runApp(VikunjaGlobal(
child: new VikunjaApp(
home: HomePage(),
key: UniqueKey(),
),
login: new VikunjaApp(
home: LoginPage(),
key: UniqueKey(),
)));
child: new VikunjaApp(
home: HomePage(),
key: UniqueKey(),
navkey: globalNavigatorKey,
),
login: new VikunjaApp(
home: LoginPage(),
key: UniqueKey(),
),
));
}
final ValueNotifier<bool> updateTheme = ValueNotifier(false);
class VikunjaApp extends StatelessWidget {
final Widget home;
final GlobalKey<NavigatorState>? navkey;
const VikunjaApp({Key? key, required this.home}) : super(key: key);
const VikunjaApp({Key? key, required this.home, this.navkey})
: super(key: key);
Future<ThemeData> getThemedata() async {
FlutterThemeMode themeMode = FlutterThemeMode.light;
try {
SettingsManager manager = SettingsManager(new FlutterSecureStorage());
themeMode = await manager.getThemeMode();
} catch (e) {
print("Failed to get theme mode: $e");
}
switch (themeMode) {
case FlutterThemeMode.dark:
return buildVikunjaDarkTheme();
case FlutterThemeMode.materialYouLight:
return buildVikunjaMaterialLightTheme();
case FlutterThemeMode.materialYouDark:
return buildVikunjaMaterialDarkTheme();
default:
return buildVikunjaTheme();
}
}
@override
Widget build(BuildContext context) {
return new MaterialApp(
title: 'Vikunja',
theme: buildVikunjaTheme(),
darkTheme: buildVikunjaDarkTheme(),
scaffoldMessengerKey: VikunjaGlobal.of(context).snackbarKey,
// <= this
home: this.home,
);
return new ValueListenableBuilder(
valueListenable: updateTheme,
builder: (_, mode, __) {
return FutureBuilder<ThemeData>(
future: getThemedata(),
builder: (BuildContext context, AsyncSnapshot<ThemeData> data) {
if (data.hasData) {
return new DynamicColorBuilder(
builder: (lightTheme, darkTheme) {
ThemeData? themeData = data.data;
if (data.data == FlutterThemeMode.materialYouLight)
themeData = themeData?.copyWith(colorScheme: lightTheme);
else if (data.data == FlutterThemeMode.materialYouDark)
themeData = themeData?.copyWith(colorScheme: darkTheme);
return MaterialApp(
title: 'Vikunja',
theme: themeData,
scaffoldMessengerKey: globalSnackbarKey,
navigatorKey: navkey,
// <= this
home: this.home,
);
});
} else {
return Center(child: CircularProgressIndicator());
}
});
});
}
}

View File

@ -5,10 +5,13 @@ import 'dart:math';
import 'package:flutter_timezone/flutter_timezone.dart';
import 'package:timezone/timezone.dart' as tz;
import 'package:flutter_local_notifications/flutter_local_notifications.dart'as notifs;
import 'package:flutter_local_notifications/flutter_local_notifications.dart'
as notifs;
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;
@ -17,62 +20,58 @@ class NotificationClass {
late String currentTimeZone;
notifs.NotificationAppLaunchDetails? notifLaunch;
notifs.FlutterLocalNotificationsPlugin get notificationsPlugin => new notifs.FlutterLocalNotificationsPlugin();
notifs.FlutterLocalNotificationsPlugin get notificationsPlugin =>
new notifs.FlutterLocalNotificationsPlugin();
var androidSpecificsDueDate = notifs.AndroidNotificationDetails(
"Vikunja1",
"Due Date Notifications",
"Vikunja1", "Due Date Notifications",
channelDescription: "description",
icon: 'vikunja_notification_logo',
importance: notifs.Importance.high
);
importance: notifs.Importance.high);
var androidSpecificsReminders = notifs.AndroidNotificationDetails(
"Vikunja2",
"Reminder Notifications",
"Vikunja2", "Reminder Notifications",
channelDescription: "description",
icon: 'vikunja_notification_logo',
importance: notifs.Importance.high
);
late notifs.IOSNotificationDetails iOSSpecifics;
importance: notifs.Importance.high);
late notifs.DarwinNotificationDetails iOSSpecifics;
late notifs.NotificationDetails platformChannelSpecificsDueDate;
late notifs.NotificationDetails platformChannelSpecificsReminders;
NotificationClass({this.id, this.body, this.payload, this.title});
final rxSub.BehaviorSubject<
NotificationClass> didReceiveLocalNotificationSubject =
rxSub.BehaviorSubject<NotificationClass>();
final rxSub.BehaviorSubject<NotificationClass>
didReceiveLocalNotificationSubject =
rxSub.BehaviorSubject<NotificationClass>();
final rxSub.BehaviorSubject<String> selectNotificationSubject =
rxSub.BehaviorSubject<String>();
rxSub.BehaviorSubject<String>();
Future<void> _initNotifications() async {
var initializationSettingsAndroid =
notifs.AndroidInitializationSettings('vikunja_logo');
var initializationSettingsIOS = notifs.IOSInitializationSettings(
notifs.AndroidInitializationSettings('vikunja_logo');
var initializationSettingsIOS = notifs.DarwinInitializationSettings(
requestAlertPermission: false,
requestBadgePermission: false,
requestSoundPermission: false,
onDidReceiveLocalNotification:
(int? id, String? title, String? body, String? payload) async {
didReceiveLocalNotificationSubject
.add(NotificationClass(
didReceiveLocalNotificationSubject.add(NotificationClass(
id: id, title: title, body: body, payload: payload));
});
var initializationSettings = notifs.InitializationSettings(
android: initializationSettingsAndroid, iOS: initializationSettingsIOS);
await notificationsPlugin.initialize(initializationSettings,
onSelectNotification: (String? payload) async {
if (payload != null) {
print('notification payload: ' + payload);
selectNotificationSubject.add(payload);
}
});
onDidReceiveNotificationResponse:
(notifs.NotificationResponse resp) async {
if (payload != null) {
print('notification payload: ' + resp.payload!);
selectNotificationSubject.add(resp.payload!);
}
});
print("Notifications initialised successfully");
}
Future<void> notificationInitializer() async {
iOSSpecifics = notifs.IOSNotificationDetails();
iOSSpecifics = notifs.DarwinNotificationDetails();
platformChannelSpecificsDueDate = notifs.NotificationDetails(
android: androidSpecificsDueDate, iOS: iOSSpecifics);
platformChannelSpecificsReminders = notifs.NotificationDetails(
@ -84,20 +83,23 @@ class NotificationClass {
return Future.value();
}
Future<void> scheduleNotification(String title, String description,
Future<void> scheduleNotification(
String title,
String description,
notifs.FlutterLocalNotificationsPlugin notifsPlugin,
DateTime scheduledTime, String currentTimeZone,
notifs.NotificationDetails platformChannelSpecifics, {int? id}) async {
if (id == null)
id = Random().nextInt(1000000);
DateTime scheduledTime,
String currentTimeZone,
notifs.NotificationDetails platformChannelSpecifics,
{int? id}) async {
if (id == null) id = Random().nextInt(1000000);
// TODO: move to setup
tz.TZDateTime time = tz.TZDateTime.from(
scheduledTime, tz.getLocation(currentTimeZone));
tz.TZDateTime time =
tz.TZDateTime.from(scheduledTime, tz.getLocation(currentTimeZone));
if (time.difference(tz.TZDateTime.now(tz.getLocation(currentTimeZone))) <
Duration.zero)
return;
await notifsPlugin.zonedSchedule(id, title, description,
time, platformChannelSpecifics, androidAllowWhileIdle: true,
Duration.zero) return;
await notifsPlugin.zonedSchedule(
id, title, description, time, platformChannelSpecifics,
androidAllowWhileIdle: true,
uiLocalNotificationDateInterpretation: notifs
.UILocalNotificationDateInterpretation
.wallClockTime); // This literally schedules the notification
@ -108,26 +110,27 @@ class NotificationClass {
"This is a test notification", platformChannelSpecificsReminders);
}
void requestIOSPermissions() {
notificationsPlugin.resolvePlatformSpecificImplementation<
notifs.IOSFlutterLocalNotificationsPlugin>()
notificationsPlugin
.resolvePlatformSpecificImplementation<
notifs.IOSFlutterLocalNotificationsPlugin>()
?.requestPermissions(
alert: true,
badge: true,
sound: true,
);
alert: true,
badge: true,
sound: true,
);
}
Future<void> scheduleDueNotifications(TaskService taskService) async {
final tasks = await taskService.getAll();
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",
@ -153,5 +156,4 @@ class NotificationClass {
}
print("notifications scheduled successfully");
}
}
}

View File

@ -5,17 +5,17 @@ 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;
User createdBy;
bool isDoneBucket;
bool? isDoneBucket;
final List<Task> tasks;
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()
@ -47,15 +47,15 @@ class Bucket {
.toList();
toJSON() => {
'id': id,
'list_id': listId,
'title': title,
'position': position,
'limit': limit,
'is_done_bucket': isDoneBucket,
'created': created.toUtc().toIso8601String(),
'updated': updated.toUtc().toIso8601String(),
'created_by': createdBy.toJSON(),
'tasks': tasks.map((task) => task.toJSON()).toList(),
};
}
'id': id,
'list_id': projectId,
'title': title,
'position': position,
'limit': limit,
'is_done_bucket': isDoneBucket,
'created': created.toUtc().toIso8601String(),
'updated': updated.toUtc().toIso8601String(),
'created_by': createdBy.toJSON(),
'tasks': tasks.map((task) => task.toJSON()).toList(),
};
}

View File

@ -10,7 +10,9 @@ class Label {
final User createdBy;
final Color? color;
late final Color textColor = color != null && color!.computeLuminance() <= 0.5 ? vLabelLight : vLabelDark;
late final Color textColor = color != null && color!.computeLuminance() <= 0.5
? vLabelLight
: vLabelDark;
Label({
this.id = 0,

View File

@ -10,7 +10,8 @@ class LabelTask {
LabelTask({required this.label, required this.task});
LabelTask.fromJson(Map<String, dynamic> json, User createdBy)
: label = new Label(id: json['label_id'], title: '', createdBy: createdBy),
: label =
new Label(id: json['label_id'], title: '', createdBy: createdBy),
task = null;
toJSON() => {

View File

@ -1,51 +0,0 @@
import 'package:vikunja_app/models/task.dart';
import 'package:vikunja_app/models/user.dart';
class TaskList {
final int id;
int namespaceId;
String title, description;
final User owner;
final DateTime created, updated;
final List<Task> tasks;
final bool isFavorite;
TaskList({
this.id = 0,
required this.title,
required this.namespaceId,
this.description = '',
required this.owner,
DateTime? created,
DateTime? updated,
List<Task>? tasks,
this.isFavorite = false,
}) : this.created = created ?? DateTime.now(),
this.updated = updated ?? DateTime.now(),
this.tasks = tasks ?? [];
TaskList.fromJson(Map<String, dynamic> json)
: id = json['id'],
owner = User.fromJson(json['owner']),
description = json['description'],
title = json['title'],
updated = DateTime.parse(json['updated']),
created = DateTime.parse(json['created']),
isFavorite = json['is_favorite'],
namespaceId = json['namespace_id'],
tasks = json['tasks'] == null ? [] : (json['tasks'] as List<dynamic>)
.map((taskJson) => Task.fromJson(taskJson))
.toList();
toJSON() {
return {
'id': id,
'title': title,
'description': description,
'owner': owner.toJSON(),
'created': created.toUtc().toIso8601String(),
'updated': updated.toUtc().toIso8601String(),
'namespace_id': namespaceId
};
}
}

View File

@ -1,53 +0,0 @@
import 'package:vikunja_app/models/user.dart';
class Namespace {
final int id;
final DateTime created, updated;
final String title, description;
final User? owner;
Namespace({
this.id = 0,
DateTime? created,
DateTime? updated,
required this.title,
this.description = '',
required this.owner,
}) : this.created = created ?? DateTime.now(),
this.updated = updated ?? DateTime.now();
Namespace.fromJson(Map<String, dynamic> json)
: title = json['title'],
description = json['description'],
id = json['id'],
created = DateTime.parse(json['created']),
updated = DateTime.parse(json['updated']),
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
};
Namespace copyWith({
int? id,
DateTime? created,
DateTime? updated,
String? title,
String? description,
User? owner,
}) {
return Namespace(
id: id ?? this.id,
created: created ?? this.created,
updated: updated ?? this.updated,
title: title ?? this.title,
description: description ?? this.description,
owner: owner ?? this.owner,
);
}
}

96
lib/models/project.dart Normal file
View File

@ -0,0 +1,96 @@
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;
final int? doneBucketId;
Iterable<Project>? subprojects;
Project(
{this.id = 0,
this.owner,
this.parentProjectId = 0,
this.description = '',
this.position = 0,
this.doneBucketId,
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'],
doneBucketId = json['done_bucket_id'],
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,
'done_bucket_id': doneBucketId,
'position': position
};
Project copyWith({
int? id,
DateTime? created,
DateTime? updated,
String? title,
User? owner,
String? description,
int? parentProjectId,
Color? color,
bool? isArchived,
bool? isFavourite,
int? doneBucketId,
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,
doneBucketId: doneBucketId ?? this.doneBucketId,
color: color ?? this.color,
isArchived: isArchived ?? this.isArchived,
isFavourite: isFavourite ?? this.isFavourite,
position: position ?? this.position,
);
}
}

View File

@ -13,8 +13,7 @@ class Server {
String? version;
Server.fromJson(Map<String, dynamic> json)
:
caldavEnabled = json['caldav_enabled'],
: caldavEnabled = json['caldav_enabled'],
emailRemindersEnabled = json['email_reminders_enabled'],
frontendUrl = json['frontend_url'],
linkSharingEnabled = json['link_sharing_enabled'],
@ -26,4 +25,4 @@ class Server {
totpEnabled = json['totp_enabled'],
userDeletion = json['user_deletion'],
version = json['version'];
}
}

View File

@ -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;
@ -19,6 +20,7 @@ class Task {
final bool done;
Color? color;
final double? kanbanPosition;
final double? percent_done;
final User createdBy;
Duration? repeatAfter;
final List<Task> subtasks;
@ -44,13 +46,15 @@ class Task {
this.repeatAfter,
this.color,
this.kanbanPosition,
this.percent_done,
this.subtasks = const [],
this.labels = const [],
this.attachments = const [],
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();
@ -63,6 +67,7 @@ class Task {
}
return Colors.white;
}
bool get hasDueDate => dueDate?.year != 1;
Task.fromJson(Map<String, dynamic> json)
@ -88,6 +93,9 @@ class Task {
kanbanPosition = json['kanban_position'] is int
? json['kanban_position'].toDouble()
: json['kanban_position'],
percent_done = json['percent_done'] is int
? json['percent_done'].toDouble()
: json['percent_done'],
labels = json['labels'] != null
? (json['labels'] as List<dynamic>)
.map((label) => Label.fromJson(label))
@ -105,7 +113,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']);
@ -123,8 +132,11 @@ class Task {
'end_date': endDate?.toUtc().toIso8601String(),
'priority': priority,
'repeat_after': repeatAfter?.inSeconds,
'hex_color': color?.value.toRadixString(16).padLeft(8, '0').substring(2),
'hex_color':
color?.value.toRadixString(16).padLeft(8, '0').substring(2),
'kanban_position': kanbanPosition,
'percent_done': percent_done,
'project_id': projectId,
'labels': labels.map((label) => label.toJSON()).toList(),
'subtasks': subtasks.map((subtask) => subtask.toJSON()).toList(),
'attachments':
@ -153,6 +165,7 @@ class Task {
bool? done,
Color? color,
double? kanbanPosition,
double? percent_done,
User? createdBy,
Duration? repeatAfter,
List<Task>? subtasks,
@ -163,7 +176,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,
@ -177,6 +191,7 @@ class Task {
done: done ?? this.done,
color: color ?? this.color,
kanbanPosition: kanbanPosition ?? this.kanbanPosition,
percent_done: percent_done ?? this.percent_done,
createdBy: createdBy ?? this.createdBy,
repeatAfter: repeatAfter ?? this.repeatAfter,
subtasks: subtasks ?? this.subtasks,

View File

@ -2,11 +2,43 @@ import 'package:json_annotation/json_annotation.dart';
import 'package:vikunja_app/models/user.dart';
class TaskAttachmentFile {
final int id;
final DateTime created;
final String mime;
final String name;
final int size;
TaskAttachmentFile({
required this.id,
required this.created,
required this.mime,
required this.name,
required this.size,
});
TaskAttachmentFile.fromJSON(Map<String, dynamic> json)
: id = json['id'],
created = DateTime.parse(json['created']),
mime = json['mime'],
name = json['name'],
size = json['size'];
toJSON() => {
'id': id,
'created': created.toUtc().toIso8601String(),
'mime': mime,
'name': name,
'size': size,
};
}
@JsonSerializable()
class TaskAttachment {
final int id, taskId;
final DateTime created;
final User createdBy;
final TaskAttachmentFile file;
// TODO: add file
TaskAttachment({
@ -14,18 +46,21 @@ class TaskAttachment {
required this.taskId,
DateTime? created,
required this.createdBy,
required this.file,
}) : this.created = created ?? DateTime.now();
TaskAttachment.fromJSON(Map<String, dynamic> json)
: id = json['id'],
taskId = json['task_id'],
created = DateTime.parse(json['created']),
file = TaskAttachmentFile.fromJSON(json['file']),
createdBy = User.fromJson(json['created_by']);
toJSON() => {
'id': id,
'task_id': taskId,
'created': created.toUtc().toIso8601String(),
'created_by': createdBy.toJSON(),
};
}
'id': id,
'task_id': taskId,
'created': created.toUtc().toIso8601String(),
'created_by': createdBy.toJSON(),
'file': file.toJSON(),
};
}

View File

@ -1,10 +1,99 @@
import 'package:flutter/cupertino.dart';
import 'package:vikunja_app/global.dart';
class UserSettings {
final int default_project_id;
final bool discoverable_by_email,
discoverable_by_name,
email_reminders_enabled;
final Map<String, dynamic>? frontend_settings;
final String language;
final String name;
final bool overdue_tasks_reminders_enabled;
final String overdue_tasks_reminders_time;
final String timezone;
final int week_start;
UserSettings({
this.default_project_id = 0,
this.discoverable_by_email = false,
this.discoverable_by_name = false,
this.email_reminders_enabled = false,
this.frontend_settings = null,
this.language = '',
this.name = '',
this.overdue_tasks_reminders_enabled = false,
this.overdue_tasks_reminders_time = '',
this.timezone = '',
this.week_start = 0,
});
UserSettings.fromJson(Map<String, dynamic> json)
: default_project_id = json['default_project_id'],
discoverable_by_email = json['discoverable_by_email'],
discoverable_by_name = json['discoverable_by_name'],
email_reminders_enabled = json['email_reminders_enabled'],
frontend_settings = json['frontend_settings'],
language = json['language'],
name = json['name'],
overdue_tasks_reminders_enabled =
json['overdue_tasks_reminders_enabled'],
overdue_tasks_reminders_time = json['overdue_tasks_reminders_time'],
timezone = json['timezone'],
week_start = json['week_start'];
toJson() => {
'default_project_id': default_project_id,
'discoverable_by_email': discoverable_by_email,
'discoverable_by_name': discoverable_by_name,
'email_reminders_enabled': email_reminders_enabled,
'frontend_settings': frontend_settings,
'language': language,
'name': name,
'overdue_tasks_reminders_enabled': overdue_tasks_reminders_enabled,
'overdue_tasks_reminders_time': overdue_tasks_reminders_time,
'timezone': timezone,
'week_start': week_start,
};
UserSettings copyWith({
int? default_project_id,
bool? discoverable_by_email,
bool? discoverable_by_name,
bool? email_reminders_enabled,
Map<String, dynamic>? frontend_settings,
String? language,
String? name,
bool? overdue_tasks_reminders_enabled,
String? overdue_tasks_reminders_time,
String? timezone,
int? week_start,
}) {
return UserSettings(
default_project_id: default_project_id ?? this.default_project_id,
discoverable_by_email:
discoverable_by_email ?? this.discoverable_by_email,
discoverable_by_name: discoverable_by_name ?? this.discoverable_by_name,
email_reminders_enabled:
email_reminders_enabled ?? this.email_reminders_enabled,
frontend_settings: frontend_settings ?? this.frontend_settings,
language: language ?? this.language,
name: name ?? this.name,
overdue_tasks_reminders_enabled: overdue_tasks_reminders_enabled ??
this.overdue_tasks_reminders_enabled,
overdue_tasks_reminders_time:
overdue_tasks_reminders_time ?? this.overdue_tasks_reminders_time,
timezone: timezone ?? this.timezone,
week_start: week_start ?? this.week_start,
);
}
}
class User {
final int id;
final String name, username;
final DateTime created, updated;
UserSettings? settings;
User({
this.id = 0,
@ -12,6 +101,7 @@ class User {
required this.username,
DateTime? created,
DateTime? updated,
this.settings,
}) : this.created = created ?? DateTime.now(),
this.updated = updated ?? DateTime.now();
@ -20,7 +110,12 @@ class User {
name = json.containsKey('name') ? json['name'] : '',
username = json['username'],
created = DateTime.parse(json['created']),
updated = DateTime.parse(json['updated']);
updated = DateTime.parse(json['updated']) {
if (json.containsKey('settings')) {
this.settings = UserSettings.fromJson(json['settings']);
}
;
}
toJSON() => {
'id': id,
@ -28,6 +123,7 @@ class User {
'username': username,
'created': created.toUtc().toIso8601String(),
'updated': updated.toUtc().toIso8601String(),
'user_settings': settings?.toJson(),
};
String avatarUrl(BuildContext context) {

View File

@ -2,88 +2,38 @@ import 'dart:async';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:after_layout/after_layout.dart';
import 'package:provider/provider.dart';
import 'package:vikunja_app/components/AddDialog.dart';
import 'package:vikunja_app/components/ErrorDialog.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/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
State<StatefulWidget> createState() => HomePageState();
}
class HomePageState extends State<HomePage> with AfterLayoutMixin<HomePage> {
List<Namespace> _namespaces = [];
Namespace? get _currentNamespace =>
_selectedDrawerIndex >= 0 && _selectedDrawerIndex < _namespaces.length
? _namespaces[_selectedDrawerIndex]
: null;
int _selectedDrawerIndex = -1, _previousDrawerIndex = -1;
bool _loading = true;
bool _showUserDetails = false;
class HomePageState extends State<HomePage> {
int _selectedDrawerIndex = 0, _previousDrawerIndex = 0;
Widget? drawerItem;
@override
void afterFirstLayout(BuildContext context) {
_loadNamespaces();
}
List<Widget> widgets = [
ChangeNotifierProvider<ProjectProvider>(
create: (_) => new ProjectProvider(),
child: LandingPage(),
),
ProjectOverviewPage(),
SettingsPage()
];
Widget _namespacesWidget() {
List<Widget> namespacesList = <Widget>[];
_namespaces
.asMap()
.forEach((i, namespace) => namespacesList.add(new ListTile(
leading: const Icon(Icons.folder),
title: new Text(namespace.title),
selected: i == _selectedDrawerIndex,
onTap: () => _onSelectItem(i),
)));
return this._loading
? Center(child: CircularProgressIndicator())
: RefreshIndicator(
child: ListView(
padding: EdgeInsets.zero,
children: ListTile.divideTiles(
context: context, tiles: namespacesList)
.toList()),
onRefresh: _loadNamespaces,
);
}
Widget _userDetailsWidget(BuildContext context) {
return ListView(padding: EdgeInsets.zero, children: <Widget>[
ListTile(
title: Text('Logout'),
leading: Icon(Icons.exit_to_app),
onTap: () {
VikunjaGlobal.of(context).logoutUser(context);
},
),
ListTile(
title: Text('Settings'),
leading: Icon(Icons.settings),
onTap: () {
Navigator.push(context,
MaterialPageRoute(builder: (context) => SettingsPage()))
.whenComplete(() => setState(() {
//returning from settings, this needs to be force-refreshed
drawerItem = _getDrawerItemWidget(_selectedDrawerIndex,
forceReload: true);
}));
},
)
]);
}
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
Widget build(BuildContext context) {
@ -92,135 +42,21 @@ class HomePageState extends State<HomePage> with AfterLayoutMixin<HomePage> {
drawerItem = _getDrawerItemWidget(_selectedDrawerIndex);
return new Scaffold(
appBar: AppBar(
title: new Text(_currentNamespace?.title ?? 'Vikunja'),
actions: _currentNamespace == null
? null
: <Widget>[
IconButton(
icon: Icon(Icons.edit),
onPressed: () => Navigator.push(
context,
MaterialPageRoute(
builder: (context) => NamespaceEditPage(
namespace: _currentNamespace!,
))).whenComplete(() => _loadNamespaces()))
],
bottomNavigationBar: NavigationBar(
destinations: navbarItems,
selectedIndex: _selectedDrawerIndex,
onDestinationSelected: (index) {
setState(() {
_selectedDrawerIndex = index;
});
},
),
drawer: Drawer(
child: Column(children: <Widget>[
UserAccountsDrawerHeader(
accountName: currentUser != null
? Text(currentUser.username)
: null,
accountEmail: currentUser != null
? Text(currentUser.name)
: null,
onDetailsPressed: () {
setState(() {
_showUserDetails = !_showUserDetails;
});
},
currentAccountPicture: currentUser == null
? null
: CircleAvatar(
//backgroundImage: NetworkImage(currentUser.avatarUrl(context)),
),
decoration: BoxDecoration(
image: DecorationImage(
image: AssetImage("assets/graphics/hypnotize.png"),
repeat: ImageRepeat.repeat,
colorFilter: ColorFilter.mode(
Theme.of(context).primaryColor, BlendMode.multiply)),
),
),
Builder(
builder: (BuildContext context) => Expanded(
child: _showUserDetails
? _userDetailsWidget(context)
: _namespacesWidget())),
Align(
alignment: FractionalOffset.bottomLeft,
child: Builder(
builder: (context) => ListTile(
leading: Icon(Icons.house),
onTap: () {
Navigator.of(context).pop();
setState(() => _selectedDrawerIndex = -1);
},
),
),
),
Align(
alignment: FractionalOffset.bottomCenter,
child: Builder(
builder: (context) => ListTile(
leading: const Icon(Icons.add),
title: const Text('Add namespace...'),
onTap: () => _addNamespaceDialog(context),
),
),
),
])),
body: drawerItem,
);
}
_getDrawerItemWidget(int pos, {bool forceReload = false}) {
_previousDrawerIndex = pos;
if (pos == -1) {
//return forceReload
// ? new LandingPage(key: UniqueKey())
// : new LandingPage();
return ChangeNotifierProvider<ListProvider>(
create: (_) => new ListProvider(),
child: forceReload ? LandingPage(key: UniqueKey()) : LandingPage(),
);
}
return new NamespacePage(namespace: _namespaces[pos]);
}
_onSelectItem(int index) {
setState(() => _selectedDrawerIndex = index);
Navigator.of(context).pop();
}
_addNamespaceDialog(BuildContext context) {
showDialog(
context: context,
builder: (_) => AddDialog(
onAdd: (name) => _addNamespace(name, context),
decoration: new InputDecoration(
labelText: 'Namespace', hintText: 'eg. Personal Namespace'),
));
}
_addNamespace(String name, BuildContext context) {
final currentUser = VikunjaGlobal.of(context).currentUser;
if (currentUser == null) {
return;
}
VikunjaGlobal.of(context)
.namespaceService
.create(Namespace(title: name, owner: currentUser))
.then((_) {
_loadNamespaces();
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text('The namespace was created successfully!'),
));
}).catchError((error) => showDialog(
context: context, builder: (context) => ErrorDialog(error: error)));
}
Future<void> _loadNamespaces() {
return VikunjaGlobal.of(context).namespaceService.getAll().then((result) {
setState(() {
_loading = false;
if(result != null)
_namespaces = result;
});
});
return widgets[pos];
}
}

View File

@ -2,6 +2,7 @@ import 'package:after_layout/after_layout.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:vikunja_app/global.dart';
import 'package:vikunja_app/service/services.dart';
import 'dart:developer';
@ -10,25 +11,37 @@ import '../components/TaskTile.dart';
import '../components/pagestatus.dart';
import '../models/task.dart';
class LandingPage extends StatefulWidget {
const LandingPage({Key? key}) : super(key: key);
class HomeScreenWidget extends StatefulWidget {
HomeScreenWidget({Key? key}) : super(key: key);
@override
State<StatefulWidget> createState() {
// TODO: implement createState
throw UnimplementedError();
}
}
class LandingPage extends HomeScreenWidget {
LandingPage({Key? key}) : super(key: key);
@override
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');
Future<void> _updateDefaultList() async {
return VikunjaGlobal.of(context).listService.getDefaultList().then(
(value) => setState(
() => defaultList = value == null ? null : int.tryParse(value)));
return VikunjaGlobal.of(context).newUserService?.getCurrentUser().then(
(value) => setState(() {
defaultList = value?.settings?.default_project_id;
}),
);
}
@override
@ -42,6 +55,10 @@ class LandingPageState extends State<LandingPage>
} catch (e) {
log(e.toString());
}
VikunjaGlobal.of(context)
.settingsManager
.getLandingPageOnlyDueDateTasks()
.then((value) => onlyDueDate = value);
}));
super.initState();
}
@ -90,10 +107,13 @@ class LandingPageState extends State<LandingPage>
Center(child: Text("There was an error loading this view"))
]);
break;
case PageStatus.empty:
body = new Stack(
children: [ListView(), Center(child: Text("This view is empty"))]);
break;
case PageStatus.success:
body = ListView(
scrollDirection: Axis.vertical,
shrinkWrap: true,
padding: EdgeInsets.symmetric(vertical: 8.0),
children:
ListTile.divideTiles(context: context, tiles: _listTasks(context))
@ -102,15 +122,48 @@ 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) {},
)
])))
];
}),
],
),
);
}
_addItemDialog(BuildContext context) {
@ -141,7 +194,7 @@ class LandingPageState extends State<LandingPage>
title: title,
dueDate: dueDate,
createdBy: globalState.currentUser!,
listId: defaultList!,
projectId: defaultList!,
),
);
@ -152,7 +205,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;
}
@ -169,31 +222,73 @@ 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(VikunjaGlobal.of(context).taskServiceOptions)
.then<Future<void>?>((taskList) {
if (taskList != null && taskList.isEmpty) {
landingPageStatus = PageStatus.error;
return null;
.settingsManager
.getLandingPageOnlyDueDateTasks()
.then((showOnlyDueDateTasks) {
VikunjaGlobalState global = VikunjaGlobal.of(context);
Map<String, dynamic>? frontend_settings =
global.currentUser?.settings?.frontend_settings;
int? filterId = 0;
if (frontend_settings != null) {
if (frontend_settings["filter_id_used_on_overview"] != null)
filterId = frontend_settings["filter_id_used_on_overview"];
}
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;
if (filterId != null && filterId != 0) {
return global.taskService.getAllByProject(filterId, {
"sort_by": ["due_date", "id"],
"order_by": ["asc", "desc"],
}).then<Future<void>?>((response) =>
_handleTaskList(response?.body, showOnlyDueDateTasks));
;
}
return global.taskService
.getByOptions(TaskServiceOptions(newOptions: [
TaskServiceOption<TaskServiceOptionSortBy>(
"sort_by", ["due_date", "id"]),
TaskServiceOption<TaskServiceOptionSortBy>(
"order_by", ["asc", "desc"]),
TaskServiceOption<TaskServiceOptionFilterBy>("filter_by", "done"),
TaskServiceOption<TaskServiceOptionFilterValue>(
"filter_value", "false"),
TaskServiceOption<TaskServiceOptionFilterComparator>(
"filter_comparator", "equals"),
TaskServiceOption<TaskServiceOptionFilterConcat>(
"filter_concat", "and"),
], 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);
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();
}
}

View File

@ -1,218 +0,0 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:after_layout/after_layout.dart';
import 'package:provider/provider.dart';
import 'package:vikunja_app/components/AddDialog.dart';
import 'package:vikunja_app/global.dart';
import 'package:vikunja_app/models/list.dart';
import 'package:vikunja_app/models/namespace.dart';
import 'package:vikunja_app/pages/list/list.dart';
import 'package:vikunja_app/stores/list_store.dart';
import '../../components/pagestatus.dart';
class NamespacePage extends StatefulWidget {
final Namespace namespace;
NamespacePage({required this.namespace})
: super(key: Key(namespace.id.toString()));
@override
_NamespacePageState createState() => new _NamespacePageState();
}
class _NamespacePageState extends State<NamespacePage> {
List<TaskList> _lists = [];
PageStatus namespacestatus = PageStatus.loading;
bool _loading = true;
/////
// This essentially shows the lists.
@override
Widget build(BuildContext context) {
Widget body;
switch (namespacestatus) {
case PageStatus.built:
_loadLists();
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 = new ListView(
padding: EdgeInsets.symmetric(vertical: 8.0),
children: ListTile.divideTiles(
context: context,
tiles: _lists.map((ls) => Dismissible(
key: Key(ls.id.toString()),
direction: DismissDirection.startToEnd,
child: ListTile(
title: new Text(ls.title),
onTap: () => _openList(context, ls),
trailing: Icon(Icons.arrow_right),
),
background: Container(
color: Colors.red,
child: const ListTile(
leading: Icon(Icons.delete,
color: Colors.white, size: 36.0)),
),
onDismissed: (direction) {
_removeList(ls).then((_) => ScaffoldMessenger.of(context)
.showSnackBar(
SnackBar(content: Text("${ls.title} removed"))));
},
))).toList(),
);
break;
}
return new Scaffold(
body: RefreshIndicator(onRefresh: () => _loadLists(), child: body),
floatingActionButton: Builder(
builder: (context) => FloatingActionButton(
onPressed: () => _addListDialog(context),
child: const Icon(Icons.add))),
);
return Scaffold(
body: !this._loading
? RefreshIndicator(
child: _lists.length > 0
? new ListView(
padding: EdgeInsets.symmetric(vertical: 8.0),
children: ListTile.divideTiles(
context: context,
tiles: _lists.map((ls) => Dismissible(
key: Key(ls.id.toString()),
direction: DismissDirection.startToEnd,
child: ListTile(
title: new Text(ls.title),
onTap: () => _openList(context, ls),
trailing: Icon(Icons.arrow_right),
),
background: Container(
color: Colors.red,
child: const ListTile(
leading: Icon(Icons.delete,
color: Colors.white, size: 36.0)),
),
onDismissed: (direction) {
_removeList(ls).then((_) =>
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(
content: Text(
"${ls.title} removed"))));
},
))).toList(),
)
: Stack(children: [
ListView(),
Center(child: Text('This namespace is empty.'))
]),
onRefresh: _loadLists,
)
: Center(child: CircularProgressIndicator()),
floatingActionButton: Builder(
builder: (context) => FloatingActionButton(
onPressed: () => _addListDialog(context),
child: const Icon(Icons.add))),
);
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
_loadLists();
}
Future<void> _removeList(TaskList list) {
return VikunjaGlobal.of(context)
.listService
.delete(list.id)
.then((_) => _loadLists());
}
Future<void> _loadLists() {
// FIXME: This is called even when the tasks on a list are loaded - which is not needed at all
namespacestatus = PageStatus.loading;
return VikunjaGlobal.of(context)
.listService
.getByNamespace(widget.namespace.id)
.then((lists) => setState(() {
if (lists != null) {
this._lists = lists;
namespacestatus = PageStatus.success;
} else {
namespacestatus = PageStatus.error;
}
}));
}
_openList(BuildContext context, TaskList list) {
Navigator.of(context).push(MaterialPageRoute(
builder: (context) => ChangeNotifierProvider<ListProvider>(
create: (_) => new ListProvider(),
child: ListPage(
taskList: list,
),
),
// ListPage(taskList: list)
));
}
_addListDialog(BuildContext context) {
showDialog(
context: context,
builder: (_) => AddDialog(
onAdd: (name) => _addList(name, context),
decoration: new InputDecoration(
labelText: 'List Name', hintText: 'eg. Shopping List')),
);
}
void _addList(String name, BuildContext context) {
final curentUser = VikunjaGlobal.of(context).currentUser;
if (curentUser == null) {
return;
}
VikunjaGlobal.of(context)
.listService
.create(
widget.namespace.id,
TaskList(
title: name,
tasks: [],
namespaceId: widget.namespace.id,
owner: curentUser,
))
.then((_) {
setState(() {});
_loadLists();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('The list was successfully created!'),
),
);
});
}
}

View File

@ -1,129 +0,0 @@
import 'package:flutter/material.dart';
import 'package:vikunja_app/global.dart';
import 'package:vikunja_app/models/namespace.dart';
import 'package:vikunja_app/theme/button.dart';
import 'package:vikunja_app/theme/buttonText.dart';
class NamespaceEditPage extends StatefulWidget {
final Namespace namespace;
NamespaceEditPage({required this.namespace}) : super(key: Key(namespace.toString()));
@override
State<StatefulWidget> createState() => _NamespaceEditPageState();
}
class _NamespaceEditPageState extends State<NamespaceEditPage> {
final _formKey = GlobalKey<FormState>();
bool _loading = false;
late String _name, _description;
@override
void initState() {
_name = widget.namespace.title;
_description = widget.namespace.description;
super.initState();
}
@override
Widget build(BuildContext ctx) {
return Scaffold(
appBar: AppBar(
title: Text('Edit Namespace'),
),
body: Builder(
builder: (BuildContext context) => SafeArea(
child: Form(
key: _formKey,
child: ListView(
padding: const EdgeInsets.all(16.0),
children: <Widget>[
Padding(
padding: EdgeInsets.symmetric(vertical: 10.0),
child: TextFormField(
maxLines: null,
keyboardType: TextInputType.multiline,
initialValue: widget.namespace.title,
onSaved: (name) => _name = name ?? '',
validator: (name) {
//if (name.length < 3 || name.length > 250) {
// return 'The name needs to have between 3 and 250 characters.';
//}
return null;
},
decoration: new InputDecoration(
labelText: 'Name',
border: OutlineInputBorder(),
),
),
),
Padding(
padding: EdgeInsets.symmetric(vertical: 10.0),
child: TextFormField(
maxLines: null,
keyboardType: TextInputType.multiline,
initialValue: widget.namespace.description,
onSaved: (description) => _description = description ?? '',
validator: (description) {
//if (description.length > 1000) {
// return 'The description can have a maximum of 1000 characters.';
//}
return null;
},
decoration: new InputDecoration(
labelText: 'Description',
border: OutlineInputBorder(),
),
),
),
Builder(
builder: (context) => Padding(
padding: EdgeInsets.symmetric(vertical: 10.0),
child: FancyButton(
onPressed: !_loading
? () {
if (_formKey.currentState!.validate()) {
Form.of(context)?.save();
_saveNamespace(context);
}
}
: null,
child: _loading
? CircularProgressIndicator()
: VikunjaButtonText('Save'),
))),
]),
),
),
),
);
}
_saveNamespace(BuildContext context) async {
setState(() => _loading = true);
final updatedNamespace = widget.namespace.copyWith(
title: _name,
description: _description,
);
VikunjaGlobal.of(context)
.namespaceService
.update(updatedNamespace)
.then((_) {
setState(() => _loading = false);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text('The namespace 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),
),
);
});
}
}

View File

@ -0,0 +1,165 @@
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)));
}
}

View File

@ -0,0 +1 @@

View File

@ -1,21 +1,22 @@
import 'dart:ffi';
import 'dart:developer';
import 'package:flutter/material.dart';
import 'package:vikunja_app/global.dart';
import 'package:vikunja_app/models/list.dart';
import 'package:vikunja_app/theme/button.dart';
import 'package:vikunja_app/theme/buttonText.dart';
class ListEditPage extends StatefulWidget {
final TaskList list;
import '../../models/project.dart';
ListEditPage({required this.list}) : super(key: Key(list.toString()));
class ProjectEditPage extends StatefulWidget {
final Project project;
ProjectEditPage({required this.project})
: super(key: Key(project.toString()));
@override
State<StatefulWidget> createState() => _ListEditPageState();
State<StatefulWidget> createState() => _ProjectEditPageState();
}
class _ListEditPageState extends State<ListEditPage> {
class _ProjectEditPageState extends State<ProjectEditPage> {
final _formKey = GlobalKey<FormState>();
bool _loading = false;
String _title = '', _description = '';
@ -23,27 +24,30 @@ class _ListEditPageState extends State<ListEditPage> {
late int listId;
@override
void initState(){
listId = widget.list.id;
void initState() {
listId = widget.project.id;
super.initState();
}
@override
Widget build(BuildContext ctx) {
if(displayDoneTasks == null)
VikunjaGlobal.of(context).listService.getDisplayDoneTasks(listId).then(
(value) => setState(() => displayDoneTasks = value == "1"));
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 List'),
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(
@ -51,7 +55,7 @@ class _ListEditPageState extends State<ListEditPage> {
child: TextFormField(
maxLines: null,
keyboardType: TextInputType.multiline,
initialValue: widget.list.title,
initialValue: widget.project.title,
onSaved: (title) => _title = title ?? '',
validator: (title) {
//if (title?.length < 3 || title.length > 250) {
@ -70,11 +74,11 @@ class _ListEditPageState extends State<ListEditPage> {
child: TextFormField(
maxLines: null,
keyboardType: TextInputType.multiline,
initialValue: widget.list.description,
onSaved: (description) => _description = description ?? '',
initialValue: widget.project.description,
onSaved: (description) =>
_description = description ?? '',
validator: (description) {
if(description == null)
return null;
if (description == null) return null;
if (description.length > 1000) {
return 'The description can have a maximum of 1000 characters.';
}
@ -93,7 +97,9 @@ class _ListEditPageState extends State<ListEditPage> {
title: Text("Show done tasks"),
onChanged: (value) {
value ??= false;
VikunjaGlobal.of(context).listService.setDisplayDoneTasks(listId, value ? "1" : "0");
VikunjaGlobal.of(context)
.projectService
.setDisplayDoneTasks(listId, value ? "1" : "0");
setState(() => displayDoneTasks = value);
},
),
@ -114,6 +120,27 @@ class _ListEditPageState extends State<ListEditPage> {
? 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;
},)
],
)*/
]),
),
),
@ -125,12 +152,12 @@ class _ListEditPageState extends State<ListEditPage> {
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?)
widget.list.title = _title;
widget.list.description = _description;
VikunjaGlobal.of(context).listService.update(widget.list).then((_) {
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 list was updated successfully!'),
content: Text('The project was updated successfully!'),
));
}).catchError((err) {
setState(() => _loading = false);

View File

@ -8,14 +8,14 @@ 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/stores/list_store.dart';
import 'package:vikunja_app/pages/project/project_edit.dart';
import 'package:vikunja_app/pages/project/task_edit.dart';
import '../../components/pagestatus.dart';
import '../../models/project.dart';
import '../../stores/project_store.dart';
enum BucketMenu { limit, done, delete }
@ -29,10 +29,10 @@ class BucketProps {
}
class ListPage extends StatefulWidget {
final TaskList taskList;
final Project project;
//ListPage({this.taskList}) : super(key: Key(taskList.id.toString()));
ListPage({required this.taskList})
ListPage({required this.project})
: super(key: Key(Random().nextInt(100000).toString()));
@override
@ -42,16 +42,16 @@ class ListPage extends StatefulWidget {
class _ListPageState extends State<ListPage> {
final _keyboardController = KeyboardVisibilityController();
int _viewIndex = 0;
late TaskList _list;
late Project _project;
List<Task> _loadingTasks = [];
int _currentPage = 1;
bool displayDoneTasks = false;
late ListProvider taskState;
late ProjectProvider taskState;
late KanbanClass _kanban;
@override
void initState() {
_list = widget.taskList;
_project = widget.project;
_keyboardController.onChange.listen((visible) {
if (!visible && mounted) FocusScope.of(context).unfocus();
});
@ -64,9 +64,9 @@ class _ListPageState extends State<ListPage> {
@override
Widget build(BuildContext context) {
taskState = Provider.of<ListProvider>(context);
taskState = Provider.of<ProjectProvider>(context);
_kanban = KanbanClass(
context, nullSetState, _onViewTapped, _addItemDialog, _list);
context, nullSetState, _onViewTapped, _addItemDialog, _project);
Widget body;
@ -95,7 +95,9 @@ class _ListPageState extends State<ListPage> {
]);
break;
case PageStatus.success:
body = taskState.tasks.length > 0 || taskState.buckets.length > 0
body = taskState.tasks.length > 0 ||
taskState.buckets.length > 0 ||
_project.subprojects!.length > 0
? ListenableProvider.value(
value: taskState,
child: Theme(
@ -126,19 +128,23 @@ class _ListPageState extends State<ListPage> {
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(_list.title),
title: Text(_project.title),
actions: <Widget>[
IconButton(
icon: Icon(Icons.edit),
onPressed: () => Navigator.push(
context,
MaterialPageRoute(
builder: (context) => ListEditPage(
list: _list,
builder: (context) => ProjectEditPage(
project: _project,
),
)).whenComplete(() => _loadList()),
),
@ -171,6 +177,32 @@ class _ListPageState extends State<ListPage> {
);
}
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;
@ -180,31 +212,55 @@ class _ListPageState extends State<ListPage> {
});
}
ListView _listView(BuildContext context) {
return ListView.builder(
padding: EdgeInsets.symmetric(vertical: 8.0),
itemCount: taskState.tasks.length * 2,
itemBuilder: (context, i) {
if (i.isOdd) return Divider();
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);
}
if (_loadingTasks.isNotEmpty) {
final loadingTask = _loadingTasks.removeLast();
return _buildLoadingTile(loadingTask);
}
final index = i ~/ 2;
final index = i ~/ 2;
if (taskState.maxPages == _currentPage &&
index == taskState.tasks.length)
throw Exception("Check itemCount attribute");
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]);
});
if (index >= taskState.tasks.length &&
_currentPage < taskState.maxPages) {
_currentPage++;
_loadTasksForPage(_currentPage);
}
return _buildTile(taskState.tasks[index]);
})));
}
return Column(children: children);
}
Widget _buildTile(Task task) {
@ -215,7 +271,7 @@ class _ListPageState extends State<ListPage> {
loading: false,
onEdit: () {},
onMarkedAsDone: (done) {
Provider.of<ListProvider>(context, listen: false).updateTask(
Provider.of<ProjectProvider>(context, listen: false).updateTask(
context: context,
task: task.copyWith(done: done),
);
@ -226,8 +282,8 @@ class _ListPageState extends State<ListPage> {
Future<void> updateDisplayDoneTasks() {
return VikunjaGlobal.of(context)
.listService
.getDisplayDoneTasks(_list.id)
.projectService
.getDisplayDoneTasks(_project.id)
.then((value) {
displayDoneTasks = value == "1";
});
@ -258,13 +314,11 @@ class _ListPageState extends State<ListPage> {
_loadTasksForPage(1);
break;
case 1:
await _kanban
.loadBucketsForPage(1);
await _kanban.loadBucketsForPage(1);
// load all buckets to get length for RecordableListView
while (_currentPage < taskState.maxPages) {
_currentPage++;
await _kanban
.loadBucketsForPage(_currentPage);
await _kanban.loadBucketsForPage(_currentPage);
}
break;
default:
@ -274,12 +328,11 @@ class _ListPageState extends State<ListPage> {
}
Future<void> _loadTasksForPage(int page) {
return Provider.of<ListProvider>(context, listen: false)
.loadTasks(
context: context,
listId: _list.id,
page: page,
displayDoneTasks: displayDoneTasks);
return Provider.of<ProjectProvider>(context, listen: false).loadTasks(
context: context,
listId: _project.id,
page: page,
displayDoneTasks: displayDoneTasks);
}
Future<void> _addItemDialog(BuildContext context, [Bucket? bucket]) {
@ -308,14 +361,14 @@ class _ListPageState extends State<ListPage> {
createdBy: currentUser,
done: false,
bucketId: bucket?.id,
listId: _list.id,
projectId: _project.id,
);
setState(() => _loadingTasks.add(newTask));
return Provider.of<ListProvider>(context, listen: false)
return Provider.of<ProjectProvider>(context, listen: false)
.addTask(
context: context,
newTask: newTask,
listId: _list.id,
listId: _project.id,
)
.then((_) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
@ -325,7 +378,20 @@ class _ListPageState extends State<ListPage> {
));
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)
));
}

View File

@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter_downloader/flutter_downloader.dart';
import 'package:flutter_typeahead/flutter_typeahead.dart';
import 'package:flutter_colorpicker/flutter_colorpicker.dart';
import 'package:vikunja_app/components/datetimePicker.dart';
@ -6,12 +7,14 @@ 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 'package:vikunja_app/utils/priority.dart';
import '../../stores/project_store.dart';
class TaskEditPage extends StatefulWidget {
final Task task;
final ListProvider taskState;
final ProjectProvider taskState;
TaskEditPage({
required this.task,
@ -45,8 +48,8 @@ class _TaskEditPageState extends State<TaskEditPage> {
@override
void initState() {
_repeatAfter = widget.task.repeatAfter;
if(_repeatAfterType == null)
_repeatAfterType = getRepeatAfterTypeFromDuration(_repeatAfter);
if (_repeatAfterType == null)
_repeatAfterType = getRepeatAfterTypeFromDuration(_repeatAfter);
_reminderDates = widget.task.reminderDates;
for (var i = 0; i < _reminderDates.length; i++) {
@ -80,6 +83,35 @@ class _TaskEditPageState extends State<TaskEditPage> {
child: Scaffold(
appBar: AppBar(
title: Text('Edit Task'),
actions: [
IconButton(
icon: Icon(Icons.delete),
onPressed: () {
showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: Text('Delete Task'),
content: Text(
'Are you sure you want to delete this task?'),
actions: [
TextButton(
child: Text('Cancel'),
onPressed: () => Navigator.of(context).pop(),
),
TextButton(
child: Text('Delete'),
onPressed: () {
_delete(widget.task.id);
Navigator.of(context).pop();
},
),
],
);
});
},
),
],
),
body: Builder(
builder: (BuildContext context) => SafeArea(
@ -87,7 +119,8 @@ class _TaskEditPageState extends State<TaskEditPage> {
key: _formKey,
child: ListView(
key: _listKey,
padding: const EdgeInsets.all(16.0),
padding: EdgeInsets.fromLTRB(
16, 16, 16, MediaQuery.of(context).size.height / 2),
children: <Widget>[
Padding(
padding: EdgeInsets.symmetric(vertical: 10.0),
@ -155,8 +188,9 @@ class _TaskEditPageState extends State<TaskEditPage> {
flex: 2,
child: TextFormField(
keyboardType: TextInputType.number,
initialValue: getRepeatAfterValueFromDuration(
_repeatAfter)?.toString(),
initialValue:
getRepeatAfterValueFromDuration(_repeatAfter)
?.toString(),
onSaved: (repeatAfter) => _repeatAfter =
getDurationFromType(
repeatAfter, _repeatAfterType),
@ -173,8 +207,7 @@ class _TaskEditPageState extends State<TaskEditPage> {
isExpanded: true,
isDense: true,
value: _repeatAfterType ??
getRepeatAfterTypeFromDuration(
_repeatAfter),
getRepeatAfterTypeFromDuration(_repeatAfter),
onChanged: (String? newValue) {
setState(() {
_repeatAfterType = newValue;
@ -225,7 +258,7 @@ class _TaskEditPageState extends State<TaskEditPage> {
_reminderDates.add(DateTime(0));
var currentIndex = _reminderDates.length - 1;
// FIXME: Why does putting this into a row fails?
// FIXME: Why does putting this into a row fail?
setState(() => _reminderInputs.add(
VikunjaDateTimePicker(
label: 'Reminder',
@ -245,12 +278,12 @@ class _TaskEditPageState extends State<TaskEditPage> {
border: InputBorder.none,
),
child: new DropdownButton<String>(
value: _priorityToString(_priority),
value: priorityToString(_priority),
isExpanded: true,
isDense: true,
onChanged: (String? newValue) {
setState(() {
_priority = _priorityFromString(newValue);
_priority = priorityFromString(newValue);
});
},
items: [
@ -294,25 +327,33 @@ class _TaskEditPageState extends State<TaskEditPage> {
Padding(
padding: EdgeInsets.only(
right: 15,
left: 2.0 + (IconTheme.of(context).size??0))),
left: 2.0 + (IconTheme.of(context).size ?? 0))),
Container(
width: MediaQuery.of(context).size.width - 80 - ((IconTheme.of(context).size ?? 0) * 2),
child: TypeAheadFormField(
textFieldConfiguration: TextFieldConfiguration(
width: MediaQuery.of(context).size.width -
80 -
((IconTheme.of(context).size ?? 0) * 2),
child: TypeAheadField(
builder: (builder, controller, focusnode) {
return TextFormField(
controller: _labelTypeAheadController,
focusNode: focusnode,
decoration: InputDecoration(
labelText: 'Add a new label')),
labelText: 'Add a new label',
border: InputBorder.none,
),
);
},
suggestionsCallback: (pattern) =>
_searchLabel(pattern),
itemBuilder: (context, suggestion) {
return new ListTile(
title: Text(suggestion.toString()));
},
transitionBuilder:
(context, suggestionsBox, controller) {
return suggestionsBox;
},
onSuggestionSelected: (suggestion) {
//transitionBuilder:
// (context, suggestionsBox, controller) {
// return suggestionsBox;
//},
onSelected: (suggestion) {
_addLabel(suggestion.toString());
},
),
@ -383,6 +424,42 @@ class _TaskEditPageState extends State<TaskEditPage> {
],
),
),
ListView.separated(
separatorBuilder: (context, index) => Divider(),
padding: const EdgeInsets.all(16.0),
shrinkWrap: true,
itemCount: widget.task.attachments.length,
itemBuilder: (context, index) {
return ListTile(
title: Text(widget.task.attachments[index].file.name),
trailing: IconButton(
icon: Icon(Icons.download),
onPressed: () async {
String url =
VikunjaGlobal.of(context).client.base;
url +=
'/tasks/${widget.task.id}/attachments/${widget.task.attachments[index].id}';
print(url);
final taskId = await FlutterDownloader.enqueue(
url: url,
fileName:
widget.task.attachments[index].file.name,
headers: VikunjaGlobal.of(context)
.client
.headers, // optional: header send with url (auth token etc)
savedDir: '/storage/emulated/0/Download/',
showNotification:
true, // show download progress in status bar (for Android)
openFileFromNotification:
true, // click on notification to open downloaded file (for Android)
);
if (taskId == null) return;
FlutterDownloader.open(taskId: taskId);
},
),
);
},
)
],
),
),
@ -470,6 +547,11 @@ class _TaskEditPageState extends State<TaskEditPage> {
});
}
_delete(int taskId) {
VikunjaGlobal.of(context).taskService.delete(taskId);
Navigator.pop(context);
}
_searchLabel(String query) {
return VikunjaGlobal.of(context)
.labelService
@ -527,44 +609,6 @@ class _TaskEditPageState extends State<TaskEditPage> {
});
}
// FIXME: Move the following two functions to an extra class or type.
_priorityFromString(String? priority) {
switch (priority) {
case 'Low':
return 1;
case 'Medium':
return 2;
case 'High':
return 3;
case 'Urgent':
return 4;
case 'DO NOW':
return 5;
default:
// unset
return 0;
}
}
_priorityToString(int? priority) {
switch (priority) {
case 0:
return 'Unset';
case 1:
return 'Low';
case 2:
return 'Medium';
case 3:
return 'High';
case 4:
return 'Urgent';
case 5:
return 'DO NOW';
default:
return null;
}
}
_onColorEdit() {
_pickerColor = _resetColor || (_color ?? widget.task.color) == null
? Colors.black

View File

@ -2,7 +2,12 @@ import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:vikunja_app/global.dart';
import 'package:vikunja_app/models/list.dart';
import 'package:collection/collection.dart';
import '../main.dart';
import '../models/project.dart';
import '../models/user.dart';
import '../service/services.dart';
class SettingsPage extends StatefulWidget {
@override
@ -10,25 +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;
FlutterThemeMode? themeMode;
User? currentUser;
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) =>
setState(
() => defaultList = value == null ? null : int.tryParse(value)));
//VikunjaGlobal.of(context).projectService.getDefaultList().then((value) =>
// setState(
// () => defaultProject = value == null ? null : int.tryParse(value)));
VikunjaGlobal.of(context).settingsManager.getIgnoreCertificates().then(
(value) =>
@ -43,24 +50,56 @@ class SettingsPageState extends State<SettingsPage> {
.getCurrentVersionTag()
.then((value) => setState(() => versionTag = value));
VikunjaGlobal.of(context).settingsManager.getWorkmanagerDuration().then(
(value) => setState(
() => durationTextController.text = (value.inMinutes.toString())));
VikunjaGlobal.of(context)
.settingsManager
.getWorkmanagerDuration()
.then((value) => setState(() => durationTextController.text = (value.inMinutes.toString())));
.getThemeMode()
.then((value) => setState(() => themeMode = value));
VikunjaGlobal.of(context).newUserService?.getCurrentUser().then((value) => {
setState(() {
currentUser = value!;
defaultProject = value.settings?.default_project_id;
}),
});
initialized = true;
}
@override
Widget build(BuildContext context) {
final global = VikunjaGlobal.of(context);
if (!initialized) init();
return new Scaffold(
appBar: AppBar(
title: Text("Settings"),
),
body: Column(
body: ListView(
children: [
taskListList != null
UserAccountsDrawerHeader(
accountName:
currentUser != null ? Text(currentUser!.username) : null,
accountEmail: currentUser != null ? Text(currentUser!.name) : null,
currentAccountPicture: currentUser == null
? null
: CircleAvatar(
backgroundImage: (currentUser?.username != "")
? NetworkImage(currentUser!.avatarUrl(context))
: null,
),
decoration: BoxDecoration(
image: DecorationImage(
image: AssetImage("assets/graphics/hypnotize.png"),
repeat: ImageRepeat.repeat,
colorFilter: ColorFilter.mode(
Theme.of(context).primaryColor, BlendMode.multiply)),
),
),
projectList != null
? ListTile(
title: Text("Default List"),
trailing: DropdownButton<int>(
@ -69,50 +108,95 @@ 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: projectList?.firstWhereOrNull(
(element) => element.id == defaultProject) !=
null
? defaultProject
: null,
onChanged: (int? value) {
setState(() => defaultList = value);
VikunjaGlobal.of(context)
.listService
.setDefaultList(value);
setState(() => defaultProject = value);
global.newUserService
?.setCurrentUserSettings(currentUser!.settings!
.copyWith(default_project_id: value))
.then((value) => currentUser!.settings = value);
//VikunjaGlobal.of(context).userManager.setDefaultList(value);
},
),
)
: ListTile(
title: Text("..."),
),
Divider(),
ListTile(
title: Text("Theme"),
trailing: DropdownButton<FlutterThemeMode>(
items: [
DropdownMenuItem(
child: Text("System"),
value: FlutterThemeMode.system,
),
DropdownMenuItem(
child: Text("Light"),
value: FlutterThemeMode.light,
),
DropdownMenuItem(
child: Text("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: (FlutterThemeMode? value) {
VikunjaGlobal.of(context).settingsManager.setThemeMode(value!);
setState(() => themeMode = value);
updateTheme.value = true;
},
),
),
Divider(),
ignoreCertificates != null
? CheckboxListTile(
title: Text("Ignore Certificates"),
value: ignoreCertificates,
onChanged: (value) {
setState(() => ignoreCertificates = value);
VikunjaGlobal.of(context).client.reload_ignore_certs(value);
VikunjaGlobal.of(context).client.reloadIgnoreCerts(value);
})
: ListTile(title: Text("...")),
Padding(padding: EdgeInsets.only(left: 15, right: 15),
Divider(),
Padding(
padding: EdgeInsets.only(left: 15, right: 15),
child: Row(children: [
Flexible(
child: TextField(
controller: durationTextController,
decoration: InputDecoration(
labelText: 'Background Refresh Interval (minutes): ',
helperText: 'Minimum: 15, Set limit of 0 for no refresh',
),
)),
TextButton(
onPressed: () => VikunjaGlobal.of(context)
.settingsManager
.setWorkmanagerDuration(Duration(
minutes: int.parse(durationTextController.text))).then((value) => VikunjaGlobal.of(context).updateWorkmanagerDuration()),
child: Text("Save")),
]))
,
Flexible(
child: TextField(
controller: durationTextController,
decoration: InputDecoration(
labelText: 'Background Refresh Interval (minutes): ',
helperText: 'Minimum: 15, Set limit of 0 for no refresh',
),
)),
TextButton(
onPressed: () => VikunjaGlobal.of(context)
.settingsManager
.setWorkmanagerDuration(Duration(
minutes: int.parse(durationTextController.text)))
.then((value) => VikunjaGlobal.of(context)
.updateWorkmanagerDuration()),
child: Text("Save")),
])),
Divider(),
getVersionNotifications != null
? CheckboxListTile(
title: Text("Get Version Notifications"),
@ -144,7 +228,11 @@ class SettingsPageState extends State<SettingsPage> {
Text("Current version: ${versionTag ?? "loading"}"),
Text(newestVersionTag != null
? "Latest version: $newestVersionTag"
: "")
: ""),
Divider(),
TextButton(
onPressed: () => VikunjaGlobal.of(context).logoutUser(context),
child: Text("Logout")),
],
),
);

View File

@ -177,7 +177,7 @@ class _TaskEditPageState extends State<TaskEditPage> {
_deleteTask(BuildContext context) async {
await VikunjaGlobal.of(context).taskService.delete(widget.task.id).then(
(value) {
Navigator.pop(context);
navigatorKey.currentState?.pop(context);
});
}
@ -229,16 +229,16 @@ class _TaskEditPageState extends State<TaskEditPage> {
child: Text('Dismiss'),
onPressed: () {
log("Dismiss");
Navigator.pop(context);
navigatorKey.currentState?.pop(context);
// make sure the list is refreshed
Navigator.pop(context);
navigatorKey.currentState?.pop(context);
},
),
TextButton(
child: Text('Cancel'),
onPressed: () {
log("cancel");
Navigator.pop(context);
navigatorKey.currentState?.pop(context);
},
),
],

View File

@ -31,13 +31,12 @@ class _LoginPageState extends State<LoginPage> {
final _usernameController = TextEditingController();
final _passwordController = TextEditingController();
final _serverSuggestionController = SuggestionsBoxController();
final _serverSuggestionController = SuggestionsController();
@override
void initState() {
super.initState();
Future.delayed(Duration.zero, () async{
Future.delayed(Duration.zero, () async {
if (VikunjaGlobal.of(context).expired) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text("Login has expired. Please reenter your details!")));
@ -48,10 +47,16 @@ class _LoginPageState extends State<LoginPage> {
});
}
final client = VikunjaGlobal.of(context).client;
await VikunjaGlobal.of(context).settingsManager.getIgnoreCertificates().then(
(value) => setState(() => client.ignoreCertificates = value == "1"));
await VikunjaGlobal.of(context)
.settingsManager
.getIgnoreCertificates()
.then((value) =>
setState(() => client.ignoreCertificates = value == "1"));
await VikunjaGlobal.of(context).settingsManager.getPastServers().then((value) {
await VikunjaGlobal.of(context)
.settingsManager
.getPastServers()
.then((value) {
print(value);
if (value != null) setState(() => pastServers = value);
});
@ -89,54 +94,73 @@ class _LoginPageState extends State<LoginPage> {
padding: vStandardVerticalPadding,
child: Row(children: [
Expanded(
child: TypeAheadFormField(
suggestionsBoxController: _serverSuggestionController,
getImmediateSuggestions: true,
enabled: !_loading,
validator: (address) {
return (isUrl(address) ||
address != null ||
address!.isEmpty)
? null
: 'Invalid URL';
child: TypeAheadField(
//suggestionsBoxController: _serverSuggestionController,
//getImmediateSuggestions: true,
//enabled: !_loading,
controller: _serverController,
builder: (context, controller, focusnode) {
return TextFormField(
controller: controller,
focusNode: focusnode,
enabled: !_loading,
validator: (address) {
return (isUrl(address) ||
address != null ||
address!.isEmpty)
? null
: 'Invalid URL';
},
decoration: new InputDecoration(
border: OutlineInputBorder(),
labelText: 'Server Address'),
);
},
/*
textFieldConfiguration: TextFieldConfiguration(
controller: _serverController,
decoration: new InputDecoration(
border: OutlineInputBorder(),
labelText: 'Server Address'),
),
onSuggestionSelected: (suggestion) {
),*/
onSelected: (suggestion) {
_serverController.text = suggestion;
setState(
() => _serverController.text = suggestion);
},
itemBuilder: (BuildContext context, Object? itemData) {
itemBuilder:
(BuildContext context, Object? itemData) {
return Card(
child: Container(
padding: EdgeInsets.all(10),
child:
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(itemData.toString()),
IconButton(onPressed: () {
setState(() {
pastServers.remove(itemData.toString());
_serverSuggestionController.suggestionsBox?.close();
VikunjaGlobal.of(context).settingsManager.setPastServers(pastServers);
});
}, icon: Icon(Icons.clear))
],
))
);
padding: EdgeInsets.all(10),
child: Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
Text(itemData.toString()),
IconButton(
onPressed: () {
setState(() {
pastServers.remove(
itemData.toString());
//_serverSuggestionController.suggestionsBox?.close();
VikunjaGlobal.of(context)
.settingsManager
.setPastServers(
pastServers);
});
},
icon: Icon(Icons.clear))
],
)));
},
suggestionsCallback: (String pattern) {
List<String> matches = <String>[];
matches.addAll(pastServers);
matches.retainWhere((s){
return s.toLowerCase().contains(pattern.toLowerCase());
matches.retainWhere((s) {
return s
.toLowerCase()
.contains(pattern.toLowerCase());
});
return matches;
},
@ -235,20 +259,18 @@ class _LoginPageState extends State<LoginPage> {
}
},
child: VikunjaButtonText("Login with Frontend"))),
CheckboxListTile(
title: Text("Ignore Certificates"),
value: client.ignoreCertificates,
onChanged: (value) {
setState(() =>
client.reload_ignore_certs(value ?? false));
VikunjaGlobal.of(context)
.settingsManager
.setIgnoreCertificates(value ?? false);
VikunjaGlobal.of(context)
.client
.ignoreCertificates = value ?? false;
})
CheckboxListTile(
title: Text("Ignore Certificates"),
value: client.ignoreCertificates,
onChanged: (value) {
setState(
() => client.reloadIgnoreCerts(value ?? false));
VikunjaGlobal.of(context)
.settingsManager
.setIgnoreCertificates(value ?? false);
VikunjaGlobal.of(context).client.ignoreCertificates =
value ?? false;
})
],
),
),
@ -265,12 +287,13 @@ class _LoginPageState extends State<LoginPage> {
String _password = _passwordController.text;
if (_server.isEmpty) return;
if(!pastServers.contains(_server)) pastServers.add(_server);
if (!pastServers.contains(_server)) pastServers.add(_server);
await VikunjaGlobal.of(context).settingsManager.setPastServers(pastServers);
setState(() => _loading = true);
try {
var vGlobal = VikunjaGlobal.of(context);
vGlobal.client.showSnackBar = false;
vGlobal.client.configure(base: _server);
Server? info = await vGlobal.serverService.getInfo();
if (info == null) throw Exception("Getting server info failed");
@ -282,26 +305,36 @@ class _LoginPageState extends State<LoginPage> {
if (newUser.error == 1017) {
TextEditingController totpController = TextEditingController();
bool dismissed = true;
await showDialog(
context: context,
builder: (context) => new AlertDialog(
title: Text("Enter One Time Passcode"),
content: TextField(
controller: totpController,
keyboardType: TextInputType.number,
inputFormatters: <TextInputFormatter>[
FilteringTextInputFormatter.digitsOnly
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text("Login"))
],
));
newUser = await vGlobal.newUserService!.login(_username, _password,
rememberMe: this._rememberMe, totp: totpController.text);
} else if (newUser.error > 0) {
context: context,
builder: (context) => new AlertDialog(
title: Text("Enter One Time Passcode"),
content: TextField(
controller: totpController,
keyboardType: TextInputType.number,
inputFormatters: <TextInputFormatter>[
FilteringTextInputFormatter.digitsOnly
],
),
actions: [
TextButton(
onPressed: () {
dismissed = false;
Navigator.pop(context);
},
child: Text("Login"))
],
),
);
if (!dismissed) {
newUser = await vGlobal.newUserService!.login(_username, _password,
rememberMe: this._rememberMe, totp: totpController.text);
} else {
throw Exception();
}
}
if (newUser.error > 0) {
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(newUser.errorString)));
}
@ -309,6 +342,7 @@ class _LoginPageState extends State<LoginPage> {
if (newUser.error == 0)
vGlobal.changeUser(newUser.user!, token: newUser.token, base: _server);
} catch (ex) {
print(ex);
/* log(stacktrace.toString());
showDialog(
context: context,
@ -324,6 +358,7 @@ class _LoginPageState extends State<LoginPage> {
));
*/
} finally {
VikunjaGlobal.of(context).client.showSnackBar = true;
setState(() {
_loading = false;
});
@ -344,6 +379,7 @@ class _LoginPageState extends State<LoginPage> {
vGS.changeUser(newUser,
token: baseTokenPair.token, base: baseTokenPair.base);
} catch (e) {
log("failed to change to user by client token");
log(e.toString());
}
setState(() => _loading = false);

View File

@ -16,67 +16,92 @@ class LoginWithWebView extends StatefulWidget {
}
class LoginWithWebViewState extends State<LoginWithWebView> {
WebView? webView;
WebViewController? webViewController;
WebViewWidget? webView;
late WebViewController webViewController;
bool destroyed = false;
@override
void initState() {
super.initState();
webView = WebView(
webViewController = WebViewController()
..clearLocalStorage()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..setUserAgent(
"Mozilla/5.0 (Linux; Android 8.0; Pixel 2 Build/OPD3.170816.012) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/93.0.4577.82 Mobile Safari/537.36")
..setNavigationDelegate(NavigationDelegate(
onPageFinished: (value) => _handlePageFinished(value),
))
..loadRequest(Uri.parse(widget.frontEndUrl)).then((value) => {
webViewController!.runJavaScript(
"localStorage.clear(); location.href=location.href;")
});
/*
webView = WebViewWidget(
initialUrl: widget.frontEndUrl,
javascriptMode: JavascriptMode.unrestricted,
onPageFinished: (value) => _handlePageFinished(value),
userAgent: "Mozilla/5.0 (Linux; Android 8.0; Pixel 2 Build/OPD3.170816.012) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/93.0.4577.82 Mobile Safari/537.36",
onWebViewCreated: (controller) {
webViewController = controller;
webViewController!.runJavascript("localStorage.clear(); location.href=location.href;");
webViewController!.runJavaScript("localStorage.clear(); location.href=location.href;");
},
);
*/
}
@override
Widget build(BuildContext context) {
return WillPopScope(child: Scaffold(
appBar: AppBar(),
body: webView
),
onWillPop: () async {
String? currentUrl = await webViewController?.currentUrl();
if (currentUrl != null) {
bool hasPopped = await _handlePageFinished(currentUrl);
return Future.value(!hasPopped);
}
return Future.value(false);
},);
return WillPopScope(
child: Scaffold(
appBar: AppBar(),
body: WebViewWidget(
controller: webViewController,
)),
onWillPop: () async {
String? currentUrl = await webViewController?.currentUrl();
if (currentUrl != null) {
bool hasPopped = await _handlePageFinished(currentUrl);
return Future.value(!hasPopped);
}
return Future.value(false);
},
);
}
Future<bool> _handlePageFinished(String pageLocation) async {
log("handlePageFinished");
if(webViewController != null) {
String localStorage = await webViewController!
.runJavascriptReturningResult("JSON.stringify(localStorage);");
if (webViewController != null) {
String localStorage = (await webViewController!
.runJavaScriptReturningResult("JSON.stringify(localStorage);"))
.toString();
String apiUrl = await webViewController!.runJavascriptReturningResult("API_URL");
if (localStorage != "{}") {
String apiUrl =
(await webViewController!.runJavaScriptReturningResult("API_URL"))
.toString();
String token = (await webViewController!
.runJavaScriptReturningResult("localStorage['token']"))
.toString();
if (localStorage.toString() != "{}") {
apiUrl = apiUrl.replaceAll("\"", "");
if(!apiUrl.startsWith("http")) {
if(pageLocation.endsWith("/"))
pageLocation = pageLocation.substring(0,pageLocation.length-1);
token = token.replaceAll("\"", "");
if (!apiUrl.startsWith("http")) {
if (pageLocation.endsWith("/"))
pageLocation = pageLocation.substring(0, pageLocation.length - 1);
apiUrl = pageLocation + apiUrl;
}
localStorage = localStorage.replaceAll("\\", "");
localStorage = localStorage.substring(1, localStorage.length - 1);
var json = jsonDecode(localStorage);
if (apiUrl != "null" && json["token"] != null) {
BaseTokenPair baseTokenPair = BaseTokenPair(
apiUrl, json["token"]);
if (apiUrl != "null" && token != "null") {
BaseTokenPair baseTokenPair = BaseTokenPair(apiUrl, token);
if (destroyed) return true;
destroyed = true;
print("pop now");
Navigator.pop(context, baseTokenPair);
return true;
}
}
}
return false;
}
}
}

View File

@ -127,10 +127,9 @@ class _RegisterPageState extends State<RegisterPage> {
setState(() => _loading = true);
try {
var vGlobal = VikunjaGlobal.of(context);
var newUserLoggedIn = await vGlobal
.newUserService
?.register(_username!, _email, _password);
if(newUserLoggedIn != null)
var newUserLoggedIn =
await vGlobal.newUserService?.register(_username!, _email, _password);
if (newUserLoggedIn != null)
vGlobal.changeUser(newUserLoggedIn.user!,
token: newUserLoggedIn.token, base: _server!);
} catch (ex) {

View File

@ -1,204 +1,15 @@
import 'dart:async';
import 'package:vikunja_app/api/response.dart';
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/service/services.dart';
// Data for mocked services
var _users = {1: User(id: 1, username: 'test1')};
var _namespaces = {
1: Namespace(
id: 1,
title: 'Test 1',
created: DateTime.now(),
updated: DateTime.now(),
description: 'A namespace for testing purposes',
owner: _users[1]!,
)
};
var _nsLists = {
1: [1]
};
var _lists = {
1: TaskList(
id: 1,
title: 'List 1',
tasks: _tasks.values.toList(),
owner: _users[1]!,
description: 'A nice list',
created: DateTime.now(),
updated: DateTime.now(),
namespaceId: 1)
};
var _tasks = {
1: Task(
id: 1,
title: 'Task 1',
createdBy: _users[1]!,
updated: DateTime.now(),
created: DateTime.now(),
description: 'A descriptive task',
done: false,
listId: 1,
)
};
// Mocked services
class MockedNamespaceService implements NamespaceService {
@override
Future<Namespace> create(Namespace ns) {
_namespaces[ns.id] = ns;
return Future.value(ns);
}
@override
Future delete(int namespaceId) {
_namespaces.remove(namespaceId);
return Future.value();
}
@override
Future<Namespace> get(int namespaceId) {
return Future.value(_namespaces[namespaceId]);
}
@override
Future<List<Namespace>> getAll() {
return Future.value(_namespaces.values.toList());
}
@override
Future<Namespace> update(Namespace ns) {
if (!_namespaces.containsKey(ns.id))
throw Exception('Namespace ${ns.id} does not exsists');
return create(ns);
}
}
class MockedListService implements ListService {
@override
Future<TaskList> create(namespaceId, TaskList tl) {
_nsLists[namespaceId]?.add(tl.id);
return Future.value(_lists[tl.id] = tl);
}
@override
Future delete(int listId) {
_lists.remove(listId);
return Future.value();
}
@override
Future<TaskList> get(int listId) {
return Future.value(_lists[listId]);
}
@override
Future<List<TaskList>> getAll() {
return Future.value(_lists.values.toList());
}
@override
Future<List<TaskList>> getByNamespace(int namespaceId) {
return Future.value(
_nsLists[namespaceId]!.map((listId) => _lists[listId]!).toList());
}
@override
Future<TaskList> update(TaskList tl) {
if (!_lists.containsKey(tl))
throw Exception('TaskList ${tl.id} does not exists');
return Future.value(_lists[tl.id] = tl);
}
@override
Future<String> getDisplayDoneTasks(int listId) {
// TODO: implement getDisplayDoneTasks
throw UnimplementedError();
}
@override
void setDisplayDoneTasks(int listId, String value) {
// TODO: implement setDisplayDoneTasks
}
@override
Future<String> getDefaultList() {
// TODO: implement getDefaultList
throw UnimplementedError();
}
@override
void setDefaultList(int? listId) {
// TODO: implement setDefaultList
}
}
class MockedTaskService implements TaskService {
@override
Future delete(int taskId) {
_lists.forEach(
(_, list) => list.tasks.removeWhere((task) => task.id == taskId));
_tasks.remove(taskId);
return Future.value();
}
@override
Future<Task> update(Task task) {
_lists.forEach((_, list) {
if (list.tasks.where((t) => t.id == task.id).length > 0) {
list.tasks.removeWhere((t) => t.id == task.id);
list.tasks.add(task);
}
});
return Future.value(_tasks[task.id] = task);
}
@override
Future<Task> add(int listId, Task task) {
var id = _tasks.keys.last + 1;
_tasks[id] = task;
_lists[listId]!.tasks.add(task);
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) {
// TODO: implement get
throw UnimplementedError();
}
@override
Future<List<Task>> getByOptions(TaskServiceOptions options) {
// TODO: implement getByOptions
throw UnimplementedError();
}
@override
Future<List<Task>> getAll() {
// TODO: implement getAll
throw UnimplementedError();
}
}
class MockedUserService implements UserService {
@override
Future<UserTokenPair> login(String username, password, {bool rememberMe = false, String? totp}) {
Future<UserTokenPair> login(String username, password,
{bool rememberMe = false, String? totp}) {
return Future.value(UserTokenPair(_users[1]!, 'abcdefg'));
}
@ -212,5 +23,15 @@ class MockedUserService implements UserService {
return Future.value(_users[1]);
}
@override
Future<UserSettings> setCurrentUserSettings(UserSettings userSettings) {
// TODO: implement setCurrentUserSettings
throw UnimplementedError();
}
@override
Future<String?> getToken() {
// TODO: implement getToken
throw UnimplementedError();
}
}

View File

@ -5,12 +5,11 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:vikunja_app/api/response.dart';
import 'package:vikunja_app/models/label.dart';
import 'package:vikunja_app/models/labelTask.dart';
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/project.dart';
import '../models/server.dart';
enum TaskServiceOptionSortBy {
@ -53,10 +52,17 @@ enum TaskServiceOptionFilterConcat { and, or }
class TaskServiceOption<T> {
String name;
dynamic value;
String? value;
List<String>? valueList;
dynamic defValue;
TaskServiceOption(this.name, this.value);
TaskServiceOption(this.name, dynamic input_values) {
if (input_values is List<String>) {
valueList = input_values;
} else if (input_values is String) {
value = input_values;
}
}
String handleValue(dynamic input) {
if (input is String) return input;
@ -64,96 +70,83 @@ class TaskServiceOption<T> {
}
dynamic getValue() {
if (value is List)
return value.map((elem) => handleValue(elem)).toList();
if (valueList != null)
return valueList!.map((elem) => handleValue(elem)).toList();
else
return handleValue(value);
}
}
final List<TaskServiceOption> defaultOptions = [
TaskServiceOption<TaskServiceOptionSortBy>("sort_by",
[TaskServiceOptionSortBy.due_date, TaskServiceOptionSortBy.id]),
TaskServiceOption<TaskServiceOptionOrderBy>(
"order_by", TaskServiceOptionOrderBy.asc),
TaskServiceOption<TaskServiceOptionFilterBy>("filter_by",
[TaskServiceOptionFilterBy.done, TaskServiceOptionFilterBy.due_date]),
TaskServiceOption<TaskServiceOptionFilterValue>("filter_value",
[TaskServiceOptionFilterValue.enum_false, '1970-01-01T00:00:00.000Z']),
TaskServiceOption<TaskServiceOptionFilterComparator>("filter_comparator", [
TaskServiceOptionFilterComparator.equals,
TaskServiceOptionFilterComparator.greater
]),
TaskServiceOption<TaskServiceOptionFilterConcat>(
"filter_concat", TaskServiceOptionFilterConcat.and),
];
class TaskServiceOptions {
List<TaskServiceOption>? options;
List<TaskServiceOption> options = [];
TaskServiceOptions({this.options}) {
if (this.options == null)
options = [
TaskServiceOption<TaskServiceOptionSortBy>("sort_by",
[TaskServiceOptionSortBy.due_date, TaskServiceOptionSortBy.id]),
TaskServiceOption<TaskServiceOptionOrderBy>(
"order_by", TaskServiceOptionOrderBy.asc),
TaskServiceOption<TaskServiceOptionFilterBy>("filter_by", [
TaskServiceOptionFilterBy.done,
TaskServiceOptionFilterBy.due_date
]),
TaskServiceOption<TaskServiceOptionFilterValue>("filter_value", [
TaskServiceOptionFilterValue.enum_false,
'0001-01-02T00:00:00.000Z'
]),
TaskServiceOption<TaskServiceOptionFilterComparator>(
"filter_comparator", [
TaskServiceOptionFilterComparator.equals,
TaskServiceOptionFilterComparator.greater
]),
TaskServiceOption<TaskServiceOptionFilterConcat>(
"filter_concat", TaskServiceOptionFilterConcat.and),
];
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);
if (index > -1) {
options.removeAt(index);
} else {
index = options.length;
}
options.insert(index, custom_option);
}
}
}
void setOption(TaskServiceOption option, dynamic value) {
options?.firstWhere((element) => element.name == option.name).value = value;
}
String getOptions() {
String result = '';
if (options == null) return '';
for (TaskServiceOption option in options!) {
Map<String, List<String>> getOptions() {
Map<String, List<String>> queryparams = {};
for (TaskServiceOption option in options) {
dynamic value = option.getValue();
if (value is List) {
for (dynamic valueEntry in value) {
result += '&' + option.name + '[]=' + valueEntry;
}
queryparams[option.name + "[]"] = value as List<String>;
//for (dynamic valueEntry in value) {
// result += '&' + option.name + '[]=' + valueEntry;
//}
} else {
result += '&' + option.name + '=' + value;
queryparams[option.name] = [value as String];
//result += '&' + option.name + '[]=' + value;
}
}
if (result.startsWith('&')) result.substring(1);
return result;
//if (result.startsWith('&')) result = result.substring(1);
//result = "?" + result;
return queryparams;
}
}
abstract class NamespaceService {
Future<List<Namespace>?> getAll();
abstract class ProjectService {
Future<List<Project>?> getAll();
Future<Namespace?> get(int namespaceId);
Future<Namespace?> create(Namespace ns);
Future<Namespace?> update(Namespace ns);
Future delete(int namespaceId);
}
abstract class ListService {
Future<List<TaskList>?> getAll();
Future<TaskList?> get(int listId);
Future<List<TaskList>?> getByNamespace(int namespaceId);
Future<TaskList?> create(int namespaceId, TaskList tl);
Future<TaskList?> update(TaskList tl);
Future delete(int listId);
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);
//Future<String?> getDefaultList();
//void setDefaultList(int? listId);
}
abstract class TaskService {
@ -167,7 +160,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);
@ -197,6 +190,9 @@ abstract class UserService {
Future<UserTokenPair?> register(String username, email, password);
Future<User?> getCurrentUser();
Future<UserSettings?> setCurrentUserSettings(UserSettings userSettings);
Future<String?> getToken();
}
abstract class LabelService {
@ -235,21 +231,20 @@ class SettingsManager {
"get-version-notifications": "1",
"workmanager-duration": "0",
"recent-servers": "[\"https://try.vikunja.io\"]",
"theme_mode": "system",
"landing-page-due-date-tasks": "1"
};
void applydefaults() {
defaults.forEach((key, value) {
_storage.containsKey(key: key).then((is_created) async {
if (!is_created) {
print("iscreated $is_created");
print("default not set, writing $value to $key");
_storage.containsKey(key: key).then((isCreated) async {
if (!isCreated) {
await _storage.write(key: key, value: value);
}
});
});
}
SettingsManager(this._storage) {
applydefaults();
}
@ -257,28 +252,45 @@ class SettingsManager {
Future<String?> getIgnoreCertificates() {
return _storage.read(key: "ignore-certificates");
}
void setIgnoreCertificates(bool value) {
_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");
}
void setVersionNotifications(bool value) {
_storage.write(key: "get-version-notifications", value: value ? "1" : "0");
}
Future<Duration> getWorkmanagerDuration() {
return _storage.read(key: "workmanager-duration").then((value) => Duration(minutes: int.parse(value ?? "0")));
}
Future<void> setWorkmanagerDuration(Duration duration) {
return _storage.write(key: "workmanager-duration", value: duration.inMinutes.toString());
return _storage
.read(key: "workmanager-duration")
.then((value) => Duration(minutes: int.parse(value ?? "0")));
}
Future<List<String>?> getPastServers() {
return _storage.read(key: "recent-servers").then((value) => (jsonDecode(value!) as List<dynamic>).cast<String>());
Future<void> setWorkmanagerDuration(Duration duration) {
return _storage.write(
key: "workmanager-duration", value: duration.inMinutes.toString());
}
Future<List<String>?> getPastServers() async {
String jsonString = await _storage.read(key: "recent-servers") ?? "[]";
List<dynamic> server = jsonDecode(jsonString);
return server.map((e) => e as String).toList();
}
Future<void> setPastServers(List<String>? server) {
@ -287,4 +299,35 @@ class SettingsManager {
return _storage.write(key: "recent-servers", value: jsonEncode(server));
}
Future<FlutterThemeMode> getThemeMode() async {
String? theme_mode = await _storage.read(key: "theme_mode");
if (theme_mode == null) setThemeMode(FlutterThemeMode.system);
switch (theme_mode) {
case "system":
return FlutterThemeMode.system;
case "light":
return FlutterThemeMode.light;
case "dark":
return FlutterThemeMode.dark;
case "materialYouLight":
return FlutterThemeMode.materialYouLight;
case "materialYouDark":
return FlutterThemeMode.materialYouDark;
default:
return FlutterThemeMode.system;
}
}
Future<void> setThemeMode(FlutterThemeMode newMode) async {
await _storage.write(
key: "theme_mode", value: newMode.toString().split('.').last);
}
}
enum FlutterThemeMode {
system,
light,
dark,
materialYouLight,
materialYouDark,
}

View File

@ -14,7 +14,6 @@ class ListProvider with ChangeNotifier {
List<Task> _tasks = [];
List<Bucket> _buckets = [];
bool get taskDragging => _taskDragging;
set taskDragging(bool value) {
@ -38,7 +37,6 @@ class ListProvider with ChangeNotifier {
List<Bucket> get buckets => _buckets;
PageStatus _pageStatus = PageStatus.built;
PageStatus get pageStatus => _pageStatus;
@ -49,7 +47,11 @@ class ListProvider with ChangeNotifier {
notifyListeners();
}
Future<void> loadTasks({required BuildContext context, required int listId, int page = 1, bool displayDoneTasks = true}) {
Future<void> loadTasks(
{required BuildContext context,
required int listId,
int page = 1,
bool displayDoneTasks = true}) {
_tasks = [];
notifyListeners();
@ -59,12 +61,14 @@ class ListProvider with ChangeNotifier {
"page": [page.toString()]
};
if(!displayDoneTasks) {
if (!displayDoneTasks) {
queryParams.addAll({
"filter_by": ["done"],
"filter_value": ["false"]
});
}
return Future.value();
/*
return VikunjaGlobal.of(context).taskService.getAllByList(listId, queryParams).then((response) {
if(response == null) {
pageStatus = PageStatus.error;
@ -75,10 +79,11 @@ class ListProvider with ChangeNotifier {
}
_tasks.addAll(response.body);
pageStatus = PageStatus.success;
});
});*/
}
Future<void> loadBuckets({required BuildContext context, required int listId, int page = 1}) {
Future<void> loadBuckets(
{required BuildContext context, required int listId, int page = 1}) {
_buckets = [];
pageStatus = PageStatus.loading;
notifyListeners();
@ -87,8 +92,11 @@ class ListProvider with ChangeNotifier {
"page": [page.toString()]
};
return VikunjaGlobal.of(context).bucketService.getAllByList(listId, queryParams).then((response) {
if(response == null) {
return VikunjaGlobal.of(context)
.bucketService
.getAllByList(listId, queryParams)
.then((response) {
if (response == null) {
pageStatus = PageStatus.error;
return;
}
@ -102,7 +110,9 @@ class ListProvider with ChangeNotifier {
}
Future<void> addTaskByTitle(
{required BuildContext context, required String title, required int listId}) async{
{required BuildContext context,
required String title,
required int listId}) async {
final globalState = VikunjaGlobal.of(context);
if (globalState.currentUser == null) {
return;
@ -112,18 +122,20 @@ class ListProvider with ChangeNotifier {
title: title,
createdBy: globalState.currentUser!,
done: false,
listId: listId,
projectId: listId,
);
pageStatus = PageStatus.loading;
return globalState.taskService.add(listId, newTask).then((task) {
if(task != null)
_tasks.insert(0, task);
if (task != null) _tasks.insert(0, task);
pageStatus = PageStatus.success;
});
}
Future<void> addTask({required BuildContext context, required Task newTask, required int listId}) {
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();
@ -133,107 +145,129 @@ class ListProvider with ChangeNotifier {
pageStatus = PageStatus.error;
return;
}
if (_tasks.isNotEmpty)
_tasks.insert(0, task);
if (_tasks.isNotEmpty) _tasks.insert(0, task);
if (_buckets.isNotEmpty) {
final bucket = _buckets[_buckets.indexWhere((b) => task.bucketId == b.id)];
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}) {
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;
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;
}
}));
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}) {
Future<void> addBucket(
{required BuildContext context,
required Bucket newBucket,
required int listId}) {
notifyListeners();
return VikunjaGlobal.of(context).bucketService.add(listId, newBucket)
return VikunjaGlobal.of(context)
.bucketService
.add(listId, newBucket)
.then((bucket) {
if(bucket == null)
return null;
_buckets.add(bucket);
notifyListeners();
});
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)
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();
});
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> 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");
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--;
if (sameBucket &&
index >
_buckets[newBucketIndex].tasks.indexWhere((t) => t.id == task?.id))
index--;
_buckets[_buckets.indexWhere((b) => b.id == task?.bucketId)].tasks.remove(task);
_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;
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 (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) {
@ -242,8 +276,12 @@ class ListProvider with ChangeNotifier {
_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!));
_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();
}

View File

@ -0,0 +1,290 @@
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();
}
}

View File

@ -17,6 +17,13 @@ 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 +40,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(

View File

@ -11,9 +11,12 @@ 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),
);
}
}

View File

@ -30,4 +30,5 @@ const vStandardVerticalPadding = EdgeInsets.symmetric(vertical: 5.0);
const vStandardHorizontalPadding = EdgeInsets.symmetric(horizontal: 5.0);
const vStandardPadding = EdgeInsets.symmetric(horizontal: 5.0, vertical: 5.0);
var vDateFormatLong = DateFormat("EEEE, MMMM d, yyyy 'at' H:mm");
var vDateFormatLong = DateFormat("EEEE, MMMM d, yyyy 'at' H:mm");
var vDateFormatShort = DateFormat("d MMM yyyy, H:mm");

View File

@ -4,10 +4,24 @@ import 'package:flutter/material.dart';
import 'package:vikunja_app/theme/constants.dart';
ThemeData buildVikunjaTheme() => _buildVikunjaTheme(ThemeData.light());
ThemeData buildVikunjaDarkTheme() => _buildVikunjaTheme(ThemeData.dark());
ThemeData buildVikunjaDarkTheme() =>
_buildVikunjaTheme(ThemeData.dark(), isDark: true);
ThemeData _buildVikunjaTheme(ThemeData base) {
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,14 +45,25 @@ ThemeData _buildVikunjaTheme(ThemeData base) {
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(
base.bottomNavigationBarTheme.backgroundColor
?? base.scaffoldBackgroundColor
);
return _hslColor.withLightness(max(_hslColor.lightness - 0.03, 0)).toColor();
base.bottomNavigationBarTheme.backgroundColor ??
base.scaffoldBackgroundColor);
return _hslColor
.withLightness(max(_hslColor.lightness - 0.03, 0))
.toColor();
}(),
),
);

View File

@ -18,4 +18,4 @@ double calculateItemPosition({double? positionBefore, double? positionAfter}) {
// in the middle (positionBefore != null && positionAfter != null)
return (positionBefore! + positionAfter!) / 2;
}
}

View File

@ -46,4 +46,3 @@ CheckboxStatistics getCheckboxStatistics(String text) {
checked: checkboxes.checked.length,
);
}

View File

@ -1,21 +1,20 @@
String durationToHumanReadable(Duration dur) {
var durString = '';
if(dur.inDays.abs() > 1)
if (dur.inDays.abs() > 1)
durString = dur.inDays.abs().toString() + " days";
else if(dur.inDays.abs() == 1)
else if (dur.inDays.abs() == 1)
durString = dur.inDays.abs().toString() + " day";
else if(dur.inHours.abs() > 1)
else if (dur.inHours.abs() > 1)
durString = dur.inHours.abs().toString() + " hours";
else if(dur.inHours.abs() == 1)
else if (dur.inHours.abs() == 1)
durString = dur.inHours.abs().toString() + " hour";
else if(dur.inMinutes.abs() > 1)
else if (dur.inMinutes.abs() > 1)
durString = dur.inMinutes.abs().toString() + " minutes";
else if(dur.inMinutes.abs() == 1)
else if (dur.inMinutes.abs() == 1)
durString = dur.inMinutes.abs().toString() + " minute";
else durString = "less than a minute";
else
durString = "less than a minute";
if (dur.isNegative) return durString + " ago";
return "in " + durString;
}
}

37
lib/utils/priority.dart Normal file
View File

@ -0,0 +1,37 @@
priorityToString(int? priority) {
switch (priority) {
case 0:
return 'Unset';
case 1:
return 'Low';
case 2:
return 'Medium';
case 3:
return 'High';
case 4:
return 'Urgent';
case 5:
return 'DO NOW';
default:
return "";
}
}
// FIXME: Move the following two functions to an extra class or type.
priorityFromString(String? priority) {
switch (priority) {
case 'Low':
return 1;
case 'Medium':
return 2;
case 'High':
return 3;
case 'Urgent':
return 4;
case 'DO NOW':
return 5;
default:
// unset
return 0;
}
}

View File

@ -66,6 +66,6 @@ Duration? getDurationFromType(String? value, String? type) {
case 'Years':
return Duration(days: val * 365);
}
return null;
}

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,7 @@
name: vikunja_app
description: Vikunja as Flutter cross platform app
version: 0.0.26-alpha
version: 0.1.5-beta
environment:
sdk: ">=2.18.0 <3.0.0"
@ -9,35 +9,44 @@ environment:
dependencies:
flutter:
sdk: flutter
cupertino_icons: ^1.0.5
http: ^0.13.5
cupertino_icons: ^1.0.6
http: ^1.2.1
after_layout: ^1.2.0
intl: ^0.17.0
flutter_local_notifications: ^9.8.0+1
rxdart: ^0.27.5
flutter_timezone: ^1.0.4
flutter_secure_storage: ^8.0.0
intl: ^0.19.0
flutter_local_notifications: ^17.0.0
rxdart: ^0.27.7
flutter_timezone: ^1.0.8
flutter_secure_storage: ^9.0.0
datetime_picker_formfield: ^2.0.1
flutter_typeahead: ^4.0.0
build: ^2.3.0
json_serializable: ^6.3.1
petitparser: ^5.0.0
provider: ^6.0.3
webview_flutter: ^3.0.4
flutter_typeahead: ^5.2.0
build: ^2.4.1
json_serializable: ^6.7.1
petitparser: ^6.0.2
provider: ^6.1.2
webview_flutter: ^4.7.0
flutter_colorpicker: ^1.0.3
flutter_keyboard_visibility: ^5.3.0
dotted_border: ^2.0.0+2
package_info_plus: ^3.0.2
url_launcher: ^6.1.7
workmanager: ^0.5.1
permission_handler: ^10.2.0
flutter_keyboard_visibility: ^6.0.0
dotted_border: ^2.1.0
url_launcher: ^6.2.5
workmanager: ^0.5.2
permission_handler: ^11.3.0
dynamic_color: ^1.7.0
flutter_widget_from_html: ^0.14.11
flutter_downloader: ^1.11.6
meta: ^1.11.0
timezone: ^0.9.2
json_annotation: ^4.8.1
collection: ^1.18.0
cupertino_http: ^1.4.0
cronet_http: ^1.2.0
package_info_plus: any
dev_dependencies:
flutter_test:
sdk: flutter
version: any
test: ^1.21.1
flutter_launcher_icons: ^0.10.0
test: ^1.24.9
flutter_launcher_icons: ^0.13.1
flutter_icons:
image_path: "assets/vikunja_logo.png"

View File

@ -7,7 +7,8 @@ import 'package:vikunja_app/models/user.dart';
void main() {
test('label color from json', () {
final String json = '{"TaskID": 123,"id": 1,"title": "this","description": "","hex_color": "e8e8e8","created_by":{"id": 1,"username": "user","email": "test@example.com","created": 1537855131,"updated": 1545233325},"created": 1552903790,"updated": 1552903790}';
final String json =
'{"TaskID": 123,"id": 1,"title": "this","description": "","hex_color": "e8e8e8","created_by":{"id": 1,"username": "user","email": "test@example.com","created": 1537855131,"updated": 1545233325},"created": 1552903790,"updated": 1552903790}';
final JsonDecoder _decoder = new JsonDecoder();
Label label = Label.fromJson(_decoder.convert(json));
@ -15,9 +16,14 @@ void main() {
});
test('hex color string from object', () {
Label label = Label(id: 1, title: '', color: Color(0xFFe8e8e8), createdBy: User(id: 0, username: ''));
Label label = Label(
id: 1,
title: '',
color: Color(0xFFe8e8e8),
createdBy: User(id: 0, username: ''));
var json = label.toJSON();
expect(json.toString(), '{id: 1, title: , description: null, hex_color: e8e8e8, created_by: {id: 0, username: ,}, updated: null, created: null}');
expect(json.toString(),
'{id: 1, title: , description: null, hex_color: e8e8e8, created_by: {id: 0, username: ,}, updated: null, created: null}');
});
}
}

View File

@ -5,7 +5,8 @@ import 'package:test/test.dart';
void main() {
test('Check encoding with all values set', () {
final String json = '{"id": 1,"text": "test","description": "Lorem Ipsum","done": true,"dueDate": 1543834800,"reminderDates": [1543834800,1544612400],"repeatAfter": 3600,"parentTaskID": 0,"priority": 100,"startDate": 1543834800,"endDate": 1543835000,"assignees": null,"labels": null,"subtasks": null,"created": 1542465818,"updated": 1552771527,"createdBy": {"id": 1,"username": "user","email": "test@example.com","created": 1537855131,"updated": 1545233325}}';
final String json =
'{"id": 1,"text": "test","description": "Lorem Ipsum","done": true,"dueDate": 1543834800,"reminderDates": [1543834800,1544612400],"repeatAfter": 3600,"parentTaskID": 0,"priority": 100,"startDate": 1543834800,"endDate": 1543835000,"assignees": null,"labels": null,"subtasks": null,"created": 1542465818,"updated": 1552771527,"createdBy": {"id": 1,"username": "user","email": "test@example.com","created": 1537855131,"updated": 1545233325}}';
final JsonDecoder _decoder = new JsonDecoder();
final task = Task.fromJson(_decoder.convert(json));
@ -17,19 +18,25 @@ void main() {
DateTime.fromMillisecondsSinceEpoch(1543834800 * 1000),
DateTime.fromMillisecondsSinceEpoch(1544612400 * 1000),
]);
expect(task.dueDate, DateTime.fromMillisecondsSinceEpoch(1543834800 * 1000));
expect(
task.dueDate, DateTime.fromMillisecondsSinceEpoch(1543834800 * 1000));
expect(task.repeatAfter, Duration(seconds: 3600));
expect(task.parentTaskId, 0);
expect(task.priority, 100);
expect(task.startDate, DateTime.fromMillisecondsSinceEpoch(1543834800 * 1000));
expect(task.endDate, DateTime.fromMillisecondsSinceEpoch(1543835000 * 1000));
expect(
task.startDate, DateTime.fromMillisecondsSinceEpoch(1543834800 * 1000));
expect(
task.endDate, DateTime.fromMillisecondsSinceEpoch(1543835000 * 1000));
expect(task.labels, null);
expect(task.subtasks, null);
expect(task.created, DateTime.fromMillisecondsSinceEpoch(1542465818 * 1000));
expect(task.updated, DateTime.fromMillisecondsSinceEpoch(1552771527 * 1000));
expect(
task.created, DateTime.fromMillisecondsSinceEpoch(1542465818 * 1000));
expect(
task.updated, DateTime.fromMillisecondsSinceEpoch(1552771527 * 1000));
});
test('Check encoding with reminder dates as null', () {
final String json = '{"id": 1,"text": "test","description": "Lorem Ipsum","done": true,"dueDate": 1543834800,"reminderDates": null,"repeatAfter": 3600,"parentTaskID": 0,"priority": 100,"startDate": 1543834800,"endDate": 1543835000,"assignees": null,"labels": null,"subtasks": null,"created": 1542465818,"updated": 1552771527,"createdBy": {"id": 1,"username": "user","email": "test@example.com","created": 1537855131,"updated": 1545233325}}';
final String json =
'{"id": 1,"text": "test","description": "Lorem Ipsum","done": true,"dueDate": 1543834800,"reminderDates": null,"repeatAfter": 3600,"parentTaskID": 0,"priority": 100,"startDate": 1543834800,"endDate": 1543835000,"assignees": null,"labels": null,"subtasks": null,"created": 1542465818,"updated": 1552771527,"createdBy": {"id": 1,"username": "user","email": "test@example.com","created": 1537855131,"updated": 1545233325}}';
final JsonDecoder _decoder = new JsonDecoder();
final task = Task.fromJson(_decoder.convert(json));
@ -38,15 +45,20 @@ void main() {
expect(task.description, 'Lorem Ipsum');
expect(task.done, true);
expect(task.reminderDates, null);
expect(task.dueDate, DateTime.fromMillisecondsSinceEpoch(1543834800 * 1000));
expect(
task.dueDate, DateTime.fromMillisecondsSinceEpoch(1543834800 * 1000));
expect(task.repeatAfter, Duration(seconds: 3600));
expect(task.parentTaskId, 0);
expect(task.priority, 100);
expect(task.startDate, DateTime.fromMillisecondsSinceEpoch(1543834800 * 1000));
expect(task.endDate, DateTime.fromMillisecondsSinceEpoch(1543835000 * 1000));
expect(
task.startDate, DateTime.fromMillisecondsSinceEpoch(1543834800 * 1000));
expect(
task.endDate, DateTime.fromMillisecondsSinceEpoch(1543835000 * 1000));
expect(task.labels, null);
expect(task.subtasks, null);
expect(task.created, DateTime.fromMillisecondsSinceEpoch(1542465818 * 1000));
expect(task.updated, DateTime.fromMillisecondsSinceEpoch(1552771527 * 1000));
expect(
task.created, DateTime.fromMillisecondsSinceEpoch(1542465818 * 1000));
expect(
task.updated, DateTime.fromMillisecondsSinceEpoch(1552771527 * 1000));
});
}
}

BIN
web/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 917 B

BIN
web/icons/Icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

BIN
web/icons/Icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

59
web/index.html Normal file
View File

@ -0,0 +1,59 @@
<!DOCTYPE html>
<html>
<head>
<!--
If you are serving your web app in a path other than the root, change the
href value below to reflect the base path you are serving from.
The path provided below has to start and end with a slash "/" in order for
it to work correctly.
For more details:
* https://developer.mozilla.org/en-US/docs/Web/HTML/Element/base
This is a placeholder for base href that will be replaced by the value of
the `--base-href` argument provided to `flutter build`.
-->
<base href="$FLUTTER_BASE_HREF">
<meta charset="UTF-8">
<meta content="IE=Edge" http-equiv="X-UA-Compatible">
<meta name="description" content="A new Flutter project.">
<!-- iOS meta tags & icons -->
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="apple-mobile-web-app-title" content="vikunja_app">
<link rel="apple-touch-icon" href="icons/Icon-192.png">
<!-- Favicon -->
<link rel="icon" type="image/png" href="favicon.png"/>
<title>vikunja_app</title>
<link rel="manifest" href="manifest.json">
<script>
// The value below is injected by flutter build, do not touch.
const serviceWorkerVersion = null;
</script>
<!-- This script adds the flutter initialization JS code -->
<script src="flutter.js" defer></script>
</head>
<body>
<script>
window.addEventListener('load', function(ev) {
// Download main.dart.js
_flutter.loader.loadEntrypoint({
serviceWorker: {
serviceWorkerVersion: serviceWorkerVersion,
},
onEntrypointLoaded: function(engineInitializer) {
engineInitializer.initializeEngine().then(function(appRunner) {
appRunner.runApp();
});
}
});
});
</script>
</body>
</html>

35
web/manifest.json Normal file
View File

@ -0,0 +1,35 @@
{
"name": "vikunja_app",
"short_name": "vikunja_app",
"start_url": ".",
"display": "standalone",
"background_color": "#0175C2",
"theme_color": "#0175C2",
"description": "A new Flutter project.",
"orientation": "portrait-primary",
"prefer_related_applications": false,
"icons": [
{
"src": "icons/Icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "icons/Icon-512.png",
"sizes": "512x512",
"type": "image/png"
},
{
"src": "icons/Icon-maskable-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "icons/Icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}