From aecc170389fd4f046b2f0cbe87ba0fdb5c15799d Mon Sep 17 00:00:00 2001 From: Emil Rossing Date: Fri, 25 Sep 2026 17:13:01 +0200 Subject: [PATCH] fix(formulus): improve UX for creating and switching profiles --- formulus/src/components/MenuDrawer.test.tsx | 3 +- formulus/src/components/MenuDrawer.tsx | 12 +- formulus/src/locales/en.json | 1 + formulus/src/locales/fr.json | 1 + formulus/src/locales/pt.json | 1 + formulus/src/screens/ProfileConnection.tsx | 130 +++++++-- formulus/src/screens/ProfilesScreen.tsx | 252 ++++++------------ .../screens/__tests__/ProfilesScreen.test.tsx | 99 +++++-- 8 files changed, 285 insertions(+), 214 deletions(-) diff --git a/formulus/src/components/MenuDrawer.test.tsx b/formulus/src/components/MenuDrawer.test.tsx index edfb5b5f6..04a21e8c3 100644 --- a/formulus/src/components/MenuDrawer.test.tsx +++ b/formulus/src/components/MenuDrawer.test.tsx @@ -106,7 +106,8 @@ test.each([ expect(name).toHaveProp('ellipsizeMode', 'tail'); expect(StyleSheet.flatten(name.props.style)).toMatchObject({ color, - alignSelf: 'stretch', + flex: 1, + marginBottom: 0, }); expect(screen.getByText('roles.readWrite')).toBeTruthy(); }); diff --git a/formulus/src/components/MenuDrawer.tsx b/formulus/src/components/MenuDrawer.tsx index 0262a4089..1882fdda2 100644 --- a/formulus/src/components/MenuDrawer.tsx +++ b/formulus/src/components/MenuDrawer.tsx @@ -118,7 +118,7 @@ export const MenuDrawerSignedIn = ({ userInfo }: { userInfo: UserInfo }) => { style={[styles.userAvatar, { backgroundColor: themeColors.primary }]}> - + { style={[ styles.roleBadge, getRoleBadgeStyle(userInfo.role), + styles.signedInRoleBadge, userInfo.role === 'admin' && { backgroundColor: themeColors.primary as string, }, @@ -470,8 +471,14 @@ const styles = StyleSheet.create({ justifyContent: 'center', alignItems: 'flex-end', }, + signedInUserInfo: { + flexDirection: 'row', + alignItems: 'center', + gap: odeSpacing.sm, + }, loggedInUserName: { - alignSelf: 'stretch', + flex: 1, + marginBottom: 0, }, userName: { fontSize: odeTypography.body, @@ -486,6 +493,7 @@ const styles = StyleSheet.create({ loginHint: { fontSize: odeTypography.caption, }, + signedInRoleBadge: { alignSelf: 'center' }, roleBadge: { alignSelf: 'flex-end', paddingHorizontal: odeSpacing.xs, diff --git a/formulus/src/locales/en.json b/formulus/src/locales/en.json index bc2e56818..5bdb05be9 100644 --- a/formulus/src/locales/en.json +++ b/formulus/src/locales/en.json @@ -27,6 +27,7 @@ "profiles.deleteLabel": "Delete profile {{label}}", "profiles.name": "Profile name", "profiles.saveName": "Save name", + "profiles.saveChanges": "Save changes", "profiles.lastProfileHint": "Keep at least one profile. The last profile cannot be deleted.", "profiles.deleteTitle": "Delete profile?", "profiles.deleteMessage": "Delete “{{label}}”? Unsynced data will be lost. Profile data is scheduled for deletion the next time you fully close and reopen Formulus. For more complete removal of local data, uninstall Formulus (this removes all profiles; OS backups and saved credentials may persist). Server data is not deleted. If this profile is active, another will be selected in the app.", diff --git a/formulus/src/locales/fr.json b/formulus/src/locales/fr.json index 45dd97283..918ade128 100644 --- a/formulus/src/locales/fr.json +++ b/formulus/src/locales/fr.json @@ -27,6 +27,7 @@ "profiles.deleteLabel": "Supprimer le profil {{label}}", "profiles.name": "Nom du profil", "profiles.saveName": "Enregistrer le nom", + "profiles.saveChanges": "Enregistrer les modifications", "profiles.lastProfileHint": "Conservez au moins un profil. Le dernier profil ne peut pas être supprimé.", "profiles.deleteTitle": "Supprimer le profil ?", "profiles.deleteMessage": "Supprimer « {{label}} » ? Les données non synchronisées seront perdues. La suppression des données du profil est prévue après avoir complètement fermé puis rouvert Formulus. Pour un retrait plus complet des données locales, désinstallez Formulus (cela supprime tous les profils ; des sauvegardes ou identifiants du système peuvent subsister). Les données du serveur ne seront pas supprimées. Si ce profil est actif, un autre sera sélectionné dans l’application.", diff --git a/formulus/src/locales/pt.json b/formulus/src/locales/pt.json index 2c59c5b10..6bb1130fb 100644 --- a/formulus/src/locales/pt.json +++ b/formulus/src/locales/pt.json @@ -27,6 +27,7 @@ "profiles.deleteLabel": "Eliminar o perfil {{label}}", "profiles.name": "Nome do perfil", "profiles.saveName": "Guardar nome", + "profiles.saveChanges": "Guardar alterações", "profiles.lastProfileHint": "Mantenha pelo menos um perfil. O último perfil não pode ser eliminado.", "profiles.deleteTitle": "Eliminar perfil?", "profiles.deleteMessage": "Eliminar «{{label}}»? Os dados não sincronizados serão perdidos. A eliminação dos dados do perfil está agendada para depois de fechar completamente e reabrir o Formulus. Para remover os dados locais de forma mais completa, desinstale o Formulus (isto remove todos os perfis; cópias de segurança ou credenciais do sistema podem persistir). Os dados do servidor não serão eliminados. Se este perfil estiver ativo, outro será selecionado na aplicação.", diff --git a/formulus/src/screens/ProfileConnection.tsx b/formulus/src/screens/ProfileConnection.tsx index 138c65333..6ab41d304 100644 --- a/formulus/src/screens/ProfileConnection.tsx +++ b/formulus/src/screens/ProfileConnection.tsx @@ -13,6 +13,10 @@ import { withAlpha } from '../theme/colors'; import { useConfirmModal } from '../contexts/ConfirmModalContext'; import type { MainTabParamList } from '../navigation/ProfileNavigationTypes'; import { profileRegistry } from '../profiles/ProfileRegistry'; +import { + resetProfileCredentials, + setCredentialsForProfile, +} from '../profiles/ProfileKeychain'; import { getActiveProfile } from '../profiles/ProfileRuntime'; import { login, @@ -29,6 +33,7 @@ import { } from '../services/QRSettingsService'; import { getSettingsHydrationCredentialPair, + invalidateSettingsHydrationCache, loadSettingsHydrationFromStorage, } from '../services/SettingsHydrationCache'; import { ToastService } from '../services/ToastService'; @@ -50,6 +55,9 @@ type Props = { }) => Promise; }; onNewProfileFromQR?: (settings: SettingsUpdate) => void; + onCancelCreation?: () => void; + onDelete?: () => void; + canDelete?: boolean; }; const ProfileConnection = ({ @@ -58,6 +66,9 @@ const ProfileConnection = ({ runAction, creation, onNewProfileFromQR, + onCancelCreation, + onDelete, + canDelete = false, }: Props) => { const { t } = useTranslation(); const { themeColors } = useAppTheme(); @@ -68,6 +79,7 @@ const ProfileConnection = ({ const initialServerUrl = creation?.initialSettings?.serverUrl; const initialUsername = creation?.initialSettings?.username; const initialPassword = creation?.initialSettings?.password; + const [name, setName] = useState(profile.label); const [serverUrl, setServerUrl] = useState( isCreating ? initialServerUrl || '' : profile.serverUrl || '', ); @@ -97,6 +109,7 @@ const ProfileConnection = ({ }; } setHydrating(true); + setName(profile.label); setPassword(''); const active = getActiveProfile(); setServerUrl(active.serverUrl || ''); @@ -124,6 +137,7 @@ const ProfileConnection = ({ }; }, [ profile.id, + profile.label, isCreating, initialServerUrl, initialUsername, @@ -195,6 +209,41 @@ const ProfileConnection = ({ }, 'profiles.connectionFailed'); }; + const handleSave = () => { + if (hydrating || !name.trim()) return; + void runAction(async () => { + assertCurrent(); + if (password && (!serverUrl.trim() || !username.trim())) + throw new ProfileUIError('profiles.credentialsIncomplete'); + const normalized = serverUrl.trim() ? normalizedUrl(serverUrl) : null; + if (normalized) warnHttp(normalized.isHttp); + const current = getActiveProfile(); + if ( + !password && + (current.username !== username.trim() || + current.serverUrl !== (normalized?.href || '')) + ) { + await resetProfileCredentials(); + } + await saveConnection(normalized?.href || '', username.trim()); + if (password) { + const saved = await setCredentialsForProfile( + profile.id, + username.trim(), + password, + ); + if (saved === false) + throw new ProfileUIError('profiles.qrCredentialsFailed'); + } + invalidateSettingsHydrationCache(); + assertCurrent(); + if (name.trim() !== profile.label) { + await profileRegistry.rename(profile.id, name.trim()); + } + ToastService.showShort(t('profiles.connectionSaved')); + }, 'profiles.connectionFailed'); + }; + const handleCreate = () => { if (!creation?.name.trim() || busy) return; void runAction(async () => { @@ -276,7 +325,7 @@ const ProfileConnection = ({ const disabled = busy || hydrating; return ( - + {!creation && ( <> @@ -289,6 +338,17 @@ const ProfileConnection = ({ )} )} + {!creation && ( + + )} {creation ? ( -