feat(layout): adapt tablet navigation and center wide content

This commit is contained in:
zarzet committed 2026-10-15 02:51:53 +07:00
1 parent 9495a6bf66
commit bd0b4a50c3
19 files changed
+910 -651

No files matched your search

+9 -3
View File
@@ -173,7 +173,11 @@ class _HomeTabState extends ConsumerState<HomeTab>
// 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<HomeTab>
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<HomeTab>
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(),
+16 -7
View File
@@ -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,
+1 -1
View File
@@ -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(
+90 -80
View File
@@ -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),
),
),
),
),
],
),
),
],
+64 -50
View File
@@ -933,10 +933,15 @@ class _MainShellState extends ConsumerState<MainShell>
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<MainShell>
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<MainShell>
child: SafeArea(
top: false,
minimum: const EdgeInsets.fromLTRB(20, 0, 20, 8),
child: useNavigationRail
? const MiniPlayer()
: ValueListenableBuilder<bool>(
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<bool>(
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,
),
),
),
),
),
),
);
}
+38 -10
View File
@@ -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<MornyeLibraryScreen> {
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<MornyeLibraryScreen> {
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<MornyeLibraryScreen> {
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<MornyeLibraryScreen> {
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<MornyeLibraryScreen> {
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<MornyeLibraryScreen> {
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;
+342 -325
View File
@@ -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<NetworkStorageScreen> {
),
],
),
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),
),
),
],
),
],
),
),
),
),
+23 -4
View File
@@ -1501,6 +1501,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
_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<QueueTab> {
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<QueueTab> {
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<QueueTab> {
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<QueueTab> {
)
: 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(
+13 -10
View File
@@ -8,8 +8,9 @@ extension _QueueTabFilterWidgets on _QueueTabState {
String countText,
List<Widget> 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 <String>[]
@@ -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,
+12 -11
View File
@@ -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<RepoTab> {
);
}
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<RepoTab> {
child: CustomScrollView(
slivers: [
AppSliverHeader.tabRoot(
alignToContent: true,
title: context.l10n.storeTitle,
actions: [
if (hasRegistryUrl)
@@ -125,7 +129,7 @@ class _RepoTabState extends ConsumerState<RepoTab> {
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<RepoTab> {
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<RepoTab> {
),
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<RepoTab> {
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<RepoTab> {
if (context.isMornye)
SliverToBoxAdapter(
child: ExtensionRepoCard(
margin: const EdgeInsets.symmetric(
horizontal: 16,
margin: EdgeInsets.symmetric(
horizontal: edge,
vertical: 4,
),
child: Column(
+14 -8
View File
@@ -202,14 +202,20 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
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,
);
},
),
),
),
+4 -1
View File
@@ -499,7 +499,10 @@ class _SettingsTabState extends ConsumerState<SettingsTab> {
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(
+99 -87
View File
@@ -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<WeeklyReleasesScreen> {
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),
+15 -11
View File
@@ -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.
+24 -6
View File
@@ -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 -
@@ -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<NavigationDestination> destinations;
final int selectedIndex;
final ValueChanged<int> 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,
),
],
),
],
),
);
}
}
+55 -30
View File
@@ -199,6 +199,12 @@ class _SettingsSearchTargetState extends State<SettingsSearchTarget> {
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,
);
},
);
}
}
+4 -1
View File
@@ -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: [
+18 -6
View File
@@ -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);
});
});