merged Benimautner/vikunja_app into go-vikunja/app

This commit is contained in:
benimautner 2022-04-20 22:57:21 +02:00
commit dd42d18612
53 changed files with 1920 additions and 223 deletions

53
.github/workflows/build.yml vendored Normal file
View File

@ -0,0 +1,53 @@
name: Flutter Build
on:
push:
branches:
- main
pull_request:
jobs:
build-app:
name: Build
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v1
- name: Setup Java
uses: actions/setup-java@v1
with:
java-version: '12.x'
- name: Setup Flutter
uses: subosito/flutter-action@v1
with:
channel: stable
- name: Cache pub dependencies
uses: actions/cache@v2
with:
path: ${{ env.FLUTTER_HOME }}/.pub-cache
key: ${{ runner.os }}-pub-${{ hashFiles('**/pubspec.lock') }}
restore-keys: ${{ runner.os }}-pub-
- name: Download pub dependencies
run: flutter pub get
- name: Download Android keystore
id: android_keystore
uses: timheuer/base64-to-file@v1.0.3
with:
fileName: key.jks
encodedString: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
- name: Create key.properties
run: |
echo "storeFile=${{ steps.android_keystore.outputs.filePath }}" > android/key.properties
echo "storePassword=${{ secrets.ANDROID_KEYSTORE_PASSWORD }}" >> android/key.properties
echo "keyPassword=${{ secrets.ANDROID_KEY_PASSWORD }}" >> android/key.properties
echo "keyAlias=${{ secrets.ANDROID_KEY_ALIAS }}" >> android/key.properties
- name: Build Debug Build
run: flutter build apk --debug

70
.github/workflows/flutter-release.yml vendored Normal file
View File

@ -0,0 +1,70 @@
# Based on https://medium.com/flutter-community/automating-publishing-your-flutter-apps-to-google-play-using-github-actions-2f67ac582032
name: Flutter release
on:
push:
branches:
- main
release:
types: [published]
jobs:
release:
name: Build
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v1
- name: Setup Java
uses: actions/setup-java@v1
with:
java-version: '12.x'
- name: Setup Flutter
uses: subosito/flutter-action@v1
with:
channel: stable
- name: Flutter version
run: flutter --version
- name: Cache pub dependencies
uses: actions/cache@v2
with:
path: ${{ env.FLUTTER_HOME }}/.pub-cache
key: ${{ runner.os }}-pub-${{ hashFiles('**/pubspec.lock') }}
restore-keys: ${{ runner.os }}-pub-
- name: Download pub dependencies
run: flutter pub get
- name: Download Android keystore
id: android_keystore
uses: timheuer/base64-to-file@v1.0.3
with:
fileName: key.jks
encodedString: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
- name: Create key.properties
run: |
echo "storeFile=${{ steps.android_keystore.outputs.filePath }}" > android/key.properties
echo "storePassword=${{ secrets.ANDROID_KEYSTORE_PASSWORD }}" >> android/key.properties
echo "keyPassword=${{ secrets.ANDROID_KEY_PASSWORD }}" >> android/key.properties
echo "keyAlias=${{ secrets.ANDROID_KEY_ALIAS }}" >> android/key.properties
- name: Build Android App Bundle
run: flutter build appbundle
- name: Build Android APK
run: flutter build apk
- name: Upload build artifacts
uses: actions/upload-artifact@v2
with:
name: app-release-bundle
path: |
build/app/outputs/bundle/release/app-release.aab
build/app/outputs/flutter-apk/app-release.apk

21
.gitignore vendored
View File

@ -75,3 +75,24 @@ ios/fastlane/README.md
ios/fastlane/report.xml ios/fastlane/report.xml
ios/Runner.ipa ios/Runner.ipa
ios/Runner.app.dSYM.zip ios/Runner.app.dSYM.zip
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Web related
lib/generated_plugin_registrant.dart
# Symbolication related
app.*.symbols
# Obfuscation related
app.*.map.json
# Android Studio will place build artifacts here
/android/app/debug
/android/app/profile
/android/app/release
# VS Code
.vscode/settings.json

10
.vscode/launch.json vendored Normal file
View File

@ -0,0 +1,10 @@
{
"configurations": [
{
"name": "Flutter",
"request": "launch",
"type": "dart",
"flutterMode": "debug"
}
]
}

View File

