From dbdbf0ca4100823dbc019b9d2c0cc85f297bed70 Mon Sep 17 00:00:00 2001 From: zarzet Date: Wed, 29 Jul 2026 17:10:42 +0700 Subject: [PATCH] refactor(ui): standardize modal sheets --- lib/screens/home_tab.dart | 38 +--- lib/screens/home_tab_explore.dart | 13 +- lib/screens/setup_screen.dart | 3 - lib/screens/track_metadata_actions.dart | 3 - lib/screens/track_metadata_convert.dart | 19 +- lib/screens/track_metadata_edit_sheet.dart | 12 +- lib/screens/track_metadata_screen.dart | 1 + lib/screens/track_metadata_screen_menu.dart | 17 +- lib/services/batch_track_actions.dart | 3 - lib/widgets/app_bottom_sheet.dart | 167 ++++++++++++++++++ lib/widgets/batch_convert_sheet.dart | 12 +- lib/widgets/cross_extension_share_sheet.dart | 121 ++++--------- lib/widgets/download_service_picker.dart | 26 +-- lib/widgets/duplicate_review_sheet.dart | 108 ++++------- lib/widgets/open_on_platform_sheet.dart | 142 ++++++--------- .../track_collection_quick_actions.dart | 18 +- 16 files changed, 306 insertions(+), 397 deletions(-) create mode 100644 lib/widgets/app_bottom_sheet.dart diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index 1d6635bd..be04a900 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -2,6 +2,9 @@ import 'dart:async'; import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:spotiflac_android/theme/app_tokens.dart'; +import 'package:spotiflac_android/widgets/app_bottom_sheet.dart'; +import 'package:spotiflac_android/widgets/app_sliver_header.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; @@ -21,7 +24,6 @@ import 'package:spotiflac_android/screens/home_search_logic.dart'; import 'package:spotiflac_android/services/csv_import_service.dart'; import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; -import 'package:spotiflac_android/utils/app_bar_layout.dart'; import 'package:spotiflac_android/utils/adaptive_layout.dart'; import 'package:spotiflac_android/utils/nav_bar_inset.dart'; import 'package:spotiflac_android/utils/file_access.dart'; @@ -747,7 +749,6 @@ class _HomeTabState extends ConsumerState trackProvider.select((s) => s.isShowingRecentAccess), ); final screenHeight = MediaQuery.sizeOf(context).height; - final topPadding = normalizedHeaderTopPadding(context); final bottomInset = context.navBarBottomInset; final hasHistoryItems = ref.watch( _homeHistoryPreviewProvider.select((items) => items.isNotEmpty), @@ -840,38 +841,7 @@ class _HomeTabState extends ConsumerState child: CustomScrollView( keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, slivers: [ - SliverAppBar( - expandedHeight: 120 + topPadding, - collapsedHeight: kToolbarHeight, - floating: false, - pinned: true, - backgroundColor: colorScheme.surface, - surfaceTintColor: Colors.transparent, - automaticallyImplyLeading: false, - flexibleSpace: LayoutBuilder( - builder: (context, constraints) { - final maxHeight = 120 + topPadding; - final minHeight = kToolbarHeight + topPadding; - final expandRatio = - ((constraints.maxHeight - minHeight) / - (maxHeight - minHeight)) - .clamp(0.0, 1.0); - - return FlexibleSpaceBar( - expandedTitleScale: 1.0, - titlePadding: const EdgeInsets.only(left: 24, bottom: 16), - title: Text( - context.l10n.homeTitle, - style: TextStyle( - fontSize: 20 + (14 * expandRatio), - fontWeight: FontWeight.bold, - color: colorScheme.onSurface, - ), - ), - ); - }, - ), - ), + AppSliverHeader.tabRoot(title: context.l10n.homeTitle), SliverToBoxAdapter( child: AnimatedSize( diff --git a/lib/screens/home_tab_explore.dart b/lib/screens/home_tab_explore.dart index 920f74af..ec657f38 100644 --- a/lib/screens/home_tab_explore.dart +++ b/lib/screens/home_tab_explore.dart @@ -354,22 +354,11 @@ extension _HomeTabExploreUI on _HomeTabState { context: context, useRootNavigator: true, backgroundColor: colorScheme.surface, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(20)), - ), builder: (context) => SafeArea( child: Column( mainAxisSize: MainAxisSize.min, children: [ - Container( - margin: const EdgeInsets.only(top: 12), - width: 40, - height: 4, - decoration: BoxDecoration( - color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), - borderRadius: BorderRadius.circular(2), - ), - ), + const AppSheetHandle(), Padding( padding: const EdgeInsets.all(16), child: Row( diff --git a/lib/screens/setup_screen.dart b/lib/screens/setup_screen.dart index 0c85a195..a5f2837e 100644 --- a/lib/screens/setup_screen.dart +++ b/lib/screens/setup_screen.dart @@ -320,9 +320,6 @@ class _SetupScreenState extends ConsumerState { context: context, useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(28)), - ), builder: (ctx) => SafeArea( child: Column( mainAxisSize: MainAxisSize.min, diff --git a/lib/screens/track_metadata_actions.dart b/lib/screens/track_metadata_actions.dart index d4b3bcb5..c544e760 100644 --- a/lib/screens/track_metadata_actions.dart +++ b/lib/screens/track_metadata_actions.dart @@ -54,9 +54,6 @@ extension _TrackMetadataFileActions on _TrackMetadataScreenState { useRootNavigator: true, isScrollControlled: true, backgroundColor: colorScheme.surface, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(28)), - ), builder: (sheetContext) => _EditMetadataSheet( colorScheme: colorScheme, initialValues: initialValues, diff --git a/lib/screens/track_metadata_convert.dart b/lib/screens/track_metadata_convert.dart index 7ea7512d..d7c5371b 100644 --- a/lib/screens/track_metadata_convert.dart +++ b/lib/screens/track_metadata_convert.dart @@ -235,9 +235,6 @@ extension _TrackMetadataConvertAndCueSplit on _TrackMetadataScreenState { context: context, useRootNavigator: true, isScrollControlled: true, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(20)), - ), builder: (sheetContext) => BatchConvertSheet( formats: formats, title: context.l10n.trackConvertTitle, @@ -337,9 +334,6 @@ extension _TrackMetadataConvertAndCueSplit on _TrackMetadataScreenState { context: this.context, useRootNavigator: true, isScrollControlled: true, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(20)), - ), builder: (sheetContext) { final colorScheme = Theme.of(sheetContext).colorScheme; return SafeArea( @@ -349,18 +343,7 @@ extension _TrackMetadataConvertAndCueSplit on _TrackMetadataScreenState { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Center( - child: Container( - width: 40, - height: 4, - decoration: BoxDecoration( - color: colorScheme.onSurfaceVariant.withValues( - alpha: 0.4, - ), - borderRadius: BorderRadius.circular(2), - ), - ), - ), + const AppSheetHandle(), const SizedBox(height: 16), Text( sheetContext.l10n.cueSplitTitle, diff --git a/lib/screens/track_metadata_edit_sheet.dart b/lib/screens/track_metadata_edit_sheet.dart index 1e2c470f..72d21fc1 100644 --- a/lib/screens/track_metadata_edit_sheet.dart +++ b/lib/screens/track_metadata_edit_sheet.dart @@ -108,9 +108,6 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { isScrollControlled: true, showDragHandle: true, backgroundColor: Theme.of(context).colorScheme.surfaceContainerHigh, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(28)), - ), builder: (sheetContext) { final cs = Theme.of(sheetContext).colorScheme; final currentId = @@ -1547,14 +1544,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { children: [ Padding( padding: const EdgeInsets.only(top: 12, bottom: 8), - child: Container( - width: 40, - height: 4, - decoration: BoxDecoration( - color: cs.onSurfaceVariant.withValues(alpha: 0.4), - borderRadius: BorderRadius.circular(2), - ), - ), + child: const AppSheetHandle(), ), Padding( padding: const EdgeInsets.fromLTRB(20, 4, 20, 12), diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index f205548b..47d6ae9f 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:io'; import 'dart:ui' show ImageFilter; 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:file_picker/file_picker.dart'; diff --git a/lib/screens/track_metadata_screen_menu.dart b/lib/screens/track_metadata_screen_menu.dart index d7901d97..ee755ae1 100644 --- a/lib/screens/track_metadata_screen_menu.dart +++ b/lib/screens/track_metadata_screen_menu.dart @@ -12,9 +12,6 @@ extension _TrackMetadataMenu on _TrackMetadataScreenState { context: screenContext, useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(28)), - ), isScrollControlled: true, builder: (sheetContext) { final l10n = sheetContext.l10n; @@ -115,19 +112,7 @@ extension _TrackMetadataMenu on _TrackMetadataScreenState { 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), - ), - ), - ), + const AppSheetHandle(), Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), child: Row( diff --git a/lib/services/batch_track_actions.dart b/lib/services/batch_track_actions.dart index 831b162e..ffdc8327 100644 --- a/lib/services/batch_track_actions.dart +++ b/lib/services/batch_track_actions.dart @@ -96,9 +96,6 @@ Future showBatchConvertSheet( context: modalContext, useRootNavigator: true, isScrollControlled: true, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(20)), - ), builder: (sheetContext) => BatchConvertSheet( formats: formats, title: sheetTitle, diff --git a/lib/widgets/app_bottom_sheet.dart b/lib/widgets/app_bottom_sheet.dart new file mode 100644 index 00000000..558d8cac --- /dev/null +++ b/lib/widgets/app_bottom_sheet.dart @@ -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 showAppBottomSheet({ + 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( + 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), + ), + ); +} diff --git a/lib/widgets/batch_convert_sheet.dart b/lib/widgets/batch_convert_sheet.dart index 2d4fa878..055f6800 100644 --- a/lib/widgets/batch_convert_sheet.dart +++ b/lib/widgets/batch_convert_sheet.dart @@ -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 { 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, diff --git a/lib/widgets/cross_extension_share_sheet.dart b/lib/widgets/cross_extension_share_sheet.dart index b36e215d..e2c8465a 100644 --- a/lib/widgets/cross_extension_share_sheet.dart +++ b/lib/widgets/cross_extension_share_sheet.dart @@ -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( + return showAppBottomSheet( 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>( + 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>( - 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, + ); + }, ); } } diff --git a/lib/widgets/download_service_picker.dart b/lib/widgets/download_service_picker.dart index da178e27..435eec06 100644 --- a/lib/widgets/download_service_picker.dart +++ b/lib/widgets/download_service_picker.dart @@ -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 { 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( diff --git a/lib/widgets/duplicate_review_sheet.dart b/lib/widgets/duplicate_review_sheet.dart index 164f3bbc..4bd93943 100644 --- a/lib/widgets/duplicate_review_sheet.dart +++ b/lib/widgets/duplicate_review_sheet.dart @@ -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 show(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; - return showModalBottomSheet( + return showAppBottomSheet( 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 { @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>( + 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>( - 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]), + ); + }, ); } diff --git a/lib/widgets/open_on_platform_sheet.dart b/lib/widgets/open_on_platform_sheet.dart index be8a39e1..471075f8 100644 --- a/lib/widgets/open_on_platform_sheet.dart +++ b/lib/widgets/open_on_platform_sheet.dart @@ -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( + return showAppBottomSheet( 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>( - 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 {}; - 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>( + 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 {}; + 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), + ], + ), + ); + }, ); } } diff --git a/lib/widgets/track_collection_quick_actions.dart b/lib/widgets/track_collection_quick_actions.dart index d78df892..065e9621 100644 --- a/lib/widgets/track_collection_quick_actions.dart +++ b/lib/widgets/track_collection_quick_actions.dart @@ -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(