From d8d46108bc65ceeb9e9cee9de41f86069cbb5a7d Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Mon, 21 Sep 2026 17:28:18 +0700 Subject: [PATCH] feat(profile): add local profiles with backup and restore Move Mornye Settings to the Home avatar and keep search above the profile card. Center the metadata artwork and actions, and make artist names clickable in the Material player. --- lib/l10n/arb/app_en.arb | 10 + lib/l10n/arb/app_id.arb | 12 +- lib/providers/user_profile_provider.dart | 39 +++ lib/screens/home_tab.dart | 4 + lib/screens/main_shell.dart | 41 ++- lib/screens/now_playing_screen.dart | 24 +- lib/screens/settings/backup_restore_page.dart | 15 + .../settings/profile_settings_page.dart | 183 +++++++++++ lib/screens/settings/settings_tab.dart | 51 ++- lib/screens/track_metadata_cards.dart | 7 +- lib/screens/track_metadata_mornye.dart | 83 +++-- lib/services/backup_service.dart | 51 +++ lib/services/shell_navigation_service.dart | 4 +- lib/services/user_profile_store.dart | 127 ++++++++ lib/widgets/profile_avatar.dart | 87 ++++++ test/backup_service_v2_test.dart | 30 ++ test/mornye_bottom_bar_test.dart | 20 +- test/shell_navigation_service_test.dart | 2 +- test/user_profile_test.dart | 293 ++++++++++++++++++ 19 files changed, 1003 insertions(+), 80 deletions(-) create mode 100644 lib/providers/user_profile_provider.dart create mode 100644 lib/screens/settings/profile_settings_page.dart create mode 100644 lib/services/user_profile_store.dart create mode 100644 lib/widgets/profile_avatar.dart create mode 100644 test/user_profile_test.dart diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 8c9fd5f0..a034dbf3 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -6638,6 +6638,16 @@ "autoMixDescription": "Blend songs with beat matching and pitch-preserving tempo adjustment. Uses crossfade when beats cannot be matched.", "autoMixOn": "AutoMix on", "autoMixOff": "AutoMix off", + "profileTitle": "Profile", + "profileSetUp": "Set up your profile", + "profileEdit": "Edit name and photo", + "backupIncludesProfile": "Includes your profile name and photo", + "profileName": "Name", + "profileNameHint": "Your name", + "profileChangePhoto": "Choose photo", + "profileRemovePhoto": "Remove photo", + "profilePhotoError": "Choose a valid image smaller than 20 MB.", + "profileSaveError": "Could not save your profile. Please try again.", "optionsEmbeddedCoverSizeOriginal": "Original resolution", "@optionsEmbeddedCoverSizeOriginal": { "description": "Option that preserves the provider artwork at its original resolution" diff --git a/lib/l10n/arb/app_id.arb b/lib/l10n/arb/app_id.arb index 2912cb1e..ac81deb1 100644 --- a/lib/l10n/arb/app_id.arb +++ b/lib/l10n/arb/app_id.arb @@ -6249,5 +6249,15 @@ "mornyeFavorited": "Difavoritkan", "autoMixDescription": "Sambungkan lagu dengan pencocokan beat dan penyesuaian tempo tanpa mengubah nada. Gunakan crossfade jika beat tidak cocok.", "autoMixOn": "AutoMix aktif", - "autoMixOff": "AutoMix nonaktif" + "autoMixOff": "AutoMix nonaktif", + "profileTitle": "Profil", + "profileSetUp": "Atur profil kamu", + "profileEdit": "Ubah nama dan foto", + "backupIncludesProfile": "Termasuk nama dan foto profil", + "profileName": "Nama", + "profileNameHint": "Nama kamu", + "profileChangePhoto": "Pilih foto", + "profileRemovePhoto": "Hapus foto", + "profilePhotoError": "Pilih gambar yang valid dengan ukuran kurang dari 20 MB.", + "profileSaveError": "Profil belum berhasil disimpan. Silakan coba lagi." } diff --git a/lib/providers/user_profile_provider.dart b/lib/providers/user_profile_provider.dart new file mode 100644 index 00000000..8ce8c27f --- /dev/null +++ b/lib/providers/user_profile_provider.dart @@ -0,0 +1,39 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotiflac_android/services/user_profile_store.dart'; + +final userProfileStoreProvider = Provider( + (ref) => UserProfileStore(), +); + +final userProfileProvider = + AsyncNotifierProvider( + UserProfileNotifier.new, + ); + +class UserProfileNotifier extends AsyncNotifier { + @override + Future build() => ref.watch(userProfileStoreProvider).read(); + + Future restoreFromBackup(UserProfile profile) async { + final path = profile.photoPath; + final photo = path == null + ? null + : await prepareProfilePhoto(await File(path).readAsBytes()); + await save(name: profile.name, photo: photo, removePhoto: photo == null); + } + + Future save({ + required String name, + Uint8List? photo, + bool removePhoto = false, + }) async { + await future; + final profile = await ref + .read(userProfileStoreProvider) + .save(name: name, photo: photo, removePhoto: removePhoto); + state = AsyncData(profile); + } +} diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index 286f32ea..8200c054 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -52,6 +52,7 @@ import 'package:spotiflac_android/widgets/in_library_badge.dart'; import 'package:spotiflac_android/widgets/preview_button.dart'; import 'package:spotiflac_android/widgets/app_search_field.dart'; import 'package:spotiflac_android/widgets/view_queue_snackbar_action.dart'; +import 'package:spotiflac_android/widgets/profile_avatar.dart'; import 'package:spotiflac_android/widgets/downloadable_cover.dart'; part 'home_tab_helpers.dart'; @@ -896,6 +897,9 @@ class _HomeTabState extends ConsumerState title: widget.mode == HomeTabMode.search ? context.l10n.mornyeSearch : context.l10n.homeTitle, + actions: context.isMornye && _showsHome + ? const [HomeProfileButton()] + : null, ), SliverToBoxAdapter( diff --git a/lib/screens/main_shell.dart b/lib/screens/main_shell.dart index 12bf83be..22664192 100644 --- a/lib/screens/main_shell.dart +++ b/lib/screens/main_shell.dart @@ -494,12 +494,16 @@ class _MainShellState extends ConsumerState } void _onShellTabRequested(ShellTab tab) { + if (tab == ShellTab.settings && context.isMornye) { + unawaited(_openProfileSettings()); + return; + } final showStore = ref.read( settingsProvider.select((s) => s.showExtensionStore), ); final index = switch (tab) { ShellTab.home => 0, - ShellTab.search => context.isMornye ? (showStore ? 4 : 3) : 0, + ShellTab.search => context.isMornye ? (showStore ? 3 : 2) : 0, ShellTab.library => 1, ShellTab.repository => showStore ? 2 : null, ShellTab.settings => showStore ? 3 : 2, @@ -507,6 +511,24 @@ class _MainShellState extends ConsumerState if (index != null) _onNavTap(index, resetHome: tab != ShellTab.search); } + bool _settingsPageOpen = false; + + Future _openProfileSettings() async { + if (_settingsPageOpen) return; + _settingsPageOpen = true; + FocusManager.instance.primaryFocus?.unfocus(); + ref.read(previewPlayerProvider.notifier).stop(); + try { + await Navigator.of(context).push( + slidePageRoute( + page: const Scaffold(body: SettingsTab(asPage: true)), + ), + ); + } finally { + _settingsPageOpen = false; + } + } + void _onNavTap(int index, {bool resetHome = true}) { final showStore = ref.read( settingsProvider.select((s) => s.showExtensionStore), @@ -594,7 +616,7 @@ class _MainShellState extends ConsumerState final trackState = ref.read(trackProvider); final isSearchTab = - _currentIndex == (context.isMornye ? (showStore ? 4 : 3) : 0); + _currentIndex == (context.isMornye ? (showStore ? 3 : 2) : 0); final isKeyboardVisible = MediaQuery.viewInsetsOf(context).bottom > 0; @@ -664,7 +686,7 @@ class _MainShellState extends ConsumerState if (index == 0) return _homeTabNavigatorKey.currentState; if (index == 1) return _libraryTabNavigatorKey.currentState; if (showStore && index == 2) return _repoTabNavigatorKey.currentState; - if (context.isMornye && index == (showStore ? 4 : 3)) { + if (context.isMornye && index == (showStore ? 3 : 2)) { return _searchTabNavigatorKey.currentState; } return null; @@ -732,7 +754,7 @@ class _MainShellState extends ConsumerState heroAnimationsEnabled: heroAnimationsEnabled, child: const RepoTab(), ), - const SettingsTab(), + if (!context.isMornye) const SettingsTab(), if (context.isMornye) _TabNavigator( key: const ValueKey('tab-search'), @@ -803,13 +825,12 @@ class _MainShellState extends ConsumerState ), label: l10n.navStore, ), - NavigationDestination( - icon: Icon( - context.isMornye ? CupertinoIcons.gear : Icons.settings_outlined, + if (!context.isMornye) + NavigationDestination( + icon: const Icon(Icons.settings_outlined), + selectedIcon: SpinIcon(child: const Icon(Icons.settings)), + label: l10n.navSettings, ), - selectedIcon: SpinIcon(child: const Icon(Icons.settings)), - label: l10n.navSettings, - ), if (context.isMornye) NavigationDestination( icon: const Icon(CupertinoIcons.search), diff --git a/lib/screens/now_playing_screen.dart b/lib/screens/now_playing_screen.dart index 75f40d1d..2e8c5088 100644 --- a/lib/screens/now_playing_screen.dart +++ b/lib/screens/now_playing_screen.dart @@ -1262,14 +1262,22 @@ class _NowPlayingScreenState extends ConsumerState { overflow: TextOverflow.ellipsis, ), const SizedBox(height: 6), - Text( - mediaItem.artist ?? '', - style: Theme.of(context).textTheme.titleMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - textAlign: TextAlign.center, - maxLines: 1, - overflow: TextOverflow.ellipsis, + Consumer( + builder: (context, ref, _) { + final track = ref + .watch(playerCollectionTrackProvider(mediaItem)) + .value; + return ClickableArtistName( + artistName: mediaItem.artist ?? '', + artistId: track?.artistId, + extensionId: track?.source, + style: Theme.of(context).textTheme.titleMedium + ?.copyWith(color: colorScheme.onSurfaceVariant), + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ); + }, ), ], ), diff --git a/lib/screens/settings/backup_restore_page.dart b/lib/screens/settings/backup_restore_page.dart index a1b90827..afb7cada 100644 --- a/lib/screens/settings/backup_restore_page.dart +++ b/lib/screens/settings/backup_restore_page.dart @@ -10,6 +10,7 @@ import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/extension_provider.dart'; import 'package:spotiflac_android/providers/library_collections_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; +import 'package:spotiflac_android/providers/user_profile_provider.dart'; import 'package:spotiflac_android/services/backup_service.dart'; import 'package:spotiflac_android/services/history_database.dart'; import 'package:spotiflac_android/utils/logger.dart'; @@ -65,6 +66,9 @@ class _BackupRestorePageState extends ConsumerState { final file = await BackupService.writeBackupArchive( settings: settings, + profile: _includeSettings + ? await ref.read(userProfileProvider.future) + : null, includeHistory: _includeHistory, loadHistoryPage: (limit, offset) => HistoryDatabase.instance.getAll(limit: limit, offset: offset), @@ -125,6 +129,11 @@ class _BackupRestorePageState extends ConsumerState { setState(() => _isImporting = true); try { + if (bundle.profile != null) { + await ref + .read(userProfileProvider.notifier) + .restoreFromBackup(bundle.profile!); + } if (bundle.hasSettings) { await ref .read(settingsProvider.notifier) @@ -201,6 +210,11 @@ class _BackupRestorePageState extends ConsumerState { icon: Icons.settings_outlined, label: l10n.backupContentsSettings, ), + if (bundle.profile != null) + _ContentRow( + icon: Icons.person_outline, + label: l10n.profileTitle, + ), if (bundle.hasHistory) _ContentRow( icon: Icons.history, @@ -281,6 +295,7 @@ class _BackupRestorePageState extends ConsumerState { SettingsSwitchItem( icon: Icons.settings_outlined, title: l10n.backupContentsSettings, + subtitle: l10n.backupIncludesProfile, value: _includeSettings, onChanged: _isBusy ? null diff --git a/lib/screens/settings/profile_settings_page.dart b/lib/screens/settings/profile_settings_page.dart new file mode 100644 index 00000000..5dbefe59 --- /dev/null +++ b/lib/screens/settings/profile_settings_page.dart @@ -0,0 +1,183 @@ +import 'dart:typed_data'; + +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/providers/user_profile_provider.dart'; +import 'package:spotiflac_android/services/user_profile_store.dart'; +import 'package:spotiflac_android/utils/adaptive_layout.dart'; +import 'package:spotiflac_android/widgets/app_action_button.dart'; +import 'package:spotiflac_android/widgets/app_sliver_header.dart'; +import 'package:spotiflac_android/widgets/profile_avatar.dart'; +import 'package:spotiflac_android/widgets/settings_group.dart'; + +class ProfileSettingsPage extends ConsumerStatefulWidget { + const ProfileSettingsPage({super.key, required this.profile}); + + final UserProfile profile; + + @override + ConsumerState createState() => + _ProfileSettingsPageState(); +} + +class _ProfileSettingsPageState extends ConsumerState { + late final _name = TextEditingController(text: widget.profile.name); + Uint8List? _photo; + bool _removePhoto = false; + bool _busy = false; + + @override + void dispose() { + _name.dispose(); + super.dispose(); + } + + Future _pickPhoto() async { + setState(() => _busy = true); + try { + final picked = await FilePicker.pickFile( + type: FileType.image, + darwinOptions: const DarwinOptions( + assetRepresentationMode: DarwinAssetRepresentationMode.compatible, + ), + ); + if (picked == null) return; + if ((await picked.length() ?? 0) > 20 * 1024 * 1024) { + throw const FormatException('Photo too large'); + } + final bytes = await picked.readAsBytes(); + final photo = await prepareProfilePhoto(bytes); + if (!mounted) return; + setState(() { + _photo = photo; + _removePhoto = false; + }); + } catch (_) { + if (!mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(context.l10n.profilePhotoError))); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + Future _save() async { + FocusManager.instance.primaryFocus?.unfocus(); + setState(() => _busy = true); + try { + await ref + .read(userProfileProvider.notifier) + .save(name: _name.text, photo: _photo, removePhoto: _removePhoto); + if (mounted) Navigator.of(context).pop(); + } catch (_) { + if (!mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(context.l10n.profileSaveError))); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + @override + Widget build(BuildContext context) { + final inset = wideListInset(context); + final hasPhoto = + _photo != null || (!_removePhoto && widget.profile.photoPath != null); + return PopScope( + canPop: !_busy, + child: Scaffold( + body: CustomScrollView( + slivers: [ + AppSliverHeader.page(title: context.l10n.profileTitle), + SliverPadding( + padding: EdgeInsets.fromLTRB(24 + inset, 16, 24 + inset, 32), + sliver: SliverToBoxAdapter( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Center( + child: ValueListenableBuilder( + valueListenable: _name, + builder: (context, value, _) => ProfileAvatar( + name: value.text, + photo: _photo, + photoPath: _removePhoto + ? null + : widget.profile.photoPath, + size: 128, + ), + ), + ), + const SizedBox(height: 16), + Center( + child: AppActionButton( + onPressed: _busy ? null : _pickPhoto, + icon: const Icon(Icons.photo_library_outlined), + label: Text(context.l10n.profileChangePhoto), + outlined: true, + tonal: true, + ), + ), + if (hasPhoto) + Center( + child: TextButton( + onPressed: _busy + ? null + : () => setState(() { + _photo = null; + _removePhoto = true; + }), + child: Text(context.l10n.profileRemovePhoto), + ), + ), + const SizedBox(height: 24), + SettingsGroup( + margin: EdgeInsets.zero, + children: [ + Padding( + padding: const EdgeInsets.all(20), + child: TextField( + controller: _name, + enabled: !_busy, + maxLength: 50, + textCapitalization: TextCapitalization.words, + textInputAction: TextInputAction.done, + onSubmitted: (_) => _busy ? null : _save(), + decoration: InputDecoration( + labelText: context.l10n.profileName, + hintText: context.l10n.profileNameHint, + border: InputBorder.none, + counterText: '', + ), + ), + ), + ], + ), + const SizedBox(height: 24), + AppActionButton( + onPressed: _busy ? null : _save, + icon: _busy + ? const SizedBox.square( + dimension: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.check), + label: Text(context.l10n.dialogSave), + ), + ], + ), + ), + ), + SliverToBoxAdapter( + child: SizedBox(height: MediaQuery.paddingOf(context).bottom), + ), + ], + ), + ), + ); + } +} diff --git a/lib/screens/settings/settings_tab.dart b/lib/screens/settings/settings_tab.dart index 0efa3863..d4a4372e 100644 --- a/lib/screens/settings/settings_tab.dart +++ b/lib/screens/settings/settings_tab.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotiflac_android/constants/app_info.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/providers/user_profile_provider.dart'; +import 'package:spotiflac_android/screens/settings/profile_settings_page.dart'; import 'package:spotiflac_android/screens/settings/about_page.dart'; import 'package:spotiflac_android/screens/settings/app_settings_page.dart'; import 'package:spotiflac_android/screens/settings/appearance_settings_page.dart'; @@ -24,6 +26,7 @@ import 'package:spotiflac_android/widgets/animation_utils.dart'; import 'package:spotiflac_android/widgets/app_search_field.dart'; import 'package:spotiflac_android/widgets/app_sliver_header.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; +import 'package:spotiflac_android/widgets/profile_avatar.dart'; /// One entry on the Settings tab. class _Destination { @@ -66,7 +69,9 @@ class _Group { } class SettingsTab extends ConsumerStatefulWidget { - const SettingsTab({super.key}); + const SettingsTab({super.key, this.asPage = false}); + + final bool asPage; @override ConsumerState createState() => _SettingsTabState(); @@ -389,7 +394,10 @@ class _SettingsTabState extends ConsumerState { return MornyeSettingsTheme( child: CustomScrollView( slivers: [ - AppSliverHeader.tabRoot(title: context.l10n.settingsTitle), + if (widget.asPage) + AppSliverHeader.page(title: context.l10n.settingsTitle) + else + AppSliverHeader.tabRoot(title: context.l10n.settingsTitle), SliverToBoxAdapter( child: Padding( padding: EdgeInsets.fromLTRB( @@ -408,6 +416,45 @@ class _SettingsTabState extends ConsumerState { ), ), ), + SliverToBoxAdapter( + child: Consumer( + builder: (context, ref, _) { + final profile = ref.watch(userProfileProvider).value; + return SettingsGroup( + margin: margin, + children: [ + ListTile( + contentPadding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 16, + ), + leading: ProfileAvatar( + name: profile?.name ?? '', + photoPath: profile?.photoPath, + size: 64, + ), + title: Text( + profile?.name.isNotEmpty == true + ? profile!.name + : context.l10n.profileSetUp, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleLarge, + ), + subtitle: Text(context.l10n.profileEdit), + trailing: const Icon(Icons.chevron_right), + onTap: profile == null + ? null + : () => _navigateTo( + context, + ProfileSettingsPage(profile: profile), + ), + ), + ], + ); + }, + ), + ), ...body, const NavBarSliverSpacer(), const SliverFillRemaining(hasScrollBody: false, child: SizedBox()), diff --git a/lib/screens/track_metadata_cards.dart b/lib/screens/track_metadata_cards.dart index fd888085..c03afd72 100644 --- a/lib/screens/track_metadata_cards.dart +++ b/lib/screens/track_metadata_cards.dart @@ -77,9 +77,10 @@ extension _TrackMetadataCards on _TrackMetadataScreenState { ), ], - const SizedBox(height: 24), - - _buildActionButtons(context, ref, colorScheme, _fileExists), + if (!context.isMornye) ...[ + const SizedBox(height: 24), + _buildActionButtons(context, ref, colorScheme, _fileExists), + ], const SizedBox(height: 32), ], diff --git a/lib/screens/track_metadata_mornye.dart b/lib/screens/track_metadata_mornye.dart index 1b4137ce..eeac44fe 100644 --- a/lib/screens/track_metadata_mornye.dart +++ b/lib/screens/track_metadata_mornye.dart @@ -58,57 +58,56 @@ extension _TrackMetadataMornye on _TrackMetadataScreenState { child: Padding( padding: const EdgeInsets.fromLTRB(24, 24, 24, 8), child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Row( - children: [ - Hero( - tag: _coverHeroTag, - child: ClipRRect( - borderRadius: BorderRadius.circular(10), - child: SizedBox.square( - dimension: 70, + Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 320), + child: AspectRatio( + aspectRatio: 1, + child: Hero( + tag: _coverHeroTag, + child: ClipRRect( + borderRadius: BorderRadius.circular(16), child: PlayerArtwork( artUri: artwork, colorScheme: scheme, + iconSize: 80, ), ), ), ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ExplicitTrackTitle( - title: trackName, - explicit: isExplicit, - style: theme.textTheme.titleLarge, - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 5), - Text( - artistName, - style: theme.textTheme.bodyLarge?.copyWith( - color: scheme.onSurfaceVariant, - ), - ), - if (albumName.trim().isNotEmpty) - Text( - albumName, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.bodyMedium - ?.copyWith( - color: scheme.onSurfaceVariant, - ), - ), - ], - ), - ), - ], + ), ), + const SizedBox(height: 20), + ExplicitTrackTitle( + title: trackName, + explicit: isExplicit, + style: theme.textTheme.titleLarge, + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 5), + Text( + artistName, + textAlign: TextAlign.center, + style: theme.textTheme.bodyLarge?.copyWith( + color: scheme.onSurfaceVariant, + ), + ), + if (albumName.trim().isNotEmpty) + Text( + albumName, + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyMedium?.copyWith( + color: scheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 16), + _buildActionButtons(context, ref, scheme, _fileExists), if (_hasCheckedFile && !_fileExists) Padding( padding: const EdgeInsets.only(top: 16), diff --git a/lib/services/backup_service.dart b/lib/services/backup_service.dart index 5c8f92a6..e62ccafd 100644 --- a/lib/services/backup_service.dart +++ b/lib/services/backup_service.dart @@ -6,6 +6,7 @@ import 'package:archive/archive_io.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; import 'package:spotiflac_android/constants/app_info.dart'; +import 'package:spotiflac_android/services/user_profile_store.dart'; import 'package:spotiflac_android/utils/logger.dart'; typedef BackupHistoryPageLoader = @@ -18,6 +19,7 @@ class BackupBundle { final String appVersion; final DateTime? createdAt; final Map? settings; + final UserProfile? profile; final List> history; final Map collections; final Map playlistCovers; @@ -36,6 +38,7 @@ class BackupBundle { required this.collections, required this.playlistCovers, required this.extensions, + this.profile, this.hasHistory = true, String? historyNdjsonPath, int? historyCount, @@ -89,6 +92,7 @@ class BackupBundle { bool get hasExtensions => extensionCount > 0; bool get isEmpty => !hasSettings && + profile == null && historyCount == 0 && likedCount == 0 && wishlistCount == 0 && @@ -110,6 +114,8 @@ class BackupService { static const int _maxHistoryBytes = 512 << 20; static const int _maxCoverBytes = 20 << 20; static const int _maxAllCoversBytes = 256 << 20; + static const int _maxProfilePhotoBytes = 2 << 20; + static const String _profilePhotoEntry = 'profile/avatar.png'; static String encode(Map envelope) => const JsonEncoder.withIndent(' ').convert(envelope); @@ -148,6 +154,7 @@ class BackupService { Directory? outputDirectory, Directory? temporaryDirectory, bool includeHistory = true, + UserProfile? profile, }) async { final output = await _newBackupFile(outputDirectory); final tempRoot = temporaryDirectory ?? await getTemporaryDirectory(); @@ -193,6 +200,11 @@ class BackupService { coverManifest[entry.key] = {'ext': ext, 'file': archiveName}; } + final photoPath = profile?.photoPath; + if (photoPath != null && + await File(photoPath).length() > _maxProfilePhotoBytes) { + throw const FormatException('Profile photo is too large'); + } final metadata = { 'magic': magic, 'format_version': formatVersion, @@ -202,6 +214,11 @@ class BackupService { 'history_count': historyCount, 'data': { 'settings': ?settings, + if (profile != null) + 'profile': { + 'name': profile.name, + if (photoPath != null) 'photo': _profilePhotoEntry, + }, if (collections.isNotEmpty) 'collections': collections, if (coverManifest.isNotEmpty) 'playlist_covers': coverManifest, if (extensions.isNotEmpty) 'extensions': extensions, @@ -214,6 +231,8 @@ class BackupService { (path: metadataFile.path, name: 'metadata.json', store: false), if (includeHistory) (path: historyFile.path, name: 'history.ndjson', store: false), + if (photoPath != null) + (path: photoPath, name: _profilePhotoEntry, store: true), ]; for (final entry in coverManifest.entries) { final sourcePath = playlistCoverFiles[entry.key]?['path']; @@ -399,11 +418,43 @@ class BackupService { } } + UserProfile? profile; + if (data.containsKey('profile')) { + final rawProfile = data['profile']; + if (rawProfile is! Map || rawProfile['name'] is! String) { + throw const FormatException('Invalid profile'); + } + String? photoPath; + if (rawProfile['photo'] != null) { + if (rawProfile['photo'] != _profilePhotoEntry) { + throw const FormatException('Invalid profile photo entry'); + } + final photoEntry = archive.find(_profilePhotoEntry); + if (photoEntry == null || + !photoEntry.isFile || + photoEntry.size > _maxProfilePhotoBytes) { + throw const FormatException('Missing or oversized profile photo'); + } + photoPath = p.join(extractionDir.path, 'profile.png'); + final output = OutputFileStream(photoPath); + try { + photoEntry.writeContent(output); + } finally { + output.closeSync(); + } + } + profile = UserProfile( + name: rawProfile['name'] as String, + photoPath: photoPath, + ); + } + return BackupBundle( formatVersion: version as int, appVersion: root['app_version'] as String? ?? '', createdAt: DateTime.tryParse(root['created_at'] as String? ?? ''), settings: _mapOrNull(data['settings']), + profile: profile, history: const [], hasHistory: historyEntry != null, historyNdjsonPath: historyPath, diff --git a/lib/services/shell_navigation_service.dart b/lib/services/shell_navigation_service.dart index 05741d0f..eaa3d2e4 100644 --- a/lib/services/shell_navigation_service.dart +++ b/lib/services/shell_navigation_service.dart @@ -61,7 +61,7 @@ class ShellNavigationService { WidgetsBinding.instance.addPostFrameCallback((_) { if (generation != _searchGeneration || !identical(owner, _tabSelectionOwner) || - _currentTabIndex != (_showSearchTab ? (_showRepoTab ? 4 : 3) : 0)) { + _currentTabIndex != (_showSearchTab ? (_showRepoTab ? 3 : 2) : 0)) { return; } final navigatorKey = _showSearchTab @@ -134,7 +134,7 @@ class ShellNavigationService { if (_showRepoTab && _currentTabIndex == 2) { return repoTabNavigatorKey; } - if (_showSearchTab && _currentTabIndex == (_showRepoTab ? 4 : 3)) { + if (_showSearchTab && _currentTabIndex == (_showRepoTab ? 3 : 2)) { return searchTabNavigatorKey; } return null; diff --git a/lib/services/user_profile_store.dart b/lib/services/user_profile_store.dart new file mode 100644 index 00000000..f3eac087 --- /dev/null +++ b/lib/services/user_profile_store.dart @@ -0,0 +1,127 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class UserProfile { + const UserProfile({this.name = '', this.photoPath}); + + final String name; + final String? photoPath; +} + +/// Local identity only. Persist a relative filename so iOS container relocation +/// does not break the avatar after reinstalling an app update. +class UserProfileStore { + UserProfileStore({ + Future Function()? preferences, + Future Function()? documents, + }) : _preferences = preferences ?? SharedPreferences.getInstance, + _documents = documents ?? getApplicationDocumentsDirectory; + + static const _key = 'user_profile_v1'; + final Future Function() _preferences; + final Future Function() _documents; + + Future _directory() async => + Directory(p.join((await _documents()).path, 'profile')); + + Future read() async { + final raw = (await _preferences()).getString(_key); + if (raw == null) return const UserProfile(); + try { + final data = jsonDecode(raw) as Map; + final name = data['name'] as String? ?? ''; + final photo = data['photo'] as String? ?? ''; + if (!RegExp(r'^avatar-\d+\.png$').hasMatch(photo)) { + return UserProfile(name: name); + } + final file = File(p.join((await _directory()).path, photo)); + return UserProfile( + name: name, + photoPath: await file.exists() ? file.path : null, + ); + } on FormatException { + return const UserProfile(); + } on TypeError { + return const UserProfile(); + } + } + + Future save({ + required String name, + Uint8List? photo, + bool removePhoto = false, + }) async { + final previous = await read(); + File? created; + var path = removePhoto ? null : previous.photoPath; + try { + if (photo != null) { + final directory = await _directory(); + await directory.create(recursive: true); + created = File( + p.join( + directory.path, + 'avatar-${DateTime.now().microsecondsSinceEpoch}.png', + ), + ); + await created.writeAsBytes(photo, flush: true); + path = created.path; + } + final updated = UserProfile(name: name.trim(), photoPath: path); + final saved = await (await _preferences()).setString( + _key, + jsonEncode({ + 'name': updated.name, + 'photo': path == null ? '' : p.basename(path), + }), + ); + if (!saved) throw const FileSystemException('Could not save profile'); + if (previous.photoPath != null && previous.photoPath != path) { + try { + await File(previous.photoPath!).delete(); + } on FileSystemException { + // The new profile is committed; an old file must not block saving. + } + } + return updated; + } catch (_) { + if (created != null && await created.exists()) await created.delete(); + rethrow; + } + } +} + +/// Decode one frame and store a bounded PNG instead of retaining a picker temp +/// path or a full-resolution camera image. The circle crop is applied in UI. +Future prepareProfilePhoto(Uint8List bytes) async { + final buffer = await ui.ImmutableBuffer.fromUint8List(bytes); + ui.ImageDescriptor? descriptor; + ui.Codec? codec; + ui.Image? image; + try { + descriptor = await ui.ImageDescriptor.encoded(buffer); + final longest = descriptor.width > descriptor.height + ? descriptor.width + : descriptor.height; + final scale = longest > 512 ? 512 / longest : 1.0; + codec = await descriptor.instantiateCodec( + targetWidth: (descriptor.width * scale).round().clamp(1, 512), + targetHeight: (descriptor.height * scale).round().clamp(1, 512), + ); + image = (await codec.getNextFrame()).image; + final png = await image.toByteData(format: ui.ImageByteFormat.png); + if (png == null) throw const FormatException('Unsupported profile photo'); + return png.buffer.asUint8List(); + } finally { + image?.dispose(); + codec?.dispose(); + descriptor?.dispose(); + buffer.dispose(); + } +} diff --git a/lib/widgets/profile_avatar.dart b/lib/widgets/profile_avatar.dart new file mode 100644 index 00000000..984ef2b4 --- /dev/null +++ b/lib/widgets/profile_avatar.dart @@ -0,0 +1,87 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/providers/user_profile_provider.dart'; +import 'package:spotiflac_android/services/shell_navigation_service.dart'; + +class ProfileAvatar extends StatelessWidget { + const ProfileAvatar({ + super.key, + this.name = '', + this.photoPath, + this.photo, + this.size = 40, + }); + + final String name; + final String? photoPath; + final Uint8List? photo; + final double size; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final fallback = ColoredBox( + color: scheme.primaryContainer, + child: Center( + child: name.trim().isEmpty + ? Icon( + Icons.person, + size: size * 0.55, + color: scheme.onPrimaryContainer, + ) + : Text( + name.trim().characters.first.toUpperCase(), + style: TextStyle( + fontSize: size * 0.42, + fontWeight: FontWeight.w600, + color: scheme.onPrimaryContainer, + ), + ), + ), + ); + return ExcludeSemantics( + child: ClipOval( + child: SizedBox.square( + dimension: size, + child: photo != null + ? Image.memory( + photo!, + fit: BoxFit.cover, + errorBuilder: (_, _, _) => fallback, + ) + : photoPath != null + ? Image.file( + File(photoPath!), + fit: BoxFit.cover, + cacheWidth: (size * MediaQuery.devicePixelRatioOf(context)) + .ceil(), + errorBuilder: (_, _, _) => fallback, + ) + : fallback, + ), + ), + ); + } +} + +class HomeProfileButton extends ConsumerWidget { + const HomeProfileButton({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final profile = ref.watch(userProfileProvider).value; + return IconButton( + tooltip: context.l10n.settingsTitle, + onPressed: () => ShellNavigationService.requestTab(ShellTab.settings), + icon: ProfileAvatar( + name: profile?.name ?? '', + photoPath: profile?.photoPath, + size: 34, + ), + ); + } +} diff --git a/test/backup_service_v2_test.dart b/test/backup_service_v2_test.dart index 7dd02c9f..5d9ff39b 100644 --- a/test/backup_service_v2_test.dart +++ b/test/backup_service_v2_test.dart @@ -128,6 +128,7 @@ void main() { temporaryDirectory: root, ))!; expect(bundle.hasSettings, settings); + expect(bundle.profile, isNull); expect(bundle.hasHistory, history); expect(bundle.hasCollections, collections); expect(bundle.hasExtensions, extensions); @@ -170,7 +171,36 @@ void main() { expect(bundle.formatVersion, 2); expect(bundle.hasHistory, isTrue); expect(bundle.hasSettings, isTrue); + expect(bundle.profile, isNull); await bundle.cleanup(); }, ); + + test('profile archive rejects foreign paths and missing photos', () async { + final root = await Directory.systemTemp.createTemp('profile-backup-'); + addTearDown(() => root.delete(recursive: true)); + for (final photo in ['../avatar.png', 'profile/avatar.png']) { + final metadata = utf8.encode( + jsonEncode({ + 'magic': BackupService.magic, + 'format_version': BackupService.formatVersion, + 'data': { + 'profile': {'name': 'Listener', 'photo': photo}, + }, + }), + ); + final archive = Archive() + ..addFile(ArchiveFile('metadata.json', metadata.length, metadata)); + final file = File(p.join(root.path, 'invalid.sflb')); + await file.writeAsBytes(ZipEncoder().encode(archive)); + expect( + await BackupService.parseFile(file.path, temporaryDirectory: root), + isNull, + ); + expect( + await root.list().where((entry) => entry is Directory).isEmpty, + isTrue, + ); + } + }); } diff --git a/test/mornye_bottom_bar_test.dart b/test/mornye_bottom_bar_test.dart index 3ce42597..0397b645 100644 --- a/test/mornye_bottom_bar_test.dart +++ b/test/mornye_bottom_bar_test.dart @@ -101,10 +101,6 @@ void main() { icon: Icon(Icons.grid_view), label: 'Repo', ), - NavigationDestination( - icon: Icon(Icons.settings), - label: 'Settings', - ), NavigationDestination( icon: Icon(Icons.search), label: 'Search', @@ -169,7 +165,7 @@ void main() { }, ); - testWidgets('five tabs keep Search at the right edge (glass: $blur)', ( + testWidgets('four tabs keep Search at the right edge (glass: $blur)', ( tester, ) async { await pumpShell(tester, blur: blur); @@ -177,18 +173,20 @@ void main() { final search = find .descendant(of: tabs, matching: find.text('Search')) .first; - final settings = find - .descendant(of: tabs, matching: find.text('Settings')) - .first; - expect(tester.widget(tabs).destinations, hasLength(5)); + final repo = find.descendant(of: tabs, matching: find.text('Repo')).first; + expect(tester.widget(tabs).destinations, hasLength(4)); + expect( + find.descendant(of: tabs, matching: find.text('Settings')), + findsNothing, + ); expect( tester.getCenter(search).dx, - greaterThan(tester.getCenter(settings).dx), + greaterThan(tester.getCenter(repo).dx), ); // The glass renderer paints the labels under a gesture overlay. await tester.tapAt(tester.getCenter(search)); await tester.pumpAndSettle(); - expect(selected, 4); + expect(selected, 3); expect(tester.takeException(), isNull); }); } diff --git a/test/shell_navigation_service_test.dart b/test/shell_navigation_service_test.dart index c25cd5be..9c1a146d 100644 --- a/test/shell_navigation_service_test.dart +++ b/test/shell_navigation_service_test.dart @@ -197,7 +197,7 @@ class _SearchShellState extends State<_SearchShell> { GlobalKey get _navigatorKey => widget.separateSearch ? ShellNavigationService.searchTabNavigatorKey : ShellNavigationService.homeTabNavigatorKey; - int get _searchIndex => widget.separateSearch ? (widget.showRepo ? 4 : 3) : 0; + int get _searchIndex => widget.separateSearch ? (widget.showRepo ? 3 : 2) : 0; late final _observer = ShellChromeObserver(_navigatorKey); @override diff --git a/test/user_profile_test.dart b/test/user_profile_test.dart new file mode 100644 index 00000000..4c287137 --- /dev/null +++ b/test/user_profile_test.dart @@ -0,0 +1,293 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/providers/user_profile_provider.dart'; +import 'package:spotiflac_android/screens/settings/settings_tab.dart'; +import 'package:spotiflac_android/services/backup_service.dart'; +import 'package:spotiflac_android/services/shell_navigation_service.dart'; +import 'package:spotiflac_android/services/user_profile_store.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/widgets/profile_avatar.dart'; +import 'package:spotiflac_android/widgets/settings_group.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + late Directory directory; + late UserProfileStore store; + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + directory = await Directory.systemTemp.createTemp('profile-test-'); + store = UserProfileStore(documents: () async => directory); + }); + + tearDown(() async => directory.delete(recursive: true)); + + test( + 'profile persists locally and replaces/removes only its previous photo', + () async { + expect((await store.read()).name, isEmpty); + final first = await store.save( + name: ' Listener ', + photo: Uint8List.fromList([1, 2, 3]), + ); + expect((await store.read()).name, 'Listener'); + expect(await File(first.photoPath!).readAsBytes(), [1, 2, 3]); + final renamed = await store.save(name: 'New name'); + expect(renamed.photoPath, first.photoPath); + final second = await store.save( + name: 'New name', + photo: Uint8List.fromList([4, 5]), + ); + expect(await File(first.photoPath!).exists(), isFalse); + expect(await File(second.photoPath!).exists(), isTrue); + await store.save(name: 'New name', removePhoto: true); + final restored = await store.read(); + expect(restored.name, 'New name'); + expect(restored.photoPath, isNull); + expect(await File(second.photoPath!).exists(), isFalse); + }, + ); + + test('stored photo follows relocated documents directory', () async { + final profile = await store.save( + name: 'Listener', + photo: Uint8List.fromList([1]), + ); + final relocated = await Directory( + p.join(directory.path, 'new-container'), + ).create(); + final photoDirectory = await Directory( + p.join(relocated.path, 'profile'), + ).create(); + await File( + profile.photoPath!, + ).copy(p.join(photoDirectory.path, p.basename(profile.photoPath!))); + final restored = await UserProfileStore( + documents: () async => relocated, + ).read(); + expect(restored.photoPath, startsWith(relocated.path)); + expect(restored.name, 'Listener'); + }); + + test('backup restores profile and photo into a new app directory', () async { + final sourcePreferences = await SharedPreferences.getInstance(); + final sourceStore = UserProfileStore( + preferences: () async => sourcePreferences, + documents: () async => directory, + ); + SharedPreferences.setMockInitialValues({}); + final destinationPreferences = await SharedPreferences.getInstance(); + final recorder = ui.PictureRecorder(); + Canvas(recorder).drawColor(Colors.blue, BlendMode.src); + final picture = recorder.endRecording(); + final image = await picture.toImage(16, 16); + final photo = (await image.toByteData( + format: ui.ImageByteFormat.png, + ))!.buffer.asUint8List(); + image.dispose(); + picture.dispose(); + + final destination = Directory(p.join(directory.path, 'restored-app')); + final destinationStore = UserProfileStore( + preferences: () async => destinationPreferences, + documents: () async => destination, + ); + final container = ProviderContainer( + overrides: [userProfileStoreProvider.overrideWithValue(destinationStore)], + ); + addTearDown(container.dispose); + final subscription = container.listen(userProfileProvider, (_, _) {}); + addTearDown(subscription.close); + await container.read(userProfileProvider.future); + + for (final withPhoto in [true, false]) { + final source = await sourceStore.save( + name: 'Backed up listener', + photo: withPhoto ? photo : null, + removePhoto: !withPhoto, + ); + final file = await BackupService.writeBackupArchive( + settings: {'theme': 'dark'}, + profile: source, + includeHistory: false, + loadHistoryPage: (_, _) async => [], + collections: {}, + playlistCoverFiles: {}, + extensions: {}, + outputDirectory: directory, + temporaryDirectory: directory, + ); + final bundle = (await BackupService.parseFile( + file.path, + temporaryDirectory: directory, + ))!; + final restoredPhoto = bundle.profile!.photoPath; + expect(restoredPhoto != null, withPhoto); + final previousPhoto = (await destinationStore.read()).photoPath; + await container + .read(userProfileProvider.notifier) + .restoreFromBackup(bundle.profile!); + await bundle.cleanup(); + final restored = await destinationStore.read(); + expect(restored.name, 'Backed up listener'); + expect(container.read(userProfileProvider).value!.name, restored.name); + if (withPhoto) { + expect(restored.photoPath, startsWith(destination.path)); + expect(await File(restored.photoPath!).readAsBytes(), photo); + expect(await File(restoredPhoto!).exists(), isFalse); + } else { + expect(restored.photoPath, isNull); + expect(await File(previousPhoto!).exists(), isFalse); + } + } + }); + + test('invalid backup photo leaves the current profile intact', () async { + await store.save(name: 'Keep me'); + final invalid = File(p.join(directory.path, 'invalid.png')); + await invalid.writeAsBytes([0, 1]); + final container = ProviderContainer( + overrides: [userProfileStoreProvider.overrideWithValue(store)], + ); + addTearDown(container.dispose); + await expectLater( + container + .read(userProfileProvider.notifier) + .restoreFromBackup( + UserProfile(name: 'Do not save', photoPath: invalid.path), + ), + throwsException, + ); + expect((await store.read()).name, 'Keep me'); + }); + + test('malformed profile and foreign photo paths fall back safely', () async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('user_profile_v1', 'broken JSON'); + expect((await store.read()).name, isEmpty); + await prefs.setString( + 'user_profile_v1', + jsonEncode({'name': 'Listener', 'photo': '../music.flac'}), + ); + final profile = await store.read(); + expect(profile.name, 'Listener'); + expect(profile.photoPath, isNull); + }); + + test('photo decoding bounds dimensions and preserves aspect ratio', () async { + final recorder = ui.PictureRecorder(); + Canvas(recorder).drawColor(Colors.blue, BlendMode.src); + final picture = recorder.endRecording(); + final input = await picture.toImage(1600, 800); + final bytes = (await input.toByteData( + format: ui.ImageByteFormat.png, + ))!.buffer.asUint8List(); + input.dispose(); + picture.dispose(); + final png = await prepareProfilePhoto(bytes); + final codec = await ui.instantiateImageCodec(png); + final image = (await codec.getNextFrame()).image; + expect(image.width, 512); + expect(image.height, 256); + image.dispose(); + codec.dispose(); + await expectLater( + prepareProfilePhoto(Uint8List.fromList([0, 1])), + throwsException, + ); + }); + + testWidgets( + 'profile card stays separate, saves the name, and cancel keeps it', + (tester) async { + tester.view.physicalSize = const Size(393, 852); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final container = ProviderContainer( + overrides: [userProfileStoreProvider.overrideWithValue(store)], + ); + addTearDown(container.dispose); + await container.read(userProfileProvider.future); + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: MaterialApp( + theme: MornyeTheme.build(Brightness.dark), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const Scaffold(body: SettingsTab()), + ), + ), + ); + await tester.pumpAndSettle(); + final card = find.ancestor( + of: find.text('Set up your profile'), + matching: find.byType(SettingsGroup), + ); + expect(card, findsOneWidget); + expect( + find.descendant(of: card, matching: find.text('Extensions')), + findsNothing, + ); + await tester.tap(find.text('Set up your profile')); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextField), 'Listener'); + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + expect(find.text('Listener'), findsOneWidget); + expect(container.read(userProfileProvider).value?.name, 'Listener'); + await tester.tap(find.text('Listener')); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextField), 'Unsaved'); + await tester.pageBack(); + await tester.pumpAndSettle(); + expect(find.text('Listener'), findsOneWidget); + expect(container.read(userProfileProvider).value?.name, 'Listener'); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets('Home avatar opens Settings and reflects the saved name', ( + tester, + ) async { + final container = ProviderContainer( + overrides: [userProfileStoreProvider.overrideWithValue(store)], + ); + addTearDown(container.dispose); + await container.read(userProfileProvider.future); + final owner = Object(); + ShellTab? requested; + ShellNavigationService.registerTabSelectionHandler( + owner: owner, + handler: (tab) => requested = tab, + ); + addTearDown( + () => ShellNavigationService.unregisterTabSelectionHandler(owner), + ); + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const Scaffold(body: HomeProfileButton()), + ), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byTooltip('Settings')); + expect(requested, ShellTab.settings); + await container.read(userProfileProvider.notifier).save(name: 'Listener'); + await tester.pumpAndSettle(); + expect(find.text('L'), findsOneWidget); + }); +}