Files
SpotiFLAC-Mobile/lib/screens/setup_screen.dart
T
zarzet 7628800604 refactor(setup): move SharedPreferences access behind SetupProgressStore and SafMigrationPromptStore
The setup draft and the SAF migration prompt flag were read and written
directly from screens. Keys, JSON shape and defaults are unchanged.
2026-10-19 00:56:52 +07:00

811 lines
28 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:material_3_expressive/material_3_expressive.dart';
import 'package:spotiflac_android/theme/app_motion.dart';
import 'package:spotiflac_android/theme/material_expressive.dart';
import 'package:spotiflac_android/widgets/app_alert_dialog.dart';
import 'package:spotiflac_android/widgets/app_content_card.dart';
import 'package:spotiflac_android/widgets/app_icon.dart';
import 'package:spotiflac_android/widgets/app_icon_button.dart';
import 'package:spotiflac_android/widgets/app_loading_indicator.dart';
import 'package:spotiflac_android/widgets/app_progress_indicator.dart';
import 'package:spotiflac_android/widgets/expressive_button.dart';
import 'package:spotiflac_android/widgets/app_snack_bar.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:path_provider/path_provider.dart';
import 'package:go_router/go_router.dart';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:spotiflac_android/services/setup_progress_store.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/constants/language_choices.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/adaptive_layout.dart';
import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/widgets/scroll_edge_fade.dart';
import 'package:spotiflac_android/utils/logger.dart';
part 'setup_steps.dart';
final _log = AppLogger('SetupScreen');
// Height of the back-button/progress header row above the PageView
// (16 vertical padding * 2 + 48 button).
const _headerHeight = 80.0;
class SetupScreen extends ConsumerStatefulWidget {
const SetupScreen({super.key});
@override
ConsumerState<SetupScreen> createState() => _SetupScreenState();
}
class _SetupScreenState extends ConsumerState<SetupScreen>
with WidgetsBindingObserver {
final _progressStore = SetupProgressStore();
final _platform = defaultTargetPlatform;
PageController? _pageController;
final ScrollController _languageScrollController = ScrollController();
late final Future<void> _initialization;
StreamSubscription<void>? _folderSelectionSubscription;
Future<void>? _folderRecovery;
bool _initializing = true;
int _currentStep = 0;
String _selectedLocale = 'system';
bool _storagePermissionGranted = false;
bool _notificationPermissionGranted = false;
bool _notificationPermissionSkipped = false;
String? _selectedDirectory;
String? _selectedDirectoryBookmark;
String? _selectedTreeUri;
bool _isLoading = false;
int _androidSdkVersion = 0;
int get _totalSteps => _androidSdkVersion >= 33 ? 5 : 4;
void _setState(VoidCallback fn) => setState(fn);
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_initialization = _initDeviceInfo();
if (_platform == TargetPlatform.android) {
_folderSelectionSubscription = PlatformBridge.setupFolderSelectionEvents()
.listen((_) => unawaited(_refreshAfterResume()));
}
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
unawaited(_folderSelectionSubscription?.cancel());
_pageController?.dispose();
_languageScrollController.dispose();
super.dispose();
}
Future<void> _initDeviceInfo() async {
try {
await ref.read(settingsProvider.notifier).ensureLoaded();
if (_platform == TargetPlatform.android) {
final androidInfo = await DeviceInfoPlugin().androidInfo;
_androidSdkVersion = androidInfo.version.sdkInt;
}
final saved = await _progressStore.readDraft();
if (!mounted) return;
_selectedLocale = ref.read(settingsProvider).locale;
if (saved != null) {
final draft = jsonDecode(saved) as Map<String, dynamic>;
_currentStep = ((draft['step'] as int?) ?? 0).clamp(0, _totalSteps - 1);
_selectedDirectory = draft['directory'] as String?;
_selectedDirectoryBookmark = draft['bookmark'] as String?;
_selectedTreeUri = draft['treeUri'] as String?;
_notificationPermissionSkipped = draft['skipNotifications'] == true;
}
await _checkInitialPermissions();
if (_platform == TargetPlatform.android) await _restoreSelectedFolder();
} catch (e, stack) {
_log.e('Failed to restore setup progress', e, stack);
} finally {
if (mounted) {
_pageController = PageController(initialPage: _currentStep);
setState(() => _initializing = false);
}
}
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
unawaited(_refreshAfterResume());
}
}
Future<void> _refreshAfterResume() async {
await _initialization;
if (!mounted) return;
try {
await _checkInitialPermissions();
if (_platform == TargetPlatform.android) await _restoreSelectedFolder();
} catch (e, stack) {
_log.e('Failed to refresh setup permissions', e, stack);
}
}
Future<void> _saveProgress({int? step}) async {
await _progressStore.writeDraft({
'step': step ?? _currentStep,
'directory': _selectedDirectory,
'bookmark': _selectedDirectoryBookmark,
'treeUri': _selectedTreeUri,
'skipNotifications': _notificationPermissionSkipped,
});
}
Future<void> _restoreSelectedFolder() => _folderRecovery ??=
_readSelectedFolder().whenComplete(() => _folderRecovery = null);
Future<void> _readSelectedFolder() async {
final payload = await PlatformBridge.readSetupFolderSelection();
if (payload == null || !mounted) return;
final result = jsonDecode(payload) as Map<String, dynamic>;
final uri = result['tree_uri'] as String? ?? '';
if (uri.isNotEmpty) {
final name = result['display_name'] as String? ?? '';
setState(() {
_selectedTreeUri = uri;
_selectedDirectory = name.isEmpty ? uri : name;
});
await _saveProgress();
}
// Acknowledge only after the recovered selection is durable in Dart.
await PlatformBridge.acknowledgeSetupFolderSelection(payload);
if (mounted) setState(() => _isLoading = false);
}
Future<void> _checkInitialPermissions() async {
if (_platform == TargetPlatform.iOS) {
final notificationStatus = await Permission.notification.status;
if (mounted) {
setState(() {
_storagePermissionGranted = true;
_notificationPermissionGranted =
notificationStatus.isGranted || notificationStatus.isProvisional;
});
}
} else if (_platform == TargetPlatform.android) {
bool storageGranted = false;
if (_androidSdkVersion >= 33) {
final audioStatus = await Permission.audio.status;
storageGranted = audioStatus.isGranted;
} else if (_androidSdkVersion >= 30) {
final manageStatus = await Permission.manageExternalStorage.status;
storageGranted = manageStatus.isGranted;
} else {
final storageStatus = await Permission.storage.status;
storageGranted = storageStatus.isGranted;
}
PermissionStatus notificationStatus = PermissionStatus.granted;
if (_androidSdkVersion >= 33) {
notificationStatus = await Permission.notification.status;
}
if (mounted) {
setState(() {
_storagePermissionGranted = storageGranted;
_notificationPermissionGranted = notificationStatus.isGranted;
});
}
} else {
setState(() {
_storagePermissionGranted = true;
_notificationPermissionGranted = true;
});
}
}
Future<void> _requestStoragePermission() async {
if (_isLoading) return;
final permissionAudio = context.l10n.permissionAudio;
final permissionStorage = context.l10n.permissionStorage;
setState(() => _isLoading = true);
try {
await _saveProgress();
if (!mounted) return;
if (_platform == TargetPlatform.iOS) {
setState(() => _storagePermissionGranted = true);
} else if (_platform == TargetPlatform.android) {
bool allGranted = false;
if (_androidSdkVersion >= 33) {
var audioStatus = await Permission.audio.status;
if (!audioStatus.isGranted) {
audioStatus = await Permission.audio.request();
}
allGranted = audioStatus.isGranted;
if (audioStatus.isPermanentlyDenied) {
await _showPermissionDeniedDialog(permissionAudio);
return;
}
} else if (_androidSdkVersion >= 30) {
var manageStatus = await Permission.manageExternalStorage.status;
if (!manageStatus.isGranted) {
final shouldOpen = await _showAndroid11StorageDialog();
if (shouldOpen == true) {
await Permission.manageExternalStorage.request();
await Future<void>.delayed(const Duration(milliseconds: 500));
manageStatus = await Permission.manageExternalStorage.status;
}
}
allGranted = manageStatus.isGranted;
} else {
final status = await Permission.storage.request();
allGranted = status.isGranted;
if (status.isPermanentlyDenied) {
await _showPermissionDeniedDialog(permissionStorage);
return;
}
}
if (!mounted) return;
setState(() => _storagePermissionGranted = allGranted);
if (!allGranted && mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.setupPermissionDeniedMessage)),
);
}
} else {
setState(() => _storagePermissionGranted = true);
}
} catch (e) {
debugPrint('Permission error: $e');
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
Future<bool?> _showAndroid11StorageDialog() {
return showAppDialog<bool>(
context: context,
builder: (context) => AppAlertDialog(
title: Text(context.l10n.setupStorageAccessRequired),
content: Text(
'${context.l10n.setupStorageAccessMessageAndroid11}\n\n'
'${context.l10n.setupAllowAccessToManageFiles}',
),
actions: [
AppDialogAction(
onPressed: () => Navigator.pop(context, false),
child: Text(context.l10n.dialogCancel),
),
AppDialogAction(
filled: true,
onPressed: () => Navigator.pop(context, true),
child: Text(context.l10n.setupOpenSettings),
),
],
),
);
}
Future<void> _requestNotificationPermission() async {
if (_isLoading) return;
final permissionNotification = context.l10n.permissionNotification;
setState(() => _isLoading = true);
try {
await _saveProgress();
if (!mounted) return;
if (_platform == TargetPlatform.iOS) {
final status = await Permission.notification.request();
if (!mounted) return;
if (status.isGranted || status.isProvisional) {
setState(() => _notificationPermissionGranted = true);
} else if (status.isPermanentlyDenied) {
await _showPermissionDeniedDialog(permissionNotification);
}
} else if (_androidSdkVersion >= 33) {
final status = await Permission.notification.request();
if (!mounted) return;
if (status.isGranted) {
setState(() => _notificationPermissionGranted = true);
} else if (status.isPermanentlyDenied) {
await _showPermissionDeniedDialog(permissionNotification);
}
} else {
setState(() => _notificationPermissionGranted = true);
}
} catch (e, stack) {
_log.e('Failed to request notification permission', e, stack);
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
Future<void> _showPermissionDeniedDialog(String permissionType) async {
await showAppDialog<void>(
context: context,
builder: (context) => AppAlertDialog(
title: Text(context.l10n.setupPermissionRequired(permissionType)),
content: Text(
context.l10n.setupPermissionRequiredMessage(permissionType),
),
actions: [
AppDialogAction(
onPressed: () => Navigator.pop(context),
child: Text(context.l10n.dialogCancel),
),
AppDialogAction(
filled: true,
onPressed: () {
Navigator.pop(context);
openAppSettings();
},
child: Text(context.l10n.setupOpenSettings),
),
],
),
);
}
Future<void> _selectDirectory() async {
if (_isLoading) return;
setState(() => _isLoading = true);
try {
// Persist before leaving Flutter: the OS may destroy this screen while
// the external picker is visible, including on Android 13 devices.
await _saveProgress();
if (!mounted) return;
if (_platform == TargetPlatform.iOS) {
await _showIOSDirectoryOptions();
} else if (_platform == TargetPlatform.android) {
Map<String, dynamic>? result;
try {
result = await PlatformBridge.pickSafTree(forSetup: true);
} catch (e) {
_log.w('Failed to open Android SAF picker: $e');
if (mounted) showCannotOpenFileSnackBar(context, e);
}
if (!mounted) return;
if (result != null) {
final treeUri = result['tree_uri'] as String? ?? '';
final displayName = result['display_name'] as String? ?? '';
if (treeUri.isNotEmpty) {
setState(() {
_selectedTreeUri = treeUri;
_selectedDirectory = displayName.isNotEmpty
? displayName
: treeUri;
});
await _saveProgress();
}
}
await _restoreSelectedFolder();
if (!mounted) return;
// Android fallback if user cancelled SAF picker
if (_selectedTreeUri == null || _selectedTreeUri!.isEmpty) {
final defaultDir = await _getDefaultDirectory();
if (mounted) {
final useDefault = await showAppDialog<bool>(
context: context,
builder: (context) => AppAlertDialog(
title: Text(context.l10n.setupUseDefaultFolder),
content: Text(
'${context.l10n.setupNoFolderSelected}\n\n$defaultDir',
),
actions: [
AppDialogAction(
onPressed: () => Navigator.pop(context, false),
child: Text(context.l10n.dialogCancel),
),
AppDialogAction(
filled: true,
onPressed: () => Navigator.pop(context, true),
child: Text(context.l10n.setupUseDefault),
),
],
),
);
if (useDefault == true) {
if (!mounted) return;
setState(() {
_selectedTreeUri = '';
_selectedDirectory = defaultDir;
});
await _saveProgress();
}
}
}
}
} catch (e, stack) {
_log.e('Failed to select setup folder', e, stack);
if (mounted) showCannotOpenFileSnackBar(context, e);
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
Future<void> _showIOSDirectoryOptions() async {
final colorScheme = Theme.of(context).colorScheme;
await showModalBottomSheet<void>(
context: context,
sheetAnimationStyle: AppMotion.surfaceEntrance(context),
useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh,
builder: (ctx) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
child: Text(
context.l10n.setupDownloadLocationTitle,
style: Theme.of(context).textTheme.titleLarge,
),
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
child: Text(
context.l10n.setupDownloadLocationIosMessage,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
ListTile(
leading: Icon(Icons.folder_special, color: colorScheme.primary),
title: Text(context.l10n.setupAppDocumentsFolder),
onTap: () async {
final dir = await _getDefaultDirectory();
if (!mounted) return;
setState(() {
_selectedDirectory = dir;
_selectedDirectoryBookmark = null;
});
if (ctx.mounted) Navigator.pop(ctx);
await _saveProgress();
},
),
ListTile(
leading: Icon(Icons.cloud, color: colorScheme.onSurfaceVariant),
title: Text(context.l10n.setupChooseFromFiles),
onTap: () async {
Navigator.pop(ctx);
await Future<void>.delayed(const Duration(milliseconds: 250));
IosPickedDirectory? picked;
try {
picked = await PlatformBridge.pickIosDirectory();
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.l10n.snackbarFolderPickerFailed(
context.friendlyError(e),
),
),
backgroundColor: Theme.of(context).colorScheme.error,
duration: const Duration(seconds: 4),
),
);
}
return;
}
if (picked == null) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.setupNoFolderSelected),
),
);
}
return;
}
final validation = validateIosPath(
picked.path,
bookmark: picked.bookmark,
);
if (!validation.isValid) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
validation.errorReason ??
context.l10n.errorInvalidFolderSelected,
),
backgroundColor: Theme.of(context).colorScheme.error,
duration: const Duration(seconds: 4),
),
);
}
return;
}
final pickedDir = picked;
if (!mounted) return;
setState(() {
_selectedDirectory = pickedDir.path;
_selectedDirectoryBookmark = pickedDir.bookmark;
});
await _saveProgress();
},
),
const SizedBox(height: 16),
],
),
),
);
}
Future<String> _getDefaultDirectory() async {
if (_platform == TargetPlatform.android) {
final downloadDir = Directory('/storage/emulated/0/Download/SpotiFLAC');
try {
if (!await downloadDir.exists()) {
await downloadDir.create(recursive: true);
}
return downloadDir.path;
} catch (e) {
debugPrint('Cannot create Download folder: $e');
}
}
final appDir = await getApplicationDocumentsDirectory();
return '${appDir.path}/SpotiFLAC';
}
Future<void> _completeSetup() async {
if (_selectedDirectory == null) return;
setState(() => _isLoading = true);
try {
if (_platform != TargetPlatform.android ||
_selectedTreeUri == null ||
_selectedTreeUri!.isEmpty) {
final iosBookmark = _platform == TargetPlatform.iOS
? _selectedDirectoryBookmark
: null;
if (iosBookmark == null || iosBookmark.isEmpty) {
final dir = Directory(_selectedDirectory!);
if (!await dir.exists()) {
await dir.create(recursive: true);
}
}
ref.read(settingsProvider.notifier).setStorageMode('app');
ref
.read(settingsProvider.notifier)
.setDownloadDirectory(
_selectedDirectory!,
iosBookmark: iosBookmark,
);
ref.read(settingsProvider.notifier).setDownloadTreeUri('');
} else {
ref.read(settingsProvider.notifier).setStorageMode('saf');
ref
.read(settingsProvider.notifier)
.setDownloadTreeUri(
_selectedTreeUri!,
displayName: _selectedDirectory,
);
}
await ref.read(settingsProvider.notifier).setFirstLaunchComplete();
await _progressStore.clear();
if (mounted) context.go('/tutorial');
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.snackbarError(context.friendlyError(e))),
),
);
}
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
void _nextPage() {
bool canProceed = false;
if (_currentStep == 0) {
canProceed = true;
} else {
canProceed = _isStepCompleted(_currentStep);
}
if (canProceed) {
unawaited(_goToStep(_currentStep + 1));
}
}
void _prevPage() {
unawaited(_goToStep(_currentStep - 1));
}
Future<void> _goToStep(int step) async {
if (_isLoading) return;
setState(() => _isLoading = true);
try {
await _saveProgress(step: step);
if (!mounted) return;
setState(() => _currentStep = step);
await _pageController?.animateToPage(
step,
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
} catch (e, stack) {
_log.e('Failed to save setup step', e, stack);
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
bool _isStepCompleted(int step) {
if (step == 0) return true;
if (step == 1) return true;
final logicStep = step - 2;
if (_androidSdkVersion >= 33) {
switch (logicStep) {
case 0:
return _storagePermissionGranted;
case 1:
return _notificationPermissionGranted ||
_notificationPermissionSkipped;
case 2:
return _selectedDirectory != null;
}
} else {
switch (logicStep) {
case 0:
return _storagePermissionGranted;
case 1:
return _selectedDirectory != null;
}
}
return false;
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
if (_initializing) {
return const Scaffold(body: Center(child: AppLoadingIndicator()));
}
final progress = (_currentStep + 1) / _totalSteps;
final stepMotion = AppMotion.spatial(
context,
const AppMotionSpec(Duration.zero, Curves.linear),
);
return Scaffold(
backgroundColor: colorScheme.surface,
body: SafeArea(
child: Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
child: Row(
children: [
if (_currentStep > 0)
AppIconButton.filledTonal(
onPressed: _prevPage,
tooltip: MaterialLocalizations.of(
context,
).backButtonTooltip,
icon: const Icon(Icons.arrow_back),
style: IconButton.styleFrom(
backgroundColor: colorScheme.surfaceContainerHighest,
foregroundColor: colorScheme.onSurfaceVariant,
),
)
else
const SizedBox(width: 48),
const Spacer(),
SizedBox(
width: 48,
height: 48,
child: Stack(
fit: StackFit.expand,
children: [
// Expressive steps fill on a spring around a wavy
// ring; the fallback jumps straight to the step.
TweenAnimationBuilder<double>(
tween: Tween(end: progress),
duration: stepMotion.duration,
curve: stepMotion.curve,
builder: (context, value, _) =>
AppProgressIndicator.circular(
wavy: false,
value: value,
strokeWidth: 4,
trackColor: colorScheme.surfaceContainerHighest,
color: colorScheme.primary,
strokeCap: StrokeCap.round,
),
),
Center(
child: Text(
'${_currentStep + 1}/$_totalSteps',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: colorScheme.onSurfaceVariant,
),
),
),
],
),
),
],
),
),
Expanded(
child: PageView(
controller: _pageController,
physics: const NeverScrollableScrollPhysics(),
children: [
_buildWelcomeStep(colorScheme),
_buildLanguageStep(colorScheme),
_buildStorageStep(colorScheme),
if (_androidSdkVersion >= 33)
_buildNotificationStep(colorScheme),
_buildDirectoryStep(colorScheme),
],
),
),
],
),
),
floatingActionButton: materialExpressiveEnabled(context)
? _buildExpressiveFab(colorScheme)
: _currentStep < _totalSteps - 1
? FloatingActionButton.extended(
onPressed: !_isLoading && _isStepCompleted(_currentStep)
? _nextPage
: null,
label: Row(
children: [
Text(context.l10n.setupNext),
const SizedBox(width: 8),
const Icon(Icons.arrow_forward),
],
),
icon: const SizedBox.shrink(), // Custom layout
)
: FloatingActionButton.extended(
onPressed: _isLoading ? null : _completeSetup,
label: _isLoading
? SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
color: colorScheme.onPrimary,
),
)
: Text(context.l10n.setupGetStarted),
icon: const Icon(Icons.check),
backgroundColor: colorScheme.primary,
foregroundColor: colorScheme.onPrimary,
),
);
}
}