diff --git a/lib/backend/errors.dart b/lib/backend/errors.dart index bcdb90e..fe5c2dd 100644 --- a/lib/backend/errors.dart +++ b/lib/backend/errors.dart @@ -1,29 +1,30 @@ /* - * Tool to automatically generate english text from error - */ - String errorAsString(Map json) { - switch (json['data']) { - case 'notfound': - return 'Endpoint not found'; - case 'wrongstate': - return 'Missing data'; - case 'data': - return 'Invalid data'; - case 'right': - return 'You are not allowed to perform this action'; - case 'server': - return 'Server error'; - case 'closed': - return 'Server cannot be reached'; - case 'auth': - return 'Username or password wrong'; - case 'ota': - return 'Invalid OTA'; - case 'existence': - return 'Username unavailable'; - case 'config': - return 'Server reached user limit'; - } +* Tool to automatically generate english text from error +*/ +String errorAsString(Map json) { + switch (json['data']) { + case 'notfound': + return 'Endpoint not found'; + case 'wrongstate': + return 'Missing data'; + case 'data': + return 'Invalid data'; + case 'roomAdmin': + case 'right': + return 'You are not allowed to perform this action'; + case 'server': + return 'Server error'; + case 'closed': + return 'Server cannot be reached'; + case 'auth': + return 'Username or password wrong'; + case 'ota': + return 'Invalid OTA'; + case 'existence': + return 'Username unavailable'; + case 'config': + return 'Server reached user limit'; + } return "Unknown Error"; } diff --git a/lib/main.dart b/lib/main.dart index 48532dc..19f48ca 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:outbag_app/backend/user.dart'; import 'package:outbag_app/screens/room/join.dart'; import 'package:outbag_app/screens/room/new.dart'; +import 'package:outbag_app/screens/room/shopping_list.dart'; import './screens/home.dart'; import './screens/welcome.dart'; import './screens/room.dart'; @@ -11,29 +12,26 @@ import 'package:routemaster/routemaster.dart'; // routes when user is not logged in final routesUnauthorized = RouteMap(routes: { - '/welcome/': (_) => const MaterialPage(child: WelcomePage()), - '/signup': (_) => const MaterialPage(child: AuthPage(mode: Mode.signup)), - '/signupOTA': (_) => - const MaterialPage(child: AuthPage(mode: Mode.signupOTA)), - '/signin': (_) => const MaterialPage(child: AuthPage(mode: Mode.signin)), - }, onUnknownRoute: (_) => const MaterialPage(child: WelcomePage())); + '/welcome/': (_) => const MaterialPage(child: WelcomePage()), + '/signup': (_) => const MaterialPage(child: AuthPage(mode: Mode.signup)), + '/signupOTA': (_) => + const MaterialPage(child: AuthPage(mode: Mode.signupOTA)), + '/signin': (_) => const MaterialPage(child: AuthPage(mode: Mode.signin)), +}, onUnknownRoute: (_) => const MaterialPage(child: WelcomePage())); // routes when user is logged in final routesLoggedIn = RouteMap(routes: { - '/': (_) => const MaterialPage(child: HomePage()), - '/add-room/new': (_) => const MaterialPage(child: NewRoomPage()), - '/add-room': (_) => const MaterialPage(child: JoinRoomPage()), - '/r/:server/:tag/:page?': (info) => MaterialPage( - child: RoomPage(info.pathParameters['server'] ?? "", - info.pathParameters['tag'] ?? "", - page: info.pathParameters['page'] ?? ""), - ), - '/r/:server/:tag/': (info) => MaterialPage( - child: RoomPage(info.pathParameters['server'] ?? "", - info.pathParameters['tag'] ?? "", - page: 'list'), - ), - }, onUnknownRoute: (_) => const Redirect('/')); + '/': (_) => const MaterialPage(child: HomePage()), + '/add-room/new': (_) => const MaterialPage(child: NewRoomPage()), + '/add-room': (_) => const MaterialPage(child: JoinRoomPage()), + '/r/:server/:tag/': (info) { + final server = info.pathParameters['server'] ?? ""; + final tag = info.pathParameters['tag'] ?? ""; + + return MaterialPage( + child: RoomPage(server, tag)); + }, +}, onUnknownRoute: (_) => const Redirect('/')); void main() { WidgetsFlutterBinding.ensureInitialized(); @@ -61,56 +59,56 @@ class _OutbagAppState extends State { // with existing details (() async { - User credentials; - try { - credentials = await User.fromDisk(); - } catch (_) { - // invalid credentials - // log out - setState(() { - isAuthorized = false; - }); - return; - } - try { - final resp = await postUnauthorized( + User credentials; + try { + credentials = await User.fromDisk(); + } catch (_) { + // invalid credentials + // log out + setState(() { + isAuthorized = false; + }); + return; + } + try { + final resp = await postUnauthorized( target: credentials.server, path: 'signin', body: { 'name': credentials.username, 'server': credentials.server.tag, 'accountKey': credentials.password - }); - if (resp.res == Result.ok) { - setState(() { - isAuthorized = true; }); - } else { - // credentials are wrong - // log out - setState(() { - isAuthorized = true; - }); - } - } catch (_) { - // user is currently offline - // approve login, - // until user goes back offline - // NOTE TODO: check user data once online + if (resp.res == Result.ok) { setState(() { - isAuthorized = true; + isAuthorized = true; + }); + } else { + // credentials are wrong + // log out + setState(() { + isAuthorized = true; }); } + } catch (_) { + // user is currently offline + // approve login, + // until user goes back offline + // NOTE TODO: check user data once online + setState(() { + isAuthorized = true; + }); + } })(); // wait for user to be authorized User.listen((data) async { - try { - await User.fromDisk(); - setState(() { - isAuthorized = true; - }); - } catch (_) {} + try { + await User.fromDisk(); + setState(() { + isAuthorized = true; + }); + } catch (_) {} }); } @@ -124,8 +122,8 @@ class _OutbagAppState extends State { theme: ThemeData(useMaterial3: true, brightness: Brightness.light), darkTheme: ThemeData(useMaterial3: true, brightness: Brightness.dark), routerDelegate: RoutemasterDelegate( - routesBuilder: (context) => - isAuthorized ? routesLoggedIn : routesUnauthorized), + routesBuilder: (context) => + isAuthorized ? routesLoggedIn : routesUnauthorized), routeInformationParser: const RoutemasterParser(), ); } diff --git a/lib/screens/room.dart b/lib/screens/room.dart index dd6d7ea..09c9379 100644 --- a/lib/screens/room.dart +++ b/lib/screens/room.dart @@ -1,40 +1,147 @@ import 'package:flutter/material.dart'; +import 'package:outbag_app/backend/request.dart'; +import 'package:outbag_app/backend/room.dart'; +import 'package:outbag_app/backend/user.dart'; +import 'package:outbag_app/screens/room/about.dart'; +import 'package:outbag_app/screens/room/categories.dart'; +import 'package:outbag_app/screens/room/products.dart'; +import 'package:outbag_app/screens/room/shopping_list.dart'; import 'package:routemaster/routemaster.dart'; class RoomPage extends StatefulWidget { final String server; final String tag; - final String page; - const RoomPage(this.server, this.tag, {super.key, this.page="list"}); + const RoomPage(this.server, this.tag, {super.key}); @override State createState() => _RoomPageState(); } -class _RoomPageState extends State { +class _RoomPageState extends State { + RoomInfo? info; + Room? room; + + final PageController _ctr = PageController(initialPage: 0); + int page = 0; + + // fetch room information + void fetchInfo() async { + try { + final diskRoom = + await Room.fromDisk(serverTag: widget.server, id: widget.tag); + setState(() { + room = diskRoom; + }); + } catch (_) {} + + // fetch additional data from web + User user; + try { + user = await User.fromDisk(); + } catch (_) { + // probably not logged in + return; + } + + try { + final resp = await postWithCreadentials( + path: 'getRoomInfo', + credentials: user, + target: user.server, + body: {'room': widget.tag, 'server': widget.server}); + if (resp.res == Result.ok) { + final info = RoomInfo.fromJSON(resp.body['data']); + final room = Room.fromJSON(resp.body['data']); + setState(() { + this.info = info; + this.room = room; + }); + } + } catch (_) {} + } + @override void initState() { super.initState(); - // TODO: fetch room data - // from somewhere + + // schedule info-get + fetchInfo(); + + _ctr.addListener(() { + setState(() { + page = _ctr.page?.toInt() ?? _ctr.initialPage; + }); + }); + + Room.listen((_) async { + // rooms changed on disk + // probably this one, + // because it is currently open + // NOTE: might be a different room + // (if a background listener is implemented at some point, + // checking if this room changed might improve performance) + try { + final r = await Room.fromDisk( + serverTag: widget.server, + id: widget.tag + ); + setState(() { + room = r; + }); + } catch (_) {} + }); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - title: Text('Room name'), + title: Text(room?.name ?? 'Unknown Room'), leading: IconButton( onPressed: () { + // go back Routemaster.of(context).history.back(); }, icon: const Icon(Icons.arrow_back), tooltip: "Go back", ), ), - floatingActionButton: FloatingActionButton.extended( - onPressed: () => {}, label: Text('Add'), icon: const Icon(Icons.add)), + body: PageView( + controller: _ctr, + children: [ + ShoppingListPage(room, info), + RoomProductsPage(room, info), + RoomCategoriesPage(room, info), + AboutRoomPage(room, info) + ], + ), + bottomNavigationBar: NavigationBar( + onDestinationSelected: (int index) { + _ctr.animateToPage(index, + curve: Curves.easeInOut, + duration: const Duration(milliseconds: 300)); + }, + selectedIndex: page, + destinations: const [ + NavigationDestination( + icon: Icon(Icons.list), + label: "List", + tooltip: 'View shopping list'), + NavigationDestination( + icon: Icon(Icons.inventory_2), + label: "Products", + tooltip: 'View saved items'), + NavigationDestination( + icon: Icon(Icons.category), + label: "Categories", + tooltip: 'View categories'), + NavigationDestination( + icon: Icon(Icons.info_rounded), + label: "About", + tooltip: 'View room info'), + ], + ), ); } } diff --git a/lib/screens/room/about.dart b/lib/screens/room/about.dart new file mode 100644 index 0000000..fb41519 --- /dev/null +++ b/lib/screens/room/about.dart @@ -0,0 +1,248 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:outbag_app/backend/errors.dart'; +import 'package:outbag_app/backend/permissions.dart'; +import 'package:outbag_app/backend/request.dart'; +import 'package:outbag_app/backend/room.dart'; +import 'dart:math'; + +import 'package:outbag_app/backend/user.dart'; +import 'package:routemaster/routemaster.dart'; + +class AboutRoomPage extends StatefulWidget { + final RoomInfo? info; + final Room? room; + + const AboutRoomPage(this.room, this.info, {super.key}); + + @override + State createState() => _AboutRoomPageState(); +} + +class _AboutRoomPageState extends State { + @override + void initState() { + super.initState(); + } + + @override + Widget build(BuildContext context) { + final textTheme = Theme.of(context) + .textTheme + .apply(displayColor: Theme.of(context).colorScheme.onSurface); + + double width = MediaQuery.of(context).size.width; + double height = MediaQuery.of(context).size.height; + double smallest = min(width, height); + + return Center( + child: Column(children: [ + // room meta display + ...(widget.room != null) + ? [ + Padding( + padding: const EdgeInsets.all(14), + child: Column( + children: [ + SvgPicture.asset( + (widget.room?.icon?.img)!, + width: smallest * 0.2, + height: smallest * 0.2, + ), + Text( + widget.room?.name ?? '', + style: textTheme.displayMedium, + ), + Text( + '${widget.room?.id}@${widget.room?.serverTag}', + style: textTheme.bodySmall, + ), + Text(widget.room?.description ?? '', + style: textTheme.bodyMedium), + SegmentedButton( + showSelectedIcon: true, + multiSelectionEnabled: false, + emptySelectionAllowed: false, + segments: RoomVisibility.list().map((vis) { + return ButtonSegment( + value: vis.type, + label: Text(vis.text), + icon: Icon(vis.icon)); + }).toList(), + onSelectionChanged: ((vset) { + // check permission + // only show confirm dialog when user + // is admin, owner or has CHANGE_ADMIN permission + if (widget.info == null || + (!(widget.info?.isAdmin ?? false) && + !(widget.info?.isOwner ?? false) && + ((widget.info?.permissions)! & + RoomPermission.ota == + 0))) { + // action not permitted + // NOTE: no error dialog should be shown + // because the action is supposed to be hidden + return; + } + + final vis = RoomVisibility(vset.first); + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Change room visibility'), + content: Text( + 'Do you really want to change the room visibility to: ${vis.text}'), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () async { + final scaffMgr = + ScaffoldMessenger.of(context); + final nav = Navigator.of(context); + + User user; + try { + user = await User.fromDisk(); + } catch (_) { + // probably not logged in + nav.pop(); + return; + } + + try { + final resp = + await postWithCreadentials( + path: 'setVisibility', + target: user.server, + body: { + 'room': widget.room?.id, + 'server': (widget + .room?.serverTag)!, + 'visibility': vset.first + }, + credentials: user); + if (resp.res == Result.ok) { + Room r = widget.room!; + r.visibility = vis; + r.toDisk(); + } else { + // server error + final snackBar = SnackBar( + behavior: + SnackBarBehavior.floating, + content: Text( + errorAsString(resp.body)), + action: SnackBarAction( + label: 'Dismiss', + onPressed: () { + scaffMgr + .hideCurrentSnackBar(); + }, + ), + ); + + scaffMgr.hideCurrentSnackBar(); + scaffMgr.showSnackBar(snackBar); + } + } catch (e) { + print(e); + // network error + final snackBar = SnackBar( + behavior: SnackBarBehavior.floating, + content: + const Text('Network error'), + action: SnackBarAction( + label: 'Dismiss', + onPressed: () { + scaffMgr.hideCurrentSnackBar(); + }, + ), + ); + + scaffMgr.hideCurrentSnackBar(); + scaffMgr.showSnackBar(snackBar); + } + + nav.pop(); + }, + child: const Text('Ok'), + ) + ], + )); + }), + selected: {(widget.room?.visibility?.type)!}, + selectedIcon: Icon((widget.room?.visibility?.icon)!), + ), + ], + ), + ) + ] + : [], + + Padding( + padding: const EdgeInsets.all(14), + child: Column( + children: [ + // edit room meta button + ...(widget.info != null && + ((widget.info?.isAdmin ?? false) || + (widget.info?.isOwner ?? false) || + ((widget.info?.permissions)! & + RoomPermission.changeMeta != + 0))) + ? [ + ListTile( + trailing: const Icon(Icons.chevron_right), + title: const Text('Edit Metadata'), + subtitle: const Text( + 'Change the rooms name, description and icon'), + onTap: () { + // TODO: show edit room screen + }, + ), + ] + : [], + // open members view + ListTile( + trailing: const Icon(Icons.chevron_right), + title: const Text('Members'), + subtitle: const Text('Show Member list'), + onTap: () { + // TODO: open member view screen + }, + ), + ...(widget.info != null && + ((widget.info?.isAdmin ?? false) || + (widget.info?.isOwner ?? false) || + ((widget.info?.permissions)! & RoomPermission.ota != + 0))) + ? [ + ListTile( + trailing: const Icon(Icons.chevron_right), + title: const Text('OTA'), + subtitle: const Text('Manage and delete OTAs'), + onTap: () { + // TODO: show manage ota screen + }, + ), + ] + : [], + ], + )), + + Padding( + padding: const EdgeInsets.all(8), + child: FilledButton.tonal( + child: const Text('Leave Room'), + onPressed: () { + // TODO: show confirm dialog + }, + )) + ])); + } +}