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); + }); +}