@ -1,4 +1,13 @@
# Vikunja Cross-Platform app # Vikunja Cross-Plattform app
## Vikunja as Flutter cross-platform app.
I have started maintaining the flutter app. It is currently in a very early stage, but already somehow usable. If you have feature requests or issues, please let me know on the issues tab. [![Build Status](https://drone.kolaente.de/api/badges/vikunja/app/status.svg)](https://drone.kolaente.de/vikunja/app)
Development for IOS has been put on hold as I do not have the resources to develop for IOS. [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
[![Download](https://img.shields.io/badge/download-v0.1-brightgreen.svg)](https://storage.kolaente.de/minio/vikunja-app/)
[![TestFlight Beta](https://img.shields.io/badge/TestFlight-Beta-026CBB)](https://testflight.apple.com/join/KxOaAraq)
Vikunja as Flutter cross platform app.
## TODO
- Move all api responses to Response type
- Save loaded tasks for a while to optimize data usage
-

9
android/.gitignore vendored
View File

@ -8,3 +8,12 @@
/build /build
/captures /captures
GeneratedPluginRegistrant.java GeneratedPluginRegistrant.java
gradle-wrapper.jar
/.gradle
/captures/
/gradlew
/gradlew.bat
# Remember to never publicly share your keystore.
# See https://flutter.dev/docs/deployment/android#reference-the-keystore-from-the-app
key.properties

View File

@ -25,6 +25,12 @@ apply plugin: 'com.android.application'
apply plugin: 'kotlin-android' apply plugin: 'kotlin-android'
apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle"
def keystoreProperties = new Properties()
def keystorePropertiesFile = rootProject.file('key.properties')
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
}
android { android {
compileSdkVersion 31 compileSdkVersion 31
@ -40,11 +46,23 @@ android {
applicationId "io.vikunja.app" applicationId "io.vikunja.app"
minSdkVersion 19 minSdkVersion 19
targetSdkVersion 31 targetSdkVersion 31
testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
versionCode flutterVersionCode.toInteger() versionCode flutterVersionCode.toInteger()
versionName flutterVersionName versionName flutterVersionName
testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
} }
/*
signingConfigs {
release {
keyAlias keystoreProperties['keyAlias']
keyPassword keystoreProperties['keyPassword']
storeFile file(keystoreProperties['storeFile'])
storePassword keystoreProperties['storePassword']
}
}
*/
flavorDimensions "deploy" flavorDimensions "deploy"
productFlavors { productFlavors {
@ -74,7 +92,10 @@ android {
release { release {
// TODO: Add your own signing config for the release build. // TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works. // Signing with the debug keys for now, so `flutter run --release` works.
signingConfig signingConfigs.debug //signingConfig signingConfigs.release
}
debug {
//signingConfig signingConfigs.debug
} }
} }
} }

View File

@ -26,6 +26,23 @@
android:hardwareAccelerated="true" android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize" android:windowSoftInputMode="adjustResize"
android:exported="true"> android:exported="true">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<!-- Displays an Android View that continues showing the launch screen
Drawable until Flutter paints its first frame, then this splash
screen fades out. A splash screen is useful to avoid any visual
gap between the end of Android's launch screen and the painting of
Flutter's first frame. -->
<meta-data
android:name="io.flutter.embedding.android.SplashScreenDrawable"
android:resource="@drawable/launch_background"
/>
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN"/> <action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/> <category android:name="android.intent.category.LAUNCHER"/>

View File

@ -2,4 +2,5 @@ package io.vikunja.flutteringvikunja
import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.android.FlutterActivity
class MainActivity : FlutterActivity() class MainActivity : FlutterActivity() {
}

View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>

View File

@ -1,8 +1,18 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar"> <style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when <!-- Show a splash screen on the activity. Automatically removed when
Flutter draws its first frame --> Flutter draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item> <item name="android:windowBackground">@drawable/launch_background</item>
</style> </style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources> </resources>

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

View File

@ -1,6 +1,9 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:core';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:vikunja_app/api/response.dart';
import 'package:vikunja_app/components/string_extension.dart';
class Client { class Client {
final JsonDecoder _decoder = new JsonDecoder(); final JsonDecoder _decoder = new JsonDecoder();
@ -25,34 +28,54 @@ class Client {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
}; };
// TODO: use Uri properly Future<Response> get(String url,
Future<dynamic> get(String url) { [Map<String, List<String>> queryParameters]) {
// TODO: This could be moved to a seperate function
var uri = Uri.parse('${this.base}$url');
// Because these are all final values, we can't just add the queryParameters and must instead build a new Uri Object every time this method is called.
var newUri = Uri(
scheme: uri.scheme,
userInfo: uri.userInfo,
host: uri.host,
port: uri.port,
path: uri.path,
query: uri.query,
queryParameters: queryParameters,
// Because dart takes a Map<String, String> here, it is only possible to sort by one parameter while the api supports n parameters.
fragment: uri.fragment);
return http.get(newUri, headers: _headers).then(_handleResponse);
}
Future<Response> delete(String url) {
return http return http
.get(Uri.parse('${this.base}$url'), headers: _headers) .delete(
'${this.base}$url'.toUri(),
headers: _headers,
)
.then(_handleResponse); .then(_handleResponse);
} }
Future<dynamic> delete(String url) { Future<Response> post(String url, {dynamic body}) {
return http return http
.delete(Uri.parse('${this.base}$url'), headers: _headers) .post(
'${this.base}$url'.toUri(),
headers: _headers,
body: _encoder.convert(body),
)
.then(_handleResponse); .then(_handleResponse);
} }
Future<dynamic> post(String url, {dynamic body}) { Future<Response> put(String url, {dynamic body}) {
return http return http
.post(Uri.parse('${this.base}$url'), .put(
headers: _headers, body: _encoder.convert(body)) '${this.base}$url'.toUri(),
headers: _headers,
body: _encoder.convert(body),
)
.then(_handleResponse); .then(_handleResponse);
} }
Future<dynamic> put(String url, {dynamic body}) { Response _handleResponse(http.Response response) {
return http
.put(Uri.parse('${this.base}$url'),
headers: _headers, body: _encoder.convert(body))
.then(_handleResponse);
}
dynamic _handleResponse(http.Response response) {
if (response.statusCode < 200 || if (response.statusCode < 200 ||
response.statusCode >= 400 || response.statusCode >= 400 ||
json == null) { json == null) {
@ -66,12 +89,14 @@ class Client {
throw new ApiException( throw new ApiException(
response.statusCode, response.request.url.toString()); response.statusCode, response.request.url.toString());
} }
return _decoder.convert(response.body); return Response(
_decoder.convert(response.body), response.statusCode, response.headers);
} }
} }
class InvalidRequestApiException extends ApiException { class InvalidRequestApiException extends ApiException {
final String message; final String message;
InvalidRequestApiException(int errorCode, String path, this.message) InvalidRequestApiException(int errorCode, String path, this.message)
: super(errorCode, path); : super(errorCode, path);
@ -84,6 +109,7 @@ class InvalidRequestApiException extends ApiException {
class ApiException implements Exception { class ApiException implements Exception {
final int errorCode; final int errorCode;
final String path; final String path;
ApiException(this.errorCode, this.path); ApiException(this.errorCode, this.path);
@override @override

32
lib/api/label_task.dart Normal file
View File

@ -0,0 +1,32 @@
import 'package:vikunja_app/api/client.dart';
import 'package:vikunja_app/api/service.dart';
import 'package:vikunja_app/models/label.dart';
import 'package:vikunja_app/models/labelTask.dart';
import 'package:vikunja_app/service/services.dart';
class LabelTaskAPIService extends APIService implements LabelTaskService {
LabelTaskAPIService(Client client) : super(client);
@override
Future<Label> create(LabelTask lt) async {
return client
.put('/tasks/${lt.task.id}/labels', body: lt.toJSON())
.then((result) => Label.fromJson(result.body));
}
@override
Future<Label> delete(LabelTask lt) async {
return client
.delete('/tasks/${lt.task.id}/labels/${lt.label.id}')
.then((result) => Label.fromJson(result.body));
}
@override
Future<List<Label>> getAll(LabelTask lt, {String query}) async {
String params =
query == '' ? null : '?s=' + Uri.encodeQueryComponent(query);
return client.get('/tasks/${lt.task.id}/labels$params').then(
(label) => convertList(label, (result) => Label.fromJson(result)));
}
}

View File

@ -0,0 +1,20 @@
import 'package:vikunja_app/api/client.dart';
import 'package:vikunja_app/api/service.dart';
import 'package:vikunja_app/models/label.dart';
import 'package:vikunja_app/models/labelTaskBulk.dart';
import 'package:vikunja_app/models/task.dart';
import 'package:vikunja_app/service/services.dart';
class LabelTaskBulkAPIService extends APIService
implements LabelTaskBulkService {
LabelTaskBulkAPIService(Client client) : super(client);
@override
Future<List<Label>> update(Task task, List<Label> labels) {
return client
.post('/tasks/${task.id}/labels/bulk',
body: LabelTaskBulk(labels: labels).toJSON())
.then((response) => convertList(
response.body['labels'], (result) => Label.fromJson(result)));
}
}

44
lib/api/labels.dart Normal file
View File

@ -0,0 +1,44 @@
import 'package:vikunja_app/api/client.dart';
import 'package:vikunja_app/api/service.dart';
import 'package:vikunja_app/models/label.dart';
import 'package:vikunja_app/service/services.dart';
class LabelAPIService extends APIService implements LabelService {
LabelAPIService(Client client) : super(client);
@override
Future<Label> create(Label label) {
return client
.put('/labels', body: label.toJSON())
.then((response) => Label.fromJson(response.body));
}
@override
Future<Label> delete(Label label) {
return client
.delete('/labels/${label.id}')
.then((response) => Label.fromJson(response.body));
}
@override
Future<Label> get(int labelID) {
return client
.get('/labels/$labelID')
.then((response) => 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(
(label) => convertList(label, (result) => Label.fromJson(result)));
}
@override
Future<Label> update(Label label) {
return client
.post('/labels/${label.id}', body: label)
.then((response) => Label.fromJson(response.body));
}
}

View File

@ -15,7 +15,7 @@ class ListAPIService extends APIService implements ListService {
Future<TaskList> create(namespaceId, TaskList tl) { Future<TaskList> create(namespaceId, TaskList tl) {
return client return client
.put('/namespaces/$namespaceId/lists', body: tl.toJSON()) .put('/namespaces/$namespaceId/lists', body: tl.toJSON())
.then((map) => TaskList.fromJson(map)); .then((response) => TaskList.fromJson(response.body));
} }
@override @override
@ -25,6 +25,7 @@ class ListAPIService extends APIService implements ListService {
@override @override
Future<TaskList> get(int listId) { Future<TaskList> get(int listId) {
/*
return client.get('/lists/$listId').then((listmap) { return client.get('/lists/$listId').then((listmap) {
return client.get('/lists/$listId/tasks').then((value) { return client.get('/lists/$listId/tasks').then((value) {
listmap["tasks"] = value; listmap["tasks"] = value;
@ -32,12 +33,25 @@ class ListAPIService extends APIService implements ListService {
}); });
} }
); );
*/
return client.get('/lists/$listId').then((response) {
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 @override
Future<List<TaskList>> getAll() { Future<List<TaskList>> getAll() {
return client.get('/lists').then( return client.get('/lists').then(
(list) => convertList(list, (result) => TaskList.fromJson(result))); (list) => convertList(list.body, (result) => TaskList.fromJson(result)));
} }
@override @override
@ -49,14 +63,14 @@ class ListAPIService extends APIService implements ListService {
return getAll().then((value) {value.removeWhere((element) => !element.isFavorite); return value;}); return getAll().then((value) {value.removeWhere((element) => !element.isFavorite); return value;});
} }
return client.get('/namespaces/$namespaceId/lists').then( return client.get('/namespaces/$namespaceId/lists').then(
(list) => convertList(list, (result) => TaskList.fromJson(result))); (list) => convertList(list.body, (result) => TaskList.fromJson(result)));
} }
@override @override
Future<TaskList> update(TaskList tl) { Future<TaskList> update(TaskList tl) {
return client return client
.post('/lists/${tl.id}', body: tl.toJSON()) .post('/lists/${tl.id}', body: tl.toJSON())
.then((map) => TaskList.fromJson(map)); .then((response) => TaskList.fromJson(response.body));
} }
@override @override

View File

@ -12,7 +12,7 @@ class NamespaceAPIService extends APIService implements NamespaceService {
Future<Namespace> create(Namespace ns) { Future<Namespace> create(Namespace ns) {
return client return client
.put('/namespaces', body: ns.toJSON()) .put('/namespaces', body: ns.toJSON())
.then((map) => Namespace.fromJson(map)); .then((response) => Namespace.fromJson(response.body));
} }
@override @override
@ -24,19 +24,19 @@ class NamespaceAPIService extends APIService implements NamespaceService {
Future<Namespace> get(int namespaceId) { Future<Namespace> get(int namespaceId) {
return client return client
.get('/namespaces/$namespaceId') .get('/namespaces/$namespaceId')
.then((map) => Namespace.fromJson(map)); .then((response) => Namespace.fromJson(response.body));
} }
@override @override
Future<List<Namespace>> getAll() { Future<List<Namespace>> getAll() {
return client.get('/namespaces').then( return client.get('/namespaces').then((response) =>
(list) => convertList(list, (result) => Namespace.fromJson(result))); convertList(response.body, (result) => Namespace.fromJson(result)));
} }
@override @override
Future<Namespace> update(Namespace ns) { Future<Namespace> update(Namespace ns) {
return client return client
.post('/namespaces/${ns.id}', body: ns.toJSON()) .post('/namespaces/${ns.id}', body: ns.toJSON())
.then((map) => Namespace.fromJson(map)); .then((response) => Namespace.fromJson(response.body));
} }
} }

9
lib/api/response.dart Normal file
View File

@ -0,0 +1,9 @@
// 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 Response {
Response(this.body, this.statusCode, this.headers);
final dynamic body;
final int statusCode;
final Map<String, String> headers;
}

View File

@ -1,6 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'package:vikunja_app/api/client.dart'; import 'package:vikunja_app/api/client.dart';
import 'package:vikunja_app/api/response.dart';
import 'package:vikunja_app/api/service.dart'; import 'package:vikunja_app/api/service.dart';
import 'package:vikunja_app/models/task.dart'; import 'package:vikunja_app/models/task.dart';
import 'package:vikunja_app/service/services.dart'; import 'package:vikunja_app/service/services.dart';
@ -12,11 +13,11 @@ class TaskAPIService extends APIService implements TaskService {
Future<Task> add(int listId, Task task) { Future<Task> add(int listId, Task task) {
return client return client
.put('/lists/$listId', body: task.toJSON()) .put('/lists/$listId', body: task.toJSON())
.then((map) => Task.fromJson(map)); .then((response) => Task.fromJson(response.body));
} }
@override @override
Future<List<Task>> get(int listId) { Future<Response> get(int listId) {
return client.get('/list/$listId/tasks'); return client.get('/list/$listId/tasks');
} }
@ -29,14 +30,24 @@ class TaskAPIService extends APIService implements TaskService {
Future<Task> update(Task task) { Future<Task> update(Task task) {
return client return client
.post('/tasks/${task.id}', body: task.toJSON()) .post('/tasks/${task.id}', body: task.toJSON())
.then((map) => Task.fromJson(map)); .then((response) => Task.fromJson(response.body));
} }
@override @override
Future<List<Task>> getAll() { Future<List<Task>> getAll() {
return client return client
.get('/tasks/all') .get('/tasks/all')
.then((value) => value.map<Task>((taskJson) => Task.fromJson(taskJson)).toList()); .then((value) => value.body.map<Task>((taskJson) => Task.fromJson(taskJson)).toList());
}
@override
Future<Response> getAllByList(int listId,
[Map<String, List<String>> queryParameters]) {
return client.get('/lists/$listId/tasks', queryParameters).then(
(response) => new Response(
convertList(response.body, (result) => Task.fromJson(result)),
response.statusCode,
response.headers));
} }
@override @override
@ -45,8 +56,12 @@ class TaskAPIService extends APIService implements TaskService {
return client return client
.get('/tasks/all?$optionString') .get('/tasks/all?$optionString')
.then((value) { .then((value) {
return value.map<Task>((taskJson) => Task.fromJson(taskJson)).toList(); return value.body.map<Task>((taskJson) => Task.fromJson(taskJson)).toList();
}); });
} }
@override
// TODO: implement maxPages
int get maxPages => maxPages;
} }

View File

@ -13,7 +13,7 @@ class UserAPIService extends APIService implements UserService {
var token = await client.post('/login', body: { var token = await client.post('/login', body: {
'username': username, 'username': username,
'password': password 'password': password
}).then((map) => map['token']); }).then((response) => response.body['token']);
return UserAPIService(Client(token, client.base)) return UserAPIService(Client(token, client.base))
.getCurrentUser() .getCurrentUser()
.then((user) => UserTokenPair(user, token)); .then((user) => UserTokenPair(user, token));
@ -25,12 +25,12 @@ class UserAPIService extends APIService implements UserService {
'username': username, 'username': username,
'email': email, 'email': email,
'password': password 'password': password
}).then((resp) => resp['username']); }).then((resp) => resp.body['username']);
return login(newUser, password); return login(newUser, password);
} }
@override @override
Future<User> getCurrentUser() { Future<User> getCurrentUser() {
return client.get('/user').then((map) => User.fromJson(map)); return client.get('/user').then((map) => User.fromJson(map.body));
} }
} }

View File

@ -56,7 +56,7 @@ class AddDialogState extends State<AddDialog> {
if (widget.onAdd != null && textController.text.isNotEmpty) if (widget.onAdd != null && textController.text.isNotEmpty)
widget.onAdd(textController.text); widget.onAdd(textController.text);
if(widget.onAddTask != null && textController.text.isNotEmpty) if(widget.onAddTask != null && textController.text.isNotEmpty)
widget.onAddTask(Task(id: null, title: textController.text, done: false, owner: null, due: DateTime.now().add(newTaskDueToDuration[newTaskDue]))); widget.onAddTask(Task(id: null, title: textController.text, done: false, createdBy: null, dueDate: DateTime.now().add(newTaskDueToDuration[newTaskDue])));
Navigator.pop(context); Navigator.pop(context);
}, },
) )
@ -70,16 +70,5 @@ class AddDialogState extends State<AddDialog> {
Checkbox(value: newTaskDue == thisNewTaskDue, onChanged: (value) { setState(() => newTaskDue = value ? thisNewTaskDue: newTaskDue);}, shape: CircleBorder(),), Checkbox(value: newTaskDue == thisNewTaskDue, onChanged: (value) { setState(() => newTaskDue = value ? thisNewTaskDue: newTaskDue);}, shape: CircleBorder(),),
Text(name), Text(name),
]); ]);
/*Row(children: [
Checkbox(value: newTaskDue == NewTaskDue.week, onChanged: (value) { setState(() => newTaskDue = value ? NewTaskDue.week: newTaskDue);}, shape: CircleBorder(),),
Text("1 Week"),
]),
Row(children: [
Checkbox(value: newTaskDue == NewTaskDue.month, onChanged: (value) { setState(() => newTaskDue = value ? NewTaskDue.month: newTaskDue);}, shape: CircleBorder(),),
Text("1 Month"),
])
];
*/
} }
} }

