mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-30 13:19:44 +02:00
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.
This commit is contained in:
1 parent
5b519070a3
commit
d8d46108bc
19 files changed
+1003
-80
No files matched your search
@@ -6638,6 +6638,16 @@
|
|||||||
"autoMixDescription": "Blend songs with beat matching and pitch-preserving tempo adjustment. Uses crossfade when beats cannot be matched.",
|
"autoMixDescription": "Blend songs with beat matching and pitch-preserving tempo adjustment. Uses crossfade when beats cannot be matched.",
|
||||||
"autoMixOn": "AutoMix on",
|
"autoMixOn": "AutoMix on",
|
||||||
"autoMixOff": "AutoMix off",
|
"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": "Original resolution",
|
||||||
"@optionsEmbeddedCoverSizeOriginal": {
|
"@optionsEmbeddedCoverSizeOriginal": {
|
||||||
"description": "Option that preserves the provider artwork at its original resolution"
|
"description": "Option that preserves the provider artwork at its original resolution"
|
||||||
|
|||||||
+11
-1
@@ -6249,5 +6249,15 @@
|
|||||||
"mornyeFavorited": "Difavoritkan",
|
"mornyeFavorited": "Difavoritkan",
|
||||||
"autoMixDescription": "Sambungkan lagu dengan pencocokan beat dan penyesuaian tempo tanpa mengubah nada. Gunakan crossfade jika beat tidak cocok.",
|
"autoMixDescription": "Sambungkan lagu dengan pencocokan beat dan penyesuaian tempo tanpa mengubah nada. Gunakan crossfade jika beat tidak cocok.",
|
||||||
"autoMixOn": "AutoMix aktif",
|
"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."
|
||||||
}
|
}
|
||||||
@@ -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<UserProfileStore>(
|
||||||
|
(ref) => UserProfileStore(),
|
||||||
|
);
|
||||||
|
|
||||||
|
final userProfileProvider =
|
||||||
|
AsyncNotifierProvider<UserProfileNotifier, UserProfile>(
|
||||||
|
UserProfileNotifier.new,
|
||||||
|
);
|
||||||
|
|
||||||
|
class UserProfileNotifier extends AsyncNotifier<UserProfile> {
|
||||||
|
@override
|
||||||
|
Future<UserProfile> build() => ref.watch(userProfileStoreProvider).read();
|
||||||
|
|
||||||
|
Future<void> 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<void> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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/preview_button.dart';
|
||||||
import 'package:spotiflac_android/widgets/app_search_field.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/view_queue_snackbar_action.dart';
|
||||||
|
import 'package:spotiflac_android/widgets/profile_avatar.dart';
|
||||||
import 'package:spotiflac_android/widgets/downloadable_cover.dart';
|
import 'package:spotiflac_android/widgets/downloadable_cover.dart';
|
||||||
|
|
||||||
part 'home_tab_helpers.dart';
|
part 'home_tab_helpers.dart';
|
||||||
@@ -896,6 +897,9 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
title: widget.mode == HomeTabMode.search
|
title: widget.mode == HomeTabMode.search
|
||||||
? context.l10n.mornyeSearch
|
? context.l10n.mornyeSearch
|
||||||
: context.l10n.homeTitle,
|
: context.l10n.homeTitle,
|
||||||
|
actions: context.isMornye && _showsHome
|
||||||
|
? const [HomeProfileButton()]
|
||||||
|
: null,
|
||||||
),
|
),
|
||||||
|
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
|
|||||||
+31
-10
@@ -494,12 +494,16 @@ class _MainShellState extends ConsumerState<MainShell>
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _onShellTabRequested(ShellTab tab) {
|
void _onShellTabRequested(ShellTab tab) {
|
||||||
|
if (tab == ShellTab.settings && context.isMornye) {
|
||||||
|
unawaited(_openProfileSettings());
|
||||||
|
return;
|
||||||
|
}
|
||||||
final showStore = ref.read(
|
final showStore = ref.read(
|
||||||
settingsProvider.select((s) => s.showExtensionStore),
|
settingsProvider.select((s) => s.showExtensionStore),
|
||||||
);
|
);
|
||||||
final index = switch (tab) {
|
final index = switch (tab) {
|
||||||
ShellTab.home => 0,
|
ShellTab.home => 0,
|
||||||
ShellTab.search => context.isMornye ? (showStore ? 4 : 3) : 0,
|
ShellTab.search => context.isMornye ? (showStore ? 3 : 2) : 0,
|
||||||
ShellTab.library => 1,
|
ShellTab.library => 1,
|
||||||
ShellTab.repository => showStore ? 2 : null,
|
ShellTab.repository => showStore ? 2 : null,
|
||||||
ShellTab.settings => showStore ? 3 : 2,
|
ShellTab.settings => showStore ? 3 : 2,
|
||||||
@@ -507,6 +511,24 @@ class _MainShellState extends ConsumerState<MainShell>
|
|||||||
if (index != null) _onNavTap(index, resetHome: tab != ShellTab.search);
|
if (index != null) _onNavTap(index, resetHome: tab != ShellTab.search);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool _settingsPageOpen = false;
|
||||||
|
|
||||||
|
Future<void> _openProfileSettings() async {
|
||||||
|
if (_settingsPageOpen) return;
|
||||||
|
_settingsPageOpen = true;
|
||||||
|
FocusManager.instance.primaryFocus?.unfocus();
|
||||||
|
ref.read(previewPlayerProvider.notifier).stop();
|
||||||
|
try {
|
||||||
|
await Navigator.of(context).push(
|
||||||
|
slidePageRoute<void>(
|
||||||
|
page: const Scaffold(body: SettingsTab(asPage: true)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
_settingsPageOpen = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void _onNavTap(int index, {bool resetHome = true}) {
|
void _onNavTap(int index, {bool resetHome = true}) {
|
||||||
final showStore = ref.read(
|
final showStore = ref.read(
|
||||||
settingsProvider.select((s) => s.showExtensionStore),
|
settingsProvider.select((s) => s.showExtensionStore),
|
||||||
@@ -594,7 +616,7 @@ class _MainShellState extends ConsumerState<MainShell>
|
|||||||
|
|
||||||
final trackState = ref.read(trackProvider);
|
final trackState = ref.read(trackProvider);
|
||||||
final isSearchTab =
|
final isSearchTab =
|
||||||
_currentIndex == (context.isMornye ? (showStore ? 4 : 3) : 0);
|
_currentIndex == (context.isMornye ? (showStore ? 3 : 2) : 0);
|
||||||
|
|
||||||
final isKeyboardVisible = MediaQuery.viewInsetsOf(context).bottom > 0;
|
final isKeyboardVisible = MediaQuery.viewInsetsOf(context).bottom > 0;
|
||||||
|
|
||||||
@@ -664,7 +686,7 @@ class _MainShellState extends ConsumerState<MainShell>
|
|||||||
if (index == 0) return _homeTabNavigatorKey.currentState;
|
if (index == 0) return _homeTabNavigatorKey.currentState;
|
||||||
if (index == 1) return _libraryTabNavigatorKey.currentState;
|
if (index == 1) return _libraryTabNavigatorKey.currentState;
|
||||||
if (showStore && index == 2) return _repoTabNavigatorKey.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 _searchTabNavigatorKey.currentState;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@@ -732,7 +754,7 @@ class _MainShellState extends ConsumerState<MainShell>
|
|||||||
heroAnimationsEnabled: heroAnimationsEnabled,
|
heroAnimationsEnabled: heroAnimationsEnabled,
|
||||||
child: const RepoTab(),
|
child: const RepoTab(),
|
||||||
),
|
),
|
||||||
const SettingsTab(),
|
if (!context.isMornye) const SettingsTab(),
|
||||||
if (context.isMornye)
|
if (context.isMornye)
|
||||||
_TabNavigator(
|
_TabNavigator(
|
||||||
key: const ValueKey('tab-search'),
|
key: const ValueKey('tab-search'),
|
||||||
@@ -803,13 +825,12 @@ class _MainShellState extends ConsumerState<MainShell>
|
|||||||
),
|
),
|
||||||
label: l10n.navStore,
|
label: l10n.navStore,
|
||||||
),
|
),
|
||||||
NavigationDestination(
|
if (!context.isMornye)
|
||||||
icon: Icon(
|
NavigationDestination(
|
||||||
context.isMornye ? CupertinoIcons.gear : Icons.settings_outlined,
|
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)
|
if (context.isMornye)
|
||||||
NavigationDestination(
|
NavigationDestination(
|
||||||
icon: const Icon(CupertinoIcons.search),
|
icon: const Icon(CupertinoIcons.search),
|
||||||
|
|||||||
@@ -1262,14 +1262,22 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
|
|||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
Text(
|
Consumer(
|
||||||
mediaItem.artist ?? '',
|
builder: (context, ref, _) {
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
final track = ref
|
||||||
color: colorScheme.onSurfaceVariant,
|
.watch(playerCollectionTrackProvider(mediaItem))
|
||||||
),
|
.value;
|
||||||
textAlign: TextAlign.center,
|
return ClickableArtistName(
|
||||||
maxLines: 1,
|
artistName: mediaItem.artist ?? '',
|
||||||
overflow: TextOverflow.ellipsis,
|
artistId: track?.artistId,
|
||||||
|
extensionId: track?.source,
|
||||||
|
style: Theme.of(context).textTheme.titleMedium
|
||||||
|
?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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/extension_provider.dart';
|
||||||
import 'package:spotiflac_android/providers/library_collections_provider.dart';
|
import 'package:spotiflac_android/providers/library_collections_provider.dart';
|
||||||
import 'package:spotiflac_android/providers/settings_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/backup_service.dart';
|
||||||
import 'package:spotiflac_android/services/history_database.dart';
|
import 'package:spotiflac_android/services/history_database.dart';
|
||||||
import 'package:spotiflac_android/utils/logger.dart';
|
import 'package:spotiflac_android/utils/logger.dart';
|
||||||
@@ -65,6 +66,9 @@ class _BackupRestorePageState extends ConsumerState<BackupRestorePage> {
|
|||||||
|
|
||||||
final file = await BackupService.writeBackupArchive(
|
final file = await BackupService.writeBackupArchive(
|
||||||
settings: settings,
|
settings: settings,
|
||||||
|
profile: _includeSettings
|
||||||
|
? await ref.read(userProfileProvider.future)
|
||||||
|
: null,
|
||||||
includeHistory: _includeHistory,
|
includeHistory: _includeHistory,
|
||||||
loadHistoryPage: (limit, offset) =>
|
loadHistoryPage: (limit, offset) =>
|
||||||
HistoryDatabase.instance.getAll(limit: limit, offset: offset),
|
HistoryDatabase.instance.getAll(limit: limit, offset: offset),
|
||||||
@@ -125,6 +129,11 @@ class _BackupRestorePageState extends ConsumerState<BackupRestorePage> {
|
|||||||
|
|
||||||
setState(() => _isImporting = true);
|
setState(() => _isImporting = true);
|
||||||
try {
|
try {
|
||||||
|
if (bundle.profile != null) {
|
||||||
|
await ref
|
||||||
|
.read(userProfileProvider.notifier)
|
||||||
|
.restoreFromBackup(bundle.profile!);
|
||||||
|
}
|
||||||
if (bundle.hasSettings) {
|
if (bundle.hasSettings) {
|
||||||
await ref
|
await ref
|
||||||
.read(settingsProvider.notifier)
|
.read(settingsProvider.notifier)
|
||||||
@@ -201,6 +210,11 @@ class _BackupRestorePageState extends ConsumerState<BackupRestorePage> {
|
|||||||
icon: Icons.settings_outlined,
|
icon: Icons.settings_outlined,
|
||||||
label: l10n.backupContentsSettings,
|
label: l10n.backupContentsSettings,
|
||||||
),
|
),
|
||||||
|
if (bundle.profile != null)
|
||||||
|
_ContentRow(
|
||||||
|
icon: Icons.person_outline,
|
||||||
|
label: l10n.profileTitle,
|
||||||
|
),
|
||||||
if (bundle.hasHistory)
|
if (bundle.hasHistory)
|
||||||
_ContentRow(
|
_ContentRow(
|
||||||
icon: Icons.history,
|
icon: Icons.history,
|
||||||
@@ -281,6 +295,7 @@ class _BackupRestorePageState extends ConsumerState<BackupRestorePage> {
|
|||||||
SettingsSwitchItem(
|
SettingsSwitchItem(
|
||||||
icon: Icons.settings_outlined,
|
icon: Icons.settings_outlined,
|
||||||
title: l10n.backupContentsSettings,
|
title: l10n.backupContentsSettings,
|
||||||
|
subtitle: l10n.backupIncludesProfile,
|
||||||
value: _includeSettings,
|
value: _includeSettings,
|
||||||
onChanged: _isBusy
|
onChanged: _isBusy
|
||||||
? null
|
? null
|
||||||
|
|||||||
@@ -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<ProfileSettingsPage> createState() =>
|
||||||
|
_ProfileSettingsPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ProfileSettingsPageState extends ConsumerState<ProfileSettingsPage> {
|
||||||
|
late final _name = TextEditingController(text: widget.profile.name);
|
||||||
|
Uint8List? _photo;
|
||||||
|
bool _removePhoto = false;
|
||||||
|
bool _busy = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_name.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _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<void> _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<TextEditingValue>(
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:spotiflac_android/constants/app_info.dart';
|
import 'package:spotiflac_android/constants/app_info.dart';
|
||||||
import 'package:spotiflac_android/l10n/l10n.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/about_page.dart';
|
||||||
import 'package:spotiflac_android/screens/settings/app_settings_page.dart';
|
import 'package:spotiflac_android/screens/settings/app_settings_page.dart';
|
||||||
import 'package:spotiflac_android/screens/settings/appearance_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_search_field.dart';
|
||||||
import 'package:spotiflac_android/widgets/app_sliver_header.dart';
|
import 'package:spotiflac_android/widgets/app_sliver_header.dart';
|
||||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||||
|
import 'package:spotiflac_android/widgets/profile_avatar.dart';
|
||||||
|
|
||||||
/// One entry on the Settings tab.
|
/// One entry on the Settings tab.
|
||||||
class _Destination {
|
class _Destination {
|
||||||
@@ -66,7 +69,9 @@ class _Group {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class SettingsTab extends ConsumerStatefulWidget {
|
class SettingsTab extends ConsumerStatefulWidget {
|
||||||
const SettingsTab({super.key});
|
const SettingsTab({super.key, this.asPage = false});
|
||||||
|
|
||||||
|
final bool asPage;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
ConsumerState<SettingsTab> createState() => _SettingsTabState();
|
ConsumerState<SettingsTab> createState() => _SettingsTabState();
|
||||||
@@ -389,7 +394,10 @@ class _SettingsTabState extends ConsumerState<SettingsTab> {
|
|||||||
return MornyeSettingsTheme(
|
return MornyeSettingsTheme(
|
||||||
child: CustomScrollView(
|
child: CustomScrollView(
|
||||||
slivers: [
|
slivers: [
|
||||||
AppSliverHeader.tabRoot(title: context.l10n.settingsTitle),
|
if (widget.asPage)
|
||||||
|
AppSliverHeader.page(title: context.l10n.settingsTitle)
|
||||||
|
else
|
||||||
|
AppSliverHeader.tabRoot(title: context.l10n.settingsTitle),
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: EdgeInsets.fromLTRB(
|
padding: EdgeInsets.fromLTRB(
|
||||||
@@ -408,6 +416,45 @@ class _SettingsTabState extends ConsumerState<SettingsTab> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
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,
|
...body,
|
||||||
const NavBarSliverSpacer(),
|
const NavBarSliverSpacer(),
|
||||||
const SliverFillRemaining(hasScrollBody: false, child: SizedBox()),
|
const SliverFillRemaining(hasScrollBody: false, child: SizedBox()),
|
||||||
|
|||||||
@@ -77,9 +77,10 @@ extension _TrackMetadataCards on _TrackMetadataScreenState {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
||||||
const SizedBox(height: 24),
|
if (!context.isMornye) ...[
|
||||||
|
const SizedBox(height: 24),
|
||||||
_buildActionButtons(context, ref, colorScheme, _fileExists),
|
_buildActionButtons(context, ref, colorScheme, _fileExists),
|
||||||
|
],
|
||||||
|
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -58,57 +58,56 @@ extension _TrackMetadataMornye on _TrackMetadataScreenState {
|
|||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
|
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Center(
|
||||||
children: [
|
child: ConstrainedBox(
|
||||||
Hero(
|
constraints: const BoxConstraints(maxWidth: 320),
|
||||||
tag: _coverHeroTag,
|
child: AspectRatio(
|
||||||
child: ClipRRect(
|
aspectRatio: 1,
|
||||||
borderRadius: BorderRadius.circular(10),
|
child: Hero(
|
||||||
child: SizedBox.square(
|
tag: _coverHeroTag,
|
||||||
dimension: 70,
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
child: PlayerArtwork(
|
child: PlayerArtwork(
|
||||||
artUri: artwork,
|
artUri: artwork,
|
||||||
colorScheme: scheme,
|
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)
|
if (_hasCheckedFile && !_fileExists)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(top: 16),
|
padding: const EdgeInsets.only(top: 16),
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import 'package:archive/archive_io.dart';
|
|||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
import 'package:spotiflac_android/constants/app_info.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';
|
import 'package:spotiflac_android/utils/logger.dart';
|
||||||
|
|
||||||
typedef BackupHistoryPageLoader =
|
typedef BackupHistoryPageLoader =
|
||||||
@@ -18,6 +19,7 @@ class BackupBundle {
|
|||||||
final String appVersion;
|
final String appVersion;
|
||||||
final DateTime? createdAt;
|
final DateTime? createdAt;
|
||||||
final Map<String, dynamic>? settings;
|
final Map<String, dynamic>? settings;
|
||||||
|
final UserProfile? profile;
|
||||||
final List<Map<String, dynamic>> history;
|
final List<Map<String, dynamic>> history;
|
||||||
final Map<String, dynamic> collections;
|
final Map<String, dynamic> collections;
|
||||||
final Map<String, dynamic> playlistCovers;
|
final Map<String, dynamic> playlistCovers;
|
||||||
@@ -36,6 +38,7 @@ class BackupBundle {
|
|||||||
required this.collections,
|
required this.collections,
|
||||||
required this.playlistCovers,
|
required this.playlistCovers,
|
||||||
required this.extensions,
|
required this.extensions,
|
||||||
|
this.profile,
|
||||||
this.hasHistory = true,
|
this.hasHistory = true,
|
||||||
String? historyNdjsonPath,
|
String? historyNdjsonPath,
|
||||||
int? historyCount,
|
int? historyCount,
|
||||||
@@ -89,6 +92,7 @@ class BackupBundle {
|
|||||||
bool get hasExtensions => extensionCount > 0;
|
bool get hasExtensions => extensionCount > 0;
|
||||||
bool get isEmpty =>
|
bool get isEmpty =>
|
||||||
!hasSettings &&
|
!hasSettings &&
|
||||||
|
profile == null &&
|
||||||
historyCount == 0 &&
|
historyCount == 0 &&
|
||||||
likedCount == 0 &&
|
likedCount == 0 &&
|
||||||
wishlistCount == 0 &&
|
wishlistCount == 0 &&
|
||||||
@@ -110,6 +114,8 @@ class BackupService {
|
|||||||
static const int _maxHistoryBytes = 512 << 20;
|
static const int _maxHistoryBytes = 512 << 20;
|
||||||
static const int _maxCoverBytes = 20 << 20;
|
static const int _maxCoverBytes = 20 << 20;
|
||||||
static const int _maxAllCoversBytes = 256 << 20;
|
static const int _maxAllCoversBytes = 256 << 20;
|
||||||
|
static const int _maxProfilePhotoBytes = 2 << 20;
|
||||||
|
static const String _profilePhotoEntry = 'profile/avatar.png';
|
||||||
|
|
||||||
static String encode(Map<String, dynamic> envelope) =>
|
static String encode(Map<String, dynamic> envelope) =>
|
||||||
const JsonEncoder.withIndent(' ').convert(envelope);
|
const JsonEncoder.withIndent(' ').convert(envelope);
|
||||||
@@ -148,6 +154,7 @@ class BackupService {
|
|||||||
Directory? outputDirectory,
|
Directory? outputDirectory,
|
||||||
Directory? temporaryDirectory,
|
Directory? temporaryDirectory,
|
||||||
bool includeHistory = true,
|
bool includeHistory = true,
|
||||||
|
UserProfile? profile,
|
||||||
}) async {
|
}) async {
|
||||||
final output = await _newBackupFile(outputDirectory);
|
final output = await _newBackupFile(outputDirectory);
|
||||||
final tempRoot = temporaryDirectory ?? await getTemporaryDirectory();
|
final tempRoot = temporaryDirectory ?? await getTemporaryDirectory();
|
||||||
@@ -193,6 +200,11 @@ class BackupService {
|
|||||||
coverManifest[entry.key] = {'ext': ext, 'file': archiveName};
|
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 = {
|
final metadata = {
|
||||||
'magic': magic,
|
'magic': magic,
|
||||||
'format_version': formatVersion,
|
'format_version': formatVersion,
|
||||||
@@ -202,6 +214,11 @@ class BackupService {
|
|||||||
'history_count': historyCount,
|
'history_count': historyCount,
|
||||||
'data': {
|
'data': {
|
||||||
'settings': ?settings,
|
'settings': ?settings,
|
||||||
|
if (profile != null)
|
||||||
|
'profile': {
|
||||||
|
'name': profile.name,
|
||||||
|
if (photoPath != null) 'photo': _profilePhotoEntry,
|
||||||
|
},
|
||||||
if (collections.isNotEmpty) 'collections': collections,
|
if (collections.isNotEmpty) 'collections': collections,
|
||||||
if (coverManifest.isNotEmpty) 'playlist_covers': coverManifest,
|
if (coverManifest.isNotEmpty) 'playlist_covers': coverManifest,
|
||||||
if (extensions.isNotEmpty) 'extensions': extensions,
|
if (extensions.isNotEmpty) 'extensions': extensions,
|
||||||
@@ -214,6 +231,8 @@ class BackupService {
|
|||||||
(path: metadataFile.path, name: 'metadata.json', store: false),
|
(path: metadataFile.path, name: 'metadata.json', store: false),
|
||||||
if (includeHistory)
|
if (includeHistory)
|
||||||
(path: historyFile.path, name: 'history.ndjson', store: false),
|
(path: historyFile.path, name: 'history.ndjson', store: false),
|
||||||
|
if (photoPath != null)
|
||||||
|
(path: photoPath, name: _profilePhotoEntry, store: true),
|
||||||
];
|
];
|
||||||
for (final entry in coverManifest.entries) {
|
for (final entry in coverManifest.entries) {
|
||||||
final sourcePath = playlistCoverFiles[entry.key]?['path'];
|
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(
|
return BackupBundle(
|
||||||
formatVersion: version as int,
|
formatVersion: version as int,
|
||||||
appVersion: root['app_version'] as String? ?? '',
|
appVersion: root['app_version'] as String? ?? '',
|
||||||
createdAt: DateTime.tryParse(root['created_at'] as String? ?? ''),
|
createdAt: DateTime.tryParse(root['created_at'] as String? ?? ''),
|
||||||
settings: _mapOrNull(data['settings']),
|
settings: _mapOrNull(data['settings']),
|
||||||
|
profile: profile,
|
||||||
history: const [],
|
history: const [],
|
||||||
hasHistory: historyEntry != null,
|
hasHistory: historyEntry != null,
|
||||||
historyNdjsonPath: historyPath,
|
historyNdjsonPath: historyPath,
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ class ShellNavigationService {
|
|||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (generation != _searchGeneration ||
|
if (generation != _searchGeneration ||
|
||||||
!identical(owner, _tabSelectionOwner) ||
|
!identical(owner, _tabSelectionOwner) ||
|
||||||
_currentTabIndex != (_showSearchTab ? (_showRepoTab ? 4 : 3) : 0)) {
|
_currentTabIndex != (_showSearchTab ? (_showRepoTab ? 3 : 2) : 0)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final navigatorKey = _showSearchTab
|
final navigatorKey = _showSearchTab
|
||||||
@@ -134,7 +134,7 @@ class ShellNavigationService {
|
|||||||
if (_showRepoTab && _currentTabIndex == 2) {
|
if (_showRepoTab && _currentTabIndex == 2) {
|
||||||
return repoTabNavigatorKey;
|
return repoTabNavigatorKey;
|
||||||
}
|
}
|
||||||
if (_showSearchTab && _currentTabIndex == (_showRepoTab ? 4 : 3)) {
|
if (_showSearchTab && _currentTabIndex == (_showRepoTab ? 3 : 2)) {
|
||||||
return searchTabNavigatorKey;
|
return searchTabNavigatorKey;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -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<SharedPreferences> Function()? preferences,
|
||||||
|
Future<Directory> Function()? documents,
|
||||||
|
}) : _preferences = preferences ?? SharedPreferences.getInstance,
|
||||||
|
_documents = documents ?? getApplicationDocumentsDirectory;
|
||||||
|
|
||||||
|
static const _key = 'user_profile_v1';
|
||||||
|
final Future<SharedPreferences> Function() _preferences;
|
||||||
|
final Future<Directory> Function() _documents;
|
||||||
|
|
||||||
|
Future<Directory> _directory() async =>
|
||||||
|
Directory(p.join((await _documents()).path, 'profile'));
|
||||||
|
|
||||||
|
Future<UserProfile> read() async {
|
||||||
|
final raw = (await _preferences()).getString(_key);
|
||||||
|
if (raw == null) return const UserProfile();
|
||||||
|
try {
|
||||||
|
final data = jsonDecode(raw) as Map<String, dynamic>;
|
||||||
|
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<UserProfile> 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<Uint8List> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -128,6 +128,7 @@ void main() {
|
|||||||
temporaryDirectory: root,
|
temporaryDirectory: root,
|
||||||
))!;
|
))!;
|
||||||
expect(bundle.hasSettings, settings);
|
expect(bundle.hasSettings, settings);
|
||||||
|
expect(bundle.profile, isNull);
|
||||||
expect(bundle.hasHistory, history);
|
expect(bundle.hasHistory, history);
|
||||||
expect(bundle.hasCollections, collections);
|
expect(bundle.hasCollections, collections);
|
||||||
expect(bundle.hasExtensions, extensions);
|
expect(bundle.hasExtensions, extensions);
|
||||||
@@ -170,7 +171,36 @@ void main() {
|
|||||||
expect(bundle.formatVersion, 2);
|
expect(bundle.formatVersion, 2);
|
||||||
expect(bundle.hasHistory, isTrue);
|
expect(bundle.hasHistory, isTrue);
|
||||||
expect(bundle.hasSettings, isTrue);
|
expect(bundle.hasSettings, isTrue);
|
||||||
|
expect(bundle.profile, isNull);
|
||||||
await bundle.cleanup();
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
@@ -101,10 +101,6 @@ void main() {
|
|||||||
icon: Icon(Icons.grid_view),
|
icon: Icon(Icons.grid_view),
|
||||||
label: 'Repo',
|
label: 'Repo',
|
||||||
),
|
),
|
||||||
NavigationDestination(
|
|
||||||
icon: Icon(Icons.settings),
|
|
||||||
label: 'Settings',
|
|
||||||
),
|
|
||||||
NavigationDestination(
|
NavigationDestination(
|
||||||
icon: Icon(Icons.search),
|
icon: Icon(Icons.search),
|
||||||
label: '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,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
await pumpShell(tester, blur: blur);
|
await pumpShell(tester, blur: blur);
|
||||||
@@ -177,18 +173,20 @@ void main() {
|
|||||||
final search = find
|
final search = find
|
||||||
.descendant(of: tabs, matching: find.text('Search'))
|
.descendant(of: tabs, matching: find.text('Search'))
|
||||||
.first;
|
.first;
|
||||||
final settings = find
|
final repo = find.descendant(of: tabs, matching: find.text('Repo')).first;
|
||||||
.descendant(of: tabs, matching: find.text('Settings'))
|
expect(tester.widget<MornyeTabBar>(tabs).destinations, hasLength(4));
|
||||||
.first;
|
expect(
|
||||||
expect(tester.widget<MornyeTabBar>(tabs).destinations, hasLength(5));
|
find.descendant(of: tabs, matching: find.text('Settings')),
|
||||||
|
findsNothing,
|
||||||
|
);
|
||||||
expect(
|
expect(
|
||||||
tester.getCenter(search).dx,
|
tester.getCenter(search).dx,
|
||||||
greaterThan(tester.getCenter(settings).dx),
|
greaterThan(tester.getCenter(repo).dx),
|
||||||
);
|
);
|
||||||
// The glass renderer paints the labels under a gesture overlay.
|
// The glass renderer paints the labels under a gesture overlay.
|
||||||
await tester.tapAt(tester.getCenter(search));
|
await tester.tapAt(tester.getCenter(search));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(selected, 4);
|
expect(selected, 3);
|
||||||
expect(tester.takeException(), isNull);
|
expect(tester.takeException(), isNull);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -197,7 +197,7 @@ class _SearchShellState extends State<_SearchShell> {
|
|||||||
GlobalKey<NavigatorState> get _navigatorKey => widget.separateSearch
|
GlobalKey<NavigatorState> get _navigatorKey => widget.separateSearch
|
||||||
? ShellNavigationService.searchTabNavigatorKey
|
? ShellNavigationService.searchTabNavigatorKey
|
||||||
: ShellNavigationService.homeTabNavigatorKey;
|
: 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);
|
late final _observer = ShellChromeObserver(_navigatorKey);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in new issue
Block a user