chore: housekeeping cleanup and code deduplication
- Remove stray tracked files (root AndroidManifest.xml, build.gradle.bak, temp_project template) - Move README-only images out of app asset bundle to reduce APK/IPA size (~1.68MB) - Fix logo filename typo (transparant -> transparent) - Deduplicate _readPositiveInt into shared int_utils.dart - Deduplicate _themeModeFromString (reuse from theme_settings.dart) - Remove deprecated LocalLibraryState.items getter - Remove unused sqflite_common_ffi dependency - Update apps.json version to 4.5.1 - Fix Flutter version in CONTRIBUTING.md (3.38.1 -> 3.41.5) - Improve .gitignore patterns (NUL, *.bak, root AndroidManifest.xml)
5
.gitignore
vendored
|
|
@ -44,6 +44,7 @@ go_backend/*.xcframework/
|
|||
# Android
|
||||
android/.gradle/
|
||||
android/app/libs/gobackend.aar
|
||||
android/app/libs/gobackend-sources.jar
|
||||
android/local.properties
|
||||
android/*.iml
|
||||
android/key.properties
|
||||
|
|
@ -57,7 +58,6 @@ ios/Pods/
|
|||
ios/.symlinks/
|
||||
ios/Flutter/Flutter.framework/
|
||||
ios/Flutter/Flutter.podspec
|
||||
android/app/libs/gobackend-sources.jar
|
||||
|
||||
# Extension folder
|
||||
extension/
|
||||
|
|
@ -67,7 +67,10 @@ AGENTS.md
|
|||
|
||||
# Temp/misc
|
||||
nul
|
||||
NUL
|
||||
network_requests.txt
|
||||
*.bak
|
||||
/AndroidManifest.xml
|
||||
|
||||
# Log files
|
||||
*.log
|
||||
|
|
|
|||
|
|
@ -86,7 +86,7 @@ Translation files are located in `lib/l10n/arb/`.
|
|||
git remote add upstream https://github.com/zarzet/SpotiFLAC-Mobile.git
|
||||
```
|
||||
|
||||
3. **Use FVM (Flutter Version: 3.38.1)**
|
||||
3. **Use FVM (Flutter Version: 3.41.5)**
|
||||
```bash
|
||||
fvm use
|
||||
```
|
||||
|
|
|
|||
14
README.md
|
|
@ -1,9 +1,9 @@
|
|||
<div align="center">
|
||||
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="assets/images/banner-readme-dark.png">
|
||||
<source media="(prefers-color-scheme: light)" srcset="assets/images/banner-readme-light.png">
|
||||
<img alt="SpotiFLAC Mobile" src="assets/images/banner-readme-light.png" width="650" height="auto">
|
||||
<source media="(prefers-color-scheme: dark)" srcset="assets/readme/banner-readme-dark.png">
|
||||
<source media="(prefers-color-scheme: light)" srcset="assets/readme/banner-readme-light.png">
|
||||
<img alt="SpotiFLAC Mobile" src="assets/readme/banner-readme-light.png" width="650" height="auto">
|
||||
</picture>
|
||||
|
||||
<p align="center">
|
||||
|
|
@ -28,10 +28,10 @@
|
|||
## Screenshots
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/images/1.jpg?v=2" width="200" />
|
||||
<img src="assets/images/2.jpg?v=2" width="200" />
|
||||
<img src="assets/images/3.jpg?v=2" width="200" />
|
||||
<img src="assets/images/4.jpg?v=2" width="200" />
|
||||
<img src="assets/readme/1.jpg?v=2" width="200" />
|
||||
<img src="assets/readme/2.jpg?v=2" width="200" />
|
||||
<img src="assets/readme/3.jpg?v=2" width="200" />
|
||||
<img src="assets/readme/4.jpg?v=2" width="200" />
|
||||
</p>
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -1,71 +0,0 @@
|
|||
plugins {
|
||||
id "com.android.application"
|
||||
id "kotlin-android"
|
||||
id "dev.flutter.flutter-gradle-plugin"
|
||||
}
|
||||
|
||||
def localProperties = new Properties()
|
||||
def localPropertiesFile = rootProject.file('local.properties')
|
||||
if (localPropertiesFile.exists()) {
|
||||
localPropertiesFile.withReader('UTF-8') { reader ->
|
||||
localProperties.load(reader)
|
||||
}
|
||||
}
|
||||
|
||||
def flutterVersionCode = localProperties.getProperty('flutter.versionCode')
|
||||
if (flutterVersionCode == null) {
|
||||
flutterVersionCode = '1'
|
||||
}
|
||||
|
||||
def flutterVersionName = localProperties.getProperty('flutter.versionName')
|
||||
if (flutterVersionName == null) {
|
||||
flutterVersionName = '1.0'
|
||||
}
|
||||
|
||||
android {
|
||||
namespace "com.zarz.spotiflac"
|
||||
compileSdk flutter.compileSdkVersion
|
||||
ndkVersion flutter.ndkVersion
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_1_8
|
||||
targetCompatibility JavaVersion.VERSION_1_8
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = '1.8'
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
main.java.srcDirs += 'src/main/kotlin'
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
applicationId "com.zarz.spotiflac"
|
||||
minSdkVersion flutter.minSdkVersion
|
||||
targetSdk flutter.targetSdkVersion
|
||||
versionCode flutterVersionCode.toInteger()
|
||||
versionName flutterVersionName
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
signingConfig signingConfigs.debug
|
||||
minifyEnabled false
|
||||
shrinkResources false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flutter {
|
||||
source '../..'
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Go backend library (gomobile generated)
|
||||
implementation fileTree(dir: 'libs', include: ['*.aar'])
|
||||
|
||||
// Kotlin coroutines for async Go backend calls
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3'
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3'
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
package com.example.temp_project
|
||||
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
|
||||
class MainActivity : FlutterActivity()
|
||||
|
|
@ -7,9 +7,9 @@
|
|||
"name": "SpotiFLAC Mobile",
|
||||
"bundleIdentifier": "com.zarzet.spotiflac",
|
||||
"developerName": "zarzet",
|
||||
"version": "4.5.0",
|
||||
"version": "4.5.1",
|
||||
"versionDate": "2026-05-06",
|
||||
"downloadURL": "https://github.com/zarzet/SpotiFLAC-Mobile/releases/download/v4.5.0/SpotiFLAC-v4.5.0-ios-unsigned.ipa",
|
||||
"downloadURL": "https://github.com/zarzet/SpotiFLAC-Mobile/releases/download/v4.5.1/SpotiFLAC-v4.5.1-ios-unsigned.ipa",
|
||||
"localizedDescription": "SpotiFLAC Mobile is written in Flutter. Download tracks in true FLAC from Tidal, Qobuz, & Amazon Music.",
|
||||
"iconURL": "https://raw.githubusercontent.com/zarzet/SpotiFLAC-Mobile/main/assets/images/logo.png",
|
||||
"size": 37191956
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 143 KiB After Width: | Height: | Size: 143 KiB |
|
Before Width: | Height: | Size: 539 KiB After Width: | Height: | Size: 539 KiB |
|
Before Width: | Height: | Size: 811 KiB After Width: | Height: | Size: 811 KiB |
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 122 KiB After Width: | Height: | Size: 122 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
|
|
@ -46,7 +46,7 @@ class ThemeSettings {
|
|||
|
||||
factory ThemeSettings.fromJson(Map<String, dynamic> json) {
|
||||
return ThemeSettings(
|
||||
themeMode: _themeModeFromString(json[kThemeModeKey] as String?),
|
||||
themeMode: themeModeFromString(json[kThemeModeKey] as String?),
|
||||
useDynamicColor: json[kUseDynamicColorKey] as bool? ?? true,
|
||||
seedColorValue: json[kSeedColorKey] as int? ?? kDefaultSeedColor,
|
||||
useAmoled: json[kUseAmoledKey] as bool? ?? false,
|
||||
|
|
@ -68,7 +68,7 @@ class ThemeSettings {
|
|||
themeMode.hashCode ^ useDynamicColor.hashCode ^ seedColorValue.hashCode ^ useAmoled.hashCode;
|
||||
}
|
||||
|
||||
ThemeMode _themeModeFromString(String? value) {
|
||||
ThemeMode themeModeFromString(String? value) {
|
||||
if (value == null) return ThemeMode.system;
|
||||
return ThemeMode.values.firstWhere(
|
||||
(e) => e.name == value,
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import 'package:spotiflac_android/utils/logger.dart' hide log;
|
|||
import 'package:spotiflac_android/utils/file_access.dart';
|
||||
import 'package:spotiflac_android/utils/string_utils.dart';
|
||||
import 'package:spotiflac_android/utils/artist_utils.dart';
|
||||
import 'package:spotiflac_android/utils/int_utils.dart';
|
||||
|
||||
export 'package:spotiflac_android/services/history_database.dart'
|
||||
show HistoryLookupRequest, HistoryBatchLookupRequest;
|
||||
|
|
@ -34,19 +35,8 @@ final _trimUnderscoresAndSpacesRegex = RegExp(r'^[_ ]+|[_ ]+$');
|
|||
final _multiWhitespaceRegex = RegExp(r'\s+');
|
||||
final _multiUnderscoreRegex = RegExp(r'_+');
|
||||
|
||||
int? _readPositiveIntValue(dynamic value) {
|
||||
if (value == null) return null;
|
||||
if (value is num) {
|
||||
final asInt = value.toInt();
|
||||
return asInt > 0 ? asInt : null;
|
||||
}
|
||||
final parsed = int.tryParse(value.toString());
|
||||
if (parsed == null || parsed <= 0) return null;
|
||||
return parsed;
|
||||
}
|
||||
|
||||
int? _readPositiveBitrateKbps(dynamic value) {
|
||||
final parsed = _readPositiveIntValue(value);
|
||||
final parsed = readPositiveInt(value);
|
||||
if (parsed == null) return null;
|
||||
return parsed >= 10000 ? (parsed / 1000).round() : parsed;
|
||||
}
|
||||
|
|
@ -666,17 +656,6 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
|||
}
|
||||
}
|
||||
|
||||
int? _readPositiveInt(dynamic value) {
|
||||
if (value == null) return null;
|
||||
if (value is num) {
|
||||
final asInt = value.toInt();
|
||||
return asInt > 0 ? asInt : null;
|
||||
}
|
||||
final parsed = int.tryParse(value.toString());
|
||||
if (parsed == null || parsed <= 0) return null;
|
||||
return parsed;
|
||||
}
|
||||
|
||||
bool _supportsAudioMetadataProbe(String filePath) {
|
||||
final trimmed = filePath.trim().toLowerCase();
|
||||
if (trimmed.isEmpty) return false;
|
||||
|
|
@ -757,8 +736,8 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
|||
return null;
|
||||
}
|
||||
|
||||
final bitDepth = _readPositiveInt(result['bit_depth']);
|
||||
final sampleRate = _readPositiveInt(result['sample_rate']);
|
||||
final bitDepth = readPositiveInt(result['bit_depth']);
|
||||
final sampleRate = readPositiveInt(result['sample_rate']);
|
||||
final bitrateKbps = _readPositiveBitrateKbps(result['bitrate']);
|
||||
final quality = _resolveDisplayQuality(
|
||||
filePath: filePath,
|
||||
|
|
@ -768,11 +747,11 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
|||
storedQuality: fallbackQuality,
|
||||
);
|
||||
final composer = normalizeOptionalString(result['composer']?.toString());
|
||||
final duration = _readPositiveInt(result['duration']);
|
||||
final trackNumber = _readPositiveInt(result['track_number']);
|
||||
final totalTracks = _readPositiveInt(result['total_tracks']);
|
||||
final discNumber = _readPositiveInt(result['disc_number']);
|
||||
final totalDiscs = _readPositiveInt(result['total_discs']);
|
||||
final duration = readPositiveInt(result['duration']);
|
||||
final trackNumber = readPositiveInt(result['track_number']);
|
||||
final totalTracks = readPositiveInt(result['total_tracks']);
|
||||
final discNumber = readPositiveInt(result['disc_number']);
|
||||
final totalDiscs = readPositiveInt(result['total_discs']);
|
||||
|
||||
if (quality == null &&
|
||||
bitDepth == null &&
|
||||
|
|
|
|||
|
|
@ -54,11 +54,6 @@ class LocalLibraryState {
|
|||
_isrcSet = isrcSet ?? const <String>{},
|
||||
_filePathById = filePathById ?? const <String, String>{};
|
||||
|
||||
@Deprecated(
|
||||
'LocalLibraryState no longer owns full track rows. Use DB-backed page providers.',
|
||||
)
|
||||
List<LocalLibraryItem> get items => const <LocalLibraryItem>[];
|
||||
|
||||
bool hasIsrc(String isrc) => _isrcSet.contains(isrc);
|
||||
|
||||
bool hasTrack(String trackName, String artistName) {
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ class ThemeNotifier extends Notifier<ThemeSettings> {
|
|||
final useAmoled = prefs.getBool(kUseAmoledKey);
|
||||
|
||||
state = ThemeSettings(
|
||||
themeMode: _themeModeFromString(modeString),
|
||||
themeMode: themeModeFromString(modeString),
|
||||
useDynamicColor: useDynamic ?? true,
|
||||
seedColorValue: seedColor ?? kDefaultSeedColor,
|
||||
useAmoled: useAmoled ?? false,
|
||||
|
|
@ -71,12 +71,4 @@ class ThemeNotifier extends Notifier<ThemeSettings> {
|
|||
state = state.copyWith(useAmoled: value);
|
||||
await _saveToStorage();
|
||||
}
|
||||
|
||||
ThemeMode _themeModeFromString(String? value) {
|
||||
if (value == null) return ThemeMode.system;
|
||||
return ThemeMode.values.firstWhere(
|
||||
(e) => e.name == value,
|
||||
orElse: () => ThemeMode.system,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1549,7 +1549,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Image.asset(
|
||||
'assets/images/logo-transparant.png',
|
||||
'assets/images/logo-transparent.png',
|
||||
color: colorScheme.onPrimary,
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (_, _, _) => ClipRRect(
|
||||
|
|
|
|||
|
|
@ -302,7 +302,7 @@ class _AppHeaderCard extends StatelessWidget {
|
|||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Image.asset(
|
||||
'assets/images/logo-transparant.png',
|
||||
'assets/images/logo-transparent.png',
|
||||
color: colorScheme.onPrimary,
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (_, _, _) => ClipRRect(
|
||||
|
|
|
|||
|
|
@ -681,7 +681,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Image.asset(
|
||||
'assets/images/logo-transparant.png',
|
||||
'assets/images/logo-transparent.png',
|
||||
width: logoSize,
|
||||
height: logoSize,
|
||||
color: colorScheme.primary,
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart';
|
|||
import 'package:spotiflac_android/utils/mime_utils.dart';
|
||||
import 'package:spotiflac_android/utils/image_cache_utils.dart';
|
||||
import 'package:spotiflac_android/utils/string_utils.dart';
|
||||
import 'package:spotiflac_android/utils/int_utils.dart';
|
||||
import 'package:spotiflac_android/widgets/audio_analysis_widget.dart';
|
||||
import 'package:spotiflac_android/widgets/cached_cover_image.dart';
|
||||
|
||||
|
|
@ -365,9 +366,9 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||
return;
|
||||
}
|
||||
|
||||
final resolvedBitDepth = _readPositiveInt(metadata['bit_depth']);
|
||||
final resolvedSampleRate = _readPositiveInt(metadata['sample_rate']);
|
||||
final resolvedDuration = _readPositiveInt(metadata['duration']);
|
||||
final resolvedBitDepth = readPositiveInt(metadata['bit_depth']);
|
||||
final resolvedSampleRate = readPositiveInt(metadata['sample_rate']);
|
||||
final resolvedDuration = readPositiveInt(metadata['duration']);
|
||||
final resolvedAlbum = metadata['album']?.toString();
|
||||
final resolvedQuality = buildDisplayAudioQuality(
|
||||
bitDepth: resolvedBitDepth ?? bitDepth,
|
||||
|
|
@ -386,10 +387,10 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||
|
||||
// Resolve label/copyright from file when the model doesn't carry them
|
||||
// (e.g. local library items, or download history items without these fields).
|
||||
final resolvedTrackNumber = _readPositiveInt(metadata['track_number']);
|
||||
final resolvedTotalTracks = _readPositiveInt(metadata['total_tracks']);
|
||||
final resolvedDiscNumber = _readPositiveInt(metadata['disc_number']);
|
||||
final resolvedTotalDiscs = _readPositiveInt(metadata['total_discs']);
|
||||
final resolvedTrackNumber = readPositiveInt(metadata['track_number']);
|
||||
final resolvedTotalTracks = readPositiveInt(metadata['total_tracks']);
|
||||
final resolvedDiscNumber = readPositiveInt(metadata['disc_number']);
|
||||
final resolvedTotalDiscs = readPositiveInt(metadata['total_discs']);
|
||||
final resolvedComposer = metadata['composer']?.toString();
|
||||
final resolvedLabel = metadata['label']?.toString();
|
||||
final resolvedCopyright = metadata['copyright']?.toString();
|
||||
|
|
@ -614,7 +615,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||
}
|
||||
|
||||
int? get totalTracks =>
|
||||
_readPositiveInt(_editedMetadata?['total_tracks']) ??
|
||||
readPositiveInt(_editedMetadata?['total_tracks']) ??
|
||||
(_isLocalItem
|
||||
? _localLibraryItem!.totalTracks
|
||||
: _downloadItem!.totalTracks);
|
||||
|
|
@ -631,7 +632,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||
}
|
||||
|
||||
int? get totalDiscs =>
|
||||
_readPositiveInt(_editedMetadata?['total_discs']) ??
|
||||
readPositiveInt(_editedMetadata?['total_discs']) ??
|
||||
(_isLocalItem
|
||||
? _localLibraryItem!.totalDiscs
|
||||
: _downloadItem!.totalDiscs);
|
||||
|
|
@ -670,13 +671,13 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||
_editedMetadata?['composer']?.toString() ??
|
||||
(_isLocalItem ? _localLibraryItem!.composer : null);
|
||||
int? get duration =>
|
||||
_readPositiveInt(_editedMetadata?['duration']) ??
|
||||
readPositiveInt(_editedMetadata?['duration']) ??
|
||||
(_isLocalItem ? _localLibraryItem!.duration : _downloadItem!.duration);
|
||||
int? get bitDepth =>
|
||||
_readPositiveInt(_editedMetadata?['bit_depth']) ??
|
||||
readPositiveInt(_editedMetadata?['bit_depth']) ??
|
||||
(_isLocalItem ? _localLibraryItem!.bitDepth : _downloadItem!.bitDepth);
|
||||
int? get sampleRate =>
|
||||
_readPositiveInt(_editedMetadata?['sample_rate']) ??
|
||||
readPositiveInt(_editedMetadata?['sample_rate']) ??
|
||||
(_isLocalItem
|
||||
? _localLibraryItem!.sampleRate
|
||||
: _downloadItem!.sampleRate);
|
||||
|
|
@ -706,17 +707,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||
|
||||
String? get _quality => _isLocalItem ? null : _downloadItem!.quality;
|
||||
|
||||
int? _readPositiveInt(dynamic value) {
|
||||
if (value == null) return null;
|
||||
if (value is num) {
|
||||
final asInt = value.toInt();
|
||||
return asInt > 0 ? asInt : null;
|
||||
}
|
||||
final parsed = int.tryParse(value.toString());
|
||||
if (parsed == null || parsed <= 0) return null;
|
||||
return parsed;
|
||||
}
|
||||
|
||||
String _displayServiceTrackId(String value) {
|
||||
final raw = value.trim();
|
||||
if (raw.isEmpty) return raw;
|
||||
|
|
@ -4394,7 +4384,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||
};
|
||||
|
||||
final initialDurationSeconds =
|
||||
_readPositiveInt(fileMetadata?['duration']) ?? duration ?? 0;
|
||||
readPositiveInt(fileMetadata?['duration']) ?? duration ?? 0;
|
||||
|
||||
if (!context.mounted) return;
|
||||
|
||||
|
|
|
|||
13
lib/utils/int_utils.dart
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
/// Parses a dynamic value to a positive integer (> 0), or returns null.
|
||||
///
|
||||
/// Accepts [num] and parseable [String] values.
|
||||
int? readPositiveInt(dynamic value) {
|
||||
if (value == null) return null;
|
||||
if (value is num) {
|
||||
final asInt = value.toInt();
|
||||
return asInt > 0 ? asInt : null;
|
||||
}
|
||||
final parsed = int.tryParse(value.toString());
|
||||
if (parsed == null || parsed <= 0) return null;
|
||||
return parsed;
|
||||
}
|
||||
|
|
@ -27,7 +27,6 @@ dependencies:
|
|||
path_provider: ^2.1.5
|
||||
path: ^1.9.0
|
||||
sqflite: ^2.4.2+1
|
||||
sqflite_common_ffi: ^2.4.0+3
|
||||
|
||||
# HTTP & Network
|
||||
http: ^1.6.0
|
||||
|
|
|
|||