refactor(ui): standardize modal sheets

This commit is contained in:
zarzet
2026-07-29 17:10:42 +07:00
parent fa0c7b20b5
commit dbdbf0ca41
16 changed files with 306 additions and 397 deletions
+167
View File
@@ -0,0 +1,167 @@
import 'package:flutter/material.dart';
import 'package:spotiflac_android/theme/app_tokens.dart';
/// The drag handle shown at the top of every modal sheet.
///
/// Seventeen copies of this container existed across the app in two sizes
/// (40x4 tinted `onSurfaceVariant`, 32x4 tinted `outlineVariant`) with four
/// different margins. Sheets that opt into Material's own `showDragHandle`
/// get an equivalent affordance from the framework and should not add this.
class AppSheetHandle extends StatelessWidget {
const AppSheetHandle({super.key, this.margin});
/// Overrides the default spacing around the handle. Sheets whose first row
/// already carries top padding pass [EdgeInsets.zero].
final EdgeInsetsGeometry? margin;
static const double _width = 40;
static const double _height = 4;
@override
Widget build(BuildContext context) {
final tokens = context.tokens;
return ExcludeSemantics(
child: Center(
child: Container(
width: _width,
height: _height,
margin:
margin ??
EdgeInsets.only(top: tokens.gapMd, bottom: tokens.gapSm),
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.onSurfaceVariant.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(_height / 2),
),
),
),
);
}
}
/// Standard chrome for modal sheet content: drag handle, optional title block,
/// height cap, keyboard inset and bottom safe area, so each sheet only supplies
/// its own body.
class AppBottomSheet extends StatelessWidget {
const AppBottomSheet({
super.key,
required this.child,
this.showHandle = true,
this.title,
this.subtitle,
this.maxHeightFactor,
});
final Widget child;
final bool showHandle;
/// Leading title row. Rendered with the app's single sheet-title style
/// instead of the three variants that had accumulated across sheets.
final String? title;
/// Secondary line under [title], e.g. the track a sheet is acting on.
final String? subtitle;
/// Caps the sheet at this fraction of the screen height.
final double? maxHeightFactor;
@override
Widget build(BuildContext context) {
final tokens = context.tokens;
final theme = Theme.of(context);
Widget content = Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (showHandle) const AppSheetHandle(),
if (title != null)
Padding(
padding: EdgeInsets.fromLTRB(
tokens.gapXl,
tokens.gapLg,
tokens.gapXl,
subtitle == null ? tokens.gapSm : tokens.gapXs,
),
child: Text(
title!,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
),
if (subtitle != null)
Padding(
padding: EdgeInsets.fromLTRB(
tokens.gapXl,
0,
tokens.gapXl,
tokens.gapMd,
),
child: Text(
subtitle!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
Flexible(child: child),
],
);
if (maxHeightFactor != null) {
content = ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.sizeOf(context).height * maxHeightFactor!,
),
child: content,
);
}
// Sheets containing text fields must lift above the keyboard; doing it here
// means no call site has to remember `viewInsets`.
final keyboardInset = MediaQuery.viewInsetsOf(context).bottom;
return Padding(
padding: EdgeInsets.only(bottom: keyboardInset),
child: SafeArea(top: false, child: content),
);
}
}
/// Opens a modal bottom sheet wrapped in [AppBottomSheet].
///
/// The shape comes from `ThemeData.bottomSheetTheme`, which is derived from
/// [AppTokens.radiusSheet]; call sites no longer pass a `shape` and therefore
/// cannot drift from the scale.
Future<T?> showAppBottomSheet<T>({
required BuildContext context,
required WidgetBuilder builder,
String? title,
String? subtitle,
double? maxHeightFactor,
bool isScrollControlled = true,
bool isDismissible = true,
bool enableDrag = true,
bool useRootNavigator = false,
bool showHandle = true,
Color? backgroundColor,
}) {
return showModalBottomSheet<T>(
context: context,
isScrollControlled: isScrollControlled,
isDismissible: isDismissible,
enableDrag: enableDrag,
useRootNavigator: useRootNavigator,
backgroundColor: backgroundColor,
builder: (sheetContext) => AppBottomSheet(
showHandle: showHandle,
title: title,
subtitle: subtitle,
maxHeightFactor: maxHeightFactor,
child: builder(sheetContext),
),
);
}
+2 -10
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:spotiflac_android/widgets/app_bottom_sheet.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/utils/audio_conversion_utils.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
@@ -100,16 +101,7 @@ class _BatchConvertSheetState extends State<BatchConvertSheet> {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: cs.onSurfaceVariant.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(2),
),
),
),
const AppSheetHandle(),
const SizedBox(height: 18),
Text(
widget.title,
+36 -85
View File
@@ -1,6 +1,7 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:spotiflac_android/widgets/app_bottom_sheet.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
@@ -30,14 +31,13 @@ class CrossExtensionShareSheet extends ConsumerStatefulWidget {
required String sourceExtensionId,
}) {
final colorScheme = Theme.of(context).colorScheme;
return showModalBottomSheet<void>(
return showAppBottomSheet<void>(
context: context,
useRootNavigator: true,
isScrollControlled: true,
backgroundColor: colorScheme.surfaceContainerHigh,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
),
title: context.l10n.openInOtherServices,
subtitle: artists.isNotEmpty ? '$name - $artists' : name,
maxHeightFactor: 0.82,
builder: (_) => CrossExtensionShareSheet(
name: name,
artists: artists,
@@ -93,93 +93,44 @@ class _CrossExtensionShareSheetState
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
return SafeArea(
top: false,
child: ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.sizeOf(context).height * 0.82,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 8),
Center(
child: Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(2),
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 16, 24, 4),
return FutureBuilder<List<CrossExtensionShareResult>>(
future: _future,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const SizedBox(
height: 180,
child: Center(child: CircularProgressIndicator()),
);
}
final results = snapshot.data ?? const [];
if (results.isEmpty) {
return SizedBox(
height: 180,
child: Center(
child: Text(
context.l10n.openInOtherServices,
style: textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 12),
child: Text(
widget.artists.isNotEmpty
? '${widget.name} - ${widget.artists}'
: widget.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
context.l10n.shareSheetNoExtensions,
style: textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
Flexible(
child: FutureBuilder<List<CrossExtensionShareResult>>(
future: _future,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const SizedBox(
height: 180,
child: Center(child: CircularProgressIndicator()),
);
}
);
}
final results = snapshot.data ?? const [];
if (results.isEmpty) {
return SizedBox(
height: 180,
child: Center(
child: Text(
context.l10n.shareSheetNoExtensions,
style: textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
);
}
return ListView.builder(
shrinkWrap: true,
padding: const EdgeInsets.only(bottom: 16, top: 4),
itemBuilder: (context, index) {
final result = results[index];
return _CrossExtensionShareTile(
result: result,
iconPath: _iconPathFor(result.extensionId),
);
},
itemCount: results.length,
);
},
),
),
],
),
),
return ListView.builder(
shrinkWrap: true,
padding: const EdgeInsets.only(bottom: 16, top: 4),
itemBuilder: (context, index) {
final result = results[index];
return _CrossExtensionShareTile(
result: result,
iconPath: _iconPathFor(result.extensionId),
);
},
itemCount: results.length,
);
},
);
}
}
+3 -23
View File
@@ -1,6 +1,7 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:spotiflac_android/widgets/app_bottom_sheet.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
@@ -40,9 +41,6 @@ class DownloadServicePicker extends ConsumerStatefulWidget {
context: context,
useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
),
isScrollControlled: true,
builder: (context) => DownloadServicePicker(
trackName: trackName,
@@ -137,17 +135,7 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
),
] else ...[
const SizedBox(height: 8),
Center(
child: Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(2),
),
),
),
const AppSheetHandle(),
],
Padding(
@@ -517,15 +505,7 @@ class _TrackInfoHeaderState extends State<_TrackInfoHeader> {
),
child: Column(
children: [
const SizedBox(height: 8),
Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(2),
),
),
const AppSheetHandle(),
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
child: Row(
+35 -73
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:spotiflac_android/widgets/app_bottom_sheet.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path/path.dart' as p;
import 'package:spotiflac_android/l10n/l10n.dart';
@@ -18,14 +19,12 @@ class DuplicateReviewSheet extends ConsumerStatefulWidget {
static Future<void> show(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return showModalBottomSheet<void>(
return showAppBottomSheet<void>(
context: context,
useRootNavigator: true,
isScrollControlled: true,
backgroundColor: colorScheme.surfaceContainerHigh,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
),
title: context.l10n.duplicatesTitle,
maxHeightFactor: 0.85,
builder: (_) => const DuplicateReviewSheet(),
);
}
@@ -154,76 +153,39 @@ class _DuplicateReviewSheetState extends ConsumerState<DuplicateReviewSheet> {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return SafeArea(
child: ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.sizeOf(context).height * 0.85,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 8),
Center(
child: Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(2),
),
return FutureBuilder<List<IsrcDuplicateGroup>>(
future: _groupsFuture,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const Padding(
padding: EdgeInsets.all(32),
child: Center(child: CircularProgressIndicator()),
);
}
final groups = snapshot.data ?? const [];
if (groups.isEmpty) {
return Padding(
padding: const EdgeInsets.fromLTRB(24, 8, 24, 32),
child: Text(
context.l10n.duplicatesEmpty,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
child: Align(
alignment: Alignment.centerLeft,
child: Text(
context.l10n.duplicatesTitle,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
),
),
Flexible(
child: FutureBuilder<List<IsrcDuplicateGroup>>(
future: _groupsFuture,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const Padding(
padding: EdgeInsets.all(32),
child: Center(child: CircularProgressIndicator()),
);
}
final groups = snapshot.data ?? const [];
if (groups.isEmpty) {
return Padding(
padding: const EdgeInsets.fromLTRB(24, 8, 24, 32),
child: Text(
context.l10n.duplicatesEmpty,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
);
}
// shrinkWrap keeps a small sheet compact but builds every
// group eagerly; past a screenful, switch to a lazy
// full-height list so a large duplicate set can't jank the
// opening frame.
final compact = groups.length <= 12;
return ListView.builder(
shrinkWrap: compact,
itemCount: groups.length,
itemBuilder: (context, index) =>
_buildGroup(context, colorScheme, groups[index]),
);
},
),
),
],
),
),
);
}
// shrinkWrap keeps a small sheet compact but builds every
// group eagerly; past a screenful, switch to a lazy
// full-height list so a large duplicate set can't jank the
// opening frame.
final compact = groups.length <= 12;
return ListView.builder(
shrinkWrap: compact,
itemCount: groups.length,
itemBuilder: (context, index) =>
_buildGroup(context, colorScheme, groups[index]),
);
},
);
}
+51 -91
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:spotiflac_android/widgets/app_bottom_sheet.dart';
import 'package:flutter/services.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
@@ -20,13 +21,12 @@ class OpenOnPlatformSheet extends StatelessWidget {
String isrc = '',
}) {
final colorScheme = Theme.of(context).colorScheme;
return showModalBottomSheet<void>(
return showAppBottomSheet<void>(
context: context,
useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
),
title: context.l10n.trackOpenOn,
maxHeightFactor: 0.7,
builder: (_) => OpenOnPlatformSheet(spotifyId: spotifyId, isrc: isrc),
);
}
@@ -82,94 +82,54 @@ class OpenOnPlatformSheet extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return SafeArea(
child: ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.sizeOf(context).height * 0.7,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 8),
Center(
child: Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(2),
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
child: Align(
alignment: Alignment.centerLeft,
child: Text(
context.l10n.trackOpenOn,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
),
),
Flexible(
child: FutureBuilder<Map<String, String>>(
future: PlatformBridge.getTrackPlatformLinks(
spotifyId: spotifyId,
isrc: isrc,
),
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const Padding(
padding: EdgeInsets.all(32),
child: Center(child: CircularProgressIndicator()),
);
}
final links = snapshot.data ?? const <String, String>{};
if (links.isEmpty) {
return Padding(
padding: const EdgeInsets.fromLTRB(24, 8, 24, 32),
child: Text(
context.l10n.trackOpenOnNoLinks,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
);
}
final entries = links.entries.toList()
..sort(
(a, b) =>
_displayName(a.key).compareTo(_displayName(b.key)),
);
return SingleChildScrollView(
child: Column(
children: [
SettingsGroup(
children: [
for (final entry in entries)
ListTile(
title: Text(_displayName(entry.key)),
trailing: Icon(
Icons.open_in_new,
size: 18,
color: colorScheme.onSurfaceVariant,
),
onTap: () => _openLink(context, entry.value),
),
],
),
const SizedBox(height: 16),
],
),
);
},
),
),
],
),
return FutureBuilder<Map<String, String>>(
future: PlatformBridge.getTrackPlatformLinks(
spotifyId: spotifyId,
isrc: isrc,
),
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const Padding(
padding: EdgeInsets.all(32),
child: Center(child: CircularProgressIndicator()),
);
}
final links = snapshot.data ?? const <String, String>{};
if (links.isEmpty) {
return Padding(
padding: const EdgeInsets.fromLTRB(24, 8, 24, 32),
child: Text(
context.l10n.trackOpenOnNoLinks,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
);
}
final entries = links.entries.toList()
..sort((a, b) => _displayName(a.key).compareTo(_displayName(b.key)));
return SingleChildScrollView(
child: Column(
children: [
SettingsGroup(
children: [
for (final entry in entries)
ListTile(
title: Text(_displayName(entry.key)),
trailing: Icon(
Icons.open_in_new,
size: 18,
color: colorScheme.onSurfaceVariant,
),
onTap: () => _openLink(context, entry.value),
),
],
),
const SizedBox(height: 16),
],
),
);
},
);
}
}
@@ -1,5 +1,6 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:spotiflac_android/widgets/app_bottom_sheet.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/models/track.dart';
@@ -34,9 +35,6 @@ class TrackCollectionQuickActions extends ConsumerWidget {
useRootNavigator: true,
isScrollControlled: true,
backgroundColor: colorScheme.surfaceContainerHigh,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
),
builder: (sheetContext) => _TrackOptionsSheet(
track: track,
hasLocalPlaybackCandidate: hasLocalPlaybackCandidate,
@@ -62,7 +60,7 @@ class TrackCollectionQuickActions extends ConsumerWidget {
hasLocalPlaybackCandidate: hasLocalPlaybackCandidate,
),
padding: const EdgeInsets.only(left: 12),
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
constraints: const BoxConstraints(minWidth: 48, minHeight: 48),
);
}
}
@@ -105,17 +103,7 @@ class _TrackOptionsSheet extends ConsumerWidget {
children: [
Column(
children: [
const SizedBox(height: 8),
Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: colorScheme.onSurfaceVariant.withValues(
alpha: 0.4,
),
borderRadius: BorderRadius.circular(2),
),
),
const AppSheetHandle(),
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
child: Row(