From 546236ca8e8d9f92e1a5156c62673368c9a18f3a Mon Sep 17 00:00:00 2001 From: boxdot Date: Thu, 30 Jan 2025 16:33:48 +0100 Subject: [PATCH] change user screen --- app/lib/core/core_client.dart | 6 +- app/lib/developer/change_user_screen.dart | 135 ++++++++++++++++++ app/lib/developer/developer.dart | 1 + .../developer/developer_settings_screen.dart | 28 +++- app/lib/intro_screen.dart | 91 +----------- app/lib/navigation/app_router.dart | 24 +++- app/lib/navigation/navigation_cubit.dart | 26 +++- app/lib/registration/registration_cubit.dart | 2 +- app/lib/theme/theme_data.dart | 18 ++- app/lib/user/loadable_user_cubit.dart | 1 + applogic/src/api/types.rs | 1 + applogic/src/api/user.rs | 35 ++--- coreclient/src/clients/mod.rs | 8 +- coreclient/src/clients/persistence.rs | 87 ++++++++++- coreclient/src/clients/store.rs | 4 +- 15 files changed, 334 insertions(+), 133 deletions(-) create mode 100644 app/lib/developer/change_user_screen.dart diff --git a/app/lib/core/core_client.dart b/app/lib/core/core_client.dart index 3bc3d34e..99d8f724 100644 --- a/app/lib/core/core_client.dart +++ b/app/lib/core/core_client.dart @@ -77,7 +77,11 @@ class CoreClient { // used in app initialization Future loadDefaultUser() async { - user = await User.loadDefault(path: await dbPath()); + user = await User.loadDefault(path: await dbPath()) + .onError((error, stackTrace) { + _log.severe("Error loading default user $error"); + return null; + }); } // used in registration cubit diff --git a/app/lib/developer/change_user_screen.dart b/app/lib/developer/change_user_screen.dart new file mode 100644 index 00000000..4c4c9cd8 --- /dev/null +++ b/app/lib/developer/change_user_screen.dart @@ -0,0 +1,135 @@ +// SPDX-FileCopyrightText: 2025 Phoenix R&D GmbH +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +import 'package:flutter/material.dart'; +import 'package:prototype/core/core.dart'; +import 'package:prototype/theme/theme.dart'; +import 'package:prototype/user/user.dart'; +import 'package:prototype/widgets/widgets.dart'; +import 'package:provider/provider.dart'; + +class ChangeUserScreen extends StatelessWidget { + const ChangeUserScreen({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Change User'), + toolbarHeight: isPointer() ? 100 : null, + leading: const AppBarBackButton(), + ), + body: Center( + child: Container( + padding: const EdgeInsets.all(Spacings.xs), + constraints: isPointer() ? const BoxConstraints(maxWidth: 800) : null, + child: const _ClientRecords(), + ), + ), + ); + } +} + +class _ClientRecords extends StatefulWidget { + const _ClientRecords(); + + @override + State<_ClientRecords> createState() => _ClientRecordsState(); +} + +class _ClientRecordsState extends State<_ClientRecords> { + Future>? _clientRecords; + + @override + void initState() { + super.initState(); + loadClientRecords(); + } + + void loadClientRecords() async { + final clientRecords = User.loadClientRecords(dbPath: await dbPath()); + setState(() { + _clientRecords = clientRecords; + }); + } + + @override + Widget build(BuildContext context) { + return FutureBuilder>( + future: _clientRecords, + builder: (context, snapshot) { + if (snapshot.hasData) { + return _ClientRecordsList(snapshot.data!); + } else if (snapshot.hasError) { + return Text( + 'Error loading contacts', + ); + } + return const CircularProgressIndicator(); + }, + ); + } +} + +class _ClientRecordsList extends StatelessWidget { + const _ClientRecordsList(this.clientRecords); + + final List clientRecords; + + @override + Widget build(BuildContext context) { + final user = context.select((LoadableUserCubit cubit) => cubit.state.user); + + return Center( + child: ListView( + children: clientRecords.map((record) { + final isCurrentUser = user?.userName == + "${record.userName.userName}@${record.userName.domain}"; + final currentUserSuffix = isCurrentUser ? " (current)" : ""; + + final textColor = isCurrentUser + ? Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.38) + : null; + + return ListTile( + titleAlignment: ListTileTitleAlignment.top, + titleTextStyle: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith(color: textColor), + subtitleTextStyle: Theme.of(context) + .textTheme + .bodySmall + ?.copyWith(color: textColor), + leading: Transform.translate( + offset: Offset(0, Spacings.xxs), + child: UserAvatar( + username: record.userName.userName, + image: record.userProfile?.profilePicture, + size: Spacings.xl, + ), + ), + title: Text( + record.userName.displayName(record.userProfile?.displayName) + + currentUserSuffix, + ), + subtitle: Text( + "Domain: ${record.userName.domain}\nID: ${record.clientId}\nCreated: ${record.createdAt}", + ), + onTap: !isCurrentUser + ? () { + final coreClient = context.read(); + coreClient.logout(); + coreClient.loadUser( + userName: record.userName, + clientId: record.clientId, + ); + } + : null, + ); + }).toList(), + ), + ); + } +} diff --git a/app/lib/developer/developer.dart b/app/lib/developer/developer.dart index 7618b552..86f98f1d 100644 --- a/app/lib/developer/developer.dart +++ b/app/lib/developer/developer.dart @@ -6,3 +6,4 @@ library; export 'developer_settings_screen.dart'; +export 'change_user_screen.dart'; diff --git a/app/lib/developer/developer_settings_screen.dart b/app/lib/developer/developer_settings_screen.dart index e1a6abe5..7095e8f1 100644 --- a/app/lib/developer/developer_settings_screen.dart +++ b/app/lib/developer/developer_settings_screen.dart @@ -75,11 +75,11 @@ class _DeveloperSettingsScreenState extends State { child: Container( padding: const EdgeInsets.all(Spacings.xs), constraints: - isPointer() ? const BoxConstraints(maxWidth: 600) : null, + isPointer() ? const BoxConstraints(maxWidth: 800) : null, child: ListView( children: [ - if (isMobile) _SectionHeader("Mobile Device"), - if (isMobile) + if (isMobile) ...[ + _SectionHeader("Mobile Device"), ListTile( title: const Text('Push Token'), subtitle: Text( @@ -88,15 +88,29 @@ class _DeveloperSettingsScreenState extends State { onTap: () => _reRegisterPushToken(context.read()), ), - if (user != null) _SectionHeader("User"), - if (user != null) + const Divider(), + ], + if (user != null) ...[ + _SectionHeader("User"), + ListTile( + title: Text("Change User"), + subtitle: Text( + "Change the currently logged in user.", + ), + onTap: () => context + .read() + .openDeveloperSettings( + screen: DeveloperSettingsScreenType.changeUser), + ), ListTile( - title: Text("Logout"), + title: Text("Log Out"), subtitle: Text( - "Logout the currently logged in user '${user.userName}, id: ${user.clientId}'.", + "Log out of the currently logged in user.", ), onTap: () => context.read().logout(), ), + const Divider(), + ], _SectionHeader("App Data"), if (user != null) ListTile( diff --git a/app/lib/intro_screen.dart b/app/lib/intro_screen.dart index 6b101857..bf32dcd7 100644 --- a/app/lib/intro_screen.dart +++ b/app/lib/intro_screen.dart @@ -16,8 +16,10 @@ class IntroScreen extends StatelessWidget { @override Widget build(BuildContext context) { - final isUserLoading = - context.select((LoadableUserCubit cubit) => cubit.state is LoadingUser); + final isUserLoading = context.select((LoadableUserCubit cubit) { + debugPrint("isUserLoading: ${cubit.state}"); + return cubit.state is LoadingUser; + }); return Scaffold( body: Center( @@ -50,7 +52,6 @@ class IntroScreen extends StatelessWidget { letterSpacing: -0.9, ), ), - const _ClientRecords(), // Text button that opens the developer settings screen TextButton( onPressed: () => @@ -80,90 +81,6 @@ class IntroScreen extends StatelessWidget { } } -class _ClientRecords extends StatefulWidget { - const _ClientRecords(); - - @override - State<_ClientRecords> createState() => _ClientRecordsState(); -} - -class _ClientRecordsState extends State<_ClientRecords> { - Future>? _clientRecords; - - @override - void initState() { - super.initState(); - loadClientRecords(); - } - - void loadClientRecords() async { - final clientRecords = User.loadClientRecords(dbPath: await dbPath()); - setState(() { - _clientRecords = clientRecords; - }); - } - - @override - Widget build(BuildContext context) { - return FutureBuilder>( - future: _clientRecords, - builder: (context, snapshot) { - if (snapshot.hasData) { - return _ClientRecordsList(snapshot.data!); - } else if (snapshot.hasError) { - return Text( - 'Error loading contacts', - ); - } - return const CircularProgressIndicator(); - }, - ); - } -} - -class _ClientRecordsList extends StatelessWidget { - const _ClientRecordsList(this.clientRecords); - - final List clientRecords; - - @override - Widget build(BuildContext context) { - const itemExtent = 72.0; - - return Center( - child: ConstrainedBox( - constraints: BoxConstraints( - maxHeight: 3.5 * itemExtent, // 3 items without scrolling - maxWidth: MediaQuery.of(context).size.width.clamp(0, 400), - ), - child: ListView( - itemExtent: itemExtent, - children: clientRecords - .map( - (record) => ListTile( - leading: UserAvatar( - username: record.userName.userName, - image: record.userProfile?.profilePicture, - size: Spacings.xl, - ), - title: Text( - record.userName - .displayName(record.userProfile?.displayName), - ), - subtitle: Text( - "@${record.userName.domain}", - ), - onTap: () => context.read().loadUser( - userName: record.userName, clientId: record.clientId), - ), - ) - .toList(), - ), - ), - ); - } -} - class _GradientText extends StatelessWidget { const _GradientText( this.text, { diff --git a/app/lib/navigation/app_router.dart b/app/lib/navigation/app_router.dart index 6bf01529..731feebb 100644 --- a/app/lib/navigation/app_router.dart +++ b/app/lib/navigation/app_router.dart @@ -189,11 +189,25 @@ extension on HomeNavigation { key: ValueKey("add-members-screen"), child: AddMembersScreen(), ), - if (developerSettingsOpen) - const MaterialPage( - key: ValueKey("developer-settings-screen"), - child: DeveloperSettingsScreen(), - ), + ...switch (developerSettingsScreen) { + null => [], + DeveloperSettingsScreenType.root => [ + const MaterialPage( + key: ValueKey("developer-settings-screen"), + child: DeveloperSettingsScreen(), + ), + ], + DeveloperSettingsScreenType.changeUser => [ + const MaterialPage( + key: ValueKey("developer-settings-screen-root"), + child: DeveloperSettingsScreen(), + ), + const MaterialPage( + key: ValueKey("developer-settings-screen-change-user"), + child: ChangeUserScreen(), + ), + ] + }, ]; } } diff --git a/app/lib/navigation/navigation_cubit.dart b/app/lib/navigation/navigation_cubit.dart index 681a1952..d66f1f14 100644 --- a/app/lib/navigation/navigation_cubit.dart +++ b/app/lib/navigation/navigation_cubit.dart @@ -28,7 +28,7 @@ sealed class NavigationState with _$NavigationState { /// screen is opened. const factory NavigationState.home({ ConversationId? conversationId, - @Default(false) bool developerSettingsOpen, + DeveloperSettingsScreenType? developerSettingsScreen, @Default(false) bool userSettingsOpen, @Default(false) bool conversationDetailsOpen, @Default(false) bool addMembersOpen, @@ -52,6 +52,8 @@ enum IntroScreenType { developerSettings, } +enum DeveloperSettingsScreenType { root, changeUser } + /// Provides the navigation state and navigation actions to the app /// /// This is main entry point for navigation. @@ -124,7 +126,9 @@ class NavigationCubit extends Cubit { throw NavigationError(state); } - void openDeveloperSettings() { + void openDeveloperSettings({ + DeveloperSettingsScreenType screen = DeveloperSettingsScreenType.root, + }) { switch (state) { case IntroNavigation intro: if (intro.screens.lastOrNull != IntroScreenType.developerSettings) { @@ -132,7 +136,7 @@ class NavigationCubit extends Cubit { emit(intro.copyWith(screens: stack)); } case HomeNavigation home: - emit(home.copyWith(developerSettingsOpen: true)); + emit(home.copyWith(developerSettingsScreen: screen)); } } @@ -179,9 +183,19 @@ class NavigationCubit extends Cubit { } return false; case HomeNavigation home: - if (home.developerSettingsOpen) { - emit(home.copyWith(developerSettingsOpen: false)); - return true; + if (home.developerSettingsScreen != null) { + switch (home.developerSettingsScreen) { + case null: + throw StateError("impossible state"); + case DeveloperSettingsScreenType.root: + emit(home.copyWith(developerSettingsScreen: null)); + return true; + case DeveloperSettingsScreenType.changeUser: + emit(home.copyWith( + developerSettingsScreen: DeveloperSettingsScreenType.root, + )); + return true; + } } else if (home.userSettingsOpen) { emit(home.copyWith(userSettingsOpen: false)); return true; diff --git a/app/lib/registration/registration_cubit.dart b/app/lib/registration/registration_cubit.dart index 0faa9516..22ee1097 100644 --- a/app/lib/registration/registration_cubit.dart +++ b/app/lib/registration/registration_cubit.dart @@ -86,7 +86,7 @@ class RegistrationCubit extends Cubit { emit(state.copyWith(isSigningUp: true)); final fqun = "${state.username}@${state.domain}"; - final url = "https://${state.domain}"; + final url = "http://${state.domain}"; try { _log.info("Registering user ${state.username} ..."); diff --git a/app/lib/theme/theme_data.dart b/app/lib/theme/theme_data.dart index cef8b54c..6305a55f 100644 --- a/app/lib/theme/theme_data.dart +++ b/app/lib/theme/theme_data.dart @@ -14,7 +14,23 @@ ThemeData themeData(BuildContext context) => ThemeData( titleTextStyle: boldLabelStyle.copyWith(color: Colors.black), ), fontFamily: fontFamily, - textTheme: const TextTheme(), + textTheme: TextTheme( + displayLarge: TextStyle(letterSpacing: -0.2), + displayMedium: TextStyle(letterSpacing: -0.2), + displaySmall: TextStyle(letterSpacing: -0.2), + headlineLarge: TextStyle(letterSpacing: -0.2), + headlineMedium: TextStyle(letterSpacing: -0.2), + headlineSmall: TextStyle(letterSpacing: -0.2), + titleLarge: TextStyle(letterSpacing: -0.2), + titleMedium: TextStyle(letterSpacing: -0.2), + titleSmall: TextStyle(letterSpacing: -0.2), + bodyLarge: TextStyle(letterSpacing: -0.2), + bodyMedium: TextStyle(letterSpacing: -0.2), + bodySmall: TextStyle(letterSpacing: -0.2), + labelLarge: TextStyle(letterSpacing: -0.2), + labelMedium: TextStyle(letterSpacing: -0.2), + labelSmall: TextStyle(letterSpacing: -0.2), + ), canvasColor: Colors.white, cardColor: Colors.white, colorScheme: ColorScheme.fromSwatch( diff --git a/app/lib/user/loadable_user_cubit.dart b/app/lib/user/loadable_user_cubit.dart index 6673468f..1c93d52e 100644 --- a/app/lib/user/loadable_user_cubit.dart +++ b/app/lib/user/loadable_user_cubit.dart @@ -4,6 +4,7 @@ import 'dart:async'; +import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:prototype/core/core.dart'; diff --git a/applogic/src/api/types.rs b/applogic/src/api/types.rs index 93a52dc2..c53fb5ed 100644 --- a/applogic/src/api/types.rs +++ b/applogic/src/api/types.rs @@ -458,6 +458,7 @@ pub struct UiClientRecord { /// Also used for identifying the client database path. pub(crate) client_id: Uuid, pub(crate) user_name: UiUserName, + pub(crate) created_at: DateTime, pub(crate) user_profile: Option, } diff --git a/applogic/src/api/user.rs b/applogic/src/api/user.rs index ae520302..6c13e481 100644 --- a/applogic/src/api/user.rs +++ b/applogic/src/api/user.rs @@ -118,6 +118,7 @@ impl User { .map(|profile| UiUserProfile::from_profile(&profile)); Some(UiClientRecord { client_id: record.as_client_id.client_id(), + created_at: record.created_at, user_name, user_profile, }) @@ -142,25 +143,25 @@ impl User { /// Loads the default user from the given database path /// - /// If ther no user or multiple users are found, returns `None`. + /// Retturns the most recent default user if any, or just the most recent user. Users that are + /// not finished registering are ignored. pub async fn load_default(path: String) -> Result> { - let records: Vec = ClientRecord::load_all_from_phnx_db(&path)? + let finished_records = ClientRecord::load_all_from_phnx_db(&path)? .into_iter() - .filter(|record| matches!(record.client_record_state, ClientRecordState::Finished)) - .collect(); - match records.as_slice() { - [] => Ok(None), - [client_record] => { - let as_client_id = client_record.as_client_id.clone(); - let user = CoreUser::load(as_client_id.clone(), &path) - .await? - .with_context(|| { - format!("Could not load user with client_id {as_client_id}") - })?; - Ok(Some(Self { user })) - } - _ => Ok(None), - } + .filter(|record| matches!(record.client_record_state, ClientRecordState::Finished)); + // Get the most recent default record or just the most recent record. + let Some(client_record) = + finished_records.max_by_key(|record| (record.is_default, record.created_at)) + else { + return Ok(None); + }; + dbg!(&client_record); + + let as_client_id = client_record.as_client_id.clone(); + let user = CoreUser::load(as_client_id.clone(), &path) + .await? + .with_context(|| format!("Could not load user with client_id {as_client_id}"))?; + Ok(Some(Self { user })) } /// Update the push token. diff --git a/coreclient/src/clients/mod.rs b/coreclient/src/clients/mod.rs index 5c861186..20dca267 100644 --- a/coreclient/src/clients/mod.rs +++ b/coreclient/src/clients/mod.rs @@ -249,13 +249,17 @@ impl CoreUser { let final_state = user_creation_state .complete_user_creation( - phnx_db_connection_mutex, + phnx_db_connection_mutex.clone(), client_db_connection_mutex.clone(), &api_clients, ) .await?; - let self_user = final_state.into_self_user(client_db_connection_mutex, api_clients); + let self_user = final_state.into_self_user(client_db_connection_mutex.clone(), api_clients); + ClientRecord::set_default( + &*phnx_db_connection_mutex.lock().await, + &self_user.as_client_id(), + )?; Ok(Some(self_user)) } diff --git a/coreclient/src/clients/persistence.rs b/coreclient/src/clients/persistence.rs index a8d23821..db35db9a 100644 --- a/coreclient/src/clients/persistence.rs +++ b/coreclient/src/clients/persistence.rs @@ -49,9 +49,10 @@ impl Storable for UserCreationState { CREATE TABLE IF NOT EXISTS user_creation_state ( client_id BLOB PRIMARY KEY, state BLOB NOT NULL, + created_at DATETIME NOT NULL );"; - fn from_row(row: &rusqlite::Row) -> anyhow::Result { + fn from_row(row: &rusqlite::Row) -> Result { row.get(0) } } @@ -72,8 +73,9 @@ impl UserCreationState { pub(super) fn store(&self, connection: &Connection) -> Result<(), rusqlite::Error> { connection.execute( - "INSERT OR REPLACE INTO user_creation_state (client_id, state) VALUES (?1, ?2)", - params![self.client_id(), self], + "INSERT OR REPLACE INTO user_creation_state + (client_id, state, created_at) VALUES (?1, ?2, ?3)", + params![self.client_id(), self, Utc::now()], )?; Ok(()) } @@ -84,7 +86,7 @@ impl Storable for ClientRecord { CREATE TABLE IF NOT EXISTS client_record ( client_id BLOB NOT NULL PRIMARY KEY, record_state TEXT NOT NULL CHECK (record_state IN ('in_progress', 'finished')), - created_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now', 'utc')), + created_at DATETIME NOT NULL, is_default BOOLEAN NOT NULL DEFAULT FALSE )"; @@ -156,8 +158,85 @@ impl ClientRecord { Ok(()) } + pub fn set_default(connection: &Connection, client_id: &AsClientId) -> rusqlite::Result<()> { + connection.execute( + "UPDATE client_record SET is_default = (client_id == ?)", + params![client_id], + )?; + Ok(()) + } + pub(crate) fn delete(connection: &Connection, client_id: &AsClientId) -> rusqlite::Result<()> { connection.execute("DELETE FROM client_record WHERE ?", params![client_id])?; Ok(()) } } + +#[cfg(test)] +mod tests { + use uuid::Uuid; + + use super::*; + + #[test] + fn client_records_persistence() { + let connection = rusqlite::Connection::open_in_memory().unwrap(); + connection + .execute(ClientRecord::CREATE_TABLE_STATEMENT, []) + .unwrap(); + + let alice_id = Uuid::new_v4(); + let alice_client_id = AsClientId::new("alice@localhost".parse().unwrap(), alice_id); + let mut alice_client_record = ClientRecord { + as_client_id: alice_client_id.clone(), + client_record_state: ClientRecordState::Finished, + created_at: Utc::now(), + is_default: false, + }; + + let bob_id = Uuid::new_v4(); + let bob_client_id = AsClientId::new("bob@localhost".parse().unwrap(), bob_id); + let mut bob_client_record = ClientRecord { + as_client_id: bob_client_id.clone(), + client_record_state: ClientRecordState::Finished, + created_at: Utc::now(), + is_default: false, + }; + + ClientRecord::create_table(&connection).unwrap(); + + // Storing and loading client records works + alice_client_record.store(&connection).unwrap(); + bob_client_record.store(&connection).unwrap(); + let records = ClientRecord::load_all(&connection).unwrap(); + assert_eq!( + records, + [alice_client_record.clone(), bob_client_record.clone()] + ); + + // Set default to alice set alice is_default + alice_client_record.is_default = true; + ClientRecord::set_default(&connection, &alice_client_id).unwrap(); + let records = ClientRecord::load_all(&connection).unwrap(); + assert_eq!( + records, + [alice_client_record.clone(), bob_client_record.clone()] + ); + + // Set default to bob clears alice is_default + alice_client_record.is_default = false; + bob_client_record.is_default = true; + ClientRecord::set_default(&connection, &bob_client_id).unwrap(); + let records = ClientRecord::load_all(&connection).unwrap(); + assert_eq!( + records, + [alice_client_record.clone(), bob_client_record.clone()] + ); + + // Delete client records + ClientRecord::delete(&connection, &alice_client_id).unwrap(); + ClientRecord::delete(&connection, &bob_client_id).unwrap(); + let records = ClientRecord::load_all(&connection).unwrap(); + assert_eq!(records, []); + } +} diff --git a/coreclient/src/clients/store.rs b/coreclient/src/clients/store.rs index 1daaf3dc..0b647884 100644 --- a/coreclient/src/clients/store.rs +++ b/coreclient/src/clients/store.rs @@ -168,13 +168,13 @@ impl UserCreationState { } } -#[derive(Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum ClientRecordState { InProgress, Finished, } -#[derive(Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ClientRecord { pub as_client_id: AsClientId, pub client_record_state: ClientRecordState,