Initial commit: SpotiFLAC Android/iOS app

This commit is contained in:
zarzet
2026-01-01 19:28:15 +07:00
commit 3fdcf6d5b7
126 changed files with 14079 additions and 0 deletions
+62
View File
@@ -0,0 +1,62 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:spotiflac_android/models/track.dart';
part 'download_item.g.dart';
/// Download status enum
enum DownloadStatus {
queued,
downloading,
completed,
failed,
skipped,
}
@JsonSerializable()
class DownloadItem {
final String id;
final Track track;
final String service;
final DownloadStatus status;
final double progress;
final String? filePath;
final String? error;
final DateTime createdAt;
const DownloadItem({
required this.id,
required this.track,
required this.service,
this.status = DownloadStatus.queued,
this.progress = 0.0,
this.filePath,
this.error,
required this.createdAt,
});
DownloadItem copyWith({
String? id,
Track? track,
String? service,
DownloadStatus? status,
double? progress,
String? filePath,
String? error,
DateTime? createdAt,
}) {
return DownloadItem(
id: id ?? this.id,
track: track ?? this.track,
service: service ?? this.service,
status: status ?? this.status,
progress: progress ?? this.progress,
filePath: filePath ?? this.filePath,
error: error ?? this.error,
createdAt: createdAt ?? this.createdAt,
);
}
factory DownloadItem.fromJson(Map<String, dynamic> json) =>
_$DownloadItemFromJson(json);
Map<String, dynamic> toJson() => _$DownloadItemToJson(this);
}
+58
View File
@@ -0,0 +1,58 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'download_item.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
DownloadItem _$DownloadItemFromJson(Map<String, dynamic> json) => DownloadItem(
id: json['id'] as String,
track: Track.fromJson(json['track'] as Map<String, dynamic>),
service: json['service'] as String,
status: $enumDecodeNullable(_$DownloadStatusEnumMap, json['status']) ??
DownloadStatus.queued,
progress: (json['progress'] as num?)?.toDouble() ?? 0.0,
filePath: json['filePath'] as String?,
error: json['error'] as String?,
createdAt: DateTime.parse(json['createdAt'] as String),
);
Map<String, dynamic> _$DownloadItemToJson(DownloadItem instance) =>
<String, dynamic>{
'id': instance.id,
'track': instance.track.toJson(),
'service': instance.service,
'status': _$DownloadStatusEnumMap[instance.status]!,
'progress': instance.progress,
'filePath': instance.filePath,
'error': instance.error,
'createdAt': instance.createdAt.toIso8601String(),
};
const _$DownloadStatusEnumMap = {
DownloadStatus.queued: 'queued',
DownloadStatus.downloading: 'downloading',
DownloadStatus.completed: 'completed',
DownloadStatus.failed: 'failed',
DownloadStatus.skipped: 'skipped',
};
K? $enumDecodeNullable<K, V>(
Map<K, V> enumValues,
Object? source, {
K? unknownValue,
}) {
if (source == null) {
return null;
}
return enumValues.entries
.singleWhere(
(e) => e.value == source,
orElse: () => throw ArgumentError(
'`$source` is not one of the supported values: '
'${enumValues.values.join(', ')}',
),
)
.key;
}
+52
View File
@@ -0,0 +1,52 @@
import 'package:json_annotation/json_annotation.dart';
part 'settings.g.dart';
@JsonSerializable()
class AppSettings {
final String defaultService;
final String audioQuality;
final String filenameFormat;
final String downloadDirectory;
final bool autoFallback;
final bool embedLyrics;
final bool maxQualityCover;
final bool isFirstLaunch;
const AppSettings({
this.defaultService = 'tidal',
this.audioQuality = 'LOSSLESS',
this.filenameFormat = '{title} - {artist}',
this.downloadDirectory = '',
this.autoFallback = true,
this.embedLyrics = true,
this.maxQualityCover = true,
this.isFirstLaunch = true,
});
AppSettings copyWith({
String? defaultService,
String? audioQuality,
String? filenameFormat,
String? downloadDirectory,
bool? autoFallback,
bool? embedLyrics,
bool? maxQualityCover,
bool? isFirstLaunch,
}) {
return AppSettings(
defaultService: defaultService ?? this.defaultService,
audioQuality: audioQuality ?? this.audioQuality,
filenameFormat: filenameFormat ?? this.filenameFormat,
downloadDirectory: downloadDirectory ?? this.downloadDirectory,
autoFallback: autoFallback ?? this.autoFallback,
embedLyrics: embedLyrics ?? this.embedLyrics,
maxQualityCover: maxQualityCover ?? this.maxQualityCover,
isFirstLaunch: isFirstLaunch ?? this.isFirstLaunch,
);
}
factory AppSettings.fromJson(Map<String, dynamic> json) =>
_$AppSettingsFromJson(json);
Map<String, dynamic> toJson() => _$AppSettingsToJson(this);
}
+30
View File
@@ -0,0 +1,30 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'settings.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
defaultService: json['defaultService'] as String? ?? 'tidal',
audioQuality: json['audioQuality'] as String? ?? 'LOSSLESS',
filenameFormat: json['filenameFormat'] as String? ?? '{title} - {artist}',
downloadDirectory: json['downloadDirectory'] as String? ?? '',
autoFallback: json['autoFallback'] as bool? ?? true,
embedLyrics: json['embedLyrics'] as bool? ?? true,
maxQualityCover: json['maxQualityCover'] as bool? ?? true,
isFirstLaunch: json['isFirstLaunch'] as bool? ?? true,
);
Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
<String, dynamic>{
'defaultService': instance.defaultService,
'audioQuality': instance.audioQuality,
'filenameFormat': instance.filenameFormat,
'downloadDirectory': instance.downloadDirectory,
'autoFallback': instance.autoFallback,
'embedLyrics': instance.embedLyrics,
'maxQualityCover': instance.maxQualityCover,
'isFirstLaunch': instance.isFirstLaunch,
};
+76
View File
@@ -0,0 +1,76 @@
import 'package:flutter/material.dart';
/// Storage keys for theme settings persistence
const String kThemeModeKey = 'theme_mode';
const String kUseDynamicColorKey = 'use_dynamic_color';
const String kSeedColorKey = 'seed_color';
/// Default Spotify green color for fallback
const int kDefaultSeedColor = 0xFF1DB954;
/// Theme settings model for Material Expressive 3
class ThemeSettings {
final ThemeMode themeMode;
final bool useDynamicColor;
final int seedColorValue;
const ThemeSettings({
this.themeMode = ThemeMode.system,
this.useDynamicColor = true,
this.seedColorValue = kDefaultSeedColor,
});
/// Get seed color as Color object
Color get seedColor => Color(seedColorValue);
/// Create a copy with updated values
ThemeSettings copyWith({
ThemeMode? themeMode,
bool? useDynamicColor,
int? seedColorValue,
}) {
return ThemeSettings(
themeMode: themeMode ?? this.themeMode,
useDynamicColor: useDynamicColor ?? this.useDynamicColor,
seedColorValue: seedColorValue ?? this.seedColorValue,
);
}
/// Convert to JSON map for persistence
Map<String, dynamic> toJson() => {
kThemeModeKey: themeMode.name,
kUseDynamicColorKey: useDynamicColor,
kSeedColorKey: seedColorValue,
};
/// Create from JSON map
factory ThemeSettings.fromJson(Map<String, dynamic> json) {
return ThemeSettings(
themeMode: _themeModeFromString(json[kThemeModeKey] as String?),
useDynamicColor: json[kUseDynamicColorKey] as bool? ?? true,
seedColorValue: json[kSeedColorKey] as int? ?? kDefaultSeedColor,
);
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is ThemeSettings &&
other.themeMode == themeMode &&
other.useDynamicColor == useDynamicColor &&
other.seedColorValue == seedColorValue;
}
@override
int get hashCode =>
themeMode.hashCode ^ useDynamicColor.hashCode ^ seedColorValue.hashCode;
}
/// Helper to convert string to ThemeMode
ThemeMode _themeModeFromString(String? value) {
if (value == null) return ThemeMode.system;
return ThemeMode.values.firstWhere(
(e) => e.name == value,
orElse: () => ThemeMode.system,
);
}
+61
View File
@@ -0,0 +1,61 @@
import 'package:json_annotation/json_annotation.dart';
part 'track.g.dart';
/// Track model representing a music track
@JsonSerializable()
class Track {
final String id;
final String name;
final String artistName;
final String albumName;
final String? albumArtist;
final String? coverUrl;
final String? isrc;
final int duration;
final int? trackNumber;
final int? discNumber;
final String? releaseDate;
final ServiceAvailability? availability;
const Track({
required this.id,
required this.name,
required this.artistName,
required this.albumName,
this.albumArtist,
this.coverUrl,
this.isrc,
required this.duration,
this.trackNumber,
this.discNumber,
this.releaseDate,
this.availability,
});
factory Track.fromJson(Map<String, dynamic> json) => _$TrackFromJson(json);
Map<String, dynamic> toJson() => _$TrackToJson(this);
}
@JsonSerializable()
class ServiceAvailability {
final bool tidal;
final bool qobuz;
final bool amazon;
final String? tidalUrl;
final String? qobuzUrl;
final String? amazonUrl;
const ServiceAvailability({
this.tidal = false,
this.qobuz = false,
this.amazon = false,
this.tidalUrl,
this.qobuzUrl,
this.amazonUrl,
});
factory ServiceAvailability.fromJson(Map<String, dynamic> json) =>
_$ServiceAvailabilityFromJson(json);
Map<String, dynamic> toJson() => _$ServiceAvailabilityToJson(this);
}
+61
View File
@@ -0,0 +1,61 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'track.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
Track _$TrackFromJson(Map<String, dynamic> json) => Track(
id: json['id'] as String,
name: json['name'] as String,
artistName: json['artistName'] as String,
albumName: json['albumName'] as String,
albumArtist: json['albumArtist'] as String?,
coverUrl: json['coverUrl'] as String?,
isrc: json['isrc'] as String?,
duration: (json['duration'] as num).toInt(),
trackNumber: (json['trackNumber'] as num?)?.toInt(),
discNumber: (json['discNumber'] as num?)?.toInt(),
releaseDate: json['releaseDate'] as String?,
availability: json['availability'] == null
? null
: ServiceAvailability.fromJson(
json['availability'] as Map<String, dynamic>),
);
Map<String, dynamic> _$TrackToJson(Track instance) => <String, dynamic>{
'id': instance.id,
'name': instance.name,
'artistName': instance.artistName,
'albumName': instance.albumName,
'albumArtist': instance.albumArtist,
'coverUrl': instance.coverUrl,
'isrc': instance.isrc,
'duration': instance.duration,
'trackNumber': instance.trackNumber,
'discNumber': instance.discNumber,
'releaseDate': instance.releaseDate,
'availability': instance.availability?.toJson(),
};
ServiceAvailability _$ServiceAvailabilityFromJson(Map<String, dynamic> json) =>
ServiceAvailability(
tidal: json['tidal'] as bool? ?? false,
qobuz: json['qobuz'] as bool? ?? false,
amazon: json['amazon'] as bool? ?? false,
tidalUrl: json['tidalUrl'] as String?,
qobuzUrl: json['qobuzUrl'] as String?,
amazonUrl: json['amazonUrl'] as String?,
);
Map<String, dynamic> _$ServiceAvailabilityToJson(
ServiceAvailability instance) =>
<String, dynamic>{
'tidal': instance.tidal,
'qobuz': instance.qobuz,
'amazon': instance.amazon,
'tidalUrl': instance.tidalUrl,
'qobuzUrl': instance.qobuzUrl,
'amazonUrl': instance.amazonUrl,
};