From b64b08c4d0bf9dde30d233587b3fc06c1e8426c6 Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Sun, 18 Oct 2026 22:38:07 +0700 Subject: [PATCH] refactor(shell): move nav icons to a widget file and startup prompts into a part BouncingIcon, SlidingIcon and SpinIcon are standalone widgets, so they now live in lib/widgets/nav_destination_icons.dart. The SAF repair, upgrade intro, update, announcement and SAF migration prompts move into a part as an extension on the shell State; the call sites in initState are unchanged. --- lib/screens/main_shell.dart | 392 +------------------- lib/screens/main_shell_startup_prompts.dart | 267 +++++++++++++ lib/widgets/nav_destination_icons.dart | 126 +++++++ 3 files changed, 396 insertions(+), 389 deletions(-) create mode 100644 lib/screens/main_shell_startup_prompts.dart create mode 100644 lib/widgets/nav_destination_icons.dart diff --git a/lib/screens/main_shell.dart b/lib/screens/main_shell.dart index 2f490685..aa9e9e38 100644 --- a/lib/screens/main_shell.dart +++ b/lib/screens/main_shell.dart @@ -38,6 +38,7 @@ import 'package:spotiflac_android/widgets/app_snack_bar.dart'; import 'package:spotiflac_android/widgets/download_queue_feedback.dart'; import 'package:spotiflac_android/widgets/update_dialog.dart'; import 'package:spotiflac_android/widgets/animation_utils.dart'; +import 'package:spotiflac_android/widgets/nav_destination_icons.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; import 'package:spotiflac_android/widgets/mini_player.dart'; import 'package:spotiflac_android/widgets/lazy_tab_view.dart'; @@ -51,6 +52,8 @@ import 'package:spotiflac_android/widgets/selection_bottom_bar.dart'; import 'package:spotiflac_android/utils/logger.dart'; import 'package:spotiflac_android/utils/adaptive_layout.dart'; +part 'main_shell_startup_prompts.dart'; + final _log = AppLogger('MainShell'); class MainShell extends ConsumerStatefulWidget { @@ -209,112 +212,6 @@ class _MainShellState extends ConsumerState } } - Future _repairSafAccessIfNeeded({bool knownLost = false}) async { - if (!Platform.isAndroid || _safRepairDialogVisible) return; - - var accessLost = knownLost; - if (!accessLost) { - final settings = ref.read(settingsProvider); - if (settings.storageMode != 'saf' || settings.downloadTreeUri.isEmpty) { - return; - } - accessLost = !await PlatformBridge.isSafTreeAccessible( - settings.downloadTreeUri, - ); - } - if (!accessLost || !mounted) return; - - _safRepairDialogVisible = true; - try { - await showAppDialog( - context: context, - barrierDismissible: false, - builder: (dialogContext) { - var isPickingFolder = false; - return StatefulBuilder( - builder: (context, setDialogState) { - return PopScope( - canPop: false, - child: AppAlertDialog( - icon: AppIcon( - Icons.folder_off_outlined, - color: Theme.of(context).colorScheme.error, - ), - title: Text(context.l10n.downloadFolderAccessLostTitle), - content: Text(context.l10n.downloadFolderAccessLostSubtitle), - actions: [ - AppDialogAction( - onPressed: isPickingFolder - ? null - : () { - final notifier = ref.read( - settingsProvider.notifier, - ); - notifier.setStorageMode('app'); - notifier.setDownloadTreeUri(''); - notifier.setDownloadDirectory(''); - Navigator.of(dialogContext).pop(); - }, - child: Text(context.l10n.storageAutomaticFolder), - ), - AppDialogAction( - filled: true, - onPressed: isPickingFolder - ? null - : () async { - setDialogState(() => isPickingFolder = true); - try { - final result = - await PlatformBridge.pickSafTree(); - if (result == null) return; - final treeUri = - result['tree_uri'] as String? ?? ''; - final displayName = - result['display_name'] as String? ?? ''; - if (treeUri.isEmpty) return; - - final notifier = ref.read( - settingsProvider.notifier, - ); - notifier.setStorageMode('saf'); - notifier.setDownloadTreeUri( - treeUri, - displayName: displayName.isNotEmpty - ? displayName - : treeUri, - ); - if (dialogContext.mounted) { - Navigator.of(dialogContext).pop(); - } - } catch (e) { - _log.w( - 'Failed to repair SAF access from startup: $e', - ); - if (context.mounted) { - showCannotOpenFileSnackBar(context, e); - } - } finally { - if (dialogContext.mounted) { - setDialogState(() => isPickingFolder = false); - } - } - }, - child: isPickingFolder - ? const AppLoadingIndicator(size: 18, strokeWidth: 2) - : Text(context.l10n.downloadFolderReselect), - ), - ], - ), - ); - }, - ); - }, - ); - } finally { - _safRepairDialogVisible = false; - } - } - void _setupShareListener() { final pendingUrl = ShareIntentService().consumePendingUrl(); if (pendingUrl != null) { @@ -379,164 +276,6 @@ class _MainShellState extends ConsumerState } } - Future _checkUpgradeIntro() async { - try { - final preferences = await SharedPreferences.getInstance(); - if (!mounted) return; - final existingInstallation = !ref - .read(initialSettingsProvider) - .isFirstLaunch; - if (UpgradeIntroService.shouldShow( - preferences, - version: AppInfo.version, - existingInstallation: existingInstallation, - )) { - await Navigator.of(context, rootNavigator: true).push( - MaterialPageRoute( - builder: (_) => const UpgradeIntroScreen(), - fullscreenDialog: true, - ), - ); - await UpgradeIntroService.markSeen(preferences); - } else if (!existingInstallation) { - // Fresh installs already have their own setup tutorial. - await UpgradeIntroService.markSeen(preferences); - } - } catch (error) { - _log.w('Could not present upgrade introduction: $error'); - } - } - - Future _checkForUpdates() async { - if (_hasCheckedUpdate) return false; - _hasCheckedUpdate = true; - - final settings = ref.read(settingsProvider); - - // The check runs even when the user disabled update prompts: versions - // that fall forceUpdateThreshold stable releases behind must update, and - // that enforcement cannot be opted out of. - final updateInfo = await UpdateChecker().checkForUpdate( - channel: settings.updateChannel, - ); - if (updateInfo == null || !mounted) return false; - - final forced = - updateInfo.releasesBehind >= UpdateChecker.forceUpdateThreshold; - if (!forced && !settings.checkForUpdates) return false; - - showUpdateDialog( - context, - updateInfo: updateInfo, - forced: forced, - onDisableUpdates: () { - ref.read(settingsProvider.notifier).setCheckForUpdates(false); - }, - ); - return true; - } - - Future _checkAppAnnouncement() async { - if (_hasCheckedAppAnnouncement) return; - _hasCheckedAppAnnouncement = true; - - final locale = Localizations.localeOf(context).toLanguageTag(); - final remoteConfigService = AppRemoteConfigService(); - final announcement = await remoteConfigService.fetchActiveAnnouncement( - locale: locale, - ); - if (announcement == null || !mounted) return; - - showAppAnnouncementDialog( - context, - announcement: announcement, - onDismiss: () { - remoteConfigService.markAnnouncementDismissed(announcement.id); - }, - ); - } - - static const _safMigrationShownKey = 'saf_migration_prompt_shown'; - - Future _checkSafMigration() async { - if (!Platform.isAndroid) return; - - final settings = ref.read(settingsProvider); - if (settings.storageMode == 'saf') return; - if (settings.downloadDirectory.isEmpty) return; - - final deviceInfo = DeviceInfoPlugin(); - final androidInfo = await deviceInfo.androidInfo; - if (androidInfo.version.sdkInt < 29) return; - - final prefs = await SharedPreferences.getInstance(); - if (prefs.getBool(_safMigrationShownKey) == true) return; - await prefs.setBool(_safMigrationShownKey, true); - - if (!mounted) return; - - final colorScheme = Theme.of(context).colorScheme; - - showAppDialog( - context: context, - barrierDismissible: false, - builder: (ctx) => AppAlertDialog( - icon: AppIcon( - Icons.folder_special_outlined, - size: 32, - color: colorScheme.primary, - ), - title: Text(context.l10n.safMigrationTitle), - content: ConstrainedBox( - // Keeps the text column readable on tablets/landscape. - constraints: const BoxConstraints(maxWidth: 400), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(context.l10n.safMigrationMessage1), - const SizedBox(height: 12), - Text(context.l10n.safMigrationMessage2), - ], - ), - ), - actions: [ - AppDialogAction( - onPressed: () => Navigator.pop(ctx), - child: Text(context.l10n.updateLater), - ), - AppDialogAction( - filled: true, - onPressed: () async { - Navigator.pop(ctx); - final result = await PlatformBridge.pickSafTree(); - if (result != null) { - final treeUri = result['tree_uri'] as String? ?? ''; - final displayName = result['display_name'] as String? ?? ''; - if (treeUri.isNotEmpty) { - ref - .read(settingsProvider.notifier) - .setDownloadTreeUri( - treeUri, - displayName: displayName.isNotEmpty - ? displayName - : treeUri, - ); - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.safMigrationSuccess)), - ); - } - } - } - }, - child: Text(context.l10n.setupSelectFolder), - ), - ], - ), - ); - } - @override void dispose() { WidgetsBinding.instance.removeObserver(this); @@ -1251,128 +990,3 @@ class _KeepAliveTabPageState extends State<_KeepAliveTabPage> return widget.child; } } - -class BouncingIcon extends StatefulWidget { - final Widget child; - const BouncingIcon({super.key, required this.child}); - - @override - State createState() => _BouncingIconState(); -} - -class _BouncingIconState extends State - with SingleTickerProviderStateMixin { - late AnimationController _controller; - late Animation _scaleAnimation; - - @override - void initState() { - super.initState(); - _controller = AnimationController( - duration: const Duration(milliseconds: 400), - vsync: this, - ); - _scaleAnimation = Tween( - begin: 0.1, - end: 1.0, - ).animate(CurvedAnimation(parent: _controller, curve: Curves.elasticOut)); - _controller.forward(); - } - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return ScaleTransition(scale: _scaleAnimation, child: widget.child); - } -} - -class SlidingIcon extends StatefulWidget { - final Widget child; - const SlidingIcon({super.key, required this.child}); - - @override - State createState() => _SlidingIconState(); -} - -class _SlidingIconState extends State - with SingleTickerProviderStateMixin { - late AnimationController _controller; - late Animation _offsetAnimation; - late Animation _fadeAnimation; - - @override - void initState() { - super.initState(); - _controller = AnimationController( - duration: const Duration(milliseconds: 350), - vsync: this, - ); - _offsetAnimation = Tween( - begin: const Offset(0, 0.5), - end: Offset.zero, - ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOutBack)); - _fadeAnimation = Tween( - begin: 0.0, - end: 1.0, - ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOut)); - _controller.forward(); - } - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return FadeTransition( - opacity: _fadeAnimation, - child: SlideTransition(position: _offsetAnimation, child: widget.child), - ); - } -} - -class SpinIcon extends StatefulWidget { - final Widget child; - const SpinIcon({super.key, required this.child}); - - @override - State createState() => _SpinIconState(); -} - -class _SpinIconState extends State - with SingleTickerProviderStateMixin { - late AnimationController _controller; - late Animation _rotationAnimation; - - @override - void initState() { - super.initState(); - _controller = AnimationController( - duration: const Duration(milliseconds: 500), - vsync: this, - ); - _rotationAnimation = Tween( - begin: 0.0, - end: 0.5, - ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOutBack)); - _controller.forward(); - } - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return RotationTransition(turns: _rotationAnimation, child: widget.child); - } -} diff --git a/lib/screens/main_shell_startup_prompts.dart b/lib/screens/main_shell_startup_prompts.dart new file mode 100644 index 00000000..7f5e77c4 --- /dev/null +++ b/lib/screens/main_shell_startup_prompts.dart @@ -0,0 +1,267 @@ +part of 'main_shell.dart'; + +extension _MainShellStartupPrompts on _MainShellState { + Future _repairSafAccessIfNeeded({bool knownLost = false}) async { + if (!Platform.isAndroid || _safRepairDialogVisible) return; + + var accessLost = knownLost; + if (!accessLost) { + final settings = ref.read(settingsProvider); + if (settings.storageMode != 'saf' || settings.downloadTreeUri.isEmpty) { + return; + } + accessLost = !await PlatformBridge.isSafTreeAccessible( + settings.downloadTreeUri, + ); + } + if (!accessLost || !mounted) return; + + _safRepairDialogVisible = true; + try { + await showAppDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) { + var isPickingFolder = false; + return StatefulBuilder( + builder: (context, setDialogState) { + return PopScope( + canPop: false, + child: AppAlertDialog( + icon: AppIcon( + Icons.folder_off_outlined, + color: Theme.of(context).colorScheme.error, + ), + title: Text(context.l10n.downloadFolderAccessLostTitle), + content: Text(context.l10n.downloadFolderAccessLostSubtitle), + actions: [ + AppDialogAction( + onPressed: isPickingFolder + ? null + : () { + final notifier = ref.read( + settingsProvider.notifier, + ); + notifier.setStorageMode('app'); + notifier.setDownloadTreeUri(''); + notifier.setDownloadDirectory(''); + Navigator.of(dialogContext).pop(); + }, + child: Text(context.l10n.storageAutomaticFolder), + ), + AppDialogAction( + filled: true, + onPressed: isPickingFolder + ? null + : () async { + setDialogState(() => isPickingFolder = true); + try { + final result = + await PlatformBridge.pickSafTree(); + if (result == null) return; + final treeUri = + result['tree_uri'] as String? ?? ''; + final displayName = + result['display_name'] as String? ?? ''; + if (treeUri.isEmpty) return; + + final notifier = ref.read( + settingsProvider.notifier, + ); + notifier.setStorageMode('saf'); + notifier.setDownloadTreeUri( + treeUri, + displayName: displayName.isNotEmpty + ? displayName + : treeUri, + ); + if (dialogContext.mounted) { + Navigator.of(dialogContext).pop(); + } + } catch (e) { + _log.w( + 'Failed to repair SAF access from startup: $e', + ); + if (context.mounted) { + showCannotOpenFileSnackBar(context, e); + } + } finally { + if (dialogContext.mounted) { + setDialogState(() => isPickingFolder = false); + } + } + }, + child: isPickingFolder + ? const AppLoadingIndicator(size: 18, strokeWidth: 2) + : Text(context.l10n.downloadFolderReselect), + ), + ], + ), + ); + }, + ); + }, + ); + } finally { + _safRepairDialogVisible = false; + } + } + + Future _checkUpgradeIntro() async { + try { + final preferences = await SharedPreferences.getInstance(); + if (!mounted) return; + final existingInstallation = !ref + .read(initialSettingsProvider) + .isFirstLaunch; + if (UpgradeIntroService.shouldShow( + preferences, + version: AppInfo.version, + existingInstallation: existingInstallation, + )) { + await Navigator.of(context, rootNavigator: true).push( + MaterialPageRoute( + builder: (_) => const UpgradeIntroScreen(), + fullscreenDialog: true, + ), + ); + await UpgradeIntroService.markSeen(preferences); + } else if (!existingInstallation) { + // Fresh installs already have their own setup tutorial. + await UpgradeIntroService.markSeen(preferences); + } + } catch (error) { + _log.w('Could not present upgrade introduction: $error'); + } + } + + Future _checkForUpdates() async { + if (_hasCheckedUpdate) return false; + _hasCheckedUpdate = true; + + final settings = ref.read(settingsProvider); + + // The check runs even when the user disabled update prompts: versions + // that fall forceUpdateThreshold stable releases behind must update, and + // that enforcement cannot be opted out of. + final updateInfo = await UpdateChecker().checkForUpdate( + channel: settings.updateChannel, + ); + if (updateInfo == null || !mounted) return false; + + final forced = + updateInfo.releasesBehind >= UpdateChecker.forceUpdateThreshold; + if (!forced && !settings.checkForUpdates) return false; + + showUpdateDialog( + context, + updateInfo: updateInfo, + forced: forced, + onDisableUpdates: () { + ref.read(settingsProvider.notifier).setCheckForUpdates(false); + }, + ); + return true; + } + + Future _checkAppAnnouncement() async { + if (_hasCheckedAppAnnouncement) return; + _hasCheckedAppAnnouncement = true; + + final locale = Localizations.localeOf(context).toLanguageTag(); + final remoteConfigService = AppRemoteConfigService(); + final announcement = await remoteConfigService.fetchActiveAnnouncement( + locale: locale, + ); + if (announcement == null || !mounted) return; + + showAppAnnouncementDialog( + context, + announcement: announcement, + onDismiss: () { + remoteConfigService.markAnnouncementDismissed(announcement.id); + }, + ); + } + + static const _safMigrationShownKey = 'saf_migration_prompt_shown'; + + Future _checkSafMigration() async { + if (!Platform.isAndroid) return; + + final settings = ref.read(settingsProvider); + if (settings.storageMode == 'saf') return; + if (settings.downloadDirectory.isEmpty) return; + + final deviceInfo = DeviceInfoPlugin(); + final androidInfo = await deviceInfo.androidInfo; + if (androidInfo.version.sdkInt < 29) return; + + final prefs = await SharedPreferences.getInstance(); + if (prefs.getBool(_safMigrationShownKey) == true) return; + await prefs.setBool(_safMigrationShownKey, true); + + if (!mounted) return; + + final colorScheme = Theme.of(context).colorScheme; + + showAppDialog( + context: context, + barrierDismissible: false, + builder: (ctx) => AppAlertDialog( + icon: AppIcon( + Icons.folder_special_outlined, + size: 32, + color: colorScheme.primary, + ), + title: Text(context.l10n.safMigrationTitle), + content: ConstrainedBox( + // Keeps the text column readable on tablets/landscape. + constraints: const BoxConstraints(maxWidth: 400), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(context.l10n.safMigrationMessage1), + const SizedBox(height: 12), + Text(context.l10n.safMigrationMessage2), + ], + ), + ), + actions: [ + AppDialogAction( + onPressed: () => Navigator.pop(ctx), + child: Text(context.l10n.updateLater), + ), + AppDialogAction( + filled: true, + onPressed: () async { + Navigator.pop(ctx); + final result = await PlatformBridge.pickSafTree(); + if (result != null) { + final treeUri = result['tree_uri'] as String? ?? ''; + final displayName = result['display_name'] as String? ?? ''; + if (treeUri.isNotEmpty) { + ref + .read(settingsProvider.notifier) + .setDownloadTreeUri( + treeUri, + displayName: displayName.isNotEmpty + ? displayName + : treeUri, + ); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.safMigrationSuccess)), + ); + } + } + } + }, + child: Text(context.l10n.setupSelectFolder), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/nav_destination_icons.dart b/lib/widgets/nav_destination_icons.dart new file mode 100644 index 00000000..e0bbc6d4 --- /dev/null +++ b/lib/widgets/nav_destination_icons.dart @@ -0,0 +1,126 @@ +import 'package:flutter/material.dart'; + +class BouncingIcon extends StatefulWidget { + final Widget child; + const BouncingIcon({super.key, required this.child}); + + @override + State createState() => _BouncingIconState(); +} + +class _BouncingIconState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _scaleAnimation; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + duration: const Duration(milliseconds: 400), + vsync: this, + ); + _scaleAnimation = Tween( + begin: 0.1, + end: 1.0, + ).animate(CurvedAnimation(parent: _controller, curve: Curves.elasticOut)); + _controller.forward(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ScaleTransition(scale: _scaleAnimation, child: widget.child); + } +} + +class SlidingIcon extends StatefulWidget { + final Widget child; + const SlidingIcon({super.key, required this.child}); + + @override + State createState() => _SlidingIconState(); +} + +class _SlidingIconState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _offsetAnimation; + late Animation _fadeAnimation; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + duration: const Duration(milliseconds: 350), + vsync: this, + ); + _offsetAnimation = Tween( + begin: const Offset(0, 0.5), + end: Offset.zero, + ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOutBack)); + _fadeAnimation = Tween( + begin: 0.0, + end: 1.0, + ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOut)); + _controller.forward(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return FadeTransition( + opacity: _fadeAnimation, + child: SlideTransition(position: _offsetAnimation, child: widget.child), + ); + } +} + +class SpinIcon extends StatefulWidget { + final Widget child; + const SpinIcon({super.key, required this.child}); + + @override + State createState() => _SpinIconState(); +} + +class _SpinIconState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _rotationAnimation; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + duration: const Duration(milliseconds: 500), + vsync: this, + ); + _rotationAnimation = Tween( + begin: 0.0, + end: 0.5, + ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOutBack)); + _controller.forward(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return RotationTransition(turns: _rotationAnimation, child: widget.child); + } +}