mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-20 22:41:38 +02:00
feat(layout): adapt tablet navigation and center wide content
This commit is contained in:
1 parent
9495a6bf66
commit
bd0b4a50c3
19 files changed
+910
-651
No files matched your search
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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: [
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user