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/Runner.ipa
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 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.
Development for IOS has been put on hold as I do not have the resources to develop for IOS.
# Vikunja Cross-Plattform app
[![Build Status](https://drone.kolaente.de/api/badges/vikunja/app/status.svg)](https://drone.kolaente.de/vikunja/app)
[![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
/captures
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 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 {
compileSdkVersion 31
@ -40,11 +46,23 @@ android {
applicationId "io.vikunja.app"
minSdkVersion 19
targetSdkVersion 31
testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
versionCode flutterVersionCode.toInteger()
versionName flutterVersionName
testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
}
/*
signingConfigs {
release {
keyAlias keystoreProperties['keyAlias']
keyPassword keystoreProperties['keyPassword']
storeFile file(keystoreProperties['storeFile'])
storePassword keystoreProperties['storePassword']
}
}
*/
flavorDimensions "deploy"
productFlavors {
@ -74,7 +92,10 @@ android {
release {
// TODO: Add your own signing config for the release build.
// 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:windowSoftInputMode="adjustResize"
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>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>

View File

@ -2,4 +2,5 @@ package io.vikunja.flutteringvikunja
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"?>
<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">
<!-- Show a splash screen on the activity. Automatically removed when
Flutter draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</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>

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

View File

@ -1,6 +1,9 @@
import 'dart:async';
import 'dart:convert';
import 'dart:core';
import 'package:http/http.dart' as http;
import 'package:vikunja_app/api/response.dart';
import 'package:vikunja_app/components/string_extension.dart';
class Client {
final JsonDecoder _decoder = new JsonDecoder();
@ -25,34 +28,54 @@ class Client {
'Content-Type': 'application/json'
};
// TODO: use Uri properly
Future<dynamic> get(String url) {
Future<Response> 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
.get(Uri.parse('${this.base}$url'), headers: _headers)
.delete(
'${this.base}$url'.toUri(),
headers: _headers,
)
.then(_handleResponse);
}
Future<dynamic> delete(String url) {
Future<Response> post(String url, {dynamic body}) {
return http
.delete(Uri.parse('${this.base}$url'), headers: _headers)
.post(
'${this.base}$url'.toUri(),
headers: _headers,
body: _encoder.convert(body),
)
.then(_handleResponse);
}
Future<dynamic> post(String url, {dynamic body}) {
Future<Response> put(String url, {dynamic body}) {
return http
.post(Uri.parse('${this.base}$url'),
headers: _headers, body: _encoder.convert(body))
.put(
'${this.base}$url'.toUri(),
headers: _headers,
body: _encoder.convert(body),
)
.then(_handleResponse);
}
Future<dynamic> put(String url, {dynamic body}) {
return http
.put(Uri.parse('${this.base}$url'),
headers: _headers, body: _encoder.convert(body))
.then(_handleResponse);
}
dynamic _handleResponse(http.Response response) {
Response _handleResponse(http.Response response) {
if (response.statusCode < 200 ||
response.statusCode >= 400 ||
json == null) {
@ -66,12 +89,14 @@ class Client {
throw new ApiException(
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 {
final String message;
InvalidRequestApiException(int errorCode, String path, this.message)
: super(errorCode, path);
@ -84,6 +109,7 @@ class InvalidRequestApiException extends ApiException {
class ApiException implements Exception {
final int errorCode;
final String path;
ApiException(this.errorCode, this.path);
@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) {
return client
.put('/namespaces/$namespaceId/lists', body: tl.toJSON())
.then((map) => TaskList.fromJson(map));
.then((response) => TaskList.fromJson(response.body));
}
@override
@ -25,6 +25,7 @@ class ListAPIService extends APIService implements ListService {
@override
Future<TaskList> get(int listId) {
/*
return client.get('/lists/$listId').then((listmap) {
return client.get('/lists/$listId/tasks').then((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
Future<List<TaskList>> getAll() {
return client.get('/lists').then(
(list) => convertList(list, (result) => TaskList.fromJson(result)));
(list) => convertList(list.body, (result) => TaskList.fromJson(result)));
}
@override
@ -49,14 +63,14 @@ class ListAPIService extends APIService implements ListService {
return getAll().then((value) {value.removeWhere((element) => !element.isFavorite); return value;});
}
return client.get('/namespaces/$namespaceId/lists').then(
(list) => convertList(list, (result) => TaskList.fromJson(result)));
(list) => convertList(list.body, (result) => TaskList.fromJson(result)));
}
@override
Future<TaskList> update(TaskList tl) {
return client
.post('/lists/${tl.id}', body: tl.toJSON())
.then((map) => TaskList.fromJson(map));
.then((response) => TaskList.fromJson(response.body));
}
@override

View File

@ -12,7 +12,7 @@ class NamespaceAPIService extends APIService implements NamespaceService {
Future<Namespace> create(Namespace ns) {
return client
.put('/namespaces', body: ns.toJSON())
.then((map) => Namespace.fromJson(map));
.then((response) => Namespace.fromJson(response.body));
}
@override
@ -24,19 +24,19 @@ class NamespaceAPIService extends APIService implements NamespaceService {
Future<Namespace> get(int namespaceId) {
return client
.get('/namespaces/$namespaceId')
.then((map) => Namespace.fromJson(map));
.then((response) => Namespace.fromJson(response.body));
}
@override
Future<List<Namespace>> getAll() {
return client.get('/namespaces').then(
(list) => convertList(list, (result) => Namespace.fromJson(result)));
return client.get('/namespaces').then((response) =>
convertList(response.body, (result) => Namespace.fromJson(result)));
}
@override
Future<Namespace> update(Namespace ns) {
return client
.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 'package:vikunja_app/api/client.dart';
import 'package:vikunja_app/api/response.dart';
import 'package:vikunja_app/api/service.dart';
import 'package:vikunja_app/models/task.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) {
return client
.put('/lists/$listId', body: task.toJSON())
.then((map) => Task.fromJson(map));
.then((response) => Task.fromJson(response.body));
}
@override
Future<List<Task>> get(int listId) {
Future<Response> get(int listId) {
return client.get('/list/$listId/tasks');
}
@ -29,14 +30,24 @@ class TaskAPIService extends APIService implements TaskService {
Future<Task> update(Task task) {
return client
.post('/tasks/${task.id}', body: task.toJSON())
.then((map) => Task.fromJson(map));
.then((response) => Task.fromJson(response.body));
}
@override
Future<List<Task>> getAll() {
return client
.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
@ -45,8 +56,12 @@ class TaskAPIService extends APIService implements TaskService {
return client
.get('/tasks/all?$optionString')
.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: {
'username': username,
'password': password
}).then((map) => map['token']);
}).then((response) => response.body['token']);
return UserAPIService(Client(token, client.base))
.getCurrentUser()
.then((user) => UserTokenPair(user, token));
@ -25,12 +25,12 @@ class UserAPIService extends APIService implements UserService {
'username': username,
'email': email,
'password': password
}).then((resp) => resp['username']);
}).then((resp) => resp.body['username']);
return login(newUser, password);
}
@override
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)
widget.onAdd(textController.text);
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);
},
)
@ -70,16 +70,5 @@ class AddDialogState extends State<AddDialog> {
Checkbox(value: newTaskDue == thisNewTaskDue, onChanged: (value) { setState(() => newTaskDue = value ? thisNewTaskDue: newTaskDue);}, shape: CircleBorder(),),
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:vikunja_app/global.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 '../pages/list/task_edit.dart';
class TaskTile extends StatefulWidget {
final Task task;
final Function onEdit;
final bool showInfo;
final bool loading;
final ValueSetter<bool> onMarkedAsDone;
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),
super(key: key);
/*
@ -35,7 +38,7 @@ class TaskTileState extends State<TaskTile> {
@override
Widget build(BuildContext context) {
Duration durationUntilDue = _currentTask.due.difference(DateTime.now());
Duration durationUntilDue = _currentTask.dueDate.difference(DateTime.now());
if (_currentTask.loading) {
return ListTile(
leading: Padding(
@ -63,14 +66,15 @@ class TaskTileState extends State<TaskTile> {
text: TextSpan(
text: null,
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),
]
)
) : Text(_currentTask.title),
controlAffinity: ListTileControlAffinity.leading,
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),)
: _currentTask.description == null || _currentTask.description.isEmpty
? null
@ -109,8 +113,8 @@ class TaskTileState extends State<TaskTile> {
done: checked,
title: task.title,
description: task.description,
owner: task.owner,
due: task.due
createdBy: task.createdBy,
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_secure_storage/flutter_secure_storage.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/namespace_implementation.dart';
import 'package:vikunja_app/api/task_implementation.dart';
@ -26,7 +29,8 @@ class VikunjaGlobal extends StatefulWidget {
VikunjaGlobalState createState() => VikunjaGlobalState();
static VikunjaGlobalState of(BuildContext context) {
var widget = context.dependOnInheritedWidgetOfExactType<_VikunjaGlobalInherited>();
var widget =
context.dependOnInheritedWidgetOfExactType<_VikunjaGlobalInherited>();
return widget.data;
}
}
@ -60,6 +64,12 @@ class VikunjaGlobalState extends State<VikunjaGlobal> {
NotificationAppLaunchDetails notifLaunch;
LabelService get labelService => new LabelAPIService(client);
LabelTaskService get labelTaskService => new LabelTaskAPIService(client);
LabelTaskBulkAPIService get labelTaskBulkService =>
new LabelTaskBulkAPIService(client);
@override
void initState() {
@ -105,12 +115,12 @@ class VikunjaGlobalState extends State<VikunjaGlobal> {
notificationsPlugin.cancelAll().then((value) {
taskService.getAll().then((value) =>
value.forEach((task) {
if(task.reminders != null)
task.reminders.forEach((reminder) {
if(task.reminderDates != null)
task.reminderDates.forEach((reminder) {
scheduleNotification("This is your reminder for '" + task.title + "'", task.description, notificationsPlugin, reminder);
});
if(task.due != null)
scheduleNotification("The task '" + task.title + "' is due.", task.description, notificationsPlugin, task.due);
if(task.dueDate != null)
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/user/login.dart';
import 'package:vikunja_app/theme/theme.dart';
//import 'package:alice/alice.dart';
void main() => runApp(VikunjaGlobal(
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: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 {
int id, list_id;
DateTime created, updated, due;
List<DateTime> reminders;
int id, parentTaskId, priority, listId;
DateTime created, updated, dueDate, startDate, endDate;
List<DateTime> reminderDates;
String title, description;
bool done;
User owner;
User createdBy;
Duration repeatAfter;
List<Task> subtasks;
List<Label> labels;
bool loading = false;
TaskList list;
Task(
{@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.updated,
this.reminders,
this.due,
@required this.title,
this.description,
@required this.done,
@required this.owner,
this.loading,
this.list_id});
this.createdBy,
this.listId});
Task.fromJson(Map<String, dynamic> json)
: 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']),
created = DateTime.parse(json['created']),
reminders = (json['reminder_dates'] as List<dynamic>)
?.map((r) => DateTime.parse(r))
?.toList(),
due =
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']);
listId = json['list_id'],
createdBy = json['created_by'] == null
? null
: User.fromJson(json['created_by']);
toJSON() => {
'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(),
'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};
String avatarUrl(BuildContext context) {
return VikunjaGlobal.of(context).client.base +
"/avatar/ " +
this.username+
"?size=50";
return VikunjaGlobal.of(context).client.base + "/avatar/${this.username}";
}
}

View File

@ -1,5 +1,6 @@
import 'dart:async';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.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/models/namespace.dart';
import 'package:vikunja_app/pages/settings.dart';
import 'package:vikunja_app/pages/placeholder.dart';
class HomePage extends StatefulWidget {
@override
State<StatefulWidget> createState() => new HomePageState();
State<StatefulWidget> createState() => HomePageState();
}
class HomePageState extends State<HomePage> with AfterLayoutMixin<HomePage> {

View File

@ -113,7 +113,7 @@ class LandingPageState extends State<LandingPage> {
.listService
.getAll()
.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(() {
_list = taskList;
});

View File

@ -2,12 +2,15 @@ import 'dart:async';
import 'dart:developer';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:vikunja_app/components/AddDialog.dart';
import 'package:vikunja_app/components/TaskTile.dart';
import 'package:vikunja_app/global.dart';
import 'package:vikunja_app/models/list.dart';
import 'package:vikunja_app/models/task.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 {
final TaskList taskList;
@ -20,117 +23,182 @@ class ListPage extends StatefulWidget {
class _ListPageState extends State<ListPage> {
TaskList _list;
List<Task> _loadingTasks = [];
int _currentPage = 1;
bool _loading = true;
bool displayDoneTasks;
int listId;
@override
void initState() {
_list = TaskList(
id: widget.taskList.id, title: widget.taskList.title, tasks: []);
listId = _list.id;
id: widget.taskList.id,
title: widget.taskList.title,
tasks: [],
);
Future.delayed(Duration.zero, (){
VikunjaGlobal.of(context).listService.getDisplayDoneTasks(listId)
.then((value) => setState((){displayDoneTasks = value == "1";}));
updateDisplayDoneTasks();
});
super.initState();
}
@override
void didChangeDependencies() {
_loadList();
super.didChangeDependencies();
}
@override
Widget build(BuildContext context) {
final taskState = Provider.of<ListProvider>(context);
return Scaffold(
appBar: AppBar(
title: new Text(_list.title),
actions: <Widget>[
IconButton(
icon: Icon(Icons.edit),
onPressed: () =>
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => ListEditPage(
list: _list,
))).whenComplete(() {
setState(() {this._loading = true;});
VikunjaGlobal.of(context).listService.getDisplayDoneTasks(listId).then((value) {
displayDoneTasks = value == "1";
_loadList();
setState(() => this._loading = false);
});
})
)
],
),
body: !this._loading
? RefreshIndicator(
child: _list.tasks.length > 0
? 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)),
));
}
appBar: AppBar(
title: Text(_list.title),
actions: <Widget>[
IconButton(
icon: Icon(Icons.edit),
onPressed: () => Navigator.push(
context,
MaterialPageRoute(
builder: (context) => ListEditPage(
list: _list,
),
)).whenComplete(() => updateDisplayDoneTasks()),
),
],
),
// TODO: it brakes the flow with _loadingTasks and conflicts with the provider
body: !taskState.isLoading
? RefreshIndicator(
child: taskState.tasks.length > 0
? ListenableProvider.value(
value: taskState,
child: ListView.builder(
padding: EdgeInsets.symmetric(vertical: 8.0),
itemBuilder: (context, i) {
if (i.isOdd) return Divider();
List<Widget> _listTasks() {
var tasks = (_list.tasks.map(_buildTile) ?? []).toList();
//tasks.addAll(_loadingTasks.map(_buildLoadingTile));
return tasks;
if (_loadingTasks.isNotEmpty) {
final loadingTask = _loadingTasks.removeLast();
return _buildLoadingTile(loadingTask);
}
final index = i ~/ 2;
// This handles the case if there are no more elements in the list left which can be provided by the api
if (taskState.maxPages == _currentPage &&
index == taskState.tasks.length - 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) {
// key: UniqueKey() seems like a weird workaround to fix the loading issue
// is there a better way?
return TaskTile(key: UniqueKey(), task: task,onEdit: () => _loadList());
return TaskTile(
task: task,
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() {
return VikunjaGlobal.of(context)
.listService
.get(widget.taskList.id)
.then((list) {
setState(() {
_loading = false;
if(displayDoneTasks != null && !displayDoneTasks)
list.tasks.removeWhere((element) => element.done);
_list = list;
});
updateDisplayDoneTasks() {
VikunjaGlobal.of(context).listService.getDisplayDoneTasks(_list.id)
.then((value) {
displayDoneTasks = value == "1";
_loadList().then((value) => setState((){}));
});
}
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) {
showDialog(
context: context,
builder: (_) => AddDialog(
onAdd: (name) => _addItem(name, context),
decoration: new InputDecoration(
labelText: 'Task Name', hintText: 'eg. Milk')));
context: context,
builder: (_) => AddDialog(
onAdd: (title) => _addItem(title, context),
decoration: InputDecoration(
labelText: 'Task Name',
hintText: 'eg. Milk',
),
),
);
}
_addItem(String name, BuildContext context) {
_addItem(String title, BuildContext context) {
var globalState = VikunjaGlobal.of(context);
var newTask = Task(
id: null, title: name, owner: globalState.currentUser, done: false, loading: true);
setState(() => _list.tasks.add(newTask));
globalState.taskService.add(_list.id, newTask).then((_) {
_loadList().then((_) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text('The task was added successfully!'),
));
id: null,
title: title,
createdBy: globalState.currentUser,
done: false,
);
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 '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/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';
class NamespacePage extends StatefulWidget {
final Namespace namespace;
@ -55,11 +58,11 @@ class _NamespacePageState extends State<NamespacePage>
color: Colors.white, size: 36.0)),
),
onDismissed: (direction) {
_removeList(ls).then((_) => ScaffoldMessenger.of(
context)
.showSnackBar(SnackBar(
content:
Text("${ls.title} removed"))));
_removeList(ls).then((_) =>
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(
content: Text(
"${ls.title} removed"))));
},
))).toList(),
)
@ -88,6 +91,7 @@ class _NamespacePageState extends State<NamespacePage>
}
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)
.listService
.getByNamespace(widget.namespace.id)
@ -98,8 +102,15 @@ class _NamespacePageState extends State<NamespacePage>
}
_openList(BuildContext context, TaskList list) {
Navigator.of(context).push(
MaterialPageRoute(builder: (context) => ListPage(taskList: list)));
Navigator.of(context).push(MaterialPageRoute(
builder: (context) => ChangeNotifierProvider<ListProvider>(
create: (_) => new ListProvider(),
child: ListPage(
taskList: list,
),
),
// ListPage(taskList: list)
));
}
_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 'package:intl/intl.dart';
import 'package:flutter/material.dart';
@ -29,13 +30,11 @@ class _TaskEditPageState extends State<TaskEditPage> {
@override
void initState() {
log("In init state: " + widget.task.due.toIso8601String());
log("In init state: " + widget.task.dueDate.toIso8601String());
titleController.text = widget.task.title;
descriptionController.text = widget.task.description;
if(widget.task.done == null)
widget.task.done = false;
_done = widget.task.done;
_due = widget.task.due;
_due = widget.task.dueDate;
super.initState();
}
@ -188,7 +187,7 @@ class _TaskEditPageState extends State<TaskEditPage> {
updatedTask.title = _title;
updatedTask.description = _description;
updatedTask.done = _done;
updatedTask.due = _due.toUtc();
updatedTask.dueDate = _due.toUtc();
VikunjaGlobal.of(context)
.taskService
@ -248,3 +247,4 @@ class _TaskEditPageState extends State<TaskEditPage> {
);
}
}
*/

View File

@ -1,5 +1,6 @@
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';
@ -39,7 +40,7 @@ var _tasks = {
1: Task(
id: 1,
title: 'Task 1',
owner: _users[1],
createdBy: _users[1],
updated: DateTime.now(),
created: DateTime.now(),
description: 'A descriptive task',
@ -168,7 +169,14 @@ class MockedTaskService implements TaskService {
}
@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
throw UnimplementedError();
}

View File

@ -1,5 +1,9 @@
import 'dart:async';
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/namespace.dart';
import 'package:vikunja_app/models/task.dart';
@ -88,16 +92,38 @@ abstract class ListService {
}
abstract class TaskService {
Future<List<Task>> get(int taskId);
Future<Response> get(int taskId);
Future<Task> update(Task task);
Future delete(int taskId);
Future<Task> add(int listId, Task task);
Future<List<Task>> getAll();
Future<Response> getAllByList(int listId,
[Map<String, List<String>> queryParameters]);
Future<List<Task>> getByOptions(TaskServiceOptions options);
}
int get maxPages;
}
abstract class UserService {
Future<UserTokenPair> login(String username, password);
Future<UserTokenPair> register(String username, email, password);
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:intl/intl.dart';
/////////
// Colors
@ -18,9 +19,15 @@ const vButtonTextColor = vWhite;
const vButtonShadowDark = Color(0xFF0b2a4a);
const vButtonShadow = Color(0xFFb2d9ff);
const vLabelLight = Color(0xFFf2f2f2);
const vLabelDark = Color(0xFF4a4a4a);
const vLabelDefaultColor = vGreen;
///////////
// Paddings
////////
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");

View File

@ -2,13 +2,7 @@ import 'package:flutter/material.dart';
import 'package:vikunja_app/theme/constants.dart';
ThemeData buildVikunjaTheme() => _buildVikunjaTheme(ThemeData.light());
ThemeData buildVikunjaDarkTheme() {
ThemeData base = _buildVikunjaTheme(ThemeData.dark());
return base.copyWith(
accentColor: vWhite,
);
}
ThemeData buildVikunjaDarkTheme() => _buildVikunjaTheme(ThemeData.dark());
ThemeData _buildVikunjaTheme(ThemeData base) {
return base.copyWith(
@ -27,12 +21,12 @@ ThemeData _buildVikunjaTheme(ThemeData base) {
),
),
textTheme: base.textTheme.copyWith(
headline1: base.textTheme.headline1.copyWith(
fontFamily: 'Quicksand',
),
subtitle1: base.textTheme.subtitle1.copyWith(
fontFamily: 'Quicksand',
),
// headline: base.textTheme.headline.copyWith(
// fontFamily: 'Quicksand',
// ),
// title: base.textTheme.title.copyWith(
// fontFamily: 'Quicksand',
// ),
button: base.textTheme.button.copyWith(
color:
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
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
_fe_analyzer_shared:
dependency: transitive
description:
name: _fe_analyzer_shared
url: "https://pub.dartlang.org"
source: hosted
version: "22.0.0"
after_layout:
dependency: "direct main"
description:
@ -8,6 +15,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "1.1.0"
analyzer:
dependency: transitive
description:
name: analyzer
url: "https://pub.dartlang.org"
source: hosted
version: "1.7.2"
archive:
dependency: transitive
description:
@ -36,6 +50,20 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
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:
dependency: transitive
description:
@ -50,6 +78,20 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
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:
dependency: transitive
description:
@ -64,6 +106,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "1.16.0"
convert:
dependency: transitive
description:
name: convert
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.1"
crypto:
dependency: transitive
description:
@ -78,6 +127,20 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
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:
dependency: transitive
description:
@ -111,6 +174,27 @@ packages:
description: flutter
source: sdk
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:
dependency: "direct dev"
description:
@ -152,17 +236,31 @@ packages:
name: flutter_secure_storage
url: "https://pub.dartlang.org"
source: hosted
version: "3.3.5"
version: "4.2.1"
flutter_test:
dependency: "direct dev"
description: flutter
source: sdk
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:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
glob:
dependency: transitive
description:
name: glob
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.2"
http:
dependency: "direct main"
description:
@ -198,6 +296,27 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
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:
dependency: transitive
description:
@ -219,6 +338,20 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
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:
dependency: transitive
description:
@ -226,13 +359,20 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "1.8.1"
petitparser:
pedantic:
dependency: transitive
description:
name: pedantic
url: "https://pub.dartlang.org"
source: hosted
version: "1.11.1"
petitparser:
dependency: "direct main"
description:
name: petitparser
url: "https://pub.dartlang.org"
source: hosted
version: "5.0.0"
version: "4.1.0"
platform:
dependency: transitive
description:
@ -254,6 +394,27 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
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:
dependency: "direct main"
description:
@ -266,6 +427,13 @@ packages:
description: flutter
source: sdk
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:
dependency: transitive
description:
@ -329,6 +497,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.2"
watcher:
dependency: transitive
description:
name: watcher
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.1"
xdg_directories:
dependency: transitive
description:
@ -342,7 +517,7 @@ packages:
name: xml
url: "https://pub.dartlang.org"
source: hosted
version: "5.4.1"
version: "5.1.2"
yaml:
dependency: transitive
description:

View File

@ -1,27 +1,35 @@
name: vikunja_app
description: Vikunja as Flutter cross platform app
version: 0.1.0
version: 0.2.0+200099
environment:
sdk: ">=2.1.0 <3.0.0"
sdk: ">=2.6.0 <3.0.0"
dependencies:
flutter:
sdk: flutter
cupertino_icons: ^0.1.3
flutter_secure_storage: 3.3.5
http: 0.13.4
after_layout: ^1.1.0
intl: ^0.17.0
flutter_local_notifications: ^9.0.0
rxdart: ^0.23.1
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:
flutter_test:
sdk: flutter
flutter_launcher_icons: "^0.9.2"
version: any
#test: any
flutter_launcher_icons: 0.9.2
flutter_icons:
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));
});
}