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:
zarzet
2026-07-27 01:50:17 +07:00
parent 55be8fc827
commit efe703d60d
2 changed files with 160 additions and 73 deletions
+98 -73
View File
@@ -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(
+62
View File
@@ -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],
),
),
),
),
),
),
],
);
}
}