diff --git a/android/app/build.gradle b/android/app/build.gradle index 2eda7a0..f94225f 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -39,7 +39,7 @@ android { defaultConfig { // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). applicationId "io.vikunja.flutteringvikunja" - minSdkVersion 16 + minSdkVersion 18 targetSdkVersion 27 versionCode flutterVersionCode.toInteger() versionName flutterVersionName diff --git a/lib/api/client.dart b/lib/api/client.dart new file mode 100644 index 0000000..033dda0 --- /dev/null +++ b/lib/api/client.dart @@ -0,0 +1,12 @@ +class Client { + final String _token; + + Client(this._token); + + bool operator ==(dynamic otherClient) { + return otherClient._token == _token; + } + + @override + int get hashCode => _token.hashCode; +} diff --git a/lib/components/GravatarImage.dart b/lib/components/GravatarImage.dart new file mode 100644 index 0000000..cab902c --- /dev/null +++ b/lib/components/GravatarImage.dart @@ -0,0 +1,14 @@ +import 'package:flutter/material.dart'; + +import 'package:crypto/crypto.dart'; + +class GravatarImageProvider extends NetworkImage { + GravatarImageProvider(String email) : super( + "https://secure.gravatar.com/avatar/" + md5.convert( + email + .trim() + .toLowerCase() + .codeUnits + ).toString() + ); +} diff --git a/lib/global.dart b/lib/global.dart new file mode 100644 index 0000000..d634c09 --- /dev/null +++ b/lib/global.dart @@ -0,0 +1,99 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:fluttering_vikunja/api/client.dart'; +import 'package:fluttering_vikunja/managers/user.dart'; +import 'package:fluttering_vikunja/models/user.dart'; +import 'package:fluttering_vikunja/service/mocked_services.dart'; +import 'package:fluttering_vikunja/service/services.dart'; + +class VikunjaGlobal extends StatefulWidget { + final Widget child; + final Widget login; + + VikunjaGlobal({this.child, this.login}); + + @override + VikunjaGlobalState createState() => VikunjaGlobalState(); + + static VikunjaGlobalState of(BuildContext context) { + var widget = context.inheritFromWidgetOfExactType(_VikunjaGlobalInherited) as _VikunjaGlobalInherited; + return widget.data; + } +} + +class VikunjaGlobalState extends State { + final FlutterSecureStorage _storage = new FlutterSecureStorage(); + + User _currentUser; + Client _client; + bool _loading = true; + + User get currentUser => _currentUser; + Client get client => _client; + + UserManager get userManager => new UserManager(_storage); + UserService get userService => new MockedUserService(); + + @override + void initState() { + super.initState(); + _loadCurrentUser(); + } + + void changeUser(User newUser, {String token}) async { + setState(() { + _loading = true; + }); + if (token == null) { + token = await _storage.read(key: newUser.id.toString()); + } else { + // Write new token to secure storage + await _storage.write(key: newUser.id.toString(), value: token); + } + // Set current user in storage + await _storage.write(key: 'currentUser', value: newUser.id.toString()); + setState(() { + _currentUser = newUser; + _client = Client(token); + _loading = false; + }); + } + + void _loadCurrentUser() async { + var currentUser = await _storage.read(key: 'currentUser'); + var token; + if (currentUser != null) { + token = await _storage.read(key: currentUser); + } + var loadedCurrentUser = await userService.get(int.tryParse(currentUser)); + setState(() { + _currentUser = loadedCurrentUser; + _client = token != null ? Client(token) : null; + _loading = false; + }); + } + + @override + Widget build(BuildContext context) { + if (_loading) { + return new Center(child: new CircularProgressIndicator()); + } + return new _VikunjaGlobalInherited( + data: this, + child: client == null ? widget.login : widget.child, + ); + } +} + +class _VikunjaGlobalInherited extends InheritedWidget { + final VikunjaGlobalState data; + + _VikunjaGlobalInherited({Key key, this.data, Widget child}) + : super(key: key, child: child); + + @override + bool updateShouldNotify(_VikunjaGlobalInherited oldWidget) { + return (data.currentUser != null && data.currentUser.id != oldWidget.data.currentUser.id) || + data.client != oldWidget.data.client; + } +} diff --git a/lib/main.dart b/lib/main.dart index d3319d9..0feae67 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,5 +1,9 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; +import 'package:fluttering_vikunja/global.dart'; import 'package:fluttering_vikunja/pages/home_page.dart'; +import 'package:fluttering_vikunja/pages/login_page.dart'; import 'package:fluttering_vikunja/style.dart'; void main() => runApp(new VikunjaApp()); @@ -11,7 +15,7 @@ class VikunjaApp extends StatelessWidget { return new MaterialApp( title: 'Vikunja', theme: buildVikunjaTheme(), - home: new HomePage(), + home: VikunjaGlobal(child: new HomePage(), login: new LoginPage()), ); } -} +} \ No newline at end of file diff --git a/lib/managers/user.dart b/lib/managers/user.dart new file mode 100644 index 0000000..15fb315 --- /dev/null +++ b/lib/managers/user.dart @@ -0,0 +1,24 @@ +import 'dart:async'; + +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +class UserManager { + final FlutterSecureStorage _storage; + + UserManager(this._storage); + + Future> loadLocalUserIds() async { + return await _storage.readAll().then((userMap) { + userMap.keys + .where((id) => _isNumeric(id)) + .map((idString) => int.tryParse(idString)); + }); + } + + bool _isNumeric(String str) { + if (str == null) { + return false; + } + return double.tryParse(str) != null; + } +} diff --git a/lib/models/namespace.dart b/lib/models/namespace.dart new file mode 100644 index 0000000..e62c5be --- /dev/null +++ b/lib/models/namespace.dart @@ -0,0 +1,25 @@ +import 'package:fluttering_vikunja/models/user.dart'; +import 'package:meta/meta.dart'; + +class Namespace { + final int id; + final DateTime created, updated; + final String name, description; + final User owner; + + Namespace( + {@required this.id, + this.created, + this.updated, + @required this.name, + this.description, + this.owner}); + + Namespace.fromJson(Map json) + : name = json['name'], + description = json['description'], + id = json['id'], + created = DateTime.fromMillisecondsSinceEpoch(json['created']), + updated = DateTime.fromMillisecondsSinceEpoch(json['updated']), + owner = User.fromJson(json['owner']); +} diff --git a/lib/models/task.dart b/lib/models/task.dart new file mode 100644 index 0000000..0b0b795 --- /dev/null +++ b/lib/models/task.dart @@ -0,0 +1,58 @@ +import 'package:fluttering_vikunja/models/user.dart'; +import 'package:meta/meta.dart'; + +class Task { + final int id; + final DateTime created, updated, reminder, due; + final String text, description; + final bool done; + final User owner; + + Task( + {@required this.id, + this.created, + this.updated, + this.reminder, + this.due, + @required this.text, + this.description, + this.done, + @required this.owner}); + + Task.fromJson(Map json) + : id = json['id'], + updated = DateTime.fromMillisecondsSinceEpoch(json['updated']), + created = DateTime.fromMillisecondsSinceEpoch(json['created']), + reminder = DateTime.fromMillisecondsSinceEpoch(json['reminderDate']), + due = DateTime.fromMillisecondsSinceEpoch(json['dueDate']), + description = json['description'], + text = json['text'], + done = json['done'], + owner = User.fromJson(json['createdBy']); +} + +class TaskList { + final int id; + final String title, description; + final User owner; + final DateTime created, updated; + final List tasks; + + TaskList( + {@required this.id, + @required this.title, + this.description, + this.owner, + this.created, + this.updated, + @required this.tasks}); + + TaskList.fromJson(Map json) + : id = json['id'], + owner = User.fromJson(json['owner']), + description = json['description'], + title = json['title'], + updated = DateTime.fromMillisecondsSinceEpoch(json['updated']), + created = DateTime.fromMillisecondsSinceEpoch(json['created']), + tasks = json['tasks'].map((taskJson) => Task.fromJson(taskJson)); +} diff --git a/lib/models/user.dart b/lib/models/user.dart new file mode 100644 index 0000000..b7b61c7 --- /dev/null +++ b/lib/models/user.dart @@ -0,0 +1,18 @@ +import 'package:meta/meta.dart'; + +class User { + final int id; + final String email, username; + + User(this.id, this.email, this.username); + User.fromJson(Map json) + : id = json['id'], + email = json['email'], + username = json['username']; +} + +class UserTokenPair { + final User user; + final String token; + UserTokenPair(this.user, this.token); +} \ No newline at end of file diff --git a/lib/pages/home_page.dart b/lib/pages/home_page.dart index 10e3269..d8d2ce8 100644 --- a/lib/pages/home_page.dart +++ b/lib/pages/home_page.dart @@ -1,6 +1,9 @@ import 'package:flutter/material.dart'; +import 'package:fluttering_vikunja/components/GravatarImage.dart'; import 'package:fluttering_vikunja/fragments/namespace.dart'; import 'package:fluttering_vikunja/fragments/placeholder.dart'; +import 'package:fluttering_vikunja/global.dart'; +import 'package:fluttering_vikunja/models/user.dart'; class HomePage extends StatefulWidget { @override @@ -26,39 +29,45 @@ class HomePageState extends State { _addNamespace() { var textController = new TextEditingController(); showDialog( - context: context, - child: new AlertDialog( - contentPadding: const EdgeInsets.all(16.0), - content: new Row(children: [ - Expanded( - child: new TextField( - autofocus: true, - decoration: new InputDecoration( - labelText: 'Namespace', hintText: 'eg. Family Namespace'), - controller: textController, - ), - ) - ]), - actions: [ - new FlatButton( - child: const Text('CANCEL'), - onPressed: () => Navigator.pop(context), - ), - new FlatButton( - child: const Text('ADD'), - onPressed: () { - if (textController.text.isNotEmpty) - setState(() => namespaces.add(textController.text)); - Navigator.pop(context); - }, - ) - ], + context: context, + child: new AlertDialog( + contentPadding: const EdgeInsets.all(16.0), + content: new Row(children: [ + Expanded( + child: new TextField( + autofocus: true, + decoration: new InputDecoration( + labelText: 'Namespace', hintText: 'eg. Family Namespace'), + controller: textController, + ), + ) + ]), + actions: [ + new FlatButton( + child: const Text('CANCEL'), + onPressed: () => Navigator.pop(context), ), - ); + new FlatButton( + child: const Text('ADD'), + onPressed: () { + if (textController.text.isNotEmpty) + setState(() => namespaces.add(textController.text)); + Navigator.pop(context); + }, + ) + ], + ), + ); + } + + @override + void initState() { + super.initState(); } @override Widget build(BuildContext context) { + var currentUser = VikunjaGlobal.of(context).currentUser; List drawerOptions = []; namespaces.asMap().forEach((i, namespace) => drawerOptions.add(new ListTile( leading: const Icon(Icons.folder), @@ -66,6 +75,7 @@ class HomePageState extends State { selected: i == _selectedDrawerIndex, onTap: () => _onSelectItem(i), ))); + return new Scaffold( appBar: AppBar( title: new Text(_selectedDrawerIndex == -1 @@ -75,14 +85,17 @@ class HomePageState extends State { drawer: new Drawer( child: new Column(children: [ new UserAccountsDrawerHeader( - accountEmail: const Text('jonas@try.vikunja.io'), - accountName: const Text('Jonas Franz'), + accountEmail: currentUser == null ? null : Text(currentUser.email), + accountName: currentUser == null ? null : Text(currentUser.username), + currentAccountPicture: currentUser == null ? null : CircleAvatar( + backgroundImage: GravatarImageProvider(currentUser.username) + ), decoration: BoxDecoration( image: DecorationImage( image: AssetImage("assets/graphics/hypnotize.png"), repeat: ImageRepeat.repeat, - colorFilter: ColorFilter.mode(Theme.of(context).primaryColor, BlendMode.multiply) - ), + colorFilter: ColorFilter.mode( + Theme.of(context).primaryColor, BlendMode.multiply)), ), ), new Expanded( diff --git a/lib/pages/list_page.dart b/lib/pages/list_page.dart index 0297088..fc29755 100644 --- a/lib/pages/list_page.dart +++ b/lib/pages/list_page.dart @@ -10,10 +10,7 @@ class ListPage extends StatefulWidget { } class _ListPageState extends State { - Map items = { - "Butter": true, - "Milch": false - }; + Map items = {"Butter": true, "Milch": false}; @override Widget build(BuildContext context) { @@ -23,53 +20,57 @@ class _ListPageState extends State { ), body: ListView( padding: EdgeInsets.symmetric(vertical: 8.0), - children: ListTile.divideTiles(context: context, - tiles: items.map((item, checked) => - MapEntry(item, CheckboxListTile( - title: Text(item), - controlAffinity: ListTileControlAffinity.leading, - value: checked, - onChanged: (bool value) => setState(() => items[item] = value), - )) - ).values - ).toList(), + children: ListTile.divideTiles( + context: context, + tiles: items + .map((item, checked) => MapEntry( + item, + CheckboxListTile( + title: Text(item), + controlAffinity: ListTileControlAffinity.leading, + value: checked, + onChanged: (bool value) => + setState(() => items[item] = value), + ))) + .values) + .toList(), ), - floatingActionButton: FloatingActionButton(onPressed: () => _addItem(), child: Icon(Icons.add)), + floatingActionButton: FloatingActionButton( + onPressed: () => _addItem(), child: Icon(Icons.add)), ); } _addItem() { var textController = new TextEditingController(); showDialog( - context: context, - child: new AlertDialog( - contentPadding: const EdgeInsets.all(16.0), - content: new Row(children: [ - Expanded( - child: new TextField( - autofocus: true, - decoration: new InputDecoration( - labelText: 'List Item', - hintText: 'eg. Milk'), - controller: textController, - ), - ) - ]), - actions: [ - new FlatButton( - child: const Text('CANCEL'), - onPressed: () => Navigator.pop(context), - ), - new FlatButton( - child: const Text('ADD'), - onPressed: () { - if (textController.text.isNotEmpty) - setState(() => items[textController.text] = false); - Navigator.pop(context); - }, - ) - ], + context: context, + child: new AlertDialog( + contentPadding: const EdgeInsets.all(16.0), + content: new Row(children: [ + Expanded( + child: new TextField( + autofocus: true, + decoration: new InputDecoration( + labelText: 'List Item', hintText: 'eg. Milk'), + controller: textController, + ), + ) + ]), + actions: [ + new FlatButton( + child: const Text('CANCEL'), + onPressed: () => Navigator.pop(context), ), - ); + new FlatButton( + child: const Text('ADD'), + onPressed: () { + if (textController.text.isNotEmpty) + setState(() => items[textController.text] = false); + Navigator.pop(context); + }, + ) + ], + ), + ); } } diff --git a/lib/pages/login_page.dart b/lib/pages/login_page.dart new file mode 100644 index 0000000..cd229a2 --- /dev/null +++ b/lib/pages/login_page.dart @@ -0,0 +1,100 @@ +import 'package:flutter/material.dart'; +import 'package:fluttering_vikunja/global.dart'; +import 'package:fluttering_vikunja/main.dart'; + +class LoginPage extends StatefulWidget { + @override + _LoginPageState createState() => _LoginPageState(); +} + +final RegExp _url = new RegExp( + r'https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)'); + +class _LoginPageState extends State { + final _formKey = GlobalKey(); + String _server, _username, _password; + bool _loading = false; + + @override + Widget build(BuildContext ctx) { + return Scaffold( + appBar: AppBar( + title: Text('Login to Vikunja'), + ), + body: Builder( + builder: (BuildContext context) => SafeArea( + top: false, + bottom: false, + child: Form( + autovalidate: true, + key: _formKey, + child: ListView( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + children: [ + Padding( + padding: const EdgeInsets.all(8.0), + child: Image( + image: AssetImage('assets/vikunja_logo.png'), + height: 128.0, + semanticLabel: 'Vikunja Logo', + ), + ), + Padding( + padding: EdgeInsets.all(8.0), + child: TextFormField( + onSaved: (serverAddress) => _server = serverAddress, + validator: (address) { + var hasMatch = _url.hasMatch(address); + return hasMatch ? null : 'Invalid URL'; + }, + decoration: new InputDecoration( + labelText: 'Server Address'), + ), + ), + Padding( + padding: const EdgeInsets.all(8.0), + child: TextFormField( + onSaved: (username) => _username = username, + decoration: + new InputDecoration(labelText: 'Username'), + ), + ), + Padding( + padding: const EdgeInsets.all(8.0), + child: TextFormField( + onSaved: (password) => _password = password, + decoration: + new InputDecoration(labelText: 'Password'), + obscureText: true, + ), + ), + ButtonTheme( + height: _loading ? 55.0 : 36.0, + child: RaisedButton( + onPressed: !_loading + ? () { + if (_formKey.currentState.validate()) { + _loginUser(context); + } + } + : null, + child: _loading + ? CircularProgressIndicator() + : Text('Login'), + )) + ], + )), + ), + )); + } + + _loginUser(BuildContext context) async { + setState(() => _loading = true); + var vGlobal = VikunjaGlobal.of(context); + var newUser = await vGlobal.userService.login(_username, _password); + vGlobal.changeUser(newUser.user, token: newUser.token); + setState(() { + _loading = false; + }); + } +} diff --git a/lib/service/services.dart b/lib/service/services.dart new file mode 100644 index 0000000..82428ee --- /dev/null +++ b/lib/service/services.dart @@ -0,0 +1,32 @@ +import 'dart:async'; + +import 'package:fluttering_vikunja/models/namespace.dart'; +import 'package:fluttering_vikunja/models/task.dart'; +import 'package:fluttering_vikunja/models/user.dart'; + +abstract class NamespaceService { + Future> getAll(); + Future get(int namespaceId); + Future create(Namespace ns); + Future update(Namespace ns); + Future delete(int namespaceId); +} + +abstract class ListService { + Future> getAll(); + Future get(int listId); + Future> getByNamespace(int namespaceId); + Future create(TaskList tl); + Future update(TaskList tl); + Future delete(int listId); +} + +abstract class TaskService { + Future update(Task task); + Future delete(int taskId); +} + +abstract class UserService { + Future login(String username, password); + Future get(int userId); +} \ No newline at end of file diff --git a/pubspec.yaml b/pubspec.yaml index bd1d234..586f860 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -19,6 +19,7 @@ dependencies: # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^0.1.2 + flutter_secure_storage: 3.1.1 dev_dependencies: flutter_test: @@ -79,6 +80,7 @@ flutter: assets: - assets/graphics/background.jpg - assets/graphics/hypnotize.png + - assets/vikunja_logo.png fonts: - family: Quicksand fonts: