From 99ccc8826fec00ff1b5b2a34e71924f9d2eed915 Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Mon, 19 Oct 2026 00:30:08 +0700 Subject: [PATCH] refactor(layering): move logic files out of screens Search bucketing, refresh policy, folder data, history snapshot and the selection and collapsing-header mixins hold no widgets, so they move to controllers; the settings search catalog is pure presentation metadata and moves to utils. Files with many importers keep a re-export in screens. --- .../collapsing_header_scroll_mixin.dart | 41 +++++++++++++ .../home_search_logic.dart | 0 .../library_tracks_folder_data.dart | 2 +- .../queue_library_refresh_policy.dart | 0 lib/controllers/selection_mode_mixin.dart | 56 ++++++++++++++++++ lib/controllers/track_history_snapshot.dart | 20 +++++++ .../collapsing_header_scroll_mixin.dart | 42 +------------- lib/screens/home_tab.dart | 2 +- lib/screens/library_tracks_folder_screen.dart | 2 +- lib/screens/queue_tab.dart | 2 +- lib/screens/selection_mode_mixin.dart | 57 +------------------ lib/screens/settings/settings_tab.dart | 2 +- lib/screens/track_history_snapshot.dart | 21 +------ .../settings_search_catalog.dart | 0 test/autoplay_settings_test.dart | 2 +- test/library_tracks_folder_data_test.dart | 2 +- test/maintainability_contracts_test.dart | 2 +- test/playback_automation_settings_test.dart | 2 +- test/queue_library_refresh_policy_test.dart | 2 +- 19 files changed, 130 insertions(+), 127 deletions(-) create mode 100644 lib/controllers/collapsing_header_scroll_mixin.dart rename lib/{screens => controllers}/home_search_logic.dart (100%) rename lib/{screens => controllers}/library_tracks_folder_data.dart (92%) rename lib/{screens => controllers}/queue_library_refresh_policy.dart (100%) create mode 100644 lib/controllers/selection_mode_mixin.dart create mode 100644 lib/controllers/track_history_snapshot.dart rename lib/{screens/settings => utils}/settings_search_catalog.dart (100%) diff --git a/lib/controllers/collapsing_header_scroll_mixin.dart b/lib/controllers/collapsing_header_scroll_mixin.dart new file mode 100644 index 00000000..d33b06b1 --- /dev/null +++ b/lib/controllers/collapsing_header_scroll_mixin.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; + +/// Expanded height of the album-style collapsing header, shared with the +/// loading screens that show the same header before the tracks arrive. +double collectionHeaderExpandedHeight(BuildContext context) { + final mediaSize = MediaQuery.sizeOf(context); + return (mediaSize.height * 0.6).clamp(400.0, 580.0); +} + +/// Shared collapsing-header scroll behavior for album-style screens: owns the +/// [ScrollController] and flips [showTitleInAppBar] once the header has +/// scrolled out of view. +mixin CollapsingHeaderScrollMixin on State { + final ScrollController scrollController = ScrollController(); + bool showTitleInAppBar = false; + + @override + void initState() { + super.initState(); + scrollController.addListener(_onHeaderScroll); + } + + @override + void dispose() { + scrollController.removeListener(_onHeaderScroll); + scrollController.dispose(); + super.dispose(); + } + + double calculateExpandedHeight(BuildContext context) => + collectionHeaderExpandedHeight(context); + + void _onHeaderScroll() { + final expandedHeight = calculateExpandedHeight(context); + final shouldShow = + scrollController.offset > (expandedHeight - kToolbarHeight - 20); + if (shouldShow != showTitleInAppBar) { + setState(() => showTitleInAppBar = shouldShow); + } + } +} diff --git a/lib/screens/home_search_logic.dart b/lib/controllers/home_search_logic.dart similarity index 100% rename from lib/screens/home_search_logic.dart rename to lib/controllers/home_search_logic.dart diff --git a/lib/screens/library_tracks_folder_data.dart b/lib/controllers/library_tracks_folder_data.dart similarity index 92% rename from lib/screens/library_tracks_folder_data.dart rename to lib/controllers/library_tracks_folder_data.dart index 04979719..3ef8f513 100644 --- a/lib/screens/library_tracks_folder_data.dart +++ b/lib/controllers/library_tracks_folder_data.dart @@ -1,5 +1,5 @@ import 'package:spotiflac_android/models/track.dart'; -import 'package:spotiflac_android/screens/track_history_snapshot.dart'; +import 'package:spotiflac_android/controllers/track_history_snapshot.dart'; import 'package:spotiflac_android/providers/library_collections_provider.dart'; import 'package:spotiflac_android/services/history_database.dart'; diff --git a/lib/screens/queue_library_refresh_policy.dart b/lib/controllers/queue_library_refresh_policy.dart similarity index 100% rename from lib/screens/queue_library_refresh_policy.dart rename to lib/controllers/queue_library_refresh_policy.dart diff --git a/lib/controllers/selection_mode_mixin.dart b/lib/controllers/selection_mode_mixin.dart new file mode 100644 index 00000000..436b41e1 --- /dev/null +++ b/lib/controllers/selection_mode_mixin.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +/// Shared multi-select state for list screens: enter/exit selection mode, +/// toggle individual items, select all, and prune ids that fell out of the +/// current item list. Mix into a `State` alongside the screen's own +/// per-item lookups (e.g. queue_tab, library_folder, artist screens). +mixin SelectionModeMixin on State { + bool isSelectionMode = false; + final Set selectedIds = {}; + + void enterSelectionMode(String itemId) { + HapticFeedback.mediumImpact(); + setState(() { + isSelectionMode = true; + selectedIds.add(itemId); + }); + } + + void exitSelectionMode() { + setState(() { + isSelectionMode = false; + selectedIds.clear(); + }); + } + + void toggleSelection(String itemId) { + setState(() { + if (selectedIds.contains(itemId)) { + selectedIds.remove(itemId); + if (selectedIds.isEmpty) { + isSelectionMode = false; + } + } else { + selectedIds.add(itemId); + } + }); + } + + void selectAll(Iterable ids) { + setState(() { + selectedIds.addAll(ids); + }); + } + + /// Drops ids no longer present in [validIds]; call from build() with the + /// current item ids. Exits selection mode next frame if that empties it. + void pruneSelection(Set validIds) { + selectedIds.removeWhere((id) => !validIds.contains(id)); + if (selectedIds.isEmpty && isSelectionMode) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) setState(() => isSelectionMode = false); + }); + } + } +} diff --git a/lib/controllers/track_history_snapshot.dart b/lib/controllers/track_history_snapshot.dart new file mode 100644 index 00000000..4800cb89 --- /dev/null +++ b/lib/controllers/track_history_snapshot.dart @@ -0,0 +1,20 @@ +import 'package:spotiflac_android/models/track.dart'; +import 'package:spotiflac_android/providers/download_history_provider.dart'; +import 'package:spotiflac_android/services/history_database.dart'; + +/// Retains derived lookups while the immutable source list is unchanged. +/// Callers replace their list for metadata, order, or filter changes. +class TrackHistorySnapshot { + List? _source; + HistoryBatchLookupRequest request = HistoryBatchLookupRequest.snapshot([]); + + List get lookups => request.tracks; + + void update(List tracks) { + if (identical(tracks, _source)) return; + _source = tracks; + request = HistoryBatchLookupRequest.snapshot( + tracks.map(historyLookupForTrack), + ); + } +} diff --git a/lib/screens/collapsing_header_scroll_mixin.dart b/lib/screens/collapsing_header_scroll_mixin.dart index d33b06b1..794f99f4 100644 --- a/lib/screens/collapsing_header_scroll_mixin.dart +++ b/lib/screens/collapsing_header_scroll_mixin.dart @@ -1,41 +1 @@ -import 'package:flutter/material.dart'; - -/// Expanded height of the album-style collapsing header, shared with the -/// loading screens that show the same header before the tracks arrive. -double collectionHeaderExpandedHeight(BuildContext context) { - final mediaSize = MediaQuery.sizeOf(context); - return (mediaSize.height * 0.6).clamp(400.0, 580.0); -} - -/// Shared collapsing-header scroll behavior for album-style screens: owns the -/// [ScrollController] and flips [showTitleInAppBar] once the header has -/// scrolled out of view. -mixin CollapsingHeaderScrollMixin on State { - final ScrollController scrollController = ScrollController(); - bool showTitleInAppBar = false; - - @override - void initState() { - super.initState(); - scrollController.addListener(_onHeaderScroll); - } - - @override - void dispose() { - scrollController.removeListener(_onHeaderScroll); - scrollController.dispose(); - super.dispose(); - } - - double calculateExpandedHeight(BuildContext context) => - collectionHeaderExpandedHeight(context); - - void _onHeaderScroll() { - final expandedHeight = calculateExpandedHeight(context); - final shouldShow = - scrollController.offset > (expandedHeight - kToolbarHeight - 20); - if (shouldShow != showTitleInAppBar) { - setState(() => showTitleInAppBar = shouldShow); - } - } -} +export 'package:spotiflac_android/controllers/collapsing_header_scroll_mixin.dart'; diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index 9dfec78d..53ffd600 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -54,7 +54,7 @@ import 'package:spotiflac_android/screens/track_metadata_screen.dart'; import 'package:spotiflac_android/screens/album_screen.dart'; import 'package:spotiflac_android/screens/artist_screen.dart'; import 'package:spotiflac_android/screens/extension_collection_screens.dart'; -import 'package:spotiflac_android/screens/home_search_logic.dart'; +import 'package:spotiflac_android/controllers/home_search_logic.dart'; import 'package:spotiflac_android/services/csv_import_service.dart'; import 'package:spotiflac_android/services/cover_download_service.dart'; import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart'; diff --git a/lib/screens/library_tracks_folder_screen.dart b/lib/screens/library_tracks_folder_screen.dart index b65dafe5..418b3ba7 100644 --- a/lib/screens/library_tracks_folder_screen.dart +++ b/lib/screens/library_tracks_folder_screen.dart @@ -35,7 +35,7 @@ import 'package:spotiflac_android/utils/adaptive_layout.dart'; import 'package:spotiflac_android/utils/cover_art_utils.dart'; import 'package:spotiflac_android/screens/collapsing_header_scroll_mixin.dart'; import 'package:spotiflac_android/screens/selection_mode_mixin.dart'; -import 'package:spotiflac_android/screens/library_tracks_folder_data.dart'; +import 'package:spotiflac_android/controllers/library_tracks_folder_data.dart'; import 'package:spotiflac_android/screens/track_metadata_screen.dart'; import 'package:spotiflac_android/widgets/selection_action_button.dart'; import 'package:spotiflac_android/widgets/selection_bottom_bar.dart'; diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index 73b59d14..96ec8cf4 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -80,7 +80,7 @@ import 'package:spotiflac_android/services/cover_cache_manager.dart'; import 'package:spotiflac_android/screens/library_tracks_folder_screen.dart'; import 'package:spotiflac_android/screens/local_album_screen.dart'; import 'package:spotiflac_android/screens/mornye_library_screen.dart'; -import 'package:spotiflac_android/screens/queue_library_refresh_policy.dart'; +import 'package:spotiflac_android/controllers/queue_library_refresh_policy.dart'; import 'package:spotiflac_android/utils/clickable_metadata.dart'; import 'package:spotiflac_android/utils/string_utils.dart'; import 'package:spotiflac_android/widgets/download_service_picker.dart'; diff --git a/lib/screens/selection_mode_mixin.dart b/lib/screens/selection_mode_mixin.dart index 436b41e1..c1d5470b 100644 --- a/lib/screens/selection_mode_mixin.dart +++ b/lib/screens/selection_mode_mixin.dart @@ -1,56 +1 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; - -/// Shared multi-select state for list screens: enter/exit selection mode, -/// toggle individual items, select all, and prune ids that fell out of the -/// current item list. Mix into a `State` alongside the screen's own -/// per-item lookups (e.g. queue_tab, library_folder, artist screens). -mixin SelectionModeMixin on State { - bool isSelectionMode = false; - final Set selectedIds = {}; - - void enterSelectionMode(String itemId) { - HapticFeedback.mediumImpact(); - setState(() { - isSelectionMode = true; - selectedIds.add(itemId); - }); - } - - void exitSelectionMode() { - setState(() { - isSelectionMode = false; - selectedIds.clear(); - }); - } - - void toggleSelection(String itemId) { - setState(() { - if (selectedIds.contains(itemId)) { - selectedIds.remove(itemId); - if (selectedIds.isEmpty) { - isSelectionMode = false; - } - } else { - selectedIds.add(itemId); - } - }); - } - - void selectAll(Iterable ids) { - setState(() { - selectedIds.addAll(ids); - }); - } - - /// Drops ids no longer present in [validIds]; call from build() with the - /// current item ids. Exits selection mode next frame if that empties it. - void pruneSelection(Set validIds) { - selectedIds.removeWhere((id) => !validIds.contains(id)); - if (selectedIds.isEmpty && isSelectionMode) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) setState(() => isSelectionMode = false); - }); - } - } -} +export 'package:spotiflac_android/controllers/selection_mode_mixin.dart'; diff --git a/lib/screens/settings/settings_tab.dart b/lib/screens/settings/settings_tab.dart index 13f79a0d..e2c52cbc 100644 --- a/lib/screens/settings/settings_tab.dart +++ b/lib/screens/settings/settings_tab.dart @@ -23,7 +23,7 @@ import 'package:spotiflac_android/screens/settings/log_screen.dart'; import 'package:spotiflac_android/screens/settings/lyrics_settings_page.dart'; import 'package:spotiflac_android/screens/settings/metadata_settings_page.dart'; import 'package:spotiflac_android/screens/settings/playback_settings_page.dart'; -import 'package:spotiflac_android/screens/settings/settings_search_catalog.dart'; +import 'package:spotiflac_android/utils/settings_search_catalog.dart'; import 'package:spotiflac_android/services/app_remote_config_service.dart'; import 'package:spotiflac_android/theme/app_tokens.dart'; import 'package:spotiflac_android/theme/mornye_theme.dart'; diff --git a/lib/screens/track_history_snapshot.dart b/lib/screens/track_history_snapshot.dart index 4800cb89..b55ce031 100644 --- a/lib/screens/track_history_snapshot.dart +++ b/lib/screens/track_history_snapshot.dart @@ -1,20 +1 @@ -import 'package:spotiflac_android/models/track.dart'; -import 'package:spotiflac_android/providers/download_history_provider.dart'; -import 'package:spotiflac_android/services/history_database.dart'; - -/// Retains derived lookups while the immutable source list is unchanged. -/// Callers replace their list for metadata, order, or filter changes. -class TrackHistorySnapshot { - List? _source; - HistoryBatchLookupRequest request = HistoryBatchLookupRequest.snapshot([]); - - List get lookups => request.tracks; - - void update(List tracks) { - if (identical(tracks, _source)) return; - _source = tracks; - request = HistoryBatchLookupRequest.snapshot( - tracks.map(historyLookupForTrack), - ); - } -} +export 'package:spotiflac_android/controllers/track_history_snapshot.dart'; diff --git a/lib/screens/settings/settings_search_catalog.dart b/lib/utils/settings_search_catalog.dart similarity index 100% rename from lib/screens/settings/settings_search_catalog.dart rename to lib/utils/settings_search_catalog.dart diff --git a/test/autoplay_settings_test.dart b/test/autoplay_settings_test.dart index a782a17a..cd30310c 100644 --- a/test/autoplay_settings_test.dart +++ b/test/autoplay_settings_test.dart @@ -9,7 +9,7 @@ import 'package:spotiflac_android/models/settings.dart'; import 'package:spotiflac_android/models/theme_settings.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/screens/settings/playback_settings_page.dart'; -import 'package:spotiflac_android/screens/settings/settings_search_catalog.dart'; +import 'package:spotiflac_android/utils/settings_search_catalog.dart'; import 'package:spotiflac_android/theme/app_theme.dart'; import 'package:spotiflac_android/theme/mornye_theme.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; diff --git a/test/library_tracks_folder_data_test.dart b/test/library_tracks_folder_data_test.dart index c1e82c98..ff513769 100644 --- a/test/library_tracks_folder_data_test.dart +++ b/test/library_tracks_folder_data_test.dart @@ -1,7 +1,7 @@ import 'dart:collection'; import 'package:spotiflac_android/models/track.dart'; import 'package:spotiflac_android/providers/library_collections_provider.dart'; -import 'package:spotiflac_android/screens/library_tracks_folder_data.dart'; +import 'package:spotiflac_android/controllers/library_tracks_folder_data.dart'; import 'package:spotiflac_android/services/history_database.dart'; import 'package:flutter_test/flutter_test.dart'; diff --git a/test/maintainability_contracts_test.dart b/test/maintainability_contracts_test.dart index 3b6d7186..06d1abe4 100644 --- a/test/maintainability_contracts_test.dart +++ b/test/maintainability_contracts_test.dart @@ -6,7 +6,7 @@ import 'package:spotiflac_android/models/download_item.dart'; import 'package:spotiflac_android/models/track.dart'; import 'package:spotiflac_android/providers/download_queue_state.dart'; import 'package:spotiflac_android/providers/extension_provider.dart'; -import 'package:spotiflac_android/screens/home_search_logic.dart'; +import 'package:spotiflac_android/controllers/home_search_logic.dart'; import 'package:spotiflac_android/services/audio_metadata_mapper.dart'; import 'package:spotiflac_android/services/ffmpeg_models.dart'; import 'package:spotiflac_android/services/id3v23_lyrics.dart'; diff --git a/test/playback_automation_settings_test.dart b/test/playback_automation_settings_test.dart index 250963ad..6cf6b583 100644 --- a/test/playback_automation_settings_test.dart +++ b/test/playback_automation_settings_test.dart @@ -9,7 +9,7 @@ import 'package:spotiflac_android/models/settings.dart'; import 'package:spotiflac_android/models/theme_settings.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/screens/settings/playback_settings_page.dart'; -import 'package:spotiflac_android/screens/settings/settings_search_catalog.dart'; +import 'package:spotiflac_android/utils/settings_search_catalog.dart'; import 'package:spotiflac_android/theme/app_theme.dart'; import 'package:spotiflac_android/theme/mornye_theme.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; diff --git a/test/queue_library_refresh_policy_test.dart b/test/queue_library_refresh_policy_test.dart index 1f059d2e..cb13fb39 100644 --- a/test/queue_library_refresh_policy_test.dart +++ b/test/queue_library_refresh_policy_test.dart @@ -3,7 +3,7 @@ import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; import 'package:spotiflac_android/models/settings.dart'; -import 'package:spotiflac_android/screens/queue_library_refresh_policy.dart'; +import 'package:spotiflac_android/controllers/queue_library_refresh_policy.dart'; import 'package:spotiflac_android/services/library_database.dart'; void main() {