mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-20 22:41:38 +02:00
refactor(setup): move the setup step builders into a part
The setup screen State mixed permission and folder logic with the step builders. Move the builders and the step widgets into a part as an extension on the State; the two setState calls in moved code go through a _setState forwarder.
This commit is contained in:
1 parent
e9834f060c
commit
12ff09907e
2 files changed
+502
-495
No files matched your search
@@ -29,6 +29,8 @@ 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
|
||||
@@ -67,6 +69,8 @@ class _SetupScreenState extends ConsumerState<SetupScreen>
|
||||
|
||||
int get _totalSteps => _androidSdkVersion >= 33 ? 5 : 4;
|
||||
|
||||
void _setState(VoidCallback fn) => setState(fn);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -808,499 +812,4 @@ class _SetupScreenState extends ConsumerState<SetupScreen>
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Expressive FABs keep their icon leading and show loading in its place.
|
||||
Widget _buildExpressiveFab(ColorScheme colorScheme) {
|
||||
final isLastStep = _currentStep >= _totalSteps - 1;
|
||||
return MaterialExpressiveScope(
|
||||
child: isLastStep
|
||||
? M3EExtendedFab(
|
||||
label: context.l10n.setupGetStarted,
|
||||
icon: _isLoading
|
||||
? AppLoadingIndicator(size: 24, color: colorScheme.onPrimary)
|
||||
: const AppIcon(Icons.check),
|
||||
color: M3EFabColor.primaryFilled,
|
||||
onPressed: _isLoading ? null : _completeSetup,
|
||||
)
|
||||
: M3EExtendedFab(
|
||||
label: context.l10n.setupNext,
|
||||
icon: const AppIcon(Icons.arrow_forward),
|
||||
onPressed: !_isLoading && _isStepCompleted(_currentStep)
|
||||
? _nextPage
|
||||
: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
const totalPadding = 48.0 + _headerHeight;
|
||||
final minContentHeight = constraints.maxHeight > totalPadding
|
||||
? constraints.maxHeight - totalPadding
|
||||
: 0.0;
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 24 + _headerHeight),
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
minHeight: minContentHeight,
|
||||
maxWidth: 560,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Image.asset(
|
||||
'assets/images/logo-transparent.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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _onLanguageSelected(String locale) {
|
||||
setState(() => _selectedLocale = locale);
|
||||
ref.read(settingsProvider.notifier).setLocale(locale);
|
||||
}
|
||||
|
||||
Widget _buildLanguageStep(ColorScheme colorScheme) {
|
||||
final languages = supportedLanguageChoices;
|
||||
|
||||
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);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
24 + wideListInset(context, contentMaxWidth: 560),
|
||||
24,
|
||||
24 + wideListInset(context, contentMaxWidth: 560),
|
||||
0,
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
padding: EdgeInsets.all(iconPadding),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
Icons.translate,
|
||||
size: iconSize,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
),
|
||||
SizedBox(height: titleGap),
|
||||
Text(
|
||||
context.l10n.setupLanguageTitle,
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
SizedBox(height: descriptionGap),
|
||||
Text(
|
||||
context.l10n.setupLanguageDescription,
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
height: 1.5,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
SizedBox(height: actionGap),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: ScrollEdgeFade(
|
||||
child: Scrollbar(
|
||||
controller: _languageScrollController,
|
||||
thumbVisibility: true,
|
||||
child: _buildLanguageList(languages, colorScheme),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLanguageList(
|
||||
List<(String, String, IconData)> languages,
|
||||
ColorScheme colorScheme,
|
||||
) {
|
||||
return ListView.builder(
|
||||
controller: _languageScrollController,
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
24 + wideListInset(context, contentMaxWidth: 560),
|
||||
0,
|
||||
24 + wideListInset(context, contentMaxWidth: 560),
|
||||
80,
|
||||
),
|
||||
itemCount: languages.length,
|
||||
itemBuilder: (context, index) {
|
||||
final lang = languages[index];
|
||||
final code = lang.$1;
|
||||
final name = lang.$2;
|
||||
final isSelected = _selectedLocale == code;
|
||||
|
||||
// Expressive rows morph into a pill as they become selected.
|
||||
final shapeMotion = AppMotion.spatial(
|
||||
context,
|
||||
const AppMotionSpec(Duration.zero, Curves.linear),
|
||||
);
|
||||
final fillMotion = AppMotion.effects(
|
||||
context,
|
||||
const AppMotionSpec(Duration.zero, Curves.linear),
|
||||
);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: TweenAnimationBuilder<double>(
|
||||
tween: Tween(end: isSelected ? 1 : 0),
|
||||
duration: fillMotion.duration,
|
||||
curve: fillMotion.curve,
|
||||
builder: (context, fill, child) => TweenAnimationBuilder<double>(
|
||||
tween: Tween(
|
||||
end: isSelected && materialExpressiveEnabled(context) ? 28 : 16,
|
||||
),
|
||||
duration: shapeMotion.duration,
|
||||
curve: shapeMotion.curve,
|
||||
builder: (context, radius, child) => Material(
|
||||
color: Color.lerp(
|
||||
colorScheme.primaryContainer.withValues(alpha: 0),
|
||||
colorScheme.primaryContainer,
|
||||
fill.clamp(0.0, 1.0),
|
||||
),
|
||||
borderRadius: BorderRadius.circular(radius.clamp(0.0, 32.0)),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: child,
|
||||
),
|
||||
child: child,
|
||||
),
|
||||
child: InkWell(
|
||||
onTap: () => _onLanguageSelected(code),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 14,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
lang.$3,
|
||||
color: isSelected
|
||||
? colorScheme.onPrimaryContainer
|
||||
: colorScheme.onSurfaceVariant,
|
||||
size: 22,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Text(
|
||||
code == 'system'
|
||||
? context.l10n.setupLanguageSystemDefault
|
||||
: name,
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: isSelected
|
||||
? FontWeight.w600
|
||||
: FontWeight.normal,
|
||||
color: isSelected
|
||||
? colorScheme.onPrimaryContainer
|
||||
: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (isSelected)
|
||||
Icon(
|
||||
Icons.check_circle,
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
size: 22,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
)
|
||||
: ExpressiveButton(
|
||||
tonal: true,
|
||||
onPressed: _requestStoragePermission,
|
||||
icon: const Icon(Icons.folder_open),
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 24,
|
||||
vertical: 16,
|
||||
),
|
||||
),
|
||||
child: Text(context.l10n.setupGrantPermission),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNotificationStep(ColorScheme colorScheme) {
|
||||
return _StepLayout(
|
||||
title: context.l10n.setupNotificationEnable,
|
||||
description: context.l10n.setupNotificationBackgroundDescription,
|
||||
icon: Icons.notifications,
|
||||
child: _notificationPermissionGranted || _notificationPermissionSkipped
|
||||
? _SuccessCard(
|
||||
text: context.l10n.setupNotificationGranted,
|
||||
colorScheme: colorScheme,
|
||||
)
|
||||
: Column(
|
||||
children: [
|
||||
ExpressiveButton(
|
||||
tonal: true,
|
||||
onPressed: _requestNotificationPermission,
|
||||
icon: const Icon(Icons.notifications_active),
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 24,
|
||||
vertical: 16,
|
||||
),
|
||||
),
|
||||
child: Text(context.l10n.setupEnableNotifications),
|
||||
),
|
||||
ExpressiveButton(
|
||||
text: true,
|
||||
onPressed: () async {
|
||||
setState(() => _notificationPermissionSkipped = true);
|
||||
await _saveProgress();
|
||||
},
|
||||
child: Text(context.l10n.setupSkipForNow),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDirectoryStep(ColorScheme colorScheme) {
|
||||
return _StepLayout(
|
||||
title: context.l10n.setupFolderChoose,
|
||||
description: _platform == TargetPlatform.android
|
||||
? context.l10n.storageDownloadFolderHint
|
||||
: context.l10n.setupFolderDescription,
|
||||
icon: Icons.create_new_folder,
|
||||
child: Column(
|
||||
children: [
|
||||
if (_selectedDirectory != null)
|
||||
AppContentCard(
|
||||
color: colorScheme.secondaryContainer,
|
||||
mornyeSurface: false,
|
||||
child: ListTile(
|
||||
leading: Icon(
|
||||
Icons.folder,
|
||||
color: colorScheme.onSecondaryContainer,
|
||||
),
|
||||
title: Text(
|
||||
_selectedDirectory!,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
trailing: AppIconButton(
|
||||
tooltip: context.l10n.setupChangeFolderTooltip,
|
||||
icon: const Icon(Icons.edit),
|
||||
onPressed: _selectDirectory,
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
ExpressiveButton(
|
||||
tonal: true,
|
||||
onPressed: _selectDirectory,
|
||||
icon: const Icon(Icons.create_new_folder),
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 24,
|
||||
vertical: 16,
|
||||
),
|
||||
),
|
||||
child: Text(context.l10n.setupSelectFolder),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
// Extra bottom padding offsets the 80dp header above the PageView so
|
||||
// the content sits at the optical center of the full screen.
|
||||
const totalPadding = 48.0 + _headerHeight;
|
||||
final minContentHeight = constraints.maxHeight > totalPadding
|
||||
? constraints.maxHeight - totalPadding
|
||||
: 0.0;
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 24 + _headerHeight),
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
minHeight: minContentHeight,
|
||||
maxWidth: 560,
|
||||
),
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,498 @@
|
||||
part of 'setup_screen.dart';
|
||||
|
||||
extension _SetupScreenSteps on _SetupScreenState {
|
||||
/// Expressive FABs keep their icon leading and show loading in its place.
|
||||
Widget _buildExpressiveFab(ColorScheme colorScheme) {
|
||||
final isLastStep = _currentStep >= _totalSteps - 1;
|
||||
return MaterialExpressiveScope(
|
||||
child: isLastStep
|
||||
? M3EExtendedFab(
|
||||
label: context.l10n.setupGetStarted,
|
||||
icon: _isLoading
|
||||
? AppLoadingIndicator(size: 24, color: colorScheme.onPrimary)
|
||||
: const AppIcon(Icons.check),
|
||||
color: M3EFabColor.primaryFilled,
|
||||
onPressed: _isLoading ? null : _completeSetup,
|
||||
)
|
||||
: M3EExtendedFab(
|
||||
label: context.l10n.setupNext,
|
||||
icon: const AppIcon(Icons.arrow_forward),
|
||||
onPressed: !_isLoading && _isStepCompleted(_currentStep)
|
||||
? _nextPage
|
||||
: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
const totalPadding = 48.0 + _headerHeight;
|
||||
final minContentHeight = constraints.maxHeight > totalPadding
|
||||
? constraints.maxHeight - totalPadding
|
||||
: 0.0;
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 24 + _headerHeight),
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
minHeight: minContentHeight,
|
||||
maxWidth: 560,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Image.asset(
|
||||
'assets/images/logo-transparent.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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _onLanguageSelected(String locale) {
|
||||
_setState(() => _selectedLocale = locale);
|
||||
ref.read(settingsProvider.notifier).setLocale(locale);
|
||||
}
|
||||
|
||||
Widget _buildLanguageStep(ColorScheme colorScheme) {
|
||||
final languages = supportedLanguageChoices;
|
||||
|
||||
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);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
24 + wideListInset(context, contentMaxWidth: 560),
|
||||
24,
|
||||
24 + wideListInset(context, contentMaxWidth: 560),
|
||||
0,
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
padding: EdgeInsets.all(iconPadding),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
Icons.translate,
|
||||
size: iconSize,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
),
|
||||
SizedBox(height: titleGap),
|
||||
Text(
|
||||
context.l10n.setupLanguageTitle,
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
SizedBox(height: descriptionGap),
|
||||
Text(
|
||||
context.l10n.setupLanguageDescription,
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
height: 1.5,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
SizedBox(height: actionGap),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: ScrollEdgeFade(
|
||||
child: Scrollbar(
|
||||
controller: _languageScrollController,
|
||||
thumbVisibility: true,
|
||||
child: _buildLanguageList(languages, colorScheme),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLanguageList(
|
||||
List<(String, String, IconData)> languages,
|
||||
ColorScheme colorScheme,
|
||||
) {
|
||||
return ListView.builder(
|
||||
controller: _languageScrollController,
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
24 + wideListInset(context, contentMaxWidth: 560),
|
||||
0,
|
||||
24 + wideListInset(context, contentMaxWidth: 560),
|
||||
80,
|
||||
),
|
||||
itemCount: languages.length,
|
||||
itemBuilder: (context, index) {
|
||||
final lang = languages[index];
|
||||
final code = lang.$1;
|
||||
final name = lang.$2;
|
||||
final isSelected = _selectedLocale == code;
|
||||
|
||||
// Expressive rows morph into a pill as they become selected.
|
||||
final shapeMotion = AppMotion.spatial(
|
||||
context,
|
||||
const AppMotionSpec(Duration.zero, Curves.linear),
|
||||
);
|
||||
final fillMotion = AppMotion.effects(
|
||||
context,
|
||||
const AppMotionSpec(Duration.zero, Curves.linear),
|
||||
);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: TweenAnimationBuilder<double>(
|
||||
tween: Tween(end: isSelected ? 1 : 0),
|
||||
duration: fillMotion.duration,
|
||||
curve: fillMotion.curve,
|
||||
builder: (context, fill, child) => TweenAnimationBuilder<double>(
|
||||
tween: Tween(
|
||||
end: isSelected && materialExpressiveEnabled(context) ? 28 : 16,
|
||||
),
|
||||
duration: shapeMotion.duration,
|
||||
curve: shapeMotion.curve,
|
||||
builder: (context, radius, child) => Material(
|
||||
color: Color.lerp(
|
||||
colorScheme.primaryContainer.withValues(alpha: 0),
|
||||
colorScheme.primaryContainer,
|
||||
fill.clamp(0.0, 1.0),
|
||||
),
|
||||
borderRadius: BorderRadius.circular(radius.clamp(0.0, 32.0)),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: child,
|
||||
),
|
||||
child: child,
|
||||
),
|
||||
child: InkWell(
|
||||
onTap: () => _onLanguageSelected(code),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 14,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
lang.$3,
|
||||
color: isSelected
|
||||
? colorScheme.onPrimaryContainer
|
||||
: colorScheme.onSurfaceVariant,
|
||||
size: 22,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Text(
|
||||
code == 'system'
|
||||
? context.l10n.setupLanguageSystemDefault
|
||||
: name,
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: isSelected
|
||||
? FontWeight.w600
|
||||
: FontWeight.normal,
|
||||
color: isSelected
|
||||
? colorScheme.onPrimaryContainer
|
||||
: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (isSelected)
|
||||
Icon(
|
||||
Icons.check_circle,
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
size: 22,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
)
|
||||
: ExpressiveButton(
|
||||
tonal: true,
|
||||
onPressed: _requestStoragePermission,
|
||||
icon: const Icon(Icons.folder_open),
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 24,
|
||||
vertical: 16,
|
||||
),
|
||||
),
|
||||
child: Text(context.l10n.setupGrantPermission),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNotificationStep(ColorScheme colorScheme) {
|
||||
return _StepLayout(
|
||||
title: context.l10n.setupNotificationEnable,
|
||||
description: context.l10n.setupNotificationBackgroundDescription,
|
||||
icon: Icons.notifications,
|
||||
child: _notificationPermissionGranted || _notificationPermissionSkipped
|
||||
? _SuccessCard(
|
||||
text: context.l10n.setupNotificationGranted,
|
||||
colorScheme: colorScheme,
|
||||
)
|
||||
: Column(
|
||||
children: [
|
||||
ExpressiveButton(
|
||||
tonal: true,
|
||||
onPressed: _requestNotificationPermission,
|
||||
icon: const Icon(Icons.notifications_active),
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 24,
|
||||
vertical: 16,
|
||||
),
|
||||
),
|
||||
child: Text(context.l10n.setupEnableNotifications),
|
||||
),
|
||||
ExpressiveButton(
|
||||
text: true,
|
||||
onPressed: () async {
|
||||
_setState(() => _notificationPermissionSkipped = true);
|
||||
await _saveProgress();
|
||||
},
|
||||
child: Text(context.l10n.setupSkipForNow),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDirectoryStep(ColorScheme colorScheme) {
|
||||
return _StepLayout(
|
||||
title: context.l10n.setupFolderChoose,
|
||||
description: _platform == TargetPlatform.android
|
||||
? context.l10n.storageDownloadFolderHint
|
||||
: context.l10n.setupFolderDescription,
|
||||
icon: Icons.create_new_folder,
|
||||
child: Column(
|
||||
children: [
|
||||
if (_selectedDirectory != null)
|
||||
AppContentCard(
|
||||
color: colorScheme.secondaryContainer,
|
||||
mornyeSurface: false,
|
||||
child: ListTile(
|
||||
leading: Icon(
|
||||
Icons.folder,
|
||||
color: colorScheme.onSecondaryContainer,
|
||||
),
|
||||
title: Text(
|
||||
_selectedDirectory!,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
trailing: AppIconButton(
|
||||
tooltip: context.l10n.setupChangeFolderTooltip,
|
||||
icon: const Icon(Icons.edit),
|
||||
onPressed: _selectDirectory,
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
ExpressiveButton(
|
||||
tonal: true,
|
||||
onPressed: _selectDirectory,
|
||||
icon: const Icon(Icons.create_new_folder),
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 24,
|
||||
vertical: 16,
|
||||
),
|
||||
),
|
||||
child: Text(context.l10n.setupSelectFolder),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
// Extra bottom padding offsets the 80dp header above the PageView so
|
||||
// the content sits at the optical center of the full screen.
|
||||
const totalPadding = 48.0 + _headerHeight;
|
||||
final minContentHeight = constraints.maxHeight > totalPadding
|
||||
? constraints.maxHeight - totalPadding
|
||||
: 0.0;
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 24 + _headerHeight),
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
minHeight: minContentHeight,
|
||||
maxWidth: 560,
|
||||
),
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user