From efe703d60df799c185d842b029e329523c333300 Mon Sep 17 00:00:00 2001 From: zarzet Date: Mon, 27 Jul 2026 01:50:17 +0700 Subject: [PATCH] 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. --- lib/screens/setup_screen.dart | 171 +++++++++++++++++------------- lib/widgets/scroll_edge_fade.dart | 62 +++++++++++ 2 files changed, 160 insertions(+), 73 deletions(-) create mode 100644 lib/widgets/scroll_edge_fade.dart diff --git a/lib/screens/setup_screen.dart b/lib/screens/setup_screen.dart index aceae6d7..0eeaab8c 100644 --- a/lib/screens/setup_screen.dart +++ b/lib/screens/setup_screen.dart @@ -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 { final PageController _pageController = PageController(); + final ScrollController _languageScrollController = ScrollController(); int _currentStep = 0; String _selectedLocale = 'system'; @@ -46,6 +52,7 @@ class _SetupScreenState extends ConsumerState { @override void dispose() { _pageController.dispose(); + _languageScrollController.dispose(); super.dispose(); } @@ -648,12 +655,13 @@ class _SetupScreenState extends ConsumerState { 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 { ), ), 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 { ); } + 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( diff --git a/lib/widgets/scroll_edge_fade.dart b/lib/widgets/scroll_edge_fade.dart new file mode 100644 index 00000000..d59b192f --- /dev/null +++ b/lib/widgets/scroll_edge_fade.dart @@ -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 createState() => _ScrollEdgeFadeState(); +} + +class _ScrollEdgeFadeState extends State { + 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( + onNotification: (notification) => _onMetrics(notification.metrics), + child: NotificationListener( + 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], + ), + ), + ), + ), + ), + ), + ], + ); + } +}