mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-02 09:08:35 +02:00
feat(setup): make language list scrollability obvious and center step content
Add ScrollEdgeFade (bottom gradient while more content remains) plus an always-visible scrollbar on the language list, and bias step centering upward by the header height so content sits at the optical screen center.
This commit is contained in:
@@ -11,10 +11,15 @@ import 'package:spotiflac_android/l10n/supported_locales.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';
|
||||
|
||||
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});
|
||||
|
||||
@@ -24,6 +29,7 @@ class SetupScreen extends ConsumerStatefulWidget {
|
||||
|
||||
class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
final PageController _pageController = PageController();
|
||||
final ScrollController _languageScrollController = ScrollController();
|
||||
int _currentStep = 0;
|
||||
|
||||
String _selectedLocale = 'system';
|
||||
@@ -46,6 +52,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
@override
|
||||
void dispose() {
|
||||
_pageController.dispose();
|
||||
_languageScrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -648,12 +655,13 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
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
|
||||
const totalPadding = 48.0 + _headerHeight;
|
||||
final minContentHeight = constraints.maxHeight > totalPadding
|
||||
? constraints.maxHeight - totalPadding
|
||||
: 0.0;
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 24 + _headerHeight),
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
@@ -788,74 +796,12 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
24 + wideListInset(context, contentMaxWidth: 560),
|
||||
0,
|
||||
24 + wideListInset(context, contentMaxWidth: 560),
|
||||
80,
|
||||
child: ScrollEdgeFade(
|
||||
child: Scrollbar(
|
||||
controller: _languageScrollController,
|
||||
thumbVisibility: true,
|
||||
child: _buildLanguageList(languages, colorScheme),
|
||||
),
|
||||
itemCount: languages.length,
|
||||
itemBuilder: (context, index) {
|
||||
final lang = languages[index];
|
||||
final code = lang.$1;
|
||||
final name = lang.$2;
|
||||
final isSelected = _selectedLocale == code;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: Material(
|
||||
color: isSelected
|
||||
? colorScheme.primaryContainer
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -864,6 +810,82 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: Material(
|
||||
color: isSelected
|
||||
? colorScheme.primaryContainer
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
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,
|
||||
@@ -990,12 +1012,15 @@ class _StepLayout extends StatelessWidget {
|
||||
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
|
||||
// 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.all(24),
|
||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 24 + _headerHeight),
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Overlays a bottom gradient on a vertical scrollable while more content
|
||||
/// remains below, signalling that the view can scroll.
|
||||
class ScrollEdgeFade extends StatefulWidget {
|
||||
final Widget child;
|
||||
final double height;
|
||||
|
||||
const ScrollEdgeFade({super.key, required this.child, this.height = 96});
|
||||
|
||||
@override
|
||||
State<ScrollEdgeFade> createState() => _ScrollEdgeFadeState();
|
||||
}
|
||||
|
||||
class _ScrollEdgeFadeState extends State<ScrollEdgeFade> {
|
||||
bool _hasMore = false;
|
||||
|
||||
bool _onMetrics(ScrollMetrics metrics) {
|
||||
if (metrics.axis == Axis.vertical) {
|
||||
final hasMore = metrics.extentAfter > 8;
|
||||
if (hasMore != _hasMore) setState(() => _hasMore = hasMore);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final background = Theme.of(context).scaffoldBackgroundColor;
|
||||
return Stack(
|
||||
children: [
|
||||
NotificationListener<ScrollMetricsNotification>(
|
||||
onNotification: (notification) => _onMetrics(notification.metrics),
|
||||
child: NotificationListener<ScrollUpdateNotification>(
|
||||
onNotification: (notification) => _onMetrics(notification.metrics),
|
||||
child: widget.child,
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: IgnorePointer(
|
||||
child: AnimatedOpacity(
|
||||
opacity: _hasMore ? 1 : 0,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
child: Container(
|
||||
height: widget.height,
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [background.withValues(alpha: 0), background],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user