Started working on settings screen

Added:
- changePassword to change the password
NOTE: this requires the old password,
just to prevent account hijacking.
- some basic user limit information
- theme selector
NOTE: the system theme is meant to function like auto-theme,
and is directly translated into a flutter ThemeMode,
however, this does not appear to be working on the web.

This commit also adds the logout and delete account buttons,
but they do not yet delete all rooms,
nor do they properly logout the user.
BUG: User is not logged out correctly,
reloading the page fixes this.
Maybe localstore.listen does not detect deletion?
This commit is contained in:
Jakob Meier 2023-03-25 14:29:28 +01:00
parent 569dda01fd
commit 30a19fcc1e
No known key found for this signature in database
GPG key ID: 66BDC7E6A01A6152
5 changed files with 557 additions and 7 deletions

84
lib/backend/themes.dart Normal file
View file

@ -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<AppTheme> list() {
return [AppTheme.auto, AppTheme.light, AppTheme.dark];
}
static listen(Function(Map<String, dynamic>) cb) {
final db = Localstore.instance;
final stream = db.collection('settings').stream;
stream.listen(cb);
}
Future<void> toDisk() async {
final db = Localstore.instance;
await db.collection('settings').doc('ui').set({'theme': mode.index});
}
static Future<AppTheme> 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;
}

View file

@ -41,12 +41,16 @@ class User {
final stream = db.collection('meta').stream; final stream = db.collection('meta').stream;
stream.listen(cb); stream.listen(cb);
} }
String get humanReadable {
return '$username@${server.tag}';
}
} }
class AccountMeta { class AccountMeta {
final int permissions; final int permissions;
final String username; final String username;
final bool discvoverable; final bool discoverable;
final int maxRoomCount; final int maxRoomCount;
final int maxRoomSize; final int maxRoomSize;
final int maxRoomMemberCount; final int maxRoomMemberCount;
@ -57,7 +61,7 @@ class AccountMeta {
required this.maxRoomSize, required this.maxRoomSize,
required this.maxRoomCount, required this.maxRoomCount,
required this.maxRoomMemberCount, required this.maxRoomMemberCount,
required this.discvoverable}); required this.discoverable});
factory AccountMeta.fromJSON(dynamic json) { factory AccountMeta.fromJSON(dynamic json) {
return AccountMeta( return AccountMeta(
@ -66,6 +70,6 @@ class AccountMeta {
maxRoomSize: json['maxRoomSize'], maxRoomSize: json['maxRoomSize'],
maxRoomCount: json['maxRooms'], maxRoomCount: json['maxRooms'],
maxRoomMemberCount: json['maxUsersPerRoom'], maxRoomMemberCount: json['maxUsersPerRoom'],
discvoverable: json['viewable'] == 1); discoverable: json['viewable'] == 1);
} }
} }

View file

@ -1,10 +1,12 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:outbag_app/backend/themes.dart';
import 'package:outbag_app/backend/user.dart'; import 'package:outbag_app/backend/user.dart';
import 'package:outbag_app/screens/room/edit.dart'; import 'package:outbag_app/screens/room/edit.dart';
import 'package:outbag_app/screens/room/join.dart'; import 'package:outbag_app/screens/room/join.dart';
import 'package:outbag_app/screens/room/members.dart'; import 'package:outbag_app/screens/room/members.dart';
import 'package:outbag_app/screens/room/permissions.dart'; import 'package:outbag_app/screens/room/permissions.dart';
import 'package:outbag_app/screens/room/new.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:outbag_app/tools/fetch_wrapper.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import './screens/home.dart'; import './screens/home.dart';
@ -26,6 +28,7 @@ final routesUnauthorized = RouteMap(routes: {
// routes when user is logged in // routes when user is logged in
final routesLoggedIn = RouteMap(routes: { final routesLoggedIn = RouteMap(routes: {
'/': (_) => const MaterialPage(child: HomePage()), '/': (_) => const MaterialPage(child: HomePage()),
'/settings': (_) => const MaterialPage(child: SettingsPage()),
'/add-room/new': (_) => const MaterialPage(child: NewRoomPage()), '/add-room/new': (_) => const MaterialPage(child: NewRoomPage()),
'/add-room': (_) => const MaterialPage(child: JoinRoomPage()), '/add-room': (_) => const MaterialPage(child: JoinRoomPage()),
'/r/:server/:tag/': (info) { '/r/:server/:tag/': (info) {
@ -73,12 +76,14 @@ class _OutbagAppState extends State {
bool isAuthorized = true; bool isAuthorized = true;
AccountMeta? info; AccountMeta? info;
AppTheme theme = AppTheme.auto;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
// wait for user to be authorized // wait for user to be authorized
User.listen((data) async { User.listen((_) async {
try { try {
await User.fromDisk(); await User.fromDisk();
setState(() { setState(() {
@ -87,6 +92,20 @@ class _OutbagAppState extends State {
} catch (_) {} } 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()); WidgetsBinding.instance.addPostFrameCallback((_) => fetchInfo());
} }
@ -145,12 +164,11 @@ class _OutbagAppState extends State {
Provider<AccountMeta?>.value( Provider<AccountMeta?>.value(
value: info, value: info,
), ),
Provider<AppTheme>.value(value: theme)
], ],
child: MaterialApp.router( child: MaterialApp.router(
title: "Outbag", title: "Outbag",
// TODO: change back to system (or load from disk) themeMode: theme.mode,
//themeMode: ThemeMode.system,
themeMode: ThemeMode.dark,
theme: ThemeData(useMaterial3: true, brightness: Brightness.light), theme: ThemeData(useMaterial3: true, brightness: Brightness.light),
darkTheme: ThemeData(useMaterial3: true, brightness: Brightness.dark), darkTheme: ThemeData(useMaterial3: true, brightness: Brightness.dark),
routerDelegate: RoutemasterDelegate( routerDelegate: RoutemasterDelegate(

View file

@ -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<StatefulWidget> createState() => _ChangePasswordDialogState();
}
class _ChangePasswordDialogState extends State<ChangePasswordDialog> {
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'),
)
],
);
}
}

View file

@ -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<StatefulWidget> createState() => _SettingsPageState();
}
class _SettingsPageState extends State<SettingsPage> {
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<AppTheme>(
selected: {context.watch<AppTheme>()},
selectedIcon: Icon(context.watch<AppTheme>().icon),
showSelectedIcon: true,
multiSelectionEnabled: false,
emptySelectionAllowed: false,
segments: AppTheme.list().map((item) {
return ButtonSegment<AppTheme>(
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'),
)
],
));
},
))
]))));
}
}