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.
This commit is contained in:
zarzet committed 2026-10-18 22:42:38 +07:00
1 parent 12ff09907e
commit b64b08c4d0
3 files changed
+396 -389

No files matched your search

+3 -389
View File
@@ -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<MainShell>
}
}
Future<void> _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<void>(
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<MainShell>
}
}
Future<void> _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<void>(
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<bool> _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<void> _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<void> _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<void>(
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<BouncingIcon> createState() => _BouncingIconState();
}
class _BouncingIconState extends State<BouncingIcon>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _scaleAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 400),
vsync: this,
);
_scaleAnimation = Tween<double>(
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<SlidingIcon> createState() => _SlidingIconState();
}
class _SlidingIconState extends State<SlidingIcon>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<Offset> _offsetAnimation;
late Animation<double> _fadeAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 350),
vsync: this,
);
_offsetAnimation = Tween<Offset>(
begin: const Offset(0, 0.5),
end: Offset.zero,
).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOutBack));
_fadeAnimation = Tween<double>(
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<SpinIcon> createState() => _SpinIconState();
}
class _SpinIconState extends State<SpinIcon>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _rotationAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 500),
vsync: this,
);
_rotationAnimation = Tween<double>(
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);
}
}
+267
View File
@@ -0,0 +1,267 @@
part of 'main_shell.dart';
extension _MainShellStartupPrompts on _MainShellState {
Future<void> _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<void>(
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<void> _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<void>(
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<bool> _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<void> _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<void> _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<void>(
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),
),
],
),
);
}
}
+126
View File
@@ -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<BouncingIcon> createState() => _BouncingIconState();
}
class _BouncingIconState extends State<BouncingIcon>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _scaleAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 400),
vsync: this,
);
_scaleAnimation = Tween<double>(
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<SlidingIcon> createState() => _SlidingIconState();
}
class _SlidingIconState extends State<SlidingIcon>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<Offset> _offsetAnimation;
late Animation<double> _fadeAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 350),
vsync: this,
);
_offsetAnimation = Tween<Offset>(
begin: const Offset(0, 0.5),
end: Offset.zero,
).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOutBack));
_fadeAnimation = Tween<double>(
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<SpinIcon> createState() => _SpinIconState();
}
class _SpinIconState extends State<SpinIcon>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _rotationAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 500),
vsync: this,
);
_rotationAnimation = Tween<double>(
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);
}
}