View File

@ -4,16 +4,19 @@ import 'dart:developer';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:vikunja_app/global.dart'; import 'package:vikunja_app/global.dart';
import 'package:vikunja_app/models/task.dart'; import 'package:vikunja_app/models/task.dart';
import 'package:vikunja_app/pages/task/edit_task.dart';
import 'package:vikunja_app/utils/misc.dart'; import 'package:vikunja_app/utils/misc.dart';
import '../pages/list/task_edit.dart';
class TaskTile extends StatefulWidget { class TaskTile extends StatefulWidget {
final Task task; final Task task;
final Function onEdit; final Function onEdit;
final bool showInfo; final bool showInfo;
final bool loading;
final ValueSetter<bool> onMarkedAsDone;
const TaskTile( const TaskTile(
{Key key, @required this.task, this.onEdit, this.showInfo = false}) {Key key, @required this.task, this.onEdit, this.loading = false, this.showInfo = false, this.onMarkedAsDone})
: assert(task != null), : assert(task != null),
super(key: key); super(key: key);
/* /*
@ -35,7 +38,7 @@ class TaskTileState extends State<TaskTile> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
Duration durationUntilDue = _currentTask.due.difference(DateTime.now()); Duration durationUntilDue = _currentTask.dueDate.difference(DateTime.now());
if (_currentTask.loading) { if (_currentTask.loading) {
return ListTile( return ListTile(
leading: Padding( leading: Padding(
@ -63,14 +66,15 @@ class TaskTileState extends State<TaskTile> {
text: TextSpan( text: TextSpan(
text: null, text: null,
children: <TextSpan> [ children: <TextSpan> [
TextSpan(text: widget.task.list.title+" - ", style: TextStyle(color: Colors.grey)), // TODO: get list name of task
//TextSpan(text: widget.task.list.title+" - ", style: TextStyle(color: Colors.grey)),
TextSpan(text: widget.task.title), TextSpan(text: widget.task.title),
] ]
) )
) : Text(_currentTask.title), ) : Text(_currentTask.title),
controlAffinity: ListTileControlAffinity.leading, controlAffinity: ListTileControlAffinity.leading,
value: _currentTask.done ?? false, value: _currentTask.done ?? false,
subtitle: widget.showInfo && _currentTask.due.year > 2 ? subtitle: widget.showInfo && _currentTask.dueDate.year > 2 ?
Text("Due in " + durationToHumanReadable(durationUntilDue),style: TextStyle(color: durationUntilDue.isNegative ? Colors.red : null),) Text("Due in " + durationToHumanReadable(durationUntilDue),style: TextStyle(color: durationUntilDue.isNegative ? Colors.red : null),)
: _currentTask.description == null || _currentTask.description.isEmpty : _currentTask.description == null || _currentTask.description.isEmpty
? null ? null
@ -109,8 +113,8 @@ class TaskTileState extends State<TaskTile> {
done: checked, done: checked,
title: task.title, title: task.title,
description: task.description, description: task.description,
owner: task.owner, createdBy: task.createdBy,
due: task.due dueDate: task.dueDate
)); ));
} }
} }

View File

View File

@ -0,0 +1,55 @@
import 'package:datetime_picker_formfield/datetime_picker_formfield.dart';
import 'package:flutter/material.dart';
import 'package:vikunja_app/theme/constants.dart';
class VikunjaDateTimePicker extends StatelessWidget {
final String label;
final Function onSaved;
final Function onChanged;
final DateTime initialValue;
final EdgeInsetsGeometry padding;
final Icon icon;
final InputBorder border;
const VikunjaDateTimePicker({
Key key,
@required this.label,
this.onSaved,
this.onChanged,
this.initialValue,
this.padding = const EdgeInsets.symmetric(vertical: 10.0),
this.icon = const Icon(Icons.date_range),
this.border = InputBorder.none,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return DateTimeField(
//dateOnly: false,
//editable: false, // Otherwise editing the date is not possible, this setting affects the underlying text field.
initialValue: initialValue.year <= 1
? null
: initialValue.toLocal(),
format: vDateFormatLong,
decoration: InputDecoration(
labelText: label,
border: border,
icon: icon,
),
onSaved: onSaved,
onChanged: onChanged,
onShowPicker: (context, currentValue) {
if(currentValue == null)
currentValue = DateTime.now();
return showDatePicker(
context: context,
firstDate: DateTime(1900),
initialDate: currentValue.year <= 1
? DateTime.now()
: currentValue,
lastDate: DateTime(2100));
},
);
}
}

52
lib/components/label.dart Normal file
View File

@ -0,0 +1,52 @@
import 'package:flutter/material.dart';
import 'package:vikunja_app/models/label.dart';
import 'package:vikunja_app/theme/constants.dart';
class LabelComponent extends StatefulWidget {
final Label label;
final VoidCallback onDelete;
const LabelComponent({Key key, @required this.label, this.onDelete})
: super(key: key);
@override
State<StatefulWidget> createState() {
return new LabelComponentState();
}
}
class LabelComponentState extends State<LabelComponent> {
@override
Widget build(BuildContext context) {
Color backgroundColor = widget.label.color ?? vLabelDefaultColor;
Color textColor =
backgroundColor.computeLuminance() > 0.5 ? vLabelDark : vLabelLight;
return Chip(
label: Text(
widget.label.title,
style: TextStyle(
color: textColor,
),
),
backgroundColor: backgroundColor,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(3)),
),
onDeleted: widget.onDelete,
deleteIconColor: textColor,
deleteIcon: Container(
padding: EdgeInsets.all(3),
decoration: BoxDecoration(
color: Color.fromARGB(50, 0, 0, 0),
shape: BoxShape.circle,
),
child: Icon(
Icons.close,
color: textColor,
size: 15,
),
),
);
}
}

View File

@ -0,0 +1,3 @@
extension StringExtensions on String {
Uri toUri() => Uri.tryParse(this);
}

View File

@ -5,6 +5,9 @@ import 'package:flutter/material.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:vikunja_app/api/client.dart'; import 'package:vikunja_app/api/client.dart';
import 'package:vikunja_app/api/label_task.dart';
import 'package:vikunja_app/api/label_task_bulk.dart';
import 'package:vikunja_app/api/labels.dart';
import 'package:vikunja_app/api/list_implementation.dart'; import 'package:vikunja_app/api/list_implementation.dart';
import 'package:vikunja_app/api/namespace_implementation.dart'; import 'package:vikunja_app/api/namespace_implementation.dart';
import 'package:vikunja_app/api/task_implementation.dart'; import 'package:vikunja_app/api/task_implementation.dart';
@ -26,7 +29,8 @@ class VikunjaGlobal extends StatefulWidget {
VikunjaGlobalState createState() => VikunjaGlobalState(); VikunjaGlobalState createState() => VikunjaGlobalState();
static VikunjaGlobalState of(BuildContext context) { static VikunjaGlobalState of(BuildContext context) {
var widget = context.dependOnInheritedWidgetOfExactType<_VikunjaGlobalInherited>(); var widget =
context.dependOnInheritedWidgetOfExactType<_VikunjaGlobalInherited>();
return widget.data; return widget.data;
} }
} }
@ -60,6 +64,12 @@ class VikunjaGlobalState extends State<VikunjaGlobal> {
NotificationAppLaunchDetails notifLaunch; NotificationAppLaunchDetails notifLaunch;
LabelService get labelService => new LabelAPIService(client);
LabelTaskService get labelTaskService => new LabelTaskAPIService(client);
LabelTaskBulkAPIService get labelTaskBulkService =>
new LabelTaskBulkAPIService(client);
@override @override
void initState() { void initState() {
@ -105,12 +115,12 @@ class VikunjaGlobalState extends State<VikunjaGlobal> {
notificationsPlugin.cancelAll().then((value) { notificationsPlugin.cancelAll().then((value) {
taskService.getAll().then((value) => taskService.getAll().then((value) =>
value.forEach((task) { value.forEach((task) {
if(task.reminders != null) if(task.reminderDates != null)
task.reminders.forEach((reminder) { task.reminderDates.forEach((reminder) {
scheduleNotification("This is your reminder for '" + task.title + "'", task.description, notificationsPlugin, reminder); scheduleNotification("This is your reminder for '" + task.title + "'", task.description, notificationsPlugin, reminder);
}); });
if(task.due != null) if(task.dueDate != null)
scheduleNotification("The task '" + task.title + "' is due.", task.description, notificationsPlugin, task.due); scheduleNotification("The task '" + task.title + "' is due.", task.description, notificationsPlugin, task.dueDate);
}) })
); );
}); });

View File

@ -3,6 +3,7 @@ import 'package:vikunja_app/global.dart';
import 'package:vikunja_app/pages/home.dart'; import 'package:vikunja_app/pages/home.dart';
import 'package:vikunja_app/pages/user/login.dart'; import 'package:vikunja_app/pages/user/login.dart';
import 'package:vikunja_app/theme/theme.dart'; import 'package:vikunja_app/theme/theme.dart';
//import 'package:alice/alice.dart';
void main() => runApp(VikunjaGlobal( void main() => runApp(VikunjaGlobal(
child: new VikunjaApp(home: HomePage()), child: new VikunjaApp(home: HomePage()),

42
lib/models/label.dart Normal file
View File

@ -0,0 +1,42 @@
import 'dart:ui';
import 'package:vikunja_app/models/user.dart';
class Label {
final int id;
final String title, description;
final DateTime created, updated;
final User createdBy;
final Color color;
Label(
{this.id,
this.title,
this.description,
this.color,
this.created,
this.updated,
this.createdBy});
Label.fromJson(Map<String, dynamic> json)
: id = json['id'],
title = json['title'],
description = json['description'],
color = json['hex_color'] == ''
? null
: new Color(int.parse(json['hex_color'], radix: 16) + 0xFF000000),
updated = DateTime.parse(json['updated']),
created = DateTime.parse(json['created']),
createdBy = User.fromJson(json['created_by']);
toJSON() => {
'id': id,
'title': title,
'description': description,
'hex_color':
color?.value?.toRadixString(16)?.padLeft(8, '0')?.substring(2),
'created_by': createdBy?.toJSON(),
'updated': updated?.millisecondsSinceEpoch,
'created': created?.millisecondsSinceEpoch,
};
}

18
lib/models/labelTask.dart Normal file
View File

@ -0,0 +1,18 @@
import 'package:meta/meta.dart';
import 'package:vikunja_app/models/label.dart';
import 'package:vikunja_app/models/task.dart';
class LabelTask {
final Label label;
final Task task;
LabelTask({@required this.label, @required this.task});
LabelTask.fromJson(Map<String, dynamic> json)
: label = new Label(id: json['label_id']),
task = null;
toJSON() => {
'label_id': label.id,
};
}

View File

@ -0,0 +1,15 @@
import 'package:meta/meta.dart';
import 'package:vikunja_app/models/label.dart';
class LabelTaskBulk {
final List<Label> labels;
LabelTaskBulk({@required this.labels});
LabelTaskBulk.fromJson(Map<String, dynamic> json)
: labels = json['labels']?.map((label) => Label.fromJson(label));
toJSON() => {
'labels': labels.map((label) => label.toJSON()).toList(),
};
}

View File

@ -1,57 +1,88 @@
import 'package:vikunja_app/models/user.dart';
import 'package:meta/meta.dart'; import 'package:meta/meta.dart';
import 'package:json_annotation/json_annotation.dart';
import 'package:vikunja_app/components/date_extension.dart';
import 'list.dart'; import 'package:vikunja_app/models/label.dart';
import 'package:vikunja_app/models/user.dart';
@JsonSerializable()
class Task { class Task {
int id, list_id; int id, parentTaskId, priority, listId;
DateTime created, updated, due; DateTime created, updated, dueDate, startDate, endDate;
List<DateTime> reminders; List<DateTime> reminderDates;
String title, description; String title, description;
bool done; bool done;
User owner; User createdBy;
Duration repeatAfter;
List<Task> subtasks;
List<Label> labels;
bool loading = false; bool loading = false;
TaskList list;
Task( Task(
{@required this.id, {@required this.id,
this.title,
this.description,
this.done = false,
this.reminderDates,
this.dueDate,
this.startDate,
this.endDate,
this.parentTaskId,
this.priority,
this.repeatAfter,
this.subtasks,
this.labels,
this.created, this.created,
this.updated, this.updated,
this.reminders, this.createdBy,
this.due, this.listId});
@required this.title,
this.description,
@required this.done,
@required this.owner,
this.loading,
this.list_id});
Task.fromJson(Map<String, dynamic> json) Task.fromJson(Map<String, dynamic> json)
: id = json['id'], : id = json['id'],
title = json['title'],
description = json['description'],
done = json['done'],
reminderDates = (json['reminder_dates'] as List<dynamic>)
?.map((ts) => DateTime.parse(ts))
?.cast<DateTime>()
?.toList(),
dueDate = DateTime.parse(json['due_date']),
startDate = DateTime.parse(json['start_date']),
endDate = DateTime.parse(json['end_date']),
parentTaskId = json['parent_task_id'],
priority = json['priority'],
repeatAfter = Duration(seconds: json['repeat_after']),
labels = (json['labels'] as List<dynamic>)
?.map((label) => Label.fromJson(label))
?.cast<Label>()
?.toList(),
subtasks = (json['subtasks'] as List<dynamic>)
?.map((subtask) => Task.fromJson(subtask))
?.cast<Task>()
?.toList(),
updated = DateTime.parse(json['updated']), updated = DateTime.parse(json['updated']),
created = DateTime.parse(json['created']), created = DateTime.parse(json['created']),
reminders = (json['reminder_dates'] as List<dynamic>) listId = json['list_id'],
?.map((r) => DateTime.parse(r)) createdBy = json['created_by'] == null
?.toList(), ? null
due = : User.fromJson(json['created_by']);
json['due_date'] != null ? DateTime.parse(json['due_date']) : null,
description = json['description'],
title = json['title'],
done = json['done'],
list_id = json['list_id'],
owner = json['created_by'] == null ? null : User.fromJson(json['created_by']);
toJSON() => { toJSON() => {
'id': id, 'id': id,
'title': title,
'description': description,
'done': done ?? false,
'reminder_dates':
reminderDates?.map((date) => date?.toIso8601String())?.toList(),
'due_date': dueDate?.toUtc()?.toIso8601String(),
'start_date': startDate?.toUtc()?.toIso8601String(),
'end_date': endDate?.toUtc()?.toIso8601String(),
'priority': priority,
'repeat_after': repeatAfter?.inSeconds,
'labels': labels?.map((label) => label.toJSON())?.toList(),
'subtasks': subtasks?.map((subtask) => subtask.toJSON())?.toList(),
'created_by': createdBy?.toJSON(),
'updated': updated?.toIso8601String(), 'updated': updated?.toIso8601String(),
'created': created?.toIso8601String(), 'created': created?.toIso8601String(),
'reminder_dates':
reminders?.map((date) => date.toIso8601String())?.toList(),
'due_date': due?.toUtc()?.toIso8601String(),
'description': description,
'title': title,
'done': done ?? false,
'created_by': owner?.toJSON(),
'list_id': list_id
}; };
} }

View File

@ -14,10 +14,7 @@ class User {
toJSON() => {"id": this.id, "email": this.email, "username": this.username}; toJSON() => {"id": this.id, "email": this.email, "username": this.username};
String avatarUrl(BuildContext context) { String avatarUrl(BuildContext context) {
return VikunjaGlobal.of(context).client.base + return VikunjaGlobal.of(context).client.base + "/avatar/${this.username}";
"/avatar/ " +
this.username+
"?size=50";
} }
} }

View File

@ -1,5 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:after_layout/after_layout.dart'; import 'package:after_layout/after_layout.dart';
@ -11,10 +12,12 @@ import 'package:vikunja_app/pages/landing_page.dart';
import 'package:vikunja_app/global.dart'; import 'package:vikunja_app/global.dart';
import 'package:vikunja_app/models/namespace.dart'; import 'package:vikunja_app/models/namespace.dart';
import 'package:vikunja_app/pages/settings.dart'; import 'package:vikunja_app/pages/settings.dart';
import 'package:vikunja_app/pages/placeholder.dart';
class HomePage extends StatefulWidget { class HomePage extends StatefulWidget {
@override @override
State<StatefulWidget> createState() => new HomePageState(); State<StatefulWidget> createState() => HomePageState();
} }
class HomePageState extends State<HomePage> with AfterLayoutMixin<HomePage> { class HomePageState extends State<HomePage> with AfterLayoutMixin<HomePage> {

View File

@ -113,7 +113,7 @@ class LandingPageState extends State<LandingPage> {
.listService .listService
.getAll() .getAll()
.then((lists) { .then((lists) {
taskList.forEach((task) {task.list = lists.firstWhere((element) => element.id == task.list_id);}); //taskList.forEach((task) {task.list = lists.firstWhere((element) => element.id == task.list_id);});
setState(() { setState(() {
_list = taskList; _list = taskList;
}); });

View File

@ -2,12 +2,15 @@ import 'dart:async';
import 'dart:developer'; import 'dart:developer';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:vikunja_app/components/AddDialog.dart'; import 'package:vikunja_app/components/AddDialog.dart';
import 'package:vikunja_app/components/TaskTile.dart'; import 'package:vikunja_app/components/TaskTile.dart';
import 'package:vikunja_app/global.dart'; import 'package:vikunja_app/global.dart';
import 'package:vikunja_app/models/list.dart'; import 'package:vikunja_app/models/list.dart';
import 'package:vikunja_app/models/task.dart'; import 'package:vikunja_app/models/task.dart';
import 'package:vikunja_app/pages/list/list_edit.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';
class ListPage extends StatefulWidget { class ListPage extends StatefulWidget {
final TaskList taskList; final TaskList taskList;
@ -20,117 +23,182 @@ class ListPage extends StatefulWidget {
class _ListPageState extends State<ListPage> { class _ListPageState extends State<ListPage> {
TaskList _list; TaskList _list;
List<Task> _loadingTasks = [];
int _currentPage = 1;
bool _loading = true; bool _loading = true;
bool displayDoneTasks; bool displayDoneTasks;
int listId;
@override @override
void initState() { void initState() {
_list = TaskList( _list = TaskList(
id: widget.taskList.id, title: widget.taskList.title, tasks: []); id: widget.taskList.id,
listId = _list.id; title: widget.taskList.title,
tasks: [],
);
Future.delayed(Duration.zero, (){ Future.delayed(Duration.zero, (){
VikunjaGlobal.of(context).listService.getDisplayDoneTasks(listId) updateDisplayDoneTasks();
.then((value) => setState((){displayDoneTasks = value == "1";}));
}); });
super.initState(); super.initState();
} }
@override
void didChangeDependencies() {
_loadList();
super.didChangeDependencies();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final taskState = Provider.of<ListProvider>(context);
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: new Text(_list.title), title: Text(_list.title),
actions: <Widget>[ actions: <Widget>[
IconButton( IconButton(
icon: Icon(Icons.edit), icon: Icon(Icons.edit),
onPressed: () => onPressed: () => Navigator.push(
Navigator.push( context,
context, MaterialPageRoute(
MaterialPageRoute( builder: (context) => ListEditPage(
builder: (context) => ListEditPage( list: _list,
list: _list, ),
))).whenComplete(() { )).whenComplete(() => updateDisplayDoneTasks()),
setState(() {this._loading = true;}); ),
VikunjaGlobal.of(context).listService.getDisplayDoneTasks(listId).then((value) { ],
displayDoneTasks = value == "1"; ),
_loadList(); // TODO: it brakes the flow with _loadingTasks and conflicts with the provider
setState(() => this._loading = false); body: !taskState.isLoading
}); ? RefreshIndicator(
}) child: taskState.tasks.length > 0
) ? ListenableProvider.value(
], value: taskState,
), child: ListView.builder(
body: !this._loading padding: EdgeInsets.symmetric(vertical: 8.0),
? RefreshIndicator( itemBuilder: (context, i) {
child: _list.tasks.length > 0 if (i.isOdd) return Divider();
? ListView(
padding: EdgeInsets.symmetric(vertical: 8.0),
children: ListTile.divideTiles(
context: context, tiles: _listTasks())
.toList(),
)
: Center(child: Text('This list is empty.')),
onRefresh: _loadList,
)
: Center(child: CircularProgressIndicator()),
floatingActionButton: Builder(
builder: (context) => FloatingActionButton(
onPressed: () => _addItemDialog(context), child: Icon(Icons.add)),
));
}
List<Widget> _listTasks() { if (_loadingTasks.isNotEmpty) {
var tasks = (_list.tasks.map(_buildTile) ?? []).toList(); final loadingTask = _loadingTasks.removeLast();
//tasks.addAll(_loadingTasks.map(_buildLoadingTile)); return _buildLoadingTile(loadingTask);
return tasks; }
final index = i ~/ 2;
// This handles the case if there are no more elements in the list left which can be provided by the api
if (taskState.maxPages == _currentPage &&
index == taskState.tasks.length - 1)
return null;
if (index >= taskState.tasks.length &&
_currentPage < taskState.maxPages) {
_currentPage++;
_loadTasksForPage(_currentPage);
}
return index < taskState.tasks.length
? _buildTile(taskState.tasks[index])
: null;
}),
)
: Center(child: Text('This list is empty.')),
onRefresh: _loadList,
)
: Center(child: CircularProgressIndicator()),
floatingActionButton: Builder(
builder: (context) => FloatingActionButton(
onPressed: () => _addItemDialog(context), child: Icon(Icons.add)),
),
);
} }
TaskTile _buildTile(Task task) { TaskTile _buildTile(Task task) {
// key: UniqueKey() seems like a weird workaround to fix the loading issue return TaskTile(
// is there a better way? task: task,
return TaskTile(key: UniqueKey(), task: task,onEdit: () => _loadList()); loading: false,
onEdit: () {
/*Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
TaskEditPage(
task: task,
),
),
);*/
},
onMarkedAsDone: (done) {
Provider.of<ListProvider>(context, listen: false).updateTask(
context: context,
id: task.id,
done: done,
);
},
);
} }
Future<void> _loadList() { updateDisplayDoneTasks() {
return VikunjaGlobal.of(context) VikunjaGlobal.of(context).listService.getDisplayDoneTasks(_list.id)
.listService .then((value) {
.get(widget.taskList.id) displayDoneTasks = value == "1";
.then((list) { _loadList().then((value) => setState((){}));
setState(() {
_loading = false;
if(displayDoneTasks != null && !displayDoneTasks)
list.tasks.removeWhere((element) => element.done);
_list = list;
});
}); });
} }
TaskTile _buildLoadingTile(Task task) {
return TaskTile(
task: task,
loading: true,
onEdit: () => Navigator.push(
context,
MaterialPageRoute(
builder: (context) => TaskEditPage(
task: task,
),
),
),
);
}
Future<void> _loadList() async {
_loadTasksForPage(1);
}
void _loadTasksForPage(int page) {
Provider.of<ListProvider>(context, listen: false).loadTasks(
context: context,
listId: _list.id,
page: page,
displayDoneTasks: displayDoneTasks ?? false
);
}
_addItemDialog(BuildContext context) { _addItemDialog(BuildContext context) {
showDialog( showDialog(
context: context, context: context,
builder: (_) => AddDialog( builder: (_) => AddDialog(
onAdd: (name) => _addItem(name, context), onAdd: (title) => _addItem(title, context),
decoration: new InputDecoration( decoration: InputDecoration(
labelText: 'Task Name', hintText: 'eg. Milk'))); labelText: 'Task Name',
hintText: 'eg. Milk',
),
),
);
} }
_addItem(String name, BuildContext context) { _addItem(String title, BuildContext context) {
var globalState = VikunjaGlobal.of(context); var globalState = VikunjaGlobal.of(context);
var newTask = Task( var newTask = Task(
id: null, title: name, owner: globalState.currentUser, done: false, loading: true); id: null,
setState(() => _list.tasks.add(newTask)); title: title,
globalState.taskService.add(_list.id, newTask).then((_) { createdBy: globalState.currentUser,
_loadList().then((_) { done: false,
ScaffoldMessenger.of(context).showSnackBar(SnackBar( );
content: Text('The task was added successfully!'), setState(() => _loadingTasks.add(newTask));
)); Provider.of<ListProvider>(context, listen: false)
.addTask(
context: context,
newTask: newTask,
listId: _list.id,
)
.then((_) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text('The task was added successfully!'),
));
setState(() {
_loadingTasks.remove(newTask);
}); });
}); });
} }

