v1.2.0: Track Metadata Screen, Hi-Res fix, Settings navigation fix
77
.github/workflows/release.yml
vendored
|
|
@ -222,6 +222,36 @@ jobs:
|
||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Extract changelog for version
|
||||||
|
id: changelog
|
||||||
|
run: |
|
||||||
|
VERSION=${{ needs.get-version.outputs.version }}
|
||||||
|
VERSION_NUM=${VERSION#v} # Remove 'v' prefix
|
||||||
|
|
||||||
|
# Extract changelog section for this version
|
||||||
|
# Look for ## [X.X.X] and capture until next ## [ or end of file
|
||||||
|
CHANGELOG=$(awk -v ver="$VERSION_NUM" '
|
||||||
|
/^## \[/ {
|
||||||
|
if (found) exit
|
||||||
|
if ($0 ~ "\\[" ver "\\]") found=1
|
||||||
|
next
|
||||||
|
}
|
||||||
|
found { print }
|
||||||
|
' CHANGELOG.md)
|
||||||
|
|
||||||
|
# If no changelog found, use default message
|
||||||
|
if [ -z "$CHANGELOG" ]; then
|
||||||
|
CHANGELOG="See CHANGELOG.md for details."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Save to file for multiline support
|
||||||
|
echo "$CHANGELOG" > /tmp/changelog.txt
|
||||||
|
echo "Extracted changelog:"
|
||||||
|
cat /tmp/changelog.txt
|
||||||
|
|
||||||
- name: Download Android APK
|
- name: Download Android APK
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v4
|
||||||
with:
|
with:
|
||||||
|
|
@ -234,24 +264,45 @@ jobs:
|
||||||
name: ios-ipa
|
name: ios-ipa
|
||||||
path: ./release
|
path: ./release
|
||||||
|
|
||||||
|
- name: Prepare release body
|
||||||
|
run: |
|
||||||
|
VERSION=${{ needs.get-version.outputs.version }}
|
||||||
|
cat > /tmp/release_body.txt << 'HEADER'
|
||||||
|
## SpotiFLAC $VERSION
|
||||||
|
|
||||||
|
Download Spotify tracks in FLAC quality from Tidal, Qobuz & Amazon Music.
|
||||||
|
|
||||||
|
### What's New
|
||||||
|
HEADER
|
||||||
|
|
||||||
|
# Replace $VERSION in header
|
||||||
|
sed -i "s/\$VERSION/$VERSION/g" /tmp/release_body.txt
|
||||||
|
|
||||||
|
cat /tmp/changelog.txt >> /tmp/release_body.txt
|
||||||
|
|
||||||
|
cat >> /tmp/release_body.txt << FOOTER
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Downloads
|
||||||
|
- **Android (arm64)**: \`SpotiFLAC-${VERSION}-arm64.apk\` (recommended)
|
||||||
|
- **Android (arm32)**: \`SpotiFLAC-${VERSION}-arm32.apk\` (older devices)
|
||||||
|
- **iOS**: \`SpotiFLAC-${VERSION}-ios-unsigned.ipa\` (sideload required)
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
**Android**: Enable "Install from unknown sources" and install the APK
|
||||||
|
**iOS**: Use AltStore, Sideloadly, or similar tools to sideload the IPA
|
||||||
|
FOOTER
|
||||||
|
|
||||||
|
echo "Release body:"
|
||||||
|
cat /tmp/release_body.txt
|
||||||
|
|
||||||
- name: Create Release
|
- name: Create Release
|
||||||
uses: softprops/action-gh-release@v1
|
uses: softprops/action-gh-release@v1
|
||||||
with:
|
with:
|
||||||
tag_name: ${{ needs.get-version.outputs.version }}
|
tag_name: ${{ needs.get-version.outputs.version }}
|
||||||
name: SpotiFLAC ${{ needs.get-version.outputs.version }}
|
name: SpotiFLAC ${{ needs.get-version.outputs.version }}
|
||||||
body: |
|
body_path: /tmp/release_body.txt
|
||||||
## SpotiFLAC ${{ needs.get-version.outputs.version }}
|
|
||||||
|
|
||||||
Download Spotify tracks in FLAC quality from Tidal, Qobuz & Amazon Music.
|
|
||||||
|
|
||||||
### Downloads
|
|
||||||
- **Android (arm64)**: `SpotiFLAC-${{ needs.get-version.outputs.version }}-arm64.apk` (recommended)
|
|
||||||
- **Android (arm32)**: `SpotiFLAC-${{ needs.get-version.outputs.version }}-arm32.apk` (older devices)
|
|
||||||
- **iOS**: `SpotiFLAC-${{ needs.get-version.outputs.version }}-ios-unsigned.ipa` (sideload required)
|
|
||||||
|
|
||||||
### Installation
|
|
||||||
**Android**: Enable "Install from unknown sources" and install the APK
|
|
||||||
**iOS**: Use AltStore, Sideloadly, or similar tools to sideload the IPA
|
|
||||||
files: ./release/*
|
files: ./release/*
|
||||||
draft: false
|
draft: false
|
||||||
prerelease: false
|
prerelease: false
|
||||||
|
|
|
||||||
|
|
@ -16,10 +16,11 @@ Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="docs/Screenshot_20260101-210622_SpotiFLAC.png" width="200" />
|
<img src="assets/images/Screenshot_20260101-210622_SpotiFLAC.png" width="200" />
|
||||||
<img src="docs/Screenshot_20260101-210626_SpotiFLAC.png" width="200" />
|
<img src="assets/images/Screenshot_20260101-210626_SpotiFLAC.png" width="200" />
|
||||||
<img src="docs/Screenshot_20260101-210633_SpotiFLAC.png" width="200" />
|
<img src="assets/images/photo_2026-01-01_23-56-11.jpg" width="200" />
|
||||||
<img src="docs/Screenshot_20260101-210653_SpotiFLAC.png" width="200" />
|
<img src="assets/images/Screenshot_20260101-210653_SpotiFLAC.png" width="200" />
|
||||||
|
<img src="assets/images/photo_2026-01-01_23-44-06.jpg" width="200" />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
## Other project
|
## Other project
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 137 KiB After Width: | Height: | Size: 137 KiB |
|
Before Width: | Height: | Size: 129 KiB After Width: | Height: | Size: 129 KiB |
|
Before Width: | Height: | Size: 202 KiB After Width: | Height: | Size: 202 KiB |
BIN
assets/images/photo_2026-01-01_23-44-06.jpg
Normal file
|
After Width: | Height: | Size: 131 KiB |
BIN
assets/images/photo_2026-01-01_23-56-11.jpg
Normal file
|
After Width: | Height: | Size: 116 KiB |
|
Before Width: | Height: | Size: 178 KiB |
|
|
@ -99,6 +99,7 @@ type DownloadRequest struct {
|
||||||
CoverURL string `json:"cover_url"`
|
CoverURL string `json:"cover_url"`
|
||||||
OutputDir string `json:"output_dir"`
|
OutputDir string `json:"output_dir"`
|
||||||
FilenameFormat string `json:"filename_format"`
|
FilenameFormat string `json:"filename_format"`
|
||||||
|
Quality string `json:"quality"` // LOSSLESS, HI_RES, HI_RES_LOSSLESS
|
||||||
EmbedLyrics bool `json:"embed_lyrics"`
|
EmbedLyrics bool `json:"embed_lyrics"`
|
||||||
EmbedMaxQualityCover bool `json:"embed_max_quality_cover"`
|
EmbedMaxQualityCover bool `json:"embed_max_quality_cover"`
|
||||||
TrackNumber int `json:"track_number"`
|
TrackNumber int `json:"track_number"`
|
||||||
|
|
|
||||||
|
|
@ -346,8 +346,22 @@ func downloadFromQobuz(req DownloadRequest) (string, error) {
|
||||||
return "EXISTS:" + outputPath, nil
|
return "EXISTS:" + outputPath, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Map quality from Tidal format to Qobuz format
|
||||||
|
// Tidal: LOSSLESS (16-bit), HI_RES (24-bit), HI_RES_LOSSLESS (24-bit hi-res)
|
||||||
|
// Qobuz: 5 (MP3 320), 6 (16-bit), 7 (24-bit 96kHz), 27 (24-bit 192kHz)
|
||||||
|
qobuzQuality := "27" // Default to highest quality
|
||||||
|
switch req.Quality {
|
||||||
|
case "LOSSLESS":
|
||||||
|
qobuzQuality = "6" // 16-bit FLAC
|
||||||
|
case "HI_RES":
|
||||||
|
qobuzQuality = "7" // 24-bit 96kHz
|
||||||
|
case "HI_RES_LOSSLESS":
|
||||||
|
qobuzQuality = "27" // 24-bit 192kHz
|
||||||
|
}
|
||||||
|
fmt.Printf("[Qobuz] Using quality: %s (mapped from %s)\n", qobuzQuality, req.Quality)
|
||||||
|
|
||||||
// Get download URL using parallel API requests
|
// Get download URL using parallel API requests
|
||||||
downloadURL, err := downloader.GetDownloadURL(track.ID, "27") // 27 = FLAC 24-bit
|
downloadURL, err := downloader.GetDownloadURL(track.ID, qobuzQuality)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to get download URL: %w", err)
|
return "", fmt.Errorf("failed to get download URL: %w", err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -859,8 +859,15 @@ func downloadFromTidal(req DownloadRequest) (string, error) {
|
||||||
return "EXISTS:" + outputPath, nil
|
return "EXISTS:" + outputPath, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Determine quality to use (default to LOSSLESS if not specified)
|
||||||
|
quality := req.Quality
|
||||||
|
if quality == "" {
|
||||||
|
quality = "LOSSLESS"
|
||||||
|
}
|
||||||
|
fmt.Printf("[Tidal] Using quality: %s\n", quality)
|
||||||
|
|
||||||
// Get download URL using parallel API requests
|
// Get download URL using parallel API requests
|
||||||
downloadURL, err := downloader.GetDownloadURL(track.ID, "LOSSLESS")
|
downloadURL, err := downloader.GetDownloadURL(track.ID, quality)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to get download URL: %w", err)
|
return "", fmt.Errorf("failed to get download URL: %w", err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,11 @@ import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||||
import 'package:spotiflac_android/theme/dynamic_color_wrapper.dart';
|
import 'package:spotiflac_android/theme/dynamic_color_wrapper.dart';
|
||||||
|
|
||||||
final _routerProvider = Provider<GoRouter>((ref) {
|
final _routerProvider = Provider<GoRouter>((ref) {
|
||||||
final settings = ref.watch(settingsProvider);
|
// Only watch isFirstLaunch to prevent router rebuild on other settings changes
|
||||||
|
final isFirstLaunch = ref.watch(settingsProvider.select((s) => s.isFirstLaunch));
|
||||||
|
|
||||||
return GoRouter(
|
return GoRouter(
|
||||||
initialLocation: settings.isFirstLaunch ? '/setup' : '/',
|
initialLocation: isFirstLaunch ? '/setup' : '/',
|
||||||
routes: [
|
routes: [
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/',
|
path: '/',
|
||||||
|
|
|
||||||
17
lib/constants/app_info.dart
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
/// App version and info constants
|
||||||
|
/// Update version here only - all other files will reference this
|
||||||
|
class AppInfo {
|
||||||
|
static const String version = '1.2.0';
|
||||||
|
static const String buildNumber = '10';
|
||||||
|
static const String fullVersion = '$version+$buildNumber';
|
||||||
|
|
||||||
|
static const String appName = 'SpotiFLAC';
|
||||||
|
static const String copyright = '© 2026 SpotiFLAC';
|
||||||
|
|
||||||
|
static const String mobileAuthor = 'zarzet';
|
||||||
|
static const String originalAuthor = 'afkarxyz';
|
||||||
|
|
||||||
|
static const String githubRepo = 'zarzet/SpotiFLAC-Mobile';
|
||||||
|
static const String githubUrl = 'https://github.com/$githubRepo';
|
||||||
|
static const String originalGithubUrl = 'https://github.com/afkarxyz/SpotiFLAC';
|
||||||
|
}
|
||||||
|
|
@ -13,6 +13,7 @@ class AppSettings {
|
||||||
final bool maxQualityCover;
|
final bool maxQualityCover;
|
||||||
final bool isFirstLaunch;
|
final bool isFirstLaunch;
|
||||||
final int concurrentDownloads; // 1 = sequential (default), max 3
|
final int concurrentDownloads; // 1 = sequential (default), max 3
|
||||||
|
final bool checkForUpdates; // Check for updates on app start
|
||||||
|
|
||||||
const AppSettings({
|
const AppSettings({
|
||||||
this.defaultService = 'tidal',
|
this.defaultService = 'tidal',
|
||||||
|
|
@ -24,6 +25,7 @@ class AppSettings {
|
||||||
this.maxQualityCover = true,
|
this.maxQualityCover = true,
|
||||||
this.isFirstLaunch = true,
|
this.isFirstLaunch = true,
|
||||||
this.concurrentDownloads = 1, // Default: sequential (off)
|
this.concurrentDownloads = 1, // Default: sequential (off)
|
||||||
|
this.checkForUpdates = true, // Default: enabled
|
||||||
});
|
});
|
||||||
|
|
||||||
AppSettings copyWith({
|
AppSettings copyWith({
|
||||||
|
|
@ -36,6 +38,7 @@ class AppSettings {
|
||||||
bool? maxQualityCover,
|
bool? maxQualityCover,
|
||||||
bool? isFirstLaunch,
|
bool? isFirstLaunch,
|
||||||
int? concurrentDownloads,
|
int? concurrentDownloads,
|
||||||
|
bool? checkForUpdates,
|
||||||
}) {
|
}) {
|
||||||
return AppSettings(
|
return AppSettings(
|
||||||
defaultService: defaultService ?? this.defaultService,
|
defaultService: defaultService ?? this.defaultService,
|
||||||
|
|
@ -47,6 +50,7 @@ class AppSettings {
|
||||||
maxQualityCover: maxQualityCover ?? this.maxQualityCover,
|
maxQualityCover: maxQualityCover ?? this.maxQualityCover,
|
||||||
isFirstLaunch: isFirstLaunch ?? this.isFirstLaunch,
|
isFirstLaunch: isFirstLaunch ?? this.isFirstLaunch,
|
||||||
concurrentDownloads: concurrentDownloads ?? this.concurrentDownloads,
|
concurrentDownloads: concurrentDownloads ?? this.concurrentDownloads,
|
||||||
|
checkForUpdates: checkForUpdates ?? this.checkForUpdates,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
||||||
maxQualityCover: json['maxQualityCover'] as bool? ?? true,
|
maxQualityCover: json['maxQualityCover'] as bool? ?? true,
|
||||||
isFirstLaunch: json['isFirstLaunch'] as bool? ?? true,
|
isFirstLaunch: json['isFirstLaunch'] as bool? ?? true,
|
||||||
concurrentDownloads: (json['concurrentDownloads'] as num?)?.toInt() ?? 1,
|
concurrentDownloads: (json['concurrentDownloads'] as num?)?.toInt() ?? 1,
|
||||||
|
checkForUpdates: json['checkForUpdates'] as bool? ?? true,
|
||||||
);
|
);
|
||||||
|
|
||||||
Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
||||||
|
|
@ -29,4 +30,5 @@ Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
||||||
'maxQualityCover': instance.maxQualityCover,
|
'maxQualityCover': instance.maxQualityCover,
|
||||||
'isFirstLaunch': instance.isFirstLaunch,
|
'isFirstLaunch': instance.isFirstLaunch,
|
||||||
'concurrentDownloads': instance.concurrentDownloads,
|
'concurrentDownloads': instance.concurrentDownloads,
|
||||||
|
'checkForUpdates': instance.checkForUpdates,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import 'package:ffmpeg_kit_flutter_new_audio/return_code.dart';
|
||||||
import 'package:spotiflac_android/models/download_item.dart';
|
import 'package:spotiflac_android/models/download_item.dart';
|
||||||
import 'package:spotiflac_android/models/settings.dart';
|
import 'package:spotiflac_android/models/settings.dart';
|
||||||
import 'package:spotiflac_android/models/track.dart';
|
import 'package:spotiflac_android/models/track.dart';
|
||||||
|
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||||
import 'package:spotiflac_android/services/ffmpeg_service.dart';
|
import 'package:spotiflac_android/services/ffmpeg_service.dart';
|
||||||
|
|
||||||
|
|
@ -18,20 +19,37 @@ class DownloadHistoryItem {
|
||||||
final String trackName;
|
final String trackName;
|
||||||
final String artistName;
|
final String artistName;
|
||||||
final String albumName;
|
final String albumName;
|
||||||
|
final String? albumArtist;
|
||||||
final String? coverUrl;
|
final String? coverUrl;
|
||||||
final String filePath;
|
final String filePath;
|
||||||
final String service;
|
final String service;
|
||||||
final DateTime downloadedAt;
|
final DateTime downloadedAt;
|
||||||
|
// Additional metadata
|
||||||
|
final String? isrc;
|
||||||
|
final String? spotifyId;
|
||||||
|
final int? trackNumber;
|
||||||
|
final int? discNumber;
|
||||||
|
final int? duration;
|
||||||
|
final String? releaseDate;
|
||||||
|
final String? quality;
|
||||||
|
|
||||||
const DownloadHistoryItem({
|
const DownloadHistoryItem({
|
||||||
required this.id,
|
required this.id,
|
||||||
required this.trackName,
|
required this.trackName,
|
||||||
required this.artistName,
|
required this.artistName,
|
||||||
required this.albumName,
|
required this.albumName,
|
||||||
|
this.albumArtist,
|
||||||
this.coverUrl,
|
this.coverUrl,
|
||||||
required this.filePath,
|
required this.filePath,
|
||||||
required this.service,
|
required this.service,
|
||||||
required this.downloadedAt,
|
required this.downloadedAt,
|
||||||
|
this.isrc,
|
||||||
|
this.spotifyId,
|
||||||
|
this.trackNumber,
|
||||||
|
this.discNumber,
|
||||||
|
this.duration,
|
||||||
|
this.releaseDate,
|
||||||
|
this.quality,
|
||||||
});
|
});
|
||||||
|
|
||||||
Map<String, dynamic> toJson() => {
|
Map<String, dynamic> toJson() => {
|
||||||
|
|
@ -39,10 +57,18 @@ class DownloadHistoryItem {
|
||||||
'trackName': trackName,
|
'trackName': trackName,
|
||||||
'artistName': artistName,
|
'artistName': artistName,
|
||||||
'albumName': albumName,
|
'albumName': albumName,
|
||||||
|
'albumArtist': albumArtist,
|
||||||
'coverUrl': coverUrl,
|
'coverUrl': coverUrl,
|
||||||
'filePath': filePath,
|
'filePath': filePath,
|
||||||
'service': service,
|
'service': service,
|
||||||
'downloadedAt': downloadedAt.toIso8601String(),
|
'downloadedAt': downloadedAt.toIso8601String(),
|
||||||
|
'isrc': isrc,
|
||||||
|
'spotifyId': spotifyId,
|
||||||
|
'trackNumber': trackNumber,
|
||||||
|
'discNumber': discNumber,
|
||||||
|
'duration': duration,
|
||||||
|
'releaseDate': releaseDate,
|
||||||
|
'quality': quality,
|
||||||
};
|
};
|
||||||
|
|
||||||
factory DownloadHistoryItem.fromJson(Map<String, dynamic> json) => DownloadHistoryItem(
|
factory DownloadHistoryItem.fromJson(Map<String, dynamic> json) => DownloadHistoryItem(
|
||||||
|
|
@ -50,10 +76,18 @@ class DownloadHistoryItem {
|
||||||
trackName: json['trackName'] as String,
|
trackName: json['trackName'] as String,
|
||||||
artistName: json['artistName'] as String,
|
artistName: json['artistName'] as String,
|
||||||
albumName: json['albumName'] as String,
|
albumName: json['albumName'] as String,
|
||||||
|
albumArtist: json['albumArtist'] as String?,
|
||||||
coverUrl: json['coverUrl'] as String?,
|
coverUrl: json['coverUrl'] as String?,
|
||||||
filePath: json['filePath'] as String,
|
filePath: json['filePath'] as String,
|
||||||
service: json['service'] as String,
|
service: json['service'] as String,
|
||||||
downloadedAt: DateTime.parse(json['downloadedAt'] as String),
|
downloadedAt: DateTime.parse(json['downloadedAt'] as String),
|
||||||
|
isrc: json['isrc'] as String?,
|
||||||
|
spotifyId: json['spotifyId'] as String?,
|
||||||
|
trackNumber: json['trackNumber'] as int?,
|
||||||
|
discNumber: json['discNumber'] as int?,
|
||||||
|
duration: json['duration'] as int?,
|
||||||
|
releaseDate: json['releaseDate'] as String?,
|
||||||
|
quality: json['quality'] as String?,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -151,6 +185,7 @@ class DownloadQueueState {
|
||||||
final bool isProcessing;
|
final bool isProcessing;
|
||||||
final String outputDir;
|
final String outputDir;
|
||||||
final String filenameFormat;
|
final String filenameFormat;
|
||||||
|
final String audioQuality; // LOSSLESS, HI_RES, HI_RES_LOSSLESS
|
||||||
final bool autoFallback;
|
final bool autoFallback;
|
||||||
final int concurrentDownloads; // 1 = sequential, max 3
|
final int concurrentDownloads; // 1 = sequential, max 3
|
||||||
|
|
||||||
|
|
@ -160,6 +195,7 @@ class DownloadQueueState {
|
||||||
this.isProcessing = false,
|
this.isProcessing = false,
|
||||||
this.outputDir = '',
|
this.outputDir = '',
|
||||||
this.filenameFormat = '{artist} - {title}',
|
this.filenameFormat = '{artist} - {title}',
|
||||||
|
this.audioQuality = 'LOSSLESS',
|
||||||
this.autoFallback = true,
|
this.autoFallback = true,
|
||||||
this.concurrentDownloads = 1,
|
this.concurrentDownloads = 1,
|
||||||
});
|
});
|
||||||
|
|
@ -170,6 +206,7 @@ class DownloadQueueState {
|
||||||
bool? isProcessing,
|
bool? isProcessing,
|
||||||
String? outputDir,
|
String? outputDir,
|
||||||
String? filenameFormat,
|
String? filenameFormat,
|
||||||
|
String? audioQuality,
|
||||||
bool? autoFallback,
|
bool? autoFallback,
|
||||||
int? concurrentDownloads,
|
int? concurrentDownloads,
|
||||||
}) {
|
}) {
|
||||||
|
|
@ -179,6 +216,7 @@ class DownloadQueueState {
|
||||||
isProcessing: isProcessing ?? this.isProcessing,
|
isProcessing: isProcessing ?? this.isProcessing,
|
||||||
outputDir: outputDir ?? this.outputDir,
|
outputDir: outputDir ?? this.outputDir,
|
||||||
filenameFormat: filenameFormat ?? this.filenameFormat,
|
filenameFormat: filenameFormat ?? this.filenameFormat,
|
||||||
|
audioQuality: audioQuality ?? this.audioQuality,
|
||||||
autoFallback: autoFallback ?? this.autoFallback,
|
autoFallback: autoFallback ?? this.autoFallback,
|
||||||
concurrentDownloads: concurrentDownloads ?? this.concurrentDownloads,
|
concurrentDownloads: concurrentDownloads ?? this.concurrentDownloads,
|
||||||
);
|
);
|
||||||
|
|
@ -284,12 +322,17 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||||
state = state.copyWith(
|
state = state.copyWith(
|
||||||
outputDir: settings.downloadDirectory.isNotEmpty ? settings.downloadDirectory : state.outputDir,
|
outputDir: settings.downloadDirectory.isNotEmpty ? settings.downloadDirectory : state.outputDir,
|
||||||
filenameFormat: settings.filenameFormat,
|
filenameFormat: settings.filenameFormat,
|
||||||
|
audioQuality: settings.audioQuality,
|
||||||
autoFallback: settings.autoFallback,
|
autoFallback: settings.autoFallback,
|
||||||
concurrentDownloads: settings.concurrentDownloads,
|
concurrentDownloads: settings.concurrentDownloads,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
String addToQueue(Track track, String service) {
|
String addToQueue(Track track, String service) {
|
||||||
|
// Sync settings before adding to queue
|
||||||
|
final settings = ref.read(settingsProvider);
|
||||||
|
updateSettings(settings);
|
||||||
|
|
||||||
final id = '${track.isrc ?? track.id}-${DateTime.now().millisecondsSinceEpoch}';
|
final id = '${track.isrc ?? track.id}-${DateTime.now().millisecondsSinceEpoch}';
|
||||||
final item = DownloadItem(
|
final item = DownloadItem(
|
||||||
id: id,
|
id: id,
|
||||||
|
|
@ -309,6 +352,10 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||||
}
|
}
|
||||||
|
|
||||||
void addMultipleToQueue(List<Track> tracks, String service) {
|
void addMultipleToQueue(List<Track> tracks, String service) {
|
||||||
|
// Sync settings before adding to queue
|
||||||
|
final settings = ref.read(settingsProvider);
|
||||||
|
updateSettings(settings);
|
||||||
|
|
||||||
final newItems = tracks.map((track) {
|
final newItems = tracks.map((track) {
|
||||||
final id = '${track.isrc ?? track.id}-${DateTime.now().millisecondsSinceEpoch}';
|
final id = '${track.isrc ?? track.id}-${DateTime.now().millisecondsSinceEpoch}';
|
||||||
return DownloadItem(
|
return DownloadItem(
|
||||||
|
|
@ -561,6 +608,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||||
|
|
||||||
if (state.autoFallback) {
|
if (state.autoFallback) {
|
||||||
print('[DownloadQueue] Using auto-fallback mode');
|
print('[DownloadQueue] Using auto-fallback mode');
|
||||||
|
print('[DownloadQueue] Quality: ${state.audioQuality}');
|
||||||
result = await PlatformBridge.downloadWithFallback(
|
result = await PlatformBridge.downloadWithFallback(
|
||||||
isrc: item.track.isrc ?? '',
|
isrc: item.track.isrc ?? '',
|
||||||
spotifyId: item.track.id,
|
spotifyId: item.track.id,
|
||||||
|
|
@ -571,6 +619,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||||
coverUrl: item.track.coverUrl,
|
coverUrl: item.track.coverUrl,
|
||||||
outputDir: state.outputDir,
|
outputDir: state.outputDir,
|
||||||
filenameFormat: state.filenameFormat,
|
filenameFormat: state.filenameFormat,
|
||||||
|
quality: state.audioQuality,
|
||||||
trackNumber: item.track.trackNumber ?? 1,
|
trackNumber: item.track.trackNumber ?? 1,
|
||||||
discNumber: item.track.discNumber ?? 1,
|
discNumber: item.track.discNumber ?? 1,
|
||||||
releaseDate: item.track.releaseDate,
|
releaseDate: item.track.releaseDate,
|
||||||
|
|
@ -588,6 +637,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||||
coverUrl: item.track.coverUrl,
|
coverUrl: item.track.coverUrl,
|
||||||
outputDir: state.outputDir,
|
outputDir: state.outputDir,
|
||||||
filenameFormat: state.filenameFormat,
|
filenameFormat: state.filenameFormat,
|
||||||
|
quality: state.audioQuality,
|
||||||
trackNumber: item.track.trackNumber ?? 1,
|
trackNumber: item.track.trackNumber ?? 1,
|
||||||
discNumber: item.track.discNumber ?? 1,
|
discNumber: item.track.discNumber ?? 1,
|
||||||
releaseDate: item.track.releaseDate,
|
releaseDate: item.track.releaseDate,
|
||||||
|
|
@ -642,10 +692,19 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||||
trackName: item.track.name,
|
trackName: item.track.name,
|
||||||
artistName: item.track.artistName,
|
artistName: item.track.artistName,
|
||||||
albumName: item.track.albumName,
|
albumName: item.track.albumName,
|
||||||
|
albumArtist: item.track.albumArtist,
|
||||||
coverUrl: item.track.coverUrl,
|
coverUrl: item.track.coverUrl,
|
||||||
filePath: filePath,
|
filePath: filePath,
|
||||||
service: result['service'] as String? ?? item.service,
|
service: result['service'] as String? ?? item.service,
|
||||||
downloadedAt: DateTime.now(),
|
downloadedAt: DateTime.now(),
|
||||||
|
// Additional metadata
|
||||||
|
isrc: item.track.isrc,
|
||||||
|
spotifyId: item.track.id,
|
||||||
|
trackNumber: item.track.trackNumber,
|
||||||
|
discNumber: item.track.discNumber,
|
||||||
|
duration: item.track.duration,
|
||||||
|
releaseDate: item.track.releaseDate,
|
||||||
|
quality: state.audioQuality,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||||
state = state.copyWith(concurrentDownloads: clamped);
|
state = state.copyWith(concurrentDownloads: clamped);
|
||||||
_saveSettings();
|
_saveSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void setCheckForUpdates(bool enabled) {
|
||||||
|
state = state.copyWith(checkForUpdates: enabled);
|
||||||
|
_saveSettings();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final settingsProvider = NotifierProvider<SettingsNotifier, AppSettings>(
|
final settingsProvider = NotifierProvider<SettingsNotifier, AppSettings>(
|
||||||
|
|
|
||||||
|
|
@ -1,372 +0,0 @@
|
||||||
import 'dart:io';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
|
||||||
import 'package:open_filex/open_filex.dart';
|
|
||||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
|
||||||
|
|
||||||
class HistoryScreen extends ConsumerWidget {
|
|
||||||
const HistoryScreen({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
final historyState = ref.watch(downloadHistoryProvider);
|
|
||||||
final history = historyState.items;
|
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
|
||||||
|
|
||||||
return Scaffold(
|
|
||||||
appBar: AppBar(
|
|
||||||
title: const Text('Download History'),
|
|
||||||
actions: [
|
|
||||||
if (history.isNotEmpty)
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.delete_sweep),
|
|
||||||
onPressed: () => _showClearHistoryDialog(context, ref),
|
|
||||||
tooltip: 'Clear history',
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
body: history.isEmpty
|
|
||||||
? _buildEmptyState(context, colorScheme)
|
|
||||||
: ListView.builder(
|
|
||||||
itemCount: history.length,
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final item = history[index];
|
|
||||||
return _buildHistoryItem(context, ref, item, colorScheme);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildEmptyState(BuildContext context, ColorScheme colorScheme) {
|
|
||||||
return Center(
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
Icons.history,
|
|
||||||
size: 64,
|
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Text(
|
|
||||||
'No download history',
|
|
||||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
'Downloaded tracks will appear here',
|
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
|
||||||
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildHistoryItem(BuildContext context, WidgetRef ref, DownloadHistoryItem item, ColorScheme colorScheme) {
|
|
||||||
final fileExists = File(item.filePath).existsSync();
|
|
||||||
|
|
||||||
return Dismissible(
|
|
||||||
key: Key(item.id),
|
|
||||||
direction: DismissDirection.endToStart,
|
|
||||||
background: Container(
|
|
||||||
alignment: Alignment.centerRight,
|
|
||||||
padding: const EdgeInsets.only(right: 16),
|
|
||||||
color: colorScheme.error,
|
|
||||||
child: Icon(Icons.delete, color: colorScheme.onError),
|
|
||||||
),
|
|
||||||
onDismissed: (_) {
|
|
||||||
ref.read(downloadHistoryProvider.notifier).removeFromHistory(item.id);
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(content: Text('Removed "${item.trackName}" from history')),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
child: ListTile(
|
|
||||||
leading: item.coverUrl != null
|
|
||||||
? ClipRRect(
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
child: CachedNetworkImage(
|
|
||||||
imageUrl: item.coverUrl!,
|
|
||||||
width: 48,
|
|
||||||
height: 48,
|
|
||||||
fit: BoxFit.cover,
|
|
||||||
placeholder: (_, __) => Container(
|
|
||||||
width: 48,
|
|
||||||
height: 48,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: colorScheme.surfaceContainerHighest,
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: Container(
|
|
||||||
width: 48,
|
|
||||||
height: 48,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: colorScheme.surfaceContainerHighest,
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant),
|
|
||||||
),
|
|
||||||
title: Text(
|
|
||||||
item.trackName,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
subtitle: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
item.artistName,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
|
||||||
),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
_getServiceIcon(item.service),
|
|
||||||
size: 12,
|
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
Text(
|
|
||||||
_formatDate(item.downloadedAt),
|
|
||||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (!fileExists) ...[
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Icon(
|
|
||||||
Icons.warning,
|
|
||||||
size: 12,
|
|
||||||
color: colorScheme.error,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 2),
|
|
||||||
Text(
|
|
||||||
'File missing',
|
|
||||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
|
||||||
color: colorScheme.error,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
trailing: fileExists
|
|
||||||
? IconButton(
|
|
||||||
icon: Icon(Icons.play_arrow, color: colorScheme.primary),
|
|
||||||
onPressed: () => _openFile(context, item.filePath),
|
|
||||||
)
|
|
||||||
: Icon(Icons.error_outline, color: colorScheme.onSurfaceVariant),
|
|
||||||
onTap: fileExists ? () => _openFile(context, item.filePath) : null,
|
|
||||||
onLongPress: () => _showItemDetails(context, ref, item, colorScheme),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
IconData _getServiceIcon(String service) {
|
|
||||||
switch (service.toLowerCase()) {
|
|
||||||
case 'tidal':
|
|
||||||
return Icons.waves;
|
|
||||||
case 'qobuz':
|
|
||||||
return Icons.album;
|
|
||||||
case 'amazon':
|
|
||||||
return Icons.shopping_cart;
|
|
||||||
default:
|
|
||||||
return Icons.cloud_download;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
String _formatDate(DateTime date) {
|
|
||||||
final now = DateTime.now();
|
|
||||||
final diff = now.difference(date);
|
|
||||||
|
|
||||||
if (diff.inDays == 0) {
|
|
||||||
if (diff.inHours == 0) {
|
|
||||||
return '${diff.inMinutes}m ago';
|
|
||||||
}
|
|
||||||
return '${diff.inHours}h ago';
|
|
||||||
} else if (diff.inDays == 1) {
|
|
||||||
return 'Yesterday';
|
|
||||||
} else if (diff.inDays < 7) {
|
|
||||||
return '${diff.inDays}d ago';
|
|
||||||
} else {
|
|
||||||
return '${date.day}/${date.month}/${date.year}';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _openFile(BuildContext context, String filePath) async {
|
|
||||||
try {
|
|
||||||
final result = await OpenFilex.open(filePath);
|
|
||||||
|
|
||||||
if (result.type != ResultType.done) {
|
|
||||||
if (context.mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text('Cannot open: ${result.message}'),
|
|
||||||
action: SnackBarAction(
|
|
||||||
label: 'Copy Path',
|
|
||||||
onPressed: () {
|
|
||||||
Clipboard.setData(ClipboardData(text: filePath));
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(content: Text('Path copied to clipboard')),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
if (context.mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(content: Text('Cannot open file: $e')),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _showItemDetails(BuildContext context, WidgetRef ref, DownloadHistoryItem item, ColorScheme colorScheme) {
|
|
||||||
showModalBottomSheet(
|
|
||||||
context: context,
|
|
||||||
builder: (context) => Container(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
if (item.coverUrl != null)
|
|
||||||
ClipRRect(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
child: CachedNetworkImage(
|
|
||||||
imageUrl: item.coverUrl!,
|
|
||||||
width: 64,
|
|
||||||
height: 64,
|
|
||||||
fit: BoxFit.cover,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 16),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
item.trackName,
|
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
item.artistName,
|
|
||||||
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
item.albumName,
|
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
||||||
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
const Divider(),
|
|
||||||
_buildDetailRow(context, 'Service', item.service.toUpperCase(), colorScheme),
|
|
||||||
_buildDetailRow(context, 'Downloaded', _formatDate(item.downloadedAt), colorScheme),
|
|
||||||
_buildDetailRow(context, 'File', item.filePath, colorScheme, isPath: true),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
|
||||||
children: [
|
|
||||||
TextButton.icon(
|
|
||||||
onPressed: () {
|
|
||||||
ref.read(downloadHistoryProvider.notifier).removeFromHistory(item.id);
|
|
||||||
Navigator.pop(context);
|
|
||||||
},
|
|
||||||
icon: Icon(Icons.delete, color: colorScheme.error),
|
|
||||||
label: Text('Remove', style: TextStyle(color: colorScheme.error)),
|
|
||||||
),
|
|
||||||
if (File(item.filePath).existsSync())
|
|
||||||
TextButton.icon(
|
|
||||||
onPressed: () {
|
|
||||||
Navigator.pop(context);
|
|
||||||
_openFile(context, item.filePath);
|
|
||||||
},
|
|
||||||
icon: Icon(Icons.play_arrow, color: colorScheme.primary),
|
|
||||||
label: Text('Play', style: TextStyle(color: colorScheme.primary)),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildDetailRow(BuildContext context, String label, String value, ColorScheme colorScheme, {bool isPath = false}) {
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
|
||||||
child: Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
SizedBox(
|
|
||||||
width: 80,
|
|
||||||
child: Text(
|
|
||||||
label,
|
|
||||||
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
value,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: isPath ? 12 : 14,
|
|
||||||
fontFamily: isPath ? 'monospace' : null,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _showClearHistoryDialog(BuildContext context, WidgetRef ref) {
|
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
|
||||||
showDialog(
|
|
||||||
context: context,
|
|
||||||
builder: (context) => AlertDialog(
|
|
||||||
title: const Text('Clear History'),
|
|
||||||
content: const Text(
|
|
||||||
'Are you sure you want to clear all download history? '
|
|
||||||
'This will not delete the downloaded files.',
|
|
||||||
),
|
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.pop(context),
|
|
||||||
child: const Text('Cancel'),
|
|
||||||
),
|
|
||||||
TextButton(
|
|
||||||
onPressed: () {
|
|
||||||
ref.read(downloadHistoryProvider.notifier).clearHistory();
|
|
||||||
Navigator.pop(context);
|
|
||||||
},
|
|
||||||
child: Text('Clear', style: TextStyle(color: colorScheme.error)),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,388 +0,0 @@
|
||||||
import 'dart:io';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
|
||||||
import 'package:open_filex/open_filex.dart';
|
|
||||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
|
||||||
|
|
||||||
class HistoryTab extends ConsumerWidget {
|
|
||||||
const HistoryTab({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
final historyState = ref.watch(downloadHistoryProvider);
|
|
||||||
final history = historyState.items;
|
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
|
||||||
|
|
||||||
return Column(
|
|
||||||
children: [
|
|
||||||
// Header with clear action
|
|
||||||
if (history.isNotEmpty)
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
|
||||||
child: Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
'${history.length} downloads',
|
|
||||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
TextButton.icon(
|
|
||||||
onPressed: () => _showClearHistoryDialog(context, ref),
|
|
||||||
icon: Icon(Icons.delete_sweep, size: 18, color: colorScheme.error),
|
|
||||||
label: Text('Clear history', style: TextStyle(color: colorScheme.error)),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// History list
|
|
||||||
Expanded(
|
|
||||||
child: history.isEmpty
|
|
||||||
? _buildEmptyState(context, colorScheme)
|
|
||||||
: ListView.builder(
|
|
||||||
itemCount: history.length,
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final item = history[index];
|
|
||||||
return _buildHistoryItem(context, ref, item, colorScheme);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildEmptyState(BuildContext context, ColorScheme colorScheme) {
|
|
||||||
return Center(
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
Icons.history,
|
|
||||||
size: 64,
|
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Text(
|
|
||||||
'No download history',
|
|
||||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
'Downloaded tracks will appear here',
|
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
|
||||||
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildHistoryItem(BuildContext context, WidgetRef ref, DownloadHistoryItem item, ColorScheme colorScheme) {
|
|
||||||
final fileExists = File(item.filePath).existsSync();
|
|
||||||
|
|
||||||
return Dismissible(
|
|
||||||
key: Key(item.id),
|
|
||||||
direction: DismissDirection.endToStart,
|
|
||||||
background: Container(
|
|
||||||
alignment: Alignment.centerRight,
|
|
||||||
padding: const EdgeInsets.only(right: 16),
|
|
||||||
color: colorScheme.error,
|
|
||||||
child: Icon(Icons.delete, color: colorScheme.onError),
|
|
||||||
),
|
|
||||||
onDismissed: (_) {
|
|
||||||
ref.read(downloadHistoryProvider.notifier).removeFromHistory(item.id);
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(content: Text('Removed "${item.trackName}" from history')),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
child: ListTile(
|
|
||||||
leading: item.coverUrl != null
|
|
||||||
? ClipRRect(
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
child: CachedNetworkImage(
|
|
||||||
imageUrl: item.coverUrl!,
|
|
||||||
width: 48,
|
|
||||||
height: 48,
|
|
||||||
fit: BoxFit.cover,
|
|
||||||
placeholder: (_, __) => Container(
|
|
||||||
width: 48,
|
|
||||||
height: 48,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: colorScheme.surfaceContainerHighest,
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: Container(
|
|
||||||
width: 48,
|
|
||||||
height: 48,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: colorScheme.surfaceContainerHighest,
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant),
|
|
||||||
),
|
|
||||||
title: Text(
|
|
||||||
item.trackName,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
subtitle: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
item.artistName,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
|
||||||
),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
_getServiceIcon(item.service),
|
|
||||||
size: 12,
|
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
Text(
|
|
||||||
_formatDate(item.downloadedAt),
|
|
||||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (!fileExists) ...[
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Icon(
|
|
||||||
Icons.warning,
|
|
||||||
size: 12,
|
|
||||||
color: colorScheme.error,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 2),
|
|
||||||
Text(
|
|
||||||
'File missing',
|
|
||||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
|
||||||
color: colorScheme.error,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
trailing: fileExists
|
|
||||||
? IconButton(
|
|
||||||
icon: Icon(Icons.play_arrow, color: colorScheme.primary),
|
|
||||||
onPressed: () => _openFile(context, item.filePath),
|
|
||||||
)
|
|
||||||
: Icon(Icons.error_outline, color: colorScheme.onSurfaceVariant),
|
|
||||||
onTap: fileExists ? () => _openFile(context, item.filePath) : null,
|
|
||||||
onLongPress: () => _showItemDetails(context, ref, item, colorScheme),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
IconData _getServiceIcon(String service) {
|
|
||||||
switch (service.toLowerCase()) {
|
|
||||||
case 'tidal':
|
|
||||||
return Icons.waves;
|
|
||||||
case 'qobuz':
|
|
||||||
return Icons.album;
|
|
||||||
case 'amazon':
|
|
||||||
return Icons.shopping_cart;
|
|
||||||
default:
|
|
||||||
return Icons.cloud_download;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
String _formatDate(DateTime date) {
|
|
||||||
final now = DateTime.now();
|
|
||||||
final diff = now.difference(date);
|
|
||||||
|
|
||||||
if (diff.inDays == 0) {
|
|
||||||
if (diff.inHours == 0) {
|
|
||||||
return '${diff.inMinutes}m ago';
|
|
||||||
}
|
|
||||||
return '${diff.inHours}h ago';
|
|
||||||
} else if (diff.inDays == 1) {
|
|
||||||
return 'Yesterday';
|
|
||||||
} else if (diff.inDays < 7) {
|
|
||||||
return '${diff.inDays}d ago';
|
|
||||||
} else {
|
|
||||||
return '${date.day}/${date.month}/${date.year}';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _openFile(BuildContext context, String filePath) async {
|
|
||||||
try {
|
|
||||||
final result = await OpenFilex.open(filePath);
|
|
||||||
|
|
||||||
if (result.type != ResultType.done) {
|
|
||||||
if (context.mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text('Cannot open: ${result.message}'),
|
|
||||||
action: SnackBarAction(
|
|
||||||
label: 'Copy Path',
|
|
||||||
onPressed: () {
|
|
||||||
Clipboard.setData(ClipboardData(text: filePath));
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(content: Text('Path copied to clipboard')),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
if (context.mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(content: Text('Cannot open file: $e')),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _showItemDetails(BuildContext context, WidgetRef ref, DownloadHistoryItem item, ColorScheme colorScheme) {
|
|
||||||
showModalBottomSheet(
|
|
||||||
context: context,
|
|
||||||
builder: (context) => Container(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
if (item.coverUrl != null)
|
|
||||||
ClipRRect(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
child: CachedNetworkImage(
|
|
||||||
imageUrl: item.coverUrl!,
|
|
||||||
width: 64,
|
|
||||||
height: 64,
|
|
||||||
fit: BoxFit.cover,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 16),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
item.trackName,
|
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
item.artistName,
|
|
||||||
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
item.albumName,
|
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
||||||
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
const Divider(),
|
|
||||||
_buildDetailRow(context, 'Service', item.service.toUpperCase(), colorScheme),
|
|
||||||
_buildDetailRow(context, 'Downloaded', _formatDate(item.downloadedAt), colorScheme),
|
|
||||||
_buildDetailRow(context, 'File', item.filePath, colorScheme, isPath: true),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
|
||||||
children: [
|
|
||||||
TextButton.icon(
|
|
||||||
onPressed: () {
|
|
||||||
ref.read(downloadHistoryProvider.notifier).removeFromHistory(item.id);
|
|
||||||
Navigator.pop(context);
|
|
||||||
},
|
|
||||||
icon: Icon(Icons.delete, color: colorScheme.error),
|
|
||||||
label: Text('Remove', style: TextStyle(color: colorScheme.error)),
|
|
||||||
),
|
|
||||||
if (File(item.filePath).existsSync())
|
|
||||||
TextButton.icon(
|
|
||||||
onPressed: () {
|
|
||||||
Navigator.pop(context);
|
|
||||||
_openFile(context, item.filePath);
|
|
||||||
},
|
|
||||||
icon: Icon(Icons.play_arrow, color: colorScheme.primary),
|
|
||||||
label: Text('Play', style: TextStyle(color: colorScheme.primary)),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildDetailRow(BuildContext context, String label, String value, ColorScheme colorScheme, {bool isPath = false}) {
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
|
||||||
child: Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
SizedBox(
|
|
||||||
width: 80,
|
|
||||||
child: Text(
|
|
||||||
label,
|
|
||||||
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
value,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: isPath ? 12 : 14,
|
|
||||||
fontFamily: isPath ? 'monospace' : null,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _showClearHistoryDialog(BuildContext context, WidgetRef ref) {
|
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
|
||||||
showDialog(
|
|
||||||
context: context,
|
|
||||||
builder: (context) => AlertDialog(
|
|
||||||
title: const Text('Clear History'),
|
|
||||||
content: const Text(
|
|
||||||
'Are you sure you want to clear all download history? '
|
|
||||||
'This will not delete the downloaded files.',
|
|
||||||
),
|
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.pop(context),
|
|
||||||
child: const Text('Cancel'),
|
|
||||||
),
|
|
||||||
TextButton(
|
|
||||||
onPressed: () {
|
|
||||||
ref.read(downloadHistoryProvider.notifier).clearHistory();
|
|
||||||
Navigator.pop(context);
|
|
||||||
},
|
|
||||||
child: Text('Clear', style: TextStyle(color: colorScheme.error)),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -7,6 +7,7 @@ import 'package:open_filex/open_filex.dart';
|
||||||
import 'package:spotiflac_android/providers/track_provider.dart';
|
import 'package:spotiflac_android/providers/track_provider.dart';
|
||||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||||
|
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
|
||||||
|
|
||||||
class HomeTab extends ConsumerStatefulWidget {
|
class HomeTab extends ConsumerStatefulWidget {
|
||||||
const HomeTab({super.key});
|
const HomeTab({super.key});
|
||||||
|
|
@ -326,25 +327,28 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||||
final fileExists = File(item.filePath).existsSync();
|
final fileExists = File(item.filePath).existsSync();
|
||||||
|
|
||||||
return ListTile(
|
return ListTile(
|
||||||
leading: item.coverUrl != null
|
leading: Hero(
|
||||||
? ClipRRect(
|
tag: 'cover_${item.id}',
|
||||||
borderRadius: BorderRadius.circular(8),
|
child: item.coverUrl != null
|
||||||
child: CachedNetworkImage(
|
? ClipRRect(
|
||||||
imageUrl: item.coverUrl!,
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: CachedNetworkImage(
|
||||||
|
imageUrl: item.coverUrl!,
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Container(
|
||||||
width: 48,
|
width: 48,
|
||||||
height: 48,
|
height: 48,
|
||||||
fit: BoxFit.cover,
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.surfaceContainerHighest,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant),
|
||||||
),
|
),
|
||||||
)
|
),
|
||||||
: Container(
|
|
||||||
width: 48,
|
|
||||||
height: 48,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: colorScheme.surfaceContainerHighest,
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant),
|
|
||||||
),
|
|
||||||
title: Text(item.trackName, maxLines: 1, overflow: TextOverflow.ellipsis),
|
title: Text(item.trackName, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||||
subtitle: Text(
|
subtitle: Text(
|
||||||
item.artistName,
|
item.artistName,
|
||||||
|
|
@ -358,7 +362,26 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||||
onPressed: () => _openFile(item.filePath),
|
onPressed: () => _openFile(item.filePath),
|
||||||
)
|
)
|
||||||
: Icon(Icons.error_outline, color: colorScheme.error, size: 20),
|
: Icon(Icons.error_outline, color: colorScheme.error, size: 20),
|
||||||
onTap: fileExists ? () => _openFile(item.filePath) : null,
|
// Tap to show metadata details
|
||||||
|
onTap: () => _navigateToMetadataScreen(item),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _navigateToMetadataScreen(DownloadHistoryItem item) {
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
PageRouteBuilder(
|
||||||
|
transitionDuration: const Duration(milliseconds: 300),
|
||||||
|
reverseTransitionDuration: const Duration(milliseconds: 250),
|
||||||
|
pageBuilder: (context, animation, secondaryAnimation) =>
|
||||||
|
TrackMetadataScreen(item: item),
|
||||||
|
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
||||||
|
return FadeTransition(
|
||||||
|
opacity: animation,
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -443,10 +466,51 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||||
child: ListView.builder(
|
child: ListView.builder(
|
||||||
controller: scrollController,
|
controller: scrollController,
|
||||||
itemCount: historyState.items.length,
|
itemCount: historyState.items.length,
|
||||||
itemBuilder: (context, index) => _buildHistoryTile(
|
itemBuilder: (context, index) {
|
||||||
historyState.items[index],
|
final item = historyState.items[index];
|
||||||
colorScheme,
|
final fileExists = File(item.filePath).existsSync();
|
||||||
),
|
|
||||||
|
return ListTile(
|
||||||
|
leading: item.coverUrl != null
|
||||||
|
? ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: CachedNetworkImage(
|
||||||
|
imageUrl: item.coverUrl!,
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Container(
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.surfaceContainerHighest,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant),
|
||||||
|
),
|
||||||
|
title: Text(item.trackName, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||||
|
subtitle: Text(
|
||||||
|
item.artistName,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
||||||
|
),
|
||||||
|
trailing: fileExists
|
||||||
|
? IconButton(
|
||||||
|
icon: Icon(Icons.play_arrow, color: colorScheme.primary),
|
||||||
|
onPressed: () => _openFile(item.filePath),
|
||||||
|
)
|
||||||
|
: Icon(Icons.error_outline, color: colorScheme.error, size: 20),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context); // Close bottom sheet first
|
||||||
|
Future.delayed(const Duration(milliseconds: 100), () {
|
||||||
|
_navigateToMetadataScreen(item);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,12 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||||
|
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||||
import 'package:spotiflac_android/screens/home_tab.dart';
|
import 'package:spotiflac_android/screens/home_tab.dart';
|
||||||
import 'package:spotiflac_android/screens/queue_tab.dart';
|
import 'package:spotiflac_android/screens/queue_tab.dart';
|
||||||
import 'package:spotiflac_android/screens/settings_tab.dart';
|
import 'package:spotiflac_android/screens/settings_tab.dart';
|
||||||
|
import 'package:spotiflac_android/services/update_checker.dart';
|
||||||
|
import 'package:spotiflac_android/widgets/update_dialog.dart';
|
||||||
|
|
||||||
class MainShell extends ConsumerStatefulWidget {
|
class MainShell extends ConsumerStatefulWidget {
|
||||||
const MainShell({super.key});
|
const MainShell({super.key});
|
||||||
|
|
@ -15,11 +18,43 @@ class MainShell extends ConsumerStatefulWidget {
|
||||||
class _MainShellState extends ConsumerState<MainShell> {
|
class _MainShellState extends ConsumerState<MainShell> {
|
||||||
int _currentIndex = 0;
|
int _currentIndex = 0;
|
||||||
late PageController _pageController;
|
late PageController _pageController;
|
||||||
|
bool _hasCheckedUpdate = false;
|
||||||
|
bool _isAnimating = false;
|
||||||
|
|
||||||
|
// Cache tab widgets to prevent rebuilds
|
||||||
|
final List<Widget> _tabs = const [
|
||||||
|
HomeTab(),
|
||||||
|
QueueTab(),
|
||||||
|
SettingsTab(),
|
||||||
|
];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_pageController = PageController(initialPage: _currentIndex);
|
_pageController = PageController(initialPage: _currentIndex);
|
||||||
|
// Check for updates after first frame
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
_checkForUpdates();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _checkForUpdates() async {
|
||||||
|
if (_hasCheckedUpdate) return;
|
||||||
|
_hasCheckedUpdate = true;
|
||||||
|
|
||||||
|
final settings = ref.read(settingsProvider);
|
||||||
|
if (!settings.checkForUpdates) return;
|
||||||
|
|
||||||
|
final updateInfo = await UpdateChecker.checkForUpdate();
|
||||||
|
if (updateInfo != null && mounted) {
|
||||||
|
showUpdateDialog(
|
||||||
|
context,
|
||||||
|
updateInfo: updateInfo,
|
||||||
|
onDisableUpdates: () {
|
||||||
|
ref.read(settingsProvider.notifier).setCheckForUpdates(false);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -29,16 +64,21 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onNavTap(int index) {
|
void _onNavTap(int index) {
|
||||||
setState(() => _currentIndex = index);
|
if (_currentIndex != index && !_isAnimating) {
|
||||||
_pageController.animateToPage(
|
_isAnimating = true;
|
||||||
index,
|
setState(() => _currentIndex = index);
|
||||||
duration: const Duration(milliseconds: 300),
|
_pageController.animateToPage(
|
||||||
curve: Curves.easeInOut,
|
index,
|
||||||
);
|
duration: const Duration(milliseconds: 200),
|
||||||
|
curve: Curves.easeOut,
|
||||||
|
).then((_) => _isAnimating = false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onPageChanged(int index) {
|
void _onPageChanged(int index) {
|
||||||
setState(() => _currentIndex = index);
|
if (_currentIndex != index) {
|
||||||
|
setState(() => _currentIndex = index);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -63,12 +103,8 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||||
body: PageView(
|
body: PageView(
|
||||||
controller: _pageController,
|
controller: _pageController,
|
||||||
onPageChanged: _onPageChanged,
|
onPageChanged: _onPageChanged,
|
||||||
physics: const BouncingScrollPhysics(),
|
physics: const ClampingScrollPhysics(),
|
||||||
children: const [
|
children: _tabs,
|
||||||
HomeTab(),
|
|
||||||
QueueTab(),
|
|
||||||
SettingsTab(),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
bottomNavigationBar: NavigationBar(
|
bottomNavigationBar: NavigationBar(
|
||||||
selectedIndex: _currentIndex,
|
selectedIndex: _currentIndex,
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:file_picker/file_picker.dart';
|
import 'package:file_picker/file_picker.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
import 'package:spotiflac_android/constants/app_info.dart';
|
||||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||||
import 'package:spotiflac_android/providers/theme_provider.dart';
|
import 'package:spotiflac_android/providers/theme_provider.dart';
|
||||||
|
|
||||||
|
|
@ -134,6 +135,15 @@ class SettingsScreen extends ConsumerWidget {
|
||||||
onTap: () => _showConcurrentDownloadsPicker(context, ref, settings.concurrentDownloads),
|
onTap: () => _showConcurrentDownloadsPicker(context, ref, settings.concurrentDownloads),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// Check for Updates
|
||||||
|
SwitchListTile(
|
||||||
|
secondary: Icon(Icons.system_update, color: colorScheme.primary),
|
||||||
|
title: const Text('Check for Updates'),
|
||||||
|
subtitle: const Text('Notify when new version is available'),
|
||||||
|
value: settings.checkForUpdates,
|
||||||
|
onChanged: (value) => ref.read(settingsProvider.notifier).setCheckForUpdates(value),
|
||||||
|
),
|
||||||
|
|
||||||
const Divider(),
|
const Divider(),
|
||||||
|
|
||||||
// GitHub & Credits Section
|
// GitHub & Credits Section
|
||||||
|
|
@ -141,22 +151,22 @@ class SettingsScreen extends ConsumerWidget {
|
||||||
|
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: Icon(Icons.code, color: colorScheme.primary),
|
leading: Icon(Icons.code, color: colorScheme.primary),
|
||||||
title: const Text('SpotiFLAC Mobile'),
|
title: Text('${AppInfo.appName} Mobile'),
|
||||||
subtitle: const Text('github.com/zarzet/SpotiFLAC-Mobile'),
|
subtitle: Text('github.com/${AppInfo.githubRepo}'),
|
||||||
onTap: () => _launchUrl('https://github.com/zarzet/SpotiFLAC-Mobile'),
|
onTap: () => _launchUrl(AppInfo.githubUrl),
|
||||||
),
|
),
|
||||||
|
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: Icon(Icons.computer, color: colorScheme.primary),
|
leading: Icon(Icons.computer, color: colorScheme.primary),
|
||||||
title: const Text('Original SpotiFLAC (Desktop)'),
|
title: Text('Original ${AppInfo.appName} (Desktop)'),
|
||||||
subtitle: const Text('github.com/afkarxyz/SpotiFLAC'),
|
subtitle: Text('github.com/${AppInfo.originalAuthor}/SpotiFLAC'),
|
||||||
onTap: () => _launchUrl('https://github.com/afkarxyz/SpotiFLAC'),
|
onTap: () => _launchUrl(AppInfo.originalGithubUrl),
|
||||||
),
|
),
|
||||||
|
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
child: Text(
|
child: Text(
|
||||||
'Mobile version maintained by zarzet\nOriginal project by afkarxyz',
|
'Mobile version maintained by ${AppInfo.mobileAuthor}\nOriginal project by ${AppInfo.originalAuthor}',
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
|
|
@ -169,7 +179,7 @@ class SettingsScreen extends ConsumerWidget {
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: Icon(Icons.info, color: colorScheme.primary),
|
leading: Icon(Icons.info, color: colorScheme.primary),
|
||||||
title: const Text('About'),
|
title: const Text('About'),
|
||||||
subtitle: const Text('SpotiFLAC v1.1.1'),
|
subtitle: Text('${AppInfo.appName} v${AppInfo.version}'),
|
||||||
onTap: () => _showAboutDialog(context),
|
onTap: () => _showAboutDialog(context),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -186,21 +196,21 @@ class SettingsScreen extends ConsumerWidget {
|
||||||
children: [
|
children: [
|
||||||
Image.asset('assets/images/logo.png', width: 40, height: 40, errorBuilder: (_, __, ___) => Icon(Icons.music_note, size: 40, color: colorScheme.primary)),
|
Image.asset('assets/images/logo.png', width: 40, height: 40, errorBuilder: (_, __, ___) => Icon(Icons.music_note, size: 40, color: colorScheme.primary)),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
const Text('SpotiFLAC'),
|
Text(AppInfo.appName),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
content: Column(
|
content: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
_buildAboutRow('Version', '1.1.1', colorScheme),
|
_buildAboutRow('Version', AppInfo.version, colorScheme),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
_buildAboutRow('Mobile', 'zarzet', colorScheme),
|
_buildAboutRow('Mobile', AppInfo.mobileAuthor, colorScheme),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
_buildAboutRow('Original', 'afkarxyz', colorScheme),
|
_buildAboutRow('Original', AppInfo.originalAuthor, colorScheme),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
'© 2026 SpotiFLAC',
|
AppInfo.copyright,
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:file_picker/file_picker.dart';
|
import 'package:file_picker/file_picker.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
import 'package:spotiflac_android/constants/app_info.dart';
|
||||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||||
import 'package:spotiflac_android/providers/theme_provider.dart';
|
import 'package:spotiflac_android/providers/theme_provider.dart';
|
||||||
|
|
||||||
|
|
@ -141,6 +142,15 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with AutomaticKeepAli
|
||||||
onTap: () => _showConcurrentDownloadsPicker(context, ref, settings.concurrentDownloads),
|
onTap: () => _showConcurrentDownloadsPicker(context, ref, settings.concurrentDownloads),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// Check for Updates
|
||||||
|
SwitchListTile(
|
||||||
|
secondary: Icon(Icons.system_update, color: colorScheme.primary),
|
||||||
|
title: const Text('Check for Updates'),
|
||||||
|
subtitle: const Text('Notify when new version is available'),
|
||||||
|
value: settings.checkForUpdates,
|
||||||
|
onChanged: (value) => ref.read(settingsProvider.notifier).setCheckForUpdates(value),
|
||||||
|
),
|
||||||
|
|
||||||
const Divider(),
|
const Divider(),
|
||||||
|
|
||||||
// GitHub & Credits Section
|
// GitHub & Credits Section
|
||||||
|
|
@ -148,22 +158,22 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with AutomaticKeepAli
|
||||||
|
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: Icon(Icons.code, color: colorScheme.primary),
|
leading: Icon(Icons.code, color: colorScheme.primary),
|
||||||
title: const Text('SpotiFLAC Mobile'),
|
title: Text('${AppInfo.appName} Mobile'),
|
||||||
subtitle: const Text('github.com/zarzet/SpotiFLAC-Mobile'),
|
subtitle: Text('github.com/${AppInfo.githubRepo}'),
|
||||||
onTap: () => _launchUrl('https://github.com/zarzet/SpotiFLAC-Mobile'),
|
onTap: () => _launchUrl(AppInfo.githubUrl),
|
||||||
),
|
),
|
||||||
|
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: Icon(Icons.computer, color: colorScheme.primary),
|
leading: Icon(Icons.computer, color: colorScheme.primary),
|
||||||
title: const Text('Original SpotiFLAC (Desktop)'),
|
title: Text('Original ${AppInfo.appName} (Desktop)'),
|
||||||
subtitle: const Text('github.com/afkarxyz/SpotiFLAC'),
|
subtitle: Text('github.com/${AppInfo.originalAuthor}/SpotiFLAC'),
|
||||||
onTap: () => _launchUrl('https://github.com/afkarxyz/SpotiFLAC'),
|
onTap: () => _launchUrl(AppInfo.originalGithubUrl),
|
||||||
),
|
),
|
||||||
|
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
child: Text(
|
child: Text(
|
||||||
'Mobile version maintained by zarzet\nOriginal project by afkarxyz',
|
'Mobile version maintained by ${AppInfo.mobileAuthor}\nOriginal project by ${AppInfo.originalAuthor}',
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
|
|
@ -176,7 +186,7 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with AutomaticKeepAli
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: Icon(Icons.info, color: colorScheme.primary),
|
leading: Icon(Icons.info, color: colorScheme.primary),
|
||||||
title: const Text('About'),
|
title: const Text('About'),
|
||||||
subtitle: const Text('SpotiFLAC v1.1.1'),
|
subtitle: Text('${AppInfo.appName} v${AppInfo.version}'),
|
||||||
onTap: () => _showAboutDialog(context),
|
onTap: () => _showAboutDialog(context),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
|
@ -195,21 +205,21 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with AutomaticKeepAli
|
||||||
children: [
|
children: [
|
||||||
Image.asset('assets/images/logo.png', width: 40, height: 40, errorBuilder: (_, __, ___) => Icon(Icons.music_note, size: 40, color: colorScheme.primary)),
|
Image.asset('assets/images/logo.png', width: 40, height: 40, errorBuilder: (_, __, ___) => Icon(Icons.music_note, size: 40, color: colorScheme.primary)),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
const Text('SpotiFLAC'),
|
Text(AppInfo.appName),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
content: Column(
|
content: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
_buildAboutRow('Version', '1.1.1', colorScheme),
|
_buildAboutRow('Version', AppInfo.version, colorScheme),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
_buildAboutRow('Mobile', 'zarzet', colorScheme),
|
_buildAboutRow('Mobile', AppInfo.mobileAuthor, colorScheme),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
_buildAboutRow('Original', 'afkarxyz', colorScheme),
|
_buildAboutRow('Original', AppInfo.originalAuthor, colorScheme),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
'© 2026 SpotiFLAC',
|
AppInfo.copyright,
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
|
|
@ -268,8 +278,9 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with AutomaticKeepAli
|
||||||
|
|
||||||
String _getQualityName(String quality) {
|
String _getQualityName(String quality) {
|
||||||
switch (quality) {
|
switch (quality) {
|
||||||
case 'LOSSLESS': return 'FLAC (Lossless)';
|
case 'LOSSLESS': return 'FLAC (16-bit / 44.1kHz)';
|
||||||
case 'HI_RES': return 'Hi-Res FLAC (24-bit)';
|
case 'HI_RES': return 'Hi-Res FLAC (24-bit / 96kHz)';
|
||||||
|
case 'HI_RES_LOSSLESS': return 'Hi-Res FLAC (24-bit / 192kHz)';
|
||||||
default: return quality;
|
default: return quality;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -380,7 +391,8 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with AutomaticKeepAli
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
_buildQualityOption(context, ref, 'LOSSLESS', 'FLAC (Lossless)', '16-bit / 44.1kHz', current, colorScheme),
|
_buildQualityOption(context, ref, 'LOSSLESS', 'FLAC (Lossless)', '16-bit / 44.1kHz', current, colorScheme),
|
||||||
_buildQualityOption(context, ref, 'HI_RES', 'Hi-Res FLAC', '24-bit / up to 192kHz', current, colorScheme),
|
_buildQualityOption(context, ref, 'HI_RES', 'Hi-Res FLAC', '24-bit / up to 96kHz', current, colorScheme),
|
||||||
|
_buildQualityOption(context, ref, 'HI_RES_LOSSLESS', 'Hi-Res FLAC Max', '24-bit / up to 192kHz', current, colorScheme),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
793
lib/screens/track_metadata_screen.dart
Normal file
|
|
@ -0,0 +1,793 @@
|
||||||
|
import 'dart:io';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import 'package:open_filex/open_filex.dart';
|
||||||
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||||
|
|
||||||
|
/// Screen to display detailed metadata for a downloaded track
|
||||||
|
/// Designed with Material Expressive 3 style
|
||||||
|
class TrackMetadataScreen extends ConsumerWidget {
|
||||||
|
final DownloadHistoryItem item;
|
||||||
|
|
||||||
|
const TrackMetadataScreen({super.key, required this.item});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final fileExists = File(item.filePath).existsSync();
|
||||||
|
|
||||||
|
// Get file info
|
||||||
|
int? fileSize;
|
||||||
|
if (fileExists) {
|
||||||
|
try {
|
||||||
|
fileSize = File(item.filePath).lengthSync();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
body: CustomScrollView(
|
||||||
|
slivers: [
|
||||||
|
// App Bar with cover art background
|
||||||
|
SliverAppBar(
|
||||||
|
expandedHeight: 280,
|
||||||
|
pinned: true,
|
||||||
|
stretch: true,
|
||||||
|
backgroundColor: colorScheme.surface,
|
||||||
|
flexibleSpace: FlexibleSpaceBar(
|
||||||
|
background: _buildHeaderBackground(context, colorScheme),
|
||||||
|
stretchModes: const [
|
||||||
|
StretchMode.zoomBackground,
|
||||||
|
StretchMode.blurBackground,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
leading: IconButton(
|
||||||
|
icon: Container(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.surface.withValues(alpha: 0.8),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: Icon(Icons.arrow_back, color: colorScheme.onSurface),
|
||||||
|
),
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: Container(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.surface.withValues(alpha: 0.8),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: Icon(Icons.more_vert, color: colorScheme.onSurface),
|
||||||
|
),
|
||||||
|
onPressed: () => _showOptionsMenu(context, ref, colorScheme),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
// Content
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Track info card
|
||||||
|
_buildTrackInfoCard(context, colorScheme, fileExists),
|
||||||
|
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Metadata card
|
||||||
|
_buildMetadataCard(context, colorScheme, fileSize),
|
||||||
|
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// File info card
|
||||||
|
_buildFileInfoCard(context, colorScheme, fileExists, fileSize),
|
||||||
|
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// Action buttons
|
||||||
|
_buildActionButtons(context, ref, colorScheme, fileExists),
|
||||||
|
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildHeaderBackground(BuildContext context, ColorScheme colorScheme) {
|
||||||
|
return Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
// Blurred background
|
||||||
|
if (item.coverUrl != null)
|
||||||
|
CachedNetworkImage(
|
||||||
|
imageUrl: item.coverUrl!,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
color: Colors.black.withValues(alpha: 0.5),
|
||||||
|
colorBlendMode: BlendMode.darken,
|
||||||
|
),
|
||||||
|
|
||||||
|
// Gradient overlay
|
||||||
|
Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topCenter,
|
||||||
|
end: Alignment.bottomCenter,
|
||||||
|
colors: [
|
||||||
|
Colors.transparent,
|
||||||
|
colorScheme.surface.withValues(alpha: 0.8),
|
||||||
|
colorScheme.surface,
|
||||||
|
],
|
||||||
|
stops: const [0.0, 0.7, 1.0],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Cover art centered
|
||||||
|
Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 60),
|
||||||
|
child: Hero(
|
||||||
|
tag: 'cover_${item.id}',
|
||||||
|
child: Container(
|
||||||
|
width: 140,
|
||||||
|
height: 140,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.3),
|
||||||
|
blurRadius: 20,
|
||||||
|
offset: const Offset(0, 10),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
child: item.coverUrl != null
|
||||||
|
? CachedNetworkImage(
|
||||||
|
imageUrl: item.coverUrl!,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
placeholder: (_, __) => Container(
|
||||||
|
color: colorScheme.surfaceContainerHighest,
|
||||||
|
child: Icon(
|
||||||
|
Icons.music_note,
|
||||||
|
size: 48,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Container(
|
||||||
|
color: colorScheme.surfaceContainerHighest,
|
||||||
|
child: Icon(
|
||||||
|
Icons.music_note,
|
||||||
|
size: 48,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTrackInfoCard(BuildContext context, ColorScheme colorScheme, bool fileExists) {
|
||||||
|
return Card(
|
||||||
|
elevation: 0,
|
||||||
|
color: colorScheme.surfaceContainerLow,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Track name
|
||||||
|
Text(
|
||||||
|
item.trackName,
|
||||||
|
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
|
||||||
|
// Artist name
|
||||||
|
Text(
|
||||||
|
item.artistName,
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
color: colorScheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
|
||||||
|
// Album name
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.album,
|
||||||
|
size: 16,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
item.albumName,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
// File status
|
||||||
|
if (!fileExists) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.errorContainer,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.warning_rounded,
|
||||||
|
size: 16,
|
||||||
|
color: colorScheme.onErrorContainer,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
|
'File not found',
|
||||||
|
style: TextStyle(
|
||||||
|
color: colorScheme.onErrorContainer,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildMetadataCard(BuildContext context, ColorScheme colorScheme, int? fileSize) {
|
||||||
|
return Card(
|
||||||
|
elevation: 0,
|
||||||
|
color: colorScheme.surfaceContainerLow,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.info_outline,
|
||||||
|
size: 20,
|
||||||
|
color: colorScheme.primary,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
'Metadata',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Metadata grid
|
||||||
|
_buildMetadataGrid(context, colorScheme),
|
||||||
|
|
||||||
|
// Spotify link button
|
||||||
|
if (item.spotifyId != null && item.spotifyId!.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: () => _openSpotifyUrl(context),
|
||||||
|
icon: const Icon(Icons.open_in_new, size: 18),
|
||||||
|
label: const Text('Open in Spotify'),
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _openSpotifyUrl(BuildContext context) async {
|
||||||
|
if (item.spotifyId == null) return;
|
||||||
|
|
||||||
|
final url = 'https://open.spotify.com/track/${item.spotifyId}';
|
||||||
|
try {
|
||||||
|
// Try to open in Spotify app first, fallback to browser
|
||||||
|
final uri = Uri.parse('spotify:track:${item.spotifyId}');
|
||||||
|
// ignore: deprecated_member_use
|
||||||
|
if (await canLaunchUrl(uri)) {
|
||||||
|
await launchUrl(uri);
|
||||||
|
} else {
|
||||||
|
await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (context.mounted) {
|
||||||
|
_copyToClipboard(context, url);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Spotify URL copied to clipboard')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildMetadataGrid(BuildContext context, ColorScheme colorScheme) {
|
||||||
|
final items = <_MetadataItem>[
|
||||||
|
_MetadataItem('Track name', item.trackName),
|
||||||
|
_MetadataItem('Artist', item.artistName),
|
||||||
|
if (item.albumArtist != null && item.albumArtist != item.artistName)
|
||||||
|
_MetadataItem('Album artist', item.albumArtist!),
|
||||||
|
_MetadataItem('Album', item.albumName),
|
||||||
|
if (item.trackNumber != null)
|
||||||
|
_MetadataItem('Track number', item.trackNumber.toString()),
|
||||||
|
if (item.discNumber != null && item.discNumber! > 1)
|
||||||
|
_MetadataItem('Disc number', item.discNumber.toString()),
|
||||||
|
if (item.duration != null)
|
||||||
|
_MetadataItem('Duration', _formatDuration(item.duration!)),
|
||||||
|
if (item.releaseDate != null && item.releaseDate!.isNotEmpty)
|
||||||
|
_MetadataItem('Release date', item.releaseDate!),
|
||||||
|
if (item.isrc != null && item.isrc!.isNotEmpty)
|
||||||
|
_MetadataItem('ISRC', item.isrc!),
|
||||||
|
if (item.spotifyId != null && item.spotifyId!.isNotEmpty)
|
||||||
|
_MetadataItem('Spotify ID', item.spotifyId!),
|
||||||
|
if (item.quality != null && item.quality!.isNotEmpty)
|
||||||
|
_MetadataItem('Quality', _formatQuality(item.quality!)),
|
||||||
|
_MetadataItem('Service', item.service.toUpperCase()),
|
||||||
|
_MetadataItem('Downloaded', _formatFullDate(item.downloadedAt)),
|
||||||
|
];
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
children: items.map((metadata) {
|
||||||
|
final isCopyable = metadata.label == 'ISRC' ||
|
||||||
|
metadata.label == 'Spotify ID';
|
||||||
|
return InkWell(
|
||||||
|
onTap: isCopyable ? () => _copyToClipboard(context, metadata.value) : null,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 4),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 100,
|
||||||
|
child: Text(
|
||||||
|
metadata.label,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
metadata.value,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (isCopyable)
|
||||||
|
Icon(
|
||||||
|
Icons.copy,
|
||||||
|
size: 14,
|
||||||
|
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.5),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatDuration(int seconds) {
|
||||||
|
final minutes = seconds ~/ 60;
|
||||||
|
final secs = seconds % 60;
|
||||||
|
return '$minutes:${secs.toString().padLeft(2, '0')}';
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatQuality(String quality) {
|
||||||
|
switch (quality) {
|
||||||
|
case 'LOSSLESS':
|
||||||
|
return 'Lossless (16-bit)';
|
||||||
|
case 'HI_RES':
|
||||||
|
return 'Hi-Res (24-bit)';
|
||||||
|
case 'HI_RES_LOSSLESS':
|
||||||
|
return 'Hi-Res Lossless (24-bit)';
|
||||||
|
default:
|
||||||
|
return quality;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatQualityShort(String quality) {
|
||||||
|
switch (quality) {
|
||||||
|
case 'LOSSLESS':
|
||||||
|
return '16-bit';
|
||||||
|
case 'HI_RES':
|
||||||
|
return '24-bit';
|
||||||
|
case 'HI_RES_LOSSLESS':
|
||||||
|
return 'Hi-Res';
|
||||||
|
default:
|
||||||
|
return quality;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildFileInfoCard(BuildContext context, ColorScheme colorScheme, bool fileExists, int? fileSize) {
|
||||||
|
final fileName = item.filePath.split(Platform.pathSeparator).last;
|
||||||
|
final fileExtension = fileName.contains('.') ? fileName.split('.').last.toUpperCase() : 'Unknown';
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
elevation: 0,
|
||||||
|
color: colorScheme.surfaceContainerLow,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.folder_outlined,
|
||||||
|
size: 20,
|
||||||
|
color: colorScheme.primary,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
'File Info',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Format chip
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.primaryContainer,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
fileExtension,
|
||||||
|
style: TextStyle(
|
||||||
|
color: colorScheme.onPrimaryContainer,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (fileSize != null)
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.secondaryContainer,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
_formatFileSize(fileSize),
|
||||||
|
style: TextStyle(
|
||||||
|
color: colorScheme.onSecondaryContainer,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (item.quality != null)
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.tertiaryContainer,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
_formatQualityShort(item.quality!),
|
||||||
|
style: TextStyle(
|
||||||
|
color: colorScheme.onTertiaryContainer,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: _getServiceColor(item.service, colorScheme),
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
_getServiceIcon(item.service),
|
||||||
|
size: 14,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
item.service.toUpperCase(),
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// File path
|
||||||
|
InkWell(
|
||||||
|
onTap: () => _copyToClipboard(context, item.filePath),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.surfaceContainerHighest,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
item.filePath,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
maxLines: 3,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Icon(
|
||||||
|
Icons.copy,
|
||||||
|
size: 18,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildActionButtons(BuildContext context, WidgetRef ref, ColorScheme colorScheme, bool fileExists) {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
// Play button
|
||||||
|
Expanded(
|
||||||
|
flex: 2,
|
||||||
|
child: FilledButton.icon(
|
||||||
|
onPressed: fileExists ? () => _openFile(context, item.filePath) : null,
|
||||||
|
icon: const Icon(Icons.play_arrow),
|
||||||
|
label: const Text('Play'),
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
|
||||||
|
// Delete button
|
||||||
|
Expanded(
|
||||||
|
child: OutlinedButton.icon(
|
||||||
|
onPressed: () => _confirmDelete(context, ref, colorScheme),
|
||||||
|
icon: Icon(Icons.delete_outline, color: colorScheme.error),
|
||||||
|
label: Text('Delete', style: TextStyle(color: colorScheme.error)),
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
side: BorderSide(color: colorScheme.error.withValues(alpha: 0.5)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showOptionsMenu(BuildContext context, WidgetRef ref, ColorScheme colorScheme) {
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||||
|
),
|
||||||
|
builder: (context) => SafeArea(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Container(
|
||||||
|
width: 40,
|
||||||
|
height: 4,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4),
|
||||||
|
borderRadius: BorderRadius.circular(2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.copy),
|
||||||
|
title: const Text('Copy file path'),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
_copyToClipboard(context, item.filePath);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.share),
|
||||||
|
title: const Text('Share'),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
// TODO: Implement share
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: Icon(Icons.delete, color: colorScheme.error),
|
||||||
|
title: Text('Remove from history', style: TextStyle(color: colorScheme.error)),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
_confirmDelete(context, ref, colorScheme);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _confirmDelete(BuildContext context, WidgetRef ref, ColorScheme colorScheme) {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Remove from history?'),
|
||||||
|
content: const Text(
|
||||||
|
'This will remove the track from your download history. '
|
||||||
|
'The downloaded file will not be deleted.',
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
ref.read(downloadHistoryProvider.notifier).removeFromHistory(item.id);
|
||||||
|
Navigator.pop(context); // Close dialog
|
||||||
|
Navigator.pop(context); // Go back to history
|
||||||
|
},
|
||||||
|
child: Text('Remove', style: TextStyle(color: colorScheme.error)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _openFile(BuildContext context, String filePath) async {
|
||||||
|
try {
|
||||||
|
final result = await OpenFilex.open(filePath);
|
||||||
|
if (result.type != ResultType.done && context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('Cannot open: ${result.message}')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('Cannot open file: $e')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _copyToClipboard(BuildContext context, String text) {
|
||||||
|
Clipboard.setData(ClipboardData(text: text));
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Copied to clipboard'),
|
||||||
|
duration: Duration(seconds: 2),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatFullDate(DateTime date) {
|
||||||
|
final months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
|
||||||
|
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||||
|
return '${date.day} ${months[date.month - 1]} ${date.year}, '
|
||||||
|
'${date.hour.toString().padLeft(2, '0')}:'
|
||||||
|
'${date.minute.toString().padLeft(2, '0')}';
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatFileSize(int bytes) {
|
||||||
|
if (bytes < 1024) return '$bytes B';
|
||||||
|
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
|
||||||
|
if (bytes < 1024 * 1024 * 1024) return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
|
||||||
|
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(2)} GB';
|
||||||
|
}
|
||||||
|
|
||||||
|
IconData _getServiceIcon(String service) {
|
||||||
|
switch (service.toLowerCase()) {
|
||||||
|
case 'tidal':
|
||||||
|
return Icons.waves;
|
||||||
|
case 'qobuz':
|
||||||
|
return Icons.album;
|
||||||
|
case 'amazon':
|
||||||
|
return Icons.shopping_cart;
|
||||||
|
default:
|
||||||
|
return Icons.cloud_download;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Color _getServiceColor(String service, ColorScheme colorScheme) {
|
||||||
|
switch (service.toLowerCase()) {
|
||||||
|
case 'tidal':
|
||||||
|
return const Color(0xFF0077B5); // Tidal blue (darker, more readable)
|
||||||
|
case 'qobuz':
|
||||||
|
return const Color(0xFF0052CC); // Qobuz blue
|
||||||
|
case 'amazon':
|
||||||
|
return const Color(0xFFFF9900); // Amazon orange
|
||||||
|
default:
|
||||||
|
return colorScheme.primary;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MetadataItem {
|
||||||
|
final String label;
|
||||||
|
final String value;
|
||||||
|
|
||||||
|
_MetadataItem(this.label, this.value);
|
||||||
|
}
|
||||||
|
|
@ -47,6 +47,7 @@ class PlatformBridge {
|
||||||
String? coverUrl,
|
String? coverUrl,
|
||||||
required String outputDir,
|
required String outputDir,
|
||||||
required String filenameFormat,
|
required String filenameFormat,
|
||||||
|
String quality = 'LOSSLESS',
|
||||||
bool embedLyrics = true,
|
bool embedLyrics = true,
|
||||||
bool embedMaxQualityCover = true,
|
bool embedMaxQualityCover = true,
|
||||||
int trackNumber = 1,
|
int trackNumber = 1,
|
||||||
|
|
@ -65,6 +66,7 @@ class PlatformBridge {
|
||||||
'cover_url': coverUrl,
|
'cover_url': coverUrl,
|
||||||
'output_dir': outputDir,
|
'output_dir': outputDir,
|
||||||
'filename_format': filenameFormat,
|
'filename_format': filenameFormat,
|
||||||
|
'quality': quality,
|
||||||
'embed_lyrics': embedLyrics,
|
'embed_lyrics': embedLyrics,
|
||||||
'embed_max_quality_cover': embedMaxQualityCover,
|
'embed_max_quality_cover': embedMaxQualityCover,
|
||||||
'track_number': trackNumber,
|
'track_number': trackNumber,
|
||||||
|
|
@ -88,6 +90,7 @@ class PlatformBridge {
|
||||||
String? coverUrl,
|
String? coverUrl,
|
||||||
required String outputDir,
|
required String outputDir,
|
||||||
required String filenameFormat,
|
required String filenameFormat,
|
||||||
|
String quality = 'LOSSLESS',
|
||||||
bool embedLyrics = true,
|
bool embedLyrics = true,
|
||||||
bool embedMaxQualityCover = true,
|
bool embedMaxQualityCover = true,
|
||||||
int trackNumber = 1,
|
int trackNumber = 1,
|
||||||
|
|
@ -107,6 +110,7 @@ class PlatformBridge {
|
||||||
'cover_url': coverUrl,
|
'cover_url': coverUrl,
|
||||||
'output_dir': outputDir,
|
'output_dir': outputDir,
|
||||||
'filename_format': filenameFormat,
|
'filename_format': filenameFormat,
|
||||||
|
'quality': quality,
|
||||||
'embed_lyrics': embedLyrics,
|
'embed_lyrics': embedLyrics,
|
||||||
'embed_max_quality_cover': embedMaxQualityCover,
|
'embed_max_quality_cover': embedMaxQualityCover,
|
||||||
'track_number': trackNumber,
|
'track_number': trackNumber,
|
||||||
|
|
|
||||||
88
lib/services/update_checker.dart
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:spotiflac_android/constants/app_info.dart';
|
||||||
|
|
||||||
|
class UpdateInfo {
|
||||||
|
final String version;
|
||||||
|
final String changelog;
|
||||||
|
final String downloadUrl;
|
||||||
|
final DateTime publishedAt;
|
||||||
|
|
||||||
|
const UpdateInfo({
|
||||||
|
required this.version,
|
||||||
|
required this.changelog,
|
||||||
|
required this.downloadUrl,
|
||||||
|
required this.publishedAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class UpdateChecker {
|
||||||
|
static const String _apiUrl = 'https://api.github.com/repos/${AppInfo.githubRepo}/releases/latest';
|
||||||
|
|
||||||
|
/// Check for updates from GitHub releases
|
||||||
|
static Future<UpdateInfo?> checkForUpdate() async {
|
||||||
|
try {
|
||||||
|
final response = await http.get(
|
||||||
|
Uri.parse(_apiUrl),
|
||||||
|
headers: {'Accept': 'application/vnd.github.v3+json'},
|
||||||
|
).timeout(const Duration(seconds: 10));
|
||||||
|
|
||||||
|
if (response.statusCode != 200) {
|
||||||
|
print('[UpdateChecker] GitHub API returned ${response.statusCode}');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final data = jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
|
final tagName = data['tag_name'] as String? ?? '';
|
||||||
|
final latestVersion = tagName.replaceFirst('v', '');
|
||||||
|
|
||||||
|
if (!_isNewerVersion(latestVersion, AppInfo.version)) {
|
||||||
|
print('[UpdateChecker] No update available (current: ${AppInfo.version}, latest: $latestVersion)');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get changelog from release body
|
||||||
|
final body = data['body'] as String? ?? 'No changelog available';
|
||||||
|
final htmlUrl = data['html_url'] as String? ?? '${AppInfo.githubUrl}/releases';
|
||||||
|
final publishedAt = DateTime.tryParse(data['published_at'] as String? ?? '') ?? DateTime.now();
|
||||||
|
|
||||||
|
print('[UpdateChecker] Update available: $latestVersion');
|
||||||
|
|
||||||
|
return UpdateInfo(
|
||||||
|
version: latestVersion,
|
||||||
|
changelog: body,
|
||||||
|
downloadUrl: htmlUrl,
|
||||||
|
publishedAt: publishedAt,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
print('[UpdateChecker] Error checking for updates: $e');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compare version strings (e.g., "1.1.1" vs "1.1.0")
|
||||||
|
static bool _isNewerVersion(String latest, String current) {
|
||||||
|
try {
|
||||||
|
final latestParts = latest.split('.').map(int.parse).toList();
|
||||||
|
final currentParts = current.split('.').map(int.parse).toList();
|
||||||
|
|
||||||
|
// Pad with zeros if needed
|
||||||
|
while (latestParts.length < 3) {
|
||||||
|
latestParts.add(0);
|
||||||
|
}
|
||||||
|
while (currentParts.length < 3) {
|
||||||
|
currentParts.add(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = 0; i < 3; i++) {
|
||||||
|
if (latestParts[i] > currentParts[i]) return true;
|
||||||
|
if (latestParts[i] < currentParts[i]) return false;
|
||||||
|
}
|
||||||
|
return false; // Same version
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static String get currentVersion => AppInfo.version;
|
||||||
|
}
|
||||||
162
lib/widgets/update_dialog.dart
Normal file
|
|
@ -0,0 +1,162 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
import 'package:spotiflac_android/constants/app_info.dart';
|
||||||
|
import 'package:spotiflac_android/services/update_checker.dart';
|
||||||
|
|
||||||
|
class UpdateDialog extends StatelessWidget {
|
||||||
|
final UpdateInfo updateInfo;
|
||||||
|
final VoidCallback onDismiss;
|
||||||
|
final VoidCallback onDisableUpdates;
|
||||||
|
|
||||||
|
const UpdateDialog({
|
||||||
|
super.key,
|
||||||
|
required this.updateInfo,
|
||||||
|
required this.onDismiss,
|
||||||
|
required this.onDisableUpdates,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
return AlertDialog(
|
||||||
|
title: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.system_update, color: colorScheme.primary),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
const Text('Update Available'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
content: SizedBox(
|
||||||
|
width: double.maxFinite,
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Version info
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.primaryContainer,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'v${AppInfo.version}',
|
||||||
|
style: TextStyle(color: colorScheme.onPrimaryContainer),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Icon(Icons.arrow_forward, size: 16, color: colorScheme.onPrimaryContainer),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
'v${updateInfo.version}',
|
||||||
|
style: TextStyle(
|
||||||
|
color: colorScheme.onPrimaryContainer,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Changelog header
|
||||||
|
Text(
|
||||||
|
'What\'s New:',
|
||||||
|
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
|
||||||
|
// Changelog content (scrollable)
|
||||||
|
Flexible(
|
||||||
|
child: Container(
|
||||||
|
constraints: const BoxConstraints(maxHeight: 200),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.surfaceContainerHighest,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
child: Text(
|
||||||
|
_formatChangelog(updateInfo.changelog),
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
// Don't remind again button
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
onDisableUpdates();
|
||||||
|
Navigator.pop(context);
|
||||||
|
},
|
||||||
|
child: Text(
|
||||||
|
'Don\'t remind',
|
||||||
|
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Later button
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
onDismiss();
|
||||||
|
Navigator.pop(context);
|
||||||
|
},
|
||||||
|
child: const Text('Later'),
|
||||||
|
),
|
||||||
|
// Download button
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () async {
|
||||||
|
final uri = Uri.parse(updateInfo.downloadUrl);
|
||||||
|
if (await canLaunchUrl(uri)) {
|
||||||
|
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||||
|
}
|
||||||
|
if (context.mounted) {
|
||||||
|
Navigator.pop(context);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: const Text('Download'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format changelog - clean up markdown
|
||||||
|
String _formatChangelog(String changelog) {
|
||||||
|
// Remove markdown headers but keep content
|
||||||
|
var formatted = changelog
|
||||||
|
.replaceAll(RegExp(r'^#{1,6}\s*', multiLine: true), '')
|
||||||
|
.replaceAll(RegExp(r'\*\*([^*]+)\*\*'), r'$1') // Remove bold
|
||||||
|
.replaceAll(RegExp(r'`([^`]+)`'), r'$1') // Remove code
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
// Limit length
|
||||||
|
if (formatted.length > 1000) {
|
||||||
|
formatted = '${formatted.substring(0, 1000)}...';
|
||||||
|
}
|
||||||
|
|
||||||
|
return formatted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Show update dialog
|
||||||
|
Future<void> showUpdateDialog(
|
||||||
|
BuildContext context, {
|
||||||
|
required UpdateInfo updateInfo,
|
||||||
|
required VoidCallback onDisableUpdates,
|
||||||
|
}) async {
|
||||||
|
return showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => UpdateDialog(
|
||||||
|
updateInfo: updateInfo,
|
||||||
|
onDismiss: () {},
|
||||||
|
onDisableUpdates: onDisableUpdates,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
name: spotiflac_android
|
name: spotiflac_android
|
||||||
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
|
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
version: 1.1.1+8
|
version: 1.2.0+10
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.10.0
|
sdk: ^3.10.0
|
||||||
|
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
// This is a basic Flutter widget test.
|
|
||||||
//
|
|
||||||
// To perform an interaction with a widget in your test, use the WidgetTester
|
|
||||||
// utility in the flutter_test package. For example, you can send tap and scroll
|
|
||||||
// gestures. You can also use WidgetTester to find child widgets in the widget
|
|
||||||
// tree, read text, and verify that the values of widget properties are correct.
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
|
||||||
|
|
||||||
import 'package:spotiflac_android/main.dart';
|
|
||||||
|
|
||||||
void main() {
|
|
||||||
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
|
|
||||||
// Build our app and trigger a frame.
|
|
||||||
await tester.pumpWidget(const MyApp());
|
|
||||||
|
|
||||||
// Verify that our counter starts at 0.
|
|
||||||
expect(find.text('0'), findsOneWidget);
|
|
||||||
expect(find.text('1'), findsNothing);
|
|
||||||
|
|
||||||
// Tap the '+' icon and trigger a frame.
|
|
||||||
await tester.tap(find.byIcon(Icons.add));
|
|
||||||
await tester.pump();
|
|
||||||
|
|
||||||
// Verify that our counter has incremented.
|
|
||||||
expect(find.text('0'), findsNothing);
|
|
||||||
expect(find.text('1'), findsOneWidget);
|
|
||||||
});
|
|
||||||
}
|
|
||||||