mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-21 15:02:21 +02:00
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:
1 parent
12ff09907e
commit
b64b08c4d0
3 files changed
+396
-389
No files matched your search
+3
-389
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user