View File

@ -0,0 +1,438 @@
import 'package:flutter/material.dart';
import 'package:flutter_typeahead/flutter_typeahead.dart';
import 'package:vikunja_app/components/datetimePicker.dart';
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/theme/button.dart';
import 'package:vikunja_app/theme/buttonText.dart';
import 'package:vikunja_app/utils/repeat_after_parse.dart';
class TaskEditPage extends StatefulWidget {
final Task task;
TaskEditPage({this.task}) : super(key: Key(task.toString()));
@override
State<StatefulWidget> createState() => _TaskEditPageState();
}
class _TaskEditPageState extends State<TaskEditPage> {
final _formKey = GlobalKey<FormState>();
bool _loading = false;
int _priority;
DateTime _dueDate, _startDate, _endDate;
List<DateTime> _reminderDates;
String _title, _description, _repeatAfterType;
Duration _repeatAfter;
List<Label> _labels;
// we use this to find the label object after a user taps on the suggestion, because the typeahead only uses strings, not full objects.
List<Label> _suggestedLabels;
var _reminderInputs = <Widget>[];
final _labelTypeAheadController = TextEditingController();
@override
Widget build(BuildContext ctx) {
// This builds the initial list of reminder inputs only once.
if (_reminderDates == null) {
_reminderDates = widget.task.reminderDates ?? [];
_reminderDates?.asMap()?.forEach((i, time) =>
setState(() => _reminderInputs?.add(VikunjaDateTimePicker(
initialValue: time,
label: 'Reminder',
onSaved: (reminder) => _reminderDates[i] = reminder,
))));
}
if (_labels == null) {
_labels = widget.task.labels ?? [];
}
return Scaffold(
appBar: AppBar(
title: Text('Edit Task'),
),
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.task.title,
onSaved: (title) => _title = title,
validator: (title) {
if (title.length < 3 || title.length > 250) {
return 'The title needs to have between 3 and 250 characters.';
}
return null;
},
decoration: new InputDecoration(
labelText: 'Title',
border: OutlineInputBorder(),
),
),
),
Padding(
padding: EdgeInsets.symmetric(vertical: 10.0),
child: TextFormField(
maxLines: null,
keyboardType: TextInputType.multiline,
initialValue: widget.task.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(),
),
),
),
VikunjaDateTimePicker(
icon: Icon(Icons.access_time),
label: 'Due Date',
initialValue: widget.task.dueDate,
onSaved: (duedate) => _dueDate = duedate,
),
VikunjaDateTimePicker(
label: 'Start Date',
initialValue: widget.task.startDate,
onSaved: (startDate) => _startDate = startDate,
),
VikunjaDateTimePicker(
label: 'End Date',
initialValue: widget.task.endDate,
onSaved: (endDate) => _endDate = endDate,
),
Row(
children: [
Expanded(
flex: 2,
child: TextFormField(
keyboardType: TextInputType.number,
initialValue: getRepeatAfterValueFromDuration(
widget.task.repeatAfter)
?.toString(),
onSaved: (repeatAfter) => _repeatAfter =
getDurationFromType(repeatAfter, _repeatAfterType),
decoration: new InputDecoration(
labelText: 'Repeat after',
border: InputBorder.none,
icon: Icon(Icons.repeat),
),
),
),
Expanded(
child: DropdownButton<String>(
isExpanded: true,
isDense: true,
value: _repeatAfterType ??
getRepeatAfterTypeFromDuration(
widget.task.repeatAfter),
onChanged: (String newValue) {
setState(() {
_repeatAfterType = newValue;
});
},
items: <String>[
'Hours',
'Days',
'Weeks',
'Months',
'Years'
].map<DropdownMenuItem<String>>((String value) {
return DropdownMenuItem<String>(
value: value,
child: Text(value),
);
}).toList(),
),
),
],
),
Column(
children: _reminderInputs,
),
GestureDetector(
child: Padding(
padding: EdgeInsets.symmetric(vertical: 10),
child: Row(
children: <Widget>[
Padding(
padding: EdgeInsets.only(right: 15, left: 2),
child: Icon(
Icons.alarm_add,
color: Colors.grey,
)),
Text(
'Add a reminder',
style: TextStyle(
color: Colors.grey,
fontSize: 16,
),
),
],
),
),
onTap: () {
// We add a new entry every time we add a new input, to make sure all inputs have a place where they can put their value.
_reminderDates.add(null);
var currentIndex = _reminderDates.length - 1;
// FIXME: Why does putting this into a row fails?
setState(() => _reminderInputs.add(Row(
children: <Widget>[
VikunjaDateTimePicker(
label: 'Reminder',
onSaved: (reminder) =>
_reminderDates[currentIndex] = reminder,
),
GestureDetector(
onTap: () => print('tapped'),
child: Icon(Icons.close),
)
],
)));
}),
InputDecorator(
isEmpty: _priority == null,
decoration: InputDecoration(
icon: const Icon(Icons.flag),
labelText: 'Priority',
border: InputBorder.none,
),
child: new DropdownButton<String>(
value: _priorityToString(_priority),
isExpanded: true,
isDense: true,
onChanged: (String newValue) {
setState(() {
_priority = _priorityFromString(newValue);
});
},
items: ['Unset', 'Low', 'Medium', 'High', 'Urgent', 'DO NOW']
.map((String value) {
return new DropdownMenuItem(
value: value,
child: new Text(value),
);
}).toList(),
),
),
Wrap(
spacing: 10,
children: _labels.map((Label label) {
return LabelComponent(
label: label,
onDelete: () {
_removeLabel(label);
},
);
}).toList()),
Row(
children: <Widget>[
Container(
width: MediaQuery.of(context).size.width - 80,
child: TypeAheadFormField(
textFieldConfiguration: TextFieldConfiguration(
controller: _labelTypeAheadController,
decoration:
InputDecoration(labelText: 'Add a new label')),
suggestionsCallback: (pattern) {
return _searchLabel(pattern);
},
itemBuilder: (context, suggestion) {
return ListTile(
title: Text(suggestion),
);
},
transitionBuilder: (context, suggestionsBox, controller) {
return suggestionsBox;
},
onSuggestionSelected: (suggestion) {
_addLabel(suggestion);
},
),
),
IconButton(
onPressed: () =>
_createAndAddLabel(_labelTypeAheadController.text),
icon: Icon(Icons.add),
)
],
),
Builder(
builder: (context) => Padding(
padding: EdgeInsets.symmetric(vertical: 10.0),
child: FancyButton(
onPressed: !_loading
? () {
if (_formKey.currentState.validate()) {
Form.of(context).save();
_saveTask(context);
}
}
: null,
child: _loading
? CircularProgressIndicator()
: VikunjaButtonText('Save'),
))),
]),
),
),
),
);
}
_saveTask(BuildContext context) async {
setState(() => _loading = true);
// Removes all reminders with no value set.
_reminderDates.removeWhere((d) => d == null);
Task updatedTask = Task(
id: widget.task.id,
title: _title,
description: _description,
done: widget.task.done,
reminderDates: _reminderDates,
createdBy: widget.task.createdBy,
dueDate: _dueDate,
startDate: _startDate,
endDate: _endDate,
priority: _priority,
repeatAfter: _repeatAfter,
);
// update the labels
VikunjaGlobal.of(context)
.labelTaskBulkService
.update(updatedTask, _labels)
.catchError((err) {
setState(() => _loading = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Something went wrong: ' + err.toString()),
),
);
});
VikunjaGlobal.of(context).taskService.update(updatedTask).then((_) {
setState(() => _loading = false);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text('The task was updated successfully!'),
));
}).catchError((err) {
throw 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),
),
);
});
}
_removeLabel(Label label) {
setState(() {
_labels.removeWhere((l) => l.id == label.id);
});
}
_searchLabel(String query) {
return VikunjaGlobal.of(context)
.labelService
.getAll(query: query)
.then((labels) {
// Only show those labels which aren't already added to the task
labels.removeWhere((labelToRemove) => _labels.contains(labelToRemove));
_suggestedLabels = labels;
return labels.map((label) => label.title).toList();
});
}
_addLabel(String labelTitle) {
// FIXME: This is not an optimal solution...
bool found = false;
_suggestedLabels.forEach((label) {
if (label.title == labelTitle) {
_labels.add(label);
found = true;
}
});
if (found) {
_labelTypeAheadController.clear();
}
}
_createAndAddLabel(String labelTitle) {
// Only add a label if there are none to add
if (labelTitle.isEmpty || (_suggestedLabels?.isNotEmpty ?? false)) {
return;
}
Label newLabel = Label(title: labelTitle);
VikunjaGlobal.of(context)
.labelService
.create(newLabel)
.then((createdLabel) {
setState(() {
_labels.add(createdLabel);
_labelTypeAheadController.clear();
});
});
}
// 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;
}
}
}

