diff --git a/lib/main.dart b/lib/main.dart index 1c6fe70..31468f8 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -9,21 +9,25 @@ import 'package:provider/provider.dart'; import 'package:sentry/sentry.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:sliceit/providers/invites.dart'; +import 'package:tuple/tuple.dart'; +import './services/api.dart'; import './providers/account.dart'; import './providers/auth.dart'; import './providers/groups.dart'; import './providers/theme.dart'; -import './screens/edit_email.dart'; -import './screens/edit_name.dart'; +import './providers/expenses.dart'; +import './screens/root.dart'; import './screens/forgot_password.dart'; import './screens/group.dart'; import './screens/group_invites.dart'; import './screens/login.dart'; import './screens/register.dart'; -import './screens/root.dart'; import './screens/settings.dart'; -import './services/api.dart'; +import './screens/edit_email.dart'; +import './screens/edit_name.dart'; +import './screens/new_payment.dart'; +import './screens/new_expense.dart'; import './widgets/no_animation_material_page_route.dart'; Future main() async { @@ -68,7 +72,7 @@ class MyApp extends StatefulWidget { } class _MyAppState extends State { - final Api _api = Api(); + final navigatorKey = GlobalKey(); ThemeProvider themeProvider; @override @@ -83,6 +87,29 @@ class _MyAppState extends State { themeProvider.themeType = preferredTheme; } + Future _showForceLogoutDialog() async { + final context = navigatorKey.currentState.overlay.context; + Navigator.pushNamedAndRemoveUntil(context, '/', (_) => false); + Provider.of(context, listen: false).setForceLogoutTimestamp(null); + await showPlatformDialog( + context: context, + androidBarrierDismissible: false, + builder: (_) { + return PlatformAlertDialog( + title: const Text('Unauthorized'), + content: const Text('Session expired, you will be logged out now'), + actions: [ + PlatformDialogAction( + child: const Text('OK'), + onPressed: () { + Navigator.of(context).pop(); + }, + ) + ], + ); + }); + } + Route _generateRoute(RouteSettings settings) { switch (settings.name) { case Root.routeName: @@ -127,8 +154,21 @@ class _MyAppState extends State { builder: (context) => GroupInvitesScreen( groupId: settings.arguments, ), + fullscreenDialog: true, settings: settings, ); + case NewPaymentScreen.routeName: + return platformPageRoute( + context: context, + builder: (context) => NewPaymentScreen(), + fullscreenDialog: true, + ); + case NewExpenseScreen.routeName: + return platformPageRoute( + context: context, + builder: (context) => NewExpenseScreen(), + fullscreenDialog: true, + ); case SettingsScreen.routeName: return platformPageRoute( context: context, @@ -157,34 +197,77 @@ class _MyAppState extends State { return MultiProvider( providers: [ ChangeNotifierProvider( - create: (_) => Auth(_api), - ), - ChangeNotifierProvider( - create: (_) => AccountProvider(_api), + create: (_) => Api(), ), ChangeNotifierProvider( create: (_) => themeProvider, ), - ChangeNotifierProxyProvider( - create: (_) => GroupsProvider(api: _api, isAuthenticated: false), - update: (_, auth, previous) => GroupsProvider( - api: _api, - isAuthenticated: auth.isAuthenticated, - ), + ChangeNotifierProxyProvider( + create: (_) => Auth()..restoreTokens(), + update: (_, api, auth) { + if (api.forceLogoutTimestamp() != null) { + auth.logout(); + } + return auth; + }, + ), + ChangeNotifierProxyProvider( + create: (_) => AccountProvider(), + update: (_, api, account) { + if (api.forceLogoutTimestamp() != null) { + account.reset(); + } + return account; + }, + ), + ChangeNotifierProxyProvider2( + create: (_) => GroupsProvider(), + update: (_, auth, api, groups) { + if (api.forceLogoutTimestamp() != null) { + groups.reset(); + } + groups.isAuthenticated = auth.isAuthenticated; + return groups; + }), + ChangeNotifierProxyProvider( + create: (_) => InvitesProvider(), + update: (_, api, invites) { + if (api.forceLogoutTimestamp() != null) { + invites.reset(); + } + return invites; + }, ), ChangeNotifierProvider( - create: (_) => InvitesProvider(_api), + create: (_) => ExpensesProvider(), ), ], - child: Consumer( - builder: (_, theme, __) => PlatformApp( - title: 'Sliceit', - initialRoute: Root.routeName, - android: (_) => MaterialAppData( - theme: theme.currentTheme, - ), - onGenerateRoute: _generateRoute, - ), + child: Selector2>( + selector: ( + _, + theme, + api, + ) => + Tuple2(theme.currentTheme, api.forceLogoutTimestamp()), + builder: (_, data, __) { + if (data.item2 != null) { + _showForceLogoutDialog(); + } + return PlatformApp( + title: 'Sliceit', + initialRoute: Root.routeName, + navigatorKey: navigatorKey, + ios: (_) => CupertinoAppData( + theme: CupertinoThemeData( + brightness: data.item1.brightness, + ), + ), + android: (_) => MaterialAppData( + theme: data.item1, + ), + onGenerateRoute: _generateRoute, + ); + }, ), ); } diff --git a/lib/models/expense.dart b/lib/models/expense.dart index fb13303..e0285c4 100644 --- a/lib/models/expense.dart +++ b/lib/models/expense.dart @@ -3,11 +3,12 @@ import 'package:flutter/foundation.dart'; class Expense { final String id; final String name; - final double amount; + final int amount; final String currency; final DateTime date; final bool isPayment; final String payerId; + final String groupId; final DateTime createdAt; final DateTime updatedAt; @@ -17,9 +18,26 @@ class Expense { @required this.amount, @required this.currency, @required this.payerId, + @required this.groupId, @required this.createdAt, @required this.date, this.updatedAt, this.isPayment = false, }); + + factory Expense.fromJson(Map json) { + return Expense( + id: json['id'], + name: json['name'], + amount: json['amount'], + currency: json['currency'], + payerId: json['payerId'], + groupId: json['groupId'], + isPayment: json['isPayment'], + date: DateTime.parse(json['date']), + createdAt: DateTime.parse(json['createdAt']), + updatedAt: + json['updatedAt'] != null ? DateTime.parse(json['updatedAt']) : null, + ); + } } diff --git a/lib/models/group.dart b/lib/models/group.dart index 0245959..4a281a3 100644 --- a/lib/models/group.dart +++ b/lib/models/group.dart @@ -1,4 +1,5 @@ import 'package:flutter/foundation.dart'; +import 'package:provider/provider.dart'; import './member.dart'; @@ -46,4 +47,8 @@ class Group { json.map((json) => Group.fromJson(json)).toList(); return result; } + + String memberFirstNameByUserId(String userId) { + return members.firstWhere((member) => member.userId == userId).firstName; + } } diff --git a/lib/models/invite.dart b/lib/models/invite.dart index 20d5396..898bef8 100644 --- a/lib/models/invite.dart +++ b/lib/models/invite.dart @@ -26,4 +26,10 @@ class Invite { json['updatedAt'] != null ? DateTime.parse(json['updatedAt']) : null, ); } + + @override + int get hashCode => id.hashCode; + + @override + bool operator ==(Object other) => other is Invite && other.id == id; } diff --git a/lib/models/member.dart b/lib/models/member.dart index 9c83b3e..dcfbe98 100644 --- a/lib/models/member.dart +++ b/lib/models/member.dart @@ -21,7 +21,7 @@ class Member { factory Member.fromJson(Map json) { return Member( - id: json['user']['id'], + id: json['id'], userId: json['user']['id'], groupId: json['groupId'], firstName: json['user']['firstName'], diff --git a/lib/providers/account.dart b/lib/providers/account.dart index 9b557ce..42a11ff 100644 --- a/lib/providers/account.dart +++ b/lib/providers/account.dart @@ -3,11 +3,9 @@ import '../models/account.dart'; import '../services/api.dart'; class AccountProvider extends BaseProvider { - final Api api; + final Api _api = Api(); Account _account; - AccountProvider(this.api); - Account get account => _account; String get fullName => account?.fullName ?? ''; @@ -17,14 +15,14 @@ class AccountProvider extends BaseProvider { bool get hasAvatar => account?.avatar != null; Future fetchAccount() async { - Account account = await api.fetchAccount(); + Account account = await _api.fetchAccount(); _account = account; notifyListeners(); } Future updateAccount( {String email, String firstName, String lastName}) async { - Account account = await api.updateAccount( + Account account = await _api.updateAccount( email: email ?? _account.email, firstName: firstName ?? _account.firstName, lastName: lastName ?? _account.lastName, @@ -37,7 +35,7 @@ class AccountProvider extends BaseProvider { status = Status.PENDING; try { - final response = await api.uploadAvatar(path); + final response = await _api.uploadAvatar(path); if (response['status']) { _account.avatar = response['data']['url']; status = Status.RESOLVED; @@ -50,14 +48,18 @@ class AccountProvider extends BaseProvider { } Future removeAvatar() async { - await api.removeAvatar(); + await _api.removeAvatar(); _account.avatar = null; notifyListeners(); } Future deleteAccount() async { - await api.deleteAccount(); + await _api.deleteAccount(); _account = null; notifyListeners(); } + + void reset() { + _account = null; + } } diff --git a/lib/providers/auth.dart b/lib/providers/auth.dart index 66d9154..3168b54 100644 --- a/lib/providers/auth.dart +++ b/lib/providers/auth.dart @@ -8,18 +8,17 @@ import '../utils/constants.dart'; class Auth extends BaseProvider { final _storage = FlutterSecureStorage(); - final Api api; + final Api _api = Api(); String _accessToken; - Auth(this.api); - - get isAuthenticated => _accessToken != null; - get isFetching => status == Status.PENDING; + bool get isAuthenticated => _accessToken != null; + bool get isFetching => status == Status.PENDING; Future restoreTokens() async { String accessToken = await _storage.read(key: ACCESS_TOKEN_KEY); + if (accessToken != _accessToken) { - api.accessToken = accessToken; + _api.accessToken = accessToken; _accessToken = accessToken; notifyListeners(); } @@ -29,7 +28,7 @@ class Auth extends BaseProvider { status = Status.PENDING; try { - final res = await api.login(email, password); + final res = await _api.login(email, password); _accessToken = res['accessToken']; status = Status.RESOLVED; } catch (err) { @@ -47,7 +46,7 @@ class Auth extends BaseProvider { status = Status.PENDING; try { - final res = await api.register( + final res = await _api.register( firstName: firstName, lastName: lastName, email: email, @@ -64,7 +63,7 @@ class Auth extends BaseProvider { Future logout() async { await _storage.deleteAll(); _accessToken = null; - api.accessToken = null; + _api.accessToken = null; status = Status.IDLE; } } diff --git a/lib/providers/expenses.dart b/lib/providers/expenses.dart new file mode 100644 index 0000000..7ef6df7 --- /dev/null +++ b/lib/providers/expenses.dart @@ -0,0 +1,117 @@ +import 'package:flutter/foundation.dart'; + +import './base.dart'; +import '../models/expense.dart'; +import '../services/api.dart'; + +class ExpensesProvider extends BaseProvider { + final Api _api = Api(); + final Map> _expensesByGroupId = {}; + + List byGroupId(String groupId) { + if (_expensesByGroupId.containsKey(groupId)) { + return _expensesByGroupId[groupId]; + } + + return []; + } + + int countByGroupId(String groupId) { + if (_expensesByGroupId.containsKey(groupId)) { + return _expensesByGroupId[groupId].length; + } + return 0; + } + + Future> fetchExpensesPage(String groupId, int page) async { + status = Status.PENDING; + + try { + final List groupExpenses = + await _api.fetchExpensesPage(groupId, page); + if (_expensesByGroupId.containsKey(groupId)) { + if (page == 1) { + _expensesByGroupId[groupId] = groupExpenses; + } else { + _expensesByGroupId[groupId].addAll(groupExpenses); + } + } else { + _expensesByGroupId[groupId] = groupExpenses; + } + status = Status.RESOLVED; + return groupExpenses; + } catch (e) { + status = Status.REJECTED; + rethrow; + } + } + + Future createExpense({ + @required String groupId, + @required String name, + @required int amount, + @required String payerId, + @required List> shares, + @required String currency, + @required String date, + }) async { + status = Status.PENDING; + try { + final Expense expense = await _api.createExpense( + groupId: groupId, + name: name, + amount: amount, + shares: shares, + payerId: payerId, + currency: currency, + date: date, + ); + if (_expensesByGroupId.containsKey(groupId)) { + _expensesByGroupId[groupId].insert(0, expense); + } else { + _expensesByGroupId[groupId] = [expense]; + } + // TODO: Apply balance to the group! + status = Status.RESOLVED; + } catch (e) { + status = Status.REJECTED; + rethrow; + } + } + + Future createPayment({ + @required String groupId, + @required int amount, + @required String from, + @required String to, + @required String currency, + @required String date, + }) async { + status = Status.PENDING; + try { + final Expense payment = await _api.createPayment( + groupId: groupId, + amount: amount, + from: from, + to: to, + currency: currency, + date: date, + ); + if (_expensesByGroupId.containsKey(groupId)) { + _expensesByGroupId[groupId].insert(0, payment); + } else { + _expensesByGroupId[groupId] = [payment]; + } + // TODO: Apply balance to the group! + status = Status.RESOLVED; + } catch (e) { + status = Status.REJECTED; + rethrow; + } + } + +// TODO: reset + void reset() { + _expensesByGroupId.clear(); + } +} diff --git a/lib/providers/groups.dart b/lib/providers/groups.dart index 60bbe31..05a1146 100644 --- a/lib/providers/groups.dart +++ b/lib/providers/groups.dart @@ -6,33 +6,19 @@ import '../models/account.dart'; import '../models/member.dart'; import '../services/api.dart'; -class GroupsProvider with ChangeNotifier { - final Api api; +class GroupsProvider extends BaseProvider { + final Api _api = Api(); final List _groups = []; int _selectedGroupIndex = 0; - // FIXME: Figure out better way of doing this - // Not using BaseProvider class, due to notifyListeners() called after provider is disposed() - Status _status = Status.IDLE; - String _selectedGroupId; int _lastFetchedTimestamp; - GroupsProvider({ - @required this.api, - @required bool isAuthenticated, - }) { - if (isAuthenticated) { + set isAuthenticated(bool authenticated) { + if (authenticated) { fetchGroups(); } } - get status => _status; - - set status(Status newStatus) { - _status = newStatus; - notifyListeners(); - } - List get groups { return _groups; } @@ -59,6 +45,13 @@ class GroupsProvider with ChangeNotifier { : null; } + String memberFirstName(String userId) { + return _groups[_selectedGroupIndex] + .members + .firstWhere((member) => member.userId == userId) + .firstName; + } + selectGroup(String id) { int groupIndex = _groups.indexWhere((group) => group.id == id); if (groupIndex != -1) { @@ -76,7 +69,7 @@ class GroupsProvider with ChangeNotifier { status = Status.PENDING; try { - final List groups = await api.fetchGroups(); + final List groups = await _api.fetchGroups(); _groups.clear(); _groups.addAll(groups); if (_groups.isNotEmpty) { @@ -86,14 +79,14 @@ class GroupsProvider with ChangeNotifier { status = Status.RESOLVED; } catch (e) { status = Status.REJECTED; - throw e; + rethrow; } } Future fetchGroup(String id) async { int groupIndex = _groups.indexWhere((group) => group.id == id); if (groupIndex != -1) { - final Group group = await api.fetchGroup(id); + final Group group = await _api.fetchGroup(id); _groups[groupIndex] = group; notifyListeners(); } @@ -101,7 +94,7 @@ class GroupsProvider with ChangeNotifier { Future createGroup( {String name, String currency, Account member}) async { - final Group group = await api.createGroup(name: name, currency: currency); + final Group group = await _api.createGroup(name: name, currency: currency); _groups.add(group); _selectedGroupIndex = _groups.length - 1; _selectedGroupId = group.id; @@ -112,7 +105,7 @@ class GroupsProvider with ChangeNotifier { {String groupId, String name, String currency}) async { final groupIndex = _groups.indexWhere((group) => group.id == groupId); if (groupIndex != -1) { - final Group updatedGroup = await api.updateGroup( + final Group updatedGroup = await _api.updateGroup( groupId: groupId, name: name, currency: currency, @@ -123,7 +116,7 @@ class GroupsProvider with ChangeNotifier { } Future deleteGroup(String groupId) async { - await api.deleteGroup(groupId); + await _api.deleteGroup(groupId); _groups.removeWhere((group) => group.id == groupId); if (_groups.isNotEmpty) { _selectedGroupIndex = 0; diff --git a/lib/providers/invites.dart b/lib/providers/invites.dart index 8535bc0..ded865f 100644 --- a/lib/providers/invites.dart +++ b/lib/providers/invites.dart @@ -5,11 +5,9 @@ import '../models/invite.dart'; import '../services/api.dart'; class InvitesProvider extends BaseProvider { - final Api api; + final Api _api = Api(); final Map> _invitesByGroupId = {}; - InvitesProvider(this.api); - get isFetching => status == Status.PENDING; List byGroupId(String groupId) { @@ -20,29 +18,24 @@ class InvitesProvider extends BaseProvider { return []; } - int byGroupIdCount(String groupId) { + int countByGroupId(String groupId) { if (_invitesByGroupId.containsKey(groupId)) { return _invitesByGroupId[groupId].length; } return 0; } - Future fetchGroupInvites(String groupId) async { - status = Status.PENDING; - try { - final List groupInvites = await api.fetchGroupInvites(groupId); - _invitesByGroupId[groupId] = groupInvites; - status = Status.RESOLVED; - } catch (e) { - status = Status.REJECTED; - throw e; - } + Future> fetchGroupInvites(String groupId) async { + final List groupInvites = await _api.fetchGroupInvites(groupId); + _invitesByGroupId[groupId] = groupInvites; + notifyListeners(); + return groupInvites; } Future createInvite(String groupId, String email) async { status = Status.PENDING; try { - final Invite invite = await api.createInvite(groupId, email); + final Invite invite = await _api.createInvite(groupId, email); if (invite != null) { if (_invitesByGroupId?.containsKey(groupId) ?? false) { _invitesByGroupId[groupId].add(invite); @@ -73,11 +66,16 @@ class InvitesProvider extends BaseProvider { _invitesByGroupId[groupId].removeAt(inviteIndex); notifyListeners(); try { - await api.deleteGroupInvite(groupId, inviteId); + await _api.deleteGroupInvite(groupId, inviteId); } catch (err) { groupInvites.insert(inviteIndex, invite); notifyListeners(); } } } + + void reset() { + _invitesByGroupId.clear(); + status = Status.PENDING; + } } diff --git a/lib/screens/group_invites.dart b/lib/screens/group_invites.dart index 3ebca9c..0eac716 100644 --- a/lib/screens/group_invites.dart +++ b/lib/screens/group_invites.dart @@ -3,8 +3,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; import 'package:provider/provider.dart'; -import '../providers/invites.dart'; import '../providers/groups.dart'; +import '../providers/invites.dart'; import '../services/api.dart'; class GroupInvitesScreen extends StatefulWidget { @@ -18,16 +18,14 @@ class GroupInvitesScreen extends StatefulWidget { } class _GroupInvitesScreenState extends State { + Future _fetchInvitesFuture; TextEditingController _emailController; @override void initState() { super.initState(); _emailController = TextEditingController(); - // https://www.didierboelens.com/2019/04/addpostframecallback/ - WidgetsBinding.instance.addPostFrameCallback((_) { - _fetchInvites(); - }); + _fetchInvites(); } @override @@ -37,10 +35,10 @@ class _GroupInvitesScreenState extends State { } Future _fetchInvites() async { - await Provider.of( - context, - listen: false, - ).fetchGroupInvites(widget.groupId); + _fetchInvitesFuture = Future.microtask(() => Provider.of( + context, + listen: false, + ).fetchGroupInvites(widget.groupId)); } Future _addInvite() async { @@ -98,125 +96,166 @@ class _GroupInvitesScreenState extends State { appBar: PlatformAppBar( title: const Text('Invites'), ), - body: GestureDetector( - onTap: () { - FocusScopeNode currentFocus = FocusScope.of(context); - if (!currentFocus.hasPrimaryFocus) { - currentFocus.unfocus(); - } - }, - child: Column( - children: [ - Flexible( - child: (invites.isFetching && - invites.byGroupIdCount(widget.groupId) == 0) - ? Center( - child: PlatformCircularProgressIndicator(), - ) - : invites.byGroupIdCount(widget.groupId) == 0 - ? Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - CircleAvatar( - radius: 24, - child: Container( - alignment: Alignment.center, - child: const Icon( - Icons.email, - size: 24, - ), - ), - ), - const SizedBox(height: 16), - Text( - 'No invites', - style: Theme.of(context) - .textTheme - .body2 - .copyWith(fontSize: 18), - textAlign: TextAlign.center, + body: SafeArea( + child: GestureDetector( + onTap: () { + FocusScopeNode currentFocus = FocusScope.of(context); + if (!currentFocus.hasPrimaryFocus) { + currentFocus.unfocus(); + } + }, + child: Column( + children: [ + Flexible( + child: FutureBuilder( + future: _fetchInvitesFuture, + builder: (_, AsyncSnapshot snapshot) { + if (snapshot.hasData) { + return invites.countByGroupId(widget.groupId) == 0 + ? Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircleAvatar( + radius: 24, + child: Container( + alignment: Alignment.center, + child: const Icon( + Icons.email, + size: 24, + ), + ), + ), + const SizedBox(height: 16), + Text( + 'No invites', + style: Theme.of(context) + .textTheme + .body2 + .copyWith(fontSize: 18), + textAlign: TextAlign.center, + ), + const SizedBox(height: 4), + SizedBox( + width: MediaQuery.of(context).size.width * + 0.50, + child: Text( + 'Add an invite by entering an email and pressing the button below', + style: + Theme.of(context).textTheme.caption, + textAlign: TextAlign.center, + ), + ), + ], + ) + : ListView.builder( + padding: + const EdgeInsets.symmetric(vertical: 8), + itemCount: + invites.countByGroupId(widget.groupId), + itemBuilder: (_, i) { + final invite = + invites.byGroupId(widget.groupId)[i]; + return ListTile( + title: Text( + invite.email, + ), + trailing: IconButton( + icon: Icon( + Icons.delete_outline, + color: Theme.of(context).errorColor, + ), + onPressed: () => _deleteInvite( + invite.id, + ), + ), + ); + }, + ); + } else if (snapshot.hasError) { + return Center( + child: Text('Error'), + ); + } + return Center( + child: PlatformCircularProgressIndicator(), + ); + }), + ), + const Divider(height: 1), + Container( + decoration: BoxDecoration( + color: Theme.of(context).platform == TargetPlatform.iOS + ? CupertinoTheme.of(context).scaffoldBackgroundColor + : Theme.of(context).cardColor, + ), + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + Expanded( + child: PlatformTextField( + autofocus: true, + autocorrect: false, + controller: _emailController, + keyboardType: TextInputType.emailAddress, + textInputAction: TextInputAction.send, + android: (_) => MaterialTextFieldData( + decoration: const InputDecoration.collapsed( + hintText: 'Email', ), - const SizedBox(height: 4), - SizedBox( - width: MediaQuery.of(context).size.width * 0.50, - child: Text( - 'Add an invite by entering an email and pressing the button below', - style: Theme.of(context).textTheme.caption, - textAlign: TextAlign.center, - ), + ), + ios: (_) => CupertinoTextFieldData( + padding: const EdgeInsets.symmetric( + horizontal: 12.0, + vertical: 6.0, ), - ], - ) - : ListView.builder( - padding: const EdgeInsets.symmetric(vertical: 8), - itemCount: invites.byGroupIdCount(widget.groupId), - itemBuilder: (_, i) { - final invite = invites.byGroupId(widget.groupId)[i]; - return ListTile( - title: Text( - invite.email, - ), - trailing: IconButton( - icon: Icon( - Icons.delete_outline, - color: Theme.of(context).errorColor, - ), - onPressed: () => _deleteInvite( - invite.id, + placeholder: 'Email', + decoration: BoxDecoration( + border: Border.all( + color: CupertinoDynamicColor.withBrightness( + color: Color(0x33000000), + darkColor: Color(0x33FFFFFF), ), ), - ); + borderRadius: BorderRadius.circular(200), + ), + ), + onSubmitted: (_) { + if (!invites.isFetching) { + _addInvite(); + } }, ), - ), - const Divider(height: 1), - Container( - decoration: BoxDecoration( - color: Theme.of(context).cardColor, - ), - child: Container( - margin: const EdgeInsets.symmetric(horizontal: 16), - child: Row( - children: [ - Flexible( - child: PlatformTextField( - autofocus: true, - autocorrect: false, - controller: _emailController, - keyboardType: TextInputType.emailAddress, - textInputAction: TextInputAction.send, - android: (_) => MaterialTextFieldData( - decoration: const InputDecoration.collapsed( - hintText: 'Email', + ), + SizedBox(height: 1, width: 6), + FittedBox( + fit: BoxFit.contain, + child: Container( + padding: EdgeInsets.all(2), + child: GestureDetector( + onTap: invites.isFetching ? null : _addInvite, + child: Container( + padding: EdgeInsets.all(8), + decoration: BoxDecoration( + color: Theme.of(context).primaryColor, + borderRadius: BorderRadius.circular(200), + ), + child: PlatformWidget( + ios: (_) => const Icon( + CupertinoIcons.up_arrow, + color: Colors.white, + ), + android: (_) => const Icon(Icons.send)), + ), ), ), - ios: (_) => CupertinoTextFieldData( - placeholder: 'Email', - ), - onSubmitted: (_) { - if (!invites.isFetching) { - _addInvite(); - } - }, - ), - ), - PlatformIconButton( - ios: (_) => CupertinoIconButtonData( - color: Theme.of(context).primaryColor, - borderRadius: BorderRadius.circular(200), - ), - onPressed: invites.isFetching ? null : _addInvite, - iosIcon: const Icon( - CupertinoIcons.up_arrow, - color: Colors.white, ), - androidIcon: const Icon(Icons.send), - ), - ], + ], + ), ), ), - ) - ], + ], + ), ), ), ); diff --git a/lib/screens/home.dart b/lib/screens/home.dart index 13a463f..2eec158 100644 --- a/lib/screens/home.dart +++ b/lib/screens/home.dart @@ -5,6 +5,8 @@ import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; import 'package:flutter_speed_dial/flutter_speed_dial.dart'; import './group.dart'; +import './new_payment.dart'; +import './new_expense.dart'; import '../models/group.dart'; import '../providers/account.dart'; import '../providers/groups.dart'; @@ -28,12 +30,12 @@ class HomeScreen extends StatefulWidget { } class _HomeScreenState extends State { - final _iosTabs = [ + final _iosTabs = const [ BottomNavigationBarItem( - icon: Icon(CupertinoIcons.home), + icon: const Icon(CupertinoIcons.home), ), BottomNavigationBarItem( - icon: Icon(CupertinoIcons.shopping_cart), + icon: const Icon(CupertinoIcons.shopping_cart), ) ]; @@ -47,23 +49,32 @@ class _HomeScreenState extends State { return _AndroidHome(); } - Widget _tabContent(BuildContext context, int i) { - return i == 0 ? BalanceList() : ExpensesList(); + Widget _tabContent(BuildContext context, int index) { + switch (index) { + case 0: + return CupertinoTabView(builder: (context) { + return BalanceList(); + }); + case 1: + return CupertinoTabView(builder: (context) { + return CupertinoPageScaffold( + child: ExpensesList(), + ); + }); + default: + return Container(); + } } @override Widget build(BuildContext context) { return PlatformWidget( android: _buildAndroidHome, - ios: (_) => PlatformTabScaffold( - appBarBuilder: (_, i) => PlatformAppBar( - title: Selector( - selector: (_, groups) => groups.selectedGroup, - builder: (_, selectedGroup, __) => Text(selectedGroup.name), - ), + ios: (_) => CupertinoTabScaffold( + tabBar: CupertinoTabBar( + items: _iosTabs, ), - bodyBuilder: _tabContent, - items: _iosTabs, + tabBuilder: _tabContent, ), ); } @@ -149,32 +160,44 @@ class _AndroidHome extends StatelessWidget { ), body: TabBarView(children: _tabsViews), drawer: const AppDrawer(), - floatingActionButton: SpeedDial( - tooltip: 'Add Expense or Payment', - child: const Icon(Icons.add), - visible: true, - curve: Curves.decelerate, - overlayOpacity: theme.brightness == Brightness.dark ? 0.54 : 0.8, - overlayColor: - theme.brightness == Brightness.dark ? Colors.black : Colors.white, - children: [ - SpeedDialChild( - child: const Icon(Icons.shopping_basket), - onTap: () {}, - labelWidget: const SpeedDialLabel( - title: 'New Expense', - subTitle: 'A purchase made for the group', + floatingActionButton: Selector( + selector: (_, groups) => groups.selectedGroup, + builder: (_, selectedGroup, __) => SpeedDial( + tooltip: 'Add Expense or Payment', + child: const Icon(Icons.add), + visible: true, + curve: Curves.decelerate, + overlayOpacity: theme.brightness == Brightness.dark ? 0.54 : 0.8, + overlayColor: theme.brightness == Brightness.dark + ? Colors.black + : Colors.white, + children: [ + SpeedDialChild( + child: const Icon(Icons.shopping_basket), + onTap: () { + Navigator.of(context).pushNamed( + NewExpenseScreen.routeName, + ); + }, + labelWidget: const SpeedDialLabel( + title: 'New Expense', + subTitle: 'A purchase made for the group', + ), ), - ), - SpeedDialChild( - child: const Icon(Icons.account_balance_wallet), - onTap: () => {}, - labelWidget: const SpeedDialLabel( - title: 'New Payment', - subTitle: 'Record a payment made in the group', + SpeedDialChild( + child: const Icon(Icons.account_balance_wallet), + onTap: () { + Navigator.of(context).pushNamed( + NewPaymentScreen.routeName, + ); + }, + labelWidget: const SpeedDialLabel( + title: 'New Payment', + subTitle: 'Record a payment made in the group', + ), ), - ), - ], + ], + ), ), ), ); diff --git a/lib/screens/new_expense.dart b/lib/screens/new_expense.dart new file mode 100644 index 0000000..7c3103d --- /dev/null +++ b/lib/screens/new_expense.dart @@ -0,0 +1,252 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; +import 'package:intl/intl.dart'; +import 'package:tuple/tuple.dart'; +import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; + +import 'package:sliceit/services/api.dart'; +import 'package:sliceit/models/member.dart'; +import 'package:sliceit/models/group.dart'; +import 'package:sliceit/providers/account.dart'; +import 'package:sliceit/providers/groups.dart'; +import 'package:sliceit/providers/expenses.dart'; +import 'package:sliceit/widgets/avatar.dart'; +import 'package:sliceit/widgets/card_input.dart'; +import 'package:sliceit/widgets/card_picker.dart'; + +class NewExpenseScreen extends StatefulWidget { + static const routeName = '/new-expense'; + + const NewExpenseScreen({ + Key key, + }) : super(key: key); + + @override + _NewExpenseScreenState createState() => _NewExpenseScreenState(); +} + +class _NewExpenseScreenState extends State { + TextEditingController _nameController; + TextEditingController _amountController; + FocusNode _amountFocusNode; + List> _participants; + Member _payer; + DateTime _date = new DateTime.now(); + bool _equalSplit = true; + + @override + void initState() { + super.initState(); + _nameController = new TextEditingController(); + _amountController = new TextEditingController(); + _amountFocusNode = new FocusNode(); + final String userId = + Provider.of(context, listen: false).account?.id; + final List groupMembers = + Provider.of(context, listen: false) + .selectedGroupMembers; + _payer = groupMembers.firstWhere((member) => member.userId == userId); + _participants = groupMembers.map((member) { + return Tuple2(member, true); + }).toList(); + } + + @override + dispose() { + _nameController.dispose(); + _amountController.dispose(); + _amountFocusNode.dispose(); + super.dispose(); + } + + void _showErrorMessage(String message) async { + showPlatformDialog( + context: context, + builder: (_) => PlatformAlertDialog( + title: const Text('Error'), + content: Text(message), + actions: [ + PlatformDialogAction( + child: const Text('OK'), + onPressed: () => Navigator.of(context, rootNavigator: true).pop(), + ) + ], + ), + ); + } + + Future _pickDate() async { + if (Theme.of(context).platform == TargetPlatform.iOS) { + // TODO: iOS date picker + } else { + DateTime newDate = await showDatePicker( + context: context, + initialDate: _date, + firstDate: DateTime(2020), + lastDate: DateTime.now(), + ); + + if (newDate != null && !newDate.isAtSameMomentAs(_date)) { + setState(() { + _date = newDate; + }); + } + } + } + + Future _pickMember() async { + List members = Provider.of(context, listen: false) + .selectedGroupMembers; + Member member = await showDialog( + context: context, + builder: (_) { + return SimpleDialog( + children: members.map((member) { + return SimpleDialogOption( + child: Text(member.fullName), + onPressed: () { + Navigator.of(context).pop(member); + }, + ); + }).toList(), + ); + }); + return member; + } + + Future _pickPayer() async { + Member payer = await _pickMember(); + setState(() { + _payer = payer; + }); + } + + Future _handleAddExpense() async { + Group group = + Provider.of(context, listen: false).selectedGroup; + if (group != null) { + try { + final int total = (double.parse(_amountController.text) * 100).floor(); + List participantsIds = _participants + .where((participation) => participation.item2) + .map((p) => p.item1.userId) + .toList(); + await Provider.of(context, listen: false) + .createExpense( + groupId: group.id, + currency: group.currency, + payerId: _payer.userId, + name: _nameController.text, + shares: participantsIds.map((id) { + return {'userId': id, 'amount': total / participantsIds.length}; + }).toList(), + amount: total, + date: _date.toIso8601String(), + ); + Navigator.of(context).pop(); + } on ApiError catch (err) { + _showErrorMessage(err.message); + } catch (e) { + _showErrorMessage('Failed to add expense!'); + } + } + } + + @override + Widget build(BuildContext context) { + return Selector>( + selector: (_, groups) => groups.selectedGroupMembers, + builder: (_, members, __) => PlatformScaffold( + appBar: PlatformAppBar( + trailingActions: [ + PlatformButton( + androidFlat: (_) => MaterialFlatButtonData( + textColor: Colors.white, + ), + child: PlatformText('Add'), + onPressed: _handleAddExpense, + ), + ], + ), + body: SafeArea( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 16), + CardInput( + autofocus: true, + controller: _nameController, + prefixText: 'Name', + onSubmitted: (_) { + FocusScope.of(context).requestFocus(_amountFocusNode); + }, + ), + CardInput( + controller: _amountController, + focusNode: _amountFocusNode, + keyboardType: TextInputType.numberWithOptions(decimal: true), + prefixText: 'Amount', + hintText: '0.00', + inputFormatters: [ + // TODO: Allow allow only 2 decimal places + WhitelistingTextInputFormatter(RegExp('[0-9.]')), + BlacklistingTextInputFormatter(RegExp('\s')), + ], + ), + const SizedBox(height: 16), + CardPicker( + prefix: 'Date', + text: DateFormat.yMMMd().format(_date), + onPressed: _pickDate, + ), + const SizedBox(height: 16), + // TODO: HANDLE UNEVEN SPLITS + // SwitchListTile( + // title: const Text('Split equally'), + // value: _equalSplit, + // onChanged: (bool value) { + // setState(() { + // _equalSplit = value; + // }); + // }, + // ), + CardPicker( + prefix: 'Paid by', + text: _payer?.fullName ?? '', + onPressed: _pickPayer, + ), + ListTile(title: const Text('Participants')), + ..._participants + .asMap() + .map((int i, participant) { + return MapEntry( + i, + CheckboxListTile( + title: Text(participant.item1.fullName), + value: participant.item2, + onChanged: (bool value) { + setState(() { + _participants[i] = + Tuple2(participant.item1, value); + }); + }, + secondary: Avatar( + initals: participant.item1.initials, + avatar: participant.item1.avatar, + ), + ), + ); + }) + .values + .toList(), + const SizedBox(height: 16) + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/screens/new_payment.dart b/lib/screens/new_payment.dart index e062e33..750973f 100644 --- a/lib/screens/new_payment.dart +++ b/lib/screens/new_payment.dart @@ -1,41 +1,219 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; +import 'package:intl/intl.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; +import 'package:sliceit/services/api.dart'; +import 'package:sliceit/models/member.dart'; +import 'package:sliceit/models/group.dart'; +import 'package:sliceit/providers/account.dart'; +import 'package:sliceit/providers/groups.dart'; +import 'package:sliceit/providers/expenses.dart'; +import 'package:sliceit/widgets/card_input.dart'; +import 'package:sliceit/widgets/card_picker.dart'; + class NewPaymentScreen extends StatefulWidget { static const routeName = '/new-payment'; + const NewPaymentScreen({ + Key key, + }) : super(key: key); + @override _NewPaymentScreenState createState() => _NewPaymentScreenState(); } class _NewPaymentScreenState extends State { - void _handleAddPayment() {} + TextEditingController _amountController; + Member _from; + Member _to; + DateTime _date = new DateTime.now(); @override - Widget build(BuildContext context) { - return PlatformScaffold( - appBar: PlatformAppBar( - trailingActions: [ - PlatformButton( - child: PlatformText('Save'), - onPressed: _handleAddPayment, - ), + void initState() { + super.initState(); + _amountController = new TextEditingController(); + final String userId = + Provider.of(context, listen: false).account?.id; + final List groupMembers = + Provider.of(context, listen: false) + .selectedGroupMembers; + final currentMemberIndex = + groupMembers.indexWhere((member) => member.userId == userId); + if (currentMemberIndex != -1) { + _from = groupMembers[currentMemberIndex]; + if (groupMembers.length == 2) { + _to = groupMembers[currentMemberIndex == 0 ? 1 : 0]; + } + } + } + + @override + dispose() { + _amountController.dispose(); + super.dispose(); + } + + void _showErrorMessage(String message) async { + showPlatformDialog( + context: context, + builder: (_) => PlatformAlertDialog( + title: Text('Error'), + content: Text(message), + actions: [ + PlatformDialogAction( + child: Text('OK'), + onPressed: () => Navigator.of(context, rootNavigator: true).pop(), + ) ], ), - body: SafeArea( - child: SingleChildScrollView( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - TextField( - autofocus: true, - textInputAction: TextInputAction.next, - decoration: InputDecoration( - labelText: 'Amount', - ), + ); + } + + Future _pickMember(List members) async { + Member member = await showDialog( + context: context, + builder: (_) { + return SimpleDialog( + children: members.map((member) { + return SimpleDialogOption( + child: Text(member.fullName), + onPressed: () { + Navigator.of(context).pop(member); + }, + ); + }).toList(), + ); + }); + return member; + } + + Future _pickFrom() async { + List groupMembers = + Provider.of(context, listen: false) + .selectedGroupMembers; + Member member = await _pickMember(groupMembers); + if (member != null) { + setState(() { + _from = member; + if (groupMembers.length == 2) { + _to = groupMembers.firstWhere((m) => m.id != member.id); + } + }); + } + } + + Future _pickTo() async { + List groupMembers = + Provider.of(context, listen: false) + .selectedGroupMembers; + Member member = await _pickMember(groupMembers); + if (member != null) { + setState(() { + _to = member; + if (groupMembers.length == 2) { + _from = groupMembers.firstWhere((m) => m.id != member.id); + } + }); + } + } + + Future _pickDate() async { + if (Theme.of(context).platform == TargetPlatform.iOS) { + // TODO: iOS date picker + } else { + DateTime newDate = await showDatePicker( + context: context, + initialDate: _date, + firstDate: DateTime(2020), + lastDate: DateTime.now(), + ); + + if (newDate != null && !newDate.isAtSameMomentAs(_date)) { + setState(() { + _date = newDate; + }); + } + } + } + + Future _handleAddPayment() async { + Group group = + Provider.of(context, listen: false).selectedGroup; + if (group != null) { + try { + await Provider.of(context, listen: false) + .createPayment( + groupId: group.id, + currency: group.currency, + amount: (double.parse(_amountController.text) * 100).floor(), + from: _from.userId, + to: _to.userId, + date: _date.toIso8601String(), + ); + Navigator.of(context).pop(); + } on ApiError catch (err) { + _showErrorMessage(err.message); + } catch (e) { + _showErrorMessage('Failed to add payment!'); + } + } + } + + @override + Widget build(BuildContext context) { + return Selector>( + selector: (_, groups) => groups.selectedGroupMembers, + builder: (_, members, __) => PlatformScaffold( + appBar: PlatformAppBar( + trailingActions: [ + PlatformButton( + androidFlat: (_) => MaterialFlatButtonData( + textColor: Colors.white, ), - ], + child: PlatformText('Save'), + onPressed: _handleAddPayment, + ), + ], + ), + body: SafeArea( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 16), + CardInput( + autofocus: true, + controller: _amountController, + keyboardType: TextInputType.numberWithOptions(decimal: true), + prefixText: 'Amount', + hintText: '0.00', + inputFormatters: [ + // TODO: Allow allow only 2 decimal places + WhitelistingTextInputFormatter(RegExp('[0-9.]')), + BlacklistingTextInputFormatter(RegExp('\s')), + ], + ), + const SizedBox(height: 16), + CardPicker( + prefix: 'From', + text: _from?.fullName ?? '', + onPressed: _pickFrom, + ), + CardPicker( + prefix: 'To', + text: _to?.fullName ?? '', + onPressed: _pickTo, + ), + const SizedBox(height: 16), + CardPicker( + prefix: 'Date', + text: DateFormat.yMMMd().format(_date), + onPressed: _pickDate, + ), + ], + ), ), ), ), diff --git a/lib/screens/root.dart b/lib/screens/root.dart index febb3a4..97157af 100644 --- a/lib/screens/root.dart +++ b/lib/screens/root.dart @@ -3,50 +3,34 @@ import 'package:provider/provider.dart'; import 'package:sliceit/screens/offline.dart'; import 'package:tuple/tuple.dart'; +import './group.dart'; import './home.dart'; import './loading.dart'; import './welcome.dart'; -import './group.dart'; -import '../providers/base.dart'; import '../providers/auth.dart'; +import '../providers/base.dart'; import '../providers/groups.dart'; -class Root extends StatefulWidget { - const Root({Key key}) : super(key: key); - +class Root extends StatelessWidget { static const routeName = '/'; - - @override - _RootState createState() => _RootState(); -} - -class _RootState extends State { - Future _restoreTokens; - - @override - void initState() { - super.initState(); - _restoreTokens = Provider.of(context, listen: false).restoreTokens(); - } + const Root({Key key}) : super(key: key); @override Widget build(BuildContext context) { - return Selector2>( - selector: (_, auth, groups) => - Tuple3(auth.isAuthenticated, groups.status, groups.hasGroups), - builder: (_, data, __) => data.item1 - ? data.item2 == Status.REJECTED + return Selector2>( + selector: (_, auth, groups) => Tuple4( + auth.status, + auth.isAuthenticated, + groups.status, + groups.hasGroups, + ), + builder: (_, data, __) => data.item2 + ? data.item3 == Status.REJECTED ? OfflineScreen() - : data.item2 == Status.PENDING + : data.item3 == Status.PENDING ? LoadingScreen() - : data.item3 ? HomeScreen() : GroupScreen() - : FutureBuilder( - future: _restoreTokens, - builder: (_, snapshot) => - snapshot.connectionState == ConnectionState.waiting - ? LoadingScreen() - : WelcomeScreen(), - ), + : data.item4 ? HomeScreen() : GroupScreen() + : data.item1 == Status.PENDING ? LoadingScreen() : WelcomeScreen(), ); } } diff --git a/lib/services/api.dart b/lib/services/api.dart index c834717..92a3e01 100644 --- a/lib/services/api.dart +++ b/lib/services/api.dart @@ -8,6 +8,7 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import '../models/group.dart'; import '../models/invite.dart'; import '../models/account.dart'; +import '../models/expense.dart'; import '../utils/constants.dart'; class ApiError extends Error { @@ -19,7 +20,7 @@ class ApiError extends Error { String toString() => 'ApiError:${this.message}'; } -class Api { +class Api with ChangeNotifier { static final Api _instance = Api._internal(); static final BaseOptions baseOptions = BaseOptions( baseUrl: kReleaseMode @@ -38,6 +39,7 @@ class Api { ..transformer = FlutterTransformer(); final _storage = FlutterSecureStorage(); String accessToken; + int _forceLogoutTimestamp; factory Api() { return _instance; @@ -64,7 +66,7 @@ class Api { String authorizationHeader = "Bearer $accessToken"; // If the token has been updated, repeat directly. - if (authorizationHeader != null && + if (accessToken != null && authorizationHeader != options.headers['Authorization']) { options.headers['Authorization'] = authorizationHeader; return _dio.request(options.path, options: options); @@ -102,13 +104,16 @@ class Api { } on DioError catch (err) { switch (err.type) { case DioErrorType.RESPONSE: - // TODO: FORCELOGOUT - return err; - default: + _forceLogoutTimestamp = + DateTime.now().millisecondsSinceEpoch; + notifyListeners(); return null; + default: + return err; } } catch (err) { - // TODO: FORCELOGOUT + _forceLogoutTimestamp = DateTime.now().millisecondsSinceEpoch; + notifyListeners(); return err; } } @@ -120,6 +125,13 @@ class Api { ); } + int forceLogoutTimestamp() => _forceLogoutTimestamp; + + setForceLogoutTimestamp(int timestamp) { + _forceLogoutTimestamp = timestamp; + notifyListeners(); + } + Future> login(String email, String password) async { try { final response = await _dio.post( @@ -135,7 +147,7 @@ class Api { if (e.response != null) { throw ApiError(_getErrorMessage(e.response.data)); } else { - throw e; + rethrow; } } } @@ -165,7 +177,7 @@ class Api { if (e.response != null) { throw ApiError(_getErrorMessage(e.response.data)); } else { - throw e; + rethrow; } } } @@ -178,7 +190,7 @@ class Api { if (e.response != null) { throw ApiError(_getErrorMessage(e.response.data)); } else { - throw e; + rethrow; } } } @@ -199,7 +211,7 @@ class Api { if (e.response != null) { throw ApiError(_getErrorMessage(e.response.data)); } else { - throw e; + rethrow; } } } @@ -223,7 +235,7 @@ class Api { if (e.response != null) { throw ApiError(_getErrorMessage(e.response.data)); } else { - throw e; + rethrow; } } } @@ -236,7 +248,7 @@ class Api { if (e.response != null) { throw ApiError(_getErrorMessage(e.response.data)); } else { - throw e; + rethrow; } } } @@ -252,7 +264,7 @@ class Api { if (e.response != null) { throw ApiError(_getErrorMessage(e.response.data)); } else { - throw e; + rethrow; } } } @@ -274,7 +286,7 @@ class Api { if (e.response != null) { throw ApiError(_getErrorMessage(e.response.data)); } else { - throw e; + rethrow; } } } @@ -287,7 +299,7 @@ class Api { if (e.response != null) { throw ApiError(_getErrorMessage(e.response.data)); } else { - throw e; + rethrow; } } } @@ -306,7 +318,7 @@ class Api { if (e.response != null) { throw ApiError(_getErrorMessage(e.response.data)); } else { - throw e; + rethrow; } } } @@ -325,7 +337,7 @@ class Api { if (e.response != null) { throw ApiError(_getErrorMessage(e.response.data)); } else { - throw e; + rethrow; } } } @@ -340,7 +352,7 @@ class Api { if (e.response != null) { throw ApiError(_getErrorMessage(e.response.data)); } else { - throw e; + rethrow; } } } @@ -362,7 +374,7 @@ class Api { if (e.response != null) { throw ApiError(_getErrorMessage(e.response.data)); } else { - throw e; + rethrow; } } } @@ -379,7 +391,7 @@ class Api { if (e.response != null) { throw ApiError(_getErrorMessage(e.response.data)); } else { - throw e; + rethrow; } } } @@ -394,7 +406,81 @@ class Api { if (e.response != null) { throw ApiError(_getErrorMessage(e.response.data)); } else { - throw e; + rethrow; + } + } + } + + Future> fetchExpensesPage( + String groupId, + int page, { + int limit = 50, + }) async { + try { + final response = + await _dio.get("/groups/$groupId/expenses?page=$page&limit=$limit"); + return response.data['expenses'] + .map((json) => Expense.fromJson(json)) + .toList(); + } on DioError catch (e) { + if (e.response != null) { + throw ApiError(_getErrorMessage(e.response.data)); + } else { + rethrow; + } + } + } + + Future createExpense({ + @required String groupId, + @required String name, + @required int amount, + @required String payerId, + @required List> shares, + @required String currency, + @required String date, + }) async { + try { + final response = await _dio.post("/groups/$groupId/expenses/", data: { + 'name': name, + 'amount': amount, + 'payerId': payerId, + 'shares': shares, + 'currency': currency, + 'date': date, + }); + return Expense.fromJson(response.data); + } on DioError catch (e) { + if (e.response != null) { + throw ApiError(_getErrorMessage(e.response.data)); + } else { + rethrow; + } + } + } + + Future createPayment({ + @required String groupId, + @required int amount, + @required String from, + @required String to, + @required String currency, + @required String date, + }) async { + try { + final response = await _dio.post("/groups/$groupId/payments/", data: { + 'amount': amount, + 'from': from, + 'to': to, + 'currency': currency, + 'date': date, + }); + return Expense.fromJson(response.data); + } on DioError catch (e) { + if (e.response != null) { + throw ApiError(_getErrorMessage(e.response.data)); + } else { + rethrow; } } } diff --git a/lib/widgets/avatar.dart b/lib/widgets/avatar.dart index 6b9f2ac..ea39708 100644 --- a/lib/widgets/avatar.dart +++ b/lib/widgets/avatar.dart @@ -4,15 +4,18 @@ import 'package:cached_network_image/cached_network_image.dart'; class Avatar extends StatelessWidget { final String initals; final String avatar; + final double radius; Avatar({ @required this.initals, this.avatar, + this.radius, }); @override Widget build(BuildContext context) { return CircleAvatar( + radius: radius, child: (avatar != null && avatar.isNotEmpty) ? ClipOval( child: CachedNetworkImage( diff --git a/lib/widgets/balance_list.dart b/lib/widgets/balance_list.dart index 0e18b43..0072cf6 100644 --- a/lib/widgets/balance_list.dart +++ b/lib/widgets/balance_list.dart @@ -1,10 +1,13 @@ import 'package:flutter/material.dart'; +import 'package:flutter/cupertino.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; import 'package:provider/provider.dart'; import 'package:tuple/tuple.dart'; import 'package:intl/intl.dart'; import '../providers/groups.dart'; +import '../models/group.dart'; +import '../screens/group.dart'; import '../models/member.dart'; import '../screens/group_invites.dart'; import './avatar.dart'; @@ -15,69 +18,208 @@ class BalanceList extends StatelessWidget { await Provider.of(context, listen: false).fetchGroup(id); } - @override - Widget build(BuildContext context) { - return SafeArea( - child: Selector, String>>( - selector: (_, groups) => Tuple3( - groups.selectedGroupId, - groups.selectedGroupMembers, - groups.selectedGroup.currency, - ), - builder: (_, data, __) => RefreshIndicator( - onRefresh: () => _fetchGroup(context, data.item1), - child: ListView.builder( - padding: EdgeInsets.symmetric(vertical: 8), - itemCount: data.item2.length + 1, - itemBuilder: (_, i) { - final ThemeData theme = Theme.of(context); - // itemCount is incremented by 1 - bool isLast = data.item2.length == i; - if (isLast) { - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const SizedBox(height: 24), - Container( - padding: const EdgeInsets.symmetric( - vertical: 0, - horizontal: 16, - ), - child: PlatformButton( - androidFlat: (_) => MaterialFlatButtonData(), - child: const Text('+ Invite more friends'), - onPressed: () => Navigator.pushNamed( - context, - GroupInvitesScreen.routeName, - arguments: data.item1, + Widget _buildIos(BuildContext context) { + return Selector, String>>( + selector: (_, groups) => Tuple3( + groups.selectedGroupId, + groups.selectedGroupMembers, + groups.selectedGroup.currency, + ), + builder: (_, data, __) => CupertinoPageScaffold( + child: CustomScrollView( + semanticChildCount: 6, + slivers: [ + CupertinoSliverNavigationBar( + // automaticallyImplyLeading: false, + leading: Align( + widthFactor: 1.0, + alignment: Alignment.centerLeft, + child: CupertinoButton( + padding: EdgeInsets.zero, + child: const Text('Groups'), + onPressed: () {}, + ), + ), + largeTitle: Selector( + selector: (_, groups) => groups.selectedGroup, + builder: (_, selectedGroup, __) => Text( + selectedGroup.name, + maxLines: 1, + ), + ), + trailing: Selector( + selector: (_, groups) => groups.selectedGroup, + builder: (_, selectedGroup, __) => Align( + widthFactor: 1.0, + alignment: Alignment.centerRight, + child: CupertinoButton( + padding: EdgeInsets.zero, + child: const Text('Edit'), + onPressed: () { + Navigator.of(context, rootNavigator: true) + .pushNamed(GroupScreen.routeName, arguments: { + 'groupId': selectedGroup.id, + 'name': selectedGroup.name, + 'currency': selectedGroup.currency, + }); + }, + ), + ), + ), + ), + CupertinoSliverRefreshControl( + onRefresh: () => _fetchGroup(context, data.item1), + ), + SliverPadding( + padding: EdgeInsets.all(16), + sliver: SliverList( + delegate: SliverChildBuilderDelegate( + (_, i) { + // itemCount is incremented by 1 + bool isLast = data.item2.length == i; + if (isLast) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 24), + Container( + padding: const EdgeInsets.symmetric( + vertical: 0, + horizontal: 16, + ), + child: PlatformButton( + androidFlat: (_) => MaterialFlatButtonData(), + child: const Text('+ Invite more friends'), + onPressed: () => + Navigator.of(context, rootNavigator: true) + .pushNamed( + GroupInvitesScreen.routeName, + arguments: data.item1, + ), + ), + ), + ], + ); + } else { + return Container( + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration(), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Avatar( + radius: 24, + initals: data.item2[i].initials, + avatar: data.item2[i].avatar, + ), + SizedBox(width: 20), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(data.item2[i].fullName), + ], + ), + ), + Text( + NumberFormat.currency( + name: data.item3, + symbol: currencies[data.item3]['symbol'], + ).format(data.item2[i].balance / 100), + style: TextStyle( + fontSize: 20, fontWeight: FontWeight.bold), + ), + ], ), + ); + } + }, + childCount: data.item2.length + 1, + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildAndroid(BuildContext context) { + return Selector, String>>( + selector: (_, groups) => Tuple3( + groups.selectedGroupId, + groups.selectedGroupMembers, + groups.selectedGroup.currency, + ), + builder: (_, data, __) => RefreshIndicator( + onRefresh: () => _fetchGroup(context, data.item1), + child: ListView.builder( + padding: EdgeInsets.symmetric(vertical: 8), + itemCount: data.item2.length + 1, + itemBuilder: (_, i) { + final ThemeData theme = Theme.of(context); + // itemCount is incremented by 1 + bool isLast = data.item2.length == i; + if (isLast) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 24), + Container( + padding: const EdgeInsets.symmetric( + vertical: 0, + horizontal: 16, + ), + child: PlatformButton( + androidFlat: (_) => MaterialFlatButtonData(), + child: const Text('+ Invite more friends'), + onPressed: () => Navigator.pushNamed( + context, + GroupInvitesScreen.routeName, + arguments: data.item1, ), ), - ], - ); - } else { - return ListTile( - leading: Avatar( - initals: data.item2[i].initials, - avatar: data.item2[i].avatar, ), - title: Text(data.item2[i].fullName), - trailing: Text( - NumberFormat.currency( - name: data.item3, - symbol: currencies[data.item3]['symbol'], - ).format(data.item2[i].balance), - style: theme.textTheme.body2.copyWith( - fontSize: 16, - fontWeight: FontWeight.bold, - ), + ], + ); + } else { + return ListTile( + leading: Avatar( + initals: data.item2[i].initials, + avatar: data.item2[i].avatar, + ), + title: Text(data.item2[i].fullName), + trailing: Text( + NumberFormat.currency( + name: data.item3, + symbol: currencies[data.item3]['symbol'], + ).format(data.item2[i].balance / 100), + style: theme.textTheme.body2.copyWith( + fontSize: 16, + fontWeight: FontWeight.bold, ), - ); - } - }, - ), + ), + ); + } + }, ), ), ); } + + @override + Widget build(BuildContext context) { + return Selector, String>>( + selector: (_, groups) => Tuple3( + groups.selectedGroupId, + groups.selectedGroupMembers, + groups.selectedGroup.currency, + ), + builder: (_, data, __) => PlatformWidget( + ios: _buildIos, + android: _buildAndroid, + ), + ); + } } diff --git a/lib/widgets/card_input.dart b/lib/widgets/card_input.dart new file mode 100644 index 0000000..9d901ec --- /dev/null +++ b/lib/widgets/card_input.dart @@ -0,0 +1,66 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; + +class CardInput extends StatelessWidget { + final bool autofocus; + final TextEditingController controller; + final FocusNode focusNode; + final List inputFormatters; + final VoidCallback onEditingComplete; + final ValueChanged onSubmitted; + final TextInputType keyboardType; + final String prefixText; + final String hintText; + + const CardInput({ + Key key, + this.autofocus, + this.controller, + this.focusNode, + this.inputFormatters, + this.onEditingComplete, + this.onSubmitted, + this.keyboardType, + this.prefixText, + this.hintText, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return PlatformTextField( + autofocus: autofocus, + controller: controller, + focusNode: focusNode, + keyboardType: keyboardType, + inputFormatters: inputFormatters, + textAlign: TextAlign.end, + onEditingComplete: onEditingComplete, + onSubmitted: onSubmitted, + android: (context) => MaterialTextFieldData( + decoration: InputDecoration( + fillColor: Theme.of(context).cardColor, + filled: true, + prefixIcon: SizedBox( + child: Center( + widthFactor: 1.0, + heightFactor: 1.0, + child: Padding( + padding: const EdgeInsets.only(left: 16, right: 8), + child: Text( + prefixText, + style: Theme.of(context).textTheme.button, + ), + ), + ), + ), + hintText: hintText, + prefixStyle: Theme.of(context).textTheme.body1, + contentPadding: const EdgeInsets.all(16), + focusedBorder: InputBorder.none, + enabledBorder: InputBorder.none, + ), + ), + ); + } +} diff --git a/lib/widgets/card_picker.dart b/lib/widgets/card_picker.dart new file mode 100644 index 0000000..6484315 --- /dev/null +++ b/lib/widgets/card_picker.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; + +class CardPicker extends StatelessWidget { + final VoidCallback onPressed; + final String prefix; + final String text; + + const CardPicker({ + Key key, + this.prefix, + this.text, + this.onPressed, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return PlatformButton( + android: (_) => MaterialRaisedButtonData( + shape: RoundedRectangleBorder( + borderRadius: const BorderRadius.all(Radius.zero), + ), + ), + padding: const EdgeInsets.all(16), + color: Theme.of(context).cardColor, + onPressed: onPressed, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(prefix), + Text(text), + ], + ), + ); + } +} diff --git a/lib/widgets/empty_expenses.dart b/lib/widgets/empty_expenses.dart index 064468b..7d5769b 100644 --- a/lib/widgets/empty_expenses.dart +++ b/lib/widgets/empty_expenses.dart @@ -1,38 +1,53 @@ import 'package:flutter/material.dart'; class EmptyExpenses extends StatelessWidget { + static const double AVATAR_RADIUS = 24; + final appBar = AppBar(); + final tabBar = TabBar(tabs: []); + @override Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - CircleAvatar( - radius: 24, - child: Container( - alignment: Alignment.center, - child: const Icon( - Icons.shopping_basket, - size: 24, + final mediaQuery = MediaQuery.of(context); + final double statusBarHeight = mediaQuery.padding.top; + return Container( + height: mediaQuery.size.height - + appBar.preferredSize.height - + tabBar.preferredSize.height - + statusBarHeight - + AVATAR_RADIUS - + 16 - + 4, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircleAvatar( + radius: AVATAR_RADIUS, + child: Container( + alignment: Alignment.center, + child: const Icon( + Icons.shopping_basket, + size: AVATAR_RADIUS, + ), ), ), - ), - const SizedBox(height: 16), - Text( - 'No expenses', - style: Theme.of(context).textTheme.body2.copyWith(fontSize: 18), - textAlign: TextAlign.center, - ), - const SizedBox(height: 4), - SizedBox( - width: MediaQuery.of(context).size.width * 0.50, - child: Text( - 'Add an expense by pressing the button below', - style: Theme.of(context).textTheme.caption, + const SizedBox(height: 16), + Text( + 'No expenses', + style: Theme.of(context).textTheme.body2.copyWith(fontSize: 18), textAlign: TextAlign.center, ), - ), - ], + const SizedBox(height: 4), + SizedBox( + width: mediaQuery.size.width * 0.50, + child: Text( + 'Add an expense by pressing the button below', + style: Theme.of(context).textTheme.caption, + textAlign: TextAlign.center, + ), + ), + ], + ), ); } } diff --git a/lib/widgets/expenses_list.dart b/lib/widgets/expenses_list.dart index e05fb86..b3dc413 100644 --- a/lib/widgets/expenses_list.dart +++ b/lib/widgets/expenses_list.dart @@ -1,48 +1,161 @@ import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:tuple/tuple.dart'; +import 'package:intl/intl.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; import './empty_expenses.dart'; import '../models/expense.dart'; +import '../providers/base.dart'; +import '../providers/groups.dart'; +import '../providers/expenses.dart'; +import '../utils/currencies.dart'; -class ExpensesList extends StatelessWidget { - final List _expenses = []; +class ExpensesList extends StatefulWidget { + @override + _ExpensesListState createState() => _ExpensesListState(); +} - Widget _renderItem(BuildContext context, int i) { - return Column( - children: [ - ListTile( - leading: CircleAvatar( - child: Icon( - _expenses[i].isPayment - ? Icons.account_balance_wallet - : Icons.shopping_basket, - ), - ), - title: Text(_expenses[i].name), - subtitle: Text('Jan'), // TODO: display dynamically payers name - trailing: Text( - '\$${_expenses[i].amount}', - style: Theme.of(context).textTheme.body2.copyWith( - fontSize: 16, - fontWeight: FontWeight.bold, - ), - ), - ), - Divider(), - ], - ); +class _ExpensesListState extends State { + int _page = 1; + bool _allFetched = false; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + _fetchExpenses(); + }); + } + + Future _refreshExpenses() async { + final String groupId = + Provider.of(context, listen: false).selectedGroupId; + if (groupId != null) { + final List expenses = + await Provider.of(context, listen: false) + .fetchExpensesPage(groupId, 1); + setState(() { + _page = 2; + _allFetched = expenses.isEmpty; + }); + } + } + + Future _fetchExpenses() async { + final String groupId = + Provider.of(context, listen: false).selectedGroupId; + if (groupId != null) { + final List expenses = + await Provider.of(context, listen: false) + .fetchExpensesPage(groupId, _page); + setState(() { + _page += 1; + _allFetched = expenses.isEmpty; + }); + } } @override Widget build(BuildContext context) { - return PlatformScaffold( - body: _expenses.isEmpty - ? EmptyExpenses() - : ListView.builder( - padding: EdgeInsets.symmetric(vertical: 8), - itemCount: _expenses.length, - itemBuilder: _renderItem, + return SafeArea( + child: Selector2>>( + selector: (_, groups, expenses) => Tuple2( + expenses.status, + expenses.byGroupId(groups.selectedGroupId), + ), + builder: (_, data, __) => PlatformWidget( + ios: (_) => Container( + child: const Text('Expenses list'), + ), + android: (_) => NotificationListener( + onNotification: (ScrollNotification scrollInfo) { + if (_allFetched) { + return false; + } + final bool endThresholdReached = scrollInfo.metrics.pixels == + scrollInfo.metrics.maxScrollExtent * 0.8; + if (data.item1 != Status.PENDING && endThresholdReached) { + _fetchExpenses(); + } + return true; + }, + child: RefreshIndicator( + onRefresh: _refreshExpenses, + child: data.item2.isEmpty + ? ListView( + children: [ + EmptyExpenses(), + ], + ) + : ListView.builder( + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: data.item1 == Status.PENDING + ? data.item2.length + 1 + : data.item2.length, + itemBuilder: (BuildContext context, int i) { + if (data.item1 == Status.PENDING && + i == data.item2.length) { + return Container( + height: 48, + child: Center( + child: PlatformCircularProgressIndicator(), + ), + ); + } + final expense = data.item2[i]; + return Column( + children: [ + ListTile( + leading: CircleAvatar( + child: Icon( + expense.isPayment + ? Icons.account_balance_wallet + : Icons.shopping_basket, + ), + ), + title: Text(expense.name), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Selector( + selector: (_, groups) => + groups.memberFirstName, + builder: (_, getMemberFirstName, __) => + Text( + getMemberFirstName(expense.payerId), + ), + ), + const SizedBox(height: 4), + Text( + DateFormat.yMMMd().format(expense.date), + style: Theme.of(context).textTheme.caption, + ), + ], + ), + trailing: Text( + NumberFormat.currency( + name: expense.currency, + symbol: currencies[expense.currency] + ['symbol'], + ).format(expense.amount / 100), + style: + Theme.of(context).textTheme.body2.copyWith( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + ), + const Divider(), + ], + ); + }, + ), ), + ), + ), + ), ); } }