From bd0b4a50c3c3560306f4752e8a49030b69df741f Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Thu, 15 Oct 2026 02:51:53 +0700 Subject: [PATCH] feat(layout): adapt tablet navigation and center wide content --- lib/screens/home_tab.dart | 12 +- lib/screens/home_tab_explore.dart | 23 +- lib/screens/home_tab_search_results.dart | 2 +- lib/screens/listening_statistics_screen.dart | 170 ++--- lib/screens/main_shell.dart | 114 ++-- lib/screens/mornye_library_screen.dart | 48 +- lib/screens/network_storage_screen.dart | 667 ++++++++++--------- lib/screens/queue_tab.dart | 27 +- lib/screens/queue_tab_filter_widgets.dart | 23 +- lib/screens/repo_tab.dart | 23 +- lib/screens/settings/extensions_page.dart | 22 +- lib/screens/settings/settings_tab.dart | 5 +- lib/screens/weekly_releases_screen.dart | 186 +++--- lib/utils/adaptive_layout.dart | 26 +- lib/widgets/app_sliver_header.dart | 30 +- lib/widgets/expressive_navigation_bar.dart | 69 ++ lib/widgets/settings_group.dart | 85 ++- lib/widgets/track_detail_actions.dart | 5 +- test/adaptive_layout_test.dart | 24 +- 19 files changed, 910 insertions(+), 651 deletions(-) diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index 9a05f879..a1060e27 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -173,7 +173,11 @@ class _HomeTabState extends ConsumerState // in portrait while leaving all phone sizes unchanged. final scale = _responsiveScale(context: context, min: 0.82, max: 1.32); final textScale = _effectiveTextScale(context); - return 145 * scale * (1 + (textScale - 1) * 0.12); + final size = 145 * scale * (1 + (textScale - 1) * 0.12); + // Landscape tablets are short: one carousel row should not take over a + // third of the screen. Portrait and phone heights never reach this cap. + final heightCap = MediaQuery.sizeOf(context).height * 0.24; + return size < heightCap ? size : heightCap; } double _exploreSectionHeight(BuildContext context) { @@ -931,6 +935,7 @@ class _HomeTabState extends ConsumerState keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, slivers: [ AppSliverHeader.tabRoot( + alignToContent: true, title: widget.mode == HomeTabMode.search ? context.l10n.mornyeSearch : context.l10n.homeTitle, @@ -956,10 +961,11 @@ class _HomeTabState extends ConsumerState if (showSearchBar) SliverToBoxAdapter( child: Padding( + // Centered with the search results on tablets. padding: EdgeInsets.fromLTRB( - 16, + 16 + wideListInset(context), (hasResults || showExplore) ? 8 : 32, - 16, + 16 + wideListInset(context), (hasResults || showExplore) ? 8 : 16, ), child: _buildSearchBar(), diff --git a/lib/screens/home_tab_explore.dart b/lib/screens/home_tab_explore.dart index 8d3eed2b..0b387af4 100644 --- a/lib/screens/home_tab_explore.dart +++ b/lib/screens/home_tab_explore.dart @@ -17,7 +17,12 @@ extension _HomeTabExploreUI on _HomeTabState { delegate: SliverChildBuilderDelegate((context, index) { if (hasGreeting && index == 0) { return Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), + padding: EdgeInsets.fromLTRB( + 16 + wideListInset(context), + 12, + 16 + wideListInset(context), + 4, + ), child: Text( greeting, style: @@ -57,11 +62,14 @@ extension _HomeTabExploreUI on _HomeTabState { return _buildYTMusicQuickPicksSection(section, colorScheme); } + // Titles and the first card line up with the centered column on tablets; + // the carousel itself still scrolls edge to edge. + final inset = wideListInset(context); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( - padding: const EdgeInsets.fromLTRB(16, 20, 16, 12), + padding: EdgeInsets.fromLTRB(16 + inset, 20, 16 + inset, 12), child: Text( section.title, style: Theme.of(context).textTheme.titleMedium?.copyWith( @@ -74,7 +82,7 @@ extension _HomeTabExploreUI on _HomeTabState { height: sectionHeight, child: ListView.builder( scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric(horizontal: 12), + padding: EdgeInsets.symmetric(horizontal: 12 + inset), itemCount: section.items.length, itemBuilder: (context, index) { final item = section.items[index]; @@ -101,19 +109,20 @@ extension _HomeTabExploreUI on _HomeTabState { /// [ShimmerLoading] for the sweep effect. Widget _buildExploreSectionSkeleton({required bool isArtist}) { final cardSize = _exploreCardSize(context); + final inset = wideListInset(context); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Padding( - padding: EdgeInsets.fromLTRB(16, 20, 16, 12), - child: SkeletonBox(width: 140, height: 20, borderRadius: 4), + Padding( + padding: EdgeInsets.fromLTRB(16 + inset, 20, 16 + inset, 12), + child: const SkeletonBox(width: 140, height: 20, borderRadius: 4), ), SizedBox( height: _exploreSectionHeight(context), child: ListView.builder( scrollDirection: Axis.horizontal, physics: const NeverScrollableScrollPhysics(), - padding: const EdgeInsets.symmetric(horizontal: 12), + padding: EdgeInsets.symmetric(horizontal: 12 + inset), itemCount: 6, itemBuilder: (context, index) => SizedBox( width: cardSize, diff --git a/lib/screens/home_tab_search_results.dart b/lib/screens/home_tab_search_results.dart index 5e47b629..cf92037a 100644 --- a/lib/screens/home_tab_search_results.dart +++ b/lib/screens/home_tab_search_results.dart @@ -739,7 +739,7 @@ extension _HomeTabSearchResultsUI on _HomeTabState { } return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), + padding: EdgeInsets.symmetric(horizontal: 16 + wideListInset(context)), child: SingleChildScrollView( scrollDirection: Axis.horizontal, child: Row( diff --git a/lib/screens/listening_statistics_screen.dart b/lib/screens/listening_statistics_screen.dart index edddcc6c..d753adf0 100644 --- a/lib/screens/listening_statistics_screen.dart +++ b/lib/screens/listening_statistics_screen.dart @@ -8,6 +8,7 @@ import 'package:spotiflac_android/services/listening_statistics.dart'; import 'package:spotiflac_android/theme/app_motion.dart'; import 'package:spotiflac_android/theme/material_expressive.dart'; import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/utils/adaptive_layout.dart'; import 'package:spotiflac_android/widgets/animation_utils.dart' show SkeletonBox; import 'package:spotiflac_android/widgets/app_alert_dialog.dart'; @@ -92,91 +93,100 @@ class _ListeningStatisticsScreenState child: CustomScrollView( slivers: [ AppSliverHeader.page(title: context.l10n.listeningStats), - SliverToBoxAdapter( - child: SettingsGroup( - children: [ - SettingsSwitchItem( - icon: Icons.insights_outlined, - title: context.l10n.listeningStatsRecord, - subtitle: context.l10n.listeningStatsDescription, - value: ref - .watch(settingsProvider) - .listeningStatisticsEnabled, - enabled: !_clearing, - onChanged: (value) => ref - .read(settingsProvider.notifier) - .setListeningStatisticsEnabled(value), - showDivider: false, - ), - ], - ), - ), - const SliverToBoxAdapter(child: ListeningRewindEntry()), + // Centered at the reading width on tablets, under the title. SliverPadding( - padding: const EdgeInsets.all(16), - sliver: SliverToBoxAdapter( - child: Wrap( - spacing: 8, - runSpacing: 8, - children: [ - for (final days in [7, 30, 0]) - AppChoiceChip( - label: Text( - days == 0 - ? '$_year' - : context.l10n.listeningStatsDays(days), + padding: EdgeInsets.symmetric(horizontal: wideListInset(context)), + sliver: SliverMainAxisGroup( + slivers: [ + SliverToBoxAdapter( + child: SettingsGroup( + children: [ + SettingsSwitchItem( + icon: Icons.insights_outlined, + title: context.l10n.listeningStatsRecord, + subtitle: context.l10n.listeningStatsDescription, + value: ref + .watch(settingsProvider) + .listeningStatisticsEnabled, + enabled: !_clearing, + onChanged: (value) => ref + .read(settingsProvider.notifier) + .setListeningStatisticsEnabled(value), + showDivider: false, ), - selected: _days == days, - onSelected: (_) => setState(() => _days = days), - ), - if (_days == 0) - AppIconButton( - tooltip: context.l10n.listeningStatsPreviousYear, - onPressed: () => setState(() => _year--), - icon: const Icon(Icons.chevron_left), - ), - if (_days == 0) - AppIconButton( - tooltip: context.l10n.listeningStatsNextYear, - onPressed: _year >= DateTime.now().year - ? null - : () => setState(() => _year++), - icon: const Icon(Icons.chevron_right), - ), - ], - ), - ), - ), - ...summary.when( - loading: () => [ - SliverToBoxAdapter(child: _summarySkeleton(context)), - ], - error: (error, _) => [ - SliverToBoxAdapter( - child: ListTile( - title: Text(context.l10n.listeningStatsLoadError), - trailing: AppIconButton( - icon: const Icon(Icons.refresh), - onPressed: () => - ref.invalidate(listeningSummaryProvider(period)), + ], ), ), - ), - ], - data: (data) => _summarySlivers(context, data), - ), - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 24, 16, 48), - child: ExpressiveButton( - text: true, - onPressed: _clearing ? null : _clear, - icon: Icon(Icons.delete_outline, color: colors.error), - child: Text( - context.l10n.listeningStatsClear, - style: TextStyle(color: colors.error), + const SliverToBoxAdapter(child: ListeningRewindEntry()), + SliverPadding( + padding: const EdgeInsets.all(16), + sliver: SliverToBoxAdapter( + child: Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final days in [7, 30, 0]) + AppChoiceChip( + label: Text( + days == 0 + ? '$_year' + : context.l10n.listeningStatsDays(days), + ), + selected: _days == days, + onSelected: (_) => setState(() => _days = days), + ), + if (_days == 0) + AppIconButton( + tooltip: context.l10n.listeningStatsPreviousYear, + onPressed: () => setState(() => _year--), + icon: const Icon(Icons.chevron_left), + ), + if (_days == 0) + AppIconButton( + tooltip: context.l10n.listeningStatsNextYear, + onPressed: _year >= DateTime.now().year + ? null + : () => setState(() => _year++), + icon: const Icon(Icons.chevron_right), + ), + ], + ), + ), ), - ), + ...summary.when( + loading: () => [ + SliverToBoxAdapter(child: _summarySkeleton(context)), + ], + error: (error, _) => [ + SliverToBoxAdapter( + child: ListTile( + title: Text(context.l10n.listeningStatsLoadError), + trailing: AppIconButton( + icon: const Icon(Icons.refresh), + onPressed: () => ref.invalidate( + listeningSummaryProvider(period), + ), + ), + ), + ), + ], + data: (data) => _summarySlivers(context, data), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 24, 16, 48), + child: ExpressiveButton( + text: true, + onPressed: _clearing ? null : _clear, + icon: Icon(Icons.delete_outline, color: colors.error), + child: Text( + context.l10n.listeningStatsClear, + style: TextStyle(color: colors.error), + ), + ), + ), + ), + ], ), ), ], diff --git a/lib/screens/main_shell.dart b/lib/screens/main_shell.dart index 4d63482b..b8165193 100644 --- a/lib/screens/main_shell.dart +++ b/lib/screens/main_shell.dart @@ -933,10 +933,15 @@ class _MainShellState extends ConsumerState final screenSize = MediaQuery.sizeOf(context); final isTablet = screenSize.shortestSide >= 600; - // Tablets remain touch-first in both orientations. Reserving the rail for - // desktop-width windows keeps all primary destinations at the reachable - // bottom edge on iPad and large Android tablets. - final useNavigationRail = useNavigationRailForWidth(screenSize.width); + // Material follows its expanded window class: landscape tablets get the + // rail, portrait tablets keep the bottom bar. Mornye keeps a bottom tab + // bar on tablets, compact and centered once the window is expanded, and + // reserves the rail for desktop-width windows. + final useNavigationRail = context.isMornye + ? useNavigationRailForWidth(screenSize.width) + : useMaterialNavigationRailForWidth(screenSize.width); + final compactMornyeChrome = + context.isMornye && screenSize.width >= expandedLayoutBreakpoint; final canMinimizeChrome = context.isMornye && !useNavigationRail && @@ -1008,41 +1013,36 @@ class _MainShellState extends ConsumerState SafeArea( right: false, bottom: false, - // The rail needs ~300dp of height for four labeled - // destinations; on short viewports (landscape phone with - // the mini player showing) it must scroll, not overflow. - child: LayoutBuilder( - builder: (context, constraints) => - SingleChildScrollView( - child: ConstrainedBox( - constraints: BoxConstraints( - minHeight: constraints.maxHeight, - ), - child: IntrinsicHeight( - child: NavigationRail( - selectedIndex: selectedDestination, - onDestinationSelected: _onNavTap, - labelType: NavigationRailLabelType.all, - backgroundColor: Theme.of( - context, - ).colorScheme.surfaceContainer, - destinations: [ - for (final destination in destinations) - NavigationRailDestination( - icon: destination.icon, - selectedIcon: - destination.selectedIcon, - label: Text(destination.label), - ), - ], - ), - ), - ), - ), + child: ExpressiveNavigationRail( + selectedIndex: selectedDestination, + onDestinationSelected: _onNavTap, + backgroundColor: Theme.of( + context, + ).colorScheme.surfaceContainer, + destinations: destinations, ), ), const VerticalDivider(width: 1), - Expanded(child: pageView), + // Width-based layout (centered columns, grids) measures + // the space beside the rail rather than the whole screen. + Expanded( + child: LayoutBuilder( + builder: (context, constraints) { + final mediaQuery = MediaQuery.of(context); + return MediaQuery( + data: mediaQuery + .copyWith( + size: Size( + constraints.maxWidth, + mediaQuery.size.height, + ), + ) + .removePadding(removeLeft: true), + child: pageView, + ); + }, + ), + ), ], ) : pageView, @@ -1069,20 +1069,34 @@ class _MainShellState extends ConsumerState child: SafeArea( top: false, minimum: const EdgeInsets.fromLTRB(20, 0, 20, 8), - child: useNavigationRail - ? const MiniPlayer() - : ValueListenableBuilder( - valueListenable: _mornyeChrome, - builder: (context, collapsed, _) => MornyeBottomBar( - collapsed: canMinimizeChrome && collapsed, - destinations: destinations, - selectedIndex: selectedDestination, - onSelected: _onNavTap, - onHome: () => _onNavTap(0), - onSearch: ShellNavigationService.requestSearch, - blurEnabled: ref.watch(mornyeBlurEnabledProvider), - ), - ), + // iPadOS-style chrome: one compact, centered pill rather + // than a bar spread across a landscape tablet. + child: Center( + heightFactor: 1, + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: compactMornyeChrome ? 600 : double.infinity, + ), + child: useNavigationRail + ? const MiniPlayer() + : ValueListenableBuilder( + valueListenable: _mornyeChrome, + builder: (context, collapsed, _) => + MornyeBottomBar( + collapsed: canMinimizeChrome && collapsed, + destinations: destinations, + selectedIndex: selectedDestination, + onSelected: _onNavTap, + onHome: () => _onNavTap(0), + onSearch: + ShellNavigationService.requestSearch, + blurEnabled: ref.watch( + mornyeBlurEnabledProvider, + ), + ), + ), + ), + ), ), ); } diff --git a/lib/screens/mornye_library_screen.dart b/lib/screens/mornye_library_screen.dart index 87283a61..b5e18822 100644 --- a/lib/screens/mornye_library_screen.dart +++ b/lib/screens/mornye_library_screen.dart @@ -10,6 +10,7 @@ import 'package:spotiflac_android/screens/local_album_screen.dart'; import 'package:spotiflac_android/services/album_completeness.dart'; import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart'; import 'package:spotiflac_android/services/library_database.dart'; +import 'package:spotiflac_android/utils/adaptive_layout.dart'; import 'package:spotiflac_android/utils/nav_bar_inset.dart'; import 'package:spotiflac_android/widgets/app_bottom_sheet.dart'; import 'package:spotiflac_android/widgets/app_search_field.dart'; @@ -234,6 +235,7 @@ class _MornyeLibraryScreenState extends ConsumerState { Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; final searchingLibrary = _overview && _query.isNotEmpty; + final listInset = wideListInset(context); final result = searchingLibrary ? const AsyncData(LibraryBrowseState([], hasMore: false)) : ref.watch(libraryBrowseProvider(_request)); @@ -298,13 +300,22 @@ class _MornyeLibraryScreenState extends ConsumerState { physics: const AlwaysScrollableScrollPhysics(), slivers: [ if (_overview) - AppSliverHeader.tabRoot(title: title, actions: actions) + AppSliverHeader.tabRoot( + title: title, + actions: actions, + alignToContent: true, + ) else AppSliverHeader.page(title: title, actions: actions), if (_showSearch) SliverToBoxAdapter( child: Padding( - padding: const EdgeInsets.fromLTRB(24, 8, 24, 16), + padding: EdgeInsets.fromLTRB( + 24 + listInset, + 8, + 24 + listInset, + 16, + ), child: AppSearchField( controller: _search, autofocus: true, @@ -327,7 +338,13 @@ class _MornyeLibraryScreenState extends ConsumerState { if (!searchingLibrary) ...[ if (_overview && _query.isEmpty) SliverPadding( - padding: const EdgeInsets.fromLTRB(24, 0, 24, 20), + // Rows center at the reading width on tablets. + padding: EdgeInsets.fromLTRB( + 24 + listInset, + 0, + 24 + listInset, + 20, + ), sliver: SliverList.list( children: [ for (final category in [ @@ -378,7 +395,12 @@ class _MornyeLibraryScreenState extends ConsumerState { if (_overview) SliverToBoxAdapter( child: Padding( - padding: const EdgeInsets.fromLTRB(24, 8, 24, 16), + padding: EdgeInsets.fromLTRB( + 24 + listInset, + 8, + 24 + listInset, + 16, + ), child: Text( _query.isEmpty ? context.l10n.libraryRecentlyAdded @@ -398,8 +420,8 @@ class _MornyeLibraryScreenState extends ConsumerState { return Column( children: [ ListTile( - contentPadding: const EdgeInsets.symmetric( - horizontal: 24, + contentPadding: EdgeInsets.symmetric( + horizontal: 24 + listInset, vertical: 8, ), leading: ClipOval( @@ -418,19 +440,25 @@ class _MornyeLibraryScreenState extends ConsumerState { artist: entry.artist, ), ), - const Divider(height: 1, indent: 96, endIndent: 24), + Divider( + height: 1, + indent: 96 + listInset, + endIndent: 24 + listInset, + ), ], ); }, ) else SliverPadding( - padding: const EdgeInsets.symmetric(horizontal: 24), + padding: EdgeInsets.symmetric(horizontal: 24 + listInset), sliver: SliverLayoutBuilder( builder: (context, constraints) { - final columns = (constraints.crossAxisExtent / 200) + // ~160dp covers: two on phones, more columns rather + // than giant covers on tablets. + final columns = (constraints.crossAxisExtent / 160) .floor() - .clamp(2, 6); + .clamp(2, 8); final width = (constraints.crossAxisExtent - 20 * (columns - 1)) / columns; diff --git a/lib/screens/network_storage_screen.dart b/lib/screens/network_storage_screen.dart index bc7bef2d..afc763a0 100644 --- a/lib/screens/network_storage_screen.dart +++ b/lib/screens/network_storage_screen.dart @@ -9,6 +9,7 @@ import 'package:spotiflac_android/services/music_player_service.dart'; import 'package:spotiflac_android/services/network_storage_service.dart'; import 'package:spotiflac_android/services/network_storage_error.dart'; import 'package:spotiflac_android/services/network_certificate.dart'; +import 'package:spotiflac_android/utils/adaptive_layout.dart'; import 'package:spotiflac_android/utils/nav_bar_inset.dart'; import 'package:spotiflac_android/widgets/app_sliver_header.dart'; import 'package:spotiflac_android/theme/mornye_theme.dart'; @@ -281,207 +282,218 @@ class _NetworkStorageScreenState extends ConsumerState { ), ], ), - if (_loading || _starting) - SliverToBoxAdapter(child: _NetworkProgress()), - if (!_loading && - !_failed && - connection != null && - connection.protocol != NetworkProtocol.http) - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - AppActionButton( - onPressed: _adding || _added ? null : _addToLibrary, - icon: Icon( - _added ? Icons.check : Icons.library_add_outlined, + // Centered at the reading width on tablets, under the title. + SliverPadding( + padding: EdgeInsets.symmetric(horizontal: wideListInset(context)), + sliver: SliverMainAxisGroup( + slivers: [ + if (_loading || _starting) + SliverToBoxAdapter(child: _NetworkProgress()), + if (!_loading && + !_failed && + connection != null && + connection.protocol != NetworkProtocol.http) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + AppActionButton( + onPressed: _adding || _added ? null : _addToLibrary, + icon: Icon( + _added ? Icons.check : Icons.library_add_outlined, + ), + label: Text( + _added + ? context.l10n.networkLibraryAdded + : context.l10n.networkAddToLibrary, + ), + tonal: true, + ), + const SizedBox(height: 8), + Text( + context.l10n.networkLibraryDescription, + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 12), + AppActionButton( + onPressed: + _selectingDestination || isDownloadDestination + ? null + : _useForDownloads, + icon: Icon( + isDownloadDestination + ? Icons.check + : Icons.download_outlined, + ), + label: Text( + isDownloadDestination + ? context.l10n.networkDownloadSelected + : context.l10n.networkUseForDownloads, + ), + tonal: true, + ), + const SizedBox(height: 8), + Text( + context.l10n.networkDownloadDescription, + style: Theme.of(context).textTheme.bodySmall, + ), + if (_selectingDestination) + const AppProgressIndicator.linear(), + if (_adding) + const Padding( + padding: EdgeInsets.only(top: 8), + child: AppProgressIndicator.linear(), + ), + ], ), - label: Text( - _added - ? context.l10n.networkLibraryAdded - : context.l10n.networkAddToLibrary, - ), - tonal: true, ), - const SizedBox(height: 8), - Text( - context.l10n.networkLibraryDescription, - style: Theme.of(context).textTheme.bodySmall, - ), - const SizedBox(height: 12), - AppActionButton( - onPressed: _selectingDestination || isDownloadDestination - ? null - : _useForDownloads, - icon: Icon( - isDownloadDestination - ? Icons.check - : Icons.download_outlined, + ), + if (_failed) + SliverToBoxAdapter( + child: _NetworkMessage( + icon: context.isMornye + ? CupertinoIcons.wifi_exclamationmark + : Icons.wifi_off_rounded, + message: _networkErrorMessage(context, _failure), + action: AppActionButton( + onPressed: _load, + icon: const Icon(Icons.refresh), + label: Text(context.l10n.dialogRetry), + tonal: true, ), - label: Text( - isDownloadDestination - ? context.l10n.networkDownloadSelected - : context.l10n.networkUseForDownloads, - ), - tonal: true, ), - const SizedBox(height: 8), - Text( - context.l10n.networkDownloadDescription, - style: Theme.of(context).textTheme.bodySmall, - ), - if (_selectingDestination) - const AppProgressIndicator.linear(), - if (_adding) - const Padding( - padding: EdgeInsets.only(top: 8), - child: AppProgressIndicator.linear(), - ), - ], - ), - ), - ), - if (_failed) - SliverToBoxAdapter( - child: _NetworkMessage( - icon: context.isMornye - ? CupertinoIcons.wifi_exclamationmark - : Icons.wifi_off_rounded, - message: _networkErrorMessage(context, _failure), - action: AppActionButton( - onPressed: _load, - icon: const Icon(Icons.refresh), - label: Text(context.l10n.dialogRetry), - tonal: true, - ), - ), - ), - if (!_loading && !_failed && connection == null) ...[ - SliverToBoxAdapter( - child: _NetworkMessage( - icon: context.isMornye - ? CupertinoIcons.cloud - : Icons.dns_outlined, - message: context.l10n.networkStorageDescription, - action: AppActionButton( - onPressed: () => _edit(), - icon: const Icon(Icons.add), - label: Text(context.l10n.networkAddConnection), - ), - ), - ), - if (_connections.isNotEmpty) - SliverToBoxAdapter( - child: SettingsSectionHeader( - title: context.l10n.networkConnections, - ), - ), - SliverList.builder( - itemCount: _connections.length, - itemBuilder: (context, index) { - final c = _connections[index]; - return SettingsGroup( - children: [ - SettingsItem( + ), + if (!_loading && !_failed && connection == null) ...[ + SliverToBoxAdapter( + child: _NetworkMessage( icon: context.isMornye ? CupertinoIcons.cloud : Icons.dns_outlined, - showIconInMornye: true, - title: c.name, - subtitle: - '${_protocolLabel(c.protocol)} · ${c.root.host}', - onTap: () => _open(c), - showDivider: false, - trailing: _NetworkIconAction( - tooltip: context.l10n.networkEditConnection, - onPressed: () => _edit(c), - icon: Icons.edit_outlined, - cupertinoIcon: CupertinoIcons.pencil, + message: context.l10n.networkStorageDescription, + action: AppActionButton( + onPressed: () => _edit(), + icon: const Icon(Icons.add), + label: Text(context.l10n.networkAddConnection), ), ), - ], - ); - }, - ), - ], - if (!_loading && !_failed && connection != null) ...[ - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(24, 4, 24, 20), - child: Text( - '${_protocolLabel(connection.protocol)} · ${connection.root.host}' - '\n/${widget.path}', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, ), - ), - ), - ), - if (visible.isEmpty) - SliverToBoxAdapter( - child: _NetworkMessage( - icon: context.isMornye - ? CupertinoIcons.folder - : Icons.folder_open_rounded, - message: context.l10n.networkNoFiles, - ), - ), - SliverList.builder( - itemCount: visible.length, - itemBuilder: (context, index) { - final entry = visible[index]; - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Material( - color: settingsGroupColor(context), - borderRadius: BorderRadius.vertical( - top: Radius.circular( - index == 0 - ? (context.isMornye - ? 28 - : context.tokens.radiusCard) - : 0, - ), - bottom: Radius.circular( - index == visible.length - 1 - ? (context.isMornye - ? 28 - : context.tokens.radiusCard) - : 0, + if (_connections.isNotEmpty) + SliverToBoxAdapter( + child: SettingsSectionHeader( + title: context.l10n.networkConnections, ), ), - clipBehavior: Clip.antiAlias, - child: SettingsItem( - icon: entry.directory - ? Icons.folder_outlined - : Icons.music_note, - showIconInMornye: true, - title: entry.name, - subtitle: entry.directory - ? null - : entry.extension.toUpperCase(), - showDivider: index != visible.length - 1, - trailing: entry.directory - ? null - : _NetworkIconAction( - tooltip: context.l10n.tooltipPlay, - icon: Icons.play_arrow_rounded, - cupertinoIcon: CupertinoIcons.play_fill, - onPressed: _starting ? null : () => _play(entry), + SliverList.builder( + itemCount: _connections.length, + itemBuilder: (context, index) { + final c = _connections[index]; + return SettingsGroup( + children: [ + SettingsItem( + icon: context.isMornye + ? CupertinoIcons.cloud + : Icons.dns_outlined, + showIconInMornye: true, + title: c.name, + subtitle: + '${_protocolLabel(c.protocol)} · ${c.root.host}', + onTap: () => _open(c), + showDivider: false, + trailing: _NetworkIconAction( + tooltip: context.l10n.networkEditConnection, + onPressed: () => _edit(c), + icon: Icons.edit_outlined, + cupertinoIcon: CupertinoIcons.pencil, ), - onTap: _starting - ? null - : () => entry.directory - ? _open(connection, entry.path) - : _play(entry), + ), + ], + ); + }, + ), + ], + if (!_loading && !_failed && connection != null) ...[ + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(24, 4, 24, 20), + child: Text( + '${_protocolLabel(connection.protocol)} · ${connection.root.host}' + '\n/${widget.path}', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), ), ), - ); - }, + if (visible.isEmpty) + SliverToBoxAdapter( + child: _NetworkMessage( + icon: context.isMornye + ? CupertinoIcons.folder + : Icons.folder_open_rounded, + message: context.l10n.networkNoFiles, + ), + ), + SliverList.builder( + itemCount: visible.length, + itemBuilder: (context, index) { + final entry = visible[index]; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Material( + color: settingsGroupColor(context), + borderRadius: BorderRadius.vertical( + top: Radius.circular( + index == 0 + ? (context.isMornye + ? 28 + : context.tokens.radiusCard) + : 0, + ), + bottom: Radius.circular( + index == visible.length - 1 + ? (context.isMornye + ? 28 + : context.tokens.radiusCard) + : 0, + ), + ), + clipBehavior: Clip.antiAlias, + child: SettingsItem( + icon: entry.directory + ? Icons.folder_outlined + : Icons.music_note, + showIconInMornye: true, + title: entry.name, + subtitle: entry.directory + ? null + : entry.extension.toUpperCase(), + showDivider: index != visible.length - 1, + trailing: entry.directory + ? null + : _NetworkIconAction( + tooltip: context.l10n.tooltipPlay, + icon: Icons.play_arrow_rounded, + cupertinoIcon: CupertinoIcons.play_fill, + onPressed: _starting + ? null + : () => _play(entry), + ), + onTap: _starting + ? null + : () => entry.directory + ? _open(connection, entry.path) + : _play(entry), + ), + ), + ); + }, + ), + ], + ], ), - ], + ), const NavBarSliverSpacer(), ], ), @@ -696,157 +708,162 @@ class _NetworkConnectionFormState extends State<_NetworkConnectionForm> { ? context.l10n.networkAddConnection : context.l10n.networkEditConnection, ), - SliverToBoxAdapter( - child: Form( - key: _form, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - SettingsSectionHeader(title: context.l10n.networkProtocol), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: AbsorbPointer( - absorbing: _busy, - child: context.isMornye - ? MornyeSegmentedControl( - labels: NetworkProtocol.values - .map(_protocolLabel) - .toList(), - selectedIndex: _protocol.index, - onChanged: (index) => setState( - () => _protocol = NetworkProtocol.values[index], - ), - ) - : Row( - children: [ - for (final protocol in NetworkProtocol.values) - Expanded( - child: Padding( - padding: EdgeInsets.only( - right: protocol == NetworkProtocol.http - ? 0 - : 8, - ), - child: SettingsChoiceChip( - label: protocol == NetworkProtocol.http - ? 'HTTP(S)' - : _protocolLabel(protocol), - isSelected: _protocol == protocol, - onTap: () => - setState(() => _protocol = protocol), + SliverPadding( + // Centered at the reading width on tablets, under the title. + padding: EdgeInsets.symmetric(horizontal: wideListInset(context)), + sliver: SliverToBoxAdapter( + child: Form( + key: _form, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SettingsSectionHeader(title: context.l10n.networkProtocol), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: AbsorbPointer( + absorbing: _busy, + child: context.isMornye + ? MornyeSegmentedControl( + labels: NetworkProtocol.values + .map(_protocolLabel) + .toList(), + selectedIndex: _protocol.index, + onChanged: (index) => setState( + () => _protocol = NetworkProtocol.values[index], + ), + ) + : Row( + children: [ + for (final protocol in NetworkProtocol.values) + Expanded( + child: Padding( + padding: EdgeInsets.only( + right: protocol == NetworkProtocol.http + ? 0 + : 8, + ), + child: SettingsChoiceChip( + label: protocol == NetworkProtocol.http + ? 'HTTP(S)' + : _protocolLabel(protocol), + isSelected: _protocol == protocol, + onTap: () => setState( + () => _protocol = protocol, + ), + ), ), ), - ), - ], - ), - ), - ), - SettingsSectionHeader(title: context.l10n.networkServer), - SettingsGroup( - children: [ - _field( - _name, - context.l10n.networkConnectionName, - required: true, - ), - _field( - _address, - context.l10n.networkAddress, - required: true, - hint: _protocol == NetworkProtocol.smb - ? 'smb://server/Music/' - : 'https://server/music/', - ), - const SizedBox(height: 12), - ], - ), - SettingsSectionHeader(title: context.l10n.networkCredentials), - SettingsGroup( - children: [ - _field(_user, context.l10n.networkUsername), - _field( - _password, - context.l10n.networkPassword, - secret: true, - ), - if (_protocol == NetworkProtocol.smb) - _field(_domain, context.l10n.networkDomain), - const SizedBox(height: 12), - ], - ), - Padding( - padding: const EdgeInsets.all(24), - child: Text( - context.l10n.networkStorageDescription, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, + ], + ), ), ), - ), - if (_failed) - _NetworkMessage( - icon: context.isMornye - ? CupertinoIcons.exclamationmark_circle - : Icons.error_outline, - message: switch (_pendingCertificate) { - final certificate? => - '${context.l10n.networkCertificatePrompt}\n\n' - '${certificate.origin}\nSHA-256:\n' - '${certificate.fingerprint}', - null => _networkErrorMessage(context, _failure), - }, + SettingsSectionHeader(title: context.l10n.networkServer), + SettingsGroup( + children: [ + _field( + _name, + context.l10n.networkConnectionName, + required: true, + ), + _field( + _address, + context.l10n.networkAddress, + required: true, + hint: _protocol == NetworkProtocol.smb + ? 'smb://server/Music/' + : 'https://server/music/', + ), + const SizedBox(height: 12), + ], + ), + SettingsSectionHeader(title: context.l10n.networkCredentials), + SettingsGroup( + children: [ + _field(_user, context.l10n.networkUsername), + _field( + _password, + context.l10n.networkPassword, + secret: true, + ), + if (_protocol == NetworkProtocol.smb) + _field(_domain, context.l10n.networkDomain), + const SizedBox(height: 12), + ], ), - if (_pendingCertificate != null) Padding( - padding: const EdgeInsets.fromLTRB(24, 0, 24, 12), + padding: const EdgeInsets.all(24), + child: Text( + context.l10n.networkStorageDescription, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + if (_failed) + _NetworkMessage( + icon: context.isMornye + ? CupertinoIcons.exclamationmark_circle + : Icons.error_outline, + message: switch (_pendingCertificate) { + final certificate? => + '${context.l10n.networkCertificatePrompt}\n\n' + '${certificate.origin}\nSHA-256:\n' + '${certificate.fingerprint}', + null => _networkErrorMessage(context, _failure), + }, + ), + if (_pendingCertificate != null) + Padding( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 12), + child: AppActionButton( + onPressed: _busy + ? null + : () { + final certificate = _pendingCertificate!; + _trustedCertificate = certificate.fingerprint; + _trustedOrigin = certificate.origin; + _save(); + }, + icon: const Icon(Icons.verified_user_outlined), + label: Text(context.l10n.networkTrustCertificate), + ), + ), + if (_busy) _NetworkProgress(), + Padding( + padding: const EdgeInsets.fromLTRB(24, 8, 24, 12), child: AppActionButton( - onPressed: _busy - ? null - : () { - final certificate = _pendingCertificate!; - _trustedCertificate = certificate.fingerprint; - _trustedOrigin = certificate.origin; - _save(); - }, - icon: const Icon(Icons.verified_user_outlined), - label: Text(context.l10n.networkTrustCertificate), + onPressed: _busy ? null : _save, + icon: const Icon(Icons.check), + label: Text(context.l10n.networkConnectSave), ), ), - if (_busy) _NetworkProgress(), - Padding( - padding: const EdgeInsets.fromLTRB(24, 8, 24, 12), - child: AppActionButton( - onPressed: _busy ? null : _save, - icon: const Icon(Icons.check), - label: Text(context.l10n.networkConnectSave), - ), - ), - if (widget.connection != null) - Padding( - padding: const EdgeInsets.fromLTRB(24, 0, 24, 16), - child: context.isMornye - ? CupertinoButton( - onPressed: _busy ? null : _remove, - child: Text( - context.l10n.networkRemoveConnection, - style: TextStyle( - color: Theme.of(context).colorScheme.error, + if (widget.connection != null) + Padding( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 16), + child: context.isMornye + ? CupertinoButton( + onPressed: _busy ? null : _remove, + child: Text( + context.l10n.networkRemoveConnection, + style: TextStyle( + color: Theme.of(context).colorScheme.error, + ), ), + ) + : ExpressiveButton( + text: true, + onPressed: _busy ? null : _remove, + icon: const Icon(Icons.link_off), + style: TextButton.styleFrom( + foregroundColor: Theme.of( + context, + ).colorScheme.error, + ), + child: Text(context.l10n.networkRemoveConnection), ), - ) - : ExpressiveButton( - text: true, - onPressed: _busy ? null : _remove, - icon: const Icon(Icons.link_off), - style: TextButton.styleFrom( - foregroundColor: Theme.of( - context, - ).colorScheme.error, - ), - child: Text(context.l10n.networkRemoveConnection), - ), - ), - ], + ), + ], + ), ), ), ), diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index 4afb75d8..dfec2b78 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -1501,6 +1501,7 @@ class _QueueTabState extends ConsumerState { _searchQuery.isNotEmpty || _searchController.text.trim().isNotEmpty; final shouldShowLibraryControls = hasLibraryContent || hasAnyLibraryItems || hasActiveSearch; + final listInset = wideListInset(context); if (!searchingLibrary) { _scheduleBlankLibraryRepair( hasQueueItems: hasQueueItems, @@ -1553,7 +1554,10 @@ class _QueueTabState extends ConsumerState { child: NestedScrollView( headerSliverBuilder: (context, innerBoxIsScrolled) => [ if (widget.librarySection == null) - AppSliverHeader.tabRoot(title: context.l10n.navLibrary) + AppSliverHeader.tabRoot( + title: context.l10n.navLibrary, + alignToContent: true, + ) else AppSliverHeader.page( title: widget.librarySection == 'playlists' @@ -1564,7 +1568,12 @@ class _QueueTabState extends ConsumerState { if (shouldShowLibraryControls || hasQueueItems) SliverToBoxAdapter( child: Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + padding: EdgeInsets.fromLTRB( + 16 + listInset, + 8, + 16 + listInset, + 0, + ), child: GestureDetector( onTap: () {}, child: AppSearchField( @@ -1587,7 +1596,12 @@ class _QueueTabState extends ConsumerState { widget.librarySection == null) SliverToBoxAdapter( child: Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), + padding: EdgeInsets.fromLTRB( + 16 + listInset, + 12, + 16 + listInset, + 4, + ), child: LayoutBuilder( builder: (context, constraints) { final activePage = _pageIndexFor(historyFilterMode); @@ -1756,7 +1770,12 @@ class _QueueTabState extends ConsumerState { ) : Padding( key: const ValueKey('dl_header'), - padding: const EdgeInsets.fromLTRB(16, 4, 4, 4), + padding: EdgeInsets.fromLTRB( + 16 + wideListInset(context), + 4, + 4 + wideListInset(context), + 4, + ), child: Row( children: [ AppIcon( diff --git a/lib/screens/queue_tab_filter_widgets.dart b/lib/screens/queue_tab_filter_widgets.dart index b347f14a..fa64219a 100644 --- a/lib/screens/queue_tab_filter_widgets.dart +++ b/lib/screens/queue_tab_filter_widgets.dart @@ -8,8 +8,9 @@ extension _QueueTabFilterWidgets on _QueueTabState { String countText, List actions, ) { + final edge = 16 + wideListInset(context); return Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), + padding: EdgeInsets.fromLTRB(edge, 8, edge, 8), child: Row( children: [ Text( @@ -50,6 +51,8 @@ extension _QueueTabFilterWidgets on _QueueTabState { final allFilteredUnifiedItems = filterData.filteredUnifiedItems; final totalTrackCount = filterData.totalTrackCount; final totalAlbumCount = filterData.totalAlbumCount; + // Controls and grids share the centered column of the track list. + final edge = 16 + wideListInset(context); final activeDownloadIds = !hasQueueItems || filterMode == 'albums' ? const [] @@ -378,7 +381,7 @@ extension _QueueTabFilterWidgets on _QueueTabState { ref.read(localLibraryProvider).totalCount > 0))) SliverToBoxAdapter( child: Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), + padding: EdgeInsets.fromLTRB(edge, 8, edge, 8), child: Row( children: [const Spacer(), _buildFilterButton(context)], ), @@ -391,7 +394,7 @@ extension _QueueTabFilterWidgets on _QueueTabState { (_activeFilterCount > 0 || unifiedItems.isNotEmpty)) SliverToBoxAdapter( child: Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), + padding: EdgeInsets.fromLTRB(edge, 8, edge, 8), child: Row( children: [ const Spacer(), @@ -407,7 +410,7 @@ extension _QueueTabFilterWidgets on _QueueTabState { (_activeFilterCount > 0 || unifiedItems.isNotEmpty)) SliverToBoxAdapter( child: Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), + padding: EdgeInsets.fromLTRB(edge, 8, edge, 8), child: Row( children: [ const Spacer(), @@ -420,7 +423,7 @@ extension _QueueTabFilterWidgets on _QueueTabState { if (showFilteringIndicator) SliverToBoxAdapter( child: Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 8), + padding: EdgeInsets.fromLTRB(edge, 12, edge, 8), child: Row( children: [ AppLoadingIndicator( @@ -447,7 +450,7 @@ extension _QueueTabFilterWidgets on _QueueTabState { (filteredGroupedAlbums.isNotEmpty || filteredGroupedLocalAlbums.isNotEmpty)) SliverPadding( - padding: const EdgeInsets.symmetric(horizontal: 16), + padding: EdgeInsets.symmetric(horizontal: edge), sliver: _AnimatedLibrarySliverGrid( maxCrossAxisExtent: _libraryAlbumGridExtent, mainAxisSpacing: 12, @@ -502,7 +505,7 @@ extension _QueueTabFilterWidgets on _QueueTabState { ), ), SliverPadding( - padding: const EdgeInsets.symmetric(horizontal: 16), + padding: EdgeInsets.symmetric(horizontal: edge), sliver: _AnimatedLibrarySliverGrid( maxCrossAxisExtent: _libraryAlbumGridExtent, mainAxisSpacing: 12, @@ -543,7 +546,7 @@ extension _QueueTabFilterWidgets on _QueueTabState { if (filterMode == 'all') ...[ if (historyViewMode == 'grid') SliverPadding( - padding: const EdgeInsets.symmetric(horizontal: 16), + padding: EdgeInsets.symmetric(horizontal: edge), sliver: _AnimatedLibrarySliverGrid( maxCrossAxisExtent: _libraryGridExtent, mainAxisSpacing: 8, @@ -727,7 +730,7 @@ extension _QueueTabFilterWidgets on _QueueTabState { filterMode == 'singles') historyViewMode == 'grid' ? SliverPadding( - padding: const EdgeInsets.symmetric(horizontal: 16), + padding: EdgeInsets.symmetric(horizontal: edge), sliver: _AnimatedLibrarySliverGrid( maxCrossAxisExtent: _libraryGridExtent, mainAxisSpacing: 8, @@ -825,7 +828,7 @@ extension _QueueTabFilterWidgets on _QueueTabState { else if (isPageLoading) SliverToBoxAdapter( child: Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 8), + padding: EdgeInsets.fromLTRB(edge, 12, edge, 8), child: Center( child: AppLoadingIndicator( size: 22, diff --git a/lib/screens/repo_tab.dart b/lib/screens/repo_tab.dart index 088af085..57c3069d 100644 --- a/lib/screens/repo_tab.dart +++ b/lib/screens/repo_tab.dart @@ -15,6 +15,7 @@ import 'package:spotiflac_android/widgets/settings_group.dart'; import 'package:spotiflac_android/widgets/animation_utils.dart'; import 'package:spotiflac_android/screens/repo/extension_details_screen.dart'; import 'package:spotiflac_android/services/extension_storage_service.dart'; +import 'package:spotiflac_android/utils/adaptive_layout.dart'; import 'package:spotiflac_android/utils/nav_bar_inset.dart'; import 'package:spotiflac_android/providers/runtime_profile_provider.dart'; import 'package:spotiflac_android/theme/mornye_theme.dart'; @@ -93,6 +94,8 @@ class _RepoTabState extends ConsumerState { ); } final colorScheme = Theme.of(context).colorScheme; + // Controls share the centered column of the extension list on tablets. + final edge = 16 + wideListInset(context); return Scaffold( body: AppRefreshIndicator( @@ -101,6 +104,7 @@ class _RepoTabState extends ConsumerState { child: CustomScrollView( slivers: [ AppSliverHeader.tabRoot( + alignToContent: true, title: context.l10n.storeTitle, actions: [ if (hasRegistryUrl) @@ -125,7 +129,7 @@ class _RepoTabState extends ConsumerState { else ...[ SliverToBoxAdapter( child: Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), + padding: EdgeInsets.fromLTRB(edge, 8, edge, 8), child: AppSearchField( controller: _searchController, hintText: context.l10n.storeSearch, @@ -142,10 +146,7 @@ class _RepoTabState extends ConsumerState { SliverToBoxAdapter( child: SingleChildScrollView( scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 8, - ), + padding: EdgeInsets.symmetric(horizontal: edge, vertical: 8), child: Row( children: [ _CategoryChip( @@ -207,10 +208,10 @@ class _RepoTabState extends ConsumerState { ), if (isLoading && extensions.isEmpty) - const SliverToBoxAdapter( + SliverToBoxAdapter( child: Padding( - padding: EdgeInsets.all(16), - child: TrackListSkeleton(itemCount: 6), + padding: EdgeInsets.fromLTRB(edge, 16, edge, 16), + child: const TrackListSkeleton(itemCount: 6), ), ) else if (error != null && extensions.isEmpty) @@ -226,7 +227,7 @@ class _RepoTabState extends ConsumerState { else ...[ SliverToBoxAdapter( child: Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), + padding: EdgeInsets.fromLTRB(edge, 8, edge, 8), child: Text( context.l10n.storeExtensionsCount( filteredExtensions.length, @@ -241,8 +242,8 @@ class _RepoTabState extends ConsumerState { if (context.isMornye) SliverToBoxAdapter( child: ExtensionRepoCard( - margin: const EdgeInsets.symmetric( - horizontal: 16, + margin: EdgeInsets.symmetric( + horizontal: edge, vertical: 4, ), child: Column( diff --git a/lib/screens/settings/extensions_page.dart b/lib/screens/settings/extensions_page.dart index 6f53d2e9..8bdd5fd5 100644 --- a/lib/screens/settings/extensions_page.dart +++ b/lib/screens/settings/extensions_page.dart @@ -202,14 +202,20 @@ class _ExtensionsPageState extends ConsumerState { SliverToBoxAdapter( child: SettingsSearchTarget( label: context.l10n.extensionsInstallButton, - child: Padding( - padding: const EdgeInsets.all(16), - child: context.isMornye - ? MornyeGlassPanel.overlay( - radius: 28, - child: installButton, - ) - : installButton, + child: LayoutBuilder( + builder: (context, constraints) { + // Same edges as the extension group above. + final inset = settingsHorizontalInset(constraints.maxWidth); + return Padding( + padding: EdgeInsets.fromLTRB(inset, 16, inset, 16), + child: context.isMornye + ? MornyeGlassPanel.overlay( + radius: 28, + child: installButton, + ) + : installButton, + ); + }, ), ), ), diff --git a/lib/screens/settings/settings_tab.dart b/lib/screens/settings/settings_tab.dart index 360c8d3b..4fe63968 100644 --- a/lib/screens/settings/settings_tab.dart +++ b/lib/screens/settings/settings_tab.dart @@ -499,7 +499,10 @@ class _SettingsTabState extends ConsumerState { if (widget.asPage) AppSliverHeader.page(title: context.l10n.settingsTitle) else - AppSliverHeader.tabRoot(title: context.l10n.settingsTitle), + AppSliverHeader.tabRoot( + title: context.l10n.settingsTitle, + alignToContent: true, + ), SliverToBoxAdapter( child: Padding( padding: EdgeInsets.fromLTRB( diff --git a/lib/screens/weekly_releases_screen.dart b/lib/screens/weekly_releases_screen.dart index 8d4aec01..2bf6b159 100644 --- a/lib/screens/weekly_releases_screen.dart +++ b/lib/screens/weekly_releases_screen.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:intl/intl.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/providers/weekly_releases_provider.dart'; +import 'package:spotiflac_android/utils/adaptive_layout.dart'; import 'package:spotiflac_android/utils/clickable_metadata.dart'; import 'package:spotiflac_android/utils/nav_bar_inset.dart'; import 'package:spotiflac_android/widgets/app_choice_chip.dart'; @@ -46,96 +47,107 @@ class _WeeklyReleasesScreenState extends ConsumerState { physics: const AlwaysScrollableScrollPhysics(), slivers: [ AppSliverHeader.page(title: context.l10n.weeklyReleases), - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(context.l10n.weeklyReleasesDescription), - const SizedBox(height: 16), - Wrap( - spacing: 8, - children: [ - for (final days in [7, 30]) - AppChoiceChip( - label: Text(context.l10n.listeningStatsDays(days)), - selected: _days == days, - onSelected: (_) => setState(() => _days = days), - ), - ], - ), - ], - ), - ), - ), - ...feed.when( - loading: () => [ - const SliverToBoxAdapter( - child: Center(child: AppLoadingIndicator()), - ), - ], - error: (_, _) => [ - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.all(24), - child: Text(context.l10n.weeklyReleasesLoadError), - ), - ), - ], - data: (data) { - final releases = data.inPeriod(DateTime.now(), _days); - return [ - if (data.unavailableArtists.isNotEmpty) - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.all(16), - child: Text( - '${context.l10n.weeklyReleasesUnavailable}\n' - '${data.unavailableArtists.join(', ')}', - ), - ), - ), - if (releases.isEmpty) - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.all(24), - child: Text(context.l10n.weeklyReleasesEmpty), - ), - ), - SliverList.list( - children: [ - for (final release in releases) - ListTile( - leading: release.coverUrl == null - ? const SizedBox( - width: 56, - height: 56, - child: Icon(Icons.album_outlined), - ) - : CachedCoverImage( - imageUrl: release.coverUrl!, - width: 56, - height: 56, - borderRadius: BorderRadius.circular(8), + // Centered at the reading width on tablets, under the title. + SliverPadding( + padding: EdgeInsets.symmetric(horizontal: wideListInset(context)), + sliver: SliverMainAxisGroup( + slivers: [ + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(context.l10n.weeklyReleasesDescription), + const SizedBox(height: 16), + Wrap( + spacing: 8, + children: [ + for (final days in [7, 30]) + AppChoiceChip( + label: Text( + context.l10n.listeningStatsDays(days), + ), + selected: _days == days, + onSelected: (_) => + setState(() => _days = days), ), - title: Text(release.name), - subtitle: Text( - '${release.artist}\n${DateFormat.yMMMd(Localizations.localeOf(context).toLanguageTag()).format(release.date)}', + ], ), - onTap: () => navigateToAlbum( - context, - albumName: release.name, - albumId: release.id, - artistName: release.artist, - coverUrl: release.coverUrl, - extensionId: release.providerId, - ), - ), - ], + ], + ), + ), ), - ]; - }, + ...feed.when( + loading: () => [ + const SliverToBoxAdapter( + child: Center(child: AppLoadingIndicator()), + ), + ], + error: (_, _) => [ + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(24), + child: Text(context.l10n.weeklyReleasesLoadError), + ), + ), + ], + data: (data) { + final releases = data.inPeriod(DateTime.now(), _days); + return [ + if (data.unavailableArtists.isNotEmpty) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(16), + child: Text( + '${context.l10n.weeklyReleasesUnavailable}\n' + '${data.unavailableArtists.join(', ')}', + ), + ), + ), + if (releases.isEmpty) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(24), + child: Text(context.l10n.weeklyReleasesEmpty), + ), + ), + SliverList.list( + children: [ + for (final release in releases) + ListTile( + leading: release.coverUrl == null + ? const SizedBox( + width: 56, + height: 56, + child: Icon(Icons.album_outlined), + ) + : CachedCoverImage( + imageUrl: release.coverUrl!, + width: 56, + height: 56, + borderRadius: BorderRadius.circular(8), + ), + title: Text(release.name), + subtitle: Text( + '${release.artist}\n${DateFormat.yMMMd(Localizations.localeOf(context).toLanguageTag()).format(release.date)}', + ), + onTap: () => navigateToAlbum( + context, + albumName: release.name, + albumId: release.id, + artistName: release.artist, + coverUrl: release.coverUrl, + extensionId: release.providerId, + ), + ), + ], + ), + ]; + }, + ), + ], + ), ), SliverToBoxAdapter( child: SizedBox(height: context.navBarBottomInset), diff --git a/lib/utils/adaptive_layout.dart b/lib/utils/adaptive_layout.dart index 0c540fdd..b4b169ad 100644 --- a/lib/utils/adaptive_layout.dart +++ b/lib/utils/adaptive_layout.dart @@ -4,12 +4,20 @@ import 'package:flutter/material.dart'; /// is reserved for genuinely desktop-width windows; the old 600dp threshold /// made an iPad portrait layout look like a shrunken desktop app. const double navigationRailBreakpoint = 1440; -const double defaultWideContentMaxWidth = 960; -const double maxWideContentInset = 32; + +/// Material's expanded window class. Landscape tablets switch the Material +/// theme to a navigation rail and Mornye to compact, centered chrome; portrait +/// tablets stay below it. +const double expandedLayoutBreakpoint = 840; +const double defaultWideContentMaxWidth = 840; bool useNavigationRailForWidth(double width) => width >= navigationRailBreakpoint; +/// The Material theme follows Material's expanded window class. +bool useMaterialNavigationRailForWidth(double width) => + width >= expandedLayoutBreakpoint; + /// Explicit horizontal safe spacing for controls drawn in full-bleed detail /// headers. iOS reports no left/right safe-area inset in portrait, so toolbar /// buttons otherwise land only a few pixels from the device edge. Filled @@ -81,15 +89,13 @@ class AdaptiveUiScaler extends StatelessWidget { } } -/// Widest content span for a surface of [maxWidth]: content is never narrower -/// than [contentMaxWidth], and the centering margin never exceeds 32dp per -/// side so tablets retain near-full-width rows. +/// Widest content span for a surface of [maxWidth]: lists, settings and +/// detail content stop at [contentMaxWidth], a comfortable reading width, and +/// center on wider tablet and landscape surfaces. double adaptiveContentMaxWidth( double maxWidth, { double contentMaxWidth = defaultWideContentMaxWidth, -}) => maxWidth > contentMaxWidth + (maxWideContentInset * 2) - ? maxWidth - (maxWideContentInset * 2) - : contentMaxWidth; +}) => maxWidth < contentMaxWidth ? maxWidth : contentMaxWidth; /// Horizontal inset that centers content of [maxWidth] at /// [adaptiveContentMaxWidth]; zero once it fits. Prefer this constraint-based @@ -98,9 +104,7 @@ double adaptiveContentMaxWidth( double wideInsetForWidth( double maxWidth, { double contentMaxWidth = defaultWideContentMaxWidth, -}) => maxWidth > contentMaxWidth - ? ((maxWidth - contentMaxWidth) / 2).clamp(0.0, maxWideContentInset) - : 0; +}) => maxWidth > contentMaxWidth ? (maxWidth - contentMaxWidth) / 2 : 0; /// Horizontal inset that centers full-width list content on tablets/landscape; /// zero on phones, so rows stop stretching across the whole screen. diff --git a/lib/widgets/app_sliver_header.dart b/lib/widgets/app_sliver_header.dart index a1fcf348..514c4afc 100644 --- a/lib/widgets/app_sliver_header.dart +++ b/lib/widgets/app_sliver_header.dart @@ -13,9 +13,13 @@ import 'package:spotiflac_android/utils/app_bar_layout.dart'; 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; + const AppSliverHeader.tabRoot({ + super.key, + required this.title, + this.actions, + this.alignToContent = false, + }) : 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. @@ -24,6 +28,7 @@ class AppSliverHeader extends StatelessWidget { required this.title, this.actions, this.leading, + this.alignToContent = true, }) : _showLeading = true; final String title; @@ -32,6 +37,10 @@ class AppSliverHeader extends StatelessWidget { /// Overrides the default back button. Only used by the page variant. final Widget? leading; + /// Lines the expanded title up with content centered by [wideListInset] on + /// tablets. Full-bleed pages (carousels, grids) keep it at the screen edge. + final bool alignToContent; + final bool _showLeading; /// Left inset of the collapsed title when a leading button is present: enough @@ -46,7 +55,14 @@ class AppSliverHeader extends StatelessWidget { final tokens = context.tokens; final colorScheme = Theme.of(context).colorScheme; final topPadding = normalizedHeaderTopPadding(context); - final maxHeight = tokens.headerExpandedHeight + topPadding; + // Short landscape windows give the large title less headroom so content + // starts higher. Portrait phones, even small ones, keep the full header. + final screenSize = MediaQuery.sizeOf(context); + final expandedHeight = + screenSize.height < screenSize.width && screenSize.height < 720 + ? tokens.headerExpandedHeight - 24 + : tokens.headerExpandedHeight; + final maxHeight = expandedHeight + topPadding; final edgeInset = detailHeaderEdgeInset(context); return SliverAppBar( @@ -108,11 +124,13 @@ class AppSliverHeader extends StatelessWidget { final expandRatio = extentDelta > 0 ? ((currentExtent - minExtent) / extentDelta).clamp(0.0, 1.0) : 0.0; + final contentMargin = + _contentMargin + (alignToContent ? wideListInset(context) : 0); final leftPadding = _showLeading ? (_leadingClearance + edgeInset) - - (((_leadingClearance + edgeInset) - _contentMargin) * + (((_leadingClearance + edgeInset) - contentMargin) * expandRatio) - : _contentMargin; + : contentMargin; final fontSize = tokens.headerCollapsedTitleSize + (tokens.headerExpandedTitleSize - diff --git a/lib/widgets/expressive_navigation_bar.dart b/lib/widgets/expressive_navigation_bar.dart index 50efb61b..eaf974c4 100644 --- a/lib/widgets/expressive_navigation_bar.dart +++ b/lib/widgets/expressive_navigation_bar.dart @@ -60,3 +60,72 @@ class ExpressiveNavigationBar extends StatelessWidget { ); } } + +/// Side navigation for wide layouts: the collapsed Material 3 Expressive +/// rail, with labels under the icons and a pill indicator that springs +/// between destinations. Reduced motion keeps Flutter's [NavigationRail]. +class ExpressiveNavigationRail extends StatelessWidget { + const ExpressiveNavigationRail({ + super.key, + required this.destinations, + required this.selectedIndex, + required this.onDestinationSelected, + required this.backgroundColor, + }); + + final List destinations; + final int selectedIndex; + final ValueChanged onDestinationSelected; + final Color backgroundColor; + + @override + Widget build(BuildContext context) { + if (!materialExpressiveEnabled(context)) { + // The rail needs ~300dp of height for four labeled destinations; on + // short viewports it must scroll, not overflow. + return LayoutBuilder( + builder: (context, constraints) => SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints(minHeight: constraints.maxHeight), + child: IntrinsicHeight( + child: NavigationRail( + selectedIndex: selectedIndex, + onDestinationSelected: onDestinationSelected, + labelType: NavigationRailLabelType.all, + backgroundColor: backgroundColor, + destinations: [ + for (final destination in destinations) + NavigationRailDestination( + icon: destination.icon, + selectedIcon: destination.selectedIcon, + label: Text(destination.label), + ), + ], + ), + ), + ), + ), + ); + } + return MaterialExpressiveScope( + child: M3ENavigationRail( + type: M3ENavigationRailType.alwaysCollapse, + selectedIndex: selectedIndex, + onDestinationSelected: onDestinationSelected, + background: backgroundColor, + sections: [ + M3ENavigationRailSection( + destinations: [ + for (final destination in destinations) + M3ENavigationRailDestination( + icon: destination.icon, + selectedIcon: destination.selectedIcon, + label: destination.label, + ), + ], + ), + ], + ), + ); + } +} diff --git a/lib/widgets/settings_group.dart b/lib/widgets/settings_group.dart index b5cf6a24..3046245f 100644 --- a/lib/widgets/settings_group.dart +++ b/lib/widgets/settings_group.dart @@ -199,6 +199,12 @@ class _SettingsSearchTargetState extends State { bool settingsRowsConnected(BuildContext context) => context.dependOnInheritedWidgetOfExactType<_ConnectedRowsScope>() != null; +/// Horizontal margin of settings content on a surface [maxWidth] wide: the +/// 16dp edge plus the wide-layout centering inset. Groups, section headers, +/// info cards and page-level buttons share it so their edges line up. +double settingsHorizontalInset(double maxWidth) => + 16 + (maxWidth.isFinite ? wideInsetForWidth(maxWidth) : 0); + /// Corners of one row in an expressive connected list: the card radius at the /// [first] and [last] ends of the run, tight corners between rows. BorderRadius connectedRowRadius( @@ -652,21 +658,28 @@ class SettingsSectionHeader extends StatelessWidget { @override Widget build(BuildContext context) { - final content = Padding( - padding: const EdgeInsets.fromLTRB(32, 24, 32, 8), - child: Text( - title, - style: - (context.isMornye - ? Theme.of(context).textTheme.bodyLarge - : Theme.of(context).textTheme.titleSmall) - ?.copyWith( - color: context.isMornye - ? Theme.of(context).colorScheme.onSurfaceVariant - : Theme.of(context).colorScheme.primary, - fontWeight: FontWeight.w600, - ), - ), + final label = Text( + title, + style: + (context.isMornye + ? Theme.of(context).textTheme.bodyLarge + : Theme.of(context).textTheme.titleSmall) + ?.copyWith( + color: context.isMornye + ? Theme.of(context).colorScheme.onSurfaceVariant + : Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.w600, + ), + ); + // Aligned with the row content of the groups below at any width. + final content = LayoutBuilder( + builder: (context, constraints) { + final inset = settingsHorizontalInset(constraints.maxWidth) + 16; + return Padding( + padding: EdgeInsets.fromLTRB(inset, 24, inset, 8), + child: label, + ); + }, ); return SettingsSearchTarget(label: title, child: content); } @@ -907,22 +920,34 @@ class SettingsInfoCard extends StatelessWidget { ], ), ); - return Padding( - padding: - margin ?? - EdgeInsets.symmetric( - horizontal: tokens.gapLg, - vertical: tokens.gapXs, - ), - child: useGlass - ? MornyeGlassPanel.overlay(child: content) - : DecoratedBox( - decoration: BoxDecoration( - color: background, - borderRadius: tokens.borderRadiusCard, - ), - child: content, + final card = useGlass + ? MornyeGlassPanel.overlay(child: content) + : DecoratedBox( + decoration: BoxDecoration( + color: background, + borderRadius: tokens.borderRadiusCard, ), + child: content, + ); + final resolved = + (margin ?? + EdgeInsets.symmetric( + horizontal: tokens.gapLg, + vertical: tokens.gapXs, + )) + .resolve(Directionality.of(context)); + // Wide layouts center groups; the card keeps the same edges. + return LayoutBuilder( + builder: (context, constraints) { + final wide = settingsHorizontalInset(constraints.maxWidth) - 16; + return Padding( + padding: resolved.copyWith( + left: resolved.left + wide, + right: resolved.right + wide, + ), + child: card, + ); + }, ); } } diff --git a/lib/widgets/track_detail_actions.dart b/lib/widgets/track_detail_actions.dart index 90540777..50c21e02 100644 --- a/lib/widgets/track_detail_actions.dart +++ b/lib/widgets/track_detail_actions.dart @@ -8,6 +8,7 @@ import 'package:spotiflac_android/providers/extension_provider.dart'; import 'package:spotiflac_android/providers/library_collections_provider.dart'; import 'package:spotiflac_android/providers/local_library_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; +import 'package:spotiflac_android/utils/adaptive_layout.dart'; import 'package:spotiflac_android/utils/logger.dart'; import 'package:spotiflac_android/widgets/download_service_picker.dart'; import 'package:spotiflac_android/widgets/app_snack_bar.dart'; @@ -308,9 +309,11 @@ Widget buildTrackListFooter( final countText = context.l10n.tracksCount(tracks.length); lines.add(totalMinutes > 0 ? '$countText • $totalMinutes min' : countText); + // Lines up with the track list, which centers on tablets. + final inset = wideListInset(context); return SliverToBoxAdapter( child: Padding( - padding: const EdgeInsets.fromLTRB(20, 8, 20, 8), + padding: EdgeInsets.fromLTRB(20 + inset, 8, 20 + inset, 8), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ diff --git a/test/adaptive_layout_test.dart b/test/adaptive_layout_test.dart index dc9a6ef5..4b5dfe6e 100644 --- a/test/adaptive_layout_test.dart +++ b/test/adaptive_layout_test.dart @@ -16,15 +16,27 @@ void main() { }); }); + group('adaptive navigation (Material)', () { + test('switches to the rail at the expanded window class', () { + expect(useMaterialNavigationRailForWidth(390), isFalse); + expect(useMaterialNavigationRailForWidth(683), isFalse); + expect( + useMaterialNavigationRailForWidth(expandedLayoutBreakpoint), + isTrue, + ); + }); + }); + group('wide content', () { - test('uses the full tablet width before applying a small gutter', () { - expect(wideInsetForWidth(853), 0); - expect(wideInsetForWidth(1024), 32); - expect(wideInsetForWidth(1366), maxWideContentInset); + test('fills compact widths and centers at the reading width', () { + expect(wideInsetForWidth(640), 0); + expect(wideInsetForWidth(defaultWideContentMaxWidth), 0); + expect(wideInsetForWidth(1040), 100); }); - test('never removes more than the shared gutter from each side', () { - expect(adaptiveContentMaxWidth(1366), 1302); + test('caps content at the reading width', () { + expect(adaptiveContentMaxWidth(600), 600); + expect(adaptiveContentMaxWidth(1366), defaultWideContentMaxWidth); }); });