View File

@ -1,13 +1,16 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:after_layout/after_layout.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/AddDialog.dart';
import 'package:vikunja_app/global.dart'; import 'package:vikunja_app/global.dart';
import 'package:vikunja_app/models/list.dart'; import 'package:vikunja_app/models/list.dart';
import 'package:vikunja_app/models/namespace.dart'; import 'package:vikunja_app/models/namespace.dart';
import 'package:vikunja_app/pages/list/list.dart'; import 'package:vikunja_app/pages/list/list.dart';
import 'package:vikunja_app/stores/list_store.dart';
class NamespacePage extends StatefulWidget { class NamespacePage extends StatefulWidget {
final Namespace namespace; final Namespace namespace;
@ -55,11 +58,11 @@ class _NamespacePageState extends State<NamespacePage>
color: Colors.white, size: 36.0)), color: Colors.white, size: 36.0)),
), ),
onDismissed: (direction) { onDismissed: (direction) {
_removeList(ls).then((_) => ScaffoldMessenger.of( _removeList(ls).then((_) =>
context) ScaffoldMessenger.of(context)
.showSnackBar(SnackBar( .showSnackBar(SnackBar(
content: content: Text(
Text("${ls.title} removed")))); "${ls.title} removed"))));
}, },
))).toList(), ))).toList(),
) )
@ -88,6 +91,7 @@ class _NamespacePageState extends State<NamespacePage>
} }
Future<void> _loadLists() { Future<void> _loadLists() {
// FIXME: This is called even when the tasks on a list are loaded - which is not needed at all
return VikunjaGlobal.of(context) return VikunjaGlobal.of(context)
.listService .listService
.getByNamespace(widget.namespace.id) .getByNamespace(widget.namespace.id)
@ -98,8 +102,15 @@ class _NamespacePageState extends State<NamespacePage>
} }
_openList(BuildContext context, TaskList list) { _openList(BuildContext context, TaskList list) {
Navigator.of(context).push( Navigator.of(context).push(MaterialPageRoute(
MaterialPageRoute(builder: (context) => ListPage(taskList: list))); builder: (context) => ChangeNotifierProvider<ListProvider>(
create: (_) => new ListProvider(),
child: ListPage(
taskList: list,
),
),
// ListPage(taskList: list)
));
} }
_addListDialog(BuildContext context) { _addListDialog(BuildContext context) {

View File

@ -0,0 +1,29 @@
import 'package:flutter/material.dart';
class PlaceholderPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return new Container(
padding: EdgeInsets.all(16),
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
children: <Widget>[
Container(
padding: EdgeInsets.only(top: 32.0),
child: Text(
'Welcome to Vikunja',
style: Theme.of(context).textTheme.headline5,
),
),
Padding(
padding: EdgeInsets.symmetric(vertical: 10),
child: Text('Please select a namespace by tapping the ☰ icon.',
style: Theme.of(context).textTheme.subtitle1),
)
],
),
),
);
}
}

