From c3868eeb7a84459fc3c0df73818bccf2718e3d5d Mon Sep 17 00:00:00 2001 From: Jan Czizikow Date: Tue, 14 Apr 2020 20:38:58 +0200 Subject: [PATCH] feat: group invites --- lib/main.dart | 50 +++++--- lib/models/group.dart | 19 +-- lib/models/invite.dart | 29 +++++ lib/models/member.dart | 38 ++++++ lib/providers/groups.dart | 57 ++++++++- lib/providers/invites.dart | 83 ++++++++++++ lib/providers/theme.dart | 5 +- lib/screens/group.dart | 83 ++++++++++-- lib/screens/group_invites.dart | 224 +++++++++++++++++++++++++++++++++ lib/screens/root.dart | 4 +- lib/services/api.dart | 71 ++++++++++- lib/widgets/balance_list.dart | 113 ++++++++++------- pubspec.lock | 2 +- pubspec.yaml | 1 + 14 files changed, 687 insertions(+), 92 deletions(-) create mode 100644 lib/models/invite.dart create mode 100644 lib/models/member.dart create mode 100644 lib/providers/invites.dart create mode 100644 lib/screens/group_invites.dart diff --git a/lib/main.dart b/lib/main.dart index b0295c7..1c6fe70 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,30 +1,34 @@ import 'dart:async'; +import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/cupertino.dart'; -import 'package:provider/provider.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; -import 'package:sentry/sentry.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; +import 'package:provider/provider.dart'; +import 'package:sentry/sentry.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:sliceit/providers/invites.dart'; -import './services/api.dart'; -import './providers/auth.dart'; import './providers/account.dart'; -import './providers/theme.dart'; +import './providers/auth.dart'; import './providers/groups.dart'; -import './screens/root.dart'; -import './screens/login.dart'; +import './providers/theme.dart'; +import './screens/edit_email.dart'; +import './screens/edit_name.dart'; import './screens/forgot_password.dart'; -import './screens/register.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 './screens/edit_name.dart'; -import './screens/edit_email.dart'; +import './services/api.dart'; import './widgets/no_animation_material_page_route.dart'; Future main() async { await DotEnv().load('.env'); + final SharedPreferences prefs = await SharedPreferences.getInstance(); final SentryClient _sentry = new SentryClient(dsn: DotEnv().env['SENTRY_DNS']); FlutterError.onError = (FlutterErrorDetails details) async { @@ -41,7 +45,7 @@ Future main() async { // an error handler that captures errors and reports them. // https://api.dartlang.org/stable/1.24.2/dart-async/Zone-class.html runZoned>(() async { - runApp(new MyApp()); + runApp(new MyApp(prefs)); }, onError: (error, stackTrace) async { if (!kReleaseMode) { print(stackTrace); @@ -56,17 +60,21 @@ Future main() async { } class MyApp extends StatefulWidget { + final SharedPreferences prefs; + MyApp(this.prefs); + @override _MyAppState createState() => _MyAppState(); } class _MyAppState extends State { final Api _api = Api(); - ThemeProvider themeProvider = ThemeProvider(); + ThemeProvider themeProvider; @override void initState() { super.initState(); + themeProvider = ThemeProvider(widget.prefs); _loadPreferredTheme(); } @@ -108,7 +116,17 @@ class _MyAppState extends State { case GroupScreen.routeName: return platformPageRoute( context: context, - builder: (context) => GroupScreen(arguments: settings.arguments), + builder: (context) => GroupScreen( + arguments: settings.arguments, + ), + settings: settings, + ); + case GroupInvitesScreen.routeName: + return platformPageRoute( + context: context, + builder: (context) => GroupInvitesScreen( + groupId: settings.arguments, + ), settings: settings, ); case SettingsScreen.routeName: @@ -152,9 +170,11 @@ class _MyAppState extends State { update: (_, auth, previous) => GroupsProvider( api: _api, isAuthenticated: auth.isAuthenticated, - prev: previous.groups, ), ), + ChangeNotifierProvider( + create: (_) => InvitesProvider(_api), + ), ], child: Consumer( builder: (_, theme, __) => PlatformApp( diff --git a/lib/models/group.dart b/lib/models/group.dart index 304a805..0245959 100644 --- a/lib/models/group.dart +++ b/lib/models/group.dart @@ -1,19 +1,24 @@ -import 'dart:convert'; import 'package:flutter/foundation.dart'; +import './member.dart'; + class Group { final String id; + final String creatorId; String name; String currency; bool isDeleted; + List members; final DateTime createdAt; DateTime updatedAt; Group({ @required this.id, @required this.name, + @required this.creatorId, this.currency, this.isDeleted = false, + this.members = const [], @required this.createdAt, this.updatedAt, }); @@ -21,9 +26,15 @@ class Group { factory Group.fromJson(Map json) { return Group( id: json['id'], + creatorId: json['creatorId'], name: json['name'], currency: json['currency'], isDeleted: json['isDeleted'], + members: json.containsKey('members') + ? json['members'] + .map((member) => Member.fromJson(member)) + .toList() + : [], createdAt: DateTime.parse(json['createdAt']), updatedAt: json['updatedAt'] != null ? DateTime.parse(json['updatedAt']) : null, @@ -35,10 +46,4 @@ class Group { json.map((json) => Group.fromJson(json)).toList(); return result; } - - static Group parseGroup(String responseBody) { - final Map parsed = - jsonDecode(responseBody) as Map; - return Group.fromJson(parsed); - } } diff --git a/lib/models/invite.dart b/lib/models/invite.dart new file mode 100644 index 0000000..20d5396 --- /dev/null +++ b/lib/models/invite.dart @@ -0,0 +1,29 @@ +import 'package:flutter/foundation.dart'; + +@immutable +class Invite { + final String id; + final String email; + final String groupId; + final DateTime createdAt; + final DateTime updatedAt; + + Invite({ + @required this.id, + @required this.email, + @required this.groupId, + @required this.createdAt, + this.updatedAt, + }); + + factory Invite.fromJson(Map json) { + return Invite( + id: json['id'], + email: json['email'], + groupId: json['groupId'], + createdAt: DateTime.parse(json['createdAt']), + updatedAt: + json['updatedAt'] != null ? DateTime.parse(json['updatedAt']) : null, + ); + } +} diff --git a/lib/models/member.dart b/lib/models/member.dart new file mode 100644 index 0000000..9c83b3e --- /dev/null +++ b/lib/models/member.dart @@ -0,0 +1,38 @@ +import 'package:flutter/foundation.dart'; + +class Member { + final String id; + final String userId; + final String groupId; + final String firstName; + final String lastName; + final String avatar; + int balance; + + Member({ + @required this.id, + @required this.userId, + @required this.groupId, + @required this.firstName, + @required this.lastName, + this.avatar, + this.balance = 0, + }); + + factory Member.fromJson(Map json) { + return Member( + id: json['user']['id'], + userId: json['user']['id'], + groupId: json['groupId'], + firstName: json['user']['firstName'], + lastName: json['user']['lastName'], + avatar: json['user']['avatar'], + balance: json['balance'], + ); + } + + get fullName => lastName.isNotEmpty ? "$firstName $lastName" : firstName; + + get initials => RegExp(r'\S+').allMatches(fullName).fold('', + (acc, match) => acc + fullName.substring(match.start, match.start + 1)); +} diff --git a/lib/providers/groups.dart b/lib/providers/groups.dart index 2081b6f..60bbe31 100644 --- a/lib/providers/groups.dart +++ b/lib/providers/groups.dart @@ -1,23 +1,36 @@ import 'package:flutter/foundation.dart'; import './base.dart'; -import '../services/api.dart'; + import '../models/group.dart'; +import '../models/account.dart'; +import '../models/member.dart'; +import '../services/api.dart'; -class GroupsProvider extends BaseProvider { +class GroupsProvider with ChangeNotifier { final 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, - List prev = const [], }) { if (isAuthenticated) { fetchGroups(); } - _groups.addAll(prev); + } + + get status => _status; + + set status(Status newStatus) { + _status = newStatus; + notifyListeners(); } List get groups { @@ -30,6 +43,16 @@ class GroupsProvider extends BaseProvider { return _lastFetchedTimestamp == null; } + List get selectedGroupMembers { + return _selectedGroupIndex < _groups.length + ? _groups[_selectedGroupIndex].members + : []; + } + + String get selectedGroupId { + return _selectedGroupId; + } + Group get selectedGroup { return _selectedGroupIndex < _groups.length ? _groups[_selectedGroupIndex] @@ -40,6 +63,7 @@ class GroupsProvider extends BaseProvider { int groupIndex = _groups.indexWhere((group) => group.id == id); if (groupIndex != -1) { _selectedGroupIndex = groupIndex; + _selectedGroupId = id; notifyListeners(); } } @@ -53,7 +77,11 @@ class GroupsProvider extends BaseProvider { try { final List groups = await api.fetchGroups(); + _groups.clear(); _groups.addAll(groups); + if (_groups.isNotEmpty) { + _selectedGroupId = _groups[_selectedGroupIndex].id; + } _lastFetchedTimestamp = DateTime.now().millisecondsSinceEpoch; status = Status.RESOLVED; } catch (e) { @@ -62,10 +90,21 @@ class GroupsProvider extends BaseProvider { } } - Future createGroup({String name, String currency}) async { + Future fetchGroup(String id) async { + int groupIndex = _groups.indexWhere((group) => group.id == id); + if (groupIndex != -1) { + final Group group = await api.fetchGroup(id); + _groups[groupIndex] = group; + notifyListeners(); + } + } + + Future createGroup( + {String name, String currency, Account member}) async { final Group group = await api.createGroup(name: name, currency: currency); _groups.add(group); _selectedGroupIndex = _groups.length - 1; + _selectedGroupId = group.id; notifyListeners(); } @@ -86,12 +125,20 @@ class GroupsProvider extends BaseProvider { Future deleteGroup(String groupId) async { await api.deleteGroup(groupId); _groups.removeWhere((group) => group.id == groupId); + if (_groups.isNotEmpty) { + _selectedGroupIndex = 0; + _selectedGroupId = _groups[0].id; + } else { + _selectedGroupIndex = 0; + _selectedGroupId = null; + } notifyListeners(); } void reset() { _groups.clear(); _selectedGroupIndex = 0; + _selectedGroupId = null; _lastFetchedTimestamp = null; status = Status.IDLE; } diff --git a/lib/providers/invites.dart b/lib/providers/invites.dart new file mode 100644 index 0000000..8535bc0 --- /dev/null +++ b/lib/providers/invites.dart @@ -0,0 +1,83 @@ +import 'package:flutter/foundation.dart'; + +import './base.dart'; +import '../models/invite.dart'; +import '../services/api.dart'; + +class InvitesProvider extends BaseProvider { + final Api api; + final Map> _invitesByGroupId = {}; + + InvitesProvider(this.api); + + get isFetching => status == Status.PENDING; + + List byGroupId(String groupId) { + if (_invitesByGroupId.containsKey(groupId)) { + return _invitesByGroupId[groupId]; + } + + return []; + } + + int byGroupIdCount(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 createInvite(String groupId, String email) async { + status = Status.PENDING; + try { + final Invite invite = await api.createInvite(groupId, email); + if (invite != null) { + if (_invitesByGroupId?.containsKey(groupId) ?? false) { + _invitesByGroupId[groupId].add(invite); + } else { + _invitesByGroupId[groupId] = [invite]; + } + status = Status.RESOLVED; + return true; + } else { + status = Status.RESOLVED; + return false; + } + } catch (e) { + status = Status.REJECTED; + throw e; + } + } + + Future deleteGroupInvite({ + @required String groupId, + @required String inviteId, + }) async { + if (_invitesByGroupId.containsKey(groupId)) { + final groupInvites = _invitesByGroupId[groupId]; + final inviteIndex = + groupInvites.indexWhere((invite) => invite.id == inviteId); + final invite = groupInvites[inviteIndex]; + _invitesByGroupId[groupId].removeAt(inviteIndex); + notifyListeners(); + try { + await api.deleteGroupInvite(groupId, inviteId); + } catch (err) { + groupInvites.insert(inviteIndex, invite); + notifyListeners(); + } + } + } +} diff --git a/lib/providers/theme.dart b/lib/providers/theme.dart index 4f4a776..1df4330 100644 --- a/lib/providers/theme.dart +++ b/lib/providers/theme.dart @@ -10,9 +10,12 @@ class ThemeProvider with ChangeNotifier { static const THEME_PREFERENCE_KEY = 'THEME_PREFERENCE_KEY'; static final ThemeData _darkTheme = ThemeData.dark(); static final ThemeData _lightTheme = ThemeData.light(); + final SharedPreferences prefs; ThemeType _themeType = ThemeType.light; ThemeData currentTheme = _lightTheme; + ThemeProvider(this.prefs); + bool get isDark { return _themeType == ThemeType.dark; } @@ -44,13 +47,11 @@ class ThemeProvider with ChangeNotifier { } Future loadPreferredTheme() async { - SharedPreferences prefs = await SharedPreferences.getInstance(); int themeTypeIndex = prefs.getInt(THEME_PREFERENCE_KEY) ?? 0; return ThemeType.values.elementAt(themeTypeIndex); } Future _storePreferredTheme(ThemeType themeType) async { - SharedPreferences prefs = await SharedPreferences.getInstance(); prefs.setInt(THEME_PREFERENCE_KEY, ThemeType.values.indexOf(themeType)); } } diff --git a/lib/screens/group.dart b/lib/screens/group.dart index 25891ed..e0dac0c 100644 --- a/lib/screens/group.dart +++ b/lib/screens/group.dart @@ -1,18 +1,21 @@ import 'package:flutter/material.dart'; import 'package:flutter/cupertino.dart'; import 'package:provider/provider.dart'; -import 'package:sliceit/services/api.dart'; +import 'package:tuple/tuple.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; import './currencies_screen.dart'; import '../providers/groups.dart'; +import '../providers/account.dart'; +import '../services/api.dart'; +import '../widgets/loading_dialog.dart'; import '../utils/currencies.dart'; class GroupScreen extends StatefulWidget { static const routeName = '/group'; final Map arguments; - GroupScreen({this.arguments}); + const GroupScreen({Key key, this.arguments}) : super(key: key); @override State createState() => _GroupState(); @@ -130,9 +133,7 @@ class _GroupState extends State { } Future _handleSubmit() async { - setState(() { - _isLoading = true; - }); + setState(() => _isLoading = true); // TODO: validation String name = _nameController.text; try { @@ -147,13 +148,48 @@ class _GroupState extends State { } } on ApiError catch (err) { _showErrorMessage(err.message); - setState(() { - _isLoading = false; - }); + setState(() => _isLoading = true); } catch (err) { - setState(() { - _isLoading = false; - }); + setState(() => _isLoading = true); + } + } + + Future _deleteGroup() async { + bool result = await showPlatformDialog( + context: context, + builder: (_) => PlatformAlertDialog( + title: Text('Delete group'), + content: Text('Are you sure? Deleting the group is irreversible.'), + actions: [ + PlatformDialogAction( + onPressed: () => Navigator.of(context).pop(false), + child: Text('Cancel'), + ), + PlatformDialogAction( + child: Text('Delete'), + onPressed: () => Navigator.of(context).pop(true), + ios: (_) => CupertinoDialogActionData(isDestructiveAction: true), + ), + ], + ), + ); + if (result) { + showPlatformDialog( + androidBarrierDismissible: false, + context: context, + builder: (_) => LoadingDialog(), + ); + try { + await Provider.of(context, listen: false) + .deleteGroup(_groupId); + Navigator.pushNamedAndRemoveUntil(context, '/', (_) => false); + } on ApiError catch (e) { + Navigator.of(context).pop(); + _showErrorMessage(e.message); + } catch (e) { + Navigator.of(context).pop(); + _showErrorMessage('Failed to delete group'); + } } } @@ -206,9 +242,32 @@ class _GroupState extends State { color: Theme.of(context).primaryColor, android: (_) => MaterialRaisedButtonData(colorBrightness: Brightness.dark), - child: _isLoading ? Text('Loading...') : Text('Next'), + child: _isLoading + ? Text('Loading...') + : Text(_groupId != null ? 'Save' : 'Next'), onPressed: _isLoading ? null : _handleSubmit, ), + if (_groupId != null) + Selector2>( + selector: (_, accountState, groupsState) => Tuple2( + accountState.account.id, + groupsState.byId(_groupId).creatorId, + ), + builder: (_, data, __) { + return data.item1 == data.item2 + ? PlatformButton( + androidFlat: (_) => MaterialFlatButtonData( + textColor: Theme.of(context).errorColor, + colorBrightness: Brightness.dark, + ), + child: Text('Delete group'), + onPressed: _deleteGroup, + ) + // TODO: leave group button + : Container(); + }, + ) ], ), ), diff --git a/lib/screens/group_invites.dart b/lib/screens/group_invites.dart new file mode 100644 index 0000000..3ebca9c --- /dev/null +++ b/lib/screens/group_invites.dart @@ -0,0 +1,224 @@ +import 'package:flutter/cupertino.dart'; +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 '../services/api.dart'; + +class GroupInvitesScreen extends StatefulWidget { + static const routeName = '/invites'; + final String groupId; + + const GroupInvitesScreen({Key key, this.groupId}) : super(key: key); + + @override + _GroupInvitesScreenState createState() => _GroupInvitesScreenState(); +} + +class _GroupInvitesScreenState extends State { + TextEditingController _emailController; + + @override + void initState() { + super.initState(); + _emailController = TextEditingController(); + // https://www.didierboelens.com/2019/04/addpostframecallback/ + WidgetsBinding.instance.addPostFrameCallback((_) { + _fetchInvites(); + }); + } + + @override + void dispose() { + _emailController.dispose(); + super.dispose(); + } + + Future _fetchInvites() async { + await Provider.of( + context, + listen: false, + ).fetchGroupInvites(widget.groupId); + } + + Future _addInvite() async { +// TODO: validation + final String email = _emailController.text; + try { + bool created = await Provider.of(context, listen: false) + .createInvite(widget.groupId, email); + _emailController.clear(); + if (!created) { + _showMessage('Success', + 'This user already has an account and has been added to the group directly.'); + // Refetch group to get new members + Provider.of(context, listen: false) + .fetchGroup(widget.groupId); + } + } on ApiError catch (e) { + _showMessage('Error', e.message); + } catch (e) { + _showMessage('Error', 'Failed to create invite. Please try again'); + } + } + + Future _deleteInvite(String id) async { + try { + await Provider.of(context, listen: false) + .deleteGroupInvite(groupId: widget.groupId, inviteId: id); + } on ApiError catch (e) { + _showMessage('Error', e.message); + } catch (e) { + _showMessage('Error', 'Failed to create invite. Please try again'); + } + } + + void _showMessage(String title, String message) async { + showPlatformDialog( + context: context, + builder: (_) => PlatformAlertDialog( + title: Text(title), + content: Text(message), + actions: [ + PlatformDialogAction( + child: const Text('OK'), + onPressed: () => Navigator.of(context, rootNavigator: true).pop(), + ) + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + final invites = Provider.of(context); + return PlatformScaffold( + 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, + ), + 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.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, + ), + ), + ); + }, + ), + ), + 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', + ), + ), + 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/root.dart b/lib/screens/root.dart index a31cc87..febb3a4 100644 --- a/lib/screens/root.dart +++ b/lib/screens/root.dart @@ -12,9 +12,7 @@ import '../providers/auth.dart'; import '../providers/groups.dart'; class Root extends StatefulWidget { - const Root({ - Key key, - }) : super(key: key); + const Root({Key key}) : super(key: key); static const routeName = '/'; diff --git a/lib/services/api.dart b/lib/services/api.dart index d021524..c834717 100644 --- a/lib/services/api.dart +++ b/lib/services/api.dart @@ -6,6 +6,7 @@ import 'package:dio_flutter_transformer/dio_flutter_transformer.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import '../models/group.dart'; +import '../models/invite.dart'; import '../models/account.dart'; import '../utils/constants.dart'; @@ -260,9 +261,8 @@ class Api { {int page = 1, List results = const []}) async { try { int limit = 50; - int offset = page * limit; Response> response = - await _dio.get("/groups?page=$page&limit=$limit&offset=$offset"); + await _dio.get("/groups?page=$page&limit=$limit"); int nextPage = response.data['next'] != null ? page + 1 : null; List data = results + response.data['groups']; if (nextPage != null) { @@ -279,6 +279,19 @@ class Api { } } + Future fetchGroup(String id) async { + try { + final response = await _dio.get("/groups/$id"); + return Group.fromJson(response.data); + } on DioError catch (e) { + if (e.response != null) { + throw ApiError(_getErrorMessage(e.response.data)); + } else { + throw e; + } + } + } + Future createGroup({String name, String currency}) async { try { final response = await _dio.post( @@ -332,6 +345,60 @@ class Api { } } + Future> fetchGroupInvites(String groupId, + {int page = 1, List results = const []}) async { + try { + int limit = 50; + Response> response = + await _dio.get("/groups/$groupId/invites/?page=$page&limit=$limit"); + int nextPage = response.data['next'] != null ? page + 1 : null; + List data = results + response.data['invites']; + if (nextPage != null) { + return fetchGroupInvites(groupId, page: nextPage, results: data); + } else { + return data.map((json) => Invite.fromJson(json)).toList(); + } + } on DioError catch (e) { + if (e.response != null) { + throw ApiError(_getErrorMessage(e.response.data)); + } else { + throw e; + } + } + } + + Future createInvite(String groupId, String email) async { + try { + final response = + await _dio.post("/groups/$groupId/invites", data: {'email': email}); + if (response.statusCode == 204) { + return null; + } + return Invite.fromJson(response.data); + } on DioError catch (e) { + if (e.response != null) { + throw ApiError(_getErrorMessage(e.response.data)); + } else { + throw e; + } + } + } + + Future deleteGroupInvite(String groupId, String inviteId) async { + try { + await _dio.delete( + "/groups/$groupId/invites/$inviteId", + ); + return true; + } on DioError catch (e) { + if (e.response != null) { + throw ApiError(_getErrorMessage(e.response.data)); + } else { + throw e; + } + } + } + Future _storeTokens( {@required String accessToken, @required String refreshToken}) async { await _storage.write(key: ACCESS_TOKEN_KEY, value: accessToken); diff --git a/lib/widgets/balance_list.dart b/lib/widgets/balance_list.dart index aabf791..0e18b43 100644 --- a/lib/widgets/balance_list.dart +++ b/lib/widgets/balance_list.dart @@ -1,59 +1,82 @@ import 'package:flutter/material.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/auth.dart'; +import '../providers/groups.dart'; +import '../models/member.dart'; +import '../screens/group_invites.dart'; +import './avatar.dart'; +import '../utils/currencies.dart'; class BalanceList extends StatelessWidget { - final _groupMembers = [0]; - - Widget _renderItem(BuildContext context, int i) { - final ThemeData theme = Theme.of(context); - // itemCount is incremented by 1 - bool isLast = _groupMembers.length == i; - - if (isLast) { - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - SizedBox(height: 24), - Container( - padding: EdgeInsets.symmetric( - vertical: 0, - horizontal: 16, - ), - child: PlatformButton( - androidFlat: (_) => MaterialFlatButtonData(), - child: Text('+ Invite more friends'), - onPressed: () => {}, - ), - ), - ], - ); - } else { - return ListTile( - leading: CircleAvatar( - backgroundColor: theme.accentColor, - ), - title: Text('Jan'), - trailing: Text( - '\$18.00', - style: theme.textTheme.body2.copyWith( - fontSize: 16, - fontWeight: FontWeight.bold, - ), - ), - ); - } + Future _fetchGroup(BuildContext context, String id) async { + await Provider.of(context, listen: false).fetchGroup(id); } @override Widget build(BuildContext context) { return SafeArea( - child: ListView.builder( - padding: EdgeInsets.symmetric(vertical: 8), - itemCount: _groupMembers.length + 1, - itemBuilder: _renderItem, + 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, + ), + ), + ), + ], + ); + } 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, + ), + ), + ); + } + }, + ), + ), ), ); } diff --git a/pubspec.lock b/pubspec.lock index 8ef7446..dd6306b 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -206,7 +206,7 @@ packages: source: hosted version: "0.6.5" intl: - dependency: transitive + dependency: "direct main" description: name: intl url: "https://pub.dartlang.org" diff --git a/pubspec.yaml b/pubspec.yaml index 1c22441..4219513 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -30,6 +30,7 @@ dependencies: flutter_speed_dial: ^1.2.5 image_cropper: ^1.2.1 image_picker: ^0.6.5 + intl: ^0.16.1 mime: ^0.9.6+3 package_info: ">=0.4.0+16 <2.0.0" provider: ^4.0.4