diff --git a/lib/screens/favorite_artists_screen.dart b/lib/screens/favorite_artists_screen.dart index 2076f9c9..a655b3e1 100644 --- a/lib/screens/favorite_artists_screen.dart +++ b/lib/screens/favorite_artists_screen.dart @@ -8,7 +8,7 @@ import 'package:spotiflac_android/services/cover_cache_manager.dart'; import 'package:spotiflac_android/utils/adaptive_layout.dart'; import 'package:spotiflac_android/utils/nav_bar_inset.dart'; import 'package:spotiflac_android/widgets/animation_utils.dart'; -import 'package:spotiflac_android/widgets/settings_sliver_app_bar.dart'; +import 'package:spotiflac_android/widgets/app_sliver_header.dart'; class FavoriteArtistsScreen extends ConsumerWidget { const FavoriteArtistsScreen({super.key}); @@ -24,7 +24,7 @@ class FavoriteArtistsScreen extends ConsumerWidget { return Scaffold( body: CustomScrollView( slivers: [ - SettingsSliverAppBar(title: context.l10n.collectionFavoriteArtists), + AppSliverHeader.page(title: context.l10n.collectionFavoriteArtists), if (artists.isEmpty) SliverFillRemaining( hasScrollBody: false, diff --git a/lib/screens/settings/about_page.dart b/lib/screens/settings/about_page.dart index 666586d9..1c8e4898 100644 --- a/lib/screens/settings/about_page.dart +++ b/lib/screens/settings/about_page.dart @@ -6,7 +6,7 @@ import 'package:spotiflac_android/constants/app_info.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/utils/adaptive_layout.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; -import 'package:spotiflac_android/widgets/settings_sliver_app_bar.dart'; +import 'package:spotiflac_android/widgets/app_sliver_header.dart'; class AboutPage extends StatelessWidget { const AboutPage({super.key}); @@ -20,7 +20,7 @@ class AboutPage extends StatelessWidget { child: Scaffold( body: CustomScrollView( slivers: [ - SettingsSliverAppBar(title: context.l10n.aboutTitle), + AppSliverHeader.page(title: context.l10n.aboutTitle), SliverToBoxAdapter( child: Padding( diff --git a/lib/screens/settings/backup_restore_page.dart b/lib/screens/settings/backup_restore_page.dart index c480ad5e..8223cd29 100644 --- a/lib/screens/settings/backup_restore_page.dart +++ b/lib/screens/settings/backup_restore_page.dart @@ -13,7 +13,7 @@ import 'package:spotiflac_android/services/backup_service.dart'; import 'package:spotiflac_android/services/history_database.dart'; import 'package:spotiflac_android/utils/logger.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; -import 'package:spotiflac_android/widgets/settings_sliver_app_bar.dart'; +import 'package:spotiflac_android/widgets/app_sliver_header.dart'; class BackupRestorePage extends ConsumerStatefulWidget { const BackupRestorePage({super.key}); @@ -222,7 +222,7 @@ class _BackupRestorePageState extends ConsumerState { return Scaffold( body: CustomScrollView( slivers: [ - SettingsSliverAppBar(title: l10n.backupTitle), + AppSliverHeader.page(title: l10n.backupTitle), SliverToBoxAdapter( child: SettingsSectionHeader(title: l10n.backupExportSectionTitle), ), diff --git a/lib/screens/settings/cache_management_page.dart b/lib/screens/settings/cache_management_page.dart index ea69a604..ec6a1408 100644 --- a/lib/screens/settings/cache_management_page.dart +++ b/lib/screens/settings/cache_management_page.dart @@ -14,7 +14,7 @@ import 'package:spotiflac_android/services/cover_cache_manager.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/utils/string_utils.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; -import 'package:spotiflac_android/widgets/settings_sliver_app_bar.dart'; +import 'package:spotiflac_android/widgets/app_sliver_header.dart'; class CacheManagementPage extends ConsumerStatefulWidget { const CacheManagementPage({super.key}); @@ -387,7 +387,7 @@ class _CacheManagementPageState extends ConsumerState { return Scaffold( body: CustomScrollView( slivers: [ - SettingsSliverAppBar( + AppSliverHeader.page( title: context.l10n.cacheTitle, actions: [ IconButton( diff --git a/lib/screens/settings/donate_page.dart b/lib/screens/settings/donate_page.dart index 916f77ae..1444bef3 100644 --- a/lib/screens/settings/donate_page.dart +++ b/lib/screens/settings/donate_page.dart @@ -6,7 +6,7 @@ import 'package:url_launcher/url_launcher.dart'; import 'package:spotiflac_android/services/app_remote_config_service.dart'; import 'package:spotiflac_android/utils/adaptive_layout.dart'; import 'package:spotiflac_android/widgets/donate_icons.dart'; -import 'package:spotiflac_android/widgets/settings_sliver_app_bar.dart'; +import 'package:spotiflac_android/widgets/app_sliver_header.dart'; class DonatePage extends StatefulWidget { final AppRemoteConfigService? remoteConfigService; @@ -61,7 +61,7 @@ class _DonatePageState extends State { return Scaffold( body: CustomScrollView( slivers: [ - const SettingsSliverAppBar(title: 'Donate'), + AppSliverHeader.page(title: 'Donate'), SliverToBoxAdapter( child: Padding( padding: EdgeInsets.fromLTRB( diff --git a/lib/screens/settings/download_fallback_extensions_page.dart b/lib/screens/settings/download_fallback_extensions_page.dart index b62ca449..0133c094 100644 --- a/lib/screens/settings/download_fallback_extensions_page.dart +++ b/lib/screens/settings/download_fallback_extensions_page.dart @@ -6,7 +6,7 @@ import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/utils/adaptive_layout.dart'; import 'package:spotiflac_android/widgets/discard_changes_dialog.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; -import 'package:spotiflac_android/widgets/settings_sliver_app_bar.dart'; +import 'package:spotiflac_android/widgets/app_sliver_header.dart'; class DownloadFallbackExtensionsPage extends ConsumerStatefulWidget { const DownloadFallbackExtensionsPage({super.key}); @@ -68,7 +68,7 @@ class _DownloadFallbackExtensionsPageState child: Scaffold( body: CustomScrollView( slivers: [ - SettingsSliverAppBar( + AppSliverHeader.page( title: context.l10n.extensionsFallbackTitle, leading: IconButton( tooltip: MaterialLocalizations.of(context).backButtonTooltip, diff --git a/lib/screens/settings/extension_detail_page.dart b/lib/screens/settings/extension_detail_page.dart index 7d69b524..ee0d7759 100644 --- a/lib/screens/settings/extension_detail_page.dart +++ b/lib/screens/settings/extension_detail_page.dart @@ -8,7 +8,7 @@ import 'package:spotiflac_android/providers/repo_provider.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/utils/extension_auth_launcher.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; -import 'package:spotiflac_android/widgets/settings_sliver_app_bar.dart'; +import 'package:spotiflac_android/widgets/app_sliver_header.dart'; class ExtensionDetailPage extends ConsumerStatefulWidget { final String extensionId; @@ -89,7 +89,7 @@ class _ExtensionDetailPageState extends ConsumerState { child: Scaffold( body: CustomScrollView( slivers: [ - SettingsSliverAppBar(title: extension.displayName), + AppSliverHeader.page(title: extension.displayName), SliverToBoxAdapter( child: Padding( diff --git a/lib/screens/settings/log_screen.dart b/lib/screens/settings/log_screen.dart index 1a08afde..53f6e8e3 100644 --- a/lib/screens/settings/log_screen.dart +++ b/lib/screens/settings/log_screen.dart @@ -4,7 +4,7 @@ import 'package:share_plus/share_plus.dart' show ShareParams, SharePlus; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/utils/logger.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; -import 'package:spotiflac_android/widgets/settings_sliver_app_bar.dart'; +import 'package:spotiflac_android/widgets/app_sliver_header.dart'; final RegExp _domainPattern = RegExp( r'domain:\s*([^\s,]+)', @@ -140,7 +140,7 @@ class _LogScreenState extends State { body: CustomScrollView( controller: _scrollController, slivers: [ - SettingsSliverAppBar( + AppSliverHeader.page( title: context.l10n.logTitle, actions: [ IconButton( diff --git a/lib/screens/settings/lyrics_settings_page.dart b/lib/screens/settings/lyrics_settings_page.dart index 75f74f49..9fbfc6cb 100644 --- a/lib/screens/settings/lyrics_settings_page.dart +++ b/lib/screens/settings/lyrics_settings_page.dart @@ -4,7 +4,7 @@ import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/screens/settings/lyrics_provider_priority_page.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; -import 'package:spotiflac_android/widgets/settings_sliver_app_bar.dart'; +import 'package:spotiflac_android/widgets/app_sliver_header.dart'; class LyricsSettingsPage extends ConsumerWidget { const LyricsSettingsPage({super.key}); @@ -18,7 +18,7 @@ class LyricsSettingsPage extends ConsumerWidget { child: Scaffold( body: CustomScrollView( slivers: [ - SettingsSliverAppBar(title: context.l10n.settingsLyrics), + AppSliverHeader.page(title: context.l10n.settingsLyrics), SliverToBoxAdapter( child: SettingsSectionHeader(title: context.l10n.sectionLyrics), @@ -202,9 +202,6 @@ class LyricsSettingsPage extends ConsumerWidget { context: context, useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(28)), - ), builder: (context) => SafeArea( child: Column( mainAxisSize: MainAxisSize.min, @@ -277,9 +274,6 @@ class LyricsSettingsPage extends ConsumerWidget { context: context, useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(28)), - ), isScrollControlled: true, builder: (context) => Padding( padding: EdgeInsets.only( diff --git a/lib/screens/settings/metadata_settings_page.dart b/lib/screens/settings/metadata_settings_page.dart index 4caa2aa4..bb458ec2 100644 --- a/lib/screens/settings/metadata_settings_page.dart +++ b/lib/screens/settings/metadata_settings_page.dart @@ -5,7 +5,7 @@ import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/utils/artist_utils.dart'; import 'package:spotiflac_android/screens/settings/metadata_provider_priority_page.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; -import 'package:spotiflac_android/widgets/settings_sliver_app_bar.dart'; +import 'package:spotiflac_android/widgets/app_sliver_header.dart'; class MetadataSettingsPage extends ConsumerWidget { const MetadataSettingsPage({super.key}); @@ -19,7 +19,7 @@ class MetadataSettingsPage extends ConsumerWidget { child: Scaffold( body: CustomScrollView( slivers: [ - SettingsSliverAppBar(title: context.l10n.settingsMetadata), + AppSliverHeader.page(title: context.l10n.settingsMetadata), SliverToBoxAdapter( child: SettingsSectionHeader(title: context.l10n.sectionDownload), @@ -165,9 +165,6 @@ class MetadataSettingsPage extends ConsumerWidget { context: context, useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(28)), - ), builder: (context) => SafeArea( child: Column( mainAxisSize: MainAxisSize.min, diff --git a/lib/widgets/app_sliver_header.dart b/lib/widgets/app_sliver_header.dart new file mode 100644 index 00000000..0e4288cd --- /dev/null +++ b/lib/widgets/app_sliver_header.dart @@ -0,0 +1,108 @@ +import 'package:flutter/material.dart'; +import 'package:spotiflac_android/theme/app_tokens.dart'; +import 'package:spotiflac_android/utils/app_bar_layout.dart'; + +/// The collapsing header used by every top-level tab and every settings-style +/// sub-page. +/// +/// This replaces five hand-rolled copies of the same `SliverAppBar` + +/// `LayoutBuilder` + `FlexibleSpaceBar` block. Those copies had drifted into two +/// type ramps (tab roots expanded the title to 34pt, sub-pages to 28pt); both +/// now expand to [AppTokens.headerExpandedTitleSize], which matches the +/// Material 3 large top app bar headline. +class AppSliverHeader extends StatelessWidget { + /// Root of a navigation tab: no back button, and the title stays aligned with + /// the content margin at every collapse ratio. + const AppSliverHeader.tabRoot({super.key, required this.title, this.actions}) + : leading = null, + _showLeading = false; + + /// A pushed page: shows [leading] (a back button by default) and slides the + /// title clear of it as the header collapses. + const AppSliverHeader.page({ + super.key, + required this.title, + this.actions, + this.leading, + }) : _showLeading = true; + + final String title; + final List? actions; + + /// Overrides the default back button. Only used by the page variant. + final Widget? leading; + + final bool _showLeading; + + /// Left inset of the collapsed title when a leading button is present: enough + /// to clear a 48dp icon button plus the 8dp toolbar margin. + static const double _leadingClearance = 56; + + /// Content margin the expanded title aligns to. + static const double _contentMargin = 24; + + @override + Widget build(BuildContext context) { + final tokens = context.tokens; + final colorScheme = Theme.of(context).colorScheme; + final topPadding = normalizedHeaderTopPadding(context); + final maxHeight = tokens.headerExpandedHeight + topPadding; + final minHeight = kToolbarHeight + topPadding; + + return SliverAppBar( + expandedHeight: maxHeight, + collapsedHeight: kToolbarHeight, + floating: false, + pinned: true, + backgroundColor: colorScheme.surface, + surfaceTintColor: Colors.transparent, + automaticallyImplyLeading: false, + leading: _showLeading + ? leading ?? + IconButton( + tooltip: MaterialLocalizations.of(context).backButtonTooltip, + icon: const Icon(Icons.arrow_back), + onPressed: () => Navigator.pop(context), + ) + : null, + actions: actions, + flexibleSpace: LayoutBuilder( + builder: (context, constraints) { + final expandRatio = + ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)) + .clamp(0.0, 1.0); + final leftPadding = _showLeading + ? _leadingClearance - + ((_leadingClearance - _contentMargin) * expandRatio) + : _contentMargin; + final fontSize = + tokens.headerCollapsedTitleSize + + (tokens.headerExpandedTitleSize - + tokens.headerCollapsedTitleSize) * + expandRatio; + + return FlexibleSpaceBar( + expandedTitleScale: 1.0, + titlePadding: EdgeInsets.only( + left: leftPadding, + right: tokens.gapLg, + bottom: tokens.gapLg, + ), + title: Text( + title, + // The title grows as the header expands; without a cap a long + // localized title overflowed instead of ellipsizing. + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: fontSize, + fontWeight: FontWeight.bold, + color: colorScheme.onSurface, + ), + ), + ); + }, + ), + ); + } +} diff --git a/lib/widgets/priority_settings_scaffold.dart b/lib/widgets/priority_settings_scaffold.dart index cff18713..13716469 100644 --- a/lib/widgets/priority_settings_scaffold.dart +++ b/lib/widgets/priority_settings_scaffold.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/utils/adaptive_layout.dart'; -import 'package:spotiflac_android/widgets/settings_sliver_app_bar.dart'; +import 'package:spotiflac_android/widgets/app_sliver_header.dart'; class PrioritySettingsScaffold extends StatelessWidget { final bool hasChanges; @@ -55,7 +55,7 @@ class PrioritySettingsScaffold extends StatelessWidget { child: Scaffold( body: CustomScrollView( slivers: [ - SettingsSliverAppBar( + AppSliverHeader.page( title: title, leading: IconButton( tooltip: MaterialLocalizations.of(context).backButtonTooltip, diff --git a/lib/widgets/settings_sliver_app_bar.dart b/lib/widgets/settings_sliver_app_bar.dart deleted file mode 100644 index 83180e9d..00000000 --- a/lib/widgets/settings_sliver_app_bar.dart +++ /dev/null @@ -1,64 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:spotiflac_android/utils/app_bar_layout.dart'; - -/// The collapsing header shared by settings-style pages: a pinned -/// [SliverAppBar] whose title slides toward the leading edge and shrinks -/// from 28 to 20 logical pixels as the header collapses. -class SettingsSliverAppBar extends StatelessWidget { - const SettingsSliverAppBar({ - super.key, - required this.title, - this.actions, - this.leading, - }); - - final String title; - final List? actions; - - /// Defaults to a back button popping the current route. - final Widget? leading; - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - final topPadding = normalizedHeaderTopPadding(context); - return SliverAppBar( - expandedHeight: 120 + topPadding, - collapsedHeight: kToolbarHeight, - floating: false, - pinned: true, - backgroundColor: colorScheme.surface, - surfaceTintColor: Colors.transparent, - leading: - leading ?? - IconButton( - tooltip: MaterialLocalizations.of(context).backButtonTooltip, - icon: const Icon(Icons.arrow_back), - onPressed: () => Navigator.pop(context), - ), - actions: actions, - flexibleSpace: LayoutBuilder( - builder: (context, constraints) { - final maxHeight = 120 + topPadding; - final minHeight = kToolbarHeight + topPadding; - final expandRatio = - ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)) - .clamp(0.0, 1.0); - final leftPadding = 56 - (32 * expandRatio); - return FlexibleSpaceBar( - expandedTitleScale: 1.0, - titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16), - title: Text( - title, - style: TextStyle( - fontSize: 20 + (8 * expandRatio), - fontWeight: FontWeight.bold, - color: colorScheme.onSurface, - ), - ), - ); - }, - ), - ); - } -}