View File

@ -1,3 +1,4 @@
/*
import 'dart:developer'; import 'dart:developer';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -29,13 +30,11 @@ class _TaskEditPageState extends State<TaskEditPage> {
@override @override
void initState() { void initState() {
log("In init state: " + widget.task.due.toIso8601String()); log("In init state: " + widget.task.dueDate.toIso8601String());
titleController.text = widget.task.title; titleController.text = widget.task.title;
descriptionController.text = widget.task.description; descriptionController.text = widget.task.description;
if(widget.task.done == null)
widget.task.done = false;
_done = widget.task.done; _done = widget.task.done;
_due = widget.task.due; _due = widget.task.dueDate;
super.initState(); super.initState();
} }
@ -188,7 +187,7 @@ class _TaskEditPageState extends State<TaskEditPage> {
updatedTask.title = _title; updatedTask.title = _title;
updatedTask.description = _description; updatedTask.description = _description;
updatedTask.done = _done; updatedTask.done = _done;
updatedTask.due = _due.toUtc(); updatedTask.dueDate = _due.toUtc();
VikunjaGlobal.of(context) VikunjaGlobal.of(context)
.taskService .taskService
@ -248,3 +247,4 @@ class _TaskEditPageState extends State<TaskEditPage> {
); );
} }
} }
*/

View File

@ -1,5 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'package:vikunja_app/api/response.dart';
import 'package:vikunja_app/models/list.dart'; import 'package:vikunja_app/models/list.dart';
import 'package:vikunja_app/models/namespace.dart'; import 'package:vikunja_app/models/namespace.dart';
import 'package:vikunja_app/models/task.dart'; import 'package:vikunja_app/models/task.dart';
@ -39,7 +40,7 @@ var _tasks = {
1: Task( 1: Task(
id: 1, id: 1,
title: 'Task 1', title: 'Task 1',
owner: _users[1], createdBy: _users[1],
updated: DateTime.now(), updated: DateTime.now(),
created: DateTime.now(), created: DateTime.now(),
description: 'A descriptive task', description: 'A descriptive task',
@ -168,7 +169,14 @@ class MockedTaskService implements TaskService {
} }
@override @override
Future<List<Task>> get(int taskId) { 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<Response> get(int taskId) {
// TODO: implement get // TODO: implement get
throw UnimplementedError(); throw UnimplementedError();
} }

View File

@ -1,5 +1,9 @@
import 'dart:async'; import 'dart:async';
import 'dart:developer'; import 'dart:developer';
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/list.dart';
import 'package:vikunja_app/models/namespace.dart'; import 'package:vikunja_app/models/namespace.dart';
import 'package:vikunja_app/models/task.dart'; import 'package:vikunja_app/models/task.dart';
@ -88,16 +92,38 @@ abstract class ListService {
} }
abstract class TaskService { abstract class TaskService {
Future<List<Task>> get(int taskId); Future<Response> get(int taskId);
Future<Task> update(Task task); Future<Task> update(Task task);
Future delete(int taskId); Future delete(int taskId);
Future<Task> add(int listId, Task task); Future<Task> add(int listId, Task task);
Future<List<Task>> getAll(); Future<List<Task>> getAll();
Future<Response> getAllByList(int listId,
[Map<String, List<String>> queryParameters]);
Future<List<Task>> getByOptions(TaskServiceOptions options); Future<List<Task>> getByOptions(TaskServiceOptions options);
}
int get maxPages;
}
abstract class UserService { abstract class UserService {
Future<UserTokenPair> login(String username, password); Future<UserTokenPair> login(String username, password);
Future<UserTokenPair> register(String username, email, password); Future<UserTokenPair> register(String username, email, password);
Future<User> getCurrentUser(); Future<User> getCurrentUser();
} }
abstract class LabelService {
Future<List<Label>> getAll({String query});
Future<Label> get(int labelID);
Future<Label> create(Label label);
Future<Label> delete(Label label);
Future<Label> update(Label label);
}
abstract class LabelTaskService {
Future<List<Label>> getAll(LabelTask lt, {String query});
Future<Label> create(LabelTask lt);
Future<Label> delete(LabelTask lt);
}
abstract class LabelTaskBulkService {
Future<List<Label>> update(Task task, List<Label> labels);
}

View File

@ -0,0 +1,93 @@
import 'package:flutter/material.dart';
import 'package:vikunja_app/models/task.dart';
import 'package:vikunja_app/global.dart';
class ListProvider with ChangeNotifier {
bool _isLoading = false;
int _maxPages = 0;
// TODO: Streams
List<Task> _tasks = [];
bool get isLoading => _isLoading;
int get maxPages => _maxPages;
set tasks(List<Task> tasks) {
_tasks = tasks;
notifyListeners();
}
List<Task> get tasks => _tasks;
void loadTasks({BuildContext context, int listId, int page = 1, bool displayDoneTasks = true}) {
_tasks = [];
_isLoading = true;
notifyListeners();
VikunjaGlobal.of(context).taskService.getAllByList(listId, {
"sort_by": ["done", "id"],
"order_by": ["asc", "desc"],
"page": [page.toString()]
}).then((response) {
if (response.headers["x-pagination-total-pages"] != null) {
_maxPages = int.parse(response.headers["x-pagination-total-pages"]);
}
_tasks.addAll(response.body);
if(!displayDoneTasks)
_tasks.removeWhere((element) => element.done);
_isLoading = false;
notifyListeners();
});
}
Future<void> addTaskByTitle(
{BuildContext context, String title, int listId}) {
var globalState = VikunjaGlobal.of(context);
var newTask = Task(
id: null,
title: title,
createdBy: globalState.currentUser,
done: false,
);
_isLoading = true;
notifyListeners();
return globalState.taskService.add(listId, newTask).then((task) {
_tasks.insert(0, task);
_isLoading = false;
notifyListeners();
});
}
Future<void> addTask({BuildContext context, Task newTask, int listId}) {
var globalState = VikunjaGlobal.of(context);
_isLoading = true;
notifyListeners();
return globalState.taskService.add(listId, newTask).then((task) {
_tasks.insert(0, task);
_isLoading = false;
notifyListeners();
});
}
void updateTask({BuildContext context, int id, bool done}) {
var globalState = VikunjaGlobal.of(context);
globalState.taskService
.update(Task(
id: id,
done: done,
))
.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 definitly fix it later.
_tasks.asMap().forEach((i, t) {
if (task.id == t.id) {
_tasks[i] = task;
}
});
notifyListeners();
});
}
}

View File

@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
///////// /////////
// Colors // Colors
@ -18,9 +19,15 @@ const vButtonTextColor = vWhite;
const vButtonShadowDark = Color(0xFF0b2a4a); const vButtonShadowDark = Color(0xFF0b2a4a);
const vButtonShadow = Color(0xFFb2d9ff); const vButtonShadow = Color(0xFFb2d9ff);
const vLabelLight = Color(0xFFf2f2f2);
const vLabelDark = Color(0xFF4a4a4a);
const vLabelDefaultColor = vGreen;
/////////// ///////////
// Paddings // Paddings
//////// ////////
const vStandardVerticalPadding = EdgeInsets.symmetric(vertical: 5.0); const vStandardVerticalPadding = EdgeInsets.symmetric(vertical: 5.0);
const vStandardHorizontalPadding = EdgeInsets.symmetric(horizontal: 5.0); const vStandardHorizontalPadding = EdgeInsets.symmetric(horizontal: 5.0);
const vStandardPadding = EdgeInsets.symmetric(horizontal: 5.0, vertical: 5.0); const vStandardPadding = EdgeInsets.symmetric(horizontal: 5.0, vertical: 5.0);
var vDateFormatLong = DateFormat("EEEE, MMMM d, yyyy 'at' H:mm");

View File

@ -2,13 +2,7 @@ import 'package:flutter/material.dart';
import 'package:vikunja_app/theme/constants.dart'; import 'package:vikunja_app/theme/constants.dart';
ThemeData buildVikunjaTheme() => _buildVikunjaTheme(ThemeData.light()); ThemeData buildVikunjaTheme() => _buildVikunjaTheme(ThemeData.light());
ThemeData buildVikunjaDarkTheme() => _buildVikunjaTheme(ThemeData.dark());
ThemeData buildVikunjaDarkTheme() {
ThemeData base = _buildVikunjaTheme(ThemeData.dark());
return base.copyWith(
accentColor: vWhite,
);
}
ThemeData _buildVikunjaTheme(ThemeData base) { ThemeData _buildVikunjaTheme(ThemeData base) {
return base.copyWith( return base.copyWith(
@ -27,12 +21,12 @@ ThemeData _buildVikunjaTheme(ThemeData base) {
), ),
), ),
textTheme: base.textTheme.copyWith( textTheme: base.textTheme.copyWith(
headline1: base.textTheme.headline1.copyWith( // headline: base.textTheme.headline.copyWith(
fontFamily: 'Quicksand', // fontFamily: 'Quicksand',
), // ),
subtitle1: base.textTheme.subtitle1.copyWith( // title: base.textTheme.title.copyWith(
fontFamily: 'Quicksand', // fontFamily: 'Quicksand',
), // ),
button: base.textTheme.button.copyWith( button: base.textTheme.button.copyWith(
color: color:
vWhite, // This does not work, looks like a bug in Flutter: https://github.com/flutter/flutter/issues/19623 vWhite, // This does not work, looks like a bug in Flutter: https://github.com/flutter/flutter/issues/19623

View File

@ -0,0 +1,11 @@
datetimeToUnixTimestamp(DateTime dt) {
return dt?.millisecondsSinceEpoch == null
? null
: (dt.millisecondsSinceEpoch / 1000).round();
}
dateTimeFromUnixTimestamp(int timestamp) {
return timestamp == null
? 0
: DateTime.fromMillisecondsSinceEpoch(timestamp * 1000);
}

View File

@ -0,0 +1,66 @@
getRepeatAfterTypeFromDuration(Duration repeatAfter) {
if (repeatAfter == null || repeatAfter.inSeconds == 0) {
return null;
}
// if its dividable by 24, its something with days, otherwise hours
if (repeatAfter.inHours % 24 == 0) {
if (repeatAfter.inDays % 7 == 0) {
return 'Weeks';
} else if (repeatAfter.inDays % 365 == 0) {
return 'Years';
} else if (repeatAfter.inDays % 30 == 0) {
return 'Months';
} else {
return 'Days';
}
}
return 'Hours';
}
getRepeatAfterValueFromDuration(Duration repeatAfter) {
if (repeatAfter == null || repeatAfter.inSeconds == 0) {
return null;
}
// if its dividable by 24, its something with days, otherwise hours
if (repeatAfter.inHours % 24 == 0) {
if (repeatAfter.inDays % 7 == 0) {
// Weeks
return (repeatAfter.inDays / 7).round();
} else if (repeatAfter.inDays % 365 == 0) {
// Years
return (repeatAfter.inDays / 365).round();
} else if (repeatAfter.inDays % 30 == 0) {
// Months
return (repeatAfter.inDays / 30).round();
} else {
return repeatAfter.inDays; // Days
}
}
// Otherwise Hours
return repeatAfter.inHours;
}
getDurationFromType(String value, String type) {
// Return an empty duration if either of the values is not set
if (value == null || value == '' || type == null || type == '') {
return Duration();
}
int val = int.parse(value);
switch (type) {
case 'Hours':
return Duration(hours: val);
case 'Days':
return Duration(days: val);
case 'Weeks':
return Duration(days: val * 7);
case 'Months':
return Duration(days: val * 30);
case 'Years':
return Duration(days: val * 365);
}
}

View File

@ -1,6 +1,13 @@
# Generated by pub # Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile # See https://dart.dev/tools/pub/glossary#lockfile
packages: packages:
_fe_analyzer_shared:
dependency: transitive
description:
name: _fe_analyzer_shared
url: "https://pub.dartlang.org"
source: hosted
version: "22.0.0"
after_layout: after_layout:
dependency: "direct main" dependency: "direct main"
description: description:
@ -8,6 +15,13 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.1.0" version: "1.1.0"
analyzer:
dependency: transitive
description:
name: analyzer
url: "https://pub.dartlang.org"
source: hosted
version: "1.7.2"
archive: archive:
dependency: transitive dependency: transitive
description: description:
@ -36,6 +50,20 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.1.0" version: "2.1.0"
build:
dependency: "direct main"
description:
name: build
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.0"
build_config:
dependency: transitive
description:
name: build_config
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.0"
characters: characters:
dependency: transitive dependency: transitive
description: description:
@ -50,6 +78,20 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.3.1" version: "1.3.1"
checked_yaml:
dependency: transitive
description:
name: checked_yaml
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.1"
cli_util:
dependency: transitive
description:
name: cli_util
url: "https://pub.dartlang.org"
source: hosted
version: "0.3.5"
clock: clock:
dependency: transitive dependency: transitive
description: description:
@ -64,6 +106,13 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.16.0" version: "1.16.0"
convert:
dependency: transitive
description:
name: convert
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.1"
crypto: crypto:
dependency: transitive dependency: transitive
description: description:
@ -78,6 +127,20 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.1.3" version: "0.1.3"
dart_style:
dependency: transitive
description:
name: dart_style
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.1"
datetime_picker_formfield:
dependency: "direct main"
description:
name: datetime_picker_formfield
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.0"
dbus: dbus:
dependency: transitive dependency: transitive
description: description:
@ -111,6 +174,27 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
flutter_keyboard_visibility:
dependency: transitive
description:
name: flutter_keyboard_visibility
url: "https://pub.dartlang.org"
source: hosted
version: "5.2.0"
flutter_keyboard_visibility_platform_interface:
dependency: transitive
description:
name: flutter_keyboard_visibility_platform_interface
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.0"
flutter_keyboard_visibility_web:
dependency: transitive
description:
name: flutter_keyboard_visibility_web
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.0"
flutter_launcher_icons: flutter_launcher_icons:
dependency: "direct dev" dependency: "direct dev"
description: description:
@ -152,17 +236,31 @@ packages:
name: flutter_secure_storage name: flutter_secure_storage
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "3.3.5" version: "4.2.1"
flutter_test: flutter_test:
dependency: "direct dev" dependency: "direct dev"
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
flutter_typeahead:
dependency: "direct main"
description:
name: flutter_typeahead
url: "https://pub.dartlang.org"
source: hosted
version: "3.2.5"
flutter_web_plugins: flutter_web_plugins:
dependency: transitive dependency: transitive
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
glob:
dependency: transitive
description:
name: glob
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.2"
http: http:
dependency: "direct main" dependency: "direct main"
description: description:
@ -198,6 +296,27 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.6.4" version: "0.6.4"
json_annotation:
dependency: transitive
description:
name: json_annotation
url: "https://pub.dartlang.org"
source: hosted
version: "4.1.0"
json_serializable:
dependency: "direct main"
description:
name: json_serializable
url: "https://pub.dartlang.org"
source: hosted
version: "4.1.4"
logging:
dependency: transitive
description:
name: logging
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.2"
matcher: matcher:
dependency: transitive dependency: transitive
description: description:
@ -219,6 +338,20 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.7.0" version: "1.7.0"
nested:
dependency: transitive
description:
name: nested
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.0"
package_config:
dependency: transitive
description:
name: package_config
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.2"
path: path:
dependency: transitive dependency: transitive
description: description:
@ -226,13 +359,20 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.8.1" version: "1.8.1"
petitparser: pedantic:
dependency: transitive dependency: transitive
description:
name: pedantic
url: "https://pub.dartlang.org"
source: hosted
version: "1.11.1"
petitparser:
dependency: "direct main"
description: description:
name: petitparser name: petitparser
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "5.0.0" version: "4.1.0"
platform: platform:
dependency: transitive dependency: transitive
description: description:
@ -254,6 +394,27 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "4.2.4" version: "4.2.4"
provider:
dependency: "direct main"
description:
name: provider
url: "https://pub.dartlang.org"
source: hosted
version: "6.0.0"
pub_semver:
dependency: transitive
description:
name: pub_semver
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.1"
pubspec_parse:
dependency: transitive
description:
name: pubspec_parse
url: "https://pub.dartlang.org"
source: hosted
version: "1.1.0"
rxdart: rxdart:
dependency: "direct main" dependency: "direct main"
description: description:
@ -266,6 +427,13 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.99" version: "0.0.99"
source_gen:
dependency: transitive
description:
name: source_gen
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.3"
source_span: source_span:
dependency: transitive dependency: transitive
description: description:
@ -329,6 +497,13 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.1.2" version: "2.1.2"
watcher:
dependency: transitive
description:
name: watcher
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.1"
xdg_directories: xdg_directories:
dependency: transitive dependency: transitive
description: description:
@ -342,7 +517,7 @@ packages:
name: xml name: xml
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "5.4.1" version: "5.1.2"
yaml: yaml:
dependency: transitive dependency: transitive
description: description:

View File

@ -1,27 +1,35 @@
name: vikunja_app name: vikunja_app
description: Vikunja as Flutter cross platform app description: Vikunja as Flutter cross platform app
version: 0.1.0 version: 0.2.0+200099
environment: environment:
sdk: ">=2.1.0 <3.0.0" sdk: ">=2.6.0 <3.0.0"
dependencies: dependencies:
flutter: flutter:
sdk: flutter sdk: flutter
cupertino_icons: ^0.1.3 cupertino_icons: ^0.1.3
flutter_secure_storage: 3.3.5
http: 0.13.4 http: 0.13.4
after_layout: ^1.1.0 after_layout: ^1.1.0
intl: ^0.17.0 intl: ^0.17.0
flutter_local_notifications: ^9.0.0 flutter_local_notifications: ^9.0.0
rxdart: ^0.23.1 rxdart: ^0.23.1
flutter_native_timezone: ^2.0.0 flutter_native_timezone: ^2.0.0
flutter_secure_storage: 4.2.1
datetime_picker_formfield: 2.0.0
flutter_typeahead: 3.2.5
build: 2.1.0
json_serializable: 4.1.4
petitparser: 4.1.0
provider: 6.0.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
sdk: flutter sdk: flutter
flutter_launcher_icons: "^0.9.2" version: any
#test: any
flutter_launcher_icons: 0.9.2
flutter_icons: flutter_icons:
image_path: "assets/vikunja_logo.png" image_path: "assets/vikunja_logo.png"

View File

@ -0,0 +1,22 @@
import 'dart:convert';
import 'dart:ui';
import 'package:test/test.dart';
import 'package:vikunja_app/models/label.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 JsonDecoder _decoder = new JsonDecoder();
Label label = Label.fromJson(_decoder.convert(json));
expect(label.color, Color(0xFFe8e8e8));
});
test('hex color string from object', () {
Label label = Label(id: 1, color: Color(0xFFe8e8e8));
var json = label.toJSON();
expect(json.toString(), '{id: 1, title: null, description: null, hex_color: e8e8e8, created_by: null, updated: null, created: null}');
});
}

View File

@ -0,0 +1,76 @@
import 'package:test/test.dart';
import 'package:vikunja_app/utils/repeat_after_parse.dart';
void main() {
test('Repeat after hours', () {
Duration testDuration = Duration(hours: 6);
expect(getRepeatAfterTypeFromDuration(testDuration), 'Hours');
expect(getRepeatAfterValueFromDuration(testDuration), 6);
});
test('Repeat after days', () {
Duration testDuration = Duration(days: 6);
expect(getRepeatAfterTypeFromDuration(testDuration), 'Days');
expect(getRepeatAfterValueFromDuration(testDuration), 6);
});
test('Repeat after weeks', () {
Duration testDuration = Duration(days: 6 * 7);
expect(getRepeatAfterTypeFromDuration(testDuration), 'Weeks');
expect(getRepeatAfterValueFromDuration(testDuration), 6);
});
test('Repeat after months', () {
Duration testDuration = Duration(days: 6 * 30);
expect(getRepeatAfterTypeFromDuration(testDuration), 'Months');
expect(getRepeatAfterValueFromDuration(testDuration), 6);
});
test('Repeat after years', () {
Duration testDuration = Duration(days: 6 * 365);
expect(getRepeatAfterTypeFromDuration(testDuration), 'Years');
expect(getRepeatAfterValueFromDuration(testDuration), 6);
});
test('Repeat null value', () {
Duration testDuration = Duration();
expect(getRepeatAfterTypeFromDuration(testDuration), null);
expect(getRepeatAfterValueFromDuration(testDuration), null);
});
test('Hours to duration', () {
Duration parsedDuration = getDurationFromType('6', 'Hours');
expect(parsedDuration, Duration(hours: 6));
});
test('Days to duration', () {
Duration parsedDuration = getDurationFromType('6', 'Days');
expect(parsedDuration, Duration(days: 6));
});
test('Weeks to duration', () {
Duration parsedDuration = getDurationFromType('6', 'Weeks');
expect(parsedDuration, Duration(days: 6 * 7));
});
test('Months to duration', () {
Duration parsedDuration = getDurationFromType('6', 'Months');
expect(parsedDuration, Duration(days: 6 * 30));
});
test('Years to duration', () {
Duration parsedDuration = getDurationFromType('6', 'Years');
expect(parsedDuration, Duration(days: 6 * 365));
});
test('null to duration', () {
Duration parsedDuration = getDurationFromType(null, null);
expect(parsedDuration, Duration());
});
}

View File

@ -0,0 +1,52 @@
import 'dart:convert';
import 'package:vikunja_app/models/task.dart';
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 JsonDecoder _decoder = new JsonDecoder();
final task = Task.fromJson(_decoder.convert(json));
expect(task.id, 1);
expect(task.title, 'test');
expect(task.description, 'Lorem Ipsum');
expect(task.done, true);
expect(task.reminderDates, [
DateTime.fromMillisecondsSinceEpoch(1543834800 * 1000),
DateTime.fromMillisecondsSinceEpoch(1544612400 * 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.labels, null);
expect(task.subtasks, null);
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 JsonDecoder _decoder = new JsonDecoder();
final task = Task.fromJson(_decoder.convert(json));
expect(task.id, 1);
expect(task.title, 'test');
expect(task.description, 'Lorem Ipsum');
expect(task.done, true);
expect(task.reminderDates, null);
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.labels, null);
expect(task.subtasks, null);
expect(task.created, DateTime.fromMillisecondsSinceEpoch(1542465818 * 1000));
expect(task.updated, DateTime.fromMillisecondsSinceEpoch(1552771527 * 1000));
});
}