diff --git a/lib/backend/room.dart b/lib/backend/room.dart index 2842ec5..4f7c5c0 100644 --- a/lib/backend/room.dart +++ b/lib/backend/room.dart @@ -127,32 +127,32 @@ class RoomIcon { String get text { switch (type.toLowerCase()) { case 'love': - return 'Friends'; + return 'Friends'; case 'sports': - return 'Sports'; + return 'Sports'; case 'pets': - return 'Pets'; + return 'Pets'; case 'vacation': - return 'Vacation'; + return 'Vacation'; case 'gifts': - return 'Gifts'; + return 'Gifts'; case 'groceries': - return 'Groceries'; + return 'Groceries'; case 'fashion': - return 'Clothing'; + return 'Clothing'; case 'art': - return 'Arts & Crafts'; + return 'Arts & Crafts'; case 'tech': - return 'Electronics'; + return 'Electronics'; case 'home': - return 'Home supplies'; + return 'Home supplies'; case 'family': - return 'Family'; + return 'Family'; case 'social': - return 'Social'; + return 'Social'; case 'other': default: - return 'Other'; + return 'Other'; } } @@ -161,44 +161,44 @@ class RoomIcon { String path = ""; switch (type.toLowerCase()) { case 'love': - path = 'undraw/undraw_couple.svg'; - break; + path = 'undraw/undraw_couple.svg'; + break; case 'sports': - path = 'undraw/undraw_greek_freak.svg'; - break; + path = 'undraw/undraw_greek_freak.svg'; + break; case 'pets': - path = 'undraw/undraw_dog.svg'; - break; + path = 'undraw/undraw_dog.svg'; + break; case 'vacation': - path = 'undraw/undraw_trip.svg'; - break; + path = 'undraw/undraw_trip.svg'; + break; case 'gifts': - path = 'undraw/undraw_gifts.svg'; - break; + path = 'undraw/undraw_gifts.svg'; + break; case 'groceries': - path = 'undraw/undraw_gone_shopping.svg'; - break; + path = 'undraw/undraw_gone_shopping.svg'; + break; case 'fashion': - path = 'undraw/undraw_jewelry.svg'; - break; + path = 'undraw/undraw_jewelry.svg'; + break; case 'art': - path = 'undraw/undraw_sculpting.svg'; - break; + path = 'undraw/undraw_sculpting.svg'; + break; case 'tech': - path = 'undraw/undraw_progressive_app.svg'; - break; + path = 'undraw/undraw_progressive_app.svg'; + break; case 'home': - path = 'undraw/undraw_under_construction.svg'; - break; + path = 'undraw/undraw_under_construction.svg'; + break; case 'family': - path = 'undraw/undraw_family.svg'; - break; + path = 'undraw/undraw_family.svg'; + break; case 'social': - path = 'undraw/undraw_pizza_sharing.svg'; - break; + path = 'undraw/undraw_pizza_sharing.svg'; + break; case 'other': default: - path = 'undraw/undraw_file_manager.svg'; + path = 'undraw/undraw_file_manager.svg'; } return asset(path); @@ -214,7 +214,7 @@ class Room { RoomVisibility? visibility = RoomVisibility.private; Room( - {required this.id, + {required this.id, required this.serverTag, this.name = "", this.description = "", @@ -263,12 +263,12 @@ class Room { factory Room.fromMap(Map map) { return Room( - id: map['id'], - serverTag: map['server'], - name: map['name'], - description: map['description'] ?? '', - icon: RoomIcon(type: map['icon'] ?? 'Other'), - visibility: RoomVisibility(map['visibility'] ?? 0)); + id: map['id'], + serverTag: map['server'], + name: map['name'], + description: map['description'] ?? '', + icon: RoomIcon(type: map['icon'] ?? 'Other'), + visibility: RoomVisibility(map['visibility'] ?? 0)); } Map toMap() { @@ -284,12 +284,12 @@ class Room { factory Room.fromJSON(dynamic json) { return Room( - id: json['name'], - serverTag: json['server'], - name: json['title'], - description: json['description'], - icon: RoomIcon(type: json['icon']), - visibility: RoomVisibility(json['visibility'])); + id: json['name'], + serverTag: json['server'], + name: json['title'], + description: json['description'], + icon: RoomIcon(type: json['icon']), + visibility: RoomVisibility(json['visibility'])); } Future toDisk() async { @@ -308,7 +308,7 @@ class Room { } static Future fromDisk( - {required String id, required String serverTag}) async { + {required String id, required String serverTag}) async { final db = Localstore.instance; final raw = await db.collection('rooms').doc('$id@$serverTag').get(); return Room.fromMap(raw!); @@ -323,13 +323,13 @@ class RoomMember { final bool isAdmin; const RoomMember( - {required this.id, required this.serverTag, required this.isAdmin}); + {required this.id, required this.serverTag, required this.isAdmin}); factory RoomMember.fromJSON(dynamic json) { return RoomMember( - id: json['name'], - serverTag: json['server'], - isAdmin: json['admin'] == 1); + id: json['name'], + serverTag: json['server'], + isAdmin: json['admin'] == 1); } String get humanReadableName { @@ -344,17 +344,17 @@ class RoomInfo { final int permissions; const RoomInfo( - {required this.permissions, + {required this.permissions, required this.owner, required this.isAdmin, required this.isOwner}); factory RoomInfo.fromJSON(dynamic json) { return RoomInfo( - permissions: json['rights'], - owner: json['owner'], - isAdmin: json['isAdmin'], - isOwner: json['isOwner']); + permissions: json['rights'], + owner: json['owner'], + isAdmin: json['isAdmin'], + isOwner: json['isOwner']); } } @@ -364,43 +364,102 @@ class RoomCategory { final ColorSwatch color; const RoomCategory( - {required this.id, required this.name, required this.color}); + {required this.id, required this.name, required this.color}); factory RoomCategory.fromJSON(dynamic json) { return RoomCategory( - id: json['id'], - name: json['title'], - color: colorFromString(json['color'])); + id: json['id'], + name: json['title'], + color: colorFromString(json['color'])); + } + + static List> listColors() { + return [ + "red", + "green", + "yellow", + "blue", + "aqua", + "purple", + "red-acc", + "green-acc", + "yellow-acc", + "blue-acc", + "aqua-acc", + "purple-acc", + ].map((txt) => colorFromString(txt)).toList(); } } ColorSwatch colorFromString(String text) { switch (text.toLowerCase()) { case 'red-acc': - return Colors.redAccent; + return Colors.redAccent; case 'green-acc': - return Colors.greenAccent; + return Colors.greenAccent; case 'yellow-acc': - return Colors.yellowAccent; + return Colors.yellowAccent; case 'blue-acc': - return Colors.blueAccent; + return Colors.blueAccent; case 'aqua-acc': - return Colors.tealAccent; + return Colors.tealAccent; case 'purple-acc': - return Colors.purpleAccent; + return Colors.purpleAccent; case 'red': - return Colors.red; + return Colors.red; case 'green': - return Colors.green; + return Colors.green; case 'yellow': - return Colors.yellow; + return Colors.yellow; case 'blue': - return Colors.blue; + return Colors.blue; case 'aqua': - return Colors.teal; + return Colors.teal; case 'purple': default: - return Colors.purple; + return Colors.purple; } } + +String colorIdFromColor(ColorSwatch color) { + if (color == Colors.redAccent) { + return 'red-acc'; + } + if (color == Colors.greenAccent) { + return 'green-acc'; + } + if (color == Colors.yellowAccent) { + return 'yellow-acc'; + } + if (color == Colors.blueAccent) { + return 'blue-acc'; + } + if (color == Colors.tealAccent) { + return 'teal-acc'; + } + if (color == Colors.purpleAccent) { + return 'purple-acc'; + } + + if (color == Colors.red) { + return 'red'; + } + if (color == Colors.green) { + return 'green'; + } + if (color == Colors.yellow) { + return 'yellow'; + } + if (color == Colors.blue) { + return 'blue'; + } + if (color == Colors.teal) { + return 'teal'; + } + if (color == Colors.purple) { + return 'purple'; + } + + return 'purple'; +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 9414070..e9dc8d1 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1,9 +1,4 @@ { - "helloWorld": "Hello World!", - "@helloWorld": { - "description": "The conventional newborn programmer greeting" - }, - "welcomeTitle": "Welcome to Outbag", "@welcomeTitle": { "description": "Title shown on welcome screen" @@ -291,7 +286,7 @@ "errorAccountDeletion": "Your account no longer exists", "errorNoSuchAccount": "Account does not exist", - "errorUsernameTaken": "Already with that username already exists", + "errorUsernameTaken": "Account with that username already exists", "errorNoSuchRoom": "Room does not exist", "errorRoomIdTaken": "Room with that ID already exists", @@ -318,5 +313,28 @@ "errorInvalidToken": "Invalid Token", "errorServerDoesntExist": "Server does not exist", - "errorInvalidServerToken": "Server token invalid" + "errorInvalidServerToken": "Server token invalid", + + "editCategoryShort": "Edit", + "editCategory": "Edit Category", + "editCategoryLong": "Change the category color or name", + "newCategory": "New Category", + "newCategoryLong": "Create a new category", + "newCategoryShort": "Create", + "deleteCategory": "Delete Category", + "deleteCategoryConfirm": "Do you really want to remove the category named {category}", + "@deleteCategoryConfirm": { + "placeholders": { + "category": { + "type": "String", + "example": "fruit" + } + } + }, + "deleteCategoryLong": "Remove category and unlink items", + "changeCategoryColor": "Change category color", + "chooseCategoryColor": "Choose a color for your category", + "inputCategoryNameLabel": "Category Name", + "inputCategoryNameHint": "Name the category", + "inputCategoryNameHelp": "Categories can be used to sort your shopping list" } diff --git a/lib/main.dart b/lib/main.dart index ef23183..09b755e 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart'; import 'package:outbag_app/backend/themes.dart'; import 'package:outbag_app/backend/user.dart'; import 'package:outbag_app/backend/request.dart'; +import 'package:outbag_app/screens/room/categories/edit.dart'; import 'package:outbag_app/tools/fetch_wrapper.dart'; @@ -28,9 +29,9 @@ void main() { } final GlobalKey _rootNavigatorKey = - GlobalKey(debugLabel: 'root'); +GlobalKey(debugLabel: 'root'); final GlobalKey _userShellNavigatorKey = - GlobalKey(debugLabel: 'user'); +GlobalKey(debugLabel: 'user'); class OutbagApp extends StatefulWidget { const OutbagApp({super.key}); @@ -52,7 +53,7 @@ class _OutbagAppState extends State { try { final theme = await AppTheme.fromDisk(); setState(() { - this.theme = theme; + this.theme = theme; }); } catch (_) {} } @@ -62,14 +63,14 @@ class _OutbagAppState extends State { try { final user = await User.fromDisk(); setState(() { - this.user = user; + this.user = user; }); } catch (_) { // user unavailable // invalid credentials // log out setState(() { - user = null; + user = null; }); } } @@ -79,8 +80,8 @@ class _OutbagAppState extends State { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) async { - loadTheme(); - loadUser(); + loadTheme(); + loadUser(); }); } @@ -90,31 +91,31 @@ class _OutbagAppState extends State { // with existing details // NOTE: also functions as a way to verify ther data await doNetworkRequest(null, - req: () => postWithCreadentials( - target: user.server, - path: 'getMyAccount', - credentials: user, - body: {}), - onOK: (body) async { - final i = AccountMeta.fromJSON(body['data']); - info = i; - }, - onServerErr: (_) { - info = null; + req: () => postWithCreadentials( + target: user.server, + path: 'getMyAccount', + credentials: user, + body: {}), + onOK: (body) async { + final i = AccountMeta.fromJSON(body['data']); + info = i; + }, + onServerErr: (_) { + info = null; - setState(() { + setState(() { this.user = null; - }); - return true; - }, - onNetworkErr: () { - info = null; - // user is currently offline - // approve login, - // until user goes back offline - // NOTE TODO: check user data once online - return true; }); + return true; + }, + onNetworkErr: () { + info = null; + // user is currently offline + // approve login, + // until user goes back offline + // NOTE TODO: check user data once online + return true; + }); return info; } @@ -122,143 +123,156 @@ class _OutbagAppState extends State { @override Widget build(BuildContext context) { return MultiProvider( - providers: [ - Provider.value(value: theme), + providers: [ + Provider.value(value: theme), + ], + child: MaterialApp.router( + title: "Outbag", + localizationsDelegates: const [ + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + AppLocalizations.delegate ], - child: MaterialApp.router( - title: "Outbag", - localizationsDelegates: const [ - GlobalMaterialLocalizations.delegate, - GlobalWidgetsLocalizations.delegate, - GlobalCupertinoLocalizations.delegate, - AppLocalizations.delegate - ], - supportedLocales: AppLocalizations.supportedLocales, - themeMode: theme.mode, - theme: ThemeData(useMaterial3: true, brightness: Brightness.light), - darkTheme: ThemeData(useMaterial3: true, brightness: Brightness.dark), - routerConfig: GoRouter( - navigatorKey: _rootNavigatorKey, - initialLocation: '/', - redirect: (context, state) async { - if (user == null) { - // prelogin - if (!state.subloc.startsWith('/welcome')) { - // prevent unauthorized user from accessing home - return '/welcome'; - } - } else { - // post login - if (state.subloc.startsWith('/welcome')) { - // prevent authorized user from accessing /welcome - return '/'; - } - } + supportedLocales: AppLocalizations.supportedLocales, + themeMode: theme.mode, + theme: ThemeData(useMaterial3: true, brightness: Brightness.light), + darkTheme: ThemeData(useMaterial3: true, brightness: Brightness.dark), + routerConfig: GoRouter( + navigatorKey: _rootNavigatorKey, + initialLocation: '/', + redirect: (context, state) async { + if (user == null) { + // prelogin + if (!state.subloc.startsWith('/welcome')) { + // prevent unauthorized user from accessing home + return '/welcome'; + } + } else { + // post login + if (state.subloc.startsWith('/welcome')) { + // prevent authorized user from accessing /welcome + return '/'; + } + } - return null; - }, + return null; + }, + routes: [ + // unauthorized routes + GoRoute( + name: 'welcome', + path: '/welcome', + builder: (context, state) => const WelcomePage(), routes: [ - // unauthorized routes GoRoute( - name: 'welcome', - path: '/welcome', - builder: (context, state) => const WelcomePage(), - routes: [ - GoRoute( - name: 'signin', - path: 'signin', - builder: (context, state) => - AuthPage(mode: Mode.signin, refresh: loadUser), - ), - GoRoute( - name: 'signup', - path: 'signup', - builder: (context, state) => - AuthPage(mode: Mode.signup, refresh: loadUser), - ), - GoRoute( - name: 'signup-ota', - path: 'signup-ota', - builder: (context, state) => - AuthPage(mode: Mode.signupOTA, refresh: loadUser), - ), - ]), - - // authorized routes - ShellRoute( - navigatorKey: _userShellNavigatorKey, - builder: (context, state, child) => Provider.value( - value: user!, - child: FutureProvider( - initialData: null, - child: child, - create: (context)=>fetchInfo(context.read()), - )), - routes: [ - GoRoute( - path: '/', - name: 'home', - builder: (context, state) => const HomePage(), - routes: [ - GoRoute( - name: 'settings', - path: 'settings', - builder: (context, state) => - SettingsPage( - refreshTheme: loadTheme, - refreshUser: loadUser - )), - GoRoute( - path: 'join-room', - name: 'add-room', - builder: (context, state) => - const JoinRoomPage(), - routes: [ - GoRoute( - path: 'new', - name: 'new-room', - builder: (context, state) => - const NewRoomPage()), - ]), - GoRoute( - name: 'room', - path: 'r/:server/:id', - builder: (context, state) => RoomPage( - state.params['server'] ?? '', - state.params['id'] ?? ''), - routes: [ - GoRoute( - name: 'edit-room', - path: 'edit', - builder: (context, state) => EditRoomPage( - state.params['server'] ?? '', - state.params['id'] ?? '')), - GoRoute( - name: 'room-members', - path: 'members', - builder: (context, state) => - ManageRoomMembersPage( - state.params['server'] ?? '', - state.params['id'] ?? '')), - GoRoute( - name: 'room-permissions', - path: 'roles', - builder: (context, state) => - EditRoomPermissionSetPage( - state.params['server'] ?? '', - state.params['id'] ?? '')), - ]) - ]), - ]), - - // routes that can be accessed - // with and without an account - // i.e the about screen + name: 'signin', + path: 'signin', + builder: (context, state) => + AuthPage(mode: Mode.signin, refresh: loadUser), + ), GoRoute( - path: '/about', - name: 'about', - builder: (context, state) => const Text('About')) - ]), - )); + name: 'signup', + path: 'signup', + builder: (context, state) => + AuthPage(mode: Mode.signup, refresh: loadUser), + ), + GoRoute( + name: 'signup-ota', + path: 'signup-ota', + builder: (context, state) => + AuthPage(mode: Mode.signupOTA, refresh: loadUser), + ), + ]), + + // authorized routes + ShellRoute( + navigatorKey: _userShellNavigatorKey, + builder: (context, state, child) => Provider.value( + value: user!, + child: FutureProvider( + initialData: null, + child: child, + create: (context)=>fetchInfo(context.read()), + )), + routes: [ + GoRoute( + path: '/', + name: 'home', + builder: (context, state) => const HomePage(), + routes: [ + GoRoute( + name: 'settings', + path: 'settings', + builder: (context, state) => + SettingsPage( + refreshTheme: loadTheme, + refreshUser: loadUser + )), + GoRoute( + path: 'join-room', + name: 'add-room', + builder: (context, state) => + const JoinRoomPage(), + routes: [ + GoRoute( + path: 'new', + name: 'new-room', + builder: (context, state) => + const NewRoomPage()), + ]), + GoRoute( + name: 'room', + path: 'r/:server/:id', + builder: (context, state) => RoomPage( + state.params['server'] ?? '', + state.params['id'] ?? ''), + routes: [ + GoRoute( + name: 'edit-room', + path: 'edit', + builder: (context, state) => EditRoomPage( + state.params['server'] ?? '', + state.params['id'] ?? '')), + GoRoute( + name: 'room-members', + path: 'members', + builder: (context, state) => + ManageRoomMembersPage( + state.params['server'] ?? '', + state.params['id'] ?? '')), + GoRoute( + name: 'room-permissions', + path: 'roles', + builder: (context, state) => + EditRoomPermissionSetPage( + state.params['server'] ?? '', + state.params['id'] ?? '')), + GoRoute( + name: 'new-category', + path: 'new-category', + builder: (context, state)=>EditCategoryPage( + state.params['server'] ?? '', + state.params['id'] ?? '')), + GoRoute( + name: 'edit-category', + path: 'edit-category/:cid', + builder: (context, state)=>EditCategoryPage( + state.params['server'] ?? '', + state.params['id'] ?? '', + id: int.tryParse(state.params['cid'] ?? ''))), + ]) + ]), + ]), + + // routes that can be accessed + // with and without an account + // i.e the about screen + GoRoute( + path: '/about', + name: 'about', + builder: (context, state) => const Text('About')) + ]), + )); } } diff --git a/lib/screens/room/categories/edit.dart b/lib/screens/room/categories/edit.dart new file mode 100644 index 0000000..75626c4 --- /dev/null +++ b/lib/screens/room/categories/edit.dart @@ -0,0 +1,248 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.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/tools/fetch_wrapper.dart'; +import 'package:outbag_app/tools/snackbar.dart'; +import 'package:provider/provider.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'dart:math'; + +class EditCategoryPage extends StatefulWidget { + final String server; + final String tag; + int? id; + + EditCategoryPage(this.server, this.tag, {super.key, this.id}); + + @override + State createState() => _EditCategoryPageState(); +} + +class _EditCategoryPageState extends State { + final TextEditingController _ctrName = TextEditingController(); + ColorSwatch _ctrColor = Colors.purple; + + bool showSpinner = false; + + @override + void initState() { + super.initState(); + + WidgetsBinding.instance.addPostFrameCallback((_) => fetchCategory()); + } + + void fetchCategory() { + final user = context.read(); + + // TODO: load cached rooms + + doNetworkRequest(ScaffoldMessenger.of(context), + req: () => postWithCreadentials( + credentials: user, + target: user.server, + path: 'getCategory', + body: { + 'room': widget.tag, + 'server': widget.server, + 'listCatID': widget.id}), + onOK: (json) { + setState(() { + _ctrName.text = json['data']['title']; + _ctrColor = colorFromString(json['data']['color']); + }); + }); + } + + @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(min(width, height), 400); + + return showSpinner + ? Scaffold( + body: Center( + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const CircularProgressIndicator(), + Text(AppLocalizations.of(context)!.loading, + style: textTheme.titleLarge), + ]))) + : Scaffold( + appBar: AppBar( + title: Text((widget.id == null) + ? AppLocalizations.of(context)!.newCategory + : AppLocalizations.of(context)!.editCategory), + ), + body: SingleChildScrollView( + child: Center( + child: Padding( + padding: const EdgeInsets.all(14), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 400), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + IconButton( + icon: Icon(Icons.square_rounded, + size: 48.0, color: _ctrColor), + tooltip: AppLocalizations.of(context)! + .changeCategoryColor, + onPressed: () { + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text( + AppLocalizations.of(context)! + .chooseCategoryColor), + actions: const [], + content: SizedBox( + width: smallest * 0.3 * 3, + height: smallest * 0.3 * 3, + child: GridView.count( + crossAxisCount: 3, + children: RoomCategory + .listColors() + .map((color) { + return GridTile( + child: IconButton( + icon: Icon( + Icons + .square_rounded, + color: + color, + size: 48.0), + // do not display tooltip for now + // as it is hard to translate + // and the tooltip prevented the click event, + // when clicked on the tooltip bar + // tooltip:icon.text, + onPressed: () { + setState(() { + _ctrColor = + color; + }); + Navigator.of( + ctx) + .pop(); + })); + }).toList())), + )); + }, + ), + Padding( + padding: const EdgeInsets.all(8), + child: TextField( + controller: _ctrName, + keyboardType: TextInputType.name, + decoration: InputDecoration( + prefixIcon: const Icon(Icons.badge), + labelText: AppLocalizations.of(context)! + .inputCategoryNameLabel, + hintText: AppLocalizations.of(context)! + .inputCategoryNameHint, + helperText: AppLocalizations.of(context)! + .inputCategoryNameHelp, + border: const OutlineInputBorder(), + ), + ), + ), + ], + ))))), + floatingActionButton: FloatingActionButton.extended( + onPressed: () async { + final scaffMgr = ScaffoldMessenger.of(context); + final router = GoRouter.of(context); + final trans = AppLocalizations.of(context); + + // name may not be empty + if (_ctrName.text.isEmpty) { + showSimpleSnackbar(scaffMgr, + text: trans!.errorNoRoomName, action: trans.ok); + + return; + } + + setState(() { + showSpinner = true; + }); + + final user = context.read(); + final color = colorIdFromColor(_ctrColor); + + if (widget.id == null) { + await doNetworkRequest(scaffMgr, + req: () => postWithCreadentials( + target: user.server, + credentials: user, + path: 'addCategory', + body: { + 'room': widget.tag, + 'server': widget.server, + 'title': _ctrName.text, + 'color': color + }), + onOK: (body) async { + final id = body['data']['catID']; + + final cat = RoomCategory( + id: id, name: _ctrName.text, color: _ctrColor); + // TODO: cache category + + // go back + router.pop(); + return; + }, + after: () { + setState(() { + showSpinner = false; + }); + }); + } else { + await doNetworkRequest(scaffMgr, + req: () => postWithCreadentials( + target: user.server, + credentials: user, + path: 'changeCategory', + body: { + 'room': widget.tag, + 'server': widget.server, + 'title': _ctrName.text, + 'listCatID': widget.id, + 'color': color + }), + onOK: (body) async { + final cat = RoomCategory( + id: widget.id!, + name: _ctrName.text, + color: _ctrColor); + // TODO: cache category + + // go back + router.pop(); + return; + }, + after: () { + setState(() { + showSpinner = false; + }); + }); + } + }, + label: Text((widget.id == null) + ? AppLocalizations.of(context)!.newCategoryShort + : AppLocalizations.of(context)!.editCategoryShort), + icon: Icon((widget.id == null) + ? Icons.add : Icons.edit)), + ); + } +} diff --git a/lib/screens/room/pages/categories.dart b/lib/screens/room/pages/categories.dart index 7aa62ed..b722fce 100644 --- a/lib/screens/room/pages/categories.dart +++ b/lib/screens/room/pages/categories.dart @@ -1,4 +1,6 @@ import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:outbag_app/backend/permissions.dart'; import 'package:outbag_app/backend/request.dart'; import 'package:outbag_app/backend/room.dart'; import 'package:outbag_app/backend/user.dart'; @@ -51,33 +53,202 @@ class _RoomCategoriesPageState extends State { @override Widget build(BuildContext context) { + final textTheme = Theme.of(context) + .textTheme + .apply(displayColor: Theme.of(context).colorScheme.onSurface); + return Scaffold( body: ReorderableListView.builder( + buildDefaultDragHandles: false, itemBuilder: (context, index) { final item = list[index]; return ListTile( key: Key('cat-${item.id}'), leading: Icon(Icons.square_rounded, color: item.color), + trailing: ((widget.info?.isAdmin ?? false) || + (widget.info?.isOwner ?? false) || + ((widget.info?.permissions)! & + RoomPermission.editRoomContent != + 0)) + ? ReorderableDragStartListener( + index: index, + child: const Icon(Icons.drag_handle) + ) + : null, title: Text(item.name), onTap: () { // TODO show edit category popup // NOTE: maybe use ModalBottomSheet // and show delete button in there + + if (!((widget.info?.isAdmin ?? false) || + (widget.info?.isOwner ?? false) || + ((widget.info?.permissions)! & + RoomPermission.editRoomContent != + 0))) { + // user is not allowed to edit or delete categories + return; + } + + showModalBottomSheet( + context: context, + builder: (context) => BottomSheet( + builder: (context) => Column(children: [ + Padding( + padding: const EdgeInsets.all(8), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon(Icons.square_rounded, + size: 48.0, color: item.color), + Text(item.name, style: textTheme.titleLarge) + ], + )), + // edit category + ListTile( + leading: const Icon(Icons.edit), + title: Text(AppLocalizations.of(context)!.editCategory), + subtitle: + Text(AppLocalizations.of(context)!.editCategoryLong), + trailing: const Icon(Icons.chevron_right), + onTap: () { + // close the modal bottom sheet + // so the user returns to the list, + // when leaving the category editor + Navigator.of(context).pop(); + + // launch category editor + context.pushNamed('edit-category', params: { + 'server': widget.room!.serverTag, + 'id': widget.room!.id, + 'cid': item.id.toString() + }); + }, + ), + // delete category + ListTile( + leading: const Icon(Icons.delete), + title: Text(AppLocalizations.of(context)!.deleteCategory), + subtitle: Text( + AppLocalizations.of(context)!.deleteCategoryLong), + trailing: const Icon(Icons.chevron_right), + onTap: () { + // show popup + showDialog( + context: context, + builder: (ctx) => AlertDialog( + icon: const Icon(Icons.delete), + title: Text(AppLocalizations.of(context)! + .deleteCategory), + content: Text(AppLocalizations.of(context)! + .deleteCategoryConfirm(item.name)), + actions: [ + TextButton( + onPressed: () { + // close popup + Navigator.of(ctx).pop(); + }, + child: Text( + AppLocalizations.of(context)!.cancel), + ), + FilledButton( + onPressed: () async { + // send request + final scaffMgr = + ScaffoldMessenger.of(ctx); + // popup context + final navInner = Navigator.of(ctx); + // bottomsheet context + final nav = Navigator.of(context); + final user = context.read(); + + doNetworkRequest(scaffMgr, + req: () => postWithCreadentials( + path: 'deleteCategory', + target: user.server, + body: { + 'room': widget.room?.id, + 'server': + widget.room?.serverTag, + 'listCatID': item.id + }, + credentials: user), + onOK: (_) async { + // TODO: remove cached category + }, + after: () { + // close popup + navInner.pop(); + // close modal bottom sheet + nav.pop(); + }); + }, + child: Text(AppLocalizations.of(context)! + .deleteCategory), + ) + ], + )); + }, + ), + ]), + onClosing: () {}, + ), + ); }, ); }, itemCount: list.length, - onReorder: (int start, int current) {}, + onReorder: (int oldIndex, int newIndex) { + + if (!((widget.info?.isAdmin ?? false) || + (widget.info?.isOwner ?? false) || + ((widget.info?.permissions)! & + RoomPermission.editRoomContent != + 0))) { + // user is not allowed to edit or delete categories + return; + } + + setState(() { + if (oldIndex < newIndex) { + newIndex -= 1; + } + final item = list.removeAt(oldIndex); + list.insert(newIndex, item); + + // network request + final user = context.read(); + doNetworkRequest(ScaffoldMessenger.of(context), + req: () => postWithCreadentials( + credentials: user, + target: user.server, + path: 'changeCategoriesOrder', + body: { + 'room': widget.room?.id, + 'server': widget.room?.serverTag, + 'listCatIDs': list.map((item) => item.id).toList() + })); + }); + }, ), - floatingActionButton: FloatingActionButton.extended( + floatingActionButton: ((widget.info?.isAdmin ?? false) || + (widget.info?.isOwner ?? false) || + ((widget.info?.permissions)! & + RoomPermission.editRoomContent != + 0))?FloatingActionButton.extended( icon: const Icon(Icons.add), label: Text(AppLocalizations.of(context)!.newCategoryShort), tooltip: AppLocalizations.of(context)!.newCategoryLong, onPressed: () { - // TODO show new category popup + // show new category popup + context.pushNamed('new-category', params: { + 'server': widget.room!.serverTag, + 'id': widget.room!.id, + }); }, - ), + ):null, ); } }