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.
This commit is contained in:
zarzet committed 2026-10-19 00:37:42 +07:00
1 parent ce21896ff5
commit 99ccc8826f
19 files changed
+130 -127

No files matched your search

@@ -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<T extends StatefulWidget> on State<T> {
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);
}
}
}
File renamed without changes.
@@ -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';
+56
View File
@@ -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<T>` alongside the screen's own
/// per-item lookups (e.g. queue_tab, library_folder, artist screens).
mixin SelectionModeMixin<T extends StatefulWidget> on State<T> {
bool isSelectionMode = false;
final Set<String> 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<String> 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<String> validIds) {
selectedIds.removeWhere((id) => !validIds.contains(id));
if (selectedIds.isEmpty && isSelectionMode) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) setState(() => isSelectionMode = false);
});
}
}
}
@@ -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<Track>? _source;
HistoryBatchLookupRequest request = HistoryBatchLookupRequest.snapshot([]);
List<HistoryLookupRequest> get lookups => request.tracks;
void update(List<Track> tracks) {
if (identical(tracks, _source)) return;
_source = tracks;
request = HistoryBatchLookupRequest.snapshot(
tracks.map(historyLookupForTrack),
);
}
}
@@ -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<T extends StatefulWidget> on State<T> {
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';
+1 -1
View File
@@ -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';
@@ -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';
+1 -1
View File
@@ -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';
+1 -56
View File
@@ -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<T>` alongside the screen's own
/// per-item lookups (e.g. queue_tab, library_folder, artist screens).
mixin SelectionModeMixin<T extends StatefulWidget> on State<T> {
bool isSelectionMode = false;
final Set<String> 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<String> 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<String> 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';
+1 -1
View File
@@ -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';
+1 -20
View File
@@ -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<Track>? _source;
HistoryBatchLookupRequest request = HistoryBatchLookupRequest.snapshot([]);
List<HistoryLookupRequest> get lookups => request.tracks;
void update(List<Track> tracks) {
if (identical(tracks, _source)) return;
_source = tracks;
request = HistoryBatchLookupRequest.snapshot(
tracks.map(historyLookupForTrack),
);
}
}
export 'package:spotiflac_android/controllers/track_history_snapshot.dart';
+1 -1
View File
@@ -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';
+1 -1
View File
@@ -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';
+1 -1
View File
@@ -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';
+1 -1
View File
@@ -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';
+1 -1
View File
@@ -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() {