Add login view

Add services and models
Add mocks
This commit is contained in:
Jonas Franz 2018-09-16 21:47:33 +02:00
parent 0e6d7778ea
commit 4c986b85df
No known key found for this signature in database
GPG Key ID: 506AEEBE80BEDECD
14 changed files with 481 additions and 79 deletions

View File

@ -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

12
lib/api/client.dart Normal file
View File

@ -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;
}

View File

@ -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()
);
}

99
lib/global.dart Normal file
View File

@ -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<VikunjaGlobal> {
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;
}
}

View File

@ -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()),
);
}
}
}

24
lib/managers/user.dart Normal file
View File

@ -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<List<int>> 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;
}
}

25
lib/models/namespace.dart Normal file
View File

@ -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<String, dynamic> 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']);
}

58
lib/models/task.dart Normal file
View File

@ -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<String, dynamic> 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<Task> tasks;
TaskList(
{@required this.id,
@required this.title,
this.description,
this.owner,
this.created,
this.updated,
@required this.tasks});
TaskList.fromJson(Map<String, dynamic> 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));
}

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

@ -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<String, dynamic> json)
: id = json['id'],
email = json['email'],
username = json['username'];
}
class UserTokenPair {
final User user;
final String token;
UserTokenPair(this.user, this.token);
}

View File

@ -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<HomePage> {
_addNamespace() {
var textController = new TextEditingController();
showDialog(
context: context,
child: new AlertDialog(
contentPadding: const EdgeInsets.all(16.0),
content: new Row(children: <Widget>[
Expanded(
child: new TextField(
autofocus: true,
decoration: new InputDecoration(
labelText: 'Namespace', hintText: 'eg. Family Namespace'),
controller: textController,
),
)
]),
actions: <Widget>[
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: <Widget>[
Expanded(
child: new TextField(
autofocus: true,
decoration: new InputDecoration(
labelText: 'Namespace', hintText: 'eg. Family Namespace'),
controller: textController,
),
)
]),
actions: <Widget>[
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<Widget> drawerOptions = <Widget>[];
namespaces.asMap().forEach((i, namespace) => drawerOptions.add(new ListTile(
leading: const Icon(Icons.folder),
@ -66,6 +75,7 @@ class HomePageState extends State<HomePage> {
selected: i == _selectedDrawerIndex,
onTap: () => _onSelectItem(i),
)));
return new Scaffold(
appBar: AppBar(
title: new Text(_selectedDrawerIndex == -1
@ -75,14 +85,17 @@ class HomePageState extends State<HomePage> {
drawer: new Drawer(
child: new Column(children: <Widget>[
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(

View File

@ -10,10 +10,7 @@ class ListPage extends StatefulWidget {
}
class _ListPageState extends State<ListPage> {
Map<String, bool> items = {
"Butter": true,
"Milch": false
};
Map<String, bool> items = {"Butter": true, "Milch": false};
@override
Widget build(BuildContext context) {
@ -23,53 +20,57 @@ class _ListPageState extends State<ListPage> {
),
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: <Widget>[
Expanded(
child: new TextField(
autofocus: true,
decoration: new InputDecoration(
labelText: 'List Item',
hintText: 'eg. Milk'),
controller: textController,
),
)
]),
actions: <Widget>[
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: <Widget>[
Expanded(
child: new TextField(
autofocus: true,
decoration: new InputDecoration(
labelText: 'List Item', hintText: 'eg. Milk'),
controller: textController,
),
)
]),
actions: <Widget>[
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);
},
)
],
),
);
}
}

100
lib/pages/login_page.dart Normal file
View File

@ -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<LoginPage> {
final _formKey = GlobalKey<FormState>();
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: <Widget>[
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;
});
}
}

32
lib/service/services.dart Normal file
View File

@ -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<List<Namespace>> getAll();
Future<Namespace> get(int namespaceId);
Future<Namespace> create(Namespace ns);
Future<Namespace> update(Namespace ns);
Future delete(int namespaceId);
}
abstract class ListService {
Future<List<TaskList>> getAll();
Future<TaskList> get(int listId);
Future<List<TaskList>> getByNamespace(int namespaceId);
Future<TaskList> create(TaskList tl);
Future<TaskList> update(TaskList tl);
Future delete(int listId);
}
abstract class TaskService {
Future<Task> update(Task task);
Future delete(int taskId);
}
abstract class UserService {
Future<UserTokenPair> login(String username, password);
Future<User> get(int userId);
}

View File

@ -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: