mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-11 23:06:40 +02:00
16ce6089fb
Remove Tidal from built-in provider registry (metadata, search, download, URL parsing) and delete tidal.go. Introduce extension runtime APIs for lyrics lookup (getLyricsLRC), ISRC existence check (checkISRCExists), and ISRC index management (addToISRCIndex). Refactor extension download response construction into normalizeExtensionDownloadResult/overlayExtensionDownloadMetadata helpers with AlreadyExists support and ISRC indexing. Switch download mirrors to DoRequestWithUserAgent for ISP blocking detection. Add 50+ new localization keys and accessibility labels across all supported locales.
883 lines
29 KiB
Dart
883 lines
29 KiB
Dart
import 'dart:io';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:permission_handler/permission_handler.dart';
|
|
import 'package:file_picker/file_picker.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/providers/settings_provider.dart';
|
|
import 'package:spotiflac_android/l10n/l10n.dart';
|
|
import 'package:spotiflac_android/services/platform_bridge.dart';
|
|
import 'package:spotiflac_android/utils/file_access.dart';
|
|
import 'package:spotiflac_android/utils/logger.dart';
|
|
|
|
final _log = AppLogger('SetupScreen');
|
|
|
|
class SetupScreen extends ConsumerStatefulWidget {
|
|
const SetupScreen({super.key});
|
|
|
|
@override
|
|
ConsumerState<SetupScreen> createState() => _SetupScreenState();
|
|
}
|
|
|
|
class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|
final PageController _pageController = PageController();
|
|
int _currentStep = 0;
|
|
|
|
bool _storagePermissionGranted = false;
|
|
bool _notificationPermissionGranted = false;
|
|
String? _selectedDirectory;
|
|
String? _selectedTreeUri;
|
|
bool _isLoading = false;
|
|
int _androidSdkVersion = 0;
|
|
|
|
int get _totalSteps => _androidSdkVersion >= 33 ? 4 : 3;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_initDeviceInfo();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_pageController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _initDeviceInfo() async {
|
|
if (Platform.isAndroid) {
|
|
final deviceInfo = DeviceInfoPlugin();
|
|
final androidInfo = await deviceInfo.androidInfo;
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_androidSdkVersion = androidInfo.version.sdkInt;
|
|
});
|
|
}
|
|
if (!mounted) return;
|
|
await _checkInitialPermissions();
|
|
}
|
|
|
|
Future<void> _checkInitialPermissions() async {
|
|
if (Platform.isIOS) {
|
|
final notificationStatus = await Permission.notification.status;
|
|
if (mounted) {
|
|
setState(() {
|
|
_storagePermissionGranted = true;
|
|
_notificationPermissionGranted =
|
|
notificationStatus.isGranted || notificationStatus.isProvisional;
|
|
});
|
|
}
|
|
} else if (Platform.isAndroid) {
|
|
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 {
|
|
setState(() => _isLoading = true);
|
|
try {
|
|
if (Platform.isIOS) {
|
|
setState(() => _storagePermissionGranted = true);
|
|
} else if (Platform.isAndroid) {
|
|
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('Audio');
|
|
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('Storage');
|
|
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 {
|
|
setState(() => _isLoading = false);
|
|
}
|
|
}
|
|
|
|
Future<bool?> _showAndroid11StorageDialog() {
|
|
return showDialog<bool>(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
title: Text(context.l10n.setupStorageAccessRequired),
|
|
content: Text(
|
|
'${context.l10n.setupStorageAccessMessageAndroid11}\n\n'
|
|
'${context.l10n.setupAllowAccessToManageFiles}',
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context, false),
|
|
child: Text(context.l10n.dialogCancel),
|
|
),
|
|
FilledButton(
|
|
onPressed: () => Navigator.pop(context, true),
|
|
child: Text(context.l10n.setupOpenSettings),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _requestNotificationPermission() async {
|
|
setState(() => _isLoading = true);
|
|
try {
|
|
if (Platform.isIOS) {
|
|
final status = await Permission.notification.request();
|
|
if (status.isGranted || status.isProvisional) {
|
|
setState(() => _notificationPermissionGranted = true);
|
|
} else if (status.isPermanentlyDenied) {
|
|
await _showPermissionDeniedDialog('Notification');
|
|
}
|
|
} else if (_androidSdkVersion >= 33) {
|
|
final status = await Permission.notification.request();
|
|
if (status.isGranted) {
|
|
setState(() => _notificationPermissionGranted = true);
|
|
} else if (status.isPermanentlyDenied) {
|
|
await _showPermissionDeniedDialog('Notification');
|
|
}
|
|
} else {
|
|
setState(() => _notificationPermissionGranted = true);
|
|
}
|
|
} finally {
|
|
setState(() => _isLoading = false);
|
|
}
|
|
}
|
|
|
|
Future<void> _showPermissionDeniedDialog(String permissionType) async {
|
|
await showDialog<void>(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
title: Text(context.l10n.setupPermissionRequired(permissionType)),
|
|
content: Text(
|
|
context.l10n.setupPermissionRequiredMessage(permissionType),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context),
|
|
child: Text(context.l10n.dialogCancel),
|
|
),
|
|
TextButton(
|
|
onPressed: () {
|
|
Navigator.pop(context);
|
|
openAppSettings();
|
|
},
|
|
child: Text(context.l10n.setupOpenSettings),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _selectDirectory() async {
|
|
setState(() => _isLoading = true);
|
|
try {
|
|
if (Platform.isIOS) {
|
|
await _showIOSDirectoryOptions();
|
|
} else if (Platform.isAndroid) {
|
|
Map<String, dynamic>? result;
|
|
try {
|
|
result = await PlatformBridge.pickSafTree();
|
|
} catch (e) {
|
|
_log.w('Failed to open Android SAF picker: $e');
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(
|
|
context.l10n.snackbarCannotOpenFile(e.toString()),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
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;
|
|
});
|
|
}
|
|
}
|
|
|
|
// Android fallback if user cancelled SAF picker
|
|
if (_selectedTreeUri == null || _selectedTreeUri!.isEmpty) {
|
|
final defaultDir = await _getDefaultDirectory();
|
|
if (mounted) {
|
|
final useDefault = await showDialog<bool>(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
title: Text(context.l10n.setupUseDefaultFolder),
|
|
content: Text(
|
|
'${context.l10n.setupNoFolderSelected}\n\n$defaultDir',
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context, false),
|
|
child: Text(context.l10n.dialogCancel),
|
|
),
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context, true),
|
|
child: Text(context.l10n.setupUseDefault),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
if (useDefault == true) {
|
|
setState(() {
|
|
_selectedTreeUri = '';
|
|
_selectedDirectory = defaultDir;
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} finally {
|
|
setState(() => _isLoading = false);
|
|
}
|
|
}
|
|
|
|
Future<void> _showIOSDirectoryOptions() async {
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
await showModalBottomSheet<void>(
|
|
context: context,
|
|
useRootNavigator: true,
|
|
backgroundColor: colorScheme.surfaceContainerHigh,
|
|
shape: const RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
|
),
|
|
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();
|
|
setState(() => _selectedDirectory = dir);
|
|
if (ctx.mounted) Navigator.pop(ctx);
|
|
},
|
|
),
|
|
ListTile(
|
|
leading: Icon(Icons.cloud, color: colorScheme.onSurfaceVariant),
|
|
title: Text(context.l10n.setupChooseFromFiles),
|
|
onTap: () async {
|
|
Navigator.pop(ctx);
|
|
if (Platform.isIOS) {
|
|
await Future<void>.delayed(const Duration(milliseconds: 250));
|
|
}
|
|
|
|
String? result;
|
|
try {
|
|
result = await FilePicker.platform.getDirectoryPath();
|
|
} catch (e) {
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(
|
|
context.l10n.snackbarFolderPickerFailed(e.toString()),
|
|
),
|
|
backgroundColor: Theme.of(context).colorScheme.error,
|
|
duration: const Duration(seconds: 4),
|
|
),
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (result != null) {
|
|
// iOS: Validate the selected path is writable
|
|
if (Platform.isIOS) {
|
|
final validation = validateIosPath(result);
|
|
if (!validation.isValid) {
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(
|
|
validation.errorReason ??
|
|
'Invalid folder selected',
|
|
),
|
|
backgroundColor: Theme.of(
|
|
context,
|
|
).colorScheme.error,
|
|
duration: const Duration(seconds: 4),
|
|
),
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
setState(() => _selectedDirectory = result);
|
|
}
|
|
},
|
|
),
|
|
const SizedBox(height: 16),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<String> _getDefaultDirectory() async {
|
|
if (Platform.isAndroid) {
|
|
final musicDir = Directory('/storage/emulated/0/Music/SpotiFLAC');
|
|
try {
|
|
if (!await musicDir.exists()) {
|
|
await musicDir.create(recursive: true);
|
|
}
|
|
return musicDir.path;
|
|
} catch (e) {
|
|
debugPrint('Cannot create Music folder: $e');
|
|
}
|
|
}
|
|
final appDir = await getApplicationDocumentsDirectory();
|
|
return '${appDir.path}/SpotiFLAC';
|
|
}
|
|
|
|
Future<void> _completeSetup() async {
|
|
if (_selectedDirectory == null) return;
|
|
setState(() => _isLoading = true);
|
|
|
|
try {
|
|
if (!Platform.isAndroid ||
|
|
_selectedTreeUri == null ||
|
|
_selectedTreeUri!.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!);
|
|
ref.read(settingsProvider.notifier).setDownloadTreeUri('');
|
|
} else {
|
|
ref.read(settingsProvider.notifier).setStorageMode('saf');
|
|
ref
|
|
.read(settingsProvider.notifier)
|
|
.setDownloadTreeUri(
|
|
_selectedTreeUri!,
|
|
displayName: _selectedDirectory,
|
|
);
|
|
}
|
|
|
|
ref.read(settingsProvider.notifier).setFirstLaunchComplete();
|
|
|
|
if (mounted) context.go('/tutorial');
|
|
} catch (e) {
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text(context.l10n.snackbarError(e.toString()))),
|
|
);
|
|
}
|
|
} finally {
|
|
setState(() => _isLoading = false);
|
|
}
|
|
}
|
|
|
|
void _nextPage() {
|
|
bool canProceed = false;
|
|
if (_currentStep == 0) {
|
|
canProceed = true;
|
|
} else {
|
|
canProceed = _isStepCompleted(_currentStep);
|
|
}
|
|
|
|
if (canProceed) {
|
|
_pageController.nextPage(
|
|
duration: const Duration(milliseconds: 300),
|
|
curve: Curves.easeInOut,
|
|
);
|
|
setState(() => _currentStep++);
|
|
}
|
|
}
|
|
|
|
void _prevPage() {
|
|
_pageController.previousPage(
|
|
duration: const Duration(milliseconds: 300),
|
|
curve: Curves.easeInOut,
|
|
);
|
|
setState(() => _currentStep--);
|
|
}
|
|
|
|
bool _isStepCompleted(int step) {
|
|
if (step == 0) return true;
|
|
|
|
final logicStep = step - 1;
|
|
|
|
if (_androidSdkVersion >= 33) {
|
|
switch (logicStep) {
|
|
case 0:
|
|
return _storagePermissionGranted;
|
|
case 1:
|
|
return _notificationPermissionGranted;
|
|
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;
|
|
|
|
final progress = (_currentStep + 1) / _totalSteps;
|
|
|
|
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)
|
|
IconButton.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: [
|
|
CircularProgressIndicator(
|
|
value: progress,
|
|
strokeWidth: 4,
|
|
backgroundColor: 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),
|
|
_buildStorageStep(colorScheme),
|
|
if (_androidSdkVersion >= 33)
|
|
_buildNotificationStep(colorScheme),
|
|
_buildDirectoryStep(colorScheme),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
floatingActionButton: _currentStep < _totalSteps - 1
|
|
? FloatingActionButton.extended(
|
|
onPressed: _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,
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildWelcomeStep(ColorScheme colorScheme) {
|
|
return LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
final shortestSide = MediaQuery.sizeOf(context).shortestSide;
|
|
final textScale = MediaQuery.textScalerOf(
|
|
context,
|
|
).scale(1.0).clamp(1.0, 1.4);
|
|
final logoSize = (shortestSide * 0.24).clamp(80.0, 104.0);
|
|
final titleGap = (shortestSide * 0.06).clamp(16.0, 32.0);
|
|
final subtitleGap = (shortestSide * 0.04).clamp(8.0, 16.0);
|
|
final minContentHeight = constraints.maxHeight > 48
|
|
? constraints.maxHeight - 48
|
|
: 0.0;
|
|
|
|
return SingleChildScrollView(
|
|
padding: const EdgeInsets.all(24),
|
|
child: ConstrainedBox(
|
|
constraints: BoxConstraints(minHeight: minContentHeight),
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Image.asset(
|
|
'assets/images/logo-transparant.png',
|
|
width: logoSize,
|
|
height: logoSize,
|
|
color: colorScheme.primary,
|
|
fit: BoxFit.contain,
|
|
),
|
|
SizedBox(height: titleGap),
|
|
Text(
|
|
context.l10n.appName,
|
|
textAlign: TextAlign.center,
|
|
style: Theme.of(context).textTheme.displaySmall?.copyWith(
|
|
fontWeight: FontWeight.bold,
|
|
color: colorScheme.onSurface,
|
|
fontSize:
|
|
(Theme.of(context).textTheme.displaySmall?.fontSize ??
|
|
36) *
|
|
(1 + ((textScale - 1) * 0.18)),
|
|
),
|
|
),
|
|
SizedBox(height: subtitleGap),
|
|
Text(
|
|
context.l10n.setupDownloadInFlac,
|
|
textAlign: TextAlign.center,
|
|
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
|
color: colorScheme.onSurfaceVariant,
|
|
height: 1.5,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _buildStorageStep(ColorScheme colorScheme) {
|
|
return _StepLayout(
|
|
title: context.l10n.setupStorageRequired,
|
|
description: context.l10n.setupStorageDescription,
|
|
icon: Icons.folder,
|
|
child: _storagePermissionGranted
|
|
? _SuccessCard(
|
|
text: context.l10n.setupStorageGranted,
|
|
colorScheme: colorScheme,
|
|
)
|
|
: FilledButton.tonalIcon(
|
|
onPressed: _requestStoragePermission,
|
|
icon: const Icon(Icons.folder_open),
|
|
label: Text(context.l10n.setupGrantPermission),
|
|
style: FilledButton.styleFrom(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 24,
|
|
vertical: 16,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildNotificationStep(ColorScheme colorScheme) {
|
|
return _StepLayout(
|
|
title: context.l10n.setupNotificationEnable,
|
|
description: context.l10n.setupNotificationBackgroundDescription,
|
|
icon: Icons.notifications,
|
|
child: _notificationPermissionGranted
|
|
? _SuccessCard(
|
|
text: context.l10n.setupNotificationGranted,
|
|
colorScheme: colorScheme,
|
|
)
|
|
: Column(
|
|
children: [
|
|
FilledButton.tonalIcon(
|
|
onPressed: _requestNotificationPermission,
|
|
icon: const Icon(Icons.notifications_active),
|
|
label: Text(context.l10n.setupEnableNotifications),
|
|
style: FilledButton.styleFrom(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 24,
|
|
vertical: 16,
|
|
),
|
|
),
|
|
),
|
|
TextButton(
|
|
onPressed: () =>
|
|
setState(() => _notificationPermissionGranted = true),
|
|
child: Text(context.l10n.setupSkipForNow),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildDirectoryStep(ColorScheme colorScheme) {
|
|
return _StepLayout(
|
|
title: context.l10n.setupFolderChoose,
|
|
description: context.l10n.setupFolderDescription,
|
|
icon: Icons.create_new_folder,
|
|
child: Column(
|
|
children: [
|
|
if (_selectedDirectory != null)
|
|
Card(
|
|
color: colorScheme.secondaryContainer,
|
|
child: ListTile(
|
|
leading: Icon(
|
|
Icons.folder,
|
|
color: colorScheme.onSecondaryContainer,
|
|
),
|
|
title: Text(
|
|
_selectedDirectory!,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
trailing: IconButton(
|
|
tooltip: context.l10n.setupChangeFolderTooltip,
|
|
icon: const Icon(Icons.edit),
|
|
onPressed: _selectDirectory,
|
|
),
|
|
),
|
|
)
|
|
else
|
|
FilledButton.tonalIcon(
|
|
onPressed: _selectDirectory,
|
|
icon: const Icon(Icons.create_new_folder),
|
|
label: Text(context.l10n.setupSelectFolder),
|
|
style: FilledButton.styleFrom(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 24,
|
|
vertical: 16,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _StepLayout extends StatelessWidget {
|
|
final String title;
|
|
final String description;
|
|
final IconData icon;
|
|
final Widget child;
|
|
|
|
const _StepLayout({
|
|
required this.title,
|
|
required this.description,
|
|
required this.icon,
|
|
required this.child,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
return LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
final shortestSide = MediaQuery.sizeOf(context).shortestSide;
|
|
final iconPadding = (shortestSide * 0.06).clamp(16.0, 24.0);
|
|
final iconSize = (shortestSide * 0.12).clamp(32.0, 48.0);
|
|
final titleGap = (shortestSide * 0.06).clamp(16.0, 32.0);
|
|
final descriptionGap = (shortestSide * 0.04).clamp(8.0, 16.0);
|
|
final actionGap = (shortestSide * 0.09).clamp(20.0, 48.0);
|
|
final minContentHeight = constraints.maxHeight > 48
|
|
? constraints.maxHeight - 48
|
|
: 0.0;
|
|
|
|
return SingleChildScrollView(
|
|
padding: const EdgeInsets.all(24),
|
|
child: ConstrainedBox(
|
|
constraints: BoxConstraints(minHeight: minContentHeight),
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Container(
|
|
padding: EdgeInsets.all(iconPadding),
|
|
decoration: BoxDecoration(
|
|
color: colorScheme.surfaceContainerHighest,
|
|
shape: BoxShape.circle,
|
|
),
|
|
child: Icon(icon, size: iconSize, color: colorScheme.primary),
|
|
),
|
|
SizedBox(height: titleGap),
|
|
Text(
|
|
title,
|
|
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
|
fontWeight: FontWeight.bold,
|
|
color: colorScheme.onSurface,
|
|
),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
SizedBox(height: descriptionGap),
|
|
Text(
|
|
description,
|
|
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
|
color: colorScheme.onSurfaceVariant,
|
|
height: 1.5,
|
|
),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
SizedBox(height: actionGap),
|
|
child,
|
|
],
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SuccessCard extends StatelessWidget {
|
|
final String text;
|
|
final ColorScheme colorScheme;
|
|
|
|
const _SuccessCard({required this.text, required this.colorScheme});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
width: double.infinity,
|
|
padding: const EdgeInsets.all(16),
|
|
decoration: BoxDecoration(
|
|
color: colorScheme.primaryContainer,
|
|
borderRadius: BorderRadius.circular(16),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Icon(Icons.check_circle, color: colorScheme.onPrimaryContainer),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Text(
|
|
text,
|
|
style: TextStyle(
|
|
fontWeight: FontWeight.bold,
|
|
color: colorScheme.onPrimaryContainer,
|
|
),
|
|
maxLines: 3,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|