Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9ca0e8cf5c | |||
| 37b8682faa | |||
| 6563f0f2b3 | |||
| 562fd4d7bb | |||
| 7aa3e77df1 | |||
| 4caa803eb2 | |||
| 6d5c9d0f91 | |||
| 1b2ad4cdd5 | |||
| 33e8ddd758 | |||
| d227d57545 | |||
| db1439e08f | |||
| 47e7850ee0 | |||
| 3ea665dab4 | |||
| bd4acdf222 | |||
| 8ac679003e |
@@ -86,16 +86,29 @@ jobs:
|
||||
- name: Generate app icons
|
||||
run: dart run flutter_launcher_icons
|
||||
|
||||
- name: Build APK (Release)
|
||||
- name: Build APK (Release - unsigned)
|
||||
run: flutter build apk --release --split-per-abi
|
||||
|
||||
- name: Sign APKs
|
||||
uses: r0adkll/sign-android-release@v1
|
||||
id: sign_arm64
|
||||
with:
|
||||
releaseDirectory: build/app/outputs/flutter-apk
|
||||
signingKeyBase64: ${{ secrets.KEYSTORE_BASE64 }}
|
||||
alias: ${{ secrets.KEY_ALIAS }}
|
||||
keyStorePassword: ${{ secrets.KEYSTORE_PASSWORD }}
|
||||
keyPassword: ${{ secrets.KEY_PASSWORD }}
|
||||
env:
|
||||
BUILD_TOOLS_VERSION: "34.0.0"
|
||||
|
||||
- name: Rename APKs
|
||||
run: |
|
||||
VERSION=${{ needs.get-version.outputs.version }}
|
||||
cd build/app/outputs/flutter-apk
|
||||
mv app-arm64-v8a-release.apk SpotiFLAC-${VERSION}-arm64.apk || true
|
||||
mv app-armeabi-v7a-release.apk SpotiFLAC-${VERSION}-arm32.apk || true
|
||||
mv app-release.apk SpotiFLAC-${VERSION}-universal.apk || true
|
||||
# Signed files have -signed suffix
|
||||
mv app-arm64-v8a-release-signed.apk SpotiFLAC-${VERSION}-arm64.apk || mv app-arm64-v8a-release.apk SpotiFLAC-${VERSION}-arm64.apk || true
|
||||
mv app-armeabi-v7a-release-signed.apk SpotiFLAC-${VERSION}-arm32.apk || mv app-armeabi-v7a-release.apk SpotiFLAC-${VERSION}-arm32.apk || true
|
||||
mv app-release-signed.apk SpotiFLAC-${VERSION}-universal.apk || mv app-release.apk SpotiFLAC-${VERSION}-universal.apk || true
|
||||
ls -la
|
||||
|
||||
- name: Upload APK artifact
|
||||
@@ -222,6 +235,34 @@ jobs:
|
||||
contents: write
|
||||
|
||||
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
|
||||
|
||||
echo "Looking for version: $VERSION_NUM"
|
||||
|
||||
# Extract changelog section for this version using sed
|
||||
# Find the line with version, then print until next version header or end
|
||||
CHANGELOG=$(sed -n "/^## \[$VERSION_NUM\]/,/^## \[/{ /^## \[$VERSION_NUM\]/d; /^## \[/d; p; }" CHANGELOG.md)
|
||||
|
||||
# If no changelog found, use default message
|
||||
if [ -z "$CHANGELOG" ]; then
|
||||
echo "No changelog found for version $VERSION_NUM"
|
||||
CHANGELOG="See CHANGELOG.md for details."
|
||||
else
|
||||
echo "Found changelog content"
|
||||
fi
|
||||
|
||||
# Save to file for multiline support
|
||||
echo "$CHANGELOG" > /tmp/changelog.txt
|
||||
echo "Extracted changelog:"
|
||||
cat /tmp/changelog.txt
|
||||
|
||||
- name: Download Android APK
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
@@ -234,24 +275,45 @@ jobs:
|
||||
name: ios-ipa
|
||||
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
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
tag_name: ${{ needs.get-version.outputs.version }}
|
||||
name: SpotiFLAC ${{ needs.get-version.outputs.version }}
|
||||
body: |
|
||||
## 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
|
||||
body_path: /tmp/release_body.txt
|
||||
files: ./release/*
|
||||
draft: false
|
||||
prerelease: false
|
||||
|
||||
@@ -7,8 +7,47 @@ Thumbs.db
|
||||
.vscode/
|
||||
*.iml
|
||||
|
||||
# Kiro specs (optional - remove if you want to track specs)
|
||||
# .kiro/
|
||||
# Kiro specs (development only)
|
||||
.kiro/
|
||||
|
||||
# Reference folder (if you don't want to include it)
|
||||
# referensi/
|
||||
# Reference folder (development only)
|
||||
referensi/
|
||||
|
||||
# Development notes
|
||||
COMPARISON_PC_vs_ANDROID.md
|
||||
|
||||
# Old spotiflac_android folder (moved to root)
|
||||
spotiflac_android/
|
||||
|
||||
# Flutter/Dart
|
||||
.dart_tool/
|
||||
.packages
|
||||
build/
|
||||
*.lock
|
||||
!pubspec.lock
|
||||
.flutter-plugins
|
||||
.flutter-plugins-dependencies
|
||||
.metadata
|
||||
|
||||
# Go backend build artifacts
|
||||
go_backend/*.aar
|
||||
go_backend/*.jar
|
||||
go_backend/*.exe
|
||||
go_backend/*.xcframework/
|
||||
|
||||
# Android
|
||||
android/.gradle/
|
||||
android/app/libs/
|
||||
android/local.properties
|
||||
android/*.iml
|
||||
android/keystore.properties
|
||||
android/*.jks
|
||||
android/*.keystore
|
||||
android/app/ci-keystore.jks
|
||||
|
||||
# iOS
|
||||
ios/Frameworks/
|
||||
ios/Pods/
|
||||
ios/.symlinks/
|
||||
ios/Flutter/Flutter.framework/
|
||||
ios/Flutter/Flutter.podspec
|
||||
|
||||
@@ -1,5 +1,145 @@
|
||||
# Changelog
|
||||
|
||||
## [1.5.0-hotfix3] - 2026-01-02
|
||||
|
||||
### Fixed
|
||||
- **App Signing**: Decode keystore in workflow before Gradle evaluation
|
||||
|
||||
## [1.5.0-hotfix2] - 2026-01-02
|
||||
|
||||
### Fixed
|
||||
- **App Signing**: Fixed CI/CD signing configuration
|
||||
|
||||
## [1.5.0-hotfix] - 2026-01-02
|
||||
|
||||
### Important Notice
|
||||
We apologize for the inconvenience. Previous releases were signed with different keys, causing "package conflicts" errors when upgrading. Starting from this version, all releases will use a consistent signing key.
|
||||
|
||||
**If you're upgrading from v1.5.0 or earlier, please uninstall the app first before installing this version.** This is a one-time requirement. Future updates will work seamlessly without uninstalling.
|
||||
|
||||
### Added
|
||||
- **In-App Update**: Download and install updates directly from the app
|
||||
- Progress bar shows download status
|
||||
- Automatic device architecture detection (arm64/arm32)
|
||||
- Downloads correct APK for your device
|
||||
- **Consistent App Signing**: All future releases will use the same signing key
|
||||
|
||||
### Fixed
|
||||
- **Update Checker**: Now downloads APK directly instead of opening browser
|
||||
|
||||
## [1.5.0] - 2026-01-02
|
||||
|
||||
### Added
|
||||
- **Download Progress Notification**: Shows notification with download progress percentage while downloading
|
||||
- Progress bar in notification during download
|
||||
- Completion notification when track finishes
|
||||
- Summary notification when all downloads complete
|
||||
- **Notification Permission in Setup**: Android 13+ users will be prompted for notification permission during initial setup
|
||||
- New step in setup wizard for notification permission
|
||||
- Option to skip if user doesn't want notifications
|
||||
- **Per-Item Queue Controls**: Each track in download queue now has individual controls
|
||||
- Cancel button for queued items
|
||||
- Stop button for currently downloading items
|
||||
- Retry and Remove buttons for failed/skipped items
|
||||
- Visual progress bar with percentage for each downloading track
|
||||
- **Pull-to-Refresh on Home**: Swipe down to clear URL input and fetched tracks
|
||||
- No need to exit app to clear current search/fetch
|
||||
- **Multi-Progress Tracking for Concurrent Downloads**: Each concurrent download now shows individual progress percentage
|
||||
- Previously concurrent downloads jumped from 0% to 100%
|
||||
- Now each track shows real-time progress when downloading in parallel
|
||||
- **In-App Update**: Download and install updates directly from the app
|
||||
- Progress bar shows download status
|
||||
- Automatic device architecture detection (arm64/arm32)
|
||||
- Downloads correct APK for your device
|
||||
|
||||
### Changed
|
||||
- **Recent Downloads**: Now shows up to 10 items (was 5) for better scrolling
|
||||
- **Queue UI Redesign**: Card-based layout with clearer status indicators
|
||||
- Removed global pause/resume in favor of per-item controls
|
||||
- Better visual hierarchy with cover art, track info, and action buttons
|
||||
- **Settings UI**: Redesigned with category-based navigation (One UI style)
|
||||
- Main settings tab with 4 categories: Appearance, Download, Options, About
|
||||
- Each category opens a detail page
|
||||
- Large title at top with menu items below
|
||||
- One-handed friendly layout
|
||||
- **Collapsing Toolbar**: Implemented One UI style collapsing header for all tabs
|
||||
- Title animates from 28px (expanded) to 20px (collapsed)
|
||||
- Back button only on settings detail pages
|
||||
- Consistent across Home, Downloads, and Settings tabs
|
||||
- **Home Search Bar Redesign**: More prominent and user-friendly input
|
||||
- Larger card-style search bar with border outline
|
||||
- Tap to open bottom sheet with full input experience
|
||||
- Paste and Search buttons clearly visible
|
||||
- Helper text showing supported URL types
|
||||
- **Empty State Improved**: Better onboarding for new users
|
||||
- "Ready to Download" title with icon
|
||||
- Clear instructions on how to use the app
|
||||
- "Add Music" button for quick access
|
||||
|
||||
### Technical
|
||||
- Added `flutter_local_notifications` package for notifications
|
||||
- Added notification permission request in setup screen for Android 13+
|
||||
- Enabled core library desugaring for all Android subprojects
|
||||
- Added multi-progress tracking in Go backend (`ItemProgress`, `ItemProgressWriter`)
|
||||
- Added `GetAllDownloadProgress`, `InitItemProgress`, `FinishItemProgress`, `ClearItemProgress` exports
|
||||
- Updated platform channel handlers for both Android (Kotlin) and iOS (Swift)
|
||||
|
||||
### Performance
|
||||
- Optimized SliverAppBar: Removed LayoutBuilder that was called every frame during scroll
|
||||
- Optimized image caching: Added `memCacheWidth/Height` to CachedNetworkImage for memory efficiency
|
||||
- Optimized state management: Use `select()` to only rebuild when specific state changes
|
||||
- Smoother animations: Changed to `BouncingScrollPhysics` and `Curves.easeOutCubic`
|
||||
|
||||
## [1.2.0] - 2026-01-02
|
||||
|
||||
### Added
|
||||
- **Track Metadata Screen**: New detailed metadata view when tapping on downloaded tracks
|
||||
- Material Expressive 3 design with cover art header and gradient
|
||||
- Hero animation from list to detail view
|
||||
- Displays: track name, artist, album artist, album, track number, disc number, duration, release date, ISRC, Spotify ID, quality, service, download date
|
||||
- File info: format (FLAC/M4A), file size, quality badge, service badge with colors
|
||||
- Tap to copy ISRC and Spotify ID
|
||||
- "Open in Spotify" button to open track in Spotify app/browser
|
||||
- File path display with copy functionality
|
||||
- Play and Delete action buttons
|
||||
- **Hi-Res Lossless MAX**: New highest quality option for maximum audio fidelity
|
||||
|
||||
### Fixed
|
||||
- **Hi-Res Quality Bug**: Fixed issue where Hi-Res downloads were stuck at Lossless quality
|
||||
- Users on previous versions are recommended to upgrade to get proper Hi-Res downloads
|
||||
- **Settings Navigation Bug**: Fixed issue where changing settings (like audio quality) would navigate back to Home tab
|
||||
- **Tidal Badge Color**: Fixed unreadable Tidal service badge (was too bright cyan, now darker blue)
|
||||
|
||||
### Changed
|
||||
- **Recent Downloads**: Tapping on a track now opens metadata screen instead of playing directly
|
||||
- Play button still available for quick playback
|
||||
- **Download History Model**: Extended with additional metadata fields (albumArtist, isrc, spotifyId, trackNumber, discNumber, duration, releaseDate, quality)
|
||||
- Removed unused `history_screen.dart` and `history_tab.dart` files
|
||||
|
||||
## [1.1.2] - 2026-01-01
|
||||
|
||||
### Added
|
||||
- **Update Checker**: Automatic check for new versions from GitHub releases
|
||||
- Shows changelog in update dialog
|
||||
- Option to disable update notifications
|
||||
- **Release Changelog**: GitHub releases now include full changelog
|
||||
|
||||
### Changed
|
||||
- Updated version to 1.1.2
|
||||
|
||||
## [1.1.1] - 2026-01-01
|
||||
|
||||
### Fixed
|
||||
- **About Dialog**: Custom About dialog with cleaner layout
|
||||
- **Setup Screen**: Fixed step indicator line alignment
|
||||
- **Warning Text**: Fixed parallel downloads warning to use Material theme colors
|
||||
- **Copyright Year**: Updated to 2026
|
||||
|
||||
### Changed
|
||||
- Removed Theme Preview from Settings
|
||||
- Added MIT License
|
||||
|
||||
|
||||
## [1.1.0] - 2026-01-01
|
||||
|
||||
### Added
|
||||
@@ -11,7 +151,6 @@
|
||||
- **History Persistence**: Download history now persists across app restarts using SharedPreferences
|
||||
- **Connection Pooling**: Shared HTTP transport to prevent TCP connection exhaustion during large batch downloads
|
||||
- **Connection Cleanup**: Automatic cleanup of idle connections every 50 downloads and at queue end
|
||||
- **GitHub & Credits Section**: Added links to SpotiFLAC Mobile and original SpotiFLAC desktop in Settings
|
||||
|
||||
### Fixed
|
||||
- **Download Progress Bug**: Fixed 0% → 100% jump by adding proper progress tracking for BTS format downloads
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 zarzet
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -13,13 +13,23 @@ Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account
|
||||
|
||||
### [Download](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
||||
|
||||
## Features
|
||||
|
||||
- Download tracks, albums, and playlists from Spotify links
|
||||
- True lossless FLAC quality from Tidal, Qobuz & Amazon Music
|
||||
- Material Expressive 3 design with dynamic colors
|
||||
- High performance rendering with Impeller (Vulkan)
|
||||
- Concurrent downloads up to 3 simultaneous
|
||||
- Real-time download progress tracking
|
||||
- Download notifications
|
||||
|
||||
## Screenshots
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/Screenshot_20260101-210622_SpotiFLAC.png" width="200" />
|
||||
<img src="docs/Screenshot_20260101-210626_SpotiFLAC.png" width="200" />
|
||||
<img src="docs/Screenshot_20260101-210633_SpotiFLAC.png" width="200" />
|
||||
<img src="docs/Screenshot_20260101-210653_SpotiFLAC.png" width="200" />
|
||||
<img src="assets/images/photo_2026-01-02_02-35-09.jpg" width="200" />
|
||||
<img src="assets/images/photo_2026-01-02_02-35-34.jpg" width="200" />
|
||||
<img src="assets/images/photo_2026-01-02_02-35-37.jpg" width="200" />
|
||||
<img src="assets/images/photo_2026-01-02_02-36-23.jpg" width="200" />
|
||||
</p>
|
||||
|
||||
## Other project
|
||||
|
||||
@@ -5,6 +5,13 @@ plugins {
|
||||
id("dev.flutter.flutter-gradle-plugin")
|
||||
}
|
||||
|
||||
// Load keystore properties for local builds
|
||||
val keystorePropertiesFile = rootProject.file("key.properties")
|
||||
val keystoreProperties = java.util.Properties()
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
keystoreProperties.load(java.io.FileInputStream(keystorePropertiesFile))
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.zarz.spotiflac"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
@@ -22,6 +29,17 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
create("release") {
|
||||
keyAlias = keystoreProperties["keyAlias"] as String
|
||||
keyPassword = keystoreProperties["keyPassword"] as String
|
||||
storeFile = file(keystoreProperties["storeFile"] as String)
|
||||
storePassword = keystoreProperties["storePassword"] as String
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.zarz.spotiflac"
|
||||
minSdk = flutter.minSdkVersion
|
||||
@@ -30,8 +48,6 @@ android {
|
||||
versionName = flutter.versionName
|
||||
multiDexEnabled = true
|
||||
|
||||
// Only include arm64-v8a for smaller APK (most modern devices)
|
||||
// Remove this line if you need to support older 32-bit devices
|
||||
ndk {
|
||||
abiFilters += listOf("arm64-v8a", "armeabi-v7a")
|
||||
}
|
||||
@@ -39,8 +55,13 @@ android {
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
// Enable code shrinking and resource shrinking
|
||||
// For local builds: use release signing if key.properties exists
|
||||
// For CI builds: APK is signed by GitHub Action after build
|
||||
signingConfig = if (keystorePropertiesFile.exists()) {
|
||||
signingConfigs.getByName("release")
|
||||
} else {
|
||||
signingConfigs.getByName("debug")
|
||||
}
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
proguardFiles(
|
||||
@@ -72,7 +93,7 @@ repositories {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4")
|
||||
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
|
||||
implementation(files("libs/gobackend.aar"))
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
|
||||
<application
|
||||
android:label="SpotiFLAC"
|
||||
@@ -76,6 +77,17 @@
|
||||
<meta-data
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
|
||||
<!-- FileProvider for APK installation -->
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileProvider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
</application>
|
||||
|
||||
<queries>
|
||||
|
||||
@@ -71,6 +71,33 @@ class MainActivity: FlutterActivity() {
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"getAllDownloadProgress" -> {
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.getAllDownloadProgress()
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"initItemProgress" -> {
|
||||
val itemId = call.argument<String>("item_id") ?: ""
|
||||
withContext(Dispatchers.IO) {
|
||||
Gobackend.initItemProgress(itemId)
|
||||
}
|
||||
result.success(null)
|
||||
}
|
||||
"finishItemProgress" -> {
|
||||
val itemId = call.argument<String>("item_id") ?: ""
|
||||
withContext(Dispatchers.IO) {
|
||||
Gobackend.finishItemProgress(itemId)
|
||||
}
|
||||
result.success(null)
|
||||
}
|
||||
"clearItemProgress" -> {
|
||||
val itemId = call.argument<String>("item_id") ?: ""
|
||||
withContext(Dispatchers.IO) {
|
||||
Gobackend.clearItemProgress(itemId)
|
||||
}
|
||||
result.success(null)
|
||||
}
|
||||
"setDownloadDirectory" -> {
|
||||
val path = call.argument<String>("path") ?: ""
|
||||
withContext(Dispatchers.IO) {
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths>
|
||||
<external-files-path name="external_files" path="." />
|
||||
<cache-path name="cache" path="." />
|
||||
<files-path name="files" path="." />
|
||||
</paths>
|
||||
@@ -10,10 +10,19 @@ subprojects {
|
||||
if (project.hasProperty("android")) {
|
||||
project.extensions.configure<com.android.build.gradle.BaseExtension>("android") {
|
||||
compileOptions {
|
||||
isCoreLibraryDesugaringEnabled = true
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
// Enable multidex for all subprojects
|
||||
defaultConfig {
|
||||
multiDexEnabled = true
|
||||
}
|
||||
}
|
||||
|
||||
// Add desugaring dependency to all Android subprojects
|
||||
project.dependencies.add("coreLibraryDesugaring", "com.android.tools:desugar_jdk_libs:2.1.4")
|
||||
}
|
||||
|
||||
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
|
||||
|
||||
|
After Width: | Height: | Size: 147 KiB |
|
After Width: | Height: | Size: 135 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 191 KiB |
|
Before Width: | Height: | Size: 137 KiB |
|
Before Width: | Height: | Size: 129 KiB |
|
Before Width: | Height: | Size: 178 KiB |
|
Before Width: | Height: | Size: 202 KiB |
@@ -202,12 +202,18 @@ func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, outputDir
|
||||
|
||||
|
||||
// DownloadFile downloads a file from URL with User-Agent and progress tracking
|
||||
func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath string) error {
|
||||
// Set current file being downloaded
|
||||
func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
|
||||
// Set current file being downloaded (legacy)
|
||||
SetCurrentFile(filepath.Base(outputPath))
|
||||
SetDownloading(true)
|
||||
defer SetDownloading(false)
|
||||
|
||||
// Initialize item progress if itemID provided
|
||||
if itemID != "" {
|
||||
StartItemProgress(itemID)
|
||||
defer CompleteItemProgress(itemID)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", downloadURL, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
@@ -228,6 +234,9 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath string) error {
|
||||
// Set total bytes if available
|
||||
if resp.ContentLength > 0 {
|
||||
SetBytesTotal(resp.ContentLength)
|
||||
if itemID != "" {
|
||||
SetItemBytesTotal(itemID, resp.ContentLength)
|
||||
}
|
||||
}
|
||||
|
||||
out, err := os.Create(outputPath)
|
||||
@@ -236,14 +245,20 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath string) error {
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
// Track download progress
|
||||
pw := NewProgressWriter(out)
|
||||
_, err = io.Copy(pw, resp.Body)
|
||||
// Use appropriate progress writer
|
||||
var bytesWritten int64
|
||||
if itemID != "" {
|
||||
pw := NewItemProgressWriter(out, itemID)
|
||||
bytesWritten, err = io.Copy(pw, resp.Body)
|
||||
} else {
|
||||
pw := NewProgressWriter(out)
|
||||
bytesWritten, err = io.Copy(pw, resp.Body)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to write file: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("\r[Amazon] Downloaded: %.2f MB (Complete)\n", float64(pw.GetTotal())/(1024*1024))
|
||||
fmt.Printf("\r[Amazon] Downloaded: %.2f MB (Complete)\n", float64(bytesWritten)/(1024*1024))
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -298,8 +313,8 @@ func downloadFromAmazon(req DownloadRequest) (string, error) {
|
||||
return "EXISTS:" + outputPath, nil
|
||||
}
|
||||
|
||||
// Download file
|
||||
if err := downloader.DownloadFile(downloadURL, outputPath); err != nil {
|
||||
// Download file with item ID for progress tracking
|
||||
if err := downloader.DownloadFile(downloadURL, outputPath, req.ItemID); err != nil {
|
||||
return "", fmt.Errorf("download failed: %w", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -99,12 +99,14 @@ type DownloadRequest struct {
|
||||
CoverURL string `json:"cover_url"`
|
||||
OutputDir string `json:"output_dir"`
|
||||
FilenameFormat string `json:"filename_format"`
|
||||
Quality string `json:"quality"` // LOSSLESS, HI_RES, HI_RES_LOSSLESS
|
||||
EmbedLyrics bool `json:"embed_lyrics"`
|
||||
EmbedMaxQualityCover bool `json:"embed_max_quality_cover"`
|
||||
TrackNumber int `json:"track_number"`
|
||||
DiscNumber int `json:"disc_number"`
|
||||
TotalTracks int `json:"total_tracks"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
ItemID string `json:"item_id"` // Unique ID for progress tracking
|
||||
}
|
||||
|
||||
// DownloadResponse represents the result of a download
|
||||
@@ -254,6 +256,27 @@ func GetDownloadProgress() string {
|
||||
return string(jsonBytes)
|
||||
}
|
||||
|
||||
// GetAllDownloadProgress returns progress for all active downloads (concurrent mode)
|
||||
func GetAllDownloadProgress() string {
|
||||
return GetMultiProgress()
|
||||
}
|
||||
|
||||
// InitItemProgress initializes progress tracking for a download item
|
||||
func InitItemProgress(itemID string) {
|
||||
StartItemProgress(itemID)
|
||||
}
|
||||
|
||||
// FinishItemProgress marks a download item as complete and removes tracking
|
||||
func FinishItemProgress(itemID string) {
|
||||
CompleteItemProgress(itemID)
|
||||
// Don't remove immediately - let Flutter poll one more time to see 100%
|
||||
}
|
||||
|
||||
// ClearItemProgress removes progress tracking for a specific item
|
||||
func ClearItemProgress(itemID string) {
|
||||
RemoveItemProgress(itemID)
|
||||
}
|
||||
|
||||
// CleanupConnections closes idle HTTP connections
|
||||
// Call this periodically during large batch downloads to prevent TCP exhaustion
|
||||
func CleanupConnections() {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// DownloadProgress represents current download progress
|
||||
// DownloadProgress represents current download progress (legacy single download)
|
||||
type DownloadProgress struct {
|
||||
CurrentFile string `json:"current_file"`
|
||||
Progress float64 `json:"progress"`
|
||||
@@ -14,20 +15,128 @@ type DownloadProgress struct {
|
||||
IsDownloading bool `json:"is_downloading"`
|
||||
}
|
||||
|
||||
// ItemProgress represents progress for a single download item
|
||||
type ItemProgress struct {
|
||||
ItemID string `json:"item_id"`
|
||||
BytesTotal int64 `json:"bytes_total"`
|
||||
BytesReceived int64 `json:"bytes_received"`
|
||||
Progress float64 `json:"progress"` // 0.0 to 1.0
|
||||
IsDownloading bool `json:"is_downloading"`
|
||||
}
|
||||
|
||||
// MultiProgress holds progress for multiple concurrent downloads
|
||||
type MultiProgress struct {
|
||||
Items map[string]*ItemProgress `json:"items"`
|
||||
}
|
||||
|
||||
var (
|
||||
currentProgress DownloadProgress
|
||||
progressMu sync.RWMutex
|
||||
downloadDir string
|
||||
downloadDirMu sync.RWMutex
|
||||
|
||||
// Multi-download progress tracking
|
||||
multiProgress = MultiProgress{Items: make(map[string]*ItemProgress)}
|
||||
multiMu sync.RWMutex
|
||||
)
|
||||
|
||||
// getProgress returns current download progress
|
||||
// getProgress returns current download progress (legacy)
|
||||
func getProgress() DownloadProgress {
|
||||
progressMu.RLock()
|
||||
defer progressMu.RUnlock()
|
||||
return currentProgress
|
||||
}
|
||||
|
||||
// GetMultiProgress returns progress for all active downloads as JSON
|
||||
func GetMultiProgress() string {
|
||||
multiMu.RLock()
|
||||
defer multiMu.RUnlock()
|
||||
|
||||
jsonBytes, err := json.Marshal(multiProgress)
|
||||
if err != nil {
|
||||
return "{\"items\":{}}"
|
||||
}
|
||||
return string(jsonBytes)
|
||||
}
|
||||
|
||||
// GetItemProgress returns progress for a specific item as JSON
|
||||
func GetItemProgress(itemID string) string {
|
||||
multiMu.RLock()
|
||||
defer multiMu.RUnlock()
|
||||
|
||||
if item, ok := multiProgress.Items[itemID]; ok {
|
||||
jsonBytes, _ := json.Marshal(item)
|
||||
return string(jsonBytes)
|
||||
}
|
||||
return "{}"
|
||||
}
|
||||
|
||||
// StartItemProgress initializes progress tracking for an item
|
||||
func StartItemProgress(itemID string) {
|
||||
multiMu.Lock()
|
||||
defer multiMu.Unlock()
|
||||
|
||||
multiProgress.Items[itemID] = &ItemProgress{
|
||||
ItemID: itemID,
|
||||
BytesTotal: 0,
|
||||
BytesReceived: 0,
|
||||
Progress: 0,
|
||||
IsDownloading: true,
|
||||
}
|
||||
}
|
||||
|
||||
// SetItemBytesTotal sets total bytes for an item
|
||||
func SetItemBytesTotal(itemID string, total int64) {
|
||||
multiMu.Lock()
|
||||
defer multiMu.Unlock()
|
||||
|
||||
if item, ok := multiProgress.Items[itemID]; ok {
|
||||
item.BytesTotal = total
|
||||
}
|
||||
}
|
||||
|
||||
// SetItemBytesReceived sets bytes received for an item
|
||||
func SetItemBytesReceived(itemID string, received int64) {
|
||||
multiMu.Lock()
|
||||
defer multiMu.Unlock()
|
||||
|
||||
if item, ok := multiProgress.Items[itemID]; ok {
|
||||
item.BytesReceived = received
|
||||
if item.BytesTotal > 0 {
|
||||
item.Progress = float64(received) / float64(item.BytesTotal)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CompleteItemProgress marks an item as complete
|
||||
func CompleteItemProgress(itemID string) {
|
||||
multiMu.Lock()
|
||||
defer multiMu.Unlock()
|
||||
|
||||
if item, ok := multiProgress.Items[itemID]; ok {
|
||||
item.Progress = 1.0
|
||||
item.IsDownloading = false
|
||||
}
|
||||
}
|
||||
|
||||
// RemoveItemProgress removes progress tracking for an item
|
||||
func RemoveItemProgress(itemID string) {
|
||||
multiMu.Lock()
|
||||
defer multiMu.Unlock()
|
||||
|
||||
delete(multiProgress.Items, itemID)
|
||||
}
|
||||
|
||||
// ClearAllItemProgress clears all item progress
|
||||
func ClearAllItemProgress() {
|
||||
multiMu.Lock()
|
||||
defer multiMu.Unlock()
|
||||
|
||||
multiProgress.Items = make(map[string]*ItemProgress)
|
||||
}
|
||||
|
||||
// Legacy functions for backward compatibility
|
||||
|
||||
// SetDownloadProgress sets the current download progress (MB downloaded)
|
||||
func SetDownloadProgress(mbDownloaded float64) {
|
||||
progressMu.Lock()
|
||||
@@ -47,7 +156,6 @@ func SetDownloadSpeed(speedMBps float64) {
|
||||
func SetCurrentFile(filename string) {
|
||||
progressMu.Lock()
|
||||
defer progressMu.Unlock()
|
||||
// Reset progress for new file
|
||||
currentProgress.BytesReceived = 0
|
||||
currentProgress.BytesTotal = 0
|
||||
currentProgress.Progress = 0
|
||||
@@ -101,7 +209,7 @@ func SetBytesReceived(received int64) {
|
||||
}
|
||||
}
|
||||
|
||||
// ProgressWriter wraps io.Writer to track download progress
|
||||
// ProgressWriter wraps io.Writer to track download progress (legacy single)
|
||||
type ProgressWriter struct {
|
||||
writer interface{ Write([]byte) (int, error) }
|
||||
total int64
|
||||
@@ -110,7 +218,6 @@ type ProgressWriter struct {
|
||||
|
||||
// NewProgressWriter creates a new progress writer wrapping an io.Writer
|
||||
func NewProgressWriter(w interface{ Write([]byte) (int, error) }) *ProgressWriter {
|
||||
// Reset bytes received when starting new download
|
||||
SetBytesReceived(0)
|
||||
return &ProgressWriter{
|
||||
writer: w,
|
||||
@@ -135,3 +242,30 @@ func (pw *ProgressWriter) Write(p []byte) (int, error) {
|
||||
func (pw *ProgressWriter) GetTotal() int64 {
|
||||
return pw.total
|
||||
}
|
||||
|
||||
// ItemProgressWriter wraps io.Writer to track download progress for a specific item
|
||||
type ItemProgressWriter struct {
|
||||
writer interface{ Write([]byte) (int, error) }
|
||||
itemID string
|
||||
current int64
|
||||
}
|
||||
|
||||
// NewItemProgressWriter creates a new progress writer for a specific item
|
||||
func NewItemProgressWriter(w interface{ Write([]byte) (int, error) }, itemID string) *ItemProgressWriter {
|
||||
return &ItemProgressWriter{
|
||||
writer: w,
|
||||
itemID: itemID,
|
||||
current: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// Write implements io.Writer
|
||||
func (pw *ItemProgressWriter) Write(p []byte) (int, error) {
|
||||
n, err := pw.writer.Write(p)
|
||||
if err != nil {
|
||||
return n, err
|
||||
}
|
||||
pw.current += int64(n)
|
||||
SetItemBytesReceived(pw.itemID, pw.current)
|
||||
return n, nil
|
||||
}
|
||||
|
||||
@@ -261,12 +261,18 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string,
|
||||
}
|
||||
|
||||
// DownloadFile downloads a file from URL with User-Agent and progress tracking
|
||||
func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath string) error {
|
||||
// Set current file being downloaded
|
||||
func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
|
||||
// Set current file being downloaded (legacy)
|
||||
SetCurrentFile(filepath.Base(outputPath))
|
||||
SetDownloading(true)
|
||||
defer SetDownloading(false)
|
||||
|
||||
// Initialize item progress if itemID provided
|
||||
if itemID != "" {
|
||||
StartItemProgress(itemID)
|
||||
defer CompleteItemProgress(itemID)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", downloadURL, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
@@ -285,6 +291,9 @@ func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath string) error {
|
||||
// Set total bytes if available
|
||||
if resp.ContentLength > 0 {
|
||||
SetBytesTotal(resp.ContentLength)
|
||||
if itemID != "" {
|
||||
SetItemBytesTotal(itemID, resp.ContentLength)
|
||||
}
|
||||
}
|
||||
|
||||
out, err := os.Create(outputPath)
|
||||
@@ -293,9 +302,14 @@ func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath string) error {
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
// Use ProgressWriter for tracking
|
||||
progressWriter := NewProgressWriter(out)
|
||||
_, err = io.Copy(progressWriter, resp.Body)
|
||||
// Use appropriate progress writer
|
||||
if itemID != "" {
|
||||
progressWriter := NewItemProgressWriter(out, itemID)
|
||||
_, err = io.Copy(progressWriter, resp.Body)
|
||||
} else {
|
||||
progressWriter := NewProgressWriter(out)
|
||||
_, err = io.Copy(progressWriter, resp.Body)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -346,14 +360,28 @@ func downloadFromQobuz(req DownloadRequest) (string, error) {
|
||||
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
|
||||
downloadURL, err := downloader.GetDownloadURL(track.ID, "27") // 27 = FLAC 24-bit
|
||||
downloadURL, err := downloader.GetDownloadURL(track.ID, qobuzQuality)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get download URL: %w", err)
|
||||
}
|
||||
|
||||
// Download file
|
||||
if err := downloader.DownloadFile(downloadURL, outputPath); err != nil {
|
||||
// Download file with item ID for progress tracking
|
||||
if err := downloader.DownloadFile(downloadURL, outputPath, req.ItemID); err != nil {
|
||||
return "", fmt.Errorf("download failed: %w", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -640,17 +640,23 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
|
||||
|
||||
|
||||
// DownloadFile downloads a file from URL with progress tracking
|
||||
func (t *TidalDownloader) DownloadFile(downloadURL, outputPath string) error {
|
||||
func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
|
||||
// Handle manifest-based download
|
||||
if strings.HasPrefix(downloadURL, "MANIFEST:") {
|
||||
return t.downloadFromManifest(strings.TrimPrefix(downloadURL, "MANIFEST:"), outputPath)
|
||||
return t.downloadFromManifest(strings.TrimPrefix(downloadURL, "MANIFEST:"), outputPath, itemID)
|
||||
}
|
||||
|
||||
// Set current file being downloaded
|
||||
// Set current file being downloaded (legacy)
|
||||
SetCurrentFile(filepath.Base(outputPath))
|
||||
SetDownloading(true)
|
||||
defer SetDownloading(false)
|
||||
|
||||
// Initialize item progress if itemID provided
|
||||
if itemID != "" {
|
||||
StartItemProgress(itemID)
|
||||
defer CompleteItemProgress(itemID)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", downloadURL, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
@@ -669,6 +675,9 @@ func (t *TidalDownloader) DownloadFile(downloadURL, outputPath string) error {
|
||||
// Set total bytes if available
|
||||
if resp.ContentLength > 0 {
|
||||
SetBytesTotal(resp.ContentLength)
|
||||
if itemID != "" {
|
||||
SetItemBytesTotal(itemID, resp.ContentLength)
|
||||
}
|
||||
}
|
||||
|
||||
out, err := os.Create(outputPath)
|
||||
@@ -677,13 +686,18 @@ func (t *TidalDownloader) DownloadFile(downloadURL, outputPath string) error {
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
// Use ProgressWriter for tracking
|
||||
progressWriter := NewProgressWriter(out)
|
||||
_, err = io.Copy(progressWriter, resp.Body)
|
||||
// Use appropriate progress writer
|
||||
if itemID != "" {
|
||||
progressWriter := NewItemProgressWriter(out, itemID)
|
||||
_, err = io.Copy(progressWriter, resp.Body)
|
||||
} else {
|
||||
progressWriter := NewProgressWriter(out)
|
||||
_, err = io.Copy(progressWriter, resp.Body)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath string) error {
|
||||
func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID string) error {
|
||||
directURL, initURL, mediaURLs, err := parseManifest(manifestB64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse manifest: %w", err)
|
||||
@@ -695,11 +709,17 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath string) e
|
||||
|
||||
// If we have a direct URL (BTS format), download directly with progress tracking
|
||||
if directURL != "" {
|
||||
// Set current file being downloaded
|
||||
// Set current file being downloaded (legacy)
|
||||
SetCurrentFile(filepath.Base(outputPath))
|
||||
SetDownloading(true)
|
||||
defer SetDownloading(false)
|
||||
|
||||
// Initialize item progress if itemID provided
|
||||
if itemID != "" {
|
||||
StartItemProgress(itemID)
|
||||
defer CompleteItemProgress(itemID)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", directURL, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
@@ -718,6 +738,9 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath string) e
|
||||
// Set total bytes for progress tracking
|
||||
if resp.ContentLength > 0 {
|
||||
SetBytesTotal(resp.ContentLength)
|
||||
if itemID != "" {
|
||||
SetItemBytesTotal(itemID, resp.ContentLength)
|
||||
}
|
||||
}
|
||||
|
||||
out, err := os.Create(outputPath)
|
||||
@@ -726,9 +749,14 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath string) e
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
// Use ProgressWriter for tracking
|
||||
progressWriter := NewProgressWriter(out)
|
||||
_, err = io.Copy(progressWriter, resp.Body)
|
||||
// Use appropriate progress writer
|
||||
if itemID != "" {
|
||||
progressWriter := NewItemProgressWriter(out, itemID)
|
||||
_, err = io.Copy(progressWriter, resp.Body)
|
||||
} else {
|
||||
progressWriter := NewProgressWriter(out)
|
||||
_, err = io.Copy(progressWriter, resp.Body)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -859,14 +887,21 @@ func downloadFromTidal(req DownloadRequest) (string, error) {
|
||||
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
|
||||
downloadURL, err := downloader.GetDownloadURL(track.ID, "LOSSLESS")
|
||||
downloadURL, err := downloader.GetDownloadURL(track.ID, quality)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get download URL: %w", err)
|
||||
}
|
||||
|
||||
// Download file
|
||||
if err := downloader.DownloadFile(downloadURL, outputPath); err != nil {
|
||||
// Download file with item ID for progress tracking
|
||||
if err := downloader.DownloadFile(downloadURL, outputPath, req.ItemID); err != nil {
|
||||
return "", fmt.Errorf("download failed: %w", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -90,6 +90,28 @@ import Gobackend // Import Go framework
|
||||
let response = GobackendGetDownloadProgress()
|
||||
return response
|
||||
|
||||
case "getAllDownloadProgress":
|
||||
let response = GobackendGetAllDownloadProgress()
|
||||
return response
|
||||
|
||||
case "initItemProgress":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let itemId = args["item_id"] as! String
|
||||
GobackendInitItemProgress(itemId)
|
||||
return nil
|
||||
|
||||
case "finishItemProgress":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let itemId = args["item_id"] as! String
|
||||
GobackendFinishItemProgress(itemId)
|
||||
return nil
|
||||
|
||||
case "clearItemProgress":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let itemId = args["item_id"] as! String
|
||||
GobackendClearItemProgress(itemId)
|
||||
return nil
|
||||
|
||||
case "setDownloadDirectory":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let path = args["path"] as! String
|
||||
@@ -145,6 +167,10 @@ import Gobackend // Import Go framework
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "cleanupConnections":
|
||||
GobackendCleanupConnections()
|
||||
return nil
|
||||
|
||||
default:
|
||||
throw NSError(
|
||||
domain: "SpotiFLAC",
|
||||
|
||||
@@ -7,10 +7,11 @@ import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/theme/dynamic_color_wrapper.dart';
|
||||
|
||||
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(
|
||||
initialLocation: settings.isFirstLaunch ? '/setup' : '/',
|
||||
initialLocation: isFirstLaunch ? '/setup' : '/',
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/',
|
||||
|
||||
@@ -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.5.0-hotfix6';
|
||||
static const String buildNumber = '20';
|
||||
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';
|
||||
}
|
||||
@@ -1,12 +1,33 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/app.dart';
|
||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||
import 'package:spotiflac_android/services/notification_service.dart';
|
||||
|
||||
void main() {
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
// Initialize notification service
|
||||
await NotificationService().initialize();
|
||||
|
||||
runApp(
|
||||
const ProviderScope(
|
||||
child: SpotiFLACApp(),
|
||||
ProviderScope(
|
||||
child: const _EagerInitialization(
|
||||
child: SpotiFLACApp(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Widget to eagerly initialize providers that need to load data on startup
|
||||
class _EagerInitialization extends ConsumerWidget {
|
||||
const _EagerInitialization({required this.child});
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
// Eagerly initialize download history provider to load from storage
|
||||
ref.watch(downloadHistoryProvider);
|
||||
return child;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ class AppSettings {
|
||||
final bool maxQualityCover;
|
||||
final bool isFirstLaunch;
|
||||
final int concurrentDownloads; // 1 = sequential (default), max 3
|
||||
final bool checkForUpdates; // Check for updates on app start
|
||||
|
||||
const AppSettings({
|
||||
this.defaultService = 'tidal',
|
||||
@@ -24,6 +25,7 @@ class AppSettings {
|
||||
this.maxQualityCover = true,
|
||||
this.isFirstLaunch = true,
|
||||
this.concurrentDownloads = 1, // Default: sequential (off)
|
||||
this.checkForUpdates = true, // Default: enabled
|
||||
});
|
||||
|
||||
AppSettings copyWith({
|
||||
@@ -36,6 +38,7 @@ class AppSettings {
|
||||
bool? maxQualityCover,
|
||||
bool? isFirstLaunch,
|
||||
int? concurrentDownloads,
|
||||
bool? checkForUpdates,
|
||||
}) {
|
||||
return AppSettings(
|
||||
defaultService: defaultService ?? this.defaultService,
|
||||
@@ -47,6 +50,7 @@ class AppSettings {
|
||||
maxQualityCover: maxQualityCover ?? this.maxQualityCover,
|
||||
isFirstLaunch: isFirstLaunch ?? this.isFirstLaunch,
|
||||
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,
|
||||
isFirstLaunch: json['isFirstLaunch'] as bool? ?? true,
|
||||
concurrentDownloads: (json['concurrentDownloads'] as num?)?.toInt() ?? 1,
|
||||
checkForUpdates: json['checkForUpdates'] as bool? ?? true,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
||||
@@ -29,4 +30,5 @@ Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
||||
'maxQualityCover': instance.maxQualityCover,
|
||||
'isFirstLaunch': instance.isFirstLaunch,
|
||||
'concurrentDownloads': instance.concurrentDownloads,
|
||||
'checkForUpdates': instance.checkForUpdates,
|
||||
};
|
||||
|
||||
@@ -9,8 +9,10 @@ import 'package:ffmpeg_kit_flutter_new_audio/return_code.dart';
|
||||
import 'package:spotiflac_android/models/download_item.dart';
|
||||
import 'package:spotiflac_android/models/settings.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/ffmpeg_service.dart';
|
||||
import 'package:spotiflac_android/services/notification_service.dart';
|
||||
|
||||
// Download History Item model
|
||||
class DownloadHistoryItem {
|
||||
@@ -18,20 +20,37 @@ class DownloadHistoryItem {
|
||||
final String trackName;
|
||||
final String artistName;
|
||||
final String albumName;
|
||||
final String? albumArtist;
|
||||
final String? coverUrl;
|
||||
final String filePath;
|
||||
final String service;
|
||||
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({
|
||||
required this.id,
|
||||
required this.trackName,
|
||||
required this.artistName,
|
||||
required this.albumName,
|
||||
this.albumArtist,
|
||||
this.coverUrl,
|
||||
required this.filePath,
|
||||
required this.service,
|
||||
required this.downloadedAt,
|
||||
this.isrc,
|
||||
this.spotifyId,
|
||||
this.trackNumber,
|
||||
this.discNumber,
|
||||
this.duration,
|
||||
this.releaseDate,
|
||||
this.quality,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
@@ -39,10 +58,18 @@ class DownloadHistoryItem {
|
||||
'trackName': trackName,
|
||||
'artistName': artistName,
|
||||
'albumName': albumName,
|
||||
'albumArtist': albumArtist,
|
||||
'coverUrl': coverUrl,
|
||||
'filePath': filePath,
|
||||
'service': service,
|
||||
'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(
|
||||
@@ -50,10 +77,18 @@ class DownloadHistoryItem {
|
||||
trackName: json['trackName'] as String,
|
||||
artistName: json['artistName'] as String,
|
||||
albumName: json['albumName'] as String,
|
||||
albumArtist: json['albumArtist'] as String?,
|
||||
coverUrl: json['coverUrl'] as String?,
|
||||
filePath: json['filePath'] as String,
|
||||
service: json['service'] 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?,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -71,22 +106,35 @@ class DownloadHistoryState {
|
||||
// Download History Notifier (Riverpod 3.x)
|
||||
class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||
static const _storageKey = 'download_history';
|
||||
bool _isLoaded = false;
|
||||
|
||||
@override
|
||||
DownloadHistoryState build() {
|
||||
// Load history from storage on init
|
||||
Future.microtask(() => _loadFromStorage());
|
||||
_loadFromStorageSync();
|
||||
return const DownloadHistoryState();
|
||||
}
|
||||
|
||||
/// Synchronously schedule load - ensures it runs before any UI renders
|
||||
void _loadFromStorageSync() {
|
||||
if (_isLoaded) return;
|
||||
Future.microtask(() async {
|
||||
await _loadFromStorage();
|
||||
_isLoaded = true;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _loadFromStorage() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final jsonStr = prefs.getString(_storageKey);
|
||||
if (jsonStr != null) {
|
||||
if (jsonStr != null && jsonStr.isNotEmpty) {
|
||||
final List<dynamic> jsonList = jsonDecode(jsonStr);
|
||||
final items = jsonList.map((e) => DownloadHistoryItem.fromJson(e as Map<String, dynamic>)).toList();
|
||||
state = state.copyWith(items: items);
|
||||
print('[DownloadHistory] Loaded ${items.length} items from storage');
|
||||
} else {
|
||||
print('[DownloadHistory] No history found in storage');
|
||||
}
|
||||
} catch (e) {
|
||||
print('[DownloadHistory] Failed to load history: $e');
|
||||
@@ -98,11 +146,17 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final jsonList = state.items.map((e) => e.toJson()).toList();
|
||||
await prefs.setString(_storageKey, jsonEncode(jsonList));
|
||||
print('[DownloadHistory] Saved ${state.items.length} items to storage');
|
||||
} catch (e) {
|
||||
print('[DownloadHistory] Failed to save history: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Force reload from storage (useful after app restart)
|
||||
Future<void> reloadFromStorage() async {
|
||||
await _loadFromStorage();
|
||||
}
|
||||
|
||||
void addToHistory(DownloadHistoryItem item) {
|
||||
state = state.copyWith(items: [item, ...state.items]);
|
||||
_saveToStorage();
|
||||
@@ -130,8 +184,10 @@ class DownloadQueueState {
|
||||
final List<DownloadItem> items;
|
||||
final DownloadItem? currentDownload;
|
||||
final bool isProcessing;
|
||||
final bool isPaused; // NEW: pause state
|
||||
final String outputDir;
|
||||
final String filenameFormat;
|
||||
final String audioQuality; // LOSSLESS, HI_RES, HI_RES_LOSSLESS
|
||||
final bool autoFallback;
|
||||
final int concurrentDownloads; // 1 = sequential, max 3
|
||||
|
||||
@@ -139,8 +195,10 @@ class DownloadQueueState {
|
||||
this.items = const [],
|
||||
this.currentDownload,
|
||||
this.isProcessing = false,
|
||||
this.isPaused = false,
|
||||
this.outputDir = '',
|
||||
this.filenameFormat = '{artist} - {title}',
|
||||
this.audioQuality = 'LOSSLESS',
|
||||
this.autoFallback = true,
|
||||
this.concurrentDownloads = 1,
|
||||
});
|
||||
@@ -149,8 +207,10 @@ class DownloadQueueState {
|
||||
List<DownloadItem>? items,
|
||||
DownloadItem? currentDownload,
|
||||
bool? isProcessing,
|
||||
bool? isPaused,
|
||||
String? outputDir,
|
||||
String? filenameFormat,
|
||||
String? audioQuality,
|
||||
bool? autoFallback,
|
||||
int? concurrentDownloads,
|
||||
}) {
|
||||
@@ -158,8 +218,10 @@ class DownloadQueueState {
|
||||
items: items ?? this.items,
|
||||
currentDownload: currentDownload ?? this.currentDownload,
|
||||
isProcessing: isProcessing ?? this.isProcessing,
|
||||
isPaused: isPaused ?? this.isPaused,
|
||||
outputDir: outputDir ?? this.outputDir,
|
||||
filenameFormat: filenameFormat ?? this.filenameFormat,
|
||||
audioQuality: audioQuality ?? this.audioQuality,
|
||||
autoFallback: autoFallback ?? this.autoFallback,
|
||||
concurrentDownloads: concurrentDownloads ?? this.concurrentDownloads,
|
||||
);
|
||||
@@ -176,6 +238,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
Timer? _progressTimer;
|
||||
int _downloadCount = 0; // Counter for connection cleanup
|
||||
static const _cleanupInterval = 50; // Cleanup every 50 downloads
|
||||
final NotificationService _notificationService = NotificationService();
|
||||
int _totalQueuedAtStart = 0; // Track total items when queue started
|
||||
|
||||
@override
|
||||
DownloadQueueState build() {
|
||||
@@ -199,6 +263,17 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
final percentage = bytesReceived / bytesTotal;
|
||||
updateProgress(itemId, percentage);
|
||||
|
||||
// Update notification with progress
|
||||
final currentItem = state.currentDownload;
|
||||
if (currentItem != null) {
|
||||
_notificationService.showDownloadProgress(
|
||||
trackName: currentItem.track.name,
|
||||
artistName: currentItem.track.artistName,
|
||||
progress: bytesReceived,
|
||||
total: bytesTotal,
|
||||
);
|
||||
}
|
||||
|
||||
// Log progress
|
||||
final mbReceived = bytesReceived / (1024 * 1024);
|
||||
final mbTotal = bytesTotal / (1024 * 1024);
|
||||
@@ -210,6 +285,56 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
});
|
||||
}
|
||||
|
||||
/// Start multi-progress polling for concurrent downloads
|
||||
void _startMultiProgressPolling() {
|
||||
_progressTimer?.cancel();
|
||||
_progressTimer = Timer.periodic(const Duration(milliseconds: 500), (timer) async {
|
||||
try {
|
||||
final allProgress = await PlatformBridge.getAllDownloadProgress();
|
||||
final items = allProgress['items'] as Map<String, dynamic>? ?? {};
|
||||
|
||||
for (final entry in items.entries) {
|
||||
final itemId = entry.key;
|
||||
final itemProgress = entry.value as Map<String, dynamic>;
|
||||
final bytesReceived = itemProgress['bytes_received'] as int? ?? 0;
|
||||
final bytesTotal = itemProgress['bytes_total'] as int? ?? 0;
|
||||
final isDownloading = itemProgress['is_downloading'] as bool? ?? false;
|
||||
|
||||
if (isDownloading && bytesTotal > 0) {
|
||||
final percentage = bytesReceived / bytesTotal;
|
||||
updateProgress(itemId, percentage);
|
||||
|
||||
// Log progress for each item
|
||||
final mbReceived = bytesReceived / (1024 * 1024);
|
||||
final mbTotal = bytesTotal / (1024 * 1024);
|
||||
print('[DownloadQueue] Progress [$itemId]: ${(percentage * 100).toStringAsFixed(1)}% (${mbReceived.toStringAsFixed(2)}/${mbTotal.toStringAsFixed(2)} MB)');
|
||||
}
|
||||
}
|
||||
|
||||
// Update notification with first active download
|
||||
if (items.isNotEmpty) {
|
||||
final firstEntry = items.entries.first;
|
||||
final firstProgress = firstEntry.value as Map<String, dynamic>;
|
||||
final bytesReceived = firstProgress['bytes_received'] as int? ?? 0;
|
||||
final bytesTotal = firstProgress['bytes_total'] as int? ?? 0;
|
||||
|
||||
// Find the item to get track info
|
||||
final downloadingItems = state.items.where((i) => i.status == DownloadStatus.downloading).toList();
|
||||
if (downloadingItems.isNotEmpty) {
|
||||
_notificationService.showDownloadProgress(
|
||||
trackName: '${downloadingItems.length} downloads',
|
||||
artistName: 'Downloading...',
|
||||
progress: bytesReceived,
|
||||
total: bytesTotal > 0 ? bytesTotal : 1,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore polling errors
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _stopProgressPolling() {
|
||||
_progressTimer?.cancel();
|
||||
_progressTimer = null;
|
||||
@@ -265,12 +390,17 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
state = state.copyWith(
|
||||
outputDir: settings.downloadDirectory.isNotEmpty ? settings.downloadDirectory : state.outputDir,
|
||||
filenameFormat: settings.filenameFormat,
|
||||
audioQuality: settings.audioQuality,
|
||||
autoFallback: settings.autoFallback,
|
||||
concurrentDownloads: settings.concurrentDownloads,
|
||||
);
|
||||
}
|
||||
|
||||
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 item = DownloadItem(
|
||||
id: id,
|
||||
@@ -290,6 +420,10 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
}
|
||||
|
||||
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 id = '${track.isrc ?? track.id}-${DateTime.now().millisecondsSinceEpoch}';
|
||||
return DownloadItem(
|
||||
@@ -343,7 +477,59 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
}
|
||||
|
||||
void clearAll() {
|
||||
state = const DownloadQueueState();
|
||||
state = state.copyWith(items: [], isPaused: false);
|
||||
}
|
||||
|
||||
/// Pause the download queue
|
||||
void pauseQueue() {
|
||||
if (state.isProcessing && !state.isPaused) {
|
||||
state = state.copyWith(isPaused: true);
|
||||
_notificationService.cancelDownloadNotification();
|
||||
print('[DownloadQueue] Queue paused');
|
||||
}
|
||||
}
|
||||
|
||||
/// Resume the download queue
|
||||
void resumeQueue() {
|
||||
if (state.isPaused) {
|
||||
state = state.copyWith(isPaused: false);
|
||||
print('[DownloadQueue] Queue resumed');
|
||||
// If there are still queued items, continue processing
|
||||
if (state.queuedCount > 0 && !state.isProcessing) {
|
||||
Future.microtask(() => _processQueue());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Toggle pause/resume
|
||||
void togglePause() {
|
||||
if (state.isPaused) {
|
||||
resumeQueue();
|
||||
} else {
|
||||
pauseQueue();
|
||||
}
|
||||
}
|
||||
|
||||
/// Retry a failed download
|
||||
void retryItem(String id) {
|
||||
final items = state.items.map((item) {
|
||||
if (item.id == id && item.status == DownloadStatus.failed) {
|
||||
return item.copyWith(status: DownloadStatus.queued, progress: 0, error: null);
|
||||
}
|
||||
return item;
|
||||
}).toList();
|
||||
state = state.copyWith(items: items);
|
||||
|
||||
// Start processing if not already
|
||||
if (!state.isProcessing) {
|
||||
Future.microtask(() => _processQueue());
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a specific item from queue
|
||||
void removeItem(String id) {
|
||||
final items = state.items.where((item) => item.id != id).toList();
|
||||
state = state.copyWith(items: items);
|
||||
}
|
||||
|
||||
/// Embed metadata and cover to a FLAC file after M4A conversion
|
||||
@@ -416,6 +602,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
state = state.copyWith(isProcessing: true);
|
||||
print('[DownloadQueue] Starting queue processing...');
|
||||
|
||||
// Track total items at start for notification
|
||||
_totalQueuedAtStart = state.items.where((i) => i.status == DownloadStatus.queued).length;
|
||||
|
||||
// Ensure output directory is initialized before processing
|
||||
if (state.outputDir.isEmpty) {
|
||||
print('[DownloadQueue] Output dir empty, initializing...');
|
||||
@@ -456,6 +645,16 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
_downloadCount = 0;
|
||||
}
|
||||
|
||||
// Show queue completion notification
|
||||
final completedCount = state.completedCount;
|
||||
final failedCount = state.failedCount;
|
||||
if (_totalQueuedAtStart > 0) {
|
||||
await _notificationService.showQueueComplete(
|
||||
completedCount: completedCount,
|
||||
failedCount: failedCount,
|
||||
);
|
||||
}
|
||||
|
||||
print('[DownloadQueue] Queue processing finished');
|
||||
state = state.copyWith(isProcessing: false, currentDownload: null);
|
||||
}
|
||||
@@ -463,6 +662,13 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
/// Sequential download processing (original behavior)
|
||||
Future<void> _processQueueSequential() async {
|
||||
while (true) {
|
||||
// Check if paused
|
||||
if (state.isPaused) {
|
||||
print('[DownloadQueue] Queue is paused, waiting...');
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
continue;
|
||||
}
|
||||
|
||||
final nextItem = state.items.firstWhere(
|
||||
(item) => item.status == DownloadStatus.queued,
|
||||
orElse: () => DownloadItem(
|
||||
@@ -487,7 +693,21 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
final maxConcurrent = state.concurrentDownloads;
|
||||
final activeDownloads = <String, Future<void>>{}; // Map item ID to future
|
||||
|
||||
// Start multi-progress polling for concurrent downloads
|
||||
_startMultiProgressPolling();
|
||||
|
||||
while (true) {
|
||||
// Check if paused - don't start new downloads but let active ones finish
|
||||
if (state.isPaused) {
|
||||
print('[DownloadQueue] Queue is paused, waiting for active downloads...');
|
||||
if (activeDownloads.isNotEmpty) {
|
||||
await Future.any(activeDownloads.values);
|
||||
} else {
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get queued items
|
||||
final queuedItems = state.items.where((item) => item.status == DownloadStatus.queued).toList();
|
||||
|
||||
@@ -497,7 +717,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
}
|
||||
|
||||
// Start new downloads up to max concurrent limit
|
||||
while (activeDownloads.length < maxConcurrent && queuedItems.isNotEmpty) {
|
||||
while (activeDownloads.length < maxConcurrent && queuedItems.isNotEmpty && !state.isPaused) {
|
||||
final item = queuedItems.removeAt(0);
|
||||
|
||||
// Mark as downloading immediately to prevent double-processing
|
||||
@@ -506,6 +726,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
// Create the download future
|
||||
final future = _downloadSingleItem(item).whenComplete(() {
|
||||
activeDownloads.remove(item.id);
|
||||
// Clear item progress after download completes
|
||||
PlatformBridge.clearItemProgress(item.id).catchError((_) {});
|
||||
});
|
||||
|
||||
activeDownloads[item.id] = future;
|
||||
@@ -542,6 +764,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
|
||||
if (state.autoFallback) {
|
||||
print('[DownloadQueue] Using auto-fallback mode');
|
||||
print('[DownloadQueue] Quality: ${state.audioQuality}');
|
||||
result = await PlatformBridge.downloadWithFallback(
|
||||
isrc: item.track.isrc ?? '',
|
||||
spotifyId: item.track.id,
|
||||
@@ -552,10 +775,12 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
coverUrl: item.track.coverUrl,
|
||||
outputDir: state.outputDir,
|
||||
filenameFormat: state.filenameFormat,
|
||||
quality: state.audioQuality,
|
||||
trackNumber: item.track.trackNumber ?? 1,
|
||||
discNumber: item.track.discNumber ?? 1,
|
||||
releaseDate: item.track.releaseDate,
|
||||
preferredService: item.service,
|
||||
itemId: item.id, // Pass item ID for progress tracking
|
||||
);
|
||||
} else {
|
||||
result = await PlatformBridge.downloadTrack(
|
||||
@@ -569,9 +794,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
coverUrl: item.track.coverUrl,
|
||||
outputDir: state.outputDir,
|
||||
filenameFormat: state.filenameFormat,
|
||||
quality: state.audioQuality,
|
||||
trackNumber: item.track.trackNumber ?? 1,
|
||||
discNumber: item.track.discNumber ?? 1,
|
||||
releaseDate: item.track.releaseDate,
|
||||
itemId: item.id, // Pass item ID for progress tracking
|
||||
);
|
||||
}
|
||||
|
||||
@@ -616,6 +843,14 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
filePath: filePath,
|
||||
);
|
||||
|
||||
// Show completion notification for this track
|
||||
await _notificationService.showDownloadComplete(
|
||||
trackName: item.track.name,
|
||||
artistName: item.track.artistName,
|
||||
completedCount: state.completedCount,
|
||||
totalCount: _totalQueuedAtStart,
|
||||
);
|
||||
|
||||
if (filePath != null) {
|
||||
ref.read(downloadHistoryProvider.notifier).addToHistory(
|
||||
DownloadHistoryItem(
|
||||
@@ -623,10 +858,19 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
trackName: item.track.name,
|
||||
artistName: item.track.artistName,
|
||||
albumName: item.track.albumName,
|
||||
albumArtist: item.track.albumArtist,
|
||||
coverUrl: item.track.coverUrl,
|
||||
filePath: filePath,
|
||||
service: result['service'] as String? ?? item.service,
|
||||
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);
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setCheckForUpdates(bool enabled) {
|
||||
state = state.copyWith(checkForUpdates: enabled);
|
||||
_saveSettings();
|
||||
}
|
||||
}
|
||||
|
||||
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,37 +7,53 @@ import 'package:open_filex/open_filex.dart';
|
||||
import 'package:spotiflac_android/providers/track_provider.dart';
|
||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
|
||||
|
||||
class HomeTab extends ConsumerStatefulWidget {
|
||||
const HomeTab({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<HomeTab> createState() => _HomeTabState();
|
||||
}
|
||||
|
||||
class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClientMixin {
|
||||
final _urlController = TextEditingController();
|
||||
|
||||
final Map<String, bool> _fileExistsCache = {}; // Cache file existence
|
||||
|
||||
@override
|
||||
bool get wantKeepAlive => true;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_urlController.dispose();
|
||||
super.dispose();
|
||||
void dispose() { _urlController.dispose(); super.dispose(); }
|
||||
|
||||
/// Check if file exists with caching to avoid blocking main thread
|
||||
bool _checkFileExists(String filePath) {
|
||||
if (_fileExistsCache.containsKey(filePath)) {
|
||||
return _fileExistsCache[filePath]!;
|
||||
}
|
||||
// Schedule async check and return false for now
|
||||
Future.microtask(() async {
|
||||
final exists = await File(filePath).exists();
|
||||
if (mounted && _fileExistsCache[filePath] != exists) {
|
||||
setState(() => _fileExistsCache[filePath] = exists);
|
||||
}
|
||||
});
|
||||
_fileExistsCache[filePath] = false; // Assume false until checked
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<void> _pasteFromClipboard() async {
|
||||
final data = await Clipboard.getData(Clipboard.kTextPlain);
|
||||
if (data?.text != null) {
|
||||
_urlController.text = data!.text!;
|
||||
}
|
||||
if (data?.text != null) _urlController.text = data!.text!;
|
||||
}
|
||||
|
||||
Future<void> _clearAndRefresh() async {
|
||||
_urlController.clear();
|
||||
ref.read(trackProvider.notifier).clear();
|
||||
await Future.delayed(const Duration(milliseconds: 300));
|
||||
}
|
||||
|
||||
Future<void> _fetchMetadata() async {
|
||||
final url = _urlController.text.trim();
|
||||
if (url.isEmpty) return;
|
||||
|
||||
if (url.startsWith('http') || url.startsWith('spotify:')) {
|
||||
await ref.read(trackProvider.notifier).fetchFromUrl(url);
|
||||
} else {
|
||||
@@ -51,35 +67,21 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
final track = trackState.tracks[index];
|
||||
final settings = ref.read(settingsProvider);
|
||||
ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Added "${track.name}" to queue')),
|
||||
);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
|
||||
}
|
||||
}
|
||||
|
||||
void _downloadAll() {
|
||||
final trackState = ref.read(trackProvider);
|
||||
if (trackState.tracks.isEmpty) return;
|
||||
|
||||
final settings = ref.read(settingsProvider);
|
||||
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(
|
||||
trackState.tracks,
|
||||
settings.defaultService,
|
||||
);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Added ${trackState.tracks.length} tracks to queue')),
|
||||
);
|
||||
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(trackState.tracks, settings.defaultService);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${trackState.tracks.length} tracks to queue')));
|
||||
}
|
||||
|
||||
Future<void> _openFile(String filePath) async {
|
||||
try {
|
||||
await OpenFilex.open(filePath);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Cannot open file: $e')),
|
||||
);
|
||||
}
|
||||
try { await OpenFilex.open(filePath); } catch (e) {
|
||||
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Cannot open file: $e')));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,138 +92,173 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
final historyState = ref.watch(downloadHistoryProvider);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return CustomScrollView(
|
||||
slivers: [
|
||||
// Search bar
|
||||
return RefreshIndicator(
|
||||
onRefresh: _clearAndRefresh,
|
||||
displacement: 100,
|
||||
child: CustomScrollView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
slivers: [
|
||||
// Collapsing App Bar - Simplified for performance
|
||||
SliverAppBar(
|
||||
expandedHeight: 100,
|
||||
collapsedHeight: kToolbarHeight,
|
||||
floating: false,
|
||||
pinned: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
automaticallyImplyLeading: false,
|
||||
flexibleSpace: FlexibleSpaceBar(
|
||||
expandedTitleScale: 1.4,
|
||||
titlePadding: const EdgeInsets.only(left: 24, bottom: 16),
|
||||
title: Text(
|
||||
'Home',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Search bar - Simple TextField with border
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
|
||||
child: TextField(
|
||||
controller: _urlController,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Paste Spotify URL or search...',
|
||||
filled: true,
|
||||
fillColor: colorScheme.surfaceContainerHighest,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
borderSide: BorderSide(color: colorScheme.outline.withOpacity(0.5)),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
borderSide: BorderSide(color: colorScheme.outline.withOpacity(0.5)),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
borderSide: BorderSide(color: colorScheme.primary, width: 2),
|
||||
),
|
||||
prefixIcon: const Icon(Icons.link),
|
||||
suffixIcon: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(icon: const Icon(Icons.paste), onPressed: _pasteFromClipboard),
|
||||
IconButton(icon: const Icon(Icons.search), onPressed: _fetchMetadata),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.paste),
|
||||
onPressed: _pasteFromClipboard,
|
||||
tooltip: 'Paste',
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: IconButton(
|
||||
icon: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primary,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(Icons.search, color: colorScheme.onPrimary, size: 20),
|
||||
),
|
||||
onPressed: _fetchMetadata,
|
||||
tooltip: 'Search',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
|
||||
),
|
||||
onSubmitted: (_) => _fetchMetadata(),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Helper text
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||
child: Text(
|
||||
'Supports: Track, Album, Playlist URLs',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Error message
|
||||
if (trackState.error != null)
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: Text(
|
||||
trackState.error!,
|
||||
style: TextStyle(color: colorScheme.error),
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Text(trackState.error!, style: TextStyle(color: colorScheme.error)),
|
||||
)),
|
||||
|
||||
// Loading indicator
|
||||
if (trackState.isLoading)
|
||||
const SliverToBoxAdapter(
|
||||
child: LinearProgressIndicator(),
|
||||
),
|
||||
const SliverToBoxAdapter(child: Padding(padding: EdgeInsets.symmetric(horizontal: 16), child: LinearProgressIndicator())),
|
||||
|
||||
// Album/Playlist header
|
||||
if (trackState.albumName != null || trackState.playlistName != null)
|
||||
SliverToBoxAdapter(child: _buildHeader(trackState, colorScheme)),
|
||||
|
||||
// Download All button (when no header)
|
||||
// Download All button
|
||||
if (trackState.tracks.length > 1 && trackState.albumName == null && trackState.playlistName == null)
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
|
||||
child: FilledButton.icon(
|
||||
onPressed: _downloadAll,
|
||||
icon: const Icon(Icons.download),
|
||||
label: Text('Download All (${trackState.tracks.length})'),
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(48),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: FilledButton.icon(onPressed: _downloadAll, icon: const Icon(Icons.download),
|
||||
label: Text('Download All (${trackState.tracks.length})'),
|
||||
style: FilledButton.styleFrom(minimumSize: const Size.fromHeight(48))),
|
||||
)),
|
||||
|
||||
// Track list
|
||||
if (trackState.tracks.isNotEmpty)
|
||||
SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) => _buildTrackTile(index, colorScheme),
|
||||
childCount: trackState.tracks.length,
|
||||
),
|
||||
),
|
||||
SliverList(delegate: SliverChildBuilderDelegate(
|
||||
(context, index) => _buildTrackTile(index, colorScheme),
|
||||
childCount: trackState.tracks.length,
|
||||
)),
|
||||
|
||||
// Divider between search results and history
|
||||
// Divider
|
||||
if (trackState.tracks.isNotEmpty && historyState.items.isNotEmpty)
|
||||
const SliverToBoxAdapter(
|
||||
child: Divider(height: 32),
|
||||
),
|
||||
const SliverToBoxAdapter(child: Divider(height: 32)),
|
||||
|
||||
// Recent Downloads section header
|
||||
// Recent Downloads header
|
||||
if (historyState.items.isNotEmpty)
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Recent Downloads',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => _showClearHistoryDialog(colorScheme),
|
||||
child: const Text('Clear'),
|
||||
),
|
||||
],
|
||||
),
|
||||
SliverToBoxAdapter(child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text('Recent Downloads', style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
color: colorScheme.primary, fontWeight: FontWeight.w600)),
|
||||
TextButton(onPressed: () => _showClearHistoryDialog(colorScheme), child: const Text('Clear')),
|
||||
],
|
||||
),
|
||||
),
|
||||
)),
|
||||
|
||||
// Recent Downloads list
|
||||
if (historyState.items.isNotEmpty)
|
||||
SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) => _buildHistoryTile(historyState.items[index], colorScheme),
|
||||
childCount: historyState.items.length > 5 ? 5 : historyState.items.length,
|
||||
),
|
||||
),
|
||||
SliverList(delegate: SliverChildBuilderDelegate(
|
||||
(context, index) => _buildHistoryTile(historyState.items[index], colorScheme),
|
||||
childCount: historyState.items.length > 10 ? 10 : historyState.items.length,
|
||||
)),
|
||||
|
||||
// Show more history button
|
||||
if (historyState.items.length > 5)
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: OutlinedButton(
|
||||
onPressed: () => _showAllHistory(colorScheme),
|
||||
child: Text('Show all ${historyState.items.length} downloads'),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Show more button
|
||||
if (historyState.items.length > 10)
|
||||
SliverToBoxAdapter(child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: OutlinedButton(onPressed: () => _showAllHistory(colorScheme),
|
||||
child: Text('Show all ${historyState.items.length} downloads')),
|
||||
)),
|
||||
|
||||
// Empty state (when no tracks and no history)
|
||||
// Empty state or fill remaining for scroll
|
||||
if (trackState.tracks.isEmpty && historyState.items.isEmpty)
|
||||
SliverFillRemaining(
|
||||
child: _buildEmptyState(colorScheme),
|
||||
),
|
||||
|
||||
// Bottom padding
|
||||
const SliverToBoxAdapter(
|
||||
child: SizedBox(height: 16),
|
||||
),
|
||||
SliverFillRemaining(hasScrollBody: false, child: _buildEmptyState(colorScheme))
|
||||
else
|
||||
const SliverFillRemaining(hasScrollBody: false, child: SizedBox()),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -233,52 +270,21 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
child: Row(
|
||||
children: [
|
||||
if (state.coverUrl != null)
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: state.coverUrl!,
|
||||
width: 80,
|
||||
height: 80,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (_, __) => Container(
|
||||
width: 80,
|
||||
height: 80,
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
),
|
||||
),
|
||||
ClipRRect(borderRadius: BorderRadius.circular(12),
|
||||
child: CachedNetworkImage(imageUrl: state.coverUrl!, width: 80, height: 80, fit: BoxFit.cover,
|
||||
placeholder: (_, __) => Container(width: 80, height: 80, color: colorScheme.surfaceContainerHighest))),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
state.albumName ?? state.playlistName ?? '',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${state.tracks.length} tracks',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Download all button
|
||||
FilledButton.tonal(
|
||||
onPressed: _downloadAll,
|
||||
style: FilledButton.styleFrom(
|
||||
shape: const CircleBorder(),
|
||||
padding: const EdgeInsets.all(16),
|
||||
),
|
||||
child: const Icon(Icons.download),
|
||||
),
|
||||
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(state.albumName ?? state.playlistName ?? '',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||
maxLines: 2, overflow: TextOverflow.ellipsis),
|
||||
const SizedBox(height: 4),
|
||||
Text('${state.tracks.length} tracks',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
|
||||
])),
|
||||
FilledButton.tonal(onPressed: _downloadAll,
|
||||
style: FilledButton.styleFrom(shape: const CircleBorder(), padding: const EdgeInsets.all(16)),
|
||||
child: const Icon(Icons.download)),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -289,168 +295,149 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
final track = ref.watch(trackProvider).tracks[index];
|
||||
return ListTile(
|
||||
leading: track.coverUrl != null
|
||||
? ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
? ClipRRect(borderRadius: BorderRadius.circular(8),
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: track.coverUrl!,
|
||||
width: 48,
|
||||
height: 48,
|
||||
imageUrl: track.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),
|
||||
),
|
||||
memCacheWidth: 96,
|
||||
memCacheHeight: 96,
|
||||
))
|
||||
: Container(width: 48, height: 48,
|
||||
decoration: BoxDecoration(color: colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(8)),
|
||||
child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant)),
|
||||
title: Text(track.name, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
subtitle: Text(
|
||||
track.artistName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
trailing: IconButton(
|
||||
icon: Icon(Icons.download, color: colorScheme.primary),
|
||||
onPressed: () => _downloadTrack(index),
|
||||
),
|
||||
subtitle: Text(track.artistName, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: colorScheme.onSurfaceVariant)),
|
||||
trailing: IconButton(icon: Icon(Icons.download, color: colorScheme.primary), onPressed: () => _downloadTrack(index)),
|
||||
onTap: () => _downloadTrack(index),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHistoryTile(DownloadHistoryItem item, ColorScheme colorScheme) {
|
||||
final fileExists = File(item.filePath).existsSync();
|
||||
|
||||
final fileExists = _checkFileExists(item.filePath);
|
||||
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),
|
||||
),
|
||||
leading: Hero(tag: 'cover_${item.id}',
|
||||
child: item.coverUrl != null
|
||||
? ClipRRect(borderRadius: BorderRadius.circular(8),
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: item.coverUrl!,
|
||||
width: 48,
|
||||
height: 48,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: 96,
|
||||
memCacheHeight: 96,
|
||||
))
|
||||
: 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),
|
||||
),
|
||||
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),
|
||||
)
|
||||
? IconButton(icon: Icon(Icons.play_arrow, color: colorScheme.primary), onPressed: () => _openFile(item.filePath))
|
||||
: Icon(Icons.error_outline, color: colorScheme.error, size: 20),
|
||||
onTap: fileExists ? () => _openFile(item.filePath) : null,
|
||||
onTap: () => _navigateToMetadataScreen(item),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyState(ColorScheme colorScheme) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.music_note,
|
||||
size: 64,
|
||||
Widget _buildEmptyState(ColorScheme colorScheme) => Center(child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primaryContainer.withOpacity(0.3),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(Icons.music_note, size: 48, color: colorScheme.primary),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'Ready to Download',
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Paste a Spotify link in the search bar above to get started',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Paste a Spotify URL to get started',
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
),
|
||||
],
|
||||
),
|
||||
));
|
||||
|
||||
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) => FadeTransition(opacity: animation, child: child),
|
||||
));
|
||||
}
|
||||
|
||||
void _showClearHistoryDialog(ColorScheme colorScheme) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Clear History'),
|
||||
content: const Text('Clear all download history?'),
|
||||
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)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
showDialog(context: context, builder: (context) => AlertDialog(
|
||||
title: const Text('Clear History'),
|
||||
content: const Text('Clear all download history?'),
|
||||
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))),
|
||||
],
|
||||
));
|
||||
}
|
||||
|
||||
void _showAllHistory(ColorScheme colorScheme) {
|
||||
final historyState = ref.read(downloadHistoryProvider);
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
showModalBottomSheet(context: context, isScrollControlled: true,
|
||||
builder: (context) => DraggableScrollableSheet(
|
||||
initialChildSize: 0.7,
|
||||
minChildSize: 0.5,
|
||||
maxChildSize: 0.95,
|
||||
expand: false,
|
||||
builder: (context, scrollController) => Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'All Downloads (${historyState.items.length})',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
controller: scrollController,
|
||||
itemCount: historyState.items.length,
|
||||
itemBuilder: (context, index) => _buildHistoryTile(
|
||||
historyState.items[index],
|
||||
colorScheme,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
initialChildSize: 0.7, minChildSize: 0.5, maxChildSize: 0.95, expand: false,
|
||||
builder: (context, scrollController) => Column(children: [
|
||||
Padding(padding: const EdgeInsets.all(16), child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text('All Downloads (${historyState.items.length})',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
|
||||
IconButton(icon: const Icon(Icons.close), onPressed: () => Navigator.pop(context)),
|
||||
],
|
||||
)),
|
||||
const Divider(height: 1),
|
||||
Expanded(child: ListView.builder(
|
||||
controller: scrollController,
|
||||
itemCount: historyState.items.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = historyState.items[index];
|
||||
final fileExists = _checkFileExists(item.filePath);
|
||||
return ListTile(
|
||||
leading: item.coverUrl != null
|
||||
? ClipRRect(borderRadius: BorderRadius.circular(8),
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: item.coverUrl!,
|
||||
width: 48,
|
||||
height: 48,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: 96,
|
||||
memCacheHeight: 96,
|
||||
))
|
||||
: 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); Future.delayed(const Duration(milliseconds: 100), () => _navigateToMetadataScreen(item)); },
|
||||
);
|
||||
},
|
||||
)),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.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/queue_tab.dart';
|
||||
import 'package:spotiflac_android/screens/settings_tab.dart';
|
||||
import 'package:spotiflac_android/screens/settings/settings_tab.dart';
|
||||
import 'package:spotiflac_android/services/update_checker.dart';
|
||||
import 'package:spotiflac_android/widgets/update_dialog.dart';
|
||||
|
||||
class MainShell extends ConsumerStatefulWidget {
|
||||
const MainShell({super.key});
|
||||
@@ -15,11 +18,35 @@ class MainShell extends ConsumerStatefulWidget {
|
||||
class _MainShellState extends ConsumerState<MainShell> {
|
||||
int _currentIndex = 0;
|
||||
late PageController _pageController;
|
||||
bool _hasCheckedUpdate = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_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
|
||||
@@ -29,37 +56,27 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
}
|
||||
|
||||
void _onNavTap(int index) {
|
||||
setState(() => _currentIndex = index);
|
||||
_pageController.animateToPage(
|
||||
index,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
if (_currentIndex != index) {
|
||||
setState(() => _currentIndex = index);
|
||||
_pageController.animateToPage(
|
||||
index,
|
||||
duration: const Duration(milliseconds: 250),
|
||||
curve: Curves.easeOutCubic,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _onPageChanged(int index) {
|
||||
setState(() => _currentIndex = index);
|
||||
if (_currentIndex != index) {
|
||||
setState(() => _currentIndex = index);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final queueState = ref.watch(downloadQueueProvider);
|
||||
final queueState = ref.watch(downloadQueueProvider.select((s) => s.queuedCount));
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: Image.asset(
|
||||
'assets/images/logo.png',
|
||||
width: 40,
|
||||
height: 40,
|
||||
),
|
||||
),
|
||||
),
|
||||
title: const Text('SpotiFLAC'),
|
||||
),
|
||||
body: PageView(
|
||||
controller: _pageController,
|
||||
onPageChanged: _onPageChanged,
|
||||
@@ -73,7 +90,7 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
bottomNavigationBar: NavigationBar(
|
||||
selectedIndex: _currentIndex,
|
||||
onDestinationSelected: _onNavTap,
|
||||
animationDuration: const Duration(milliseconds: 300),
|
||||
animationDuration: const Duration(milliseconds: 200),
|
||||
destinations: [
|
||||
const NavigationDestination(
|
||||
icon: Icon(Icons.home_outlined),
|
||||
@@ -82,13 +99,13 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Badge(
|
||||
isLabelVisible: queueState.queuedCount > 0,
|
||||
label: Text('${queueState.queuedCount}'),
|
||||
isLabelVisible: queueState > 0,
|
||||
label: Text('$queueState'),
|
||||
child: const Icon(Icons.download_outlined),
|
||||
),
|
||||
selectedIcon: Badge(
|
||||
isLabelVisible: queueState.queuedCount > 0,
|
||||
label: Text('${queueState.queuedCount}'),
|
||||
isLabelVisible: queueState > 0,
|
||||
label: Text('$queueState'),
|
||||
child: const Icon(Icons.download),
|
||||
),
|
||||
label: 'Downloads',
|
||||
|
||||
@@ -12,23 +12,108 @@ class QueueTab extends ConsumerWidget {
|
||||
final queueState = ref.watch(downloadQueueProvider);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
// Header with actions
|
||||
if (queueState.items.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'${queueState.items.length} items',
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
return CustomScrollView(
|
||||
slivers: [
|
||||
// Collapsing App Bar - Simplified for performance
|
||||
SliverAppBar(
|
||||
expandedHeight: 100,
|
||||
collapsedHeight: kToolbarHeight,
|
||||
floating: false,
|
||||
pinned: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
automaticallyImplyLeading: false,
|
||||
flexibleSpace: FlexibleSpaceBar(
|
||||
expandedTitleScale: 1.4,
|
||||
titlePadding: const EdgeInsets.only(left: 24, bottom: 16),
|
||||
title: Text(
|
||||
'Downloads',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Pause/Resume controls when downloading
|
||||
if (queueState.isProcessing || queueState.queuedCount > 0)
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
||||
child: Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Row(
|
||||
children: [
|
||||
// Status icon
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: queueState.isPaused
|
||||
? colorScheme.errorContainer
|
||||
: colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(
|
||||
queueState.isPaused ? Icons.pause : Icons.downloading,
|
||||
color: queueState.isPaused
|
||||
? colorScheme.onErrorContainer
|
||||
: colorScheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// Status text
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
queueState.isPaused ? 'Queue Paused' : 'Downloading...',
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${queueState.completedCount}/${queueState.items.length} completed',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Pause/Resume button
|
||||
FilledButton.tonal(
|
||||
onPressed: () => ref.read(downloadQueueProvider.notifier).togglePause(),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(queueState.isPaused ? Icons.play_arrow : Icons.pause, size: 20),
|
||||
const SizedBox(width: 4),
|
||||
Text(queueState.isPaused ? 'Resume' : 'Pause'),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Header with actions
|
||||
if (queueState.items.isNotEmpty)
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 8, 8),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text('${queueState.items.length} items',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
|
||||
Row(children: [
|
||||
TextButton.icon(
|
||||
onPressed: () => ref.read(downloadQueueProvider.notifier).clearCompleted(),
|
||||
icon: const Icon(Icons.done_all, size: 18),
|
||||
@@ -39,213 +124,247 @@ class QueueTab extends ConsumerWidget {
|
||||
icon: Icon(Icons.clear_all, size: 18, color: colorScheme.error),
|
||||
label: Text('Clear all', style: TextStyle(color: colorScheme.error)),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
]),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
|
||||
// Queue list
|
||||
Expanded(
|
||||
child: queueState.items.isEmpty
|
||||
? _buildEmptyState(context, colorScheme)
|
||||
: ListView.builder(
|
||||
itemCount: queueState.items.length,
|
||||
itemBuilder: (context, index) => _buildQueueItem(context, ref, queueState.items[index], colorScheme),
|
||||
),
|
||||
),
|
||||
if (queueState.items.isNotEmpty)
|
||||
SliverList(delegate: SliverChildBuilderDelegate(
|
||||
(context, index) => _buildQueueItem(context, ref, queueState.items[index], colorScheme),
|
||||
childCount: queueState.items.length,
|
||||
)),
|
||||
|
||||
// Empty state or fill remaining for scroll
|
||||
if (queueState.items.isEmpty)
|
||||
SliverFillRemaining(hasScrollBody: false, child: _buildEmptyState(context, colorScheme))
|
||||
else
|
||||
const SliverFillRemaining(hasScrollBody: false, child: SizedBox()),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyState(BuildContext context, ColorScheme colorScheme) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.queue_music,
|
||||
size: 64,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'No downloads in queue',
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Add tracks from the Home tab',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
Widget _buildEmptyState(BuildContext context, ColorScheme colorScheme) => Center(
|
||||
child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
|
||||
Icon(Icons.queue_music, size: 64, color: colorScheme.onSurfaceVariant),
|
||||
const SizedBox(height: 16),
|
||||
Text('No downloads in queue', style: Theme.of(context).textTheme.bodyLarge?.copyWith(color: colorScheme.onSurfaceVariant)),
|
||||
const SizedBox(height: 8),
|
||||
Text('Add tracks from the Home tab', style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7))),
|
||||
]),
|
||||
);
|
||||
|
||||
Widget _buildQueueItem(BuildContext context, WidgetRef ref, DownloadItem item, ColorScheme colorScheme) {
|
||||
return ListTile(
|
||||
leading: item.track.coverUrl != null
|
||||
? ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: item.track.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.track.name, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
item.track.artistName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
if (item.status == DownloadStatus.downloading) ...[
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: LinearProgressIndicator(
|
||||
value: item.progress > 0 ? item.progress : null,
|
||||
backgroundColor: colorScheme.surfaceContainerHighest,
|
||||
color: colorScheme.primary,
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Row(
|
||||
children: [
|
||||
// Cover art
|
||||
item.track.coverUrl != null
|
||||
? ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: item.track.coverUrl!,
|
||||
width: 56,
|
||||
height: 56,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: 112,
|
||||
memCacheHeight: 112,
|
||||
),
|
||||
)
|
||||
: Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'${(item.progress * 100).toStringAsFixed(0)}%',
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.bold,
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// Track info
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
item.track.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
item.track.artistName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
if (item.status == DownloadStatus.downloading) ...[
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: LinearProgressIndicator(
|
||||
value: item.progress > 0 ? item.progress : null,
|
||||
backgroundColor: colorScheme.surfaceContainerHighest,
|
||||
color: colorScheme.primary,
|
||||
minHeight: 6,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'${(item.progress * 100).toStringAsFixed(0)}%',
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: colorScheme.primary,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
if (item.status == DownloadStatus.failed) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
item.error ?? 'Download failed',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: colorScheme.error,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
|
||||
// Action buttons based on status
|
||||
_buildActionButtons(context, ref, item, colorScheme),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
trailing: _buildStatusIcon(context, item, colorScheme),
|
||||
onTap: item.status == DownloadStatus.queued
|
||||
? () => ref.read(downloadQueueProvider.notifier).cancelItem(item.id)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatusIcon(BuildContext context, DownloadItem item, ColorScheme colorScheme) {
|
||||
Widget _buildActionButtons(BuildContext context, WidgetRef ref, DownloadItem item, ColorScheme colorScheme) {
|
||||
switch (item.status) {
|
||||
case DownloadStatus.queued:
|
||||
return Icon(Icons.hourglass_empty, color: colorScheme.onSurfaceVariant);
|
||||
case DownloadStatus.downloading:
|
||||
return SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(
|
||||
value: item.progress,
|
||||
strokeWidth: 2,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
);
|
||||
case DownloadStatus.completed:
|
||||
return Icon(Icons.check_circle, color: colorScheme.primary);
|
||||
case DownloadStatus.failed:
|
||||
return IconButton(
|
||||
icon: Icon(Icons.error, color: colorScheme.error),
|
||||
onPressed: () => _showErrorDialog(context, item, colorScheme),
|
||||
tooltip: 'Tap to see error details',
|
||||
);
|
||||
case DownloadStatus.skipped:
|
||||
return Icon(Icons.skip_next, color: colorScheme.primary);
|
||||
}
|
||||
}
|
||||
|
||||
void _showErrorDialog(BuildContext context, DownloadItem item, ColorScheme colorScheme) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Row(
|
||||
// Queued: Show play (start) and cancel buttons
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.error, color: colorScheme.error),
|
||||
const SizedBox(width: 8),
|
||||
const Text('Download Failed'),
|
||||
],
|
||||
),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('Track: ${item.track.name}', style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
Text('Artist: ${item.track.artistName}'),
|
||||
const SizedBox(height: 16),
|
||||
const Text('Error:', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 4),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.errorContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
item.error ?? 'Unknown error',
|
||||
style: TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 12,
|
||||
color: colorScheme.onErrorContainer,
|
||||
),
|
||||
),
|
||||
// Cancel button
|
||||
IconButton(
|
||||
onPressed: () => ref.read(downloadQueueProvider.notifier).cancelItem(item.id),
|
||||
icon: Icon(Icons.close, color: colorScheme.error),
|
||||
tooltip: 'Cancel',
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: colorScheme.errorContainer.withValues(alpha: 0.3),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
case DownloadStatus.downloading:
|
||||
// Downloading: Show progress indicator and cancel button
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Cancel button (skip this download)
|
||||
IconButton(
|
||||
onPressed: () => ref.read(downloadQueueProvider.notifier).cancelItem(item.id),
|
||||
icon: Icon(Icons.stop, color: colorScheme.error),
|
||||
tooltip: 'Stop',
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: colorScheme.errorContainer.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
case DownloadStatus.completed:
|
||||
// Completed: Show check icon
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primaryContainer,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Close'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
child: Icon(Icons.check, color: colorScheme.onPrimaryContainer, size: 20),
|
||||
);
|
||||
|
||||
case DownloadStatus.failed:
|
||||
// Failed: Show retry and remove buttons
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: () => ref.read(downloadQueueProvider.notifier).retryItem(item.id),
|
||||
icon: Icon(Icons.refresh, color: colorScheme.primary),
|
||||
tooltip: 'Retry',
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: colorScheme.primaryContainer.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
IconButton(
|
||||
onPressed: () => ref.read(downloadQueueProvider.notifier).removeItem(item.id),
|
||||
icon: Icon(Icons.close, color: colorScheme.error),
|
||||
tooltip: 'Remove',
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: colorScheme.errorContainer.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
case DownloadStatus.skipped:
|
||||
// Skipped: Show retry and remove buttons
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: () => ref.read(downloadQueueProvider.notifier).retryItem(item.id),
|
||||
icon: Icon(Icons.refresh, color: colorScheme.primary),
|
||||
tooltip: 'Retry',
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: colorScheme.primaryContainer.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
IconButton(
|
||||
onPressed: () => ref.read(downloadQueueProvider.notifier).removeItem(item.id),
|
||||
icon: Icon(Icons.close, color: colorScheme.onSurfaceVariant),
|
||||
tooltip: 'Remove',
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _showClearAllDialog(BuildContext context, WidgetRef ref) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Clear All'),
|
||||
content: const Text('Are you sure you want to clear all downloads?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
ref.read(downloadQueueProvider.notifier).clearAll();
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: Text('Clear', style: TextStyle(color: colorScheme.error)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
showDialog(context: context, builder: (context) => AlertDialog(
|
||||
title: const Text('Clear All'),
|
||||
content: const Text('Are you sure you want to clear all downloads?'),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')),
|
||||
TextButton(onPressed: () { ref.read(downloadQueueProvider.notifier).clearAll(); Navigator.pop(context); },
|
||||
child: Text('Clear', style: TextStyle(color: colorScheme.error))),
|
||||
],
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:spotiflac_android/constants/app_info.dart';
|
||||
|
||||
class AboutPage extends StatelessWidget {
|
||||
const AboutPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final topPadding = MediaQuery.of(context).padding.top;
|
||||
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
// Collapsing App Bar with back button
|
||||
SliverAppBar(
|
||||
expandedHeight: 120 + topPadding,
|
||||
collapsedHeight: kToolbarHeight,
|
||||
floating: false,
|
||||
pinned: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.pop(context)),
|
||||
flexibleSpace: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final maxHeight = 120 + topPadding;
|
||||
final minHeight = kToolbarHeight + topPadding;
|
||||
final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0);
|
||||
final animation = AlwaysStoppedAnimation(expandRatio);
|
||||
return FlexibleSpaceBar(
|
||||
expandedTitleScale: 1.0,
|
||||
titlePadding: EdgeInsets.zero,
|
||||
title: SafeArea(
|
||||
child: Container(
|
||||
alignment: Alignment.bottomLeft,
|
||||
padding: EdgeInsets.only(
|
||||
left: Tween<double>(begin: 56, end: 24).evaluate(animation),
|
||||
bottom: Tween<double>(begin: 12, end: 16).evaluate(animation),
|
||||
),
|
||||
child: Text('About',
|
||||
style: TextStyle(
|
||||
fontSize: Tween<double>(begin: 20, end: 28).evaluate(animation),
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// App info card
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
child: Card(
|
||||
elevation: 0,
|
||||
color: colorScheme.surfaceContainerHigh,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(children: [
|
||||
Container(
|
||||
width: 56, height: 56,
|
||||
decoration: BoxDecoration(color: colorScheme.primaryContainer, borderRadius: BorderRadius.circular(16)),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Image.asset('assets/images/logo.png', fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) => Icon(Icons.music_note, size: 32, color: colorScheme.onPrimaryContainer)),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(AppInfo.appName, style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 4),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(color: colorScheme.secondaryContainer, borderRadius: BorderRadius.circular(12)),
|
||||
child: Text('v${AppInfo.version}', style: Theme.of(context).textTheme.labelMedium?.copyWith(color: colorScheme.onSecondaryContainer)),
|
||||
),
|
||||
]),
|
||||
]),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// GitHub section
|
||||
SliverToBoxAdapter(child: _SectionHeader(title: 'GitHub')),
|
||||
SliverList(delegate: SliverChildListDelegate([
|
||||
ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
leading: Icon(Icons.phone_android, color: colorScheme.onSurfaceVariant),
|
||||
title: Text('${AppInfo.appName} Mobile'),
|
||||
subtitle: Text('github.com/${AppInfo.githubRepo}'),
|
||||
trailing: const Icon(Icons.open_in_new, size: 20),
|
||||
onTap: () => _launchUrl(AppInfo.githubUrl),
|
||||
),
|
||||
ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
leading: Icon(Icons.computer, color: colorScheme.onSurfaceVariant),
|
||||
title: Text('Original ${AppInfo.appName}'),
|
||||
subtitle: Text('github.com/${AppInfo.originalAuthor}/SpotiFLAC'),
|
||||
trailing: const Icon(Icons.open_in_new, size: 20),
|
||||
onTap: () => _launchUrl(AppInfo.originalGithubUrl),
|
||||
),
|
||||
])),
|
||||
|
||||
// Credits section
|
||||
SliverToBoxAdapter(child: _SectionHeader(title: 'Credits')),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: Column(children: [
|
||||
_CreditRow(label: 'Mobile Version', value: AppInfo.mobileAuthor),
|
||||
const SizedBox(height: 12),
|
||||
_CreditRow(label: 'Original Project', value: AppInfo.originalAuthor),
|
||||
]),
|
||||
),
|
||||
),
|
||||
|
||||
// Copyright
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Center(child: Text(AppInfo.copyright,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant))),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _launchUrl(String url) async {
|
||||
final uri = Uri.parse(url);
|
||||
if (await canLaunchUrl(uri)) await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionHeader extends StatelessWidget {
|
||||
final String title;
|
||||
const _SectionHeader({required this.title});
|
||||
@override
|
||||
Widget build(BuildContext context) => Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
|
||||
child: Text(title, style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.primary, fontWeight: FontWeight.w600)),
|
||||
);
|
||||
}
|
||||
|
||||
class _CreditRow extends StatelessWidget {
|
||||
final String label;
|
||||
final String value;
|
||||
const _CreditRow({required this.label, required this.value});
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
|
||||
Text(label, style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
|
||||
Text(value, style: Theme.of(context).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600)),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/providers/theme_provider.dart';
|
||||
|
||||
class AppearanceSettingsPage extends ConsumerWidget {
|
||||
const AppearanceSettingsPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final themeSettings = ref.watch(themeProvider);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final topPadding = MediaQuery.of(context).padding.top;
|
||||
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
// Collapsing App Bar with back button
|
||||
SliverAppBar(
|
||||
expandedHeight: 120 + topPadding,
|
||||
collapsedHeight: kToolbarHeight,
|
||||
floating: false,
|
||||
pinned: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.pop(context)),
|
||||
flexibleSpace: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final maxHeight = 120 + topPadding;
|
||||
final minHeight = kToolbarHeight + topPadding;
|
||||
final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0);
|
||||
final animation = AlwaysStoppedAnimation(expandRatio);
|
||||
return FlexibleSpaceBar(
|
||||
expandedTitleScale: 1.0,
|
||||
titlePadding: EdgeInsets.zero,
|
||||
title: SafeArea(
|
||||
child: Container(
|
||||
alignment: Alignment.bottomLeft,
|
||||
padding: EdgeInsets.only(
|
||||
left: Tween<double>(begin: 56, end: 24).evaluate(animation),
|
||||
bottom: Tween<double>(begin: 12, end: 16).evaluate(animation),
|
||||
),
|
||||
child: Text('Appearance',
|
||||
style: TextStyle(
|
||||
fontSize: Tween<double>(begin: 20, end: 28).evaluate(animation),
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// Theme section
|
||||
SliverToBoxAdapter(child: _SectionHeader(title: 'Theme')),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: _ThemeModeSelector(
|
||||
currentMode: themeSettings.themeMode,
|
||||
onChanged: (mode) => ref.read(themeProvider.notifier).setThemeMode(mode),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Color section
|
||||
SliverToBoxAdapter(child: _SectionHeader(title: 'Color')),
|
||||
SliverToBoxAdapter(
|
||||
child: SwitchListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
title: const Text('Dynamic Color'),
|
||||
subtitle: const Text('Use colors from your wallpaper'),
|
||||
value: themeSettings.useDynamicColor,
|
||||
onChanged: (value) => ref.read(themeProvider.notifier).setUseDynamicColor(value),
|
||||
),
|
||||
),
|
||||
|
||||
if (!themeSettings.useDynamicColor)
|
||||
SliverToBoxAdapter(
|
||||
child: _ColorPicker(
|
||||
currentColor: themeSettings.seedColorValue,
|
||||
onColorSelected: (color) => ref.read(themeProvider.notifier).setSeedColor(color),
|
||||
),
|
||||
),
|
||||
|
||||
// Fill remaining for scroll
|
||||
const SliverFillRemaining(hasScrollBody: false, child: SizedBox()),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionHeader extends StatelessWidget {
|
||||
final String title;
|
||||
const _SectionHeader({required this.title});
|
||||
@override
|
||||
Widget build(BuildContext context) => Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
|
||||
child: Text(title, style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.primary, fontWeight: FontWeight.w600)),
|
||||
);
|
||||
}
|
||||
|
||||
class _ThemeModeSelector extends StatelessWidget {
|
||||
final ThemeMode currentMode;
|
||||
final ValueChanged<ThemeMode> onChanged;
|
||||
const _ThemeModeSelector({required this.currentMode, required this.onChanged});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Card(
|
||||
elevation: 0,
|
||||
color: colorScheme.surfaceContainerHigh,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Row(children: [
|
||||
_ThemeModeChip(icon: Icons.brightness_auto, label: 'System', isSelected: currentMode == ThemeMode.system, onTap: () => onChanged(ThemeMode.system)),
|
||||
const SizedBox(width: 8),
|
||||
_ThemeModeChip(icon: Icons.light_mode, label: 'Light', isSelected: currentMode == ThemeMode.light, onTap: () => onChanged(ThemeMode.light)),
|
||||
const SizedBox(width: 8),
|
||||
_ThemeModeChip(icon: Icons.dark_mode, label: 'Dark', isSelected: currentMode == ThemeMode.dark, onTap: () => onChanged(ThemeMode.dark)),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ThemeModeChip extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final bool isSelected;
|
||||
final VoidCallback onTap;
|
||||
const _ThemeModeChip({required this.icon, required this.label, required this.isSelected, required this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Expanded(
|
||||
child: Material(
|
||||
color: isSelected ? colorScheme.primaryContainer : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
child: Column(children: [
|
||||
Icon(icon, color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant),
|
||||
const SizedBox(height: 6),
|
||||
Text(label, style: TextStyle(fontSize: 12,
|
||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
|
||||
color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant)),
|
||||
]),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ColorPicker extends StatelessWidget {
|
||||
final int currentColor;
|
||||
final ValueChanged<Color> onColorSelected;
|
||||
const _ColorPicker({required this.currentColor, required this.onColorSelected});
|
||||
|
||||
static const _colors = [
|
||||
Color(0xFF1DB954), Color(0xFF6750A4), Color(0xFF0061A4), Color(0xFF006E1C),
|
||||
Color(0xFFBA1A1A), Color(0xFF984061), Color(0xFF7D5260), Color(0xFF006874), Color(0xFFFF6F00),
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text('Accent Color', style: Theme.of(context).textTheme.titleSmall),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(spacing: 12, runSpacing: 12, children: _colors.map((color) {
|
||||
final isSelected = color.toARGB32() == currentColor;
|
||||
return GestureDetector(
|
||||
onTap: () => onColorSelected(color),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
width: 44, height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: color, shape: BoxShape.circle,
|
||||
border: isSelected ? Border.all(color: colorScheme.onSurface, width: 3) : null,
|
||||
boxShadow: isSelected ? [BoxShadow(color: color.withValues(alpha: 0.4), blurRadius: 8, spreadRadius: 2)] : null,
|
||||
),
|
||||
child: isSelected ? const Icon(Icons.check, color: Colors.white, size: 20) : null,
|
||||
),
|
||||
);
|
||||
}).toList()),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
|
||||
class DownloadSettingsPage extends ConsumerWidget {
|
||||
const DownloadSettingsPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final settings = ref.watch(settingsProvider);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final topPadding = MediaQuery.of(context).padding.top;
|
||||
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
// Collapsing App Bar with back button
|
||||
SliverAppBar(
|
||||
expandedHeight: 120 + topPadding,
|
||||
collapsedHeight: kToolbarHeight,
|
||||
floating: false,
|
||||
pinned: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.pop(context)),
|
||||
flexibleSpace: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final maxHeight = 120 + topPadding;
|
||||
final minHeight = kToolbarHeight + topPadding;
|
||||
final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0);
|
||||
final animation = AlwaysStoppedAnimation(expandRatio);
|
||||
return FlexibleSpaceBar(
|
||||
expandedTitleScale: 1.0,
|
||||
titlePadding: EdgeInsets.zero,
|
||||
title: SafeArea(
|
||||
child: Container(
|
||||
alignment: Alignment.bottomLeft,
|
||||
padding: EdgeInsets.only(
|
||||
left: Tween<double>(begin: 56, end: 24).evaluate(animation),
|
||||
bottom: Tween<double>(begin: 12, end: 16).evaluate(animation),
|
||||
),
|
||||
child: Text('Download',
|
||||
style: TextStyle(
|
||||
fontSize: Tween<double>(begin: 20, end: 28).evaluate(animation),
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// Service section
|
||||
SliverToBoxAdapter(child: _SectionHeader(title: 'Service')),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: _ServiceSelector(
|
||||
currentService: settings.defaultService,
|
||||
onChanged: (service) => ref.read(settingsProvider.notifier).setDefaultService(service),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Quality section
|
||||
SliverToBoxAdapter(child: _SectionHeader(title: 'Audio Quality')),
|
||||
SliverList(delegate: SliverChildListDelegate([
|
||||
_QualityOption(title: 'FLAC Lossless', subtitle: '16-bit / 44.1kHz', value: 'LOSSLESS',
|
||||
isSelected: settings.audioQuality == 'LOSSLESS',
|
||||
onTap: () => ref.read(settingsProvider.notifier).setAudioQuality('LOSSLESS')),
|
||||
_QualityOption(title: 'Hi-Res FLAC', subtitle: '24-bit / up to 96kHz', value: 'HI_RES',
|
||||
isSelected: settings.audioQuality == 'HI_RES',
|
||||
onTap: () => ref.read(settingsProvider.notifier).setAudioQuality('HI_RES')),
|
||||
_QualityOption(title: 'Hi-Res FLAC Max', subtitle: '24-bit / up to 192kHz', value: 'HI_RES_LOSSLESS',
|
||||
isSelected: settings.audioQuality == 'HI_RES_LOSSLESS',
|
||||
onTap: () => ref.read(settingsProvider.notifier).setAudioQuality('HI_RES_LOSSLESS')),
|
||||
])),
|
||||
|
||||
// File settings section
|
||||
SliverToBoxAdapter(child: _SectionHeader(title: 'File Settings')),
|
||||
SliverList(delegate: SliverChildListDelegate([
|
||||
ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
leading: Icon(Icons.text_fields, color: colorScheme.onSurfaceVariant),
|
||||
title: const Text('Filename Format'),
|
||||
subtitle: Text(settings.filenameFormat),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => _showFormatEditor(context, ref, settings.filenameFormat),
|
||||
),
|
||||
ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
leading: Icon(Icons.folder_outlined, color: colorScheme.onSurfaceVariant),
|
||||
title: const Text('Download Directory'),
|
||||
subtitle: Text(settings.downloadDirectory.isEmpty ? 'Music/SpotiFLAC' : settings.downloadDirectory, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => _pickDirectory(ref),
|
||||
),
|
||||
])),
|
||||
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 32)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showFormatEditor(BuildContext context, WidgetRef ref, String current) {
|
||||
final controller = TextEditingController(text: current);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
showModalBottomSheet(
|
||||
context: context, isScrollControlled: true,
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(28))),
|
||||
builder: (context) => Padding(
|
||||
padding: EdgeInsets.fromLTRB(24, 24, 24, MediaQuery.of(context).viewInsets.bottom + 24),
|
||||
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text('Filename Format', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 16),
|
||||
TextField(controller: controller, decoration: const InputDecoration(hintText: '{artist} - {title}'), autofocus: true),
|
||||
const SizedBox(height: 16),
|
||||
Text('Available: {title}, {artist}, {album}, {track}, {year}, {disc}',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant)),
|
||||
const SizedBox(height: 24),
|
||||
Row(mainAxisAlignment: MainAxisAlignment.end, children: [
|
||||
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')),
|
||||
const SizedBox(width: 8),
|
||||
FilledButton(onPressed: () { ref.read(settingsProvider.notifier).setFilenameFormat(controller.text); Navigator.pop(context); }, child: const Text('Save')),
|
||||
]),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _pickDirectory(WidgetRef ref) async {
|
||||
final result = await FilePicker.platform.getDirectoryPath();
|
||||
if (result != null) ref.read(settingsProvider.notifier).setDownloadDirectory(result);
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionHeader extends StatelessWidget {
|
||||
final String title;
|
||||
const _SectionHeader({required this.title});
|
||||
@override
|
||||
Widget build(BuildContext context) => Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
|
||||
child: Text(title, style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.primary, fontWeight: FontWeight.w600)),
|
||||
);
|
||||
}
|
||||
|
||||
class _ServiceSelector extends StatelessWidget {
|
||||
final String currentService;
|
||||
final ValueChanged<String> onChanged;
|
||||
const _ServiceSelector({required this.currentService, required this.onChanged});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Card(
|
||||
elevation: 0,
|
||||
color: colorScheme.surfaceContainerHigh,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Row(children: [
|
||||
_ServiceChip(icon: Icons.music_note, label: 'Tidal', isSelected: currentService == 'tidal', onTap: () => onChanged('tidal')),
|
||||
const SizedBox(width: 8),
|
||||
_ServiceChip(icon: Icons.album, label: 'Qobuz', isSelected: currentService == 'qobuz', onTap: () => onChanged('qobuz')),
|
||||
const SizedBox(width: 8),
|
||||
_ServiceChip(icon: Icons.shopping_bag, label: 'Amazon', isSelected: currentService == 'amazon', onTap: () => onChanged('amazon')),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ServiceChip extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final bool isSelected;
|
||||
final VoidCallback onTap;
|
||||
const _ServiceChip({required this.icon, required this.label, required this.isSelected, required this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Expanded(
|
||||
child: Material(
|
||||
color: isSelected ? colorScheme.primaryContainer : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
child: Column(children: [
|
||||
Icon(icon, color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant),
|
||||
const SizedBox(height: 6),
|
||||
Text(label, style: TextStyle(fontSize: 12,
|
||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
|
||||
color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant)),
|
||||
]),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _QualityOption extends StatelessWidget {
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final String value;
|
||||
final bool isSelected;
|
||||
final VoidCallback onTap;
|
||||
const _QualityOption({required this.title, required this.subtitle, required this.value, required this.isSelected, required this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
title: Text(title),
|
||||
subtitle: Text(subtitle),
|
||||
trailing: isSelected ? Icon(Icons.check_circle, color: colorScheme.primary) : Icon(Icons.circle_outlined, color: colorScheme.outline),
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
|
||||
class OptionsSettingsPage extends ConsumerWidget {
|
||||
const OptionsSettingsPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final settings = ref.watch(settingsProvider);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final topPadding = MediaQuery.of(context).padding.top;
|
||||
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
// Collapsing App Bar with back button
|
||||
SliverAppBar(
|
||||
expandedHeight: 120 + topPadding,
|
||||
collapsedHeight: kToolbarHeight,
|
||||
floating: false,
|
||||
pinned: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.pop(context)),
|
||||
flexibleSpace: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final maxHeight = 120 + topPadding;
|
||||
final minHeight = kToolbarHeight + topPadding;
|
||||
final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0);
|
||||
final animation = AlwaysStoppedAnimation(expandRatio);
|
||||
return FlexibleSpaceBar(
|
||||
expandedTitleScale: 1.0,
|
||||
titlePadding: EdgeInsets.zero,
|
||||
title: SafeArea(
|
||||
child: Container(
|
||||
alignment: Alignment.bottomLeft,
|
||||
padding: EdgeInsets.only(
|
||||
left: Tween<double>(begin: 56, end: 24).evaluate(animation),
|
||||
bottom: Tween<double>(begin: 12, end: 16).evaluate(animation),
|
||||
),
|
||||
child: Text('Options',
|
||||
style: TextStyle(
|
||||
fontSize: Tween<double>(begin: 20, end: 28).evaluate(animation),
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// Download options section
|
||||
SliverToBoxAdapter(child: _SectionHeader(title: 'Download')),
|
||||
SliverList(delegate: SliverChildListDelegate([
|
||||
SwitchListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
secondary: Icon(Icons.sync, color: colorScheme.onSurfaceVariant),
|
||||
title: const Text('Auto Fallback'),
|
||||
subtitle: const Text('Try other services if download fails'),
|
||||
value: settings.autoFallback,
|
||||
onChanged: (v) => ref.read(settingsProvider.notifier).setAutoFallback(v),
|
||||
),
|
||||
SwitchListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
secondary: Icon(Icons.lyrics, color: colorScheme.onSurfaceVariant),
|
||||
title: const Text('Embed Lyrics'),
|
||||
subtitle: const Text('Embed synced lyrics into FLAC files'),
|
||||
value: settings.embedLyrics,
|
||||
onChanged: (v) => ref.read(settingsProvider.notifier).setEmbedLyrics(v),
|
||||
),
|
||||
SwitchListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
secondary: Icon(Icons.image, color: colorScheme.onSurfaceVariant),
|
||||
title: const Text('Max Quality Cover'),
|
||||
subtitle: const Text('Download highest resolution cover art'),
|
||||
value: settings.maxQualityCover,
|
||||
onChanged: (v) => ref.read(settingsProvider.notifier).setMaxQualityCover(v),
|
||||
),
|
||||
])),
|
||||
|
||||
// Performance section
|
||||
SliverToBoxAdapter(child: _SectionHeader(title: 'Performance')),
|
||||
SliverToBoxAdapter(
|
||||
child: _ConcurrentDownloadsSelector(
|
||||
currentValue: settings.concurrentDownloads,
|
||||
onChanged: (v) => ref.read(settingsProvider.notifier).setConcurrentDownloads(v),
|
||||
),
|
||||
),
|
||||
|
||||
// App section
|
||||
SliverToBoxAdapter(child: _SectionHeader(title: 'App')),
|
||||
SliverToBoxAdapter(
|
||||
child: SwitchListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
secondary: Icon(Icons.system_update, color: colorScheme.onSurfaceVariant),
|
||||
title: const Text('Check for Updates'),
|
||||
subtitle: const Text('Notify when new version is available'),
|
||||
value: settings.checkForUpdates,
|
||||
onChanged: (v) => ref.read(settingsProvider.notifier).setCheckForUpdates(v),
|
||||
),
|
||||
),
|
||||
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 32)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionHeader extends StatelessWidget {
|
||||
final String title;
|
||||
const _SectionHeader({required this.title});
|
||||
@override
|
||||
Widget build(BuildContext context) => Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
|
||||
child: Text(title, style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.primary, fontWeight: FontWeight.w600)),
|
||||
);
|
||||
}
|
||||
|
||||
class _ConcurrentDownloadsSelector extends StatelessWidget {
|
||||
final int currentValue;
|
||||
final ValueChanged<int> onChanged;
|
||||
const _ConcurrentDownloadsSelector({required this.currentValue, required this.onChanged});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Row(children: [
|
||||
Icon(Icons.download_for_offline, color: colorScheme.onSurfaceVariant),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
const Text('Concurrent Downloads'),
|
||||
Text(currentValue == 1 ? 'Sequential (1 at a time)' : '$currentValue parallel downloads',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant)),
|
||||
])),
|
||||
]),
|
||||
const SizedBox(height: 16),
|
||||
Row(children: [
|
||||
_ConcurrentChip(label: '1', isSelected: currentValue == 1, onTap: () => onChanged(1)),
|
||||
const SizedBox(width: 8),
|
||||
_ConcurrentChip(label: '2', isSelected: currentValue == 2, onTap: () => onChanged(2)),
|
||||
const SizedBox(width: 8),
|
||||
_ConcurrentChip(label: '3', isSelected: currentValue == 3, onTap: () => onChanged(3)),
|
||||
]),
|
||||
const SizedBox(height: 12),
|
||||
Row(children: [
|
||||
Icon(Icons.warning_amber_rounded, size: 16, color: colorScheme.error),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: Text('Parallel downloads may trigger rate limiting',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.error))),
|
||||
]),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ConcurrentChip extends StatelessWidget {
|
||||
final String label;
|
||||
final bool isSelected;
|
||||
final VoidCallback onTap;
|
||||
const _ConcurrentChip({required this.label, required this.isSelected, required this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Expanded(
|
||||
child: Material(
|
||||
color: isSelected ? colorScheme.primaryContainer : colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
child: Center(child: Text(label, style: TextStyle(
|
||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
|
||||
color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant))),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/constants/app_info.dart';
|
||||
import 'package:spotiflac_android/screens/settings/appearance_settings_page.dart';
|
||||
import 'package:spotiflac_android/screens/settings/download_settings_page.dart';
|
||||
import 'package:spotiflac_android/screens/settings/options_settings_page.dart';
|
||||
import 'package:spotiflac_android/screens/settings/about_page.dart';
|
||||
|
||||
class SettingsTab extends ConsumerWidget {
|
||||
const SettingsTab({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return CustomScrollView(
|
||||
slivers: [
|
||||
// Collapsing App Bar - Simplified for performance
|
||||
SliverAppBar(
|
||||
expandedHeight: 100,
|
||||
collapsedHeight: kToolbarHeight,
|
||||
floating: false,
|
||||
pinned: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
automaticallyImplyLeading: false,
|
||||
flexibleSpace: FlexibleSpaceBar(
|
||||
expandedTitleScale: 1.4,
|
||||
titlePadding: const EdgeInsets.only(left: 24, bottom: 16),
|
||||
title: Text(
|
||||
'Settings',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Menu items
|
||||
SliverList(delegate: SliverChildListDelegate([
|
||||
_SettingsMenuItem(
|
||||
icon: Icons.palette_outlined,
|
||||
title: 'Appearance',
|
||||
subtitle: 'Theme, colors, display',
|
||||
onTap: () => _navigateTo(context, const AppearanceSettingsPage()),
|
||||
),
|
||||
_SettingsMenuItem(
|
||||
icon: Icons.download_outlined,
|
||||
title: 'Download',
|
||||
subtitle: 'Service, quality, filename format',
|
||||
onTap: () => _navigateTo(context, const DownloadSettingsPage()),
|
||||
),
|
||||
_SettingsMenuItem(
|
||||
icon: Icons.tune_outlined,
|
||||
title: 'Options',
|
||||
subtitle: 'Fallback, lyrics, cover art, updates',
|
||||
onTap: () => _navigateTo(context, const OptionsSettingsPage()),
|
||||
),
|
||||
_SettingsMenuItem(
|
||||
icon: Icons.info_outline,
|
||||
title: 'About',
|
||||
subtitle: 'Version ${AppInfo.version}, credits, GitHub',
|
||||
onTap: () => _navigateTo(context, const AboutPage()),
|
||||
),
|
||||
])),
|
||||
|
||||
// Fill remaining space to enable scroll
|
||||
const SliverFillRemaining(hasScrollBody: false, child: SizedBox()),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _navigateTo(BuildContext context, Widget page) {
|
||||
Navigator.of(context).push(MaterialPageRoute(builder: (_) => page));
|
||||
}
|
||||
}
|
||||
|
||||
class _SettingsMenuItem extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _SettingsMenuItem({required this.icon, required this.title, required this.subtitle, required this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
|
||||
child: Row(children: [
|
||||
Container(
|
||||
width: 44, height: 44,
|
||||
decoration: BoxDecoration(color: colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(12)),
|
||||
child: Icon(icon, color: colorScheme.onSurfaceVariant, size: 22),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(title, style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500)),
|
||||
const SizedBox(height: 2),
|
||||
Text(subtitle, style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
|
||||
])),
|
||||
Icon(Icons.chevron_right, color: colorScheme.onSurfaceVariant, size: 24),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:file_picker/file_picker.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/theme_provider.dart';
|
||||
|
||||
@@ -55,9 +56,6 @@ class SettingsScreen extends ConsumerWidget {
|
||||
onTap: () => _showColorPicker(context, ref, themeSettings.seedColorValue),
|
||||
),
|
||||
|
||||
// Theme Preview
|
||||
_buildThemePreview(context, colorScheme),
|
||||
|
||||
const Divider(),
|
||||
|
||||
// Download Section
|
||||
@@ -136,6 +134,15 @@ class SettingsScreen extends ConsumerWidget {
|
||||
: '${settings.concurrentDownloads} parallel downloads'),
|
||||
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(),
|
||||
|
||||
@@ -144,22 +151,22 @@ class SettingsScreen extends ConsumerWidget {
|
||||
|
||||
ListTile(
|
||||
leading: Icon(Icons.code, color: colorScheme.primary),
|
||||
title: const Text('SpotiFLAC Mobile'),
|
||||
subtitle: const Text('github.com/zarzet/SpotiFLAC-Mobile'),
|
||||
onTap: () => _launchUrl('https://github.com/zarzet/SpotiFLAC-Mobile'),
|
||||
title: Text('${AppInfo.appName} Mobile'),
|
||||
subtitle: Text('github.com/${AppInfo.githubRepo}'),
|
||||
onTap: () => _launchUrl(AppInfo.githubUrl),
|
||||
),
|
||||
|
||||
ListTile(
|
||||
leading: Icon(Icons.computer, color: colorScheme.primary),
|
||||
title: const Text('Original SpotiFLAC (Desktop)'),
|
||||
subtitle: const Text('github.com/afkarxyz/SpotiFLAC'),
|
||||
onTap: () => _launchUrl('https://github.com/afkarxyz/SpotiFLAC'),
|
||||
title: Text('Original ${AppInfo.appName} (Desktop)'),
|
||||
subtitle: Text('github.com/${AppInfo.originalAuthor}/SpotiFLAC'),
|
||||
onTap: () => _launchUrl(AppInfo.originalGithubUrl),
|
||||
),
|
||||
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
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(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
@@ -172,19 +179,64 @@ class SettingsScreen extends ConsumerWidget {
|
||||
ListTile(
|
||||
leading: Icon(Icons.info, color: colorScheme.primary),
|
||||
title: const Text('About'),
|
||||
subtitle: const Text('SpotiFLAC v1.1.0'),
|
||||
onTap: () => showAboutDialog(
|
||||
context: context,
|
||||
applicationName: 'SpotiFLAC',
|
||||
applicationVersion: '1.1.0',
|
||||
applicationLegalese: '© 2024 SpotiFLAC\n\nMobile: zarzet\nOriginal: afkarxyz',
|
||||
),
|
||||
subtitle: Text('${AppInfo.appName} v${AppInfo.version}'),
|
||||
onTap: () => _showAboutDialog(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showAboutDialog(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Row(
|
||||
children: [
|
||||
Image.asset('assets/images/logo.png', width: 40, height: 40, errorBuilder: (_, __, ___) => Icon(Icons.music_note, size: 40, color: colorScheme.primary)),
|
||||
const SizedBox(width: 12),
|
||||
Text(AppInfo.appName),
|
||||
],
|
||||
),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildAboutRow('Version', AppInfo.version, colorScheme),
|
||||
const SizedBox(height: 8),
|
||||
_buildAboutRow('Mobile', AppInfo.mobileAuthor, colorScheme),
|
||||
const SizedBox(height: 8),
|
||||
_buildAboutRow('Original', AppInfo.originalAuthor, colorScheme),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
AppInfo.copyright,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Close'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAboutRow(String label, String value, ColorScheme colorScheme) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(label, style: TextStyle(color: colorScheme.onSurfaceVariant)),
|
||||
Text(value, style: const TextStyle(fontWeight: FontWeight.w500)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSectionHeader(BuildContext context, String title, ColorScheme colorScheme) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
@@ -198,51 +250,6 @@ class SettingsScreen extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildThemePreview(BuildContext context, ColorScheme colorScheme) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Theme Preview',
|
||||
style: Theme.of(context).textTheme.titleSmall,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
_buildColorChip('Primary', colorScheme.primary, colorScheme.onPrimary),
|
||||
_buildColorChip('Secondary', colorScheme.secondary, colorScheme.onSecondary),
|
||||
_buildColorChip('Tertiary', colorScheme.tertiary, colorScheme.onTertiary),
|
||||
_buildColorChip('Surface', colorScheme.surface, colorScheme.onSurface),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildColorChip(String label, Color background, Color foreground) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: background,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(color: foreground, fontSize: 12),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _getThemeModeName(ThemeMode mode) {
|
||||
switch (mode) {
|
||||
case ThemeMode.light: return 'Light';
|
||||
@@ -478,11 +485,20 @@ class SettingsScreen extends ConsumerWidget {
|
||||
_buildConcurrentOption(context, ref, 2, '2 Parallel', 'Download 2 tracks simultaneously', current, colorScheme),
|
||||
_buildConcurrentOption(context, ref, 3, '3 Parallel', 'Download 3 tracks simultaneously', current, colorScheme),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'⚠️ Parallel downloads may trigger rate limiting from streaming services.',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.error,
|
||||
),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(Icons.warning_amber_rounded, size: 16, color: colorScheme.error),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Parallel downloads may trigger rate limiting from streaming services.',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.error,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:file_picker/file_picker.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/theme_provider.dart';
|
||||
|
||||
@@ -62,9 +63,6 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with AutomaticKeepAli
|
||||
onTap: () => _showColorPicker(context, ref, themeSettings.seedColorValue),
|
||||
),
|
||||
|
||||
// Theme Preview
|
||||
_buildThemePreview(context, colorScheme),
|
||||
|
||||
const Divider(),
|
||||
|
||||
// Download Section
|
||||
@@ -143,6 +141,15 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with AutomaticKeepAli
|
||||
: '${settings.concurrentDownloads} parallel downloads'),
|
||||
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(),
|
||||
|
||||
@@ -151,22 +158,22 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with AutomaticKeepAli
|
||||
|
||||
ListTile(
|
||||
leading: Icon(Icons.code, color: colorScheme.primary),
|
||||
title: const Text('SpotiFLAC Mobile'),
|
||||
subtitle: const Text('github.com/zarzet/SpotiFLAC-Mobile'),
|
||||
onTap: () => _launchUrl('https://github.com/zarzet/SpotiFLAC-Mobile'),
|
||||
title: Text('${AppInfo.appName} Mobile'),
|
||||
subtitle: Text('github.com/${AppInfo.githubRepo}'),
|
||||
onTap: () => _launchUrl(AppInfo.githubUrl),
|
||||
),
|
||||
|
||||
ListTile(
|
||||
leading: Icon(Icons.computer, color: colorScheme.primary),
|
||||
title: const Text('Original SpotiFLAC (Desktop)'),
|
||||
subtitle: const Text('github.com/afkarxyz/SpotiFLAC'),
|
||||
onTap: () => _launchUrl('https://github.com/afkarxyz/SpotiFLAC'),
|
||||
title: Text('Original ${AppInfo.appName} (Desktop)'),
|
||||
subtitle: Text('github.com/${AppInfo.originalAuthor}/SpotiFLAC'),
|
||||
onTap: () => _launchUrl(AppInfo.originalGithubUrl),
|
||||
),
|
||||
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
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(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
@@ -179,13 +186,8 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with AutomaticKeepAli
|
||||
ListTile(
|
||||
leading: Icon(Icons.info, color: colorScheme.primary),
|
||||
title: const Text('About'),
|
||||
subtitle: const Text('SpotiFLAC v1.1.0'),
|
||||
onTap: () => showAboutDialog(
|
||||
context: context,
|
||||
applicationName: 'SpotiFLAC',
|
||||
applicationVersion: '1.1.0',
|
||||
applicationLegalese: '© 2024 SpotiFLAC\n\nMobile: zarzet\nOriginal: afkarxyz',
|
||||
),
|
||||
subtitle: Text('${AppInfo.appName} v${AppInfo.version}'),
|
||||
onTap: () => _showAboutDialog(context),
|
||||
),
|
||||
|
||||
// Bottom padding for navigation bar
|
||||
@@ -194,6 +196,56 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with AutomaticKeepAli
|
||||
);
|
||||
}
|
||||
|
||||
void _showAboutDialog(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Row(
|
||||
children: [
|
||||
Image.asset('assets/images/logo.png', width: 40, height: 40, errorBuilder: (_, __, ___) => Icon(Icons.music_note, size: 40, color: colorScheme.primary)),
|
||||
const SizedBox(width: 12),
|
||||
Text(AppInfo.appName),
|
||||
],
|
||||
),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildAboutRow('Version', AppInfo.version, colorScheme),
|
||||
const SizedBox(height: 8),
|
||||
_buildAboutRow('Mobile', AppInfo.mobileAuthor, colorScheme),
|
||||
const SizedBox(height: 8),
|
||||
_buildAboutRow('Original', AppInfo.originalAuthor, colorScheme),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
AppInfo.copyright,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Close'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAboutRow(String label, String value, ColorScheme colorScheme) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(label, style: TextStyle(color: colorScheme.onSurfaceVariant)),
|
||||
Text(value, style: const TextStyle(fontWeight: FontWeight.w500)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSectionHeader(BuildContext context, String title, ColorScheme colorScheme) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
@@ -207,42 +259,6 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with AutomaticKeepAli
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildThemePreview(BuildContext context, ColorScheme colorScheme) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Theme Preview', style: Theme.of(context).textTheme.titleSmall),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
_buildColorChip('Primary', colorScheme.primary, colorScheme.onPrimary),
|
||||
_buildColorChip('Secondary', colorScheme.secondary, colorScheme.onSecondary),
|
||||
_buildColorChip('Tertiary', colorScheme.tertiary, colorScheme.onTertiary),
|
||||
_buildColorChip('Surface', colorScheme.surface, colorScheme.onSurface),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildColorChip(String label, Color background, Color foreground) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(color: background, borderRadius: BorderRadius.circular(16)),
|
||||
child: Text(label, style: TextStyle(color: foreground, fontSize: 12)),
|
||||
);
|
||||
}
|
||||
|
||||
String _getThemeModeName(ThemeMode mode) {
|
||||
switch (mode) {
|
||||
case ThemeMode.light: return 'Light';
|
||||
@@ -262,8 +278,9 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with AutomaticKeepAli
|
||||
|
||||
String _getQualityName(String quality) {
|
||||
switch (quality) {
|
||||
case 'LOSSLESS': return 'FLAC (Lossless)';
|
||||
case 'HI_RES': return 'Hi-Res FLAC (24-bit)';
|
||||
case 'LOSSLESS': return 'FLAC (16-bit / 44.1kHz)';
|
||||
case 'HI_RES': return 'Hi-Res FLAC (24-bit / 96kHz)';
|
||||
case 'HI_RES_LOSSLESS': return 'Hi-Res FLAC (24-bit / 192kHz)';
|
||||
default: return quality;
|
||||
}
|
||||
}
|
||||
@@ -374,7 +391,8 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with AutomaticKeepAli
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_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),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -447,11 +465,20 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with AutomaticKeepAli
|
||||
_buildConcurrentOption(context, ref, 2, '2 Parallel', 'Download 2 tracks simultaneously', current, colorScheme),
|
||||
_buildConcurrentOption(context, ref, 3, '3 Parallel', 'Download 3 tracks simultaneously', current, colorScheme),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'⚠️ Parallel downloads may trigger rate limiting from streaming services.',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.error,
|
||||
),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(Icons.warning_amber_rounded, size: 16, color: colorScheme.error),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Parallel downloads may trigger rate limiting from streaming services.',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.error,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:io';
|
||||
import 'dart:math' as math;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
@@ -17,11 +18,15 @@ class SetupScreen extends ConsumerStatefulWidget {
|
||||
|
||||
class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
int _currentStep = 0;
|
||||
bool _permissionGranted = false;
|
||||
bool _storagePermissionGranted = false;
|
||||
bool _notificationPermissionGranted = false;
|
||||
String? _selectedDirectory;
|
||||
bool _isLoading = false;
|
||||
int _androidSdkVersion = 0;
|
||||
|
||||
// Total steps: Storage -> Notification (Android 13+) -> Folder
|
||||
int get _totalSteps => _androidSdkVersion >= 33 ? 3 : 2;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -35,47 +40,54 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
_androidSdkVersion = androidInfo.version.sdkInt;
|
||||
debugPrint('Android SDK Version: $_androidSdkVersion');
|
||||
}
|
||||
await _checkInitialPermission();
|
||||
await _checkInitialPermissions();
|
||||
}
|
||||
|
||||
Future<void> _checkInitialPermission() async {
|
||||
Future<void> _checkInitialPermissions() async {
|
||||
if (Platform.isIOS) {
|
||||
// iOS doesn't need storage permission - app uses its own Documents directory
|
||||
if (mounted) {
|
||||
setState(() => _permissionGranted = true);
|
||||
setState(() {
|
||||
_storagePermissionGranted = true;
|
||||
_notificationPermissionGranted = true;
|
||||
});
|
||||
}
|
||||
} else if (Platform.isAndroid) {
|
||||
PermissionStatus status;
|
||||
|
||||
// Check storage permission
|
||||
PermissionStatus storageStatus;
|
||||
if (_androidSdkVersion >= 33) {
|
||||
status = await Permission.audio.status;
|
||||
storageStatus = await Permission.audio.status;
|
||||
} else if (_androidSdkVersion >= 30) {
|
||||
status = await Permission.manageExternalStorage.status;
|
||||
storageStatus = await Permission.manageExternalStorage.status;
|
||||
} else {
|
||||
status = await Permission.storage.status;
|
||||
storageStatus = await Permission.storage.status;
|
||||
}
|
||||
|
||||
if (status.isGranted && mounted) {
|
||||
setState(() => _permissionGranted = true);
|
||||
// Check notification permission (Android 13+)
|
||||
PermissionStatus notificationStatus = PermissionStatus.granted;
|
||||
if (_androidSdkVersion >= 33) {
|
||||
notificationStatus = await Permission.notification.status;
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_storagePermissionGranted = storageStatus.isGranted;
|
||||
_notificationPermissionGranted = notificationStatus.isGranted;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _requestPermission() async {
|
||||
Future<void> _requestStoragePermission() async {
|
||||
setState(() => _isLoading = true);
|
||||
|
||||
try {
|
||||
if (Platform.isIOS) {
|
||||
// iOS doesn't need storage permission - app uses its own Documents directory
|
||||
setState(() => _permissionGranted = true);
|
||||
setState(() => _storagePermissionGranted = true);
|
||||
} else if (Platform.isAndroid) {
|
||||
PermissionStatus status;
|
||||
|
||||
if (_androidSdkVersion >= 33) {
|
||||
status = await Permission.audio.request();
|
||||
if (!status.isGranted) {
|
||||
await Permission.notification.request();
|
||||
}
|
||||
} else if (_androidSdkVersion >= 30) {
|
||||
status = await Permission.manageExternalStorage.request();
|
||||
} else {
|
||||
@@ -83,15 +95,13 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
}
|
||||
|
||||
if (status.isGranted) {
|
||||
setState(() => _permissionGranted = true);
|
||||
setState(() => _storagePermissionGranted = true);
|
||||
} else if (status.isPermanentlyDenied) {
|
||||
_showPermissionDeniedDialog();
|
||||
_showPermissionDeniedDialog('Storage');
|
||||
} else {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Permission denied. Please grant permission to continue.'),
|
||||
),
|
||||
const SnackBar(content: Text('Permission denied. Please grant permission to continue.')),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -99,22 +109,46 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
} catch (e) {
|
||||
debugPrint('Permission error: $e');
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Error: $e')),
|
||||
);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error: $e')));
|
||||
}
|
||||
} finally {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _showPermissionDeniedDialog() {
|
||||
Future<void> _requestNotificationPermission() async {
|
||||
setState(() => _isLoading = true);
|
||||
|
||||
try {
|
||||
if (_androidSdkVersion >= 33) {
|
||||
final status = await Permission.notification.request();
|
||||
if (status.isGranted) {
|
||||
setState(() => _notificationPermissionGranted = true);
|
||||
} else if (status.isPermanentlyDenied) {
|
||||
_showPermissionDeniedDialog('Notification');
|
||||
}
|
||||
} else {
|
||||
// Notification permission not needed for older Android
|
||||
setState(() => _notificationPermissionGranted = true);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Notification permission error: $e');
|
||||
} finally {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _skipNotificationPermission() {
|
||||
setState(() => _notificationPermissionGranted = true);
|
||||
}
|
||||
|
||||
void _showPermissionDeniedDialog(String permissionType) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Permission Required'),
|
||||
content: const Text(
|
||||
'Storage permission is required to save downloaded music files. '
|
||||
title: Text('$permissionType Permission Required'),
|
||||
content: Text(
|
||||
'$permissionType permission is required for the best experience. '
|
||||
'Please grant permission in app settings.',
|
||||
),
|
||||
actions: [
|
||||
@@ -151,18 +185,10 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Use Default Folder?'),
|
||||
content: Text(
|
||||
'No folder selected. Would you like to use the default Music folder?\n\n$defaultDir',
|
||||
),
|
||||
content: Text('No folder selected. Would you like to use the default Music folder?\n\n$defaultDir'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: const Text('Use Default'),
|
||||
),
|
||||
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')),
|
||||
TextButton(onPressed: () => Navigator.pop(context, true), child: const Text('Use Default')),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -179,7 +205,6 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
|
||||
Future<String> _getDefaultDirectory() async {
|
||||
if (Platform.isIOS) {
|
||||
// iOS: Use Documents directory (accessible via Files app)
|
||||
final appDir = await getApplicationDocumentsDirectory();
|
||||
final musicDir = Directory('${appDir.path}/SpotiFLAC');
|
||||
try {
|
||||
@@ -225,9 +250,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Error: $e')),
|
||||
);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error: $e')));
|
||||
}
|
||||
} finally {
|
||||
setState(() => _isLoading = false);
|
||||
@@ -244,9 +267,9 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
minHeight: MediaQuery.of(context).size.height -
|
||||
minHeight: math.max(0, MediaQuery.of(context).size.height -
|
||||
MediaQuery.of(context).padding.top -
|
||||
MediaQuery.of(context).padding.bottom - 48,
|
||||
MediaQuery.of(context).padding.bottom - 48),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
@@ -257,27 +280,16 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
const SizedBox(height: 24),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
child: Image.asset(
|
||||
'assets/images/logo.png',
|
||||
width: 96,
|
||||
height: 96,
|
||||
),
|
||||
child: Image.asset('assets/images/logo.png', width: 96, height: 96),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'SpotiFLAC',
|
||||
Text('SpotiFLAC',
|
||||
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
),
|
||||
fontWeight: FontWeight.bold, color: colorScheme.primary)),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Download Spotify tracks in FLAC',
|
||||
Text('Download Spotify tracks in FLAC',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
color: colorScheme.onSurfaceVariant)),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -287,9 +299,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
const SizedBox(height: 24),
|
||||
_buildStepIndicator(colorScheme),
|
||||
const SizedBox(height: 24),
|
||||
_currentStep == 0
|
||||
? _buildPermissionStep(colorScheme)
|
||||
: _buildDirectoryStep(colorScheme),
|
||||
_buildCurrentStepContent(colorScheme),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -310,24 +320,32 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
}
|
||||
|
||||
Widget _buildStepIndicator(ColorScheme colorScheme) {
|
||||
final steps = _androidSdkVersion >= 33
|
||||
? ['Storage', 'Notification', 'Folder']
|
||||
: ['Permission', 'Folder'];
|
||||
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
_buildStepDot(0, 'Permission', colorScheme),
|
||||
Container(
|
||||
width: 40,
|
||||
height: 2,
|
||||
color: _currentStep >= 1 ? colorScheme.primary : colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
_buildStepDot(1, 'Folder', colorScheme),
|
||||
for (int i = 0; i < steps.length; i++) ...[
|
||||
if (i > 0)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 20),
|
||||
child: Container(
|
||||
width: 32,
|
||||
height: 2,
|
||||
color: _currentStep >= i ? colorScheme.primary : colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
),
|
||||
_buildStepDot(i, steps[i], colorScheme),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStepDot(int step, String label, ColorScheme colorScheme) {
|
||||
final isActive = _currentStep >= step;
|
||||
final isCompleted = (step == 0 && _permissionGranted) ||
|
||||
(step == 1 && _selectedDirectory != null);
|
||||
final isCompleted = _isStepCompleted(step);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
@@ -338,86 +356,143 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
shape: BoxShape.circle,
|
||||
color: isCompleted
|
||||
? colorScheme.primary
|
||||
: isActive
|
||||
? colorScheme.primaryContainer
|
||||
: colorScheme.surfaceContainerHighest,
|
||||
: isActive ? colorScheme.primaryContainer : colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
child: Center(
|
||||
child: isCompleted
|
||||
? Icon(Icons.check, size: 18, color: colorScheme.onPrimary)
|
||||
: Text(
|
||||
'${step + 1}',
|
||||
: Text('${step + 1}',
|
||||
style: TextStyle(
|
||||
color: isActive ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
fontWeight: FontWeight.bold)),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
label,
|
||||
Text(label,
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: isActive ? colorScheme.onSurface : colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
color: isActive ? colorScheme.onSurface : colorScheme.onSurfaceVariant)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPermissionStep(ColorScheme colorScheme) {
|
||||
bool _isStepCompleted(int step) {
|
||||
if (_androidSdkVersion >= 33) {
|
||||
// 3 steps: Storage, Notification, Folder
|
||||
switch (step) {
|
||||
case 0: return _storagePermissionGranted;
|
||||
case 1: return _notificationPermissionGranted;
|
||||
case 2: return _selectedDirectory != null;
|
||||
}
|
||||
} else {
|
||||
// 2 steps: Permission, Folder
|
||||
switch (step) {
|
||||
case 0: return _storagePermissionGranted;
|
||||
case 1: return _selectedDirectory != null;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Widget _buildCurrentStepContent(ColorScheme colorScheme) {
|
||||
if (_androidSdkVersion >= 33) {
|
||||
switch (_currentStep) {
|
||||
case 0: return _buildStoragePermissionStep(colorScheme);
|
||||
case 1: return _buildNotificationPermissionStep(colorScheme);
|
||||
case 2: return _buildDirectoryStep(colorScheme);
|
||||
}
|
||||
} else {
|
||||
switch (_currentStep) {
|
||||
case 0: return _buildStoragePermissionStep(colorScheme);
|
||||
case 1: return _buildDirectoryStep(colorScheme);
|
||||
}
|
||||
}
|
||||
return const SizedBox();
|
||||
}
|
||||
|
||||
Widget _buildStoragePermissionStep(ColorScheme colorScheme) {
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
_permissionGranted ? Icons.check_circle : Icons.folder_open,
|
||||
_storagePermissionGranted ? Icons.check_circle : Icons.folder_open,
|
||||
size: 56,
|
||||
color: _permissionGranted ? colorScheme.primary : colorScheme.onSurfaceVariant,
|
||||
color: _storagePermissionGranted ? colorScheme.primary : colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_permissionGranted
|
||||
? 'Storage Permission Granted!'
|
||||
: 'Storage Permission Required',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
_storagePermissionGranted ? 'Storage Permission Granted!' : 'Storage Permission Required',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_permissionGranted
|
||||
? 'You can now select where to save your music files.'
|
||||
_storagePermissionGranted
|
||||
? 'You can now proceed to the next step.'
|
||||
: 'SpotiFLAC needs storage access to save downloaded music files to your device.',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
if (!_permissionGranted)
|
||||
if (!_storagePermissionGranted)
|
||||
FilledButton.icon(
|
||||
onPressed: _isLoading ? null : _requestPermission,
|
||||
onPressed: _isLoading ? null : _requestStoragePermission,
|
||||
icon: _isLoading
|
||||
? SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: colorScheme.onPrimary,
|
||||
),
|
||||
)
|
||||
? SizedBox(width: 20, height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2, color: colorScheme.onPrimary))
|
||||
: const Icon(Icons.security),
|
||||
label: const Text('Grant Permission'),
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||
),
|
||||
style: FilledButton.styleFrom(padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12)),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNotificationPermissionStep(ColorScheme colorScheme) {
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
_notificationPermissionGranted ? Icons.check_circle : Icons.notifications_outlined,
|
||||
size: 56,
|
||||
color: _notificationPermissionGranted ? colorScheme.primary : colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_notificationPermissionGranted ? 'Notification Permission Granted!' : 'Enable Notifications',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_notificationPermissionGranted
|
||||
? 'You will receive download progress notifications.'
|
||||
: 'Get notified about download progress and completion. This helps you track downloads when the app is in background.',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
if (!_notificationPermissionGranted) ...[
|
||||
FilledButton.icon(
|
||||
onPressed: _isLoading ? null : _requestNotificationPermission,
|
||||
icon: _isLoading
|
||||
? SizedBox(width: 20, height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2, color: colorScheme.onPrimary))
|
||||
: const Icon(Icons.notifications_active),
|
||||
label: const Text('Enable Notifications'),
|
||||
style: FilledButton.styleFrom(padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12)),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextButton(
|
||||
onPressed: _skipNotificationPermission,
|
||||
child: const Text('Skip for now'),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDirectoryStep(ColorScheme colorScheme) {
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
@@ -430,12 +505,8 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_selectedDirectory != null
|
||||
? 'Download Folder Selected!'
|
||||
: 'Choose Download Folder',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
_selectedDirectory != null ? 'Download Folder Selected!' : 'Choose Download Folder',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
@@ -452,46 +523,35 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
Icon(Icons.folder, color: colorScheme.primary, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Flexible(
|
||||
child: Text(
|
||||
_selectedDirectory!,
|
||||
child: Text(_selectedDirectory!,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
else
|
||||
Text(
|
||||
'Select a folder where your downloaded music will be saved.',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
Text('Select a folder where your downloaded music will be saved.',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||
textAlign: TextAlign.center),
|
||||
const SizedBox(height: 20),
|
||||
FilledButton.icon(
|
||||
onPressed: _isLoading ? null : _selectDirectory,
|
||||
icon: _isLoading
|
||||
? SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: colorScheme.onPrimary,
|
||||
),
|
||||
)
|
||||
? SizedBox(width: 20, height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2, color: colorScheme.onPrimary))
|
||||
: Icon(_selectedDirectory != null ? Icons.edit : Icons.folder_open),
|
||||
label: Text(_selectedDirectory != null ? 'Change Folder' : 'Select Folder'),
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||
),
|
||||
style: FilledButton.styleFrom(padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12)),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNavigationButtons(ColorScheme colorScheme) {
|
||||
final isLastStep = _currentStep == _totalSteps - 1;
|
||||
final canProceed = _isStepCompleted(_currentStep);
|
||||
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
@@ -506,41 +566,23 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
const SizedBox(width: 100),
|
||||
|
||||
// Next/Finish button
|
||||
if (_currentStep == 0)
|
||||
if (!isLastStep)
|
||||
FilledButton(
|
||||
onPressed: _permissionGranted
|
||||
? () => setState(() => _currentStep++)
|
||||
: null,
|
||||
onPressed: canProceed ? () => setState(() => _currentStep++) : null,
|
||||
child: const Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('Next'),
|
||||
SizedBox(width: 8),
|
||||
Icon(Icons.arrow_forward, size: 18),
|
||||
],
|
||||
children: [Text('Next'), SizedBox(width: 8), Icon(Icons.arrow_forward, size: 18)],
|
||||
),
|
||||
)
|
||||
else
|
||||
FilledButton(
|
||||
onPressed: _selectedDirectory != null && !_isLoading
|
||||
? _completeSetup
|
||||
: null,
|
||||
onPressed: _selectedDirectory != null && !_isLoading ? _completeSetup : null,
|
||||
child: _isLoading
|
||||
? SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: colorScheme.onPrimary,
|
||||
),
|
||||
)
|
||||
? SizedBox(width: 20, height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2, color: colorScheme.onPrimary))
|
||||
: const Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('Get Started'),
|
||||
SizedBox(width: 8),
|
||||
Icon(Icons.check, size: 18),
|
||||
],
|
||||
children: [Text('Get Started'), SizedBox(width: 8), Icon(Icons.check, size: 18)],
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -0,0 +1,817 @@
|
||||
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 ConsumerStatefulWidget {
|
||||
final DownloadHistoryItem item;
|
||||
|
||||
const TrackMetadataScreen({super.key, required this.item});
|
||||
|
||||
@override
|
||||
ConsumerState<TrackMetadataScreen> createState() => _TrackMetadataScreenState();
|
||||
}
|
||||
|
||||
class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
bool _fileExists = false;
|
||||
int? _fileSize;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_checkFile();
|
||||
}
|
||||
|
||||
Future<void> _checkFile() async {
|
||||
final file = File(widget.item.filePath);
|
||||
final exists = await file.exists();
|
||||
int? size;
|
||||
if (exists) {
|
||||
try {
|
||||
size = await file.length();
|
||||
} catch (_) {}
|
||||
}
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_fileExists = exists;
|
||||
_fileSize = size;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
DownloadHistoryItem get item => widget.item;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import 'dart:io';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:open_filex/open_filex.dart';
|
||||
|
||||
typedef ProgressCallback = void Function(int received, int total);
|
||||
|
||||
class ApkDownloader {
|
||||
static Future<String?> downloadApk({
|
||||
required String url,
|
||||
required String version,
|
||||
ProgressCallback? onProgress,
|
||||
}) async {
|
||||
try {
|
||||
final client = http.Client();
|
||||
final request = http.Request('GET', Uri.parse(url));
|
||||
final response = await client.send(request);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
print('[ApkDownloader] Failed to download: ${response.statusCode}');
|
||||
return null;
|
||||
}
|
||||
|
||||
final contentLength = response.contentLength ?? 0;
|
||||
|
||||
// Get download directory
|
||||
final dir = await getExternalStorageDirectory();
|
||||
if (dir == null) {
|
||||
print('[ApkDownloader] Could not get storage directory');
|
||||
return null;
|
||||
}
|
||||
|
||||
final filePath = '${dir.path}/SpotiFLAC-$version.apk';
|
||||
final file = File(filePath);
|
||||
|
||||
// Delete if exists
|
||||
if (await file.exists()) {
|
||||
await file.delete();
|
||||
}
|
||||
|
||||
final sink = file.openWrite();
|
||||
int received = 0;
|
||||
|
||||
await for (final chunk in response.stream) {
|
||||
sink.add(chunk);
|
||||
received += chunk.length;
|
||||
onProgress?.call(received, contentLength);
|
||||
}
|
||||
|
||||
await sink.close();
|
||||
client.close();
|
||||
|
||||
print('[ApkDownloader] Downloaded to: $filePath');
|
||||
return filePath;
|
||||
} catch (e) {
|
||||
print('[ApkDownloader] Error: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> installApk(String filePath) async {
|
||||
try {
|
||||
final result = await OpenFilex.open(filePath);
|
||||
print('[ApkDownloader] Open result: ${result.type} - ${result.message}');
|
||||
} catch (e) {
|
||||
print('[ApkDownloader] Install error: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
|
||||
class NotificationService {
|
||||
static final NotificationService _instance = NotificationService._internal();
|
||||
factory NotificationService() => _instance;
|
||||
NotificationService._internal();
|
||||
|
||||
final FlutterLocalNotificationsPlugin _notifications = FlutterLocalNotificationsPlugin();
|
||||
bool _isInitialized = false;
|
||||
|
||||
static const int downloadProgressId = 1;
|
||||
static const int updateDownloadId = 2;
|
||||
static const String channelId = 'download_progress';
|
||||
static const String channelName = 'Download Progress';
|
||||
static const String channelDescription = 'Shows download progress for tracks';
|
||||
|
||||
Future<void> initialize() async {
|
||||
if (_isInitialized) return;
|
||||
|
||||
const androidSettings = AndroidInitializationSettings('@mipmap/ic_launcher');
|
||||
const iosSettings = DarwinInitializationSettings(
|
||||
requestAlertPermission: true,
|
||||
requestBadgePermission: true,
|
||||
requestSoundPermission: false,
|
||||
);
|
||||
|
||||
const initSettings = InitializationSettings(
|
||||
android: androidSettings,
|
||||
iOS: iosSettings,
|
||||
);
|
||||
|
||||
await _notifications.initialize(initSettings);
|
||||
|
||||
// Create notification channel for Android
|
||||
if (Platform.isAndroid) {
|
||||
await _notifications
|
||||
.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>()
|
||||
?.createNotificationChannel(
|
||||
const AndroidNotificationChannel(
|
||||
channelId,
|
||||
channelName,
|
||||
description: channelDescription,
|
||||
importance: Importance.low,
|
||||
showBadge: false,
|
||||
playSound: false,
|
||||
enableVibration: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
_isInitialized = true;
|
||||
}
|
||||
|
||||
Future<void> showDownloadProgress({
|
||||
required String trackName,
|
||||
required String artistName,
|
||||
required int progress,
|
||||
required int total,
|
||||
}) async {
|
||||
if (!_isInitialized) await initialize();
|
||||
|
||||
final percentage = total > 0 ? (progress * 100 ~/ total) : 0;
|
||||
|
||||
final androidDetails = AndroidNotificationDetails(
|
||||
channelId,
|
||||
channelName,
|
||||
channelDescription: channelDescription,
|
||||
importance: Importance.low,
|
||||
priority: Priority.low,
|
||||
showProgress: true,
|
||||
maxProgress: 100,
|
||||
progress: percentage,
|
||||
ongoing: true,
|
||||
autoCancel: false,
|
||||
playSound: false,
|
||||
enableVibration: false,
|
||||
onlyAlertOnce: true,
|
||||
icon: '@mipmap/ic_launcher',
|
||||
);
|
||||
|
||||
const iosDetails = DarwinNotificationDetails(
|
||||
presentAlert: false,
|
||||
presentBadge: false,
|
||||
presentSound: false,
|
||||
);
|
||||
|
||||
final details = NotificationDetails(
|
||||
android: androidDetails,
|
||||
iOS: iosDetails,
|
||||
);
|
||||
|
||||
await _notifications.show(
|
||||
downloadProgressId,
|
||||
'Downloading $trackName',
|
||||
'$artistName • $percentage%',
|
||||
details,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> showDownloadComplete({
|
||||
required String trackName,
|
||||
required String artistName,
|
||||
int? completedCount,
|
||||
int? totalCount,
|
||||
}) async {
|
||||
if (!_isInitialized) await initialize();
|
||||
|
||||
final title = completedCount != null && totalCount != null
|
||||
? 'Download Complete ($completedCount/$totalCount)'
|
||||
: 'Download Complete';
|
||||
|
||||
const androidDetails = AndroidNotificationDetails(
|
||||
channelId,
|
||||
channelName,
|
||||
channelDescription: channelDescription,
|
||||
importance: Importance.defaultImportance,
|
||||
priority: Priority.defaultPriority,
|
||||
autoCancel: true,
|
||||
playSound: false,
|
||||
icon: '@mipmap/ic_launcher',
|
||||
);
|
||||
|
||||
const iosDetails = DarwinNotificationDetails(
|
||||
presentAlert: true,
|
||||
presentBadge: true,
|
||||
presentSound: false,
|
||||
);
|
||||
|
||||
const details = NotificationDetails(
|
||||
android: androidDetails,
|
||||
iOS: iosDetails,
|
||||
);
|
||||
|
||||
await _notifications.show(
|
||||
downloadProgressId,
|
||||
title,
|
||||
'$trackName - $artistName',
|
||||
details,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> showQueueComplete({
|
||||
required int completedCount,
|
||||
required int failedCount,
|
||||
}) async {
|
||||
if (!_isInitialized) await initialize();
|
||||
|
||||
final title = failedCount > 0
|
||||
? 'Downloads Finished ($completedCount done, $failedCount failed)'
|
||||
: 'All Downloads Complete';
|
||||
|
||||
const androidDetails = AndroidNotificationDetails(
|
||||
channelId,
|
||||
channelName,
|
||||
channelDescription: channelDescription,
|
||||
importance: Importance.defaultImportance,
|
||||
priority: Priority.defaultPriority,
|
||||
autoCancel: true,
|
||||
playSound: true,
|
||||
icon: '@mipmap/ic_launcher',
|
||||
);
|
||||
|
||||
const iosDetails = DarwinNotificationDetails(
|
||||
presentAlert: true,
|
||||
presentBadge: true,
|
||||
presentSound: true,
|
||||
);
|
||||
|
||||
const details = NotificationDetails(
|
||||
android: androidDetails,
|
||||
iOS: iosDetails,
|
||||
);
|
||||
|
||||
await _notifications.show(
|
||||
downloadProgressId,
|
||||
title,
|
||||
'$completedCount tracks downloaded successfully',
|
||||
details,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> cancelDownloadNotification() async {
|
||||
await _notifications.cancel(downloadProgressId);
|
||||
}
|
||||
|
||||
// Update APK download notifications
|
||||
Future<void> showUpdateDownloadProgress({
|
||||
required String version,
|
||||
required int received,
|
||||
required int total,
|
||||
}) async {
|
||||
if (!_isInitialized) await initialize();
|
||||
|
||||
final percentage = total > 0 ? (received * 100 ~/ total) : 0;
|
||||
final receivedMB = (received / 1024 / 1024).toStringAsFixed(1);
|
||||
final totalMB = (total / 1024 / 1024).toStringAsFixed(1);
|
||||
|
||||
final androidDetails = AndroidNotificationDetails(
|
||||
channelId,
|
||||
channelName,
|
||||
channelDescription: channelDescription,
|
||||
importance: Importance.low,
|
||||
priority: Priority.low,
|
||||
showProgress: true,
|
||||
maxProgress: 100,
|
||||
progress: percentage,
|
||||
ongoing: true,
|
||||
autoCancel: false,
|
||||
playSound: false,
|
||||
enableVibration: false,
|
||||
onlyAlertOnce: true,
|
||||
icon: '@mipmap/ic_launcher',
|
||||
);
|
||||
|
||||
const iosDetails = DarwinNotificationDetails(
|
||||
presentAlert: false,
|
||||
presentBadge: false,
|
||||
presentSound: false,
|
||||
);
|
||||
|
||||
final details = NotificationDetails(
|
||||
android: androidDetails,
|
||||
iOS: iosDetails,
|
||||
);
|
||||
|
||||
await _notifications.show(
|
||||
updateDownloadId,
|
||||
'Downloading SpotiFLAC v$version',
|
||||
'$receivedMB / $totalMB MB • $percentage%',
|
||||
details,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> showUpdateDownloadComplete({required String version}) async {
|
||||
if (!_isInitialized) await initialize();
|
||||
|
||||
const androidDetails = AndroidNotificationDetails(
|
||||
channelId,
|
||||
channelName,
|
||||
channelDescription: channelDescription,
|
||||
importance: Importance.defaultImportance,
|
||||
priority: Priority.defaultPriority,
|
||||
autoCancel: true,
|
||||
playSound: true,
|
||||
icon: '@mipmap/ic_launcher',
|
||||
);
|
||||
|
||||
const iosDetails = DarwinNotificationDetails(
|
||||
presentAlert: true,
|
||||
presentBadge: true,
|
||||
presentSound: true,
|
||||
);
|
||||
|
||||
const details = NotificationDetails(
|
||||
android: androidDetails,
|
||||
iOS: iosDetails,
|
||||
);
|
||||
|
||||
await _notifications.show(
|
||||
updateDownloadId,
|
||||
'Update Ready',
|
||||
'SpotiFLAC v$version downloaded. Tap to install.',
|
||||
details,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> showUpdateDownloadFailed() async {
|
||||
if (!_isInitialized) await initialize();
|
||||
|
||||
const androidDetails = AndroidNotificationDetails(
|
||||
channelId,
|
||||
channelName,
|
||||
channelDescription: channelDescription,
|
||||
importance: Importance.defaultImportance,
|
||||
priority: Priority.defaultPriority,
|
||||
autoCancel: true,
|
||||
icon: '@mipmap/ic_launcher',
|
||||
);
|
||||
|
||||
const iosDetails = DarwinNotificationDetails(
|
||||
presentAlert: true,
|
||||
presentBadge: false,
|
||||
presentSound: false,
|
||||
);
|
||||
|
||||
const details = NotificationDetails(
|
||||
android: androidDetails,
|
||||
iOS: iosDetails,
|
||||
);
|
||||
|
||||
await _notifications.show(
|
||||
updateDownloadId,
|
||||
'Update Failed',
|
||||
'Could not download update. Try again later.',
|
||||
details,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> cancelUpdateNotification() async {
|
||||
await _notifications.cancel(updateDownloadId);
|
||||
}
|
||||
}
|
||||
@@ -47,12 +47,14 @@ class PlatformBridge {
|
||||
String? coverUrl,
|
||||
required String outputDir,
|
||||
required String filenameFormat,
|
||||
String quality = 'LOSSLESS',
|
||||
bool embedLyrics = true,
|
||||
bool embedMaxQualityCover = true,
|
||||
int trackNumber = 1,
|
||||
int discNumber = 1,
|
||||
int totalTracks = 1,
|
||||
String? releaseDate,
|
||||
String? itemId,
|
||||
}) async {
|
||||
final request = jsonEncode({
|
||||
'isrc': isrc,
|
||||
@@ -65,12 +67,14 @@ class PlatformBridge {
|
||||
'cover_url': coverUrl,
|
||||
'output_dir': outputDir,
|
||||
'filename_format': filenameFormat,
|
||||
'quality': quality,
|
||||
'embed_lyrics': embedLyrics,
|
||||
'embed_max_quality_cover': embedMaxQualityCover,
|
||||
'track_number': trackNumber,
|
||||
'disc_number': discNumber,
|
||||
'total_tracks': totalTracks,
|
||||
'release_date': releaseDate ?? '',
|
||||
'item_id': itemId ?? '',
|
||||
});
|
||||
|
||||
final result = await _channel.invokeMethod('downloadTrack', request);
|
||||
@@ -88,6 +92,7 @@ class PlatformBridge {
|
||||
String? coverUrl,
|
||||
required String outputDir,
|
||||
required String filenameFormat,
|
||||
String quality = 'LOSSLESS',
|
||||
bool embedLyrics = true,
|
||||
bool embedMaxQualityCover = true,
|
||||
int trackNumber = 1,
|
||||
@@ -95,6 +100,7 @@ class PlatformBridge {
|
||||
int totalTracks = 1,
|
||||
String? releaseDate,
|
||||
String preferredService = 'tidal',
|
||||
String? itemId,
|
||||
}) async {
|
||||
final request = jsonEncode({
|
||||
'isrc': isrc,
|
||||
@@ -107,24 +113,47 @@ class PlatformBridge {
|
||||
'cover_url': coverUrl,
|
||||
'output_dir': outputDir,
|
||||
'filename_format': filenameFormat,
|
||||
'quality': quality,
|
||||
'embed_lyrics': embedLyrics,
|
||||
'embed_max_quality_cover': embedMaxQualityCover,
|
||||
'track_number': trackNumber,
|
||||
'disc_number': discNumber,
|
||||
'total_tracks': totalTracks,
|
||||
'release_date': releaseDate ?? '',
|
||||
'item_id': itemId ?? '',
|
||||
});
|
||||
|
||||
final result = await _channel.invokeMethod('downloadWithFallback', request);
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
/// Get download progress
|
||||
/// Get download progress (legacy single download)
|
||||
static Future<Map<String, dynamic>> getDownloadProgress() async {
|
||||
final result = await _channel.invokeMethod('getDownloadProgress');
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
/// Get progress for all active downloads (concurrent mode)
|
||||
static Future<Map<String, dynamic>> getAllDownloadProgress() async {
|
||||
final result = await _channel.invokeMethod('getAllDownloadProgress');
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
/// Initialize progress tracking for a download item
|
||||
static Future<void> initItemProgress(String itemId) async {
|
||||
await _channel.invokeMethod('initItemProgress', {'item_id': itemId});
|
||||
}
|
||||
|
||||
/// Finish progress tracking for a download item
|
||||
static Future<void> finishItemProgress(String itemId) async {
|
||||
await _channel.invokeMethod('finishItemProgress', {'item_id': itemId});
|
||||
}
|
||||
|
||||
/// Clear progress tracking for a download item
|
||||
static Future<void> clearItemProgress(String itemId) async {
|
||||
await _channel.invokeMethod('clearItemProgress', {'item_id': itemId});
|
||||
}
|
||||
|
||||
/// Set download directory
|
||||
static Future<void> setDownloadDirectory(String path) async {
|
||||
await _channel.invokeMethod('setDownloadDirectory', {'path': path});
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
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 String? apkDownloadUrl; // Direct APK download URL
|
||||
final DateTime publishedAt;
|
||||
|
||||
const UpdateInfo({
|
||||
required this.version,
|
||||
required this.changelog,
|
||||
required this.downloadUrl,
|
||||
this.apkDownloadUrl,
|
||||
required this.publishedAt,
|
||||
});
|
||||
}
|
||||
|
||||
class UpdateChecker {
|
||||
static const String _apiUrl = 'https://api.github.com/repos/${AppInfo.githubRepo}/releases/latest';
|
||||
|
||||
/// Get device CPU architecture
|
||||
static Future<String> _getDeviceArch() async {
|
||||
if (!Platform.isAndroid) return 'unknown';
|
||||
|
||||
try {
|
||||
// Read CPU info from /proc/cpuinfo
|
||||
final cpuInfo = await File('/proc/cpuinfo').readAsString();
|
||||
|
||||
// Check for 64-bit indicators
|
||||
if (cpuInfo.contains('AArch64') || cpuInfo.contains('aarch64')) {
|
||||
return 'arm64';
|
||||
}
|
||||
|
||||
// Check architecture from uname
|
||||
final result = await Process.run('uname', ['-m']);
|
||||
final arch = result.stdout.toString().trim().toLowerCase();
|
||||
|
||||
if (arch.contains('aarch64') || arch.contains('arm64')) {
|
||||
return 'arm64';
|
||||
} else if (arch.contains('armv7') || arch.contains('arm')) {
|
||||
return 'arm32';
|
||||
} else if (arch.contains('x86_64')) {
|
||||
return 'x86_64';
|
||||
} else if (arch.contains('x86') || arch.contains('i686')) {
|
||||
return 'x86';
|
||||
}
|
||||
|
||||
return 'arm64'; // Default to arm64 for modern devices
|
||||
} catch (e) {
|
||||
print('[UpdateChecker] Error detecting arch: $e');
|
||||
return 'arm64'; // Default fallback
|
||||
}
|
||||
}
|
||||
|
||||
/// 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();
|
||||
|
||||
// Find APK download URL from assets based on device architecture
|
||||
final deviceArch = await _getDeviceArch();
|
||||
print('[UpdateChecker] Device architecture: $deviceArch');
|
||||
|
||||
String? arm64Url;
|
||||
String? arm32Url;
|
||||
String? universalUrl;
|
||||
|
||||
final assets = data['assets'] as List<dynamic>? ?? [];
|
||||
for (final asset in assets) {
|
||||
final name = (asset['name'] as String? ?? '').toLowerCase();
|
||||
if (name.endsWith('.apk')) {
|
||||
final downloadUrl = asset['browser_download_url'] as String?;
|
||||
if (name.contains('arm64') || name.contains('v8a')) {
|
||||
arm64Url = downloadUrl;
|
||||
} else if (name.contains('arm32') || name.contains('v7a') || name.contains('armeabi')) {
|
||||
arm32Url = downloadUrl;
|
||||
} else if (name.contains('universal')) {
|
||||
universalUrl = downloadUrl;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Select APK based on device architecture
|
||||
String? apkUrl;
|
||||
if (deviceArch == 'arm64') {
|
||||
apkUrl = arm64Url ?? universalUrl ?? arm32Url;
|
||||
} else if (deviceArch == 'arm32') {
|
||||
apkUrl = arm32Url ?? universalUrl;
|
||||
} else {
|
||||
apkUrl = universalUrl ?? arm64Url ?? arm32Url;
|
||||
}
|
||||
|
||||
print('[UpdateChecker] Update available: $latestVersion, APK URL: $apkUrl');
|
||||
|
||||
return UpdateInfo(
|
||||
version: latestVersion,
|
||||
changelog: body,
|
||||
downloadUrl: htmlUrl,
|
||||
apkDownloadUrl: apkUrl,
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// A collapsing header widget
|
||||
/// Title collapses from large to small when scrolling
|
||||
class CollapsingHeader extends StatelessWidget {
|
||||
final String title;
|
||||
final bool showBackButton;
|
||||
final Widget? infoCard;
|
||||
final List<Widget> slivers;
|
||||
|
||||
const CollapsingHeader({
|
||||
super.key,
|
||||
required this.title,
|
||||
this.showBackButton = false,
|
||||
this.infoCard,
|
||||
required this.slivers,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final topPadding = MediaQuery.of(context).padding.top;
|
||||
|
||||
return CustomScrollView(
|
||||
slivers: [
|
||||
SliverAppBar(
|
||||
expandedHeight: 140,
|
||||
floating: false,
|
||||
pinned: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
leading: showBackButton
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
)
|
||||
: null,
|
||||
automaticallyImplyLeading: false,
|
||||
flexibleSpace: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final expandRatio = _calculateExpandRatio(constraints, topPadding);
|
||||
final animation = AlwaysStoppedAnimation(expandRatio);
|
||||
|
||||
return FlexibleSpaceBar(
|
||||
expandedTitleScale: 1.0,
|
||||
titlePadding: EdgeInsets.zero,
|
||||
title: Container(
|
||||
alignment: Alignment.bottomLeft,
|
||||
padding: EdgeInsets.only(
|
||||
left: Tween<double>(begin: showBackButton ? 56 : 24, end: 24).evaluate(animation),
|
||||
bottom: Tween<double>(begin: 16, end: 24).evaluate(animation),
|
||||
),
|
||||
child: Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontSize: Tween<double>(begin: 20, end: 28).evaluate(animation),
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// Info card if provided
|
||||
if (infoCard != null)
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
||||
child: infoCard,
|
||||
),
|
||||
),
|
||||
|
||||
// Content slivers
|
||||
...slivers,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
double _calculateExpandRatio(BoxConstraints constraints, double topPadding) {
|
||||
final maxHeight = 140;
|
||||
final minHeight = kToolbarHeight + topPadding;
|
||||
final currentHeight = constraints.maxHeight;
|
||||
final expandRatio = (currentHeight - minHeight) / (maxHeight - minHeight);
|
||||
return expandRatio.clamp(0.0, 1.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Section header for settings
|
||||
class SettingsSection extends StatelessWidget {
|
||||
final String title;
|
||||
const SettingsSection({super.key, required this.title});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
child: Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Info card widget (like version info)
|
||||
class InfoCard extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const InfoCard({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Card(
|
||||
elevation: 0,
|
||||
color: colorScheme.surfaceContainerHigh,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, color: colorScheme.onSurfaceVariant),
|
||||
const SizedBox(width: 16),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title, style: Theme.of(context).textTheme.bodyLarge),
|
||||
Text(subtitle, style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
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';
|
||||
import 'package:spotiflac_android/services/apk_downloader.dart';
|
||||
import 'package:spotiflac_android/services/notification_service.dart';
|
||||
|
||||
class UpdateDialog extends StatefulWidget {
|
||||
final UpdateInfo updateInfo;
|
||||
final VoidCallback onDismiss;
|
||||
final VoidCallback onDisableUpdates;
|
||||
|
||||
const UpdateDialog({
|
||||
super.key,
|
||||
required this.updateInfo,
|
||||
required this.onDismiss,
|
||||
required this.onDisableUpdates,
|
||||
});
|
||||
|
||||
@override
|
||||
State<UpdateDialog> createState() => _UpdateDialogState();
|
||||
}
|
||||
|
||||
class _UpdateDialogState extends State<UpdateDialog> {
|
||||
bool _isDownloading = false;
|
||||
double _progress = 0;
|
||||
String _statusText = '';
|
||||
|
||||
Future<void> _downloadAndInstall() async {
|
||||
final apkUrl = widget.updateInfo.apkDownloadUrl;
|
||||
|
||||
// If no direct APK URL, open release page
|
||||
if (apkUrl == null) {
|
||||
final uri = Uri.parse(widget.updateInfo.downloadUrl);
|
||||
if (await canLaunchUrl(uri)) {
|
||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
}
|
||||
if (mounted) Navigator.pop(context);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isDownloading = true;
|
||||
_progress = 0;
|
||||
_statusText = 'Starting download...';
|
||||
});
|
||||
|
||||
final notificationService = NotificationService();
|
||||
|
||||
final filePath = await ApkDownloader.downloadApk(
|
||||
url: apkUrl,
|
||||
version: widget.updateInfo.version,
|
||||
onProgress: (received, total) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_progress = total > 0 ? received / total : 0;
|
||||
final receivedMB = (received / 1024 / 1024).toStringAsFixed(1);
|
||||
final totalMB = (total / 1024 / 1024).toStringAsFixed(1);
|
||||
_statusText = '$receivedMB / $totalMB MB';
|
||||
});
|
||||
}
|
||||
// Update notification
|
||||
notificationService.showUpdateDownloadProgress(
|
||||
version: widget.updateInfo.version,
|
||||
received: received,
|
||||
total: total,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
if (filePath != null) {
|
||||
await notificationService.showUpdateDownloadComplete(
|
||||
version: widget.updateInfo.version,
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
|
||||
// Open APK for installation
|
||||
await ApkDownloader.installApk(filePath);
|
||||
} else {
|
||||
await notificationService.showUpdateDownloadFailed();
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isDownloading = false;
|
||||
_statusText = 'Download failed';
|
||||
});
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Failed to download update')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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${widget.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) - hide when downloading
|
||||
if (!_isDownloading)
|
||||
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(widget.updateInfo.changelog),
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Download progress
|
||||
if (_isDownloading) ...[
|
||||
const SizedBox(height: 8),
|
||||
LinearProgressIndicator(value: _progress),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_statusText,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: _isDownloading
|
||||
? [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
]
|
||||
: [
|
||||
// Don't remind again button
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
widget.onDisableUpdates();
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: Text(
|
||||
'Don\'t remind',
|
||||
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
),
|
||||
// Later button
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
widget.onDismiss();
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: const Text('Later'),
|
||||
),
|
||||
// Download button
|
||||
FilledButton(
|
||||
onPressed: _downloadAndInstall,
|
||||
child: const Text('Install'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Format changelog - clean up markdown and extract relevant content
|
||||
String _formatChangelog(String changelog) {
|
||||
// Try to extract just the changelog section (between "What's New" and "Downloads" or "---")
|
||||
var content = changelog;
|
||||
|
||||
// Find content after "What's New" header
|
||||
final whatsNewMatch = RegExp(r"###?\s*What'?s\s*New\s*\n", caseSensitive: false).firstMatch(content);
|
||||
if (whatsNewMatch != null) {
|
||||
content = content.substring(whatsNewMatch.end);
|
||||
}
|
||||
|
||||
// Cut off at "Downloads" section or horizontal rule
|
||||
final cutoffMatch = RegExp(r'\n---|\n###?\s*Downloads', caseSensitive: false).firstMatch(content);
|
||||
if (cutoffMatch != null) {
|
||||
content = content.substring(0, cutoffMatch.start);
|
||||
}
|
||||
|
||||
// Process line by line for better formatting
|
||||
final lines = content.split('\n');
|
||||
final formattedLines = <String>[];
|
||||
String? currentSection;
|
||||
|
||||
for (var line in lines) {
|
||||
line = line.trim();
|
||||
if (line.isEmpty) continue;
|
||||
|
||||
// Check if it's a section header (### Added, ### Fixed, etc.)
|
||||
final sectionMatch = RegExp(r'^#{1,3}\s*(.+)$').firstMatch(line);
|
||||
if (sectionMatch != null) {
|
||||
currentSection = sectionMatch.group(1)?.trim();
|
||||
if (currentSection != null && currentSection.isNotEmpty) {
|
||||
if (formattedLines.isNotEmpty) formattedLines.add('');
|
||||
formattedLines.add('$currentSection:');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if it's a list item
|
||||
final listMatch = RegExp(r'^[-*]\s+(.+)$').firstMatch(line);
|
||||
if (listMatch != null) {
|
||||
var itemText = listMatch.group(1) ?? '';
|
||||
// Remove bold markdown
|
||||
itemText = itemText.replaceAllMapped(
|
||||
RegExp(r'\*\*([^*]+)\*\*'),
|
||||
(m) => m.group(1) ?? ''
|
||||
);
|
||||
// Remove code markdown
|
||||
itemText = itemText.replaceAllMapped(
|
||||
RegExp(r'`([^`]+)`'),
|
||||
(m) => m.group(1) ?? ''
|
||||
);
|
||||
formattedLines.add('• $itemText');
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if it's a sub-item (indented list)
|
||||
final subListMatch = RegExp(r'^\s+[-*]\s+(.+)$').firstMatch(line);
|
||||
if (subListMatch != null) {
|
||||
var itemText = subListMatch.group(1) ?? '';
|
||||
itemText = itemText.replaceAllMapped(
|
||||
RegExp(r'\*\*([^*]+)\*\*'),
|
||||
(m) => m.group(1) ?? ''
|
||||
);
|
||||
formattedLines.add(' - $itemText');
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
var formatted = formattedLines.join('\n').trim();
|
||||
|
||||
// Limit length
|
||||
if (formatted.length > 2000) {
|
||||
formatted = '${formatted.substring(0, 2000)}...';
|
||||
}
|
||||
|
||||
return formatted.isEmpty ? 'See release notes for details.' : 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -382,6 +382,30 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.0.0"
|
||||
flutter_local_notifications:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_local_notifications
|
||||
sha256: ef41ae901e7529e52934feba19ed82827b11baa67336829564aeab3129460610
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "18.0.1"
|
||||
flutter_local_notifications_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_local_notifications_linux
|
||||
sha256: "8f685642876742c941b29c32030f6f4f6dacd0e4eaecb3efbb187d6a3812ca01"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.0.0"
|
||||
flutter_local_notifications_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_local_notifications_platform_interface
|
||||
sha256: "6c5b83c86bf819cdb177a9247a3722067dd8cc6313827ce7c77a4b238a26fd52"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.0.0"
|
||||
flutter_plugin_android_lifecycle:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1109,6 +1133,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.6.12"
|
||||
timezone:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: timezone
|
||||
sha256: dd14a3b83cfd7cb19e7888f1cbc20f258b8d71b54c06f79ac585f14093a287d1
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.10.1"
|
||||
timing:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: spotiflac_android
|
||||
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
|
||||
publish_to: 'none'
|
||||
version: 1.1.0+7
|
||||
version: 1.5.0+20
|
||||
|
||||
environment:
|
||||
sdk: ^3.10.0
|
||||
@@ -51,6 +51,9 @@ dependencies:
|
||||
# FFmpeg for audio conversion (audio-only version - much smaller)
|
||||
ffmpeg_kit_flutter_new_audio: ^2.0.0
|
||||
open_filex: ^4.7.0
|
||||
|
||||
# Notifications
|
||||
flutter_local_notifications: ^18.0.1
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# Changelog
|
||||
|
||||
## [1.1.0] - 2026-01-01
|
||||
|
||||
### Added
|
||||
- **Parallel Downloads**: Download up to 3 tracks simultaneously (configurable in Settings)
|
||||
- Default: Sequential (1 at a time) for stability
|
||||
- Options: 1, 2, or 3 concurrent downloads
|
||||
- Warning about potential rate limiting from streaming services
|
||||
- **Download Progress Tracking**: Real-time progress for BTS manifest downloads from Tidal
|
||||
- **History Persistence**: Download history now persists across app restarts using SharedPreferences
|
||||
- **Connection Pooling**: Shared HTTP transport to prevent TCP connection exhaustion during large batch downloads
|
||||
- **Connection Cleanup**: Automatic cleanup of idle connections every 50 downloads and at queue end
|
||||
- **GitHub & Credits Section**: Added links to SpotiFLAC Mobile and original SpotiFLAC desktop in Settings
|
||||
|
||||
### Fixed
|
||||
- **Download Progress Bug**: Fixed 0% → 100% jump by adding proper progress tracking for BTS format downloads
|
||||
- **TCP Connection Exhaustion**: Fixed slow downloads after ~300 tracks by implementing connection pooling and periodic cleanup
|
||||
- **Trailing Space in Names**: Fixed download failures when playlist/album/track names have trailing spaces
|
||||
- **History Loss on Debug**: History no longer disappears when sideloading via `flutter run --debug`
|
||||
|
||||
### Changed
|
||||
- Updated version to 1.1.0
|
||||
|
||||
### Technical Details
|
||||
- Added `concurrentDownloads` field to `AppSettings` model (default: 1, max: 3)
|
||||
- Implemented worker pool pattern in `DownloadQueueNotifier` for parallel processing
|
||||
- Added `SetCurrentFile()`, `SetBytesTotal()`, and `ProgressWriter` for BTS downloads in Go backend
|
||||
- Added `strings.TrimSpace()` to all string fields in `DownloadTrack()` and `DownloadWithFallback()`
|
||||
- Added shared `http.Transport` with connection pooling in `httputil.go`
|
||||
- Added `CleanupConnections()` export for Flutter to call via method channel
|
||||
|
||||
## [1.0.5] - Previous Release
|
||||
- Material Expressive 3 UI
|
||||
- Dynamic color support
|
||||
- Swipe navigation with PageView
|
||||
- Settings as bottom navigation tab
|
||||
- APK size optimization
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||