mirror of
https://github.com/spotiflacapp/SpotiFLAC-Mobile.git
synced 2026-06-01 03:15:17 +07:00
fix: back gesture freeze on Android 13+ and add album folder structure setting
- Add PopScope with canPop:true to all settings pages for predictive back gesture support - Change settings navigation to use PageRouteBuilder instead of MaterialPageRoute - Add album folder structure setting (artist_album vs album_only) - Fix extension search result parsing to handle both array and object formats - Update CHANGELOG Fixes back gesture freeze issue on OnePlus and other Android 13+ devices with gesture navigation
This commit is contained in:
parent
e049f9b868
commit
15acf181d1
17 changed files with 254 additions and 65 deletions
36
CHANGELOG.md
36
CHANGELOG.md
|
|
@ -2,12 +2,46 @@
|
|||
|
||||
## [3.0.0-beta.2] - 2026-01-13
|
||||
|
||||
### Added
|
||||
|
||||
- **Album Folder Structure Setting**: Option to remove artist folder from album path
|
||||
- New setting in Download Settings when "Separate Singles Folder" is enabled
|
||||
- `Artist / Album` (default): `Albums/Artist Name/Album Name/`
|
||||
- `Album Only`: `Albums/Album Name/`
|
||||
- Requested by user who prefers flat album organization
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Back Gesture Freeze on OnePlus/Android 13+**: Fixed app freeze when using back gesture in settings
|
||||
- Added `PopScope` with `canPop: true` to all settings pages
|
||||
- Changed navigation to use `PageRouteBuilder` with proper slide transition
|
||||
- Fixes predictive back gesture conflict on devices with gesture navigation
|
||||
- Affected pages: Download, Appearance, Options, Extensions, About, Logs, Extension Detail
|
||||
|
||||
- **Extension Search Result Parsing**: Fixed "cannot unmarshal array into Go value" error
|
||||
- Go backend now handles both array and object formats from extensions
|
||||
- Extensions returning `[{track}, {track}]` now work correctly
|
||||
- Extensions returning `{tracks: [...], total: N}` still work as before
|
||||
|
||||
- **Max Resolution Cover Download**: Fixed cover not upgrading to max resolution on mobile
|
||||
- Added missing `spotifySize300` constant (300x300 size code)
|
||||
- Mobile now correctly upgrades 300x300 → 640x640 → max resolution (~2000x2000)
|
||||
- Matches PC version behavior when "Download max resolution song cover" is enabled
|
||||
- Added `_upgradeToMaxQualityCover()` helper in Flutter for M4A conversion path
|
||||
- Go backend `cover.go` now directly replaces URL without HEAD verification
|
||||
|
||||
- **Extension Search Provider Reset**: Fixed search provider not resetting to default when disabled
|
||||
- `copyWith` in `AppSettings` couldn't set `searchProvider` to `null`
|
||||
- Added `clearSearchProvider` boolean parameter to properly clear the value
|
||||
- Settings menu now correctly switches back to default provider
|
||||
|
||||
- **Extension Disabled Search Fallback**: Fixed error when extension is disabled but still called
|
||||
- `_performSearch` now checks if extension is still enabled before calling custom search
|
||||
- Automatically falls back to Deezer/Spotify search if extension was disabled
|
||||
- Clears `searchProvider` setting if extension no longer available
|
||||
|
||||
- **Store Tab Unmount Crash**: Fixed "Using ref when widget is unmounted" error
|
||||
- Added `mounted` check after async operation in `_initialize()`
|
||||
- Prevents crash when navigating away from Store tab during initialization
|
||||
|
||||
- **EXISTS: Prefix in File Path**: Fixed "File not found" error in metadata screen after download
|
||||
- Duplicate detection was adding `EXISTS:` prefix to file paths
|
||||
|
|
|
|||
|
|
@ -91,7 +91,8 @@ func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
|
|||
}
|
||||
|
||||
// upgradeToMaxQuality upgrades Spotify cover URL to maximum quality
|
||||
// Uses same logic as PC version - replaces 640x640 size code with max resolution
|
||||
// Same logic as PC version - directly replaces 640x640 size code with max resolution
|
||||
// No HEAD verification needed - Spotify CDN always serves max resolution if available
|
||||
func upgradeToMaxQuality(coverURL string) string {
|
||||
// Spotify image URLs can be upgraded by changing the size parameter
|
||||
// Format: https://i.scdn.co/image/ab67616d0000b273...
|
||||
|
|
@ -99,21 +100,7 @@ func upgradeToMaxQuality(coverURL string) string {
|
|||
// ab67616d000082c1 = Max resolution (~2000x2000)
|
||||
|
||||
if strings.Contains(coverURL, spotifySize640) {
|
||||
// Try max resolution first
|
||||
maxURL := strings.Replace(coverURL, spotifySize640, spotifySizeMax, 1)
|
||||
|
||||
// Verify max resolution URL is available
|
||||
client := NewHTTPClientWithTimeout(DefaultTimeout)
|
||||
req, err := http.NewRequest("HEAD", maxURL, nil)
|
||||
if err == nil {
|
||||
resp, err := DoRequestWithUserAgent(client, req)
|
||||
if err == nil {
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
return maxURL
|
||||
}
|
||||
}
|
||||
}
|
||||
return strings.Replace(coverURL, spotifySize640, spotifySizeMax, 1)
|
||||
}
|
||||
|
||||
return coverURL
|
||||
|
|
|
|||
|
|
@ -162,8 +162,19 @@ func (p *ExtensionProviderWrapper) SearchTracks(query string, limit int) (*ExtSe
|
|||
}
|
||||
|
||||
var searchResult ExtSearchResult
|
||||
|
||||
// Try to parse as ExtSearchResult object first
|
||||
if err := json.Unmarshal(jsonBytes, &searchResult); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse search result: %w", err)
|
||||
// If that fails, try parsing as array of tracks directly
|
||||
var tracks []ExtTrackMetadata
|
||||
if arrErr := json.Unmarshal(jsonBytes, &tracks); arrErr != nil {
|
||||
return nil, fmt.Errorf("failed to parse search result: %w (also tried array: %v)", err, arrErr)
|
||||
}
|
||||
// Wrap array in ExtSearchResult
|
||||
searchResult = ExtSearchResult{
|
||||
Tracks: tracks,
|
||||
Total: len(tracks),
|
||||
}
|
||||
}
|
||||
|
||||
// Set provider ID on all tracks
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ class AppSettings {
|
|||
final bool useExtensionProviders; // Use extension providers for downloads when available
|
||||
final String? searchProvider; // null/empty = default (Deezer/Spotify), otherwise extension ID
|
||||
final bool separateSingles; // Separate singles/EPs into their own folder
|
||||
final String albumFolderStructure; // artist_album or album_only
|
||||
final bool showExtensionStore; // Show Extension Store tab in navigation
|
||||
|
||||
const AppSettings({
|
||||
|
|
@ -55,6 +56,7 @@ class AppSettings {
|
|||
this.useExtensionProviders = true, // Default: use extensions when available
|
||||
this.searchProvider, // Default: null (use Deezer/Spotify)
|
||||
this.separateSingles = false, // Default: disabled
|
||||
this.albumFolderStructure = 'artist_album', // Default: Albums/Artist/Album
|
||||
this.showExtensionStore = true, // Default: show store
|
||||
});
|
||||
|
||||
|
|
@ -82,7 +84,9 @@ class AppSettings {
|
|||
bool? enableLogging,
|
||||
bool? useExtensionProviders,
|
||||
String? searchProvider,
|
||||
bool clearSearchProvider = false, // Set to true to clear searchProvider to null
|
||||
bool? separateSingles,
|
||||
String? albumFolderStructure,
|
||||
bool? showExtensionStore,
|
||||
}) {
|
||||
return AppSettings(
|
||||
|
|
@ -108,8 +112,9 @@ class AppSettings {
|
|||
metadataSource: metadataSource ?? this.metadataSource,
|
||||
enableLogging: enableLogging ?? this.enableLogging,
|
||||
useExtensionProviders: useExtensionProviders ?? this.useExtensionProviders,
|
||||
searchProvider: searchProvider ?? this.searchProvider,
|
||||
searchProvider: clearSearchProvider ? null : (searchProvider ?? this.searchProvider),
|
||||
separateSingles: separateSingles ?? this.separateSingles,
|
||||
albumFolderStructure: albumFolderStructure ?? this.albumFolderStructure,
|
||||
showExtensionStore: showExtensionStore ?? this.showExtensionStore,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
|||
useExtensionProviders: json['useExtensionProviders'] as bool? ?? true,
|
||||
searchProvider: json['searchProvider'] as String?,
|
||||
separateSingles: json['separateSingles'] as bool? ?? false,
|
||||
albumFolderStructure: json['albumFolderStructure'] as String? ?? 'artist_album',
|
||||
showExtensionStore: json['showExtensionStore'] as bool? ?? true,
|
||||
);
|
||||
|
||||
|
|
@ -61,5 +62,6 @@ Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
|||
'useExtensionProviders': instance.useExtensionProviders,
|
||||
'searchProvider': instance.searchProvider,
|
||||
'separateSingles': instance.separateSingles,
|
||||
'albumFolderStructure': instance.albumFolderStructure,
|
||||
'showExtensionStore': instance.showExtensionStore,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -669,7 +669,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||
}
|
||||
|
||||
/// Build output directory based on folder organization setting and separateSingles
|
||||
Future<String> _buildOutputDir(Track track, String folderOrganization, {bool separateSingles = false}) async {
|
||||
Future<String> _buildOutputDir(Track track, String folderOrganization, {bool separateSingles = false, String albumFolderStructure = 'artist_album'}) async {
|
||||
String baseDir = state.outputDir;
|
||||
|
||||
// If separateSingles is enabled, use Albums/Singles structure
|
||||
|
|
@ -686,10 +686,19 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||
}
|
||||
return singlesPath;
|
||||
} else {
|
||||
// Albums go to Albums/Artist/Album structure
|
||||
final artistName = _sanitizeFolderName(track.albumArtist ?? track.artistName);
|
||||
// Albums folder structure based on setting
|
||||
final albumName = _sanitizeFolderName(track.albumName);
|
||||
final albumPath = '$baseDir${Platform.pathSeparator}Albums${Platform.pathSeparator}$artistName${Platform.pathSeparator}$albumName';
|
||||
String albumPath;
|
||||
|
||||
if (albumFolderStructure == 'album_only') {
|
||||
// Albums/Album structure (no artist folder)
|
||||
albumPath = '$baseDir${Platform.pathSeparator}Albums${Platform.pathSeparator}$albumName';
|
||||
} else {
|
||||
// Albums/Artist/Album structure (default)
|
||||
final artistName = _sanitizeFolderName(track.albumArtist ?? track.artistName);
|
||||
albumPath = '$baseDir${Platform.pathSeparator}Albums${Platform.pathSeparator}$artistName${Platform.pathSeparator}$albumName';
|
||||
}
|
||||
|
||||
final dir = Directory(albumPath);
|
||||
if (!await dir.exists()) {
|
||||
await dir.create(recursive: true);
|
||||
|
|
@ -1001,13 +1010,42 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||
}
|
||||
}
|
||||
|
||||
/// Upgrade Spotify cover URL to max quality (~2000x2000)
|
||||
/// Same logic as Go backend cover.go
|
||||
String _upgradeToMaxQualityCover(String coverUrl) {
|
||||
const spotifySize300 = 'ab67616d00001e02'; // 300x300 (small)
|
||||
const spotifySize640 = 'ab67616d0000b273'; // 640x640 (medium)
|
||||
const spotifySizeMax = 'ab67616d000082c1'; // Max resolution (~2000x2000)
|
||||
|
||||
// First upgrade small (300) to medium (640)
|
||||
var result = coverUrl;
|
||||
if (result.contains(spotifySize300)) {
|
||||
result = result.replaceFirst(spotifySize300, spotifySize640);
|
||||
}
|
||||
|
||||
// Then upgrade medium (640) to max
|
||||
if (result.contains(spotifySize640)) {
|
||||
result = result.replaceFirst(spotifySize640, spotifySizeMax);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Embed metadata and cover to a FLAC file after M4A conversion
|
||||
Future<void> _embedMetadataAndCover(String flacPath, Track track) async {
|
||||
final settings = ref.read(settingsProvider);
|
||||
|
||||
// Download cover first
|
||||
String? coverPath;
|
||||
final coverUrl = track.coverUrl;
|
||||
var coverUrl = track.coverUrl;
|
||||
if (coverUrl != null && coverUrl.isNotEmpty) {
|
||||
try {
|
||||
// Upgrade cover URL to max quality if setting is enabled
|
||||
if (settings.maxQualityCover) {
|
||||
coverUrl = _upgradeToMaxQualityCover(coverUrl);
|
||||
_log.d('Cover URL upgraded to max quality: $coverUrl');
|
||||
}
|
||||
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final uniqueId =
|
||||
'${DateTime.now().millisecondsSinceEpoch}_${Random().nextInt(10000)}';
|
||||
|
|
@ -1446,6 +1484,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||
trackToDownload,
|
||||
settings.folderOrganization,
|
||||
separateSingles: settings.separateSingles,
|
||||
albumFolderStructure: settings.albumFolderStructure,
|
||||
);
|
||||
|
||||
// Use quality override if set, otherwise use default from settings
|
||||
|
|
|
|||
|
|
@ -196,7 +196,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||
}
|
||||
|
||||
void setSearchProvider(String? provider) {
|
||||
state = state.copyWith(searchProvider: provider);
|
||||
if (provider == null || provider.isEmpty) {
|
||||
state = state.copyWith(clearSearchProvider: true);
|
||||
} else {
|
||||
state = state.copyWith(searchProvider: provider);
|
||||
}
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
|
|
@ -217,6 +221,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||
_saveSettings();
|
||||
}
|
||||
|
||||
void setAlbumFolderStructure(String structure) {
|
||||
state = state.copyWith(albumFolderStructure: structure);
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setShowExtensionStore(bool enabled) {
|
||||
state = state.copyWith(showExtensionStore: enabled);
|
||||
_saveSettings();
|
||||
|
|
|
|||
|
|
@ -81,6 +81,7 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||
|
||||
Future<void> _performSearch(String query) async {
|
||||
final settings = ref.read(settingsProvider);
|
||||
final extState = ref.read(extensionProvider);
|
||||
final searchProvider = settings.searchProvider;
|
||||
|
||||
// Skip if same query already searched with same provider
|
||||
|
|
@ -88,11 +89,20 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||
if (_lastSearchQuery == searchKey) return;
|
||||
_lastSearchQuery = searchKey;
|
||||
|
||||
if (searchProvider != null && searchProvider.isNotEmpty) {
|
||||
// Check if extension search provider is set AND still enabled
|
||||
final isExtensionEnabled = searchProvider != null &&
|
||||
searchProvider.isNotEmpty &&
|
||||
extState.extensions.any((e) => e.id == searchProvider && e.enabled);
|
||||
|
||||
if (isExtensionEnabled) {
|
||||
// Use custom search from extension
|
||||
await ref.read(trackProvider.notifier).customSearch(searchProvider, query);
|
||||
} else {
|
||||
// Use default search (Deezer/Spotify)
|
||||
// Also clear searchProvider if it was set but extension is disabled
|
||||
if (searchProvider != null && searchProvider.isNotEmpty && !isExtensionEnabled) {
|
||||
ref.read(settingsProvider.notifier).setSearchProvider(null);
|
||||
}
|
||||
await ref.read(trackProvider.notifier).search(query, metadataSource: settings.metadataSource);
|
||||
}
|
||||
ref.read(settingsProvider.notifier).setHasSearchedBefore();
|
||||
|
|
|
|||
|
|
@ -12,11 +12,13 @@ class AboutPage extends StatelessWidget {
|
|||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final topPadding = MediaQuery.of(context).padding.top;
|
||||
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
// Collapsing App Bar with back button
|
||||
SliverAppBar(
|
||||
return PopScope(
|
||||
canPop: true, // Always allow back gesture
|
||||
child: Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
// Collapsing App Bar with back button
|
||||
SliverAppBar(
|
||||
expandedHeight: 120 + topPadding,
|
||||
collapsedHeight: kToolbarHeight,
|
||||
floating: false,
|
||||
|
|
@ -218,6 +220,7 @@ class AboutPage extends StatelessWidget {
|
|||
const SliverToBoxAdapter(child: SizedBox(height: 16)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,11 +14,13 @@ class AppearanceSettingsPage extends ConsumerWidget {
|
|||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final topPadding = MediaQuery.of(context).padding.top;
|
||||
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
// Collapsing App Bar with back button
|
||||
SliverAppBar(
|
||||
return PopScope(
|
||||
canPop: true, // Always allow back gesture
|
||||
child: Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
// Collapsing App Bar with back button
|
||||
SliverAppBar(
|
||||
expandedHeight: 120 + topPadding,
|
||||
collapsedHeight: kToolbarHeight,
|
||||
floating: false,
|
||||
|
|
@ -129,7 +131,8 @@ class AppearanceSettingsPage extends ConsumerWidget {
|
|||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -22,11 +22,13 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||
// Check if current service is built-in (supports quality options)
|
||||
final isBuiltInService = _builtInServices.contains(settings.defaultService);
|
||||
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
// Collapsing App Bar with back button
|
||||
SliverAppBar(
|
||||
return PopScope(
|
||||
canPop: true, // Always allow back gesture
|
||||
child: Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
// Collapsing App Bar with back button
|
||||
SliverAppBar(
|
||||
expandedHeight: 120 + topPadding,
|
||||
collapsedHeight: kToolbarHeight,
|
||||
floating: false,
|
||||
|
|
@ -194,6 +196,19 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||
.read(settingsProvider.notifier)
|
||||
.setSeparateSingles(value),
|
||||
),
|
||||
if (settings.separateSingles)
|
||||
SettingsItem(
|
||||
icon: Icons.folder_outlined,
|
||||
title: 'Album Folder Structure',
|
||||
subtitle: settings.albumFolderStructure == 'album_only'
|
||||
? 'Albums/Album Name/'
|
||||
: 'Albums/Artist/Album Name/',
|
||||
onTap: () => _showAlbumFolderStructurePicker(
|
||||
context,
|
||||
ref,
|
||||
settings.albumFolderStructure,
|
||||
),
|
||||
),
|
||||
if (!settings.separateSingles)
|
||||
SettingsItem(
|
||||
icon: Icons.create_new_folder_outlined,
|
||||
|
|
@ -215,7 +230,41 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||
const SliverToBoxAdapter(child: SizedBox(height: 32)),
|
||||
],
|
||||
),
|
||||
);
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showAlbumFolderStructurePicker(BuildContext context, WidgetRef ref, String current) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (context) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.folder_outlined),
|
||||
title: const Text('Artist / Album'),
|
||||
subtitle: const Text('Albums/Artist Name/Album Name/'),
|
||||
trailing: current == 'artist_album' ? const Icon(Icons.check) : null,
|
||||
onTap: () {
|
||||
ref.read(settingsProvider.notifier).setAlbumFolderStructure('artist_album');
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.album_outlined),
|
||||
title: const Text('Album Only'),
|
||||
subtitle: const Text('Albums/Album Name/'),
|
||||
trailing: current == 'album_only' ? const Icon(Icons.check) : null,
|
||||
onTap: () {
|
||||
ref.read(settingsProvider.notifier).setAlbumFolderStructure('album_only');
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showFormatEditor(BuildContext context, WidgetRef ref, String current) {
|
||||
|
|
|
|||
|
|
@ -56,11 +56,13 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
|
|||
final topPadding = MediaQuery.of(context).padding.top;
|
||||
final hasError = extension.status == 'error';
|
||||
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
// App Bar
|
||||
SliverAppBar(
|
||||
return PopScope(
|
||||
canPop: true, // Always allow back gesture
|
||||
child: Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
// App Bar
|
||||
SliverAppBar(
|
||||
expandedHeight: 120 + topPadding,
|
||||
collapsedHeight: kToolbarHeight,
|
||||
floating: false,
|
||||
|
|
@ -348,6 +350,7 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
|
|||
const SliverToBoxAdapter(child: SizedBox(height: 32)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -45,9 +45,11 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
|
|||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final topPadding = MediaQuery.of(context).padding.top;
|
||||
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
return PopScope(
|
||||
canPop: true, // Always allow back gesture
|
||||
child: Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
// App Bar
|
||||
SliverAppBar(
|
||||
expandedHeight: 120 + topPadding,
|
||||
|
|
@ -248,6 +250,7 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
|
|||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -124,12 +124,14 @@ class _LogScreenState extends State<LogScreen> {
|
|||
final topPadding = MediaQuery.of(context).padding.top;
|
||||
final logs = _filteredLogs;
|
||||
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
controller: _scrollController,
|
||||
slivers: [
|
||||
// Collapsing App Bar with back button - same as other settings pages
|
||||
SliverAppBar(
|
||||
return PopScope(
|
||||
canPop: true, // Always allow back gesture
|
||||
child: Scaffold(
|
||||
body: CustomScrollView(
|
||||
controller: _scrollController,
|
||||
slivers: [
|
||||
// Collapsing App Bar with back button - same as other settings pages
|
||||
SliverAppBar(
|
||||
expandedHeight: 120 + topPadding,
|
||||
collapsedHeight: kToolbarHeight,
|
||||
floating: false,
|
||||
|
|
@ -378,7 +380,8 @@ class _LogScreenState extends State<LogScreen> {
|
|||
const SliverToBoxAdapter(child: SizedBox(height: 32)),
|
||||
],
|
||||
),
|
||||
);
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -17,11 +17,13 @@ class OptionsSettingsPage extends ConsumerWidget {
|
|||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final topPadding = MediaQuery.of(context).padding.top;
|
||||
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
// Collapsing App Bar with back button
|
||||
SliverAppBar(
|
||||
return PopScope(
|
||||
canPop: true, // Always allow back gesture
|
||||
child: Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
// Collapsing App Bar with back button
|
||||
SliverAppBar(
|
||||
expandedHeight: 120 + topPadding,
|
||||
collapsedHeight: kToolbarHeight,
|
||||
floating: false,
|
||||
|
|
@ -271,7 +273,8 @@ class OptionsSettingsPage extends ConsumerWidget {
|
|||
const SliverToBoxAdapter(child: SizedBox(height: 32)),
|
||||
],
|
||||
),
|
||||
);
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showClearHistoryDialog(
|
||||
|
|
|
|||
|
|
@ -116,6 +116,27 @@ class SettingsTab extends ConsumerWidget {
|
|||
}
|
||||
|
||||
void _navigateTo(BuildContext context, Widget page) {
|
||||
Navigator.of(context).push(MaterialPageRoute(builder: (_) => page));
|
||||
Navigator.of(context).push(
|
||||
// Use PageRouteBuilder for better predictive back gesture support
|
||||
// MaterialPageRoute can cause freeze on some devices with gesture navigation
|
||||
PageRouteBuilder(
|
||||
pageBuilder: (context, animation, secondaryAnimation) => page,
|
||||
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
||||
// Use slide transition similar to MaterialPageRoute
|
||||
const begin = Offset(1.0, 0.0);
|
||||
const end = Offset.zero;
|
||||
const curve = Curves.easeInOut;
|
||||
var tween = Tween(begin: begin, end: end).chain(
|
||||
CurveTween(curve: curve),
|
||||
);
|
||||
return SlideTransition(
|
||||
position: animation.drive(tween),
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
transitionDuration: const Duration(milliseconds: 300),
|
||||
reverseTransitionDuration: const Duration(milliseconds: 250),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,10 @@ class _StoreTabState extends ConsumerState<StoreTab> {
|
|||
_isInitialized = true;
|
||||
|
||||
final cacheDir = await getApplicationCacheDirectory();
|
||||
|
||||
// Check if widget is still mounted after async operation
|
||||
if (!mounted) return;
|
||||
|
||||
await ref.read(storeProvider.notifier).initialize(cacheDir.path);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue