diff --git a/lib/backend/themes.dart b/lib/backend/themes.dart new file mode 100644 index 0000000..2075479 --- /dev/null +++ b/lib/backend/themes.dart @@ -0,0 +1,84 @@ +import 'package:flutter/material.dart'; +import 'package:localstore/localstore.dart'; + +class AppTheme { + ThemeMode mode; + + AppTheme(this.mode); + + String get name { + if (mode == ThemeMode.light) { + return 'Light'; + } + if (mode == ThemeMode.dark) { + return 'Dark'; + } + + return 'System'; + } + + IconData get icon { + if (mode == ThemeMode.light) { + return Icons.light_mode; + } + if (mode == ThemeMode.dark) { + return Icons.dark_mode; + } + + return Icons.brightness_auto; + } + + static get auto { + return AppTheme(ThemeMode.system); + } + + static get light { + return AppTheme(ThemeMode.light); + } + + static get dark { + return AppTheme(ThemeMode.dark); + } + + static List list() { + return [AppTheme.auto, AppTheme.light, AppTheme.dark]; + } + + static listen(Function(Map) cb) { + final db = Localstore.instance; + final stream = db.collection('settings').stream; + stream.listen(cb); + } + + Future toDisk() async { + final db = Localstore.instance; + await db.collection('settings').doc('ui').set({'theme': mode.index}); + } + + static Future fromDisk() async { + final db = Localstore.instance; + final doc = await db.collection('settings').doc('ui').get(); + try { + final index = doc?['theme']; + final mode = ThemeMode.values[index]; + return AppTheme(mode); + } catch (_) { + return AppTheme(ThemeMode.system); + } + } + + @override + bool operator ==(other) { + if (other.runtimeType == runtimeType) { + if (other.hashCode == hashCode) { + return true; + } + } + + return false; + } + + @override + int get hashCode => mode.index; + +} diff --git a/lib/backend/user.dart b/lib/backend/user.dart index 5816d95..3b604a1 100644 --- a/lib/backend/user.dart +++ b/lib/backend/user.dart @@ -41,12 +41,16 @@ class User { final stream = db.collection('meta').stream; stream.listen(cb); } + + String get humanReadable { + return '$username@${server.tag}'; + } } class AccountMeta { final int permissions; final String username; - final bool discvoverable; + final bool discoverable; final int maxRoomCount; final int maxRoomSize; final int maxRoomMemberCount; @@ -57,7 +61,7 @@ class AccountMeta { required this.maxRoomSize, required this.maxRoomCount, required this.maxRoomMemberCount, - required this.discvoverable}); + required this.discoverable}); factory AccountMeta.fromJSON(dynamic json) { return AccountMeta( @@ -66,6 +70,6 @@ class AccountMeta { maxRoomSize: json['maxRoomSize'], maxRoomCount: json['maxRooms'], maxRoomMemberCount: json['maxUsersPerRoom'], - discvoverable: json['viewable'] == 1); + discoverable: json['viewable'] == 1); } } diff --git a/lib/main.dart b/lib/main.dart index 0e76edf..26a4171 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,10 +1,12 @@ import 'package:flutter/material.dart'; +import 'package:outbag_app/backend/themes.dart'; import 'package:outbag_app/backend/user.dart'; import 'package:outbag_app/screens/room/edit.dart'; import 'package:outbag_app/screens/room/join.dart'; import 'package:outbag_app/screens/room/members.dart'; import 'package:outbag_app/screens/room/permissions.dart'; import 'package:outbag_app/screens/room/new.dart'; +import 'package:outbag_app/screens/settings/main.dart'; import 'package:outbag_app/tools/fetch_wrapper.dart'; import 'package:provider/provider.dart'; import './screens/home.dart'; @@ -26,6 +28,7 @@ final routesUnauthorized = RouteMap(routes: { // routes when user is logged in final routesLoggedIn = RouteMap(routes: { '/': (_) => const MaterialPage(child: HomePage()), + '/settings': (_) => const MaterialPage(child: SettingsPage()), '/add-room/new': (_) => const MaterialPage(child: NewRoomPage()), '/add-room': (_) => const MaterialPage(child: JoinRoomPage()), '/r/:server/:tag/': (info) { @@ -73,12 +76,14 @@ class _OutbagAppState extends State { bool isAuthorized = true; AccountMeta? info; + AppTheme theme = AppTheme.auto; + @override void initState() { super.initState(); // wait for user to be authorized - User.listen((data) async { + User.listen((_) async { try { await User.fromDisk(); setState(() { @@ -87,6 +92,20 @@ class _OutbagAppState extends State { } catch (_) {} }); + AppTheme.listen((_) async { + final theme = await AppTheme.fromDisk(); + setState(() { + this.theme = theme; + }); + }); + + (() async { + final theme = await AppTheme.fromDisk(); + setState(() { + this.theme = theme; + }); + })(); + WidgetsBinding.instance.addPostFrameCallback((_) => fetchInfo()); } @@ -145,12 +164,11 @@ class _OutbagAppState extends State { Provider.value( value: info, ), + Provider.value(value: theme) ], child: MaterialApp.router( title: "Outbag", - // TODO: change back to system (or load from disk) - //themeMode: ThemeMode.system, - themeMode: ThemeMode.dark, + themeMode: theme.mode, theme: ThemeData(useMaterial3: true, brightness: Brightness.light), darkTheme: ThemeData(useMaterial3: true, brightness: Brightness.dark), routerDelegate: RoutemasterDelegate( diff --git a/lib/screens/settings/dialogs/password.dart b/lib/screens/settings/dialogs/password.dart new file mode 100644 index 0000000..e763e96 --- /dev/null +++ b/lib/screens/settings/dialogs/password.dart @@ -0,0 +1,177 @@ +import 'package:flutter/material.dart'; +import 'package:outbag_app/backend/crypto.dart'; +import 'package:outbag_app/backend/request.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:routemaster/routemaster.dart'; + +class ChangePasswordDialog extends StatefulWidget { + const ChangePasswordDialog({super.key}); + + @override + State createState() => _ChangePasswordDialogState(); +} + +class _ChangePasswordDialogState extends State { + final TextEditingController _ctrOldPassword = TextEditingController(); + final TextEditingController _ctrNewPassword = TextEditingController(); + final TextEditingController _ctrNewPasswordRepeat = TextEditingController(); + + User? user; + + void loadUser() async { + final rmaster = Routemaster.of(context); + try { + final u = await User.fromDisk(); + setState(() { + user = u; + }); + } catch (_) { + // logout user + await User.removeDisk(); + // move to welcome screen + rmaster.replace('/'); + } + } + + @override + void initState() { + super.initState(); + + User.listen((data) async { + try { + final u = await User.fromDisk(); + setState(() { + user = u; + }); + } catch (_) {} + }); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('Change Password'), + icon: const Icon(Icons.password), + content: SingleChildScrollView( + child: Column( + children: [ + Padding( + padding: const EdgeInsets.all(8), + child: TextField( + controller: _ctrOldPassword, + keyboardType: TextInputType.visiblePassword, + obscureText: true, + decoration: const InputDecoration( + prefixIcon: Icon(Icons.lock), + labelText: 'Old Password', + hintText: 'Your current password', + helperText: + 'For safety, you have to type your current passwort', + border: OutlineInputBorder(), + ), + ), + ), + Padding( + padding: const EdgeInsets.all(8), + child: TextField( + controller: _ctrNewPassword, + keyboardType: TextInputType.visiblePassword, + obscureText: true, + decoration: const InputDecoration( + prefixIcon: Icon(Icons.lock), + labelText: 'New Password', + hintText: 'Your new password', + helperText: 'Password have to be at least six characters long', + border: OutlineInputBorder(), + ), + ), + ), + Padding( + padding: const EdgeInsets.all(8), + child: TextField( + controller: _ctrNewPasswordRepeat, + keyboardType: TextInputType.visiblePassword, + obscureText: true, + decoration: const InputDecoration( + prefixIcon: Icon(Icons.lock), + labelText: 'Repeat new Password', + hintText: 'Type your new password again', + helperText: + 'Type your new password again, to make sure you know it', + border: OutlineInputBorder(), + ), + ), + ), + ], + )), + actions: [ + TextButton( + onPressed: () { + // close popup + Navigator.of(context).pop(); + }, + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () async { + final scaffMgr = ScaffoldMessenger.of(context); + final nav = Navigator.of(context); + + // validate password + if (_ctrNewPassword.text.length < 6) { + // password has to be at least 6 characters long + showSimpleSnackbar(scaffMgr, + text: 'Password has to be at least 6 characters longs', + action: 'Dismiss'); + + _ctrNewPasswordRepeat.clear(); + return; + } + if (_ctrNewPassword.text != _ctrNewPasswordRepeat.text) { + // new passwords do not match + showSimpleSnackbar(scaffMgr, + text: 'New passwords do not match', action: 'Dismiss'); + + _ctrNewPasswordRepeat.clear(); + return; + } + if (hashPassword(_ctrOldPassword.text) != user?.password) { + // current password wrong + showSimpleSnackbar(scaffMgr, + text: 'Old password is wrong', action: 'Dismiss'); + + _ctrOldPassword.clear(); + return; + } + + final password = hashPassword(_ctrNewPassword.text); + + // send request + doNetworkRequest(scaffMgr, + needUser: false, + req: (_) => postWithCreadentials( + path: 'changePassword', + target: (user?.server)!, + body: {'accountKey': password}, + credentials: user!), + onOK: (_) async { + // update local user struct + final updatedUser = User( + username: (user?.username)!, + password: password, + server: (user?.server)!); + await updatedUser.toDisk(); + }, + after: () { + // close popup + nav.pop(); + }); + }, + child: const Text('Change password'), + ) + ], + ); + } +} diff --git a/lib/screens/settings/main.dart b/lib/screens/settings/main.dart new file mode 100644 index 0000000..c069085 --- /dev/null +++ b/lib/screens/settings/main.dart @@ -0,0 +1,267 @@ +import 'package:flutter/material.dart'; +import 'package:outbag_app/backend/request.dart'; +import 'package:outbag_app/backend/themes.dart'; +import 'package:outbag_app/backend/user.dart'; +import 'package:outbag_app/screens/settings/dialogs/password.dart'; +import 'package:outbag_app/tools/fetch_wrapper.dart'; +import 'package:provider/provider.dart'; +import 'package:routemaster/routemaster.dart'; + +class SettingsPage extends StatefulWidget { + const SettingsPage({super.key}); + + @override + State createState() => _SettingsPageState(); +} + +class _SettingsPageState extends State { + User? user; + AccountMeta? meta; + + void fetchMeta() { + doNetworkRequest(ScaffoldMessenger.of(context), req: (user) { + setState(() { + this.user = user; + }); + + return postWithCreadentials( + path: 'getMyAccount', + credentials: user!, + target: user.server, + body: {}); + }, onOK: (body) { + final meta = AccountMeta.fromJSON(body['data']); + setState(() { + this.meta = meta; + }); + }); + } + + @override + void initState() { + super.initState(); + + WidgetsBinding.instance.addPostFrameCallback((_) => fetchMeta()); + } + + @override + Widget build(BuildContext context) { + final textTheme = Theme.of(context) + .textTheme + .apply(displayColor: Theme.of(context).colorScheme.onSurface); + + return Scaffold( + appBar: AppBar( + title: const Text('Settings'), + leading: IconButton( + onPressed: () { + // go back + Navigator.of(context).pop(); + }, + icon: const Icon(Icons.arrow_back), + tooltip: "Go back", + ), + ), + body: SingleChildScrollView( + child: Center( + child: Column(children: [ + // uswer information widget + Padding( + padding: const EdgeInsets.all(14), + child: Card( + child: Padding( + padding: const EdgeInsets.all(8), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.all(8), + child: Text('${user?.humanReadable}', + style: textTheme.titleLarge)), + ListTile( + title: const Text('Room count limit:'), + subtitle: const Text( + 'How many rooms you are allowed to own'), + trailing: Text('${meta?.maxRoomCount ?? ""}'), + ), + ListTile( + title: const Text('Room size limit:'), + subtitle: const Text( + 'How many items/products/categories each room may contain'), + trailing: Text('${meta?.maxRoomSize ?? ""}'), + ), + ListTile( + title: const Text('Room member limit:'), + subtitle: const Text( + 'How many members each of your rooms may have'), + trailing: + Text('${meta?.maxRoomMemberCount ?? ""}')), + ListTile( + title: const Text('Discoverable'), + subtitle: const Text( + 'Determines if your account can be discovered by users from other servers'), + trailing: Checkbox( + tristate: true, + value: meta?.discoverable, + onChanged: (_) {}, + )) + ], + )))), + + // change theme button + ListTile( + title: const Text('Change Theme'), + subtitle: const Text( + 'You can change between a light theme, a dark theme and automatic theme selection'), + // NOTE: have the trailing item be a value select widget + trailing: SegmentedButton( + selected: {context.watch()}, + selectedIcon: Icon(context.watch().icon), + showSelectedIcon: true, + multiSelectionEnabled: false, + emptySelectionAllowed: false, + segments: AppTheme.list().map((item) { + return ButtonSegment( + value: item, icon: Icon(item.icon), label: Text(item.name)); + }).toList(), + onSelectionChanged: (item) async { + try { + await item.first.toDisk(); + } catch(_) {} + }, + ), + ), + + // change password button + ListTile( + title: const Text('Change password'), + subtitle: const Text('Choose a new password for your account'), + onTap: () { + // TODO: show confirm dialog + // NOTE: needs an input field for the current password + // NOTE: might want to show a message explaining, + // that there is no way to reset the password + showDialog( + context: context, + builder: (context) => const ChangePasswordDialog()); + }, + trailing: const Icon(Icons.chevron_right), + ), + + // export account to json + ListTile( + title: const Text('Export account'), + subtitle: const Text('Export account data'), + onTap: () { + // TODO: show confirm dialog + // NOTE: json dump the localstore + // including users and rooms + // NOTE: feature not confirmed + }, + trailing: const Icon(Icons.chevron_right), + ), + + // delete account button + ListTile( + title: const Text('Delete account'), + subtitle: const Text('Delete your account from your homeserver'), + onTap: () { + // show confirm dialog + // NOTE: same as logout + // and performs a network request + // but deletes account beforehand + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Delete account'), + content: const Text( + 'Do you really want to delete your account?'), + actions: [ + TextButton( + onPressed: () { + // close popup + Navigator.of(ctx).pop(); + }, + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () async { + // send request + final scaffMgr = ScaffoldMessenger.of(ctx); + final nav = Navigator.of(ctx); + final rmaster = Routemaster.of(ctx); + + doNetworkRequest(scaffMgr, + req: (user) => postWithCreadentials( + path: 'deleteAccount', + target: (user?.server)!, + body: {}, + credentials: user!), + onOK: (_) async { + // delete everything + // delete user data (meta) + try { + await User.removeDisk(); + } catch (_) {} + // TODO: delete all rooms + + // go back home + rmaster.replace('/'); + }, + after: () { + // close popup + nav.pop(); + }); + }, + child: const Text('Delete Account'), + ) + ], + )); + }, + trailing: const Icon(Icons.chevron_right), + ), + + // logout button + Padding( + padding: const EdgeInsets.all(8), + child: FilledButton.tonal( + child: const Text('Log out'), + onPressed: () { + // show confirm dialog + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Log out'), + content: + const Text('Do you really want to log out?'), + actions: [ + TextButton( + onPressed: () { + // close popup + Navigator.of(ctx).pop(); + }, + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () async { + // send request + final rmaster = Routemaster.of(ctx); + + // delete everything + // delete user data (meta) + try { + await User.removeDisk(); + } catch (_) {} + // TODO: delete all rooms + + // go back home + rmaster.replace('/'); + }, + child: const Text('Log out'), + ) + ], + )); + }, + )) + ])))); + } +}