diff --git a/.dockerignore b/.dockerignore index 2653c48b..938a46c7 100644 --- a/.dockerignore +++ b/.dockerignore @@ -9,7 +9,6 @@ web/build # Flutter mobile client — built separately on developer machines / Flutter CI. # Including it in the Go build context wastes ~70 files and invalidates the # `COPY . .` layer cache on every Flutter-only change. -flutter_client/ # Docs and IDE noise docs/ diff --git a/.gitignore b/.gitignore index b2fcc709..f1619185 100644 --- a/.gitignore +++ b/.gitignore @@ -52,20 +52,6 @@ GEMINI.md .windsurfrules .aider.conf.yml -# Flutter -flutter_client/.dart_tool/ -flutter_client/.flutter-plugins -flutter_client/.flutter-plugins-dependencies -flutter_client/build/ -flutter_client/.idea/ -flutter_client/ios/Podfile.lock -flutter_client/ios/Pods/ -flutter_client/android/.gradle/ -flutter_client/android/app/build/ -flutter_client/android/local.properties -flutter_client/android/key.properties -flutter_client/*.iml - # Native Android (Kotlin/Compose) — M8 rewrite android/.gradle/ android/.kotlin/ diff --git a/android/app/src/main/java/com/fabledsword/minstrel/theme/FabledSwordTokens.kt b/android/app/src/main/java/com/fabledsword/minstrel/theme/FabledSwordTokens.kt index 7309a465..5a52e73c 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/theme/FabledSwordTokens.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/theme/FabledSwordTokens.kt @@ -5,7 +5,8 @@ import androidx.compose.ui.unit.dp /** * Raw color values for the dark surface cohort. Source of truth: - * `flutter_client/shared/fabledsword.tokens.json` (dark block). + * `shared/fabledsword.tokens.json` (dark block) — moved to the repo root + * when the Flutter client was deleted; the tokens were never its property. * * Token names are **semantic roles**, not literal colors. In dark * mode, `parchment` is light text on dark surfaces (#E8E4D8); in diff --git a/ci-requirements.md b/ci-requirements.md index cbf3592d..52bf57ca 100644 --- a/ci-requirements.md +++ b/ci-requirements.md @@ -15,11 +15,16 @@ git.fabledsword.com/bvandeusen/ci-android:36 - `ci-go:1.26` — Go server tests (`.gitea/workflows/test-go.yml`), web SPA tests (`.gitea/workflows/test-web.yml`), and the release container build (`release.yml`'s `image-release` job). - `ci-android:36` — native Kotlin/Compose client: ktlint + detekt + unit tests + debug APK (`.gitea/workflows/android.yml`), and the signed release APK (`release.yml`'s `android-release` job). -**`ci-flutter` is no longer consumed.** The M8 rewrite replaced the Flutter -client with the native Android app and `flutter.yml` was removed; `ci-android` -took its place. `flutter_client/` is still in the tree but nothing builds it. -CI-Runner still publishes `ci-flutter` and will retire it once that directory -goes — so if the Flutter client is ever revived, say so there first. +**`ci-flutter` is no longer consumed, and `ci-flutter` can now be retired.** +The M8 rewrite replaced the Flutter client with the native Android app and +`flutter.yml` was removed; `ci-android` took its place. `flutter_client/` +itself was deleted on 2026-08-16, which was the condition CI-Runner was +waiting on before dropping the image — nothing in this repo needs a Flutter +toolchain any more. + +Note this does **not** mean the `flutter-ci` runner *label* goes: the Android +jobs still schedule on it while pulling `ci-android:36`, per the label/image +split below. The label is a scheduling handle, not a toolchain assertion. ## Image deps used diff --git a/flutter_client/.gitignore b/flutter_client/.gitignore deleted file mode 100644 index 7545eaf9..00000000 --- a/flutter_client/.gitignore +++ /dev/null @@ -1,49 +0,0 @@ -# Miscellaneous -*.class -*.log -*.pyc -*.swp -.DS_Store -.atom/ -.build/ -.buildlog/ -.history -.svn/ -.swiftpm/ -migrate_working_dir/ - -# IntelliJ related -*.iml -*.ipr -*.iws -.idea/ - -# The .vscode folder contains launch configuration and tasks you configure in -# VS Code which you may wish to be included in version control, so this line -# is commented out by default. -#.vscode/ - -# Flutter/Dart/Pub related -**/doc/api/ -**/ios/Flutter/.last_build_id -.dart_tool/ -.flutter-plugins-dependencies -.pub-cache/ -.pub/ -/build/ -/coverage/ - -# Symbolication related -app.*.symbols - -# Obfuscation related -app.*.map.json - -# Android Studio will place build artifacts here -/android/app/debug -/android/app/profile -/android/app/release - -# drift codegen output (regenerated by build_runner; CI runs build_runner) -*.g.dart - diff --git a/flutter_client/.metadata b/flutter_client/.metadata deleted file mode 100644 index 9e9a413a..00000000 --- a/flutter_client/.metadata +++ /dev/null @@ -1,33 +0,0 @@ -# This file tracks properties of this Flutter project. -# Used by Flutter tool to assess capabilities and perform upgrades etc. -# -# This file should be version controlled and should not be manually edited. - -version: - revision: "02085feb3f5d8a8156e5e28512b9d99351d510c0" - channel: "stable" - -project_type: app - -# Tracks metadata for the flutter migrate command -migration: - platforms: - - platform: root - create_revision: 02085feb3f5d8a8156e5e28512b9d99351d510c0 - base_revision: 02085feb3f5d8a8156e5e28512b9d99351d510c0 - - platform: android - create_revision: 02085feb3f5d8a8156e5e28512b9d99351d510c0 - base_revision: 02085feb3f5d8a8156e5e28512b9d99351d510c0 - - platform: ios - create_revision: 02085feb3f5d8a8156e5e28512b9d99351d510c0 - base_revision: 02085feb3f5d8a8156e5e28512b9d99351d510c0 - - # User provided section - - # List of Local paths (relative to this file) that should be - # ignored by the migrate tool. - # - # Files that are not part of the templates will be ignored by default. - unmanaged_files: - - 'lib/main.dart' - - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/flutter_client/README.md b/flutter_client/README.md deleted file mode 100644 index 19c5e0d6..00000000 --- a/flutter_client/README.md +++ /dev/null @@ -1,81 +0,0 @@ -# Minstrel mobile client - -Flutter (iOS + Android) sibling of the SvelteKit web SPA. v1 first -slice ships: scaffold, auth, library browse (home / artist / album), -likes, player with background audio + lock-screen controls. - -## Setup - -```bash -cd flutter_client -flutter pub get -./tool/sync_shared.sh # copy tokens / error-copy / placeholders from ../web -dart run tool/gen_tokens.dart # regen lib/theme/tokens.dart from shared/fabledsword.tokens.json -flutter run -``` - -`sync_shared.sh` is idempotent. CI runs it on every build; rerun locally -whenever `web/src/lib/styles/tokens.json`, -`web/src/lib/styles/error-copy.json`, or -`web/static/placeholders/album-fallback.svg` changes. - -## Project layout - -See `docs/superpowers/specs/2026-05-02-flutter-mobile-foundation-design.md` -section 3. Briefly: - -- `lib/api/` — dio client, ApiError, error-copy loader, per-surface endpoints. -- `lib/auth/` — server URL screen, login screen, AuthController + secure storage. -- `lib/library/` — home, artist detail, album detail, providers, widgets. -- `lib/likes/` — LikeButton + optimistic toggle controller. -- `lib/player/` — audio handler, player provider, mini PlayerBar, NowPlaying. -- `lib/theme/` — FabledSword token Dart class (generated), ThemeExtension, ThemeData. -- `lib/shared/` — routing (go_router shell), version gate, connection error banner. - -`shared/fabledsword.tokens.json` and `assets/error-copy.json` are -synced from web/. Don't edit them directly — edit `web/src/lib/styles/` -and re-run `./tool/sync_shared.sh`. - -## Architecture - -- **State:** Riverpod 2 (`AsyncValue`, `AsyncNotifier`, family providers). -- **HTTP:** dio 5 with a Bearer-auth interceptor; 401 clears the session - and the router redirects to login. -- **Audio:** just_audio wrapped by audio_service for background - playback + lock-screen / notification controls. -- **Auth:** Bearer tokens in flutter_secure_storage. Server already - supports both cookie (web SPA) and Bearer (`internal/auth/session.go`). -- **Routing:** go_router with a ShellRoute so the PlayerBar persists - across navigation. Cold launch flow: no server-url → `/server-url`, - url set / no token → `/login`, token present → `/home`. - -## CI - -`.gitea/workflows/flutter.yml` runs on push to dev/main, tag pushes, -PR to main, and manual dispatch — path-filtered to `flutter_client/**` -plus the shared web inputs. Steps: sync, analyze, test, build APK. -Debug APKs upload as artifacts; release APKs attach to the Gitea -release on tag. - -## Versioning - -`pubspec.yaml` `version` mirrors the server tag (e.g. server `v0.1.0` -→ Flutter `0.1.0+1`). The server's `/healthz` returns -`min_client_version`; old clients show an Update Required modal and -refuse to operate. Bump `internal/server/version.go` `MinClientVersion` -when a server-side change requires a paired client update. - -## Distribution - -- **Android:** APK on the Gitea release page. No Play Store for v1. -- **iOS:** TestFlight on tag (manual upload v1, automate later). - -## Out-of-scope for slice 1 - -Search, discover, requests, settings beyond server URL, admin, -listening history, playlists, offline cache (#357). Those land in -subsequent slices with their own brainstorm/spec/plan cycles. Per the -operator's M7 ordering decision (2026-05-02), slice 2+ pauses to ship -the missing web features first (#352 playlists, #362 theme toggle, -#363 queue UI, #364 listening history, etc.) before mirroring on -Flutter. diff --git a/flutter_client/analysis_options.yaml b/flutter_client/analysis_options.yaml deleted file mode 100644 index b91a43a1..00000000 --- a/flutter_client/analysis_options.yaml +++ /dev/null @@ -1,12 +0,0 @@ -include: package:flutter_lints/flutter.yaml - -analyzer: - exclude: - - build/** - - lib/theme/tokens.dart # generated; allow magic numbers - -linter: - rules: - avoid_print: true - prefer_const_constructors: true - sort_pub_dependencies: false diff --git a/flutter_client/android/.gitignore b/flutter_client/android/.gitignore deleted file mode 100644 index be3943c9..00000000 --- a/flutter_client/android/.gitignore +++ /dev/null @@ -1,14 +0,0 @@ -gradle-wrapper.jar -/.gradle -/captures/ -/gradlew -/gradlew.bat -/local.properties -GeneratedPluginRegistrant.java -.cxx/ - -# Remember to never publicly share your keystore. -# See https://flutter.dev/to/reference-keystore -key.properties -**/*.keystore -**/*.jks diff --git a/flutter_client/android/app/build.gradle.kts b/flutter_client/android/app/build.gradle.kts deleted file mode 100644 index 54f8d478..00000000 --- a/flutter_client/android/app/build.gradle.kts +++ /dev/null @@ -1,71 +0,0 @@ -plugins { - id("com.android.application") - id("kotlin-android") - // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. - id("dev.flutter.flutter-gradle-plugin") -} - -android { - namespace = "com.fabledsword.minstrel" - compileSdk = flutter.compileSdkVersion - ndkVersion = flutter.ndkVersion - - compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - } - - kotlinOptions { - jvmTarget = JavaVersion.VERSION_17.toString() - } - - defaultConfig { - // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). - applicationId = "com.fabledsword.minstrel" - // You can update the following values to match your application needs. - // For more information, see: https://flutter.dev/to/review-gradle-config. - minSdk = flutter.minSdkVersion - targetSdk = flutter.targetSdkVersion - versionCode = flutter.versionCode - versionName = flutter.versionName - } - - // Real release signing config — populated only when CI exports - // ANDROID_KEYSTORE_PATH (decoded from a base64 secret) plus the - // matching password/alias env vars. Without these (local - // `flutter build apk --release` runs), the release build falls - // through to the debug keystore so local builds still work. - // - // Why this matters: Flutter's default `flutter run --release` and - // CI's earlier setup both signed with the per-machine debug - // keystore (~/.android/debug.keystore). Every CI runner generated - // its own debug key, so consecutive release APKs had different - // signatures and Android refused to upgrade an existing install. - val keystorePath: String? = System.getenv("ANDROID_KEYSTORE_PATH") - if (keystorePath != null && keystorePath.isNotEmpty()) { - signingConfigs { - create("release") { - storeFile = file(keystorePath) - storePassword = System.getenv("ANDROID_STORE_PASSWORD") - keyAlias = System.getenv("ANDROID_KEY_ALIAS") - keyPassword = System.getenv("ANDROID_KEY_PASSWORD") - } - } - } - - buildTypes { - release { - signingConfig = if (keystorePath != null && keystorePath.isNotEmpty()) { - signingConfigs.getByName("release") - } else { - // Local-only fallback so `flutter run --release` works - // without the keystore. CI must export ANDROID_KEYSTORE_PATH. - signingConfigs.getByName("debug") - } - } - } -} - -flutter { - source = "../.." -} diff --git a/flutter_client/android/app/src/debug/AndroidManifest.xml b/flutter_client/android/app/src/debug/AndroidManifest.xml deleted file mode 100644 index 399f6981..00000000 --- a/flutter_client/android/app/src/debug/AndroidManifest.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - diff --git a/flutter_client/android/app/src/main/AndroidManifest.xml b/flutter_client/android/app/src/main/AndroidManifest.xml deleted file mode 100644 index cfa07960..00000000 --- a/flutter_client/android/app/src/main/AndroidManifest.xml +++ /dev/null @@ -1,89 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/flutter_client/android/app/src/main/kotlin/com/fabledsword/minstrel/MainActivity.kt b/flutter_client/android/app/src/main/kotlin/com/fabledsword/minstrel/MainActivity.kt deleted file mode 100644 index 0be662c1..00000000 --- a/flutter_client/android/app/src/main/kotlin/com/fabledsword/minstrel/MainActivity.kt +++ /dev/null @@ -1,70 +0,0 @@ -package com.fabledsword.minstrel - -import android.content.Intent -import androidx.core.content.FileProvider -import com.ryanheise.audioservice.AudioServiceActivity -import io.flutter.embedding.engine.FlutterEngine -import io.flutter.plugin.common.MethodChannel -import java.io.File - -// Must extend AudioServiceActivity (not FlutterActivity) so the -// audio_service plugin can wire its FlutterEngine through. Without this -// the platform call from AudioService.init() throws PlatformException -// "The Activity class declared in your AndroidManifest.xml is wrong", -// which on first start cascades into a flutter_cache_manager sqlite -// EXCLUSIVE-lock crash because the engine partially re-initialises. -class MainActivity : AudioServiceActivity() { - - companion object { - private const val INSTALLER_CHANNEL = "com.fabledsword.minstrel/installer" - } - - override fun configureFlutterEngine(flutterEngine: FlutterEngine) { - super.configureFlutterEngine(flutterEngine) - - // In-app update channel (#397). Dart calls install(path) with - // the cache-dir APK path; we hand it to Android's - // PackageInstaller via FileProvider + ACTION_VIEW. - MethodChannel(flutterEngine.dartExecutor.binaryMessenger, INSTALLER_CHANNEL) - .setMethodCallHandler { call, result -> - when (call.method) { - "install" -> { - val path = call.argument("path") - if (path == null) { - result.error("missing_path", "path argument required", null) - return@setMethodCallHandler - } - try { - installApk(path) - result.success(null) - } catch (e: Exception) { - result.error("install_failed", e.message, null) - } - } - else -> result.notImplemented() - } - } - } - - private fun installApk(path: String) { - val file = File(path) - if (!file.exists()) { - throw IllegalStateException("apk not found at $path") - } - val uri = FileProvider.getUriForFile( - this, - "${packageName}.fileprovider", - file - ) - val intent = Intent(Intent.ACTION_VIEW).apply { - setDataAndType(uri, "application/vnd.android.package-archive") - // NEW_TASK: PackageInstaller runs in its own task. - // GRANT_READ_URI_PERMISSION: hands the content:// URI to - // the installer process, which lacks our app's read perms - // by default. - flags = Intent.FLAG_ACTIVITY_NEW_TASK or - Intent.FLAG_GRANT_READ_URI_PERMISSION - } - startActivity(intent) - } -} diff --git a/flutter_client/android/app/src/main/res/drawable-v21/launch_background.xml b/flutter_client/android/app/src/main/res/drawable-v21/launch_background.xml deleted file mode 100644 index f74085f3..00000000 --- a/flutter_client/android/app/src/main/res/drawable-v21/launch_background.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - diff --git a/flutter_client/android/app/src/main/res/drawable/ic_stat_favorite.xml b/flutter_client/android/app/src/main/res/drawable/ic_stat_favorite.xml deleted file mode 100644 index 9c9aca56..00000000 --- a/flutter_client/android/app/src/main/res/drawable/ic_stat_favorite.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - diff --git a/flutter_client/android/app/src/main/res/drawable/ic_stat_favorite_border.xml b/flutter_client/android/app/src/main/res/drawable/ic_stat_favorite_border.xml deleted file mode 100644 index 96ecf90f..00000000 --- a/flutter_client/android/app/src/main/res/drawable/ic_stat_favorite_border.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - diff --git a/flutter_client/android/app/src/main/res/drawable/launch_background.xml b/flutter_client/android/app/src/main/res/drawable/launch_background.xml deleted file mode 100644 index 304732f8..00000000 --- a/flutter_client/android/app/src/main/res/drawable/launch_background.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - diff --git a/flutter_client/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/flutter_client/android/app/src/main/res/mipmap-hdpi/ic_launcher.png deleted file mode 100644 index db77bb4b..00000000 Binary files a/flutter_client/android/app/src/main/res/mipmap-hdpi/ic_launcher.png and /dev/null differ diff --git a/flutter_client/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/flutter_client/android/app/src/main/res/mipmap-mdpi/ic_launcher.png deleted file mode 100644 index 17987b79..00000000 Binary files a/flutter_client/android/app/src/main/res/mipmap-mdpi/ic_launcher.png and /dev/null differ diff --git a/flutter_client/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/flutter_client/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png deleted file mode 100644 index 09d43914..00000000 Binary files a/flutter_client/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and /dev/null differ diff --git a/flutter_client/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/flutter_client/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png deleted file mode 100644 index d5f1c8d3..00000000 Binary files a/flutter_client/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and /dev/null differ diff --git a/flutter_client/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/flutter_client/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png deleted file mode 100644 index 4d6372ee..00000000 Binary files a/flutter_client/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and /dev/null differ diff --git a/flutter_client/android/app/src/main/res/values-night/styles.xml b/flutter_client/android/app/src/main/res/values-night/styles.xml deleted file mode 100644 index 06952be7..00000000 --- a/flutter_client/android/app/src/main/res/values-night/styles.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - diff --git a/flutter_client/android/app/src/main/res/values/styles.xml b/flutter_client/android/app/src/main/res/values/styles.xml deleted file mode 100644 index cb1ef880..00000000 --- a/flutter_client/android/app/src/main/res/values/styles.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - diff --git a/flutter_client/android/app/src/main/res/xml/file_paths.xml b/flutter_client/android/app/src/main/res/xml/file_paths.xml deleted file mode 100644 index 4e45bfed..00000000 --- a/flutter_client/android/app/src/main/res/xml/file_paths.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - - diff --git a/flutter_client/android/app/src/main/res/xml/network_security_config.xml b/flutter_client/android/app/src/main/res/xml/network_security_config.xml deleted file mode 100644 index b600a598..00000000 --- a/flutter_client/android/app/src/main/res/xml/network_security_config.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - 127.0.0.1 - - diff --git a/flutter_client/android/app/src/profile/AndroidManifest.xml b/flutter_client/android/app/src/profile/AndroidManifest.xml deleted file mode 100644 index 399f6981..00000000 --- a/flutter_client/android/app/src/profile/AndroidManifest.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - diff --git a/flutter_client/android/build.gradle.kts b/flutter_client/android/build.gradle.kts deleted file mode 100644 index dbee657b..00000000 --- a/flutter_client/android/build.gradle.kts +++ /dev/null @@ -1,24 +0,0 @@ -allprojects { - repositories { - google() - mavenCentral() - } -} - -val newBuildDir: Directory = - rootProject.layout.buildDirectory - .dir("../../build") - .get() -rootProject.layout.buildDirectory.value(newBuildDir) - -subprojects { - val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) - project.layout.buildDirectory.value(newSubprojectBuildDir) -} -subprojects { - project.evaluationDependsOn(":app") -} - -tasks.register("clean") { - delete(rootProject.layout.buildDirectory) -} diff --git a/flutter_client/android/gradle.properties b/flutter_client/android/gradle.properties deleted file mode 100644 index fbee1d8c..00000000 --- a/flutter_client/android/gradle.properties +++ /dev/null @@ -1,2 +0,0 @@ -org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError -android.useAndroidX=true diff --git a/flutter_client/android/gradle/wrapper/gradle-wrapper.properties b/flutter_client/android/gradle/wrapper/gradle-wrapper.properties deleted file mode 100644 index e4ef43fb..00000000 --- a/flutter_client/android/gradle/wrapper/gradle-wrapper.properties +++ /dev/null @@ -1,5 +0,0 @@ -distributionBase=GRADLE_USER_HOME -distributionPath=wrapper/dists -zipStoreBase=GRADLE_USER_HOME -zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip diff --git a/flutter_client/android/settings.gradle.kts b/flutter_client/android/settings.gradle.kts deleted file mode 100644 index ca7fe065..00000000 --- a/flutter_client/android/settings.gradle.kts +++ /dev/null @@ -1,26 +0,0 @@ -pluginManagement { - val flutterSdkPath = - run { - val properties = java.util.Properties() - file("local.properties").inputStream().use { properties.load(it) } - val flutterSdkPath = properties.getProperty("flutter.sdk") - require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } - flutterSdkPath - } - - includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") - - repositories { - google() - mavenCentral() - gradlePluginPortal() - } -} - -plugins { - id("dev.flutter.flutter-plugin-loader") version "1.0.0" - id("com.android.application") version "8.11.1" apply false - id("org.jetbrains.kotlin.android") version "2.2.20" apply false -} - -include(":app") diff --git a/flutter_client/assets/error-copy.json b/flutter_client/assets/error-copy.json deleted file mode 100644 index 9a29973f..00000000 --- a/flutter_client/assets/error-copy.json +++ /dev/null @@ -1,48 +0,0 @@ -{ - "unknown": "Something went wrong.", - "unauthenticated": "Your session has ended. Please sign in again.", - "auth_required": "You need to sign in to do that.", - "forbidden": "You don't have permission to do that.", - "not_authorized": "You don't have permission to do that.", - "invalid_credentials": "Wrong username or password.", - "wrong_password": "Current password is incorrect.", - "password_too_short": "Password must be at least 8 characters.", - "username_invalid": "That username isn't valid.", - "username_taken": "That username is already taken.", - "email_invalid": "Enter a valid email address.", - "email_taken": "That email is already in use.", - "invalid_token": "That link has expired or already been used. Request a new one.", - "invite_invalid": "That invite has expired or already been used.", - "invite_required": "An invite is required to register on this server.", - "last_admin": "Can't demote or delete the last admin.", - "no_email_on_file": "No email is associated with that account.", - "not_configured": "This integration isn't set up yet.", - "validation": "Some fields aren't valid. Check and try again.", - "missing_fields": "Required fields are missing.", - "missing_query": "Search needs at least one keyword.", - "invalid_id": "Invalid identifier.", - "invalid_body": "Couldn't read the request.", - "bad_request": "Invalid request.", - "bad_body": "Couldn't read the request.", - "bad_kind": "Invalid request type.", - "bad_paging": "Invalid page size or offset.", - "bad_reason": "Invalid quarantine reason.", - "mbid_required": "An MBID is required for this lookup.", - "system_playlist_readonly": "System playlists can't be edited directly.", - "connection_refused": "Couldn't reach the server. Check the URL and try again.", - "lidarr_unreachable": "Lidarr is unreachable right now. Try again, or check Admin → Integrations.", - "lidarr_disabled": "Lidarr integration is not enabled.", - "lidarr_auth_failed": "Lidarr authentication failed.", - "lidarr_defaults_incomplete": "Lidarr is missing a default quality profile or root folder. Set them in Admin → Integrations.", - "lidarr_server_error": "Lidarr returned an error. Check Lidarr's logs for the cause.", - "lidarr_rejected": "Lidarr rejected the request. Check the server logs for the field-level reason.", - "lidarr_album_lookup_failed": "Lidarr doesn't recognize this album. Try Resolve or Delete file instead.", - "album_mbid_missing": "This track has no Lidarr album to remove.", - "request_not_pending": "This request is no longer pending.", - "request_not_found": "That request no longer exists.", - "track_not_found": "That track no longer exists.", - "album_not_found": "That album no longer exists.", - "artist_not_found": "That artist no longer exists.", - "playlist_not_found": "That playlist no longer exists.", - "user_not_found": "That user no longer exists." -} diff --git a/flutter_client/assets/svg/album-fallback.svg b/flutter_client/assets/svg/album-fallback.svg deleted file mode 100644 index 9720c751..00000000 --- a/flutter_client/assets/svg/album-fallback.svg +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - - - - diff --git a/flutter_client/ios/.gitignore b/flutter_client/ios/.gitignore deleted file mode 100644 index 7a7f9873..00000000 --- a/flutter_client/ios/.gitignore +++ /dev/null @@ -1,34 +0,0 @@ -**/dgph -*.mode1v3 -*.mode2v3 -*.moved-aside -*.pbxuser -*.perspectivev3 -**/*sync/ -.sconsign.dblite -.tags* -**/.vagrant/ -**/DerivedData/ -Icon? -**/Pods/ -**/.symlinks/ -profile -xcuserdata -**/.generated/ -Flutter/App.framework -Flutter/Flutter.framework -Flutter/Flutter.podspec -Flutter/Generated.xcconfig -Flutter/ephemeral/ -Flutter/app.flx -Flutter/app.zip -Flutter/flutter_assets/ -Flutter/flutter_export_environment.sh -ServiceDefinitions.json -Runner/GeneratedPluginRegistrant.* - -# Exceptions to above rules. -!default.mode1v3 -!default.mode2v3 -!default.pbxuser -!default.perspectivev3 diff --git a/flutter_client/ios/Flutter/AppFrameworkInfo.plist b/flutter_client/ios/Flutter/AppFrameworkInfo.plist deleted file mode 100644 index 391a902b..00000000 --- a/flutter_client/ios/Flutter/AppFrameworkInfo.plist +++ /dev/null @@ -1,24 +0,0 @@ - - - - - CFBundleDevelopmentRegion - en - CFBundleExecutable - App - CFBundleIdentifier - io.flutter.flutter.app - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - App - CFBundlePackageType - FMWK - CFBundleShortVersionString - 1.0 - CFBundleSignature - ???? - CFBundleVersion - 1.0 - - diff --git a/flutter_client/ios/Flutter/Debug.xcconfig b/flutter_client/ios/Flutter/Debug.xcconfig deleted file mode 100644 index 592ceee8..00000000 --- a/flutter_client/ios/Flutter/Debug.xcconfig +++ /dev/null @@ -1 +0,0 @@ -#include "Generated.xcconfig" diff --git a/flutter_client/ios/Flutter/Release.xcconfig b/flutter_client/ios/Flutter/Release.xcconfig deleted file mode 100644 index 592ceee8..00000000 --- a/flutter_client/ios/Flutter/Release.xcconfig +++ /dev/null @@ -1 +0,0 @@ -#include "Generated.xcconfig" diff --git a/flutter_client/ios/Runner.xcodeproj/project.pbxproj b/flutter_client/ios/Runner.xcodeproj/project.pbxproj deleted file mode 100644 index e8759b3f..00000000 --- a/flutter_client/ios/Runner.xcodeproj/project.pbxproj +++ /dev/null @@ -1,620 +0,0 @@ -// !$*UTF8*$! -{ - archiveVersion = 1; - classes = { - }; - objectVersion = 54; - objects = { - -/* Begin PBXBuildFile section */ - 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; - 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; - 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; - 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; - 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; }; - 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; - 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; - 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; -/* End PBXBuildFile section */ - -/* Begin PBXContainerItemProxy section */ - 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 97C146E61CF9000F007C117D /* Project object */; - proxyType = 1; - remoteGlobalIDString = 97C146ED1CF9000F007C117D; - remoteInfo = Runner; - }; -/* End PBXContainerItemProxy section */ - -/* Begin PBXCopyFilesBuildPhase section */ - 9705A1C41CF9048500538489 /* Embed Frameworks */ = { - isa = PBXCopyFilesBuildPhase; - buildActionMask = 2147483647; - dstPath = ""; - dstSubfolderSpec = 10; - files = ( - ); - name = "Embed Frameworks"; - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXCopyFilesBuildPhase section */ - -/* Begin PBXFileReference section */ - 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; - 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; - 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; - 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; - 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; - 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; - 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; - 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; - 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; - 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; - 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; - 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; - 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; - 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; - 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; - 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; -/* End PBXFileReference section */ - -/* Begin PBXFrameworksBuildPhase section */ - 97C146EB1CF9000F007C117D /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXFrameworksBuildPhase section */ - -/* Begin PBXGroup section */ - 331C8082294A63A400263BE5 /* RunnerTests */ = { - isa = PBXGroup; - children = ( - 331C807B294A618700263BE5 /* RunnerTests.swift */, - ); - path = RunnerTests; - sourceTree = ""; - }; - 9740EEB11CF90186004384FC /* Flutter */ = { - isa = PBXGroup; - children = ( - 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, - 9740EEB21CF90195004384FC /* Debug.xcconfig */, - 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, - 9740EEB31CF90195004384FC /* Generated.xcconfig */, - ); - name = Flutter; - sourceTree = ""; - }; - 97C146E51CF9000F007C117D = { - isa = PBXGroup; - children = ( - 9740EEB11CF90186004384FC /* Flutter */, - 97C146F01CF9000F007C117D /* Runner */, - 97C146EF1CF9000F007C117D /* Products */, - 331C8082294A63A400263BE5 /* RunnerTests */, - ); - sourceTree = ""; - }; - 97C146EF1CF9000F007C117D /* Products */ = { - isa = PBXGroup; - children = ( - 97C146EE1CF9000F007C117D /* Runner.app */, - 331C8081294A63A400263BE5 /* RunnerTests.xctest */, - ); - name = Products; - sourceTree = ""; - }; - 97C146F01CF9000F007C117D /* Runner */ = { - isa = PBXGroup; - children = ( - 97C146FA1CF9000F007C117D /* Main.storyboard */, - 97C146FD1CF9000F007C117D /* Assets.xcassets */, - 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, - 97C147021CF9000F007C117D /* Info.plist */, - 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, - 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, - 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, - 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */, - 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, - ); - path = Runner; - sourceTree = ""; - }; -/* End PBXGroup section */ - -/* Begin PBXNativeTarget section */ - 331C8080294A63A400263BE5 /* RunnerTests */ = { - isa = PBXNativeTarget; - buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; - buildPhases = ( - 331C807D294A63A400263BE5 /* Sources */, - 331C807F294A63A400263BE5 /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - 331C8086294A63A400263BE5 /* PBXTargetDependency */, - ); - name = RunnerTests; - productName = RunnerTests; - productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; - productType = "com.apple.product-type.bundle.unit-test"; - }; - 97C146ED1CF9000F007C117D /* Runner */ = { - isa = PBXNativeTarget; - buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; - buildPhases = ( - 9740EEB61CF901F6004384FC /* Run Script */, - 97C146EA1CF9000F007C117D /* Sources */, - 97C146EB1CF9000F007C117D /* Frameworks */, - 97C146EC1CF9000F007C117D /* Resources */, - 9705A1C41CF9048500538489 /* Embed Frameworks */, - 3B06AD1E1E4923F5004D2608 /* Thin Binary */, - ); - buildRules = ( - ); - dependencies = ( - ); - name = Runner; - productName = Runner; - productReference = 97C146EE1CF9000F007C117D /* Runner.app */; - productType = "com.apple.product-type.application"; - }; -/* End PBXNativeTarget section */ - -/* Begin PBXProject section */ - 97C146E61CF9000F007C117D /* Project object */ = { - isa = PBXProject; - attributes = { - BuildIndependentTargetsInParallel = YES; - LastUpgradeCheck = 1510; - ORGANIZATIONNAME = ""; - TargetAttributes = { - 331C8080294A63A400263BE5 = { - CreatedOnToolsVersion = 14.0; - TestTargetID = 97C146ED1CF9000F007C117D; - }; - 97C146ED1CF9000F007C117D = { - CreatedOnToolsVersion = 7.3.1; - LastSwiftMigration = 1100; - }; - }; - }; - buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; - compatibilityVersion = "Xcode 9.3"; - developmentRegion = en; - hasScannedForEncodings = 0; - knownRegions = ( - en, - Base, - ); - mainGroup = 97C146E51CF9000F007C117D; - productRefGroup = 97C146EF1CF9000F007C117D /* Products */; - projectDirPath = ""; - projectRoot = ""; - targets = ( - 97C146ED1CF9000F007C117D /* Runner */, - 331C8080294A63A400263BE5 /* RunnerTests */, - ); - }; -/* End PBXProject section */ - -/* Begin PBXResourcesBuildPhase section */ - 331C807F294A63A400263BE5 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 97C146EC1CF9000F007C117D /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, - 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, - 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, - 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXResourcesBuildPhase section */ - -/* Begin PBXShellScriptBuildPhase section */ - 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { - isa = PBXShellScriptBuildPhase; - alwaysOutOfDate = 1; - buildActionMask = 2147483647; - files = ( - ); - inputPaths = ( - "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", - ); - name = "Thin Binary"; - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; - }; - 9740EEB61CF901F6004384FC /* Run Script */ = { - isa = PBXShellScriptBuildPhase; - alwaysOutOfDate = 1; - buildActionMask = 2147483647; - files = ( - ); - inputPaths = ( - ); - name = "Run Script"; - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; - }; -/* End PBXShellScriptBuildPhase section */ - -/* Begin PBXSourcesBuildPhase section */ - 331C807D294A63A400263BE5 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 97C146EA1CF9000F007C117D /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, - 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, - 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXSourcesBuildPhase section */ - -/* Begin PBXTargetDependency section */ - 331C8086294A63A400263BE5 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = 97C146ED1CF9000F007C117D /* Runner */; - targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; - }; -/* End PBXTargetDependency section */ - -/* Begin PBXVariantGroup section */ - 97C146FA1CF9000F007C117D /* Main.storyboard */ = { - isa = PBXVariantGroup; - children = ( - 97C146FB1CF9000F007C117D /* Base */, - ); - name = Main.storyboard; - sourceTree = ""; - }; - 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { - isa = PBXVariantGroup; - children = ( - 97C147001CF9000F007C117D /* Base */, - ); - name = LaunchScreen.storyboard; - sourceTree = ""; - }; -/* End PBXVariantGroup section */ - -/* Begin XCBuildConfiguration section */ - 249021D3217E4FDB00AE95B9 /* Profile */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - CLANG_ANALYZER_NONNULL = YES; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - ENABLE_NS_ASSERTIONS = NO; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_USER_SCRIPT_SANDBOXING = NO; - GCC_C_LANGUAGE_STANDARD = gnu99; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; - MTL_ENABLE_DEBUG_INFO = NO; - SDKROOT = iphoneos; - SUPPORTED_PLATFORMS = iphoneos; - TARGETED_DEVICE_FAMILY = "1,2"; - VALIDATE_PRODUCT = YES; - }; - name = Profile; - }; - 249021D4217E4FDB00AE95B9 /* Profile */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - ENABLE_BITCODE = NO; - INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = com.fabledsword.minstrel; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; - SWIFT_VERSION = 5.0; - VERSIONING_SYSTEM = "apple-generic"; - }; - name = Profile; - }; - 331C8088294A63A400263BE5 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - BUNDLE_LOADER = "$(TEST_HOST)"; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.fabledsword.minstrel.RunnerTests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; - }; - name = Debug; - }; - 331C8089294A63A400263BE5 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - BUNDLE_LOADER = "$(TEST_HOST)"; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.fabledsword.minstrel.RunnerTests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; - }; - name = Release; - }; - 331C808A294A63A400263BE5 /* Profile */ = { - isa = XCBuildConfiguration; - buildSettings = { - BUNDLE_LOADER = "$(TEST_HOST)"; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.fabledsword.minstrel.RunnerTests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; - }; - name = Profile; - }; - 97C147031CF9000F007C117D /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - CLANG_ANALYZER_NONNULL = YES; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = dwarf; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_TESTABILITY = YES; - ENABLE_USER_SCRIPT_SANDBOXING = NO; - GCC_C_LANGUAGE_STANDARD = gnu99; - GCC_DYNAMIC_NO_PIC = NO; - GCC_NO_COMMON_BLOCKS = YES; - GCC_OPTIMIZATION_LEVEL = 0; - GCC_PREPROCESSOR_DEFINITIONS = ( - "DEBUG=1", - "$(inherited)", - ); - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; - MTL_ENABLE_DEBUG_INFO = YES; - ONLY_ACTIVE_ARCH = YES; - SDKROOT = iphoneos; - TARGETED_DEVICE_FAMILY = "1,2"; - }; - name = Debug; - }; - 97C147041CF9000F007C117D /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - CLANG_ANALYZER_NONNULL = YES; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - ENABLE_NS_ASSERTIONS = NO; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_USER_SCRIPT_SANDBOXING = NO; - GCC_C_LANGUAGE_STANDARD = gnu99; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; - MTL_ENABLE_DEBUG_INFO = NO; - SDKROOT = iphoneos; - SUPPORTED_PLATFORMS = iphoneos; - SWIFT_COMPILATION_MODE = wholemodule; - SWIFT_OPTIMIZATION_LEVEL = "-O"; - TARGETED_DEVICE_FAMILY = "1,2"; - VALIDATE_PRODUCT = YES; - }; - name = Release; - }; - 97C147061CF9000F007C117D /* Debug */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - ENABLE_BITCODE = NO; - INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = com.fabledsword.minstrel; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; - VERSIONING_SYSTEM = "apple-generic"; - }; - name = Debug; - }; - 97C147071CF9000F007C117D /* Release */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - ENABLE_BITCODE = NO; - INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = com.fabledsword.minstrel; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; - SWIFT_VERSION = 5.0; - VERSIONING_SYSTEM = "apple-generic"; - }; - name = Release; - }; -/* End XCBuildConfiguration section */ - -/* Begin XCConfigurationList section */ - 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 331C8088294A63A400263BE5 /* Debug */, - 331C8089294A63A400263BE5 /* Release */, - 331C808A294A63A400263BE5 /* Profile */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 97C147031CF9000F007C117D /* Debug */, - 97C147041CF9000F007C117D /* Release */, - 249021D3217E4FDB00AE95B9 /* Profile */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 97C147061CF9000F007C117D /* Debug */, - 97C147071CF9000F007C117D /* Release */, - 249021D4217E4FDB00AE95B9 /* Profile */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; -/* End XCConfigurationList section */ - }; - rootObject = 97C146E61CF9000F007C117D /* Project object */; -} diff --git a/flutter_client/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/flutter_client/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata deleted file mode 100644 index 919434a6..00000000 --- a/flutter_client/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata +++ /dev/null @@ -1,7 +0,0 @@ - - - - - diff --git a/flutter_client/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/flutter_client/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist deleted file mode 100644 index 18d98100..00000000 --- a/flutter_client/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +++ /dev/null @@ -1,8 +0,0 @@ - - - - - IDEDidComputeMac32BitWarning - - - diff --git a/flutter_client/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/flutter_client/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings deleted file mode 100644 index f9b0d7c5..00000000 --- a/flutter_client/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings +++ /dev/null @@ -1,8 +0,0 @@ - - - - - PreviewsEnabled - - - diff --git a/flutter_client/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/flutter_client/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme deleted file mode 100644 index e3773d42..00000000 --- a/flutter_client/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ /dev/null @@ -1,101 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/flutter_client/ios/Runner.xcworkspace/contents.xcworkspacedata b/flutter_client/ios/Runner.xcworkspace/contents.xcworkspacedata deleted file mode 100644 index 1d526a16..00000000 --- a/flutter_client/ios/Runner.xcworkspace/contents.xcworkspacedata +++ /dev/null @@ -1,7 +0,0 @@ - - - - - diff --git a/flutter_client/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/flutter_client/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist deleted file mode 100644 index 18d98100..00000000 --- a/flutter_client/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +++ /dev/null @@ -1,8 +0,0 @@ - - - - - IDEDidComputeMac32BitWarning - - - diff --git a/flutter_client/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/flutter_client/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings deleted file mode 100644 index f9b0d7c5..00000000 --- a/flutter_client/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings +++ /dev/null @@ -1,8 +0,0 @@ - - - - - PreviewsEnabled - - - diff --git a/flutter_client/ios/Runner/AppDelegate.swift b/flutter_client/ios/Runner/AppDelegate.swift deleted file mode 100644 index c30b367e..00000000 --- a/flutter_client/ios/Runner/AppDelegate.swift +++ /dev/null @@ -1,16 +0,0 @@ -import Flutter -import UIKit - -@main -@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { - override func application( - _ application: UIApplication, - didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? - ) -> Bool { - return super.application(application, didFinishLaunchingWithOptions: launchOptions) - } - - func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { - GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) - } -} diff --git a/flutter_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/flutter_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json deleted file mode 100644 index d36b1fab..00000000 --- a/flutter_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json +++ /dev/null @@ -1,122 +0,0 @@ -{ - "images" : [ - { - "size" : "20x20", - "idiom" : "iphone", - "filename" : "Icon-App-20x20@2x.png", - "scale" : "2x" - }, - { - "size" : "20x20", - "idiom" : "iphone", - "filename" : "Icon-App-20x20@3x.png", - "scale" : "3x" - }, - { - "size" : "29x29", - "idiom" : "iphone", - "filename" : "Icon-App-29x29@1x.png", - "scale" : "1x" - }, - { - "size" : "29x29", - "idiom" : "iphone", - "filename" : "Icon-App-29x29@2x.png", - "scale" : "2x" - }, - { - "size" : "29x29", - "idiom" : "iphone", - "filename" : "Icon-App-29x29@3x.png", - "scale" : "3x" - }, - { - "size" : "40x40", - "idiom" : "iphone", - "filename" : "Icon-App-40x40@2x.png", - "scale" : "2x" - }, - { - "size" : "40x40", - "idiom" : "iphone", - "filename" : "Icon-App-40x40@3x.png", - "scale" : "3x" - }, - { - "size" : "60x60", - "idiom" : "iphone", - "filename" : "Icon-App-60x60@2x.png", - "scale" : "2x" - }, - { - "size" : "60x60", - "idiom" : "iphone", - "filename" : "Icon-App-60x60@3x.png", - "scale" : "3x" - }, - { - "size" : "20x20", - "idiom" : "ipad", - "filename" : "Icon-App-20x20@1x.png", - "scale" : "1x" - }, - { - "size" : "20x20", - "idiom" : "ipad", - "filename" : "Icon-App-20x20@2x.png", - "scale" : "2x" - }, - { - "size" : "29x29", - "idiom" : "ipad", - "filename" : "Icon-App-29x29@1x.png", - "scale" : "1x" - }, - { - "size" : "29x29", - "idiom" : "ipad", - "filename" : "Icon-App-29x29@2x.png", - "scale" : "2x" - }, - { - "size" : "40x40", - "idiom" : "ipad", - "filename" : "Icon-App-40x40@1x.png", - "scale" : "1x" - }, - { - "size" : "40x40", - "idiom" : "ipad", - "filename" : "Icon-App-40x40@2x.png", - "scale" : "2x" - }, - { - "size" : "76x76", - "idiom" : "ipad", - "filename" : "Icon-App-76x76@1x.png", - "scale" : "1x" - }, - { - "size" : "76x76", - "idiom" : "ipad", - "filename" : "Icon-App-76x76@2x.png", - "scale" : "2x" - }, - { - "size" : "83.5x83.5", - "idiom" : "ipad", - "filename" : "Icon-App-83.5x83.5@2x.png", - "scale" : "2x" - }, - { - "size" : "1024x1024", - "idiom" : "ios-marketing", - "filename" : "Icon-App-1024x1024@1x.png", - "scale" : "1x" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} diff --git a/flutter_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/flutter_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png deleted file mode 100644 index dc9ada47..00000000 Binary files a/flutter_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png and /dev/null differ diff --git a/flutter_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/flutter_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png deleted file mode 100644 index 7353c41e..00000000 Binary files a/flutter_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png and /dev/null differ diff --git a/flutter_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/flutter_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png deleted file mode 100644 index 797d452e..00000000 Binary files a/flutter_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png and /dev/null differ diff --git a/flutter_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/flutter_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png deleted file mode 100644 index 6ed2d933..00000000 Binary files a/flutter_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png and /dev/null differ diff --git a/flutter_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/flutter_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png deleted file mode 100644 index 4cd7b009..00000000 Binary files a/flutter_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png and /dev/null differ diff --git a/flutter_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/flutter_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png deleted file mode 100644 index fe730945..00000000 Binary files a/flutter_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png and /dev/null differ diff --git a/flutter_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/flutter_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png deleted file mode 100644 index 321773cd..00000000 Binary files a/flutter_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png and /dev/null differ diff --git a/flutter_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/flutter_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png deleted file mode 100644 index 797d452e..00000000 Binary files a/flutter_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png and /dev/null differ diff --git a/flutter_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/flutter_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png deleted file mode 100644 index 502f463a..00000000 Binary files a/flutter_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png and /dev/null differ diff --git a/flutter_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/flutter_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png deleted file mode 100644 index 0ec30343..00000000 Binary files a/flutter_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png and /dev/null differ diff --git a/flutter_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/flutter_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png deleted file mode 100644 index 0ec30343..00000000 Binary files a/flutter_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png and /dev/null differ diff --git a/flutter_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/flutter_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png deleted file mode 100644 index e9f5fea2..00000000 Binary files a/flutter_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png and /dev/null differ diff --git a/flutter_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/flutter_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png deleted file mode 100644 index 84ac32ae..00000000 Binary files a/flutter_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png and /dev/null differ diff --git a/flutter_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/flutter_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png deleted file mode 100644 index 8953cba0..00000000 Binary files a/flutter_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png and /dev/null differ diff --git a/flutter_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/flutter_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png deleted file mode 100644 index 0467bf12..00000000 Binary files a/flutter_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png and /dev/null differ diff --git a/flutter_client/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/flutter_client/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json deleted file mode 100644 index 0bedcf2f..00000000 --- a/flutter_client/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "images" : [ - { - "idiom" : "universal", - "filename" : "LaunchImage.png", - "scale" : "1x" - }, - { - "idiom" : "universal", - "filename" : "LaunchImage@2x.png", - "scale" : "2x" - }, - { - "idiom" : "universal", - "filename" : "LaunchImage@3x.png", - "scale" : "3x" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} diff --git a/flutter_client/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/flutter_client/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png deleted file mode 100644 index 9da19eac..00000000 Binary files a/flutter_client/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png and /dev/null differ diff --git a/flutter_client/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/flutter_client/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png deleted file mode 100644 index 9da19eac..00000000 Binary files a/flutter_client/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png and /dev/null differ diff --git a/flutter_client/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/flutter_client/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png deleted file mode 100644 index 9da19eac..00000000 Binary files a/flutter_client/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png and /dev/null differ diff --git a/flutter_client/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/flutter_client/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md deleted file mode 100644 index 89c2725b..00000000 --- a/flutter_client/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md +++ /dev/null @@ -1,5 +0,0 @@ -# Launch Screen Assets - -You can customize the launch screen with your own desired assets by replacing the image files in this directory. - -You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/flutter_client/ios/Runner/Base.lproj/LaunchScreen.storyboard b/flutter_client/ios/Runner/Base.lproj/LaunchScreen.storyboard deleted file mode 100644 index f2e259c7..00000000 --- a/flutter_client/ios/Runner/Base.lproj/LaunchScreen.storyboard +++ /dev/null @@ -1,37 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/flutter_client/ios/Runner/Base.lproj/Main.storyboard b/flutter_client/ios/Runner/Base.lproj/Main.storyboard deleted file mode 100644 index f3c28516..00000000 --- a/flutter_client/ios/Runner/Base.lproj/Main.storyboard +++ /dev/null @@ -1,26 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/flutter_client/ios/Runner/Info.plist b/flutter_client/ios/Runner/Info.plist deleted file mode 100644 index ca00e585..00000000 --- a/flutter_client/ios/Runner/Info.plist +++ /dev/null @@ -1,74 +0,0 @@ - - - - - CADisableMinimumFrameDurationOnPhone - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleDisplayName - Minstrel - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - minstrel - CFBundlePackageType - APPL - CFBundleShortVersionString - $(FLUTTER_BUILD_NAME) - CFBundleSignature - ???? - CFBundleVersion - $(FLUTTER_BUILD_NUMBER) - LSRequiresIPhoneOS - - UIApplicationSceneManifest - - UIApplicationSupportsMultipleScenes - - UISceneConfigurations - - UIWindowSceneSessionRoleApplication - - - UISceneClassName - UIWindowScene - UISceneConfigurationName - flutter - UISceneDelegateClassName - $(PRODUCT_MODULE_NAME).SceneDelegate - UISceneStoryboardFile - Main - - - - - UIApplicationSupportsIndirectInputEvents - - UIBackgroundModes - - audio - - UILaunchStoryboardName - LaunchScreen - UIMainStoryboardFile - Main - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UISupportedInterfaceOrientations~ipad - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - - diff --git a/flutter_client/ios/Runner/Runner-Bridging-Header.h b/flutter_client/ios/Runner/Runner-Bridging-Header.h deleted file mode 100644 index 308a2a56..00000000 --- a/flutter_client/ios/Runner/Runner-Bridging-Header.h +++ /dev/null @@ -1 +0,0 @@ -#import "GeneratedPluginRegistrant.h" diff --git a/flutter_client/ios/Runner/SceneDelegate.swift b/flutter_client/ios/Runner/SceneDelegate.swift deleted file mode 100644 index b9ce8ea2..00000000 --- a/flutter_client/ios/Runner/SceneDelegate.swift +++ /dev/null @@ -1,6 +0,0 @@ -import Flutter -import UIKit - -class SceneDelegate: FlutterSceneDelegate { - -} diff --git a/flutter_client/ios/RunnerTests/RunnerTests.swift b/flutter_client/ios/RunnerTests/RunnerTests.swift deleted file mode 100644 index 86a7c3b1..00000000 --- a/flutter_client/ios/RunnerTests/RunnerTests.swift +++ /dev/null @@ -1,12 +0,0 @@ -import Flutter -import UIKit -import XCTest - -class RunnerTests: XCTestCase { - - func testExample() { - // If you add code to the Runner application, consider adding tests here. - // See https://developer.apple.com/documentation/xctest for more information about using XCTest. - } - -} diff --git a/flutter_client/lib/admin/admin_landing_screen.dart b/flutter_client/lib/admin/admin_landing_screen.dart deleted file mode 100644 index 79d0a03c..00000000 --- a/flutter_client/lib/admin/admin_landing_screen.dart +++ /dev/null @@ -1,67 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_lucide/flutter_lucide.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; - -import '../shared/widgets/main_app_bar_actions.dart'; -import '../theme/theme_extension.dart'; -import 'admin_providers.dart'; -import 'widgets/admin_section_card.dart'; - -class AdminLandingScreen extends ConsumerWidget { - const AdminLandingScreen({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final fs = Theme.of(context).extension()!; - final counts = ref.watch(adminCountsProvider); - return Scaffold( - backgroundColor: fs.obsidian, - appBar: AppBar( - backgroundColor: fs.obsidian, - elevation: 0, - title: Text('Admin', style: TextStyle(color: fs.parchment)), - actions: const [MainAppBarActions(currentRoute: '/admin')], - ), - body: SafeArea( - child: counts.when( - loading: () => const Center(child: CircularProgressIndicator()), - error: (e, _) => Center( - child: Text('$e', style: TextStyle(color: fs.error)), - ), - data: (c) => RefreshIndicator( - onRefresh: () async => ref.refresh(adminCountsProvider.future), - child: ListView( - children: [ - AdminSectionCard( - key: const Key('admin_card_requests'), - icon: LucideIcons.inbox, - title: 'Requests', - subtitle: 'Approve or reject Lidarr requests', - count: c.requests, - onTap: () => context.push('/admin/requests'), - ), - AdminSectionCard( - key: const Key('admin_card_quarantine'), - icon: LucideIcons.triangle_alert, - title: 'Quarantine', - subtitle: 'Resolve scan failures', - count: c.quarantine, - onTap: () => context.push('/admin/quarantine'), - ), - AdminSectionCard( - key: const Key('admin_card_users'), - icon: LucideIcons.users, - title: 'Users', - subtitle: 'Manage accounts and invites', - count: c.users, - onTap: () => context.push('/admin/users'), - ), - ], - ), - ), - ), - ), - ); - } -} diff --git a/flutter_client/lib/admin/admin_providers.dart b/flutter_client/lib/admin/admin_providers.dart deleted file mode 100644 index dcc220e6..00000000 --- a/flutter_client/lib/admin/admin_providers.dart +++ /dev/null @@ -1,237 +0,0 @@ -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../api/endpoints/admin_invites.dart'; -import '../api/endpoints/admin_quarantine.dart'; -import '../api/endpoints/admin_requests.dart'; -import '../api/endpoints/admin_users.dart'; -import '../library/library_providers.dart' show dioProvider; -import '../models/admin_quarantine_item.dart'; -import '../models/admin_request.dart'; -import '../models/admin_user.dart'; -import '../models/invite.dart'; - -final adminRequestsApiProvider = FutureProvider((ref) async { - return AdminRequestsApi(await ref.watch(dioProvider.future)); -}); - -final adminQuarantineApiProvider = - FutureProvider((ref) async { - return AdminQuarantineApi(await ref.watch(dioProvider.future)); -}); - -final adminUsersApiProvider = FutureProvider((ref) async { - return AdminUsersApi(await ref.watch(dioProvider.future)); -}); - -final adminInvitesApiProvider = FutureProvider((ref) async { - return AdminInvitesApi(await ref.watch(dioProvider.future)); -}); - -class AdminCounts { - const AdminCounts({ - required this.requests, - required this.quarantine, - required this.users, - }); - final int requests; - final int quarantine; - final int users; -} - -/// Fans out to the three list endpoints in parallel and rolls them up -/// into `{requests, quarantine, users}` for the landing screen badges. -final adminCountsProvider = FutureProvider((ref) async { - final requestsApi = await ref.watch(adminRequestsApiProvider.future); - final quarantineApi = await ref.watch(adminQuarantineApiProvider.future); - final usersApi = await ref.watch(adminUsersApiProvider.future); - final results = await Future.wait([ - requestsApi.list(), - quarantineApi.list(), - usersApi.list(), - ]); - return AdminCounts( - requests: (results[0] as List).length, - quarantine: (results[1] as List).length, - users: (results[2] as List).length, - ); -}); - -class AdminRequestsController extends AsyncNotifier> { - @override - Future> build() async { - final api = await ref.watch(adminRequestsApiProvider.future); - return api.list(); - } - - Future approve(String id) async { - await _decide(id, (api) => api.approve(id)); - } - - Future reject(String id) async { - await _decide(id, (api) => api.reject(id)); - } - - Future _decide( - String id, - Future Function(AdminRequestsApi api) action, - ) async { - final api = await ref.read(adminRequestsApiProvider.future); - final current = state.value ?? const []; - state = AsyncData(current.where((r) => r.id != id).toList()); - try { - await action(api); - // Refresh landing badge. - ref.invalidate(adminCountsProvider); - } catch (e, st) { - state = AsyncData(current); - Error.throwWithStackTrace(e, st); - } - } -} - -final adminRequestsProvider = - AsyncNotifierProvider>( - AdminRequestsController.new); - -/// Read side originally defined for the Requests screen's -/// requester-UUID → username join. Mutation methods (setAdmin, -/// setAutoApprove, resetPassword, delete) added alongside the Users -/// screen in Slice 3. resetPassword is admin-supplies; the server -/// has no auto-generation mode. -class AdminUsersController extends AsyncNotifier> { - @override - Future> build() async { - final api = await ref.watch(adminUsersApiProvider.future); - return api.list(); - } - - /// Optimistic flip of `is_admin` on the user row; rolls back on - /// server error (including the last-admin guard 4xx). - Future setAdmin(String id, bool isAdmin) => - _patch(id, (u) => _copy(u, isAdmin: isAdmin), - (api) => api.setAdmin(id, isAdmin)); - - Future setAutoApprove(String id, bool autoApprove) => - _patch(id, (u) => _copy(u, autoApproveRequests: autoApprove), - (api) => api.setAutoApprove(id, autoApprove)); - - Future delete(String id) async { - final api = await ref.read(adminUsersApiProvider.future); - final current = state.value ?? const []; - state = AsyncData(current.where((u) => u.id != id).toList()); - try { - await api.delete(id); - ref.invalidate(adminCountsProvider); - } catch (e, st) { - state = AsyncData(current); - Error.throwWithStackTrace(e, st); - } - } - - Future resetPassword(String id, String newPassword) async { - final api = await ref.read(adminUsersApiProvider.future); - await api.resetPassword(id, newPassword); - } - - Future _patch( - String id, - AdminUser Function(AdminUser) mutate, - Future Function(AdminUsersApi api) action, - ) async { - final api = await ref.read(adminUsersApiProvider.future); - final current = state.value ?? const []; - state = AsyncData([ - for (final u in current) u.id == id ? mutate(u) : u, - ]); - try { - await action(api); - } catch (e, st) { - state = AsyncData(current); - Error.throwWithStackTrace(e, st); - } - } - - AdminUser _copy(AdminUser u, {bool? isAdmin, bool? autoApproveRequests}) => - AdminUser( - id: u.id, - username: u.username, - displayName: u.displayName, - isAdmin: isAdmin ?? u.isAdmin, - autoApproveRequests: autoApproveRequests ?? u.autoApproveRequests, - createdAt: u.createdAt, - ); -} - -final adminUsersProvider = - AsyncNotifierProvider>( - AdminUsersController.new); - -class AdminQuarantineController - extends AsyncNotifier> { - @override - Future> build() async { - final api = await ref.watch(adminQuarantineApiProvider.future); - return api.list(); - } - - Future resolve(String trackId) => - _act(trackId, (api) => api.resolve(trackId)); - Future deleteFile(String trackId) => - _act(trackId, (api) => api.deleteFile(trackId)); - Future deleteViaLidarr(String trackId) => - _act(trackId, (api) => api.deleteViaLidarr(trackId)); - - Future _act( - String trackId, - Future Function(AdminQuarantineApi api) action, - ) async { - final api = await ref.read(adminQuarantineApiProvider.future); - final current = state.value ?? const []; - state = AsyncData(current.where((q) => q.trackId != trackId).toList()); - try { - await action(api); - ref.invalidate(adminCountsProvider); - } catch (e, st) { - state = AsyncData(current); - Error.throwWithStackTrace(e, st); - } - } -} - -final adminQuarantineProvider = AsyncNotifierProvider>(AdminQuarantineController.new); - -class AdminInvitesController extends AsyncNotifier> { - @override - Future> build() async { - final api = await ref.watch(adminInvitesApiProvider.future); - return api.list(); - } - - /// Returns the freshly-minted invite so the screen can show the - /// token in a copy-once dialog. The new invite is also prepended - /// to the local list so it shows up without a refresh. - Future create({String? note}) async { - final api = await ref.read(adminInvitesApiProvider.future); - final invite = await api.create(note: note); - final current = state.value ?? const []; - state = AsyncData([invite, ...current]); - return invite; - } - - Future revoke(String token) async { - final api = await ref.read(adminInvitesApiProvider.future); - final current = state.value ?? const []; - state = AsyncData(current.where((i) => i.token != token).toList()); - try { - await api.revoke(token); - } catch (e, st) { - state = AsyncData(current); - Error.throwWithStackTrace(e, st); - } - } -} - -final adminInvitesProvider = - AsyncNotifierProvider>( - AdminInvitesController.new); diff --git a/flutter_client/lib/admin/admin_quarantine_screen.dart b/flutter_client/lib/admin/admin_quarantine_screen.dart deleted file mode 100644 index be77580a..00000000 --- a/flutter_client/lib/admin/admin_quarantine_screen.dart +++ /dev/null @@ -1,67 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../shared/live_events_provider.dart'; -import '../shared/widgets/main_app_bar_actions.dart'; -import '../theme/theme_extension.dart'; -import 'admin_providers.dart'; -import 'widgets/admin_quarantine_row.dart'; - -class AdminQuarantineScreen extends ConsumerWidget { - const AdminQuarantineScreen({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final fs = Theme.of(context).extension()!; - // #402 wire-up: any quarantine event (flag from a user / admin - // resolve / file delete / lidarr delete) refreshes the admin queue. - ref.listen>(liveEventsProvider, (_, next) { - final e = next.asData?.value; - if (e == null) return; - if (e.kind.startsWith('quarantine.')) { - ref.invalidate(adminQuarantineProvider); - } - }); - final items = ref.watch(adminQuarantineProvider); - return Scaffold( - backgroundColor: fs.obsidian, - appBar: AppBar( - backgroundColor: fs.obsidian, - elevation: 0, - title: Text('Quarantine', style: TextStyle(color: fs.parchment)), - actions: const [MainAppBarActions(currentRoute: '/admin/quarantine')], - ), - body: SafeArea( - child: items.when( - loading: () => const Center(child: CircularProgressIndicator()), - error: (e, _) => - Center(child: Text('$e', style: TextStyle(color: fs.error))), - data: (rows) { - if (rows.isEmpty) { - return Center( - child: Text('No quarantined tracks.', - style: TextStyle(color: fs.ash)), - ); - } - final notifier = ref.read(adminQuarantineProvider.notifier); - return RefreshIndicator( - onRefresh: () async => - ref.refresh(adminQuarantineProvider.future), - child: ListView.builder( - itemCount: rows.length, - itemBuilder: (_, i) => AdminQuarantineRow( - key: Key('admin_quarantine_row_${rows[i].trackId}'), - item: rows[i], - onResolve: () => notifier.resolve(rows[i].trackId), - onDeleteFile: () => notifier.deleteFile(rows[i].trackId), - onDeleteViaLidarr: () => - notifier.deleteViaLidarr(rows[i].trackId), - ), - ), - ); - }, - ), - ), - ); - } -} diff --git a/flutter_client/lib/admin/admin_requests_screen.dart b/flutter_client/lib/admin/admin_requests_screen.dart deleted file mode 100644 index 5063265e..00000000 --- a/flutter_client/lib/admin/admin_requests_screen.dart +++ /dev/null @@ -1,81 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../models/admin_user.dart'; -import '../shared/live_events_provider.dart'; -import '../shared/widgets/main_app_bar_actions.dart'; -import '../theme/theme_extension.dart'; -import 'admin_providers.dart'; -import 'widgets/admin_request_row.dart'; - -class AdminRequestsScreen extends ConsumerWidget { - const AdminRequestsScreen({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final fs = Theme.of(context).extension()!; - // #402 wire-up: invalidate the admin requests list when a user - // creates/cancels/admin approves/rejects/reconciler completes — - // request.status_changed covers all of them. - ref.listen>(liveEventsProvider, (_, next) { - final e = next.asData?.value; - if (e?.kind == 'request.status_changed') { - ref.invalidate(adminRequestsProvider); - } - }); - final requests = ref.watch(adminRequestsProvider); - // Best-effort lookup for requester usernames. If the users provider - // hasn't loaded yet, valueOrNull is null and rows fall back to the - // UUID prefix; no blocking spinner. - final users = ref.watch(adminUsersProvider).value ?? const []; - final usersById = {for (final u in users) u.id: u}; - - return Scaffold( - backgroundColor: fs.obsidian, - appBar: AppBar( - backgroundColor: fs.obsidian, - elevation: 0, - title: Text('Requests', style: TextStyle(color: fs.parchment)), - actions: const [MainAppBarActions(currentRoute: '/admin/requests')], - ), - body: SafeArea( - child: requests.when( - loading: () => const Center(child: CircularProgressIndicator()), - error: (e, _) => - Center(child: Text('$e', style: TextStyle(color: fs.error))), - data: (rows) { - if (rows.isEmpty) { - return Center( - child: Text('No pending requests.', - style: TextStyle(color: fs.ash)), - ); - } - final notifier = ref.read(adminRequestsProvider.notifier); - return RefreshIndicator( - onRefresh: () async => - ref.refresh(adminRequestsProvider.future), - child: ListView.builder( - itemCount: rows.length, - itemBuilder: (_, i) { - final req = rows[i]; - final user = usersById[req.userId]; - final display = user?.username ?? - (req.userId.length >= 8 - ? req.userId.substring(0, 8) - : req.userId); - return AdminRequestRow( - key: Key('admin_request_row_${req.id}'), - request: req, - requesterDisplay: display, - onApprove: () => notifier.approve(req.id), - onReject: () => notifier.reject(req.id), - ); - }, - ), - ); - }, - ), - ), - ); - } -} diff --git a/flutter_client/lib/admin/admin_users_screen.dart b/flutter_client/lib/admin/admin_users_screen.dart deleted file mode 100644 index 122a5891..00000000 --- a/flutter_client/lib/admin/admin_users_screen.dart +++ /dev/null @@ -1,201 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_lucide/flutter_lucide.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../shared/widgets/main_app_bar_actions.dart'; -import '../theme/theme_extension.dart'; -import 'admin_providers.dart'; -import 'widgets/admin_user_edit_sheet.dart'; -import 'widgets/admin_user_row.dart'; -import 'widgets/invite_row.dart'; - -class AdminUsersScreen extends ConsumerWidget { - const AdminUsersScreen({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final fs = Theme.of(context).extension()!; - final users = ref.watch(adminUsersProvider); - final invites = ref.watch(adminInvitesProvider); - return Scaffold( - backgroundColor: fs.obsidian, - appBar: AppBar( - backgroundColor: fs.obsidian, - elevation: 0, - title: Text('Users', style: TextStyle(color: fs.parchment)), - actions: const [MainAppBarActions(currentRoute: '/admin/users')], - ), - body: SafeArea( - child: RefreshIndicator( - onRefresh: () async { - ref.invalidate(adminUsersProvider); - ref.invalidate(adminInvitesProvider); - }, - child: ListView( - children: [ - const _SectionHeader(text: 'Users'), - users.when( - loading: () => const Padding( - padding: EdgeInsets.all(16), - child: Center(child: CircularProgressIndicator()), - ), - error: (e, _) => Padding( - padding: const EdgeInsets.all(16), - child: Text('$e', style: TextStyle(color: fs.error)), - ), - data: (rows) => Column( - children: rows - .map((u) => AdminUserRow( - key: Key('admin_user_row_${u.id}'), - user: u, - onTap: () => AdminUserEditSheet.show(context, u), - )) - .toList(), - ), - ), - const Divider(), - _SectionHeader( - text: 'Invites', - trailing: TextButton.icon( - key: const Key('invite_generate_button'), - onPressed: () => _showGenerateInvite(context, ref), - icon: Icon(LucideIcons.plus, color: fs.parchment), - label: Text('Generate', - style: TextStyle(color: fs.parchment)), - ), - ), - invites.when( - loading: () => const Padding( - padding: EdgeInsets.all(16), - child: Center(child: CircularProgressIndicator()), - ), - error: (e, _) => Padding( - padding: const EdgeInsets.all(16), - child: Text('$e', style: TextStyle(color: fs.error)), - ), - data: (rows) => Column( - children: rows - .map((i) => InviteRow( - key: Key('invite_row_${i.token}'), - invite: i, - onRevoke: () => ref - .read(adminInvitesProvider.notifier) - .revoke(i.token), - )) - .toList(), - ), - ), - ], - ), - ), - ), - ); - } - - Future _showGenerateInvite(BuildContext context, WidgetRef ref) async { - final controller = TextEditingController(); - final note = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: const Text('Generate invite'), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text('Token expires in 24 hours.'), - const SizedBox(height: 12), - TextField( - controller: controller, - decoration: const InputDecoration( - labelText: 'Note (optional)', - helperText: 'e.g. "for alice"', - ), - ), - ], - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx), - child: const Text('Cancel'), - ), - TextButton( - onPressed: () => Navigator.pop(ctx, controller.text), - child: const Text('Generate'), - ), - ], - ), - ); - if (note == null || !context.mounted) return; - try { - final invite = await ref - .read(adminInvitesProvider.notifier) - .create(note: note.isEmpty ? null : note); - if (!context.mounted) return; - await showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: const Text('Invite created'), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text('Share this token with the new user:'), - const SizedBox(height: 8), - SelectableText( - invite.token, - style: const TextStyle(fontFamily: 'JetBrainsMono'), - ), - ], - ), - actions: [ - TextButton( - onPressed: () { - Clipboard.setData(ClipboardData(text: invite.token)); - Navigator.pop(ctx); - }, - child: const Text('Copy'), - ), - TextButton( - onPressed: () => Navigator.pop(ctx), - child: const Text('Close'), - ), - ], - ), - ); - } catch (e) { - if (context.mounted) { - ScaffoldMessenger.of(context) - .showSnackBar(SnackBar(content: Text('$e'))); - } - } - } -} - -class _SectionHeader extends StatelessWidget { - const _SectionHeader({required this.text, this.trailing}); - final String text; - final Widget? trailing; - - @override - Widget build(BuildContext context) { - final fs = Theme.of(context).extension()!; - return Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), - child: Row( - children: [ - Text( - text, - style: TextStyle( - color: fs.parchment, - fontFamily: 'Fraunces', - fontSize: 18, - ), - ), - const Spacer(), - if (trailing != null) trailing!, - ], - ), - ); - } -} diff --git a/flutter_client/lib/admin/widgets/admin_quarantine_row.dart b/flutter_client/lib/admin/widgets/admin_quarantine_row.dart deleted file mode 100644 index 8221b82b..00000000 --- a/flutter_client/lib/admin/widgets/admin_quarantine_row.dart +++ /dev/null @@ -1,102 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_lucide/flutter_lucide.dart'; - -import '../../models/admin_quarantine_item.dart'; -import '../../theme/theme_extension.dart'; -import 'typed_confirm_sheet.dart'; - -class AdminQuarantineRow extends StatelessWidget { - const AdminQuarantineRow({ - super.key, - required this.item, - required this.onResolve, - required this.onDeleteFile, - required this.onDeleteViaLidarr, - }); - - final AdminQuarantineItem item; - final VoidCallback onResolve; - final VoidCallback onDeleteFile; - final VoidCallback onDeleteViaLidarr; - - @override - Widget build(BuildContext context) { - final fs = Theme.of(context).extension()!; - return ExpansionTile( - key: Key('admin_quarantine_tile_${item.trackId}'), - title: Text( - item.trackTitle, - style: TextStyle( - color: fs.parchment, - fontFamily: 'Fraunces', - fontSize: 16, - ), - ), - subtitle: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('${item.artistName} · ${item.albumTitle}', - style: TextStyle(color: fs.ash)), - Text( - '${item.reportCount} ' - '${item.reportCount == 1 ? "report" : "reports"} · ${item.topReasonSummary}', - style: TextStyle(color: fs.error, fontSize: 12), - ), - ], - ), - trailing: PopupMenuButton( - key: Key('admin_quarantine_menu_${item.trackId}'), - icon: Icon(LucideIcons.ellipsis_vertical, color: fs.parchment), - onSelected: (action) async { - switch (action) { - case 'resolve': - onResolve(); - break; - case 'delete_file': - if (await TypedConfirmSheet.show( - context, - title: 'Delete file?', - message: - 'Permanently delete the local file for "${item.trackTitle}". ' - 'Cannot be undone.', - )) { - onDeleteFile(); - } - break; - case 'delete_lidarr': - if (await TypedConfirmSheet.show( - context, - title: 'Delete via Lidarr?', - message: - 'Ask Lidarr to delete the file for "${item.trackTitle}".', - )) { - onDeleteViaLidarr(); - } - break; - } - }, - itemBuilder: (_) => const [ - PopupMenuItem(value: 'resolve', child: Text('Resolve')), - PopupMenuItem(value: 'delete_file', child: Text('Delete file')), - PopupMenuItem( - value: 'delete_lidarr', child: Text('Delete via Lidarr')), - ], - ), - childrenPadding: const EdgeInsets.only(left: 16, right: 16, bottom: 8), - children: [ - for (final report in item.reports) - ListTile( - dense: true, - title: Text( - '${report.username} — ${report.reason}', - style: TextStyle(color: fs.parchment, fontSize: 13), - ), - subtitle: report.notes != null - ? Text(report.notes!, - style: TextStyle(color: fs.ash, fontSize: 12)) - : null, - ), - ], - ); - } -} diff --git a/flutter_client/lib/admin/widgets/admin_request_row.dart b/flutter_client/lib/admin/widgets/admin_request_row.dart deleted file mode 100644 index f826556c..00000000 --- a/flutter_client/lib/admin/widgets/admin_request_row.dart +++ /dev/null @@ -1,75 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../../models/admin_request.dart'; -import '../../theme/theme_extension.dart'; - -class AdminRequestRow extends StatelessWidget { - const AdminRequestRow({ - super.key, - required this.request, - required this.requesterDisplay, - required this.onApprove, - required this.onReject, - }); - - final AdminRequest request; - - /// Pre-resolved username (or UUID-prefix fallback) for the requester. - final String requesterDisplay; - - final VoidCallback onApprove; - final VoidCallback onReject; - - @override - Widget build(BuildContext context) { - final fs = Theme.of(context).extension()!; - final subtitle = - '${request.kind} · ${request.artistName} · requested by $requesterDisplay'; - return ListTile( - title: Text( - request.displayName, - style: TextStyle( - color: fs.parchment, - fontFamily: 'Fraunces', - fontSize: 16, - ), - ), - subtitle: Text(subtitle, style: TextStyle(color: fs.ash)), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - TextButton( - onPressed: onApprove, - style: TextButton.styleFrom(foregroundColor: fs.moss), - child: const Text('Approve'), - ), - TextButton( - onPressed: () async { - final ok = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: const Text('Reject request?'), - content: Text('Reject "${request.displayName}"?'), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx, false), - child: const Text('Cancel'), - ), - TextButton( - onPressed: () => Navigator.pop(ctx, true), - style: TextButton.styleFrom(foregroundColor: fs.oxblood), - child: const Text('Reject'), - ), - ], - ), - ); - if (ok == true) onReject(); - }, - style: TextButton.styleFrom(foregroundColor: fs.oxblood), - child: const Text('Reject'), - ), - ], - ), - ); - } -} diff --git a/flutter_client/lib/admin/widgets/admin_section_card.dart b/flutter_client/lib/admin/widgets/admin_section_card.dart deleted file mode 100644 index 919441de..00000000 --- a/flutter_client/lib/admin/widgets/admin_section_card.dart +++ /dev/null @@ -1,53 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../../theme/theme_extension.dart'; - -class AdminSectionCard extends StatelessWidget { - const AdminSectionCard({ - super.key, - required this.icon, - required this.title, - required this.subtitle, - required this.count, - required this.onTap, - }); - - final IconData icon; - final String title; - final String subtitle; - final int count; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - final fs = Theme.of(context).extension()!; - return Card( - color: fs.iron, - margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - child: ListTile( - leading: Icon(icon, color: fs.parchment), - title: Text( - title, - style: TextStyle( - color: fs.parchment, - fontFamily: 'Fraunces', - fontSize: 18, - ), - ), - subtitle: Text(subtitle, style: TextStyle(color: fs.ash)), - trailing: Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), - decoration: BoxDecoration( - color: fs.bronze, - borderRadius: BorderRadius.circular(12), - ), - child: Text( - '$count', - style: TextStyle(color: fs.obsidian, fontWeight: FontWeight.w500), - ), - ), - onTap: onTap, - ), - ); - } -} diff --git a/flutter_client/lib/admin/widgets/admin_user_edit_sheet.dart b/flutter_client/lib/admin/widgets/admin_user_edit_sheet.dart deleted file mode 100644 index 870d4af4..00000000 --- a/flutter_client/lib/admin/widgets/admin_user_edit_sheet.dart +++ /dev/null @@ -1,167 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../../models/admin_user.dart'; -import '../../theme/theme_extension.dart'; -import '../admin_providers.dart'; -import 'typed_confirm_sheet.dart'; - -class AdminUserEditSheet extends ConsumerWidget { - const AdminUserEditSheet({super.key, required this.user}); - - final AdminUser user; - - static Future show(BuildContext context, AdminUser user) => - showModalBottomSheet( - context: context, - isScrollControlled: true, - builder: (_) => AdminUserEditSheet(user: user), - ); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final fs = Theme.of(context).extension()!; - final users = ref.read(adminUsersProvider.notifier); - return Padding( - padding: EdgeInsets.only( - bottom: MediaQuery.of(context).viewInsets.bottom, - ), - child: Container( - color: fs.iron, - padding: const EdgeInsets.all(20), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - user.username, - style: TextStyle( - color: fs.parchment, - fontFamily: 'Fraunces', - fontSize: 20, - ), - ), - const SizedBox(height: 12), - SwitchListTile( - key: const Key('user_edit_is_admin'), - title: Text('Admin', style: TextStyle(color: fs.parchment)), - value: user.isAdmin, - onChanged: (v) async { - try { - await users.setAdmin(user.id, v); - } catch (e) { - if (context.mounted) { - ScaffoldMessenger.of(context) - .showSnackBar(SnackBar(content: Text('$e'))); - } - } - }, - ), - SwitchListTile( - key: const Key('user_edit_auto_approve'), - title: Text('Auto-approve requests', - style: TextStyle(color: fs.parchment)), - value: user.autoApproveRequests, - onChanged: (v) async { - try { - await users.setAutoApprove(user.id, v); - } catch (e) { - if (context.mounted) { - ScaffoldMessenger.of(context) - .showSnackBar(SnackBar(content: Text('$e'))); - } - } - }, - ), - const SizedBox(height: 12), - Row( - children: [ - TextButton( - key: const Key('user_edit_reset_password'), - onPressed: () => _resetPassword(context, users), - child: const Text('Reset password'), - ), - const Spacer(), - TextButton( - key: const Key('user_edit_delete'), - style: TextButton.styleFrom(foregroundColor: fs.oxblood), - onPressed: () => _deleteUser(context, users), - child: const Text('Delete'), - ), - ], - ), - ], - ), - ), - ); - } - - Future _resetPassword( - BuildContext context, AdminUsersController users) async { - final controller = TextEditingController(); - final newPw = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: const Text('Reset password'), - content: TextField( - controller: controller, - obscureText: true, - decoration: const InputDecoration( - labelText: 'New password', - helperText: 'Minimum 8 characters', - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx), - child: const Text('Cancel'), - ), - TextButton( - onPressed: () { - if (controller.text.length >= 8) { - Navigator.pop(ctx, controller.text); - } - }, - child: const Text('Reset'), - ), - ], - ), - ); - if (newPw == null || !context.mounted) return; - try { - await users.resetPassword(user.id, newPw); - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Password reset.')), - ); - } - } catch (e) { - if (context.mounted) { - ScaffoldMessenger.of(context) - .showSnackBar(SnackBar(content: Text('$e'))); - } - } - } - - Future _deleteUser( - BuildContext context, AdminUsersController users) async { - if (!await TypedConfirmSheet.show( - context, - title: 'Delete user?', - message: - 'Permanently delete user "${user.username}" and all their data. ' - 'Cannot be undone.', - )) { - return; - } - try { - await users.delete(user.id); - if (context.mounted) Navigator.pop(context); - } catch (e) { - if (context.mounted) { - ScaffoldMessenger.of(context) - .showSnackBar(SnackBar(content: Text('$e'))); - } - } - } -} diff --git a/flutter_client/lib/admin/widgets/admin_user_row.dart b/flutter_client/lib/admin/widgets/admin_user_row.dart deleted file mode 100644 index e4deee49..00000000 --- a/flutter_client/lib/admin/widgets/admin_user_row.dart +++ /dev/null @@ -1,59 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_lucide/flutter_lucide.dart'; - -import '../../models/admin_user.dart'; -import '../../theme/theme_extension.dart'; - -class AdminUserRow extends StatelessWidget { - const AdminUserRow({super.key, required this.user, required this.onTap}); - - final AdminUser user; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - final fs = Theme.of(context).extension()!; - return ListTile( - title: Text( - user.username, - style: TextStyle( - color: fs.parchment, - fontFamily: 'Fraunces', - fontSize: 16, - ), - ), - subtitle: Wrap( - spacing: 6, - children: [ - if (user.isAdmin) _Badge(label: 'admin', color: fs.bronze), - if (user.autoApproveRequests) - _Badge(label: 'auto-approve', color: fs.moss), - ], - ), - trailing: Icon(LucideIcons.chevron_right, color: fs.ash), - onTap: onTap, - ); - } -} - -class _Badge extends StatelessWidget { - const _Badge({required this.label, required this.color}); - final String label; - final Color color; - - @override - Widget build(BuildContext context) { - final fs = Theme.of(context).extension()!; - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), - decoration: BoxDecoration( - color: color, - borderRadius: BorderRadius.circular(8), - ), - child: Text( - label, - style: TextStyle(color: fs.obsidian, fontSize: 11), - ), - ); - } -} diff --git a/flutter_client/lib/admin/widgets/invite_row.dart b/flutter_client/lib/admin/widgets/invite_row.dart deleted file mode 100644 index 5a9987dd..00000000 --- a/flutter_client/lib/admin/widgets/invite_row.dart +++ /dev/null @@ -1,73 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_lucide/flutter_lucide.dart'; -import 'package:flutter/services.dart'; - -import '../../models/invite.dart'; -import '../../theme/theme_extension.dart'; - -class InviteRow extends StatelessWidget { - const InviteRow({ - super.key, - required this.invite, - required this.onRevoke, - }); - - final Invite invite; - final VoidCallback onRevoke; - - @override - Widget build(BuildContext context) { - final fs = Theme.of(context).extension()!; - return ListTile( - title: Row( - children: [ - Expanded( - child: SelectableText( - invite.token, - style: TextStyle( - color: fs.parchment, - fontFamily: 'JetBrainsMono', - fontSize: 13, - ), - ), - ), - IconButton( - icon: Icon(LucideIcons.copy, color: fs.ash, size: 18), - tooltip: 'Copy', - onPressed: () => - Clipboard.setData(ClipboardData(text: invite.token)), - ), - ], - ), - subtitle: Wrap( - spacing: 8, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - Text('expires ${invite.expiresAt}', - style: TextStyle(color: fs.ash, fontSize: 12)), - if (invite.note != null) - Text('· ${invite.note}', - style: TextStyle(color: fs.ash, fontSize: 12)), - if (invite.isRedeemed) - Container( - padding: - const EdgeInsets.symmetric(horizontal: 6, vertical: 1), - decoration: BoxDecoration( - color: fs.ash, - borderRadius: BorderRadius.circular(6), - ), - child: Text( - 'redeemed', - style: TextStyle(color: fs.obsidian, fontSize: 10), - ), - ), - ], - ), - trailing: IconButton( - icon: Icon(LucideIcons.trash_2, color: fs.oxblood), - tooltip: 'Revoke', - onPressed: onRevoke, - ), - ); - } -} diff --git a/flutter_client/lib/admin/widgets/typed_confirm_sheet.dart b/flutter_client/lib/admin/widgets/typed_confirm_sheet.dart deleted file mode 100644 index 2904e7ae..00000000 --- a/flutter_client/lib/admin/widgets/typed_confirm_sheet.dart +++ /dev/null @@ -1,124 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../../theme/theme_extension.dart'; - -/// Modal bottom sheet that requires the user to type [confirmWord] -/// (default "DELETE") before the confirm button enables. Used for -/// destructive actions like quarantine delete-file / delete-via-Lidarr -/// and user delete. -/// -/// Returns true if the user confirmed, false (or null → false) otherwise. -class TypedConfirmSheet extends StatefulWidget { - const TypedConfirmSheet({ - super.key, - required this.title, - required this.message, - this.confirmWord = 'DELETE', - this.confirmLabel = 'Delete', - }); - - final String title; - final String message; - final String confirmWord; - final String confirmLabel; - - static Future show( - BuildContext context, { - required String title, - required String message, - String confirmWord = 'DELETE', - String confirmLabel = 'Delete', - }) async { - final ok = await showModalBottomSheet( - context: context, - isScrollControlled: true, - builder: (_) => TypedConfirmSheet( - title: title, - message: message, - confirmWord: confirmWord, - confirmLabel: confirmLabel, - ), - ); - return ok ?? false; - } - - @override - State createState() => _TypedConfirmSheetState(); -} - -class _TypedConfirmSheetState extends State { - final _controller = TextEditingController(); - bool _enabled = false; - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final fs = Theme.of(context).extension()!; - return Padding( - padding: EdgeInsets.only( - bottom: MediaQuery.of(context).viewInsets.bottom, - ), - child: Container( - color: fs.iron, - padding: const EdgeInsets.all(20), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - widget.title, - style: TextStyle( - color: fs.parchment, - fontFamily: 'Fraunces', - fontSize: 20, - ), - ), - const SizedBox(height: 12), - Text(widget.message, style: TextStyle(color: fs.ash)), - const SizedBox(height: 16), - Text( - 'Type ${widget.confirmWord} to confirm:', - style: TextStyle(color: fs.ash), - ), - const SizedBox(height: 8), - TextField( - key: const Key('typed_confirm_input'), - controller: _controller, - autofocus: true, - style: TextStyle(color: fs.parchment, fontFamily: 'JetBrainsMono'), - decoration: const InputDecoration(border: OutlineInputBorder()), - onChanged: (v) => - setState(() => _enabled = v == widget.confirmWord), - ), - const SizedBox(height: 16), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - TextButton( - onPressed: () => Navigator.pop(context, false), - child: const Text('Cancel'), - ), - const SizedBox(width: 8), - ElevatedButton( - key: const Key('typed_confirm_button'), - onPressed: - _enabled ? () => Navigator.pop(context, true) : null, - style: ElevatedButton.styleFrom( - backgroundColor: fs.oxblood, - foregroundColor: fs.parchment, - ), - child: Text(widget.confirmLabel), - ), - ], - ), - ], - ), - ), - ); - } -} diff --git a/flutter_client/lib/api/client.dart b/flutter_client/lib/api/client.dart deleted file mode 100644 index dd6363fa..00000000 --- a/flutter_client/lib/api/client.dart +++ /dev/null @@ -1,41 +0,0 @@ -import 'package:dio/dio.dart'; - -typedef TokenResolver = Future Function(); -typedef OnUnauthenticated = Future Function(); - -class ApiClient { - /// Builds a dio instance pinned to [baseUrl] with a Bearer-auth - /// interceptor that pulls the current token from [tokenResolver] - /// on every request. Server already supports cookie OR bearer - /// (internal/auth/session.go); we use bearer to skip cookie-jar. - /// - /// [on401] fires when any response comes back 401. Use it to clear - /// stored credentials so the router redirect can route back to login. - static Dio buildDio({ - required String baseUrl, - required TokenResolver tokenResolver, - OnUnauthenticated? on401, - }) { - final d = Dio(BaseOptions( - baseUrl: baseUrl, - connectTimeout: const Duration(seconds: 8), - receiveTimeout: const Duration(seconds: 30), - contentType: Headers.jsonContentType, - responseType: ResponseType.json, - )); - d.interceptors.add(InterceptorsWrapper( - onRequest: (opts, h) async { - final t = await tokenResolver(); - if (t != null && t.isNotEmpty) opts.headers['Authorization'] = 'Bearer $t'; - h.next(opts); - }, - onError: (e, h) async { - if (e.response?.statusCode == 401 && on401 != null) { - await on401(); - } - h.next(e); - }, - )); - return d; - } -} diff --git a/flutter_client/lib/api/endpoints/admin_invites.dart b/flutter_client/lib/api/endpoints/admin_invites.dart deleted file mode 100644 index e5ab6142..00000000 --- a/flutter_client/lib/api/endpoints/admin_invites.dart +++ /dev/null @@ -1,32 +0,0 @@ -import 'package:dio/dio.dart'; - -import '../../models/invite.dart'; - -class AdminInvitesApi { - AdminInvitesApi(this._dio); - final Dio _dio; - - /// GET /api/admin/invites → `{"invites": [...]}`. Envelope unwrapped. - Future> list() async { - final r = await _dio.get>('/api/admin/invites'); - final raw = (r.data?['invites'] as List?) ?? const []; - return raw - .map((e) => Invite.fromJson((e as Map).cast())) - .toList(growable: false); - } - - /// POST /api/admin/invites — body is optional `{"note": "..."}`. - /// Server hardcodes the 24h TTL; admins cannot configure expiry. - /// Returns the bare invite (not enveloped). - Future create({String? note}) async { - final r = await _dio.post>( - '/api/admin/invites', - data: {if (note != null && note.isNotEmpty) 'note': note}, - ); - return Invite.fromJson(r.data ?? const {}); - } - - Future revoke(String token) async { - await _dio.delete('/api/admin/invites/$token'); - } -} diff --git a/flutter_client/lib/api/endpoints/admin_quarantine.dart b/flutter_client/lib/api/endpoints/admin_quarantine.dart deleted file mode 100644 index e72736c0..00000000 --- a/flutter_client/lib/api/endpoints/admin_quarantine.dart +++ /dev/null @@ -1,31 +0,0 @@ -import 'package:dio/dio.dart'; - -import '../../models/admin_quarantine_item.dart'; - -class AdminQuarantineApi { - AdminQuarantineApi(this._dio); - final Dio _dio; - - /// GET /api/admin/quarantine — flat list (no envelope) of aggregated - /// quarantine rows. - Future> list() async { - final r = await _dio.get>('/api/admin/quarantine'); - final raw = r.data ?? const []; - return raw - .map((e) => - AdminQuarantineItem.fromJson((e as Map).cast())) - .toList(growable: false); - } - - Future resolve(String trackId) async { - await _dio.post('/api/admin/quarantine/$trackId/resolve'); - } - - Future deleteFile(String trackId) async { - await _dio.post('/api/admin/quarantine/$trackId/delete-file'); - } - - Future deleteViaLidarr(String trackId) async { - await _dio.post('/api/admin/quarantine/$trackId/delete-via-lidarr'); - } -} diff --git a/flutter_client/lib/api/endpoints/admin_requests.dart b/flutter_client/lib/api/endpoints/admin_requests.dart deleted file mode 100644 index 519cd7bd..00000000 --- a/flutter_client/lib/api/endpoints/admin_requests.dart +++ /dev/null @@ -1,26 +0,0 @@ -import 'package:dio/dio.dart'; - -import '../../models/admin_request.dart'; - -class AdminRequestsApi { - AdminRequestsApi(this._dio); - final Dio _dio; - - /// GET /api/admin/requests — flat list of pending requests. - /// Server defaults to ?status=pending; we don't override. - Future> list() async { - final r = await _dio.get>('/api/admin/requests'); - final raw = r.data ?? const []; - return raw - .map((e) => AdminRequest.fromJson((e as Map).cast())) - .toList(growable: false); - } - - Future approve(String id) async { - await _dio.post('/api/admin/requests/$id/approve'); - } - - Future reject(String id) async { - await _dio.post('/api/admin/requests/$id/reject'); - } -} diff --git a/flutter_client/lib/api/endpoints/admin_users.dart b/flutter_client/lib/api/endpoints/admin_users.dart deleted file mode 100644 index 92252c57..00000000 --- a/flutter_client/lib/api/endpoints/admin_users.dart +++ /dev/null @@ -1,47 +0,0 @@ -import 'package:dio/dio.dart'; - -import '../../models/admin_user.dart'; - -class AdminUsersApi { - AdminUsersApi(this._dio); - final Dio _dio; - - /// GET /api/admin/users → `{"users": [...]}`. Envelope unwrapped here. - Future> list() async { - final r = await _dio.get>('/api/admin/users'); - final raw = (r.data?['users'] as List?) ?? const []; - return raw - .map((e) => AdminUser.fromJson((e as Map).cast())) - .toList(growable: false); - } - - Future setAdmin(String id, bool isAdmin) async { - await _dio.put( - '/api/admin/users/$id/admin', - data: {'is_admin': isAdmin}, - ); - } - - /// Server's body field is `auto_approve` (NOT `auto_approve_requests`) - /// — different from the response field name. Verified against - /// internal/api/admin_users.go `adminAutoApproveReq` (May 2026). - Future setAutoApprove(String id, bool autoApprove) async { - await _dio.put( - '/api/admin/users/$id/auto-approve', - data: {'auto_approve': autoApprove}, - ); - } - - /// Admin supplies the new password; server returns 204. There is no - /// server-generated-password mode. - Future resetPassword(String id, String newPassword) async { - await _dio.post( - '/api/admin/users/$id/reset-password', - data: {'password': newPassword}, - ); - } - - Future delete(String id) async { - await _dio.delete('/api/admin/users/$id'); - } -} diff --git a/flutter_client/lib/api/endpoints/auth.dart b/flutter_client/lib/api/endpoints/auth.dart deleted file mode 100644 index 0f5b2983..00000000 --- a/flutter_client/lib/api/endpoints/auth.dart +++ /dev/null @@ -1,33 +0,0 @@ -import 'dart:convert'; - -import 'package:dio/dio.dart'; - -import '../../models/user.dart'; - -class AuthApi { - AuthApi(this._dio); - final Dio _dio; - - /// Returns (token, user, rawUserJson) on success. Server emits - /// LoginResponse{token, user{id, username, is_admin}}. - Future<({String token, User user, String rawUserJson})> login({ - required String username, - required String password, - }) async { - final r = await _dio.post>( - '/api/auth/login', - data: {'username': username, 'password': password}, - ); - final body = r.data!; - final userMap = (body['user'] as Map).cast(); - return ( - token: body['token'] as String, - user: User.fromJson(userMap), - rawUserJson: jsonEncode(userMap), - ); - } - - Future logout() async { - await _dio.post('/api/auth/logout'); - } -} diff --git a/flutter_client/lib/api/endpoints/discover.dart b/flutter_client/lib/api/endpoints/discover.dart deleted file mode 100644 index 1a6d2f4f..00000000 --- a/flutter_client/lib/api/endpoints/discover.dart +++ /dev/null @@ -1,73 +0,0 @@ -import 'package:dio/dio.dart'; - -import '../../models/artist_suggestion.dart'; -import '../../models/lidarr.dart'; - -class DiscoverApi { - DiscoverApi(this._dio); - final Dio _dio; - - /// GET /api/discover/suggestions — out-of-library artist suggestions - /// (ListenBrainz-derived; image_url resolved on-demand from Lidarr, - /// may be empty). The server already filters in-library and - /// non-terminal-request candidates. - Future> listSuggestions() async { - final r = await _dio.get>('/api/discover/suggestions'); - final raw = r.data ?? const []; - return raw - .map((e) => - ArtistSuggestion.fromJson((e as Map).cast())) - .toList(growable: false); - } - - /// GET /api/lidarr/search?q=...&kind=artist|album|track. Server has a - /// 60s LRU cache for repeat queries so re-typing the same string in - /// quick succession is cheap. - Future> search( - String query, - LidarrRequestKind kind, - ) async { - final r = await _dio.get>( - '/api/lidarr/search', - queryParameters: {'q': query, 'kind': kind.wire}, - ); - final raw = r.data ?? const []; - return raw - .map((e) => - LidarrSearchResult.fromJson((e as Map).cast())) - .toList(growable: false); - } - - /// POST /api/requests. Returns the newly-created LidarrRequest body - /// — we don't expose a typed wrapper for that yet on mobile (admin - /// reviews requests; user just kicks them off), so we discard the - /// response and surface success/failure to the caller. - Future createRequest({ - required LidarrRequestKind kind, - required String artistMbid, - required String artistName, - String? albumMbid, - String? albumTitle, - String? trackMbid, - String? trackTitle, - }) async { - final body = { - 'kind': kind.wire, - 'lidarr_artist_mbid': artistMbid, - 'artist_name': artistName, - }; - if (albumMbid != null && albumMbid.isNotEmpty) { - body['lidarr_album_mbid'] = albumMbid; - } - if (trackMbid != null && trackMbid.isNotEmpty) { - body['lidarr_track_mbid'] = trackMbid; - } - if (albumTitle != null && albumTitle.isNotEmpty) { - body['album_title'] = albumTitle; - } - if (trackTitle != null && trackTitle.isNotEmpty) { - body['track_title'] = trackTitle; - } - await _dio.post>('/api/requests', data: body); - } -} diff --git a/flutter_client/lib/api/endpoints/events.dart b/flutter_client/lib/api/endpoints/events.dart deleted file mode 100644 index c5ab0fc0..00000000 --- a/flutter_client/lib/api/endpoints/events.dart +++ /dev/null @@ -1,91 +0,0 @@ -import 'package:dio/dio.dart'; - -/// Thin client for POST /api/events — the play-event lifecycle the -/// server uses for history, recommendation scoring, ListenBrainz -/// scrobbles, and (since #415) system-playlist rotation. -/// -/// Mirrors the web events dispatcher's three calls. Best-effort by -/// contract: callers swallow errors — a missed event is acceptable -/// per the server spec's v1 stance, and the server's -/// auto-close-prior-open keeps history sane even if an ended/skipped -/// is lost. -class EventsApi { - EventsApi(this._dio); - final Dio _dio; - - /// POST play_started. Returns the server's play_event_id (used to - /// close the row later), or null if the call failed / response was - /// malformed. `source` tags the originating system playlist - /// ('for_you' | 'discover') so the server advances that rotation; - /// omit for library / user-playlist / radio plays. - Future playStarted({ - required String trackId, - required String clientId, - String? source, - }) async { - final r = await _dio.post>( - '/api/events', - data: { - 'type': 'play_started', - 'track_id': trackId, - 'client_id': clientId, - if (source != null && source.isNotEmpty) 'source': source, - }, - ); - return (r.data ?? const {})['play_event_id'] as String?; - } - - Future playEnded({ - required String playEventId, - required int durationPlayedMs, - }) async { - await _dio.post( - '/api/events', - data: { - 'type': 'play_ended', - 'play_event_id': playEventId, - 'duration_played_ms': durationPlayedMs, - }, - ); - } - - Future playSkipped({ - required String playEventId, - required int positionMs, - }) async { - await _dio.post( - '/api/events', - data: { - 'type': 'play_skipped', - 'play_event_id': playEventId, - 'position_ms': positionMs, - }, - ); - } - - /// Replays a complete play that happened offline / on a flaky - /// connection (#426 part B). One call: the server records start+end - /// from `atIso` (the original play-start time) + durationPlayedMs, - /// applies the canonical skip rule, and advances #415 rotation when - /// source is a system playlist. Driven by the offline mutation - /// queue, never the live path. - Future playOffline({ - required String trackId, - required String clientId, - required String atIso, - required int durationPlayedMs, - String? source, - }) async { - await _dio.post( - '/api/events', - data: { - 'type': 'play_offline', - 'track_id': trackId, - 'client_id': clientId, - 'at': atIso, - 'duration_played_ms': durationPlayedMs, - if (source != null && source.isNotEmpty) 'source': source, - }, - ); - } -} diff --git a/flutter_client/lib/api/endpoints/health.dart b/flutter_client/lib/api/endpoints/health.dart deleted file mode 100644 index 5f43b117..00000000 --- a/flutter_client/lib/api/endpoints/health.dart +++ /dev/null @@ -1,14 +0,0 @@ -import 'package:dio/dio.dart'; - -class HealthApi { - HealthApi(this._dio); - final Dio _dio; - - /// Returns {status, min_client_version}. Hits the unauthenticated - /// `/healthz` endpoint at the configured base URL. - Future> check() async { - final r = await _dio.get>('/healthz'); - return (r.data ?? const {}) - .map((k, v) => MapEntry(k, v.toString())); - } -} diff --git a/flutter_client/lib/api/endpoints/library.dart b/flutter_client/lib/api/endpoints/library.dart deleted file mode 100644 index 53895b7f..00000000 --- a/flutter_client/lib/api/endpoints/library.dart +++ /dev/null @@ -1,121 +0,0 @@ -import 'package:dio/dio.dart'; - -import '../../models/album.dart'; -import '../../models/artist.dart'; -import '../../models/home_data.dart'; -import '../../models/home_index.dart'; -import '../../models/track.dart'; - -/// LibraryApi wraps the server's native /api/* library surface. -/// -/// Response shapes (verified against internal/api/library.go, -/// internal/api/library_albums.go, internal/api/home.go): -/// -/// GET /api/home → HomePayload (flat sections) -/// GET /api/artists/{id} → ArtistDetail (ArtistRef fields + "albums") -/// GET /api/artists/{id}/tracks → flat []TrackRef array (NOT enveloped) -/// GET /api/albums/{id} → AlbumDetail (AlbumRef fields + "tracks") -/// -/// The artist-tracks endpoint deviates from the plan-text starter: the -/// server emits a bare JSON array, not `{"tracks": [...]}`. We parse the -/// top-level response as `List` accordingly. -class LibraryApi { - LibraryApi(this._dio); - final Dio _dio; - - Future getHome() async { - final r = await _dio.get>('/api/home'); - return HomeData.fromJson(r.data ?? const {}); - } - - /// GET /api/home/index — per-item rendering variant. Returns just IDs - /// per section; client hydrates each tile via the per-entity - /// endpoints. ~10× smaller than /api/home on populated libraries so - /// the cold-visit round-trip is correspondingly short. - Future getHomeIndex() async { - final r = await _dio.get>('/api/home/index'); - return HomeIndex.fromJson(r.data ?? const {}); - } - - /// GET /api/tracks/{id}. Returns the canonical TrackRef. Used by - /// the HydrationQueue to populate cached_tracks on a per-tile miss - /// — the existing endpoint already joins album + artist so the - /// response carries everything TrackRef needs. - Future getTrack(String id) async { - final r = await _dio.get>('/api/tracks/$id'); - return TrackRef.fromJson(r.data ?? const {}); - } - - /// GET /api/artists/{id}. Server returns ArtistDetail which embeds - /// ArtistRef inline; ArtistRef.fromJson already reads only the fields - /// it cares about, so passing the whole body is correct. - Future getArtist(String id) async { - final r = await _dio.get>('/api/artists/$id'); - return ArtistRef.fromJson(r.data ?? const {}); - } - - /// GET /api/artists/{id} — full response with the ArtistRef AND the - /// embedded album list, both parsed. Single round-trip variant used - /// by CacheFiller and other callers that want to populate both - /// cached_artists and cached_albums in one shot. The existing - /// getArtist / getArtistAlbums keep working for callers that only - /// need one half — they hit the same URL but the per-id Riverpod - /// caching layer dedupes. - Future<({ArtistRef artist, List albums})> getArtistDetail( - String id) async { - final r = await _dio.get>('/api/artists/$id'); - final body = r.data ?? const {}; - final artist = ArtistRef.fromJson(body); - final albums = ((body['albums'] as List?) ?? const []) - .map((e) => AlbumRef.fromJson((e as Map).cast())) - .toList(growable: false); - return (artist: artist, albums: albums); - } - - /// Pulls the "albums" array out of the same ArtistDetail body. Callers - /// that need both the artist and its albums should issue two provider - /// reads (artistProvider + artistAlbumsProvider) — both hit the same - /// underlying URL and dio's response is not memoized here, but the - /// Riverpod layer caches per-id so cost stays at one round-trip. - Future> getArtistAlbums(String id) async { - final r = await _dio.get>('/api/artists/$id'); - final raw = (r.data?['albums'] as List?) ?? const []; - return raw - .map((e) => AlbumRef.fromJson((e as Map).cast())) - .toList(growable: false); - } - - /// GET /api/library/shuffle?limit=N (#427 S4). N random library - /// tracks — the online source for "Shuffle all". Bare JSON array. - Future> shuffle({int limit = 100}) async { - final r = await _dio.get>( - '/api/library/shuffle', - queryParameters: {'limit': limit}, - ); - final raw = r.data ?? const []; - return raw - .map((e) => TrackRef.fromJson((e as Map).cast())) - .toList(growable: false); - } - - /// GET /api/artists/{id}/tracks. Server emits a bare JSON array, so we - /// type the response as `List` rather than a Map envelope. - Future> getArtistTracks(String id) async { - final r = await _dio.get>('/api/artists/$id/tracks'); - final raw = r.data ?? const []; - return raw - .map((e) => TrackRef.fromJson((e as Map).cast())) - .toList(growable: false); - } - - /// GET /api/albums/{id}. Returns the album ref alongside its track list - /// in a single record so screens can render both without a second fetch. - Future<({AlbumRef album, List tracks})> getAlbum(String id) async { - final r = await _dio.get>('/api/albums/$id'); - final body = r.data ?? const {}; - final tracks = ((body['tracks'] as List?) ?? const []) - .map((e) => TrackRef.fromJson((e as Map).cast())) - .toList(growable: false); - return (album: AlbumRef.fromJson(body), tracks: tracks); - } -} diff --git a/flutter_client/lib/api/endpoints/library_lists.dart b/flutter_client/lib/api/endpoints/library_lists.dart deleted file mode 100644 index 8b274791..00000000 --- a/flutter_client/lib/api/endpoints/library_lists.dart +++ /dev/null @@ -1,36 +0,0 @@ -import 'package:dio/dio.dart'; - -import '../../models/album.dart'; -import '../../models/artist.dart'; -import '../../models/page.dart'; - -/// Paged library browse endpoints. Distinct from LibraryApi (which -/// fetches Home + entity detail) so screens that just need a flat -/// list don't drag in detail-page providers. -class LibraryListsApi { - LibraryListsApi(this._dio); - final Dio _dio; - - Future> listArtists({int limit = 50, int offset = 0}) async { - // Server mounts the artists list at /api/artists (handleListArtists), - // not /api/library/artists. Albums use /api/library/albums for - // historical reasons; the paths aren't symmetric. - final r = await _dio.get>( - '/api/artists', - queryParameters: { - 'limit': limit, - 'offset': offset, - 'sort': 'alpha', - }, - ); - return Paged.fromJson(r.data ?? const {}, ArtistRef.fromJson); - } - - Future> listAlbums({int limit = 50, int offset = 0}) async { - final r = await _dio.get>( - '/api/library/albums', - queryParameters: {'limit': limit, 'offset': offset}, - ); - return Paged.fromJson(r.data ?? const {}, AlbumRef.fromJson); - } -} diff --git a/flutter_client/lib/api/endpoints/likes.dart b/flutter_client/lib/api/endpoints/likes.dart deleted file mode 100644 index e4732369..00000000 --- a/flutter_client/lib/api/endpoints/likes.dart +++ /dev/null @@ -1,70 +0,0 @@ -import 'package:dio/dio.dart'; - -import '../../models/album.dart'; -import '../../models/artist.dart'; -import '../../models/page.dart'; -import '../../models/track.dart'; - -enum LikeKind { artist, album, track } - -extension LikeKindPath on LikeKind { - String get path => switch (this) { - LikeKind.artist => 'artists', - LikeKind.album => 'albums', - LikeKind.track => 'tracks', - }; -} - -class LikesApi { - LikesApi(this._dio); - final Dio _dio; - - Future like(LikeKind kind, String id) async { - await _dio.post('/api/likes/${kind.path}/$id'); - } - - Future unlike(LikeKind kind, String id) async { - await _dio.delete('/api/likes/${kind.path}/$id'); - } - - /// GET /api/likes/tracks?limit=N&offset=N. Paged. - Future> listTracks({int limit = 50, int offset = 0}) async { - final r = await _dio.get>( - '/api/likes/tracks', - queryParameters: {'limit': limit, 'offset': offset}, - ); - return Paged.fromJson(r.data ?? const {}, TrackRef.fromJson); - } - - Future> listAlbums({int limit = 50, int offset = 0}) async { - final r = await _dio.get>( - '/api/likes/albums', - queryParameters: {'limit': limit, 'offset': offset}, - ); - return Paged.fromJson(r.data ?? const {}, AlbumRef.fromJson); - } - - Future> listArtists({int limit = 50, int offset = 0}) async { - final r = await _dio.get>( - '/api/likes/artists', - queryParameters: {'limit': limit, 'offset': offset}, - ); - return Paged.fromJson(r.data ?? const {}, ArtistRef.fromJson); - } - - /// Returns sets of {artists, albums, tracks} the user has liked. - /// Server response keys (verified in internal/api/likes.go - /// `likedIDsResponse`): `artist_ids`, `album_ids`, `track_ids`. - Future<({Set artists, Set albums, Set tracks})> - ids() async { - final r = await _dio.get>('/api/likes/ids'); - final body = r.data ?? const {}; - Set set(String key) => - ((body[key] as List?) ?? const []).map((e) => e.toString()).toSet(); - return ( - artists: set('artist_ids'), - albums: set('album_ids'), - tracks: set('track_ids'), - ); - } -} diff --git a/flutter_client/lib/api/endpoints/me.dart b/flutter_client/lib/api/endpoints/me.dart deleted file mode 100644 index 9d867c2e..00000000 --- a/flutter_client/lib/api/endpoints/me.dart +++ /dev/null @@ -1,49 +0,0 @@ -import 'package:dio/dio.dart'; - -import '../../models/history_event.dart'; -import '../../models/quarantine_mine.dart'; -import '../../models/system_playlists_status.dart'; - -/// /api/me/* endpoints — caller-scoped data (history, profile, etc.). -class MeApi { - MeApi(this._dio); - final Dio _dio; - - /// GET /api/me/history. Paginated via offset/limit; server emits - /// `{events, has_more}` rather than the standard `Page` envelope. - Future history({int limit = 50, int offset = 0}) async { - final r = await _dio.get>( - '/api/me/history', - queryParameters: {'limit': limit, 'offset': offset}, - ); - return HistoryPage.fromJson(r.data ?? const {}); - } - - /// GET /api/quarantine/mine — flat list (no envelope). - Future> quarantineMine() async { - final r = await _dio.get>('/api/quarantine/mine'); - final raw = r.data ?? const []; - return raw - .map((e) => - QuarantineMineRow.fromJson((e as Map).cast())) - .toList(growable: false); - } - - /// GET /api/me/system-playlists-status. Returns the caller's most - /// recent system-playlist build state. Used by the home Playlists - /// row to choose between real and placeholder cards. - Future systemPlaylistsStatus() async { - final r = await _dio.get>( - '/api/me/system-playlists-status', - ); - return SystemPlaylistsStatus.fromJson(r.data ?? const {}); - } - - /// PUT /api/me/timezone — submit the device's IANA timezone. The - /// scheduler uses this to fire the user's daily playlist build at - /// 03:00 in their local time. See AuthController._sendTimezoneIfStale - /// for the weekly cadence trigger. - Future putTimezone(String timezone) async { - await _dio.put('/api/me/timezone', data: {'timezone': timezone}); - } -} diff --git a/flutter_client/lib/api/endpoints/playlists.dart b/flutter_client/lib/api/endpoints/playlists.dart deleted file mode 100644 index 3455aaed..00000000 --- a/flutter_client/lib/api/endpoints/playlists.dart +++ /dev/null @@ -1,80 +0,0 @@ -import 'package:dio/dio.dart'; - -import '../../models/playlist.dart'; - -/// Wire shape for GET /api/playlists. Server splits owned vs. public so -/// the UI can present them as different sections; we preserve that split -/// to give the integrations page room to grow. -class PlaylistsList { - const PlaylistsList({required this.owned, required this.public}); - final List owned; - final List public; - - factory PlaylistsList.empty() => - const PlaylistsList(owned: [], public: []); - - /// Concatenated view for callers that don't care about the split. - List get all => [...owned, ...public]; -} - -class PlaylistsApi { - PlaylistsApi(this._dio); - final Dio _dio; - - /// GET /api/playlists?kind=user|system|all. Server returns - /// `{"owned": [...], "public": [...]}`. Owned is the caller's own - /// playlists, filtered by the kind param (default "user"). Public - /// is other users' shared playlists; not filtered by kind. - Future list({String kind = 'user'}) async { - final r = await _dio.get>( - '/api/playlists', - queryParameters: {'kind': kind}, - ); - final body = r.data ?? const {}; - List parse(String key) { - final raw = (body[key] as List?) ?? const []; - return raw - .map((e) => Playlist.fromJson((e as Map).cast())) - .toList(growable: false); - } - return PlaylistsList(owned: parse('owned'), public: parse('public')); - } - - Future get(String id) async { - final r = await _dio.get>('/api/playlists/$id'); - return PlaylistDetail.fromJson(r.data ?? const {}); - } - - /// GET /api/playlists/system/{kind}/shuffle (#415 / #411 R2). - /// Same shape as get() but tracks are server-ordered rotation-aware - /// (unplayed-this-rotation first; resets when exhausted). {kind} is - /// the raw system_variant. Intentionally uncached — varies per play. - Future systemShuffle(String variant) async { - final r = await _dio.get>( - '/api/playlists/system/$variant/shuffle', - ); - return PlaylistDetail.fromJson(r.data ?? const {}); - } - - /// POST /api/playlists/{id}/tracks. Owner only; server returns the - /// playlist detail with the new rows. - Future appendTracks(String playlistId, List trackIds) async { - await _dio.post( - '/api/playlists/$playlistId/tracks', - data: {'track_ids': trackIds}, - ); - } - - /// POST /api/playlists/system/{kind}/refresh (#411 R2). Rebuilds - /// the caller's system playlists and returns the named kind's new - /// playlist id, or null when the library is empty. {kind} is the - /// raw system_variant — the server routes generically off the - /// kind registry, no hyphen mapping. - Future refreshSystem(String variant) async { - final r = await _dio.post>( - '/api/playlists/system/$variant/refresh', - data: const {}, - ); - return (r.data ?? const {})['playlist_id'] as String?; - } -} diff --git a/flutter_client/lib/api/endpoints/quarantine.dart b/flutter_client/lib/api/endpoints/quarantine.dart deleted file mode 100644 index 5568e222..00000000 --- a/flutter_client/lib/api/endpoints/quarantine.dart +++ /dev/null @@ -1,25 +0,0 @@ -import 'package:dio/dio.dart'; - -/// /api/quarantine — flag a track (with reason + optional notes) and -/// unflag it. Both endpoints are user-scoped: callers can only flag -/// their own quarantine entries; admins use a separate /admin/quarantine -/// surface. -class QuarantineApi { - QuarantineApi(this._dio); - final Dio _dio; - - /// POST /api/quarantine. Server returns 201 with the row. - /// Reason values: bad_rip | wrong_file | wrong_tags | duplicate | other. - Future flag(String trackId, String reason, {String notes = ''}) async { - await _dio.post('/api/quarantine', data: { - 'track_id': trackId, - 'reason': reason, - 'notes': notes, - }); - } - - /// DELETE /api/quarantine/{track_id}. Server returns 204. - Future unflag(String trackId) async { - await _dio.delete('/api/quarantine/$trackId'); - } -} diff --git a/flutter_client/lib/api/endpoints/radio.dart b/flutter_client/lib/api/endpoints/radio.dart deleted file mode 100644 index b8e4ca5a..00000000 --- a/flutter_client/lib/api/endpoints/radio.dart +++ /dev/null @@ -1,27 +0,0 @@ -import 'package:dio/dio.dart'; - -import '../../models/track.dart'; - -/// `GET /api/radio?seed_track=&limit=`. Returns the seed -/// at index 0 followed by up to `limit-1` weighted-shuffle picks -/// scored by the server's recommendation engine. The shape matches -/// what `playerActions.playTracks` expects, so a radio start is just -/// one fetch + one playTracks call. -class RadioApi { - RadioApi(this._dio); - final Dio _dio; - - Future> seedTrack(String trackId, {int? limit}) async { - final r = await _dio.get>( - '/api/radio', - queryParameters: { - 'seed_track': trackId, - if (limit != null) 'limit': limit, - }, - ); - final raw = (r.data?['tracks'] as List?) ?? const []; - return raw - .map((e) => TrackRef.fromJson((e as Map).cast())) - .toList(growable: false); - } -} diff --git a/flutter_client/lib/api/endpoints/requests.dart b/flutter_client/lib/api/endpoints/requests.dart deleted file mode 100644 index 219668fc..00000000 --- a/flutter_client/lib/api/endpoints/requests.dart +++ /dev/null @@ -1,31 +0,0 @@ -import 'package:dio/dio.dart'; - -import '../../models/admin_request.dart'; - -/// User-side requests API — `/api/requests`. Server scopes results to -/// the caller; admins see only their own here, not all users'. The -/// admin-cross-user view lives in `/api/admin/requests` (AdminRequestsApi). -/// -/// Wire shape is identical to the admin endpoint, so the same -/// AdminRequest model is reused. -class RequestsApi { - RequestsApi(this._dio); - final Dio _dio; - - /// GET /api/requests — caller's own requests, all statuses. - Future> listMine() async { - final r = await _dio.get>('/api/requests'); - final raw = r.data ?? const []; - return raw - .map((e) => AdminRequest.fromJson((e as Map).cast())) - .toList(growable: false); - } - - /// DELETE /api/requests/{id} — cancel a pending request. Server - /// returns the cancelled row body (not 204) so the caller can patch - /// local state without a refetch. - Future cancel(String id) async { - final r = await _dio.delete>('/api/requests/$id'); - return AdminRequest.fromJson(r.data ?? const {}); - } -} diff --git a/flutter_client/lib/api/endpoints/search.dart b/flutter_client/lib/api/endpoints/search.dart deleted file mode 100644 index 2ba84df1..00000000 --- a/flutter_client/lib/api/endpoints/search.dart +++ /dev/null @@ -1,29 +0,0 @@ -import 'package:dio/dio.dart'; - -import '../../models/search_response.dart'; - -/// SearchApi wraps GET /api/search. The server runs three facets (artists, -/// albums, tracks) sharing one limit/offset pair. Each facet returns its -/// own total reflecting the full match count. -class SearchApi { - SearchApi(this._dio); - final Dio _dio; - - /// Empty / whitespace-only query is the caller's responsibility to - /// guard against — the server returns 400 bad_request for it. - Future search( - String query, { - int limit = 20, - int offset = 0, - }) async { - final r = await _dio.get>( - '/api/search', - queryParameters: { - 'q': query, - 'limit': limit, - 'offset': offset, - }, - ); - return SearchResponse.fromJson(r.data ?? const {}); - } -} diff --git a/flutter_client/lib/api/endpoints/settings.dart b/flutter_client/lib/api/endpoints/settings.dart deleted file mode 100644 index 70cd1e57..00000000 --- a/flutter_client/lib/api/endpoints/settings.dart +++ /dev/null @@ -1,57 +0,0 @@ -import 'package:dio/dio.dart'; - -import '../../models/my_profile.dart'; - -class SettingsApi { - SettingsApi(this._dio); - final Dio _dio; - - Future getProfile() async { - final r = await _dio.get>('/api/me'); - return MyProfile.fromJson(r.data ?? const {}); - } - - /// Pass only the fields you want to change; server merges. Empty - /// strings clear; null leaves the existing value alone. - Future updateProfile({String? displayName, String? email}) async { - final body = {}; - if (displayName != null) body['display_name'] = displayName; - if (email != null) body['email'] = email; - final r = await _dio.put>( - '/api/me/profile', - data: body, - ); - return MyProfile.fromJson(r.data ?? const {}); - } - - Future changePassword({ - required String current, - required String next, - }) async { - await _dio.put('/api/me/password', data: { - 'current_password': current, - 'new_password': next, - }); - } - - Future getListenBrainz() async { - final r = await _dio.get>('/api/me/listenbrainz'); - return ListenBrainzStatus.fromJson(r.data ?? const {}); - } - - Future setListenBrainzToken(String token) async { - final r = await _dio.put>( - '/api/me/listenbrainz', - data: {'token': token}, - ); - return ListenBrainzStatus.fromJson(r.data ?? const {}); - } - - Future setListenBrainzEnabled(bool enabled) async { - final r = await _dio.put>( - '/api/me/listenbrainz', - data: {'enabled': enabled}, - ); - return ListenBrainzStatus.fromJson(r.data ?? const {}); - } -} diff --git a/flutter_client/lib/api/error_copy.dart b/flutter_client/lib/api/error_copy.dart deleted file mode 100644 index 7ff65bba..00000000 --- a/flutter_client/lib/api/error_copy.dart +++ /dev/null @@ -1,22 +0,0 @@ -import 'dart:convert'; -import 'package:flutter/services.dart' show rootBundle; - -class ErrorCopy { - ErrorCopy._(this._table); - final Map _table; - - static ErrorCopy? _instance; - - static Future load() async { - if (_instance != null) return _instance!; - final raw = await rootBundle.loadString('assets/error-copy.json'); - final m = (jsonDecode(raw) as Map).cast().map( - (k, v) => MapEntry(k, v.toString()), - ); - _instance = ErrorCopy._(m); - return _instance!; - } - - String forCode(String code) => - _table[code] ?? _table['unknown'] ?? 'Something went wrong.'; -} diff --git a/flutter_client/lib/api/errors.dart b/flutter_client/lib/api/errors.dart deleted file mode 100644 index 7283c350..00000000 --- a/flutter_client/lib/api/errors.dart +++ /dev/null @@ -1,33 +0,0 @@ -import 'package:dio/dio.dart'; - -class ApiError implements Exception { - ApiError({required this.code, required this.message, required this.status}); - - final String code; - final String message; - final int status; - - factory ApiError.fromDio(DioException e) { - if (e.type == DioExceptionType.connectionError || - e.type == DioExceptionType.connectionTimeout) { - return ApiError(code: 'connection_refused', message: 'Connection refused', status: 0); - } - final response = e.response; - final data = response?.data; - if (data is Map) { - final field = data['error']; - if (field is String) { - return ApiError(code: field, message: field, status: response?.statusCode ?? 0); - } - if (field is Map) { - final code = field['code']?.toString() ?? 'unknown'; - final msg = field['message']?.toString() ?? code; - return ApiError(code: code, message: msg, status: response?.statusCode ?? 0); - } - } - final status = response?.statusCode ?? 0; - if (status == 401) return ApiError(code: 'unauthenticated', message: 'unauthenticated', status: status); - if (status == 404) return ApiError(code: 'not_found', message: 'not_found', status: status); - return ApiError(code: 'unknown', message: e.message ?? 'unknown', status: status); - } -} diff --git a/flutter_client/lib/app.dart b/flutter_client/lib/app.dart deleted file mode 100644 index 40f4ce8c..00000000 --- a/flutter_client/lib/app.dart +++ /dev/null @@ -1,107 +0,0 @@ -import 'dart:io'; - -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:permission_handler/permission_handler.dart'; - -import 'cache/cache_filler.dart'; -import 'cache/metadata_prefetcher.dart'; -import 'cache/offline_provider.dart'; -import 'cache/mutation_queue.dart'; -import 'cache/prefetcher.dart'; -import 'cache/resume_controller.dart'; -import 'cache/sync_controller.dart'; -import 'player/play_events_reporter.dart'; -import 'player/playback_error_reporter.dart'; -import 'shared/live_events_dispatcher.dart'; -import 'shared/routing.dart'; -import 'theme/theme_data.dart'; -import 'theme/theme_mode_provider.dart'; - -class MinstrelApp extends ConsumerStatefulWidget { - const MinstrelApp({super.key}); - - @override - ConsumerState createState() => _MinstrelAppState(); -} - -class _MinstrelAppState extends ConsumerState { - @override - void initState() { - super.initState(); - // Activate offline-mode infrastructure once the first frame ships. - // SyncController.sync() is connectivity-aware (no-ops on no auth / - // no server URL), so calling it unconditionally is safe. - // Reading prefetcherProvider runs its constructor, which wires the - // queue + settings listeners. - WidgetsBinding.instance.addPostFrameCallback((_) { - // ignore: unawaited_futures - ref.read(syncControllerProvider.notifier).sync(); - ref.read(prefetcherProvider); - // Metadata prefetcher: when /api/home returns, fire background - // albumProvider/artistProvider reads for the top-N items in - // each section so subsequent taps are drift hits, not network - // round trips. - ref.read(metadataPrefetcherProvider); - // Live events (#392): subscribes to /api/events/stream and - // invalidates publicly-scoped providers when relevant events - // arrive. Also installs an AppLifecycleState observer for - // resume-time defensive invalidation. - ref.read(liveEventsDispatcherProvider); - // Cache filler: background sweeper that walks cached_artists - // / cached_albums for missing relations and fetches the - // per-entity detail so tapping an artist surfaces albums - // immediately (drift hit, no /api/artists/:id round-trip at - // tap time). First sweep 10s after launch; every 5 minutes - // thereafter. Throttled 200ms between requests so it never - // competes with user activity. - ref.read(cacheFillerProvider); - // Mutation replayer: drains the cached_mutations queue when - // connectivity comes back. Controllers (LikesController, - // MyQuarantineController, the add-to-playlist + request flows) - // enqueue on REST failure so user intent persists across - // network loss instead of getting rolled back. - ref.read(mutationReplayerProvider); - // Play-events reporter (#415): the Flutter client otherwise - // reports no plays at all — this feeds history, recommendation - // scoring, scrobbles, and system-playlist rotation, and is the - // path that carries the `source` tag for #415. - ref.read(playEventsReporterProvider); - // Resume-on-launch (#54): restores the last persisted session - // (paused) and persists queue/index/position on track change, - // pause, and app teardown. Pairs with the #52 idle teardown so a - // torn-down session is recoverable instead of lost. - ref.read(resumeControllerProvider); - // Playback-error reporter (#58): turns the handler's silent - // dead-track skips into a debounced/coalesced SnackBar so a - // vanished track isn't mysterious (and aids server/cache debug). - ref.read(playbackErrorReporterProvider); - // Offline marker (#427 S1): periodic /healthz reachability - // probe → offlineProvider. Read here to start the poller; S4 - // gates system-playlist play + Shuffle-all on it. - ref.read(offlineProvider); - // POST_NOTIFICATIONS (Android 13+) is denied-by-default until - // requested; without it the media notification is silently - // suppressed on physical devices. One-shot, post-first-frame so - // it never blocks launch; no-op on <13 / once already decided. - if (Platform.isAndroid) { - // ignore: unawaited_futures - Permission.notification.request(); - } - }); - } - - @override - Widget build(BuildContext context) { - final router = ref.watch(routerProvider); - final mode = ref.watch(themeModeProvider).value ?? AppThemeMode.system; - return MaterialApp.router( - title: 'Minstrel', - scaffoldMessengerKey: scaffoldMessengerKey, - theme: buildLightTheme(), - darkTheme: buildDarkTheme(), - themeMode: mode.materialMode, - routerConfig: router, - ); - } -} diff --git a/flutter_client/lib/auth/auth_provider.dart b/flutter_client/lib/auth/auth_provider.dart deleted file mode 100644 index 4ab61793..00000000 --- a/flutter_client/lib/auth/auth_provider.dart +++ /dev/null @@ -1,89 +0,0 @@ -import 'dart:convert'; - -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; -import 'package:flutter_timezone/flutter_timezone.dart'; - -import '../api/endpoints/me.dart'; -import '../library/library_providers.dart' show dioProvider; -import '../models/user.dart'; - -const _kServerUrl = 'server_url'; -const _kSessionToken = 'session_token'; -const _kCurrentUser = 'current_user'; -const _kTzLastSentAt = 'tz_last_sent_at'; -const _weeklyMs = 7 * 24 * 60 * 60 * 1000; - -final secureStorageProvider = Provider( - (ref) => const FlutterSecureStorage(), -); - -final serverUrlProvider = FutureProvider((ref) async { - return ref.watch(secureStorageProvider).read(key: _kServerUrl); -}); - -final sessionTokenProvider = FutureProvider((ref) async { - return ref.watch(secureStorageProvider).read(key: _kSessionToken); -}); - -class AuthController extends AsyncNotifier { - late FlutterSecureStorage _storage; - - @override - Future build() async { - _storage = ref.watch(secureStorageProvider); - final raw = await _storage.read(key: _kCurrentUser); - if (raw == null) return null; - final user = User.fromJson(jsonDecode(raw) as Map); - // Fire-and-forget timezone send on app start with an existing - // session — no-op when last_sent_at is fresh. - // ignore: unawaited_futures - _sendTimezoneIfStale(); - return user; - } - - Future setServerUrl(String url) async { - await _storage.write(key: _kServerUrl, value: url); - ref.invalidate(serverUrlProvider); - } - - Future setSession({required String token, required String userJson}) async { - await _storage.write(key: _kSessionToken, value: token); - await _storage.write(key: _kCurrentUser, value: userJson); - ref.invalidate(sessionTokenProvider); - state = AsyncData(User.fromJson(jsonDecode(userJson) as Map)); - // ignore: unawaited_futures - _sendTimezoneIfStale(); - } - - Future clearSession() async { - await _storage.delete(key: _kSessionToken); - await _storage.delete(key: _kCurrentUser); - ref.invalidate(sessionTokenProvider); - state = const AsyncData(null); - } - - /// Sends the device's current IANA timezone to PUT /api/me/timezone - /// when the last successful send was >7 days ago (or never). - /// Cadence persists in flutter_secure_storage so it survives app - /// restarts. Failures are swallowed: the server keeps its previous - /// value (or 'UTC' default) until the next attempt. - Future _sendTimezoneIfStale() async { - try { - final lastStr = await _storage.read(key: _kTzLastSentAt); - final lastMs = lastStr == null ? 0 : int.tryParse(lastStr) ?? 0; - final nowMs = DateTime.now().millisecondsSinceEpoch; - if (nowMs - lastMs < _weeklyMs) return; - final tz = await FlutterTimezone.getLocalTimezone(); - if (tz.isEmpty) return; - final dio = await ref.read(dioProvider.future); - await MeApi(dio).putTimezone(tz); - await _storage.write(key: _kTzLastSentAt, value: nowMs.toString()); - } catch (_) { - // Non-fatal — server falls back to UTC or last-known value. - } - } -} - -final authControllerProvider = - AsyncNotifierProvider(AuthController.new); diff --git a/flutter_client/lib/auth/login_screen.dart b/flutter_client/lib/auth/login_screen.dart deleted file mode 100644 index c2fee1ed..00000000 --- a/flutter_client/lib/auth/login_screen.dart +++ /dev/null @@ -1,95 +0,0 @@ -import 'package:dio/dio.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; - -import '../api/client.dart'; -import '../api/endpoints/auth.dart'; -import '../api/error_copy.dart'; -import '../api/errors.dart'; -import '../theme/theme_extension.dart'; -import 'auth_provider.dart'; - -class LoginScreen extends ConsumerStatefulWidget { - const LoginScreen({super.key}); - - @override - ConsumerState createState() => _LoginScreenState(); -} - -class _LoginScreenState extends ConsumerState { - final _user = TextEditingController(); - final _pass = TextEditingController(); - bool _busy = false; - String? _error; - - Future _submit() async { - setState(() { - _busy = true; - _error = null; - }); - try { - final url = await ref.read(serverUrlProvider.future); - if (url == null) { - if (!mounted) return; - context.go('/server-url'); - return; - } - final dio = ApiClient.buildDio( - baseUrl: url, - tokenResolver: () async => null, - ); - final res = await AuthApi(dio).login( - username: _user.text.trim(), - password: _pass.text, - ); - await ref.read(authControllerProvider.notifier).setSession( - token: res.token, - userJson: res.rawUserJson, - ); - if (!mounted) return; - context.go('/home'); - } on DioException catch (e) { - final code = ApiError.fromDio(e).code; - final copy = (await ErrorCopy.load()).forCode(code); - if (mounted) setState(() => _error = copy); - } finally { - if (mounted) setState(() => _busy = false); - } - } - - @override - Widget build(BuildContext context) { - final fs = Theme.of(context).extension()!; - return Scaffold( - body: SafeArea( - child: Padding( - padding: const EdgeInsets.all(24), - child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - const SizedBox(height: 48), - Text('Sign in', style: TextStyle(fontFamily: fs.display.fontFamily, fontSize: 28)), - const SizedBox(height: 24), - TextField(controller: _user, decoration: const InputDecoration(labelText: 'Username')), - const SizedBox(height: 12), - TextField(controller: _pass, obscureText: true, decoration: const InputDecoration(labelText: 'Password')), - if (_error != null) Padding( - padding: const EdgeInsets.only(top: 8), - child: Text(_error!, style: TextStyle(color: fs.error)), - ), - const SizedBox(height: 16), - FilledButton( - onPressed: _busy ? null : _submit, - child: _busy - ? const SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2)) - : const Text('Sign in'), - ), - TextButton( - onPressed: () => context.go('/server-url'), - child: const Text('Change server URL'), - ), - ]), - ), - ), - ); - } -} diff --git a/flutter_client/lib/auth/server_url_screen.dart b/flutter_client/lib/auth/server_url_screen.dart deleted file mode 100644 index 47d1d544..00000000 --- a/flutter_client/lib/auth/server_url_screen.dart +++ /dev/null @@ -1,82 +0,0 @@ -import 'package:dio/dio.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; - -import '../api/endpoints/health.dart'; -import '../theme/theme_extension.dart'; -import 'auth_provider.dart'; - -class ServerUrlScreen extends ConsumerStatefulWidget { - const ServerUrlScreen({super.key}); - - @override - ConsumerState createState() => _ServerUrlScreenState(); -} - -class _ServerUrlScreenState extends ConsumerState { - final _ctrl = TextEditingController(); - bool _busy = false; - String? _error; - - Future _connect() async { - final url = _ctrl.text.trim(); - if (url.isEmpty) { - setState(() => _error = 'Enter a server URL.'); - return; - } - setState(() { - _busy = true; - _error = null; - }); - try { - final dio = Dio(BaseOptions(baseUrl: url, connectTimeout: const Duration(seconds: 5))); - final body = await HealthApi(dio).check(); - if (body['status'] != 'ok') { - throw StateError('unhealthy'); - } - await ref.read(authControllerProvider.notifier).setServerUrl(url); - if (!mounted) return; - context.go('/login'); - } on DioException catch (_) { - setState(() => _error = "Couldn't reach that server."); - } catch (_) { - setState(() => _error = "Couldn't reach that server."); - } finally { - if (mounted) setState(() => _busy = false); - } - } - - @override - Widget build(BuildContext context) { - final fs = Theme.of(context).extension()!; - return Scaffold( - body: SafeArea( - child: Padding( - padding: const EdgeInsets.all(24), - child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - const SizedBox(height: 48), - Text('Connect to your Minstrel', style: TextStyle(fontFamily: fs.display.fontFamily, fontSize: 28)), - const SizedBox(height: 24), - TextField( - controller: _ctrl, - keyboardType: TextInputType.url, - decoration: const InputDecoration(labelText: 'Server URL', hintText: 'https://music.example.com'), - ), - if (_error != null) Padding( - padding: const EdgeInsets.only(top: 8), - child: Text(_error!, style: TextStyle(color: fs.error)), - ), - const SizedBox(height: 16), - FilledButton( - onPressed: _busy ? null : _connect, - child: _busy - ? const SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2)) - : const Text('Connect'), - ), - ]), - ), - ), - ); - } -} diff --git a/flutter_client/lib/cache/adapters.dart b/flutter_client/lib/cache/adapters.dart deleted file mode 100644 index 11899469..00000000 --- a/flutter_client/lib/cache/adapters.dart +++ /dev/null @@ -1,133 +0,0 @@ -// Drift row → model adapters and reverse-write adapters for populating -// drift from REST responses (#357 plan C). -// -// The cache loses some server-derived fields: -// - ArtistRef.coverUrl (server computes from most-recent album) -// - AlbumRef.coverUrl (server-emitted derived path) -// - TrackRef.streamUrl (could be reconstructed but kept empty for clarity) -// UI already handles empty coverUrl/streamUrl gracefully. REST cold-cache -// fallback briefly shows the real values before drift takes over. - -import 'package:drift/drift.dart' as drift; - -import '../models/album.dart'; -import '../models/artist.dart'; -import '../models/playlist.dart'; -import '../models/track.dart'; -import 'db.dart'; - -extension CachedArtistAdapter on CachedArtist { - /// `coverAlbumId` lets the caller pass a representative album id so - /// the ArtistRef carries a reconstructed `/api/albums//cover` - /// URL. cached_artists doesn't store this directly — the server - /// derives it from the most-recent album at query time — so drift - /// readers that want the cover URL join cached_albums and pass the - /// first album's id through. Empty string yields empty coverUrl, - /// matching the server's behavior for endpoints that don't carry - /// the lookup (artist detail, search, raw cached_artists row reads). - ArtistRef toRef({String coverAlbumId = ''}) => ArtistRef( - id: id, - name: name, - sortName: sortName, - coverUrl: coverAlbumId.isNotEmpty - ? '/api/albums/$coverAlbumId/cover' - : '', - ); -} - -extension ArtistRefDriftWrite on ArtistRef { - CachedArtistsCompanion toDrift() => CachedArtistsCompanion.insert( - id: id, - name: name, - sortName: sortName.isNotEmpty ? sortName : name, - ); -} - -extension CachedAlbumAdapter on CachedAlbum { - /// `artistName` is supplied by the joined CachedArtists row at query time. - /// `coverUrl` is reconstructed deterministically from the album id — - /// the server emits the same shape (see internal/api/convert.go:69). - /// We don't need to persist it, so AlbumRef.coverUrl is non-empty - /// even when the row was populated from a sync that didn't carry the - /// derived URL. - AlbumRef toRef({String artistName = ''}) => AlbumRef( - id: id, - title: title, - sortTitle: sortTitle, - artistId: artistId, - artistName: artistName, - coverUrl: '/api/albums/$id/cover', - ); -} - -extension AlbumRefDriftWrite on AlbumRef { - CachedAlbumsCompanion toDrift() => CachedAlbumsCompanion.insert( - id: id, - artistId: artistId, - title: title, - sortTitle: sortTitle.isNotEmpty ? sortTitle : title, - ); -} - -extension CachedTrackAdapter on CachedTrack { - /// `artistName` and `albumTitle` come from joined rows. - TrackRef toRef({String artistName = '', String albumTitle = ''}) => TrackRef( - id: id, - title: title, - albumId: albumId, - albumTitle: albumTitle, - artistId: artistId, - artistName: artistName, - trackNumber: trackNumber, - discNumber: discNumber, - durationSec: durationMs ~/ 1000, - ); -} - -extension TrackRefDriftWrite on TrackRef { - CachedTracksCompanion toDrift() => CachedTracksCompanion.insert( - id: id, - albumId: albumId, - artistId: artistId, - title: title, - durationMs: drift.Value(durationSec * 1000), - trackNumber: drift.Value(trackNumber), - discNumber: drift.Value(discNumber), - ); -} - -extension CachedPlaylistAdapter on CachedPlaylist { - /// `ownerUsername` is server-derived; cache stores the userId only. - /// Pass empty unless a join supplies it. - /// `coverUrl` is reconstructed deterministically from the playlist - /// id — the server serves the cached collage at this path - /// (handleGetPlaylistCover). Mirrors the album-cover trick. When - /// the server hasn't built a collage yet (system playlists with no - /// tracks at build time), the endpoint 404s and PlaylistCard's - /// ServerImage falls back to its slate placeholder. - Playlist toRef({String ownerUsername = ''}) => Playlist( - id: id, - userId: userId, - name: name, - description: description, - isPublic: isPublic, - systemVariant: systemVariant, - trackCount: trackCount, - coverUrl: '/api/playlists/$id/cover', - ownerUsername: ownerUsername, - createdAt: '', - updatedAt: '', - ); -} - -extension PlaylistDriftWrite on Playlist { - CachedPlaylistsCompanion toDrift() => CachedPlaylistsCompanion.insert( - id: id, - userId: userId, - name: name, - description: drift.Value(description), - isPublic: drift.Value(isPublic), - trackCount: drift.Value(trackCount), - systemVariant: drift.Value(systemVariant), - ); -} diff --git a/flutter_client/lib/cache/audio_cache_manager.dart b/flutter_client/lib/cache/audio_cache_manager.dart deleted file mode 100644 index a26aa665..00000000 --- a/flutter_client/lib/cache/audio_cache_manager.dart +++ /dev/null @@ -1,296 +0,0 @@ -import 'dart:io'; - -import 'package:dio/dio.dart'; -import 'package:drift/drift.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:path_provider/path_provider.dart'; - -import '../library/library_providers.dart' show dioProvider; -import 'db.dart'; - -/// Per-bucket cache usage (#427 S2). Rolling includes orphan files — -/// partials written by LockCachingAudioSource that were never indexed -/// (skip-before-fully-buffered) — so the rolling cap actually bounds -/// disk. -typedef BucketUsage = ({int liked, int rolling}); - -/// Owns the audio cache directory + drift index. -/// -/// #427 S2: two storage buckets keyed by liked-ness, not by -/// CacheSource. A cached track currently in the user's liked set is -/// charged to (and evicted under) the Liked budget; everything else -/// is Rolling. This dedup is storage/eviction-only — it never filters -/// what the offline surfaces can play (S4). `CacheSource` is retained -/// on the API for callers but is now informational. -class AudioCacheManager { - AudioCacheManager({ - required AppDb db, - required Future Function() dioFactory, - Future Function()? cacheDirFactory, - }) : _db = db, - _dioFactory = dioFactory, - _cacheDirFactory = cacheDirFactory ?? getApplicationCacheDirectory; - - final AppDb _db; - final Future Function() _dioFactory; - final Future Function() _cacheDirFactory; - - Future _tracksDir() async { - final base = await _cacheDirFactory(); - final d = Directory('${base.path}/audio_cache'); - if (!await d.exists()) await d.create(recursive: true); - return d.path; - } - - Future isCached(String trackId) async { - final row = await (_db.select(_db.audioCacheIndex) - ..where((t) => t.trackId.equals(trackId))) - .getSingleOrNull(); - if (row == null) return false; - return File(row.path).existsSync(); - } - - Future pathFor(String trackId) async { - final row = await (_db.select(_db.audioCacheIndex) - ..where((t) => t.trackId.equals(trackId))) - .getSingleOrNull(); - if (row == null) return null; - return File(row.path).existsSync() ? row.path : null; - } - - /// Downloads the track's stream to disk and indexes it. Idempotent. - /// lastPlayedAt is set now — pinning is play-intent. - Future pin(String trackId, {required CacheSource source}) async { - final existing = await pathFor(trackId); - if (existing != null) { - await touch(trackId); - return existing; - } - final dir = await _tracksDir(); - final path = '$dir/$trackId.mp3'; - final dio = await _dioFactory(); - try { - await dio.download('/api/tracks/$trackId/stream', path); - } catch (_) { - final f = File(path); - if (f.existsSync()) await f.delete(); - return null; - } - final size = await File(path).length(); - await _db.into(_db.audioCacheIndex).insertOnConflictUpdate( - AudioCacheIndexCompanion.insert( - trackId: trackId, - path: path, - sizeBytes: size, - source: source, - lastPlayedAt: Value(DateTime.now()), - ), - ); - return path; - } - - /// Registers a file LockCachingAudioSource wrote itself. Called - /// once a track is fully buffered; it's being played, so stamp - /// lastPlayedAt. - Future registerStreamCache( - String trackId, - String path, - int sizeBytes, { - CacheSource source = CacheSource.incidental, - }) async { - await _db.into(_db.audioCacheIndex).insertOnConflictUpdate( - AudioCacheIndexCompanion.insert( - trackId: trackId, - path: path, - sizeBytes: sizeBytes, - source: source, - lastPlayedAt: Value(DateTime.now()), - ), - ); - } - - /// Bumps lastPlayedAt for an already-indexed track so the rolling - /// LRU + the offline "Recently played" view reflect real plays - /// (not just download time). No-op if not indexed. - Future touch(String trackId) async { - await (_db.update(_db.audioCacheIndex) - ..where((t) => t.trackId.equals(trackId))) - .write(AudioCacheIndexCompanion(lastPlayedAt: Value(DateTime.now()))); - } - - Future unpin(String trackId) async { - final row = await (_db.select(_db.audioCacheIndex) - ..where((t) => t.trackId.equals(trackId))) - .getSingleOrNull(); - if (row == null) return; - final f = File(row.path); - if (f.existsSync()) await f.delete(); - await (_db.delete(_db.audioCacheIndex) - ..where((t) => t.trackId.equals(trackId))) - .go(); - } - - /// Total bytes on disk (directory walk — authoritative; catches - /// orphan partials the index misses). - Future usageBytes() async { - final dir = Directory(await _tracksDir()); - if (!await dir.exists()) return 0; - var total = 0; - await for (final e in dir.list(followLinks: false)) { - if (e is File) { - try { - total += await e.length(); - } catch (_) {} - } - } - return total; - } - - /// Bytes per bucket. Indexed rows split by liked-ness; on-disk - /// files with no index row (orphan partials) count as Rolling so - /// the rolling cap genuinely bounds disk. - Future bucketUsage(Set liked) async { - final rows = await _db.select(_db.audioCacheIndex).get(); - final indexed = {}; - var likedB = 0; - var rollingB = 0; - for (final r in rows) { - indexed.add(r.trackId); - if (liked.contains(r.trackId)) { - likedB += r.sizeBytes; - } else { - rollingB += r.sizeBytes; - } - } - final dir = Directory(await _tracksDir()); - if (await dir.exists()) { - await for (final e in dir.list(followLinks: false)) { - if (e is! File) continue; - final name = e.uri.pathSegments.last; - final id = name.endsWith('.mp3') - ? name.substring(0, name.length - 4) - : name; - if (indexed.contains(id)) continue; - try { - rollingB += await e.length(); - } catch (_) {} - } - } - return (liked: likedB, rolling: rollingB); - } - - /// Enforces both budgets independently (0 = unlimited). - /// - /// Rolling: evict non-liked indexed rows LRU (oldest lastPlayedAt - /// /cachedAt first), then sweep orphan files oldest-by-mtime, until - /// rolling usage ≤ rollingCap. Liked: evict liked indexed rows LRU - /// until ≤ likedCap. Liked is only ever touched by its own (large) - /// cap, so normal use never evicts the user's liked library. - Future evictBuckets({ - required int likedCap, - required int rollingCap, - required Set liked, - }) async { - final usage = await bucketUsage(liked); - - if (rollingCap > 0 && usage.rolling > rollingCap) { - var over = usage.rolling - rollingCap; - final rolling = await (_db.select(_db.audioCacheIndex) - ..where((t) => t.trackId.isNotIn(liked.toList())) - ..orderBy([ - (t) => OrderingTerm.asc(t.lastPlayedAt), - (t) => OrderingTerm.asc(t.cachedAt), - ])) - .get(); - for (final r in rolling) { - if (over <= 0) break; - final f = File(r.path); - if (f.existsSync()) await f.delete(); - await (_db.delete(_db.audioCacheIndex) - ..where((t) => t.trackId.equals(r.trackId))) - .go(); - over -= r.sizeBytes; - } - if (over > 0) await _sweepOrphans(over); - } - - if (likedCap > 0 && usage.liked > likedCap) { - var over = usage.liked - likedCap; - final likedRows = await (_db.select(_db.audioCacheIndex) - ..where((t) => t.trackId.isIn(liked.toList())) - ..orderBy([ - (t) => OrderingTerm.asc(t.lastPlayedAt), - (t) => OrderingTerm.asc(t.cachedAt), - ])) - .get(); - for (final r in likedRows) { - if (over <= 0) break; - final f = File(r.path); - if (f.existsSync()) await f.delete(); - await (_db.delete(_db.audioCacheIndex) - ..where((t) => t.trackId.equals(r.trackId))) - .go(); - over -= r.sizeBytes; - } - } - } - - /// Deletes orphan files (on disk, no index row) oldest-mtime-first - /// until `over` bytes are reclaimed. These are unindexed partials, - /// always Rolling, always evict-first. - Future _sweepOrphans(int over) async { - final indexed = { - for (final r in await _db.select(_db.audioCacheIndex).get()) r.trackId - }; - final dir = Directory(await _tracksDir()); - if (!await dir.exists()) return; - final orphans = <({File f, int size, DateTime mtime})>[]; - await for (final e in dir.list(followLinks: false)) { - if (e is! File) continue; - final name = e.uri.pathSegments.last; - final id = - name.endsWith('.mp3') ? name.substring(0, name.length - 4) : name; - if (indexed.contains(id)) continue; - try { - final st = e.statSync(); - orphans.add((f: e, size: st.size, mtime: st.modified)); - } catch (_) {} - } - orphans.sort((a, b) => a.mtime.compareTo(b.mtime)); - var remaining = over; - for (final o in orphans) { - if (remaining <= 0) break; - try { - await o.f.delete(); - remaining -= o.size; - } catch (_) {} - } - } - - /// Clears EVERY row + EVERY file. Wired to "Clear cache". - Future clearAll() async { - final dir = await _tracksDir(); - final d = Directory(dir); - if (d.existsSync()) { - for (final e in d.listSync()) { - if (e is File) await e.delete(); - } - } - await _db.delete(_db.audioCacheIndex).go(); - } -} - -/// AppDb singleton. One per app run; ref.onDispose closes it. -final appDbProvider = Provider((ref) { - final db = AppDb(); - ref.onDispose(db.close); - return db; -}); - -final audioCacheManagerProvider = Provider((ref) { - final db = ref.watch(appDbProvider); - return AudioCacheManager( - db: db, - dioFactory: () async => ref.read(dioProvider.future), - ); -}); diff --git a/flutter_client/lib/cache/cache_filler.dart b/flutter_client/lib/cache/cache_filler.dart deleted file mode 100644 index 84f060d1..00000000 --- a/flutter_client/lib/cache/cache_filler.dart +++ /dev/null @@ -1,235 +0,0 @@ -// Background metadata sweeper that walks the drift cache for missing -// relations and fills them via /api/artists/:id + /api/albums/:id. -// Cover bytes for newly-filled albums are pre-warmed too so the -// per-tile display path doesn't have to wait on network. -// -// Why this layer on top of SyncController and HydrationQueue: -// - SyncController.sync only ingests entities the server emitted -// into the library_changes delta for this user. For a fresh -// install, that's everything; for an existing user, only recent -// changes. The per-artist album list and per-album track list -// are NOT in those deltas — they're derived at query time. -// - HydrationQueue fills these lazily on tile render. CacheFiller -// fills them proactively on a slow schedule so tapping an artist -// surfaces albums immediately instead of triggering a fresh -// /api/artists/:id at tap time. -// -// Pacing is conservative — 200ms between requests, max 200 entities -// per sweep, 5-minute interval. Designed to never compete with user -// activity. Wall time on a 1000-artist library: ~3-4 minutes spread -// over multiple sweeps. - -import 'dart:async'; - -import 'package:drift/drift.dart' show Variable; -import 'package:flutter/foundation.dart' show debugPrint; -import 'package:flutter_cache_manager/flutter_cache_manager.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../auth/auth_provider.dart' - show serverUrlProvider, sessionTokenProvider; -import '../cache/adapters.dart'; -import '../cache/audio_cache_manager.dart' show appDbProvider; -import '../cache/connectivity_provider.dart'; -import '../library/library_providers.dart' show libraryApiProvider; - -class CacheFiller { - CacheFiller(this._ref); - final Ref _ref; - - Timer? _initialTimer; - Timer? _intervalTimer; - bool _running = false; - bool _disposed = false; - - /// Delay between launch and the first sweep. Long enough for - /// SyncController to land its initial /api/library/sync so the - /// CacheFiller's "unfilled relations" query has meaningful work. - static const _initialDelay = Duration(seconds: 10); - - /// Cadence between sweeps. Once a sweep finds nothing to do (steady - /// state) the interval becomes a cheap drift query + early exit. - static const _interval = Duration(minutes: 5); - - /// Throttle between per-entity REST requests so the filler doesn't - /// saturate the server or compete with user-initiated playback. - static const _requestThrottle = Duration(milliseconds: 200); - - /// Per-sweep cap. Without this, a fresh install with thousands of - /// artists would tie up the network for many minutes in one go. - /// The next sweep continues where this one left off (the WHERE - /// NOT EXISTS query naturally skips already-filled rows). - static const _maxIdsPerSweep = 200; - - void start() { - _initialTimer = Timer(_initialDelay, _sweep); - _intervalTimer = Timer.periodic(_interval, (_) => _sweep()); - } - - Future _sweep() async { - if (_disposed || _running) return; - final online = await _ref - .read(connectivityProvider.future) - .timeout(const Duration(seconds: 3), onTimeout: () => true); - if (!online) return; - _running = true; - try { - await _fillArtists(); - if (_disposed) return; - await _fillAlbums(); - } catch (e, st) { - debugPrint('cache_filler: sweep failed: $e\n$st'); - } finally { - _running = false; - } - } - - /// Find artists with no albums in cache and fetch /api/artists/:id - /// for each (single round-trip yields artist + albums via the new - /// getArtistDetail API method). Newly-discovered album covers - /// land in flutter_cache_manager's disk cache too so the next - /// visit to the artist's albums grid paints from disk. - Future _fillArtists() async { - final db = _ref.read(appDbProvider); - final rows = await db.customSelect( - ''' - SELECT a.id FROM cached_artists a - WHERE NOT EXISTS ( - SELECT 1 FROM cached_albums b WHERE b.artist_id = a.id - ) - LIMIT ? - ''', - variables: [Variable.withInt(_maxIdsPerSweep)], - ).get(); - final ids = rows.map((r) => r.read('id')).toList(); - if (ids.isEmpty) return; - - final api = await _ref.read(libraryApiProvider.future); - final newAlbumIds = []; - - for (final id in ids) { - if (_disposed) return; - try { - final detail = await api.getArtistDetail(id); - await db.transaction(() async { - await db - .into(db.cachedArtists) - .insertOnConflictUpdate(detail.artist.toDrift()); - if (detail.albums.isNotEmpty) { - await db.batch((b) { - b.insertAllOnConflictUpdate( - db.cachedAlbums, - detail.albums.map((a) => a.toDrift()).toList(), - ); - }); - newAlbumIds.addAll(detail.albums.map((a) => a.id)); - } - }); - } catch (e) { - debugPrint('cache_filler: fillArtist($id) failed: $e'); - } - await Future.delayed(_requestThrottle); - } - - if (newAlbumIds.isNotEmpty) { - await _prewarmCovers(newAlbumIds); - } - } - - /// Find albums with no tracks in cache and fetch /api/albums/:id - /// for each. The bulk response carries the album's track list which - /// we persist into cached_tracks for instant album detail render - /// on the next visit. - Future _fillAlbums() async { - final db = _ref.read(appDbProvider); - final rows = await db.customSelect( - ''' - SELECT a.id FROM cached_albums a - WHERE NOT EXISTS ( - SELECT 1 FROM cached_tracks t WHERE t.album_id = a.id - ) - LIMIT ? - ''', - variables: [Variable.withInt(_maxIdsPerSweep)], - ).get(); - final ids = rows.map((r) => r.read('id')).toList(); - if (ids.isEmpty) return; - - final api = await _ref.read(libraryApiProvider.future); - for (final id in ids) { - if (_disposed) return; - try { - final result = await api.getAlbum(id); - if (result.tracks.isNotEmpty) { - await db.batch((b) { - b.insertAllOnConflictUpdate( - db.cachedTracks, - result.tracks.map((t) => t.toDrift()).toList(), - ); - }); - } - } catch (e) { - debugPrint('cache_filler: fillAlbum($id) failed: $e'); - } - await Future.delayed(_requestThrottle); - } - } - - /// Pre-warm album cover bytes via flutter_cache_manager so the - /// per-tile ServerImage / CachedNetworkImage paints from disk on - /// the user's first visit. Mirrors SyncController's prewarm - /// helper — duplicated here intentionally to keep this file self- - /// contained; a shared helper can come out of #357 follow-up if - /// the duplication grows. - Future _prewarmCovers(List albumIds) async { - final baseUrl = await _ref.read(serverUrlProvider.future); - if (baseUrl == null || baseUrl.isEmpty) return; - final token = await _ref.read(sessionTokenProvider.future); - final headers = (token != null && token.isNotEmpty) - ? {'Authorization': 'Bearer $token'} - : {}; - final base = baseUrl.endsWith('/') - ? baseUrl.substring(0, baseUrl.length - 1) - : baseUrl; - final mgr = DefaultCacheManager(); - - var idx = 0; - Future worker() async { - while (idx < albumIds.length) { - if (_disposed) return; - final i = idx++; - try { - await mgr.downloadFile( - '$base/api/albums/${albumIds[i]}/cover', - authHeaders: headers, - ); - } catch (_) { - // Best-effort prewarm — 404 (missing collage) and 401 - // (token refresh races) shouldn't abort the rest. - } - } - } - - // Concurrency 3 — same as SyncController's prewarm. Covers are - // small enough that more parallelism doesn't help much and - // burns radio. - await Future.wait(List.generate(3, (_) => worker())); - } - - void dispose() { - _disposed = true; - _initialTimer?.cancel(); - _intervalTimer?.cancel(); - } -} - -/// Read once at app start (from app.dart's postFrameCallback) to -/// activate the filler. Disposed via ref.onDispose when the provider -/// scope tears down; in practice the scope lives for the app's -/// lifetime so dispose only fires on uninstall / process death. -final cacheFillerProvider = Provider((ref) { - final filler = CacheFiller(ref); - ref.onDispose(filler.dispose); - filler.start(); - return filler; -}); diff --git a/flutter_client/lib/cache/cache_first.dart b/flutter_client/lib/cache/cache_first.dart deleted file mode 100644 index 274b285a..00000000 --- a/flutter_client/lib/cache/cache_first.dart +++ /dev/null @@ -1,115 +0,0 @@ -// Drift-first reactive read pattern with REST cold-cache fallback (#357 plan C). -// -// Subscribes to a drift watch() stream. On each emission: -// - non-empty → map to result type T and yield -// - empty + online → fetch via REST, populate drift, await re-emission -// - empty + offline → yield mapped empty result (UI shows empty state) -// -// With `alwaysRefresh: true`, also kicks off a one-shot REST refresh -// in the background after the first non-empty emission. Use for -// aggregate lists (playlists, etc.) where the server may have rows -// the local sync didn't pick up — yields cache immediately, refreshes -// silently, and drift watch() re-emits with whatever new rows landed. -// -// The pattern lets every read provider trust drift as the source of -// truth. SyncController keeps drift fresh in the background; widget -// rebuilds happen automatically as drift writes propagate via watch(). - -import 'dart:async'; - -import 'package:flutter/foundation.dart' show debugPrint; - -// `tag` parameter is preserved for future ad-hoc instrumentation. -// Normal operation only logs failure paths so the per-screen log -// noise stays low. - -/// Wraps the watch + cold-cache fallback pattern. Generic over: -/// D — the drift row type (or TypedResult for joins) -/// T — the result type the caller wants (e.g. `List`) -/// -/// `fetchAndPopulate` is invoked when drift is empty AND `isOnline()` -/// returns true. It must populate drift via its own side-effect; the -/// drift watch() stream will re-emit and this helper yields the -/// populated rows on the next iteration. -/// -/// REST failures are swallowed — the helper falls through to yielding -/// the empty result. Caller is responsible for surfacing errors via -/// toast etc. -Stream cacheFirst({ - required Stream> driftStream, - required Future Function() fetchAndPopulate, - required T Function(List) toResult, - required Future Function() isOnline, - bool alwaysRefresh = false, - String? tag, -}) async* { - // Tracks whether we've already kicked off a stale-while-revalidate - // refresh for this stream subscription, so we don't fire one on every - // drift re-emission (otherwise the populate cycles forever). - var revalidated = false; - - // Tracks whether we've already tried a cold-cache fetch on this - // subscription. Without this, providers can spin forever in the - // rows-empty branch when fetchAndPopulate writes rows that don't - // match THIS filter — e.g. three liked-tab streams sharing one - // populate: the track populate writes track-typed rows, drift - // watch fires for the cached_likes table, the album stream - // re-emits with rows-empty (no album likes), the rows-empty - // branch re-fires populate, repeats forever. Setting this guard - // makes the first fetch attempt also the last for any given - // subscription. - var coldFetchAttempted = false; - - await for (final rows in driftStream) { - if (rows.isNotEmpty) { - yield toResult(rows); - coldFetchAttempted = true; - // Stale-while-revalidate: yield cache immediately, then kick off - // a REST refresh in the background. Drift watch() picks up the - // resulting writes and re-emits via this same stream loop. - // Useful for aggregate lists (e.g. playlists) where the server - // may have rows the local sync didn't pick up. - if (alwaysRefresh && !revalidated && await isOnline()) { - revalidated = true; - unawaited(_safeFetch(fetchAndPopulate)); - } - continue; - } - // rows is empty. If we've already attempted a cold fetch and - // drift is still empty for this filter, yield empty so the UI - // shows the no-content state instead of spinning forever. - if (coldFetchAttempted) { - yield toResult(rows); - continue; - } - coldFetchAttempted = true; - if (await isOnline()) { - try { - await fetchAndPopulate(); - // Yield the current (still-empty) rows so the UI moves past - // loading even if populate was a no-op for this filter - // (server returned nothing matching, or all rows were - // already in drift via sync). If populate DID write rows - // matching this filter, the drift watch's next emission - // yields them via the rows.isNotEmpty branch — UI briefly - // shows empty then populates, instead of spinning. - yield toResult(rows); - } catch (e, st) { - if (tag != null) { - debugPrint('cacheFirst[$tag]: fetchAndPopulate failed: $e\n$st'); - } - yield toResult(rows); // empty result; caller surfaces error - } - } else { - yield toResult(rows); // empty result; offline - } - } -} - -Future _safeFetch(Future Function() fn) async { - try { - await fn(); - } catch (_) { - // Background revalidate — swallow; UI already showed cached state. - } -} diff --git a/flutter_client/lib/cache/cache_settings_provider.dart b/flutter_client/lib/cache/cache_settings_provider.dart deleted file mode 100644 index 517ab195..00000000 --- a/flutter_client/lib/cache/cache_settings_provider.dart +++ /dev/null @@ -1,114 +0,0 @@ -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; - -import '../auth/auth_provider.dart' show secureStorageProvider; - -/// Operator-tunable cache settings. Persisted via -/// flutter_secure_storage on the same device. -/// -/// #427 S2: the single `capBytes` is replaced by two independent -/// budgets — Liked and Rolling-recent — each defaulting to 5GB. -/// Bucketing is by liked-ness (a cached track currently in the -/// user's liked set is charged to Liked; everything else to -/// Rolling), so the dedup is storage-only and never filters -/// playback. The old `cache_cap_bytes` key is intentionally not -/// migrated; defaults reapply (a one-time re-tune, not data loss). -class CacheSettings { - const CacheSettings({ - required this.likedCapBytes, - required this.rollingCapBytes, - required this.prefetchWindow, - required this.cacheLikedTracks, - }); - - /// Liked-bucket budget. 0 = unlimited. - final int likedCapBytes; - - /// Rolling (recently-played) budget. 0 = unlimited. - final int rollingCapBytes; - - /// 1..10. Number of next-tracks the prefetcher pre-downloads. - final int prefetchWindow; - - /// When true, every like_track event triggers a pin with source autoLiked. - final bool cacheLikedTracks; - - CacheSettings copyWith({ - int? likedCapBytes, - int? rollingCapBytes, - int? prefetchWindow, - bool? cacheLikedTracks, - }) => - CacheSettings( - likedCapBytes: likedCapBytes ?? this.likedCapBytes, - rollingCapBytes: rollingCapBytes ?? this.rollingCapBytes, - prefetchWindow: prefetchWindow ?? this.prefetchWindow, - cacheLikedTracks: cacheLikedTracks ?? this.cacheLikedTracks, - ); - - static const _fiveGiB = 5 * 1024 * 1024 * 1024; - - static const defaults = CacheSettings( - likedCapBytes: _fiveGiB, - rollingCapBytes: _fiveGiB, - prefetchWindow: 5, - cacheLikedTracks: true, - ); -} - -class CacheSettingsController extends AsyncNotifier { - static const _kLikedCap = 'cache_liked_cap_bytes'; - static const _kRollingCap = 'cache_rolling_cap_bytes'; - static const _kPrefetch = 'cache_prefetch_window'; - static const _kCacheLiked = 'cache_liked_tracks'; - - late FlutterSecureStorage _storage; - - @override - Future build() async { - _storage = ref.read(secureStorageProvider); - final likedCap = await _storage.read(key: _kLikedCap); - final rollingCap = await _storage.read(key: _kRollingCap); - final pre = await _storage.read(key: _kPrefetch); - final liked = await _storage.read(key: _kCacheLiked); - return CacheSettings( - likedCapBytes: likedCap == null - ? CacheSettings.defaults.likedCapBytes - : int.tryParse(likedCap) ?? CacheSettings.defaults.likedCapBytes, - rollingCapBytes: rollingCap == null - ? CacheSettings.defaults.rollingCapBytes - : int.tryParse(rollingCap) ?? CacheSettings.defaults.rollingCapBytes, - prefetchWindow: pre == null - ? CacheSettings.defaults.prefetchWindow - : (int.tryParse(pre) ?? 5).clamp(1, 10), - cacheLikedTracks: liked == null - ? CacheSettings.defaults.cacheLikedTracks - : liked == 'true', - ); - } - - Future setLikedCapBytes(int bytes) async { - await _storage.write(key: _kLikedCap, value: bytes.toString()); - state = AsyncData(state.value!.copyWith(likedCapBytes: bytes)); - } - - Future setRollingCapBytes(int bytes) async { - await _storage.write(key: _kRollingCap, value: bytes.toString()); - state = AsyncData(state.value!.copyWith(rollingCapBytes: bytes)); - } - - Future setPrefetchWindow(int n) async { - final clamped = n.clamp(1, 10); - await _storage.write(key: _kPrefetch, value: clamped.toString()); - state = AsyncData(state.value!.copyWith(prefetchWindow: clamped)); - } - - Future setCacheLikedTracks(bool on) async { - await _storage.write(key: _kCacheLiked, value: on.toString()); - state = AsyncData(state.value!.copyWith(cacheLikedTracks: on)); - } -} - -final cacheSettingsProvider = - AsyncNotifierProvider( - CacheSettingsController.new); diff --git a/flutter_client/lib/cache/connectivity_provider.dart b/flutter_client/lib/cache/connectivity_provider.dart deleted file mode 100644 index 06fa3e6c..00000000 --- a/flutter_client/lib/cache/connectivity_provider.dart +++ /dev/null @@ -1,35 +0,0 @@ -import 'package:connectivity_plus/connectivity_plus.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -/// Online if at least one connectivity result is non-none. -/// connectivity_plus reports the union (wifi, mobile, vpn, etc.) — operator -/// chose "no Wi-Fi gate" for #357, so any connection means "go ahead and -/// pull/cache". -/// -/// IMPORTANT: onConnectivityChanged only emits on *changes*, not on -/// subscription. Without seeding an initial value via checkConnectivity(), -/// any consumer using `ref.read(connectivityProvider.future)` would -/// block until the OS happened to report a connectivity flip — which -/// is exactly what made album/artist/playlist detail screens spin -/// forever for tiles tapped before the first event landed. -final connectivityProvider = StreamProvider((ref) async* { - final c = Connectivity(); - bool isOnline(List rs) => - rs.any((r) => r != ConnectivityResult.none); - - // checkConnectivity() goes over a platform channel and on some - // Android builds it can stall. Fall back to "assume online" after - // 2s so consumers waiting on .future never block forever — being - // wrong about connectivity costs at most one failed request, but - // being stuck spins the UI indefinitely. - try { - final initial = await c.checkConnectivity().timeout( - const Duration(seconds: 2), - onTimeout: () => const [ConnectivityResult.wifi], - ); - yield isOnline(initial); - } catch (_) { - yield true; - } - yield* c.onConnectivityChanged.map(isOnline); -}); diff --git a/flutter_client/lib/cache/db.dart b/flutter_client/lib/cache/db.dart deleted file mode 100644 index 82da7178..00000000 --- a/flutter_client/lib/cache/db.dart +++ /dev/null @@ -1,343 +0,0 @@ -// Drift database for Minstrel's offline cache (#357 plan B). -// -// Two cache layers in one database: -// - Metadata cache (CachedArtists, CachedAlbums, CachedTracks, -// CachedLikes, CachedPlaylists, CachedPlaylistTracks) — populated -// by SyncController via /api/library/sync -// - Audio cache index (AudioCacheIndex) — owned by AudioCacheManager -// -// Plus SyncMetadata holding the latest sync cursor. -// -// All entity ids are TEXT (server-side UUIDs serialized as strings). -// build_runner generates db.g.dart from this file; it's gitignored and -// regenerated in CI. - -import 'package:drift/drift.dart'; -import 'package:drift_flutter/drift_flutter.dart'; - -part 'db.g.dart'; - -class CachedArtists extends Table { - TextColumn get id => text()(); - TextColumn get name => text()(); - TextColumn get sortName => text()(); - TextColumn get mbid => text().nullable()(); - TextColumn get artistThumbPath => text().nullable()(); - TextColumn get artistFanartPath => text().nullable()(); - DateTimeColumn get fetchedAt => dateTime().withDefault(currentDateAndTime)(); - @override - Set get primaryKey => {id}; -} - -class CachedAlbums extends Table { - TextColumn get id => text()(); - TextColumn get artistId => text()(); - TextColumn get title => text()(); - TextColumn get sortTitle => text()(); - TextColumn get releaseDate => text().nullable()(); - TextColumn get coverPath => text().nullable()(); - TextColumn get mbid => text().nullable()(); - DateTimeColumn get fetchedAt => dateTime().withDefault(currentDateAndTime)(); - @override - Set get primaryKey => {id}; -} - -class CachedTracks extends Table { - TextColumn get id => text()(); - TextColumn get albumId => text()(); - TextColumn get artistId => text()(); - TextColumn get title => text()(); - IntColumn get durationMs => integer().withDefault(const Constant(0))(); - IntColumn get trackNumber => integer().nullable()(); - IntColumn get discNumber => integer().nullable()(); - TextColumn get filePath => text().nullable()(); - TextColumn get fileFormat => text().nullable()(); - TextColumn get genre => text().nullable()(); - DateTimeColumn get fetchedAt => dateTime().withDefault(currentDateAndTime)(); - @override - Set get primaryKey => {id}; -} - -class CachedLikes extends Table { - TextColumn get userId => text()(); - TextColumn get entityType => text()(); // 'track' | 'album' | 'artist' - TextColumn get entityId => text()(); - DateTimeColumn get likedAt => dateTime().withDefault(currentDateAndTime)(); - @override - Set get primaryKey => {userId, entityType, entityId}; -} - -class CachedPlaylists extends Table { - TextColumn get id => text()(); - TextColumn get userId => text()(); - TextColumn get name => text()(); - TextColumn get description => text().withDefault(const Constant(''))(); - BoolColumn get isPublic => boolean().withDefault(const Constant(false))(); - TextColumn get coverPath => text().nullable()(); - IntColumn get trackCount => integer().withDefault(const Constant(0))(); - IntColumn get durationSec => integer().withDefault(const Constant(0))(); - /// Server's system_variant: null for user playlists, "for_you" / - /// "songs_like_artist" / "discover" for system-generated mixes. - /// Added in schemaVersion 2 to let the add-to-playlist sheet filter - /// out system playlists locally without a REST round-trip. - TextColumn get systemVariant => text().nullable()(); - DateTimeColumn get fetchedAt => dateTime().withDefault(currentDateAndTime)(); - @override - Set get primaryKey => {id}; -} - -class CachedPlaylistTracks extends Table { - TextColumn get playlistId => text()(); - TextColumn get trackId => text()(); - IntColumn get position => integer().withDefault(const Constant(0))(); - @override - Set get primaryKey => {playlistId, trackId}; -} - -/// One row per fully-downloaded audio file. `source` drives tiered LRU -/// eviction; `incidental` evicts first, `manual` last. -class AudioCacheIndex extends Table { - TextColumn get trackId => text()(); - TextColumn get path => text()(); - IntColumn get sizeBytes => integer()(); - DateTimeColumn get cachedAt => dateTime().withDefault(currentDateAndTime)(); - /// When the track was last played. Drives the offline "Recently - /// played" ordering and rolling-bucket LRU eviction. Distinct from - /// cachedAt (download time). Nullable for pre-schema-9 rows until - /// the next play touches them (migration backfills to cachedAt). - DateTimeColumn get lastPlayedAt => dateTime().nullable()(); - TextColumn get source => textEnum()(); - @override - Set get primaryKey => {trackId}; -} - -/// Single-row table holding the latest sync cursor. -class SyncMetadata extends Table { - IntColumn get id => integer().withDefault(const Constant(1))(); - IntColumn get cursor => integer().withDefault(const Constant(0))(); - DateTimeColumn get lastSyncAt => dateTime().nullable()(); - @override - Set get primaryKey => {id}; -} - -/// Single-row cache of the /api/me/history response. Mirrors the -/// CachedHomeSnapshot pattern: opening the History tab in the Library -/// screen yields the last-known page immediately while a fresh REST -/// pull lands underneath via SWR. Also makes basic offline scrollback -/// possible — the last fetched page survives both app restart and -/// loss of connectivity. Schema 4+. -class CachedHistorySnapshot extends Table { - IntColumn get id => integer().withDefault(const Constant(1))(); - TextColumn get json => text()(); - DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)(); - @override - Set get primaryKey => {id}; -} - -/// Single-row cache of the /api/me/system-playlists-status response. -/// The home Playlists row reads this every render to pick between -/// real cards and placeholder cards for For-You / Discover / Songs- -/// like slots, so a fresh-mount cold fetch produces a visible -/// "building / pending / failed" flicker. Storing the last result -/// as a JSON blob means the home renders with the prior status -/// instantly, then SWR refreshes underneath. Schema 7+. -class CachedSystemPlaylistsStatus extends Table { - IntColumn get id => integer().withDefault(const Constant(1))(); - TextColumn get json => text()(); - DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)(); - @override - Set get primaryKey => {id}; -} - -/// Section→position→entity-id index for the home screen, populated -/// from the per-item discovery endpoint `/api/home/index`. Each row -/// pins one tile slot to an entity; the actual entity data lives in -/// cached_albums / cached_artists / cached_tracks. The home screen -/// reads this table to know the layout, then hydrates each tile -/// against the entity tables (per-item rendering). Schema 6+. -class CachedHomeIndex extends Table { - /// One of: 'recently_added_albums', 'rediscover_albums', - /// 'rediscover_artists', 'most_played_tracks', 'last_played_artists'. - /// Mirrors the keys /api/home and /api/home/index emit. - TextColumn get section => text()(); - IntColumn get position => integer()(); - /// One of: 'album', 'artist', 'track'. Used to dispatch hydration - /// to the right per-entity endpoint. - TextColumn get entityType => text()(); - TextColumn get entityId => text()(); - DateTimeColumn get fetchedAt => dateTime().withDefault(currentDateAndTime)(); - @override - Set get primaryKey => {section, position}; -} - -/// Outbound mutation queue for offline-resilient REST calls. Likes, -/// quarantine flag/unflag, playlist appendTracks, Lidarr request -/// create/cancel all enqueue here on REST failure so the user's -/// intent persists across network loss. MutationReplayer drains on -/// connectivity transitions and a 1-minute periodic tick; entries -/// with attempts >= 5 are skipped (the server treats them as -/// permanent failures and library_changes sync will correct any -/// resulting drift drift on next sync). Schema 8+. -class CachedMutations extends Table { - IntColumn get id => integer().autoIncrement()(); - /// Stable kind string (see mutation_queue.dart constants). The - /// replayer looks up the handler in a kind→Function map; unknown - /// kinds are dropped to avoid getting stuck. - TextColumn get kind => text()(); - /// JSON-encoded payload — args needed to replay the REST call. - /// Shape depends on kind; see mutation_queue.dart. - TextColumn get payload => text()(); - DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)(); - /// When the last drain attempt fired against this row. Null until - /// the first attempt. Useful for diagnostic queries. - DateTimeColumn get lastAttemptAt => dateTime().nullable()(); - IntColumn get attempts => integer().withDefault(const Constant(0))(); -} - -/// Caller-scoped quarantine ("hidden") rows. Mirrors the wire shape of -/// /api/quarantine/mine — fully denormalized (track + album + artist -/// fields inline) because the Hidden tab renders straight from this row -/// without joining other tables. Columnar (vs JSON blob) so flag/unflag -/// can do row-level INSERT/DELETE; the drift watch() emission feeds -/// MyQuarantineController state directly. Schema 5+. -class CachedQuarantineMine extends Table { - TextColumn get trackId => text()(); - TextColumn get reason => text()(); - TextColumn get notes => text().nullable()(); - TextColumn get createdAt => text()(); - TextColumn get trackTitle => text()(); - IntColumn get trackDurationMs => integer().withDefault(const Constant(0))(); - TextColumn get albumId => text()(); - TextColumn get albumTitle => text()(); - TextColumn get albumCoverArtPath => text().nullable()(); - TextColumn get artistId => text()(); - TextColumn get artistName => text()(); - DateTimeColumn get fetchedAt => dateTime().withDefault(currentDateAndTime)(); - @override - Set get primaryKey => {trackId}; -} - -/// Single-row snapshot of the last playback session — queue (TrackRef -/// JSON), current index, position, and #415 source. Lets a torn-down -/// session (the #52 idle/dismissed teardown) resume on next launch; -/// without it the headset / lock-screen play button has nothing to -/// resume. Mirrors the CachedHomeSnapshot single-row JSON pattern. -/// Schema 10+. -class CachedResumeState extends Table { - IntColumn get id => integer().withDefault(const Constant(1))(); - TextColumn get json => text()(); - DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)(); - @override - Set get primaryKey => {id}; -} - -enum CacheSource { manual, autoLiked, autoPlaylist, autoPrefetch, incidental } - -@DriftDatabase(tables: [ - CachedArtists, - CachedAlbums, - CachedTracks, - CachedLikes, - CachedPlaylists, - CachedPlaylistTracks, - AudioCacheIndex, - SyncMetadata, - CachedHistorySnapshot, - CachedQuarantineMine, - CachedHomeIndex, - CachedSystemPlaylistsStatus, - CachedMutations, - CachedResumeState, -]) -class AppDb extends _$AppDb { - AppDb([QueryExecutor? e]) : super(e ?? driftDatabase(name: 'minstrel_cache')); - - @override - int get schemaVersion => 11; - - @override - MigrationStrategy get migration => MigrationStrategy( - onCreate: (m) => m.createAll(), - onUpgrade: (m, from, to) async { - if (from < 2) { - // Schema 2: add CachedPlaylists.systemVariant. Existing - // rows get null and the next sync rebuilds them with the - // server's system_variant value. - await m.addColumn(cachedPlaylists, cachedPlaylists.systemVariant); - // Reset cursor so the next /api/library/sync re-emits all - // playlists; otherwise the new column would stay null on - // pre-existing rows until they happen to change server-side. - await customStatement('UPDATE sync_metadata SET cursor = 0'); - } - if (from < 3) { - // Schema 3: cached_home_snapshot (legacy drift-first home). - // The table + its homeProvider/JSON encoders were removed - // in #406; recreated here as raw SQL so this historical - // step still compiles without the generated table symbol. - // The from<11 step below drops it. - await customStatement( - 'CREATE TABLE IF NOT EXISTS "cached_home_snapshot" ' - '("id" INTEGER NOT NULL DEFAULT 1, "json" TEXT, ' - '"updated_at" INTEGER, PRIMARY KEY ("id"));', - ); - } - if (from < 4) { - // Schema 4: cached_history_snapshot for drift-first - // History tab. Same pattern as cached_home_snapshot — - // empty on upgrade; first /api/me/history fetch populates. - await m.createTable(cachedHistorySnapshot); - } - if (from < 5) { - // Schema 5: cached_quarantine_mine for drift-first Hidden - // tab. Empty on upgrade; first /api/quarantine/mine fetch - // populates. Optimistic flag/unflag also writes here. - await m.createTable(cachedQuarantineMine); - } - if (from < 6) { - // Schema 6: cached_home_index for per-item rendering. - // Empty on upgrade; first /api/home/index fetch populates. - // cached_home_snapshot stays in place for now — older - // builds still read from it as a fallback path. - await m.createTable(cachedHomeIndex); - } - if (from < 7) { - // Schema 7: cached_system_playlists_status. Single-row - // snapshot of /api/me/system-playlists-status used by the - // home Playlists row. Empty on upgrade; first fetch - // populates. - await m.createTable(cachedSystemPlaylistsStatus); - } - if (from < 8) { - // Schema 8: cached_mutations outbound queue. Empty on - // upgrade; populated by controllers when REST calls fail. - await m.createTable(cachedMutations); - } - if (from < 9) { - // Schema 9 (#427 S2): two-bucket cache. lastPlayedAt - // gives the offline "Recently played" view a real - // recency signal (cachedAt is download time, not play - // time). Nullable — backfilled to cachedAt so existing - // rows order sensibly until next play touches them. - await m.addColumn(audioCacheIndex, audioCacheIndex.lastPlayedAt); - await customStatement( - 'UPDATE audio_cache_index SET last_played_at = cached_at', - ); - } - if (from < 10) { - // Schema 10 (#54): cached_resume_state — single-row last- - // session snapshot for resume-on-launch. Empty on upgrade; - // first persist populates it. - await m.createTable(cachedResumeState); - } - if (from < 11) { - // Schema 11 (#406): drop the legacy cached_home_snapshot - // table. The home screen now renders solely from the - // per-item cached_home_index path; the legacy homeProvider - // + its JSON encoders were removed. - await customStatement( - 'DROP TABLE IF EXISTS "cached_home_snapshot";', - ); - } - }, - ); -} diff --git a/flutter_client/lib/cache/hydration_queue.dart b/flutter_client/lib/cache/hydration_queue.dart deleted file mode 100644 index a16c614d..00000000 --- a/flutter_client/lib/cache/hydration_queue.dart +++ /dev/null @@ -1,139 +0,0 @@ -// Per-item hydration coordinator for the home/playlist/liked surfaces. -// -// Tile widgets are reactive to their per-entity drift row. On first -// build, if drift has no row for the requested id, the tile asks this -// queue to hydrate it. The queue dispatches one /api//:id -// request, writes the result to drift, and the drift watch() on the -// tile re-emits with the populated row. -// -// Concurrency cap: HydrationQueue limits the number of in-flight -// requests so a 50-tile home screen doesn't fire 50 parallel /api/ -// calls and stall the auth-token-bound dio pool. In-flight dedup -// (keyed by `:`) ensures a single hydration when multiple -// tiles share an entity (rare on home, common when several screens -// reference the same album). -// -// Failures are swallowed — a single failed hydration leaves the tile -// in skeleton state. A future retry-on-visit pass can layer on top -// without changing the queue's contract. - -import 'dart:async'; -import 'dart:collection'; - -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../cache/adapters.dart'; -import '../cache/audio_cache_manager.dart' show appDbProvider; -import '../library/library_providers.dart' show libraryApiProvider; - -/// Bounded-concurrency request queue for per-entity hydration. One -/// instance per Riverpod scope (provider singleton below). -class HydrationQueue { - HydrationQueue(this._ref); - final Ref _ref; - - /// Concurrent slot count. 4 keeps the dio token pool from saturating - /// while still pulling tiles down faster than a serial loop. Bump - /// if profiling shows the queue idle while the network is healthy. - static const _maxConcurrent = 4; - - final Queue<_HydrationRequest> _pending = Queue(); - final Set _inFlightKeys = {}; - int _inFlight = 0; - - /// Request hydration for (entityType, entityId). Idempotent — second - /// call for the same key while the first is queued or in flight is - /// dropped, so multiple tile rebuilds during a fast scroll don't - /// inflate the queue. - void enqueue({required String entityType, required String entityId}) { - if (entityId.isEmpty) return; - final key = '$entityType:$entityId'; - if (_inFlightKeys.contains(key)) return; - if (_pending.any((r) => r.key == key)) return; - _pending.add(_HydrationRequest( - entityType: entityType, entityId: entityId, key: key)); - _pump(); - } - - void _pump() { - while (_inFlight < _maxConcurrent && _pending.isNotEmpty) { - final req = _pending.removeFirst(); - _inFlightKeys.add(req.key); - _inFlight++; - // Unawaited on purpose — _pump returns immediately so further - // enqueues can fill remaining slots while this one runs. - unawaited(_execute(req).whenComplete(() { - _inFlight--; - _inFlightKeys.remove(req.key); - _pump(); - })); - } - } - - Future _execute(_HydrationRequest req) async { - try { - switch (req.entityType) { - case 'album': - await _hydrateAlbum(req.entityId); - case 'artist': - await _hydrateArtist(req.entityId); - case 'track': - await _hydrateTrack(req.entityId); - } - } catch (_) { - // Swallow — tile stays in skeleton. A retry-on-visit pass can - // layer on top later without changing this contract. - } - } - - Future _hydrateAlbum(String id) async { - final api = await _ref.read(libraryApiProvider.future); - final result = await api.getAlbum(id); - final db = _ref.read(appDbProvider); - // Album + tracks come bundled in the same response; persist both - // so opening the album detail later is also a drift hit. - await db.transaction(() async { - await db - .into(db.cachedAlbums) - .insertOnConflictUpdate(result.album.toDrift()); - if (result.tracks.isNotEmpty) { - await db.batch((b) { - b.insertAllOnConflictUpdate( - db.cachedTracks, result.tracks.map((t) => t.toDrift()).toList()); - }); - } - }); - } - - Future _hydrateArtist(String id) async { - final api = await _ref.read(libraryApiProvider.future); - final fresh = await api.getArtist(id); - final db = _ref.read(appDbProvider); - await db.into(db.cachedArtists).insertOnConflictUpdate(fresh.toDrift()); - } - - Future _hydrateTrack(String id) async { - final api = await _ref.read(libraryApiProvider.future); - final fresh = await api.getTrack(id); - final db = _ref.read(appDbProvider); - await db.into(db.cachedTracks).insertOnConflictUpdate(fresh.toDrift()); - } -} - -class _HydrationRequest { - _HydrationRequest({ - required this.entityType, - required this.entityId, - required this.key, - }); - final String entityType; - final String entityId; - final String key; -} - -/// Singleton queue scoped to the Riverpod container. Kept as a plain -/// Provider (not StateProvider) since the queue's internal state is -/// pump-driven, not reactively observed. -final hydrationQueueProvider = Provider((ref) { - return HydrationQueue(ref); -}); diff --git a/flutter_client/lib/cache/metadata_prefetcher.dart b/flutter_client/lib/cache/metadata_prefetcher.dart deleted file mode 100644 index bafbc3fe..00000000 --- a/flutter_client/lib/cache/metadata_prefetcher.dart +++ /dev/null @@ -1,67 +0,0 @@ -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../library/library_providers.dart'; -import '../models/home_index.dart'; - -/// Pre-warms the drift cache for likely-tap targets. Conservative on -/// purpose: only warms artistProvider rows (single row, single round -/// trip per id) and only ever fires once per id per session. Album -/// detail is NOT prewarmed — the albumProvider auto-fetches its track -/// list when missing, and pre-warming N albums fans out N parallel -/// "fetch tracks" round trips that compete with the user's actual -/// playback request for bandwidth. -/// -/// Driven off the per-item home path (homeIndexProvider). Only the -/// artist-typed sections (rediscover / last-played artists) carry -/// artist ids directly; album/track tiles hydrate their own artist on -/// render via the per-item path, so warming those here is unnecessary. -class MetadataPrefetcher { - MetadataPrefetcher(this._ref) { - _ref.listen>(homeIndexProvider, (_, next) { - next.whenData(_warmIndex); - }); - } - - final Ref _ref; - - /// Per-session dedupe so re-rendering a screen (every UI rebuild - /// fires the data: callback) doesn't trigger N more fetches for - /// ids we've already warmed. - final Set _warmedArtists = {}; - - /// Cap warmed per emission. Covers what fits on screen without - /// scrolling. - static const _topN = 8; - - /// Pre-warm artists. Albums are intentionally not pre-warmed — - /// see class comment. - void warmArtists(Iterable ids) { - var n = 0; - for (final id in ids) { - if (id.isEmpty) continue; - if (!_warmedArtists.add(id)) continue; // already warmed - if (n++ >= _topN) break; - _swallow(_ref.read(artistProvider(id).future)); - } - } - - void _warmIndex(HomeIndex h) { - warmArtists({ - ...h.rediscoverArtists.take(_topN), - ...h.lastPlayedArtists.take(_topN), - }); - } - - /// Discards the return value and any error from a fire-and-forget - /// provider read. We don't care about the value here — we only want - /// the side effect of writing drift. - void _swallow(Future f) { - f.then((_) {}).onError((_, __) {}); - } -} - -/// Read once at app start to activate the prefetcher (e.g. wire it -/// from a top-level Consumer or main.dart container override). -final metadataPrefetcherProvider = Provider((ref) { - return MetadataPrefetcher(ref); -}); diff --git a/flutter_client/lib/cache/mutation_queue.dart b/flutter_client/lib/cache/mutation_queue.dart deleted file mode 100644 index 5077f892..00000000 --- a/flutter_client/lib/cache/mutation_queue.dart +++ /dev/null @@ -1,311 +0,0 @@ -// Outbound mutation queue for offline-resilient REST calls. -// -// Controllers (LikesController, MyQuarantineController, the add-to- -// playlist sheet, the Discover request flow, the Requests cancel -// action) write their optimistic local state to drift first and try -// the corresponding REST call. On failure they enqueue here. The -// replayer drains the queue when connectivity comes back; the user's -// intent persists across network loss without rolling back their -// visible action. -// -// Why this is separate from SyncController: sync ingests AUTHORITATIVE -// SERVER state into drift. The mutation queue carries USER INTENT -// outbound to the server. They flow in opposite directions and -// shouldn't be conflated — sync wins on conflict (server is the -// source of truth), and a mutation that fails forever (5 attempts) -// is dropped on the trust that next sync will reconcile drift to -// match the server's actual state. -// -// Drop semantics: 5 attempts then drop. The drop is intentional — -// holding onto a forever-failing mutation just delays sync's -// reconciliation. The user's optimistic drift state will diverge -// from server, and the next library_changes delta corrects it. - -import 'dart:async'; -import 'dart:convert'; - -import 'package:dio/dio.dart' show DioException, DioExceptionType; -import 'package:drift/drift.dart'; -import 'package:flutter/foundation.dart' show debugPrint; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../api/endpoints/discover.dart'; -import '../api/endpoints/events.dart'; -import '../api/endpoints/likes.dart'; -import '../library/library_providers.dart' show dioProvider; -import '../likes/likes_provider.dart' show likesApiProvider; -import '../models/lidarr.dart' show LidarrRequestKind; -import '../playlists/playlists_provider.dart' show playlistsApiProvider; -import '../quarantine/quarantine_provider.dart' show quarantineApiProvider; -import '../requests/requests_provider.dart' show requestsApiProvider; -import 'audio_cache_manager.dart' show appDbProvider; -import 'connectivity_provider.dart'; -import 'db.dart'; - -/// Stable kind constants. Each controller emits one of these via -/// MutationQueue.enqueue; the replayer dispatches by kind. -class MutationKinds { - MutationKinds._(); - static const likeAdd = 'like.add'; - static const likeRemove = 'like.remove'; - static const quarantineFlag = 'quarantine.flag'; - static const quarantineUnflag = 'quarantine.unflag'; - static const playlistAppend = 'playlist.append'; - static const requestCreate = 'request.create'; - static const requestCancel = 'request.cancel'; - static const playOffline = 'play.offline'; -} - -class MutationQueue { - MutationQueue(this._ref); - final Ref _ref; - - /// Persist a mutation for replay. Caller has already done the - /// optimistic local mutation (drift write, in-memory state update) - /// — this only records the REST call that needs to fire eventually. - Future enqueue(String kind, Map payload) async { - final db = _ref.read(appDbProvider); - await db.into(db.cachedMutations).insert( - CachedMutationsCompanion.insert( - kind: kind, - payload: jsonEncode(payload), - ), - ); - // Nudge the replayer in case we're online right now. - unawaited(_ref.read(mutationReplayerProvider).drain()); - } - - /// Count of pending mutations (attempts < 5). Diagnostic / future - /// UI surface for a "syncing N pending changes" indicator. - Future pendingCount() async { - final db = _ref.read(appDbProvider); - final rows = await db.customSelect( - 'SELECT COUNT(*) AS c FROM cached_mutations WHERE attempts < 5', - ).get(); - return rows.first.read('c'); - } -} - -final mutationQueueProvider = - Provider((ref) => MutationQueue(ref)); - -class MutationReplayer { - MutationReplayer(this._ref); - final Ref _ref; - - Timer? _initialTimer; - Timer? _intervalTimer; - bool _running = false; - bool _disposed = false; - - /// Periodic poll cadence. The connectivity listener catches most - /// transitions; this is the belt-and-suspenders for cases where - /// connectivity_plus misses a state change (e.g. flaky Wi-Fi). - static const _interval = Duration(minutes: 1); - - /// Max replay attempts before we drop a mutation. 5 is enough to - /// span a few reconnection cycles without holding onto a forever- - /// broken request. See file header for the drop rationale. - static const _maxAttempts = 5; - - void start() { - // Drain shortly after launch in case there are queued mutations - // from a prior session that died offline. 3s lets the auth + - // server-url + dio providers finish their async warmup. - _initialTimer = Timer(const Duration(seconds: 3), drain); - // Periodic ticker is the only reconnect signal. We previously had - // a ref.listen(connectivityProvider, …) edge trigger here, but - // subscribing at start-time eagerly mounted the connectivity - // StreamProvider and leaked its initial-timeout Timer through - // tests that never reach the auth state. The drain() loop itself - // gates on connectivity via the .future read below. - _intervalTimer = Timer.periodic(_interval, (_) => drain()); - } - - /// Walk the queue oldest-first, replaying each mutation. Stops on - /// any transient network error (we'll retry on the next tick); a - /// permanent error (404 etc.) increments the attempt counter and - /// moves on to the next mutation. This is public so MutationQueue. - /// enqueue can trigger an immediate attempt after writing. - Future drain() async { - if (_disposed || _running) return; - final online = await _ref - .read(connectivityProvider.future) - .timeout(const Duration(seconds: 3), onTimeout: () => true); - if (!online) return; - _running = true; - try { - while (!_disposed) { - final db = _ref.read(appDbProvider); - final next = await (db.select(db.cachedMutations) - ..where((t) => t.attempts.isSmallerThanValue(_maxAttempts)) - ..orderBy([(t) => OrderingTerm.asc(t.createdAt)]) - ..limit(1)) - .getSingleOrNull(); - if (next == null) break; - - final handler = _handlers[next.kind]; - if (handler == null) { - debugPrint('mutation_replayer: unknown kind ${next.kind}, dropping'); - await (db.delete(db.cachedMutations) - ..where((t) => t.id.equals(next.id))) - .go(); - continue; - } - - Map payload; - try { - payload = jsonDecode(next.payload) as Map; - } catch (e) { - debugPrint('mutation_replayer: bad payload for ${next.kind}: $e'); - await (db.delete(db.cachedMutations) - ..where((t) => t.id.equals(next.id))) - .go(); - continue; - } - - try { - await handler(_ref, payload); - await (db.delete(db.cachedMutations) - ..where((t) => t.id.equals(next.id))) - .go(); - } on DioException catch (e) { - await (db.update(db.cachedMutations) - ..where((t) => t.id.equals(next.id))) - .write(CachedMutationsCompanion( - attempts: Value(next.attempts + 1), - lastAttemptAt: Value(DateTime.now()), - )); - if (_isTransient(e)) { - // Network is flaky again — bail and try the whole queue - // later. The current mutation will be retried next tick. - break; - } - // Permanent failure (4xx/5xx with a real response). Leave - // the row with bumped attempts and move on to the next so - // one bad mutation doesn't block the others. - } catch (e, st) { - debugPrint('mutation_replayer: unexpected error: $e\n$st'); - await (db.update(db.cachedMutations) - ..where((t) => t.id.equals(next.id))) - .write(CachedMutationsCompanion( - attempts: Value(next.attempts + 1), - lastAttemptAt: Value(DateTime.now()), - )); - } - } - } finally { - _running = false; - } - } - - bool _isTransient(DioException e) { - switch (e.type) { - case DioExceptionType.connectionError: - case DioExceptionType.connectionTimeout: - case DioExceptionType.sendTimeout: - case DioExceptionType.receiveTimeout: - return true; - default: - return false; - } - } - - void dispose() { - _disposed = true; - _initialTimer?.cancel(); - _intervalTimer?.cancel(); - } -} - -final mutationReplayerProvider = Provider((ref) { - final r = MutationReplayer(ref); - ref.onDispose(r.dispose); - r.start(); - return r; -}); - -/// Type signature for kind-specific replay handlers. -typedef _Handler = Future Function(Ref, Map); - -/// Kind → handler dispatch. Each handler decodes its payload and -/// re-fires the original REST call. Throws on failure (caught by the -/// replayer's drain loop above). -/// -/// Payload shapes (see also enqueue call sites for the writer side): -/// * like.add / like.remove: {'kind': 'track'|'album'|'artist', 'id': uuid} -/// * quarantine.flag: {'trackId': uuid, 'reason': str, 'notes': str} -/// * quarantine.unflag: {'trackId': uuid} -/// * playlist.append: {'playlistId': uuid, 'trackIds': [uuid, …]} -/// * request.create: {full createRequest args; see DiscoverApi} -/// * request.cancel: {'id': uuid} -/// * play.offline: {'trackId': uuid, 'clientId': str, 'at': iso8601, -/// 'durationPlayedMs': int, 'source'?: 'for_you'|'discover'} -final Map _handlers = { - MutationKinds.likeAdd: (ref, p) async { - final api = await ref.read(likesApiProvider.future); - await api.like(_likeKindFromString(p['kind'] as String), p['id'] as String); - }, - MutationKinds.likeRemove: (ref, p) async { - final api = await ref.read(likesApiProvider.future); - await api.unlike( - _likeKindFromString(p['kind'] as String), p['id'] as String); - }, - MutationKinds.quarantineFlag: (ref, p) async { - final api = await ref.read(quarantineApiProvider.future); - await api.flag( - p['trackId'] as String, - p['reason'] as String, - notes: (p['notes'] as String?) ?? '', - ); - }, - MutationKinds.quarantineUnflag: (ref, p) async { - final api = await ref.read(quarantineApiProvider.future); - await api.unflag(p['trackId'] as String); - }, - MutationKinds.playlistAppend: (ref, p) async { - final api = await ref.read(playlistsApiProvider.future); - final ids = (p['trackIds'] as List).cast(); - await api.appendTracks(p['playlistId'] as String, ids); - }, - MutationKinds.requestCreate: (ref, p) async { - final dio = await ref.read(dioProvider.future); - final api = DiscoverApi(dio); - await api.createRequest( - kind: _lidarrKindFromString(p['kind'] as String), - artistMbid: p['artistMbid'] as String, - artistName: p['artistName'] as String, - albumMbid: p['albumMbid'] as String?, - albumTitle: p['albumTitle'] as String?, - trackMbid: p['trackMbid'] as String?, - trackTitle: p['trackTitle'] as String?, - ); - }, - MutationKinds.requestCancel: (ref, p) async { - final api = await ref.read(requestsApiProvider.future); - await api.cancel(p['id'] as String); - }, - MutationKinds.playOffline: (ref, p) async { - final dio = await ref.read(dioProvider.future); - final api = EventsApi(dio); - await api.playOffline( - trackId: p['trackId'] as String, - clientId: p['clientId'] as String, - atIso: p['at'] as String, - durationPlayedMs: (p['durationPlayedMs'] as num).toInt(), - source: p['source'] as String?, - ); - }, -}; - -LikeKind _likeKindFromString(String s) => switch (s) { - 'album' => LikeKind.album, - 'artist' => LikeKind.artist, - _ => LikeKind.track, - }; - -LidarrRequestKind _lidarrKindFromString(String s) => switch (s) { - 'album' => LidarrRequestKind.album, - 'track' => LidarrRequestKind.track, - _ => LidarrRequestKind.artist, - }; diff --git a/flutter_client/lib/cache/offline_provider.dart b/flutter_client/lib/cache/offline_provider.dart deleted file mode 100644 index 0ef4bcc4..00000000 --- a/flutter_client/lib/cache/offline_provider.dart +++ /dev/null @@ -1,105 +0,0 @@ -// Reachability-based offline marker (#427 S1). -// -// `connectivityProvider` only knows whether a network *interface* is -// up — not whether the Minstrel server is actually reachable (captive -// portals, server down, DNS, VPN-only routes all read "online"). This -// is the single source of truth other features gate on: offline = -// the server failed its /healthz probe N times in a row; recovery on -// the first success. -// -// Deliberately NOT coupled to connectivityProvider: subscribing to -// that StreamProvider eagerly mounts its 2s checkConnectivity timeout -// and leaks a pending Timer through widget tests that never reach the -// auth state (the bug fixed for MutationReplayer). /healthz failing -// already covers interface-down — it just fails the probe. -// -// Optimistic: assume online until proven offline, so a cold launch -// behaves normally and only flips after sustained unreachability. - -import 'dart:async'; - -import 'package:dio/dio.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../api/endpoints/health.dart'; -import '../library/library_providers.dart' show dioProvider; - -class OfflineMonitor extends Notifier { - Timer? _initial; - Timer? _periodic; - int _consecutiveFails = 0; - - /// Consecutive failed probes before we declare offline. Small - /// enough to react within ~half a minute, large enough that a - /// single dropped request doesn't flip the whole UI. - static const _threshold = 3; - - /// Probe cadence. Slower when believed-online (cheap heartbeat); - /// faster when offline so recovery is noticed quickly. - static const _onlineInterval = Duration(seconds: 30); - static const _offlineInterval = Duration(seconds: 10); - - /// Let auth + server-url + dio providers finish async warmup - /// before the first probe so a cold start doesn't false-positive. - static const _initialDelay = Duration(seconds: 5); - - @override - bool build() { - ref.onDispose(() { - _initial?.cancel(); - _periodic?.cancel(); - }); - _initial = Timer(_initialDelay, () { - _check(); - _arm(); - }); - return false; // optimistic until a probe says otherwise - } - - void _arm() { - _periodic?.cancel(); - _periodic = Timer.periodic( - state ? _offlineInterval : _onlineInterval, - (_) => _check(), - ); - } - - Future _check() async { - final Dio dio; - try { - // Not configured yet (no server URL) → don't flip; the app is - // still on the connect screen and "offline" is meaningless. - dio = await ref.read(dioProvider.future); - } catch (_) { - return; - } - try { - // dio's own connect/receive timeouts bound this — no extra - // Timer (which would leak in widget tests). - await HealthApi(dio).check(); - _consecutiveFails = 0; - _set(false); - } catch (_) { - _consecutiveFails++; - if (_consecutiveFails >= _threshold) { - _set(true); - } - } - } - - void _set(bool offline) { - if (state == offline) return; - state = offline; - _arm(); // cadence follows the new state - } - - /// Force an immediate probe — e.g. a user-initiated retry. Public - /// so S4's offline surfaces can offer a "try again" affordance. - Future recheck() => _check(); -} - -/// `true` when the server is unreachable. Watch this to gate -/// online-only affordances; read it once at app start to activate -/// the poller. -final offlineProvider = - NotifierProvider(OfflineMonitor.new); diff --git a/flutter_client/lib/cache/prefetcher.dart b/flutter_client/lib/cache/prefetcher.dart deleted file mode 100644 index 2abeaf8e..00000000 --- a/flutter_client/lib/cache/prefetcher.dart +++ /dev/null @@ -1,108 +0,0 @@ -import 'dart:async'; - -import 'package:audio_service/audio_service.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../likes/likes_provider.dart' show likedIdsProvider; -import '../player/album_color_extractor.dart'; -import '../player/player_provider.dart'; -import 'audio_cache_manager.dart'; -import 'cache_settings_provider.dart'; -import 'db.dart'; - -/// Listens to the player's currently-playing track. When it changes, -/// computes the next-N tracks ahead in the queue and pins them via -/// AudioCacheManager (source: autoPrefetch). After pinning, runs an -/// eviction pass against the operator-set cap. -/// -/// The window N comes from cacheSettingsProvider.prefetchWindow -/// (default 5, configurable in Settings). -class Prefetcher { - Prefetcher(this._ref) { - // The mediaItem stream changes whenever the active track changes - // (skipNext/skipPrev or natural progression). Settings changes (e.g. - // operator bumps prefetch window) also trigger a reconcile. - _ref.listen>(mediaItemProvider, (_, __) => _reconcile()); - _ref.listen>(cacheSettingsProvider, (_, __) => _reconcile()); - } - - final Ref _ref; - - Future _reconcile() async { - final settings = _ref.read(cacheSettingsProvider).value; - if (settings == null) return; - - final queue = _ref.read(queueProvider).value ?? const []; - final current = _ref.read(mediaItemProvider).value; - if (queue.isEmpty || current == null) return; - - final currentIdx = queue.indexWhere((m) => m.id == current.id); - if (currentIdx < 0) return; - - final endIdx = - (currentIdx + settings.prefetchWindow).clamp(0, queue.length - 1); - - final mgr = _ref.read(audioCacheManagerProvider); - final coverCache = _ref.read(albumCoverCacheProvider); - final colorCache = _ref.read(albumColorCacheProvider); - - // Walk the window. For each upcoming track we want THREE things - // ready when the player transitions into it: - // - // 1. Audio file on disk (otherwise playback stalls on stream - // load — the original prefetcher concern). - // 2. Cover bytes on disk under AlbumCoverCache (otherwise - // _toMediaItem's peekCached returns null, mediaItem - // broadcasts with artUri=null, and the now-playing screen - // stalls in _scheduleSwap awaiting precacheImage of a file - // that doesn't exist yet). - // 3. Palette color extracted and memoized in AlbumColorCache - // (otherwise the gradient backdrop has to wait for - // PaletteGenerator to run after the cover lands — - // visible as the cover snapping in before the gradient). - // - // Each call is idempotent (cache-aware): if already cached, it's - // a no-op. Everything fire-and-forget so the reconcile completes - // quickly even on a fresh queue. - for (var i = currentIdx; i <= endIdx; i++) { - final media = queue[i]; - final trackId = media.id; - final albumId = media.extras?['album_id'] as String?; - - if (!await mgr.isCached(trackId)) { - // ignore: unawaited_futures - mgr.pin(trackId, source: CacheSource.autoPrefetch); - } - - if (albumId != null && albumId.isNotEmpty) { - // Cover bytes: getOrFetch returns the file path; the side - // effect (writing to disk) is what we care about. - // ignore: unawaited_futures - coverCache.getOrFetch(albumId); - // Palette: getOrExtract chains off coverCache.getOrFetch so - // it'll wait for the cover before sampling — safe to call - // in parallel here. - // ignore: unawaited_futures - colorCache.getOrExtract(albumId); - } - } - - // Eviction pass after pinning new files. Per-bucket (#427 S2): - // liked-ness decides the bucket, so a track that's both liked - // and recently played is protected by the Liked cap, not the - // rolling LRU. - final liked = - _ref.read(likedIdsProvider).value?.tracks ?? const {}; - await mgr.evictBuckets( - likedCap: settings.likedCapBytes, - rollingCap: settings.rollingCapBytes, - liked: liked, - ); - } -} - -/// Read this provider once at app start to activate the prefetcher. -/// Constructor wires the listeners. -final prefetcherProvider = Provider((ref) { - return Prefetcher(ref); -}); diff --git a/flutter_client/lib/cache/resume_controller.dart b/flutter_client/lib/cache/resume_controller.dart deleted file mode 100644 index 80878dd5..00000000 --- a/flutter_client/lib/cache/resume_controller.dart +++ /dev/null @@ -1,183 +0,0 @@ -// Resume-on-launch (#54). The #52 teardown tears the audio_service -// session down (and clears mediaItem) when idle/dismissed, so the -// headset / lock-screen play button otherwise has nothing to resume. -// This controller persists the live queue + index + position + #415 -// source to a single-row drift snapshot on track change / pause / app -// teardown, and on construction restores the last snapshot into the -// handler PAUSED (the user sees their last track and continues with -// play / a media button — we never auto-blast on launch). -// -// Persist guard: when the handler clears (teardown → mediaItem null / -// empty queue) we deliberately do NOT write, so the last good snapshot -// survives for the next launch — that survival is the whole point. - -import 'dart:async'; -import 'dart:convert'; - -import 'package:drift/drift.dart' as drift; -import 'package:flutter/widgets.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../auth/auth_provider.dart'; -import '../models/track.dart'; -import '../player/player_provider.dart'; -import 'audio_cache_manager.dart' show appDbProvider; -import 'db.dart'; - -class ResumeController with WidgetsBindingObserver { - ResumeController(this._ref); - final Ref _ref; - - final _subs = >[]; - Timer? _debounce; - bool _disposed = false; - - Future start() async { - try { - // audioHandlerProvider throws until main() overrides it (the real - // app always does). In tests / no-audio environments there's - // nothing to resume — stay inert. - _ref.read(audioHandlerProvider); - } catch (_) { - return; - } - if (_disposed) return; - await _restore(); - if (_disposed) return; - final h = _ref.read(audioHandlerProvider); - // Media-button-when-torn-down hook (#448): play() invokes this when - // mediaItem is null so a headset/watch press resumes the snapshot. - h.setResumeHook(resumeFromMediaButton); - _subs.add(h.mediaItem.listen((_) => _schedulePersist())); - _subs.add(h.playbackState.listen((_) => _schedulePersist())); - WidgetsBinding.instance.addObserver(this); - } - - void _schedulePersist() { - if (_disposed) return; - _debounce?.cancel(); - _debounce = Timer(const Duration(seconds: 3), () { - unawaited(_persist()); - }); - } - - Future _persist() async { - if (_disposed) return; - try { - final h = _ref.read(audioHandlerProvider); - final tracks = h.queuedTracks; - final mi = h.mediaItem.value; - // Cleared by teardown — keep the last good snapshot for next - // launch rather than wiping it with an empty queue. - if (tracks.isEmpty || mi == null) return; - var idx = tracks.indexWhere((t) => t.id == mi.id); - if (idx < 0) idx = 0; - final blob = jsonEncode({ - 'v': 1, - 'source': h.queueSource, - 'index': idx, - 'position_ms': h.position.inMilliseconds, - 'tracks': tracks.map((t) => t.toJson()).toList(), - }); - final db = _ref.read(appDbProvider); - await db.into(db.cachedResumeState).insertOnConflictUpdate( - CachedResumeStateCompanion.insert( - json: blob, - updatedAt: drift.Value(DateTime.now()), - ), - ); - } catch (e, st) { - debugPrint('resume_controller: persist failed: $e\n$st'); - } - } - - /// Launch path: restore the last session PAUSED (no auto-blast). - Future _restore() async { - try { - await _loadAndRestore(); - } catch (e, st) { - debugPrint('resume_controller: restore failed: $e\n$st'); - } - } - - /// Media-button path (#448): the user pressed play on the headset / - /// watch / lock screen while the session was fully torn down (#52) and - /// nothing is loaded. Restore the snapshot, then START playback (they - /// asked to play). Registered as the handler's resume hook in start(). - /// No-op if there's nothing to resume. - Future resumeFromMediaButton() async { - try { - if (await _loadAndRestore()) { - await _ref.read(audioHandlerProvider).play(); - } - } catch (e, st) { - debugPrint('resume_controller: media-button resume failed: $e\n$st'); - } - } - - /// Shared loader. Restores the last persisted session PAUSED and - /// returns whether it actually restored a queue. Guards: an already- - /// active session (don't stomp), missing auth, no/empty snapshot. - Future _loadAndRestore() async { - final h = _ref.read(audioHandlerProvider); - if (h.mediaItem.value != null) return false; - final url = await _ref.read(serverUrlProvider.future); - final token = - await _ref.read(secureStorageProvider).read(key: 'session_token'); - if (url == null || url.isEmpty || token == null || token.isEmpty) { - return false; - } - final db = _ref.read(appDbProvider); - final row = await db.select(db.cachedResumeState).getSingleOrNull(); - if (row == null) return false; - final m = jsonDecode(row.json) as Map; - final rawTracks = (m['tracks'] as List?) ?? const []; - final tracks = rawTracks - .map((e) => TrackRef.fromJson(e as Map)) - .toList(); - if (tracks.isEmpty) return false; - final idx = (m['index'] as num?)?.toInt() ?? 0; - final posMs = (m['position_ms'] as num?)?.toInt() ?? 0; - final source = m['source'] as String?; - await _ref.read(playerActionsProvider).restoreQueue( - tracks, - initialIndex: idx, - position: Duration(milliseconds: posMs), - source: source, - ); - return true; - } - - @override - void didChangeAppLifecycleState(AppLifecycleState state) { - // App backgrounded / killed: persist durably right now (the debounce - // may not fire before teardown). - if (state == AppLifecycleState.paused || - state == AppLifecycleState.detached) { - _debounce?.cancel(); - unawaited(_persist()); - } - } - - void dispose() { - _disposed = true; - _debounce?.cancel(); - WidgetsBinding.instance.removeObserver(this); - for (final s in _subs) { - s.cancel(); - } - _subs.clear(); - } -} - -/// Read once at app start (app.dart postFrame). On construction it -/// restores the last persisted session (paused) then persists -/// queue/index/position on track change, pause, and app teardown. -/// Disposed via ref.onDispose when the scope tears down. -final resumeControllerProvider = Provider((ref) { - final c = ResumeController(ref); - ref.onDispose(c.dispose); - // ignore: unawaited_futures - c.start(); - return c; -}); diff --git a/flutter_client/lib/cache/shuffle_source.dart b/flutter_client/lib/cache/shuffle_source.dart deleted file mode 100644 index e42d9767..00000000 --- a/flutter_client/lib/cache/shuffle_source.dart +++ /dev/null @@ -1,104 +0,0 @@ -// Offline play sources over the local cache index (#427 S4). -// -// "Shuffle all" is always-present and degrades with reachability: -// online → GET /api/library/shuffle (random over the whole library) -// offline → a client shuffle over the entire local cache index -// -// Offline-only pools surfaced on Home beside the (disabled) system -// playlists: "Recently played" (cache by lastPlayedAt desc) and -// "Liked" (cache ∩ liked set). All of these are UNIONs over the -// cache regardless of storage bucket — liked AND recently-played -// both included. The two-bucket split (S2) is storage/eviction-only -// and never filters playback, which is exactly what this relies on. - -import 'dart:math'; - -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../library/library_providers.dart' show libraryApiProvider; -import '../likes/likes_provider.dart' show likedIdsProvider; -import '../models/track.dart'; -import 'adapters.dart'; -import 'audio_cache_manager.dart' show appDbProvider; -import 'offline_provider.dart'; - -class ShuffleSource { - ShuffleSource(this._ref); - final Ref _ref; - - /// Shuffle-all. Online: server-random. Offline: every cached - /// track, shuffled. Empty offline → nothing cached yet. - Future> tracks({int limit = 100}) async { - if (_ref.read(offlineProvider)) { - final ids = await _cachedIdsByRecency(); - final list = await _refs(ids); - list.shuffle(Random()); - return list.length > limit ? list.sublist(0, limit) : list; - } - final api = await _ref.read(libraryApiProvider.future); - return api.shuffle(limit: limit); - } - - /// Cached tracks, most-recently-played first (liked included). - /// Cache-only — surfaced on Home only when offline. - Future> recentlyPlayed({int limit = 100}) async { - final ids = await _cachedIdsByRecency(); - final list = await _refs(ids); - return list.length > limit ? list.sublist(0, limit) : list; - } - - /// Cached tracks that are in the user's liked set. Cache-only — - /// surfaced on Home only when offline. - Future> liked({int limit = 100}) async { - final likedSet = - _ref.read(likedIdsProvider).value?.tracks ?? const {}; - final ids = - (await _cachedIdsByRecency()).where(likedSet.contains).toList(); - final list = await _refs(ids); - return list.length > limit ? list.sublist(0, limit) : list; - } - - /// Cached track ids ordered by lastPlayedAt desc (nulls last so - /// never-touched downloads sort after real plays). - Future> _cachedIdsByRecency() async { - final db = _ref.read(appDbProvider); - final rows = await db.select(db.audioCacheIndex).get(); - rows.sort((a, b) { - final av = a.lastPlayedAt, bv = b.lastPlayedAt; - if (av == null && bv == null) return 0; - if (av == null) return 1; - if (bv == null) return -1; - return bv.compareTo(av); - }); - return rows.map((r) => r.trackId).toList(); - } - - /// Materializes ordered track ids into TrackRefs from the cached - /// metadata tables, preserving the given order. - Future> _refs(List orderedIds) async { - if (orderedIds.isEmpty) return const []; - final db = _ref.read(appDbProvider); - final meta = await (db.select(db.cachedTracks) - ..where((t) => t.id.isIn(orderedIds))) - .get(); - if (meta.isEmpty) return const []; - final byId = {for (final t in meta) t.id: t}; - final artistName = { - for (final a in await db.select(db.cachedArtists).get()) a.id: a.name - }; - final albumTitle = { - for (final a in await db.select(db.cachedAlbums).get()) a.id: a.title - }; - return [ - for (final id in orderedIds) - if (byId[id] case final t?) - t.toRef( - artistName: artistName[t.artistId] ?? '', - albumTitle: albumTitle[t.albumId] ?? '', - ) - ]; - } -} - -final shuffleSourceProvider = - Provider((ref) => ShuffleSource(ref)); diff --git a/flutter_client/lib/cache/sync_controller.dart b/flutter_client/lib/cache/sync_controller.dart deleted file mode 100644 index b4f1f91a..00000000 --- a/flutter_client/lib/cache/sync_controller.dart +++ /dev/null @@ -1,360 +0,0 @@ -import 'dart:async'; - -import 'package:drift/drift.dart' as drift; -import 'package:flutter_cache_manager/flutter_cache_manager.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../auth/auth_provider.dart' show serverUrlProvider, sessionTokenProvider; -import '../library/library_providers.dart' show dioProvider; -import 'audio_cache_manager.dart' show appDbProvider; -import 'db.dart'; - -/// Counts returned from a sync operation. Surfaced to the operator- -/// facing "Sync now" button + Settings card. -class SyncResult { - const SyncResult({ - required this.upserts, - required this.deletes, - required this.cursor, - }); - final int upserts; - final int deletes; - final int cursor; -} - -/// Drives the delta-sync against the server (#357 Plan A endpoint). -/// Reads cursor from drift, calls `/api/library/sync?since={cursor}`, -/// applies upserts + deletes, advances cursor. -class SyncController extends AsyncNotifier { - @override - Future build() async => null; - - Future sync() async { - state = const AsyncLoading(); - try { - final db = ref.read(appDbProvider); - final dio = await ref.read(dioProvider.future); - - final meta = await db.select(db.syncMetadata).getSingleOrNull(); - final cursor = meta?.cursor ?? 0; - - final resp = await dio.get( - '/api/library/sync', - queryParameters: {'since': cursor}, - ); - - // 204 No Content — no changes since cursor. - if (resp.statusCode == 204) { - await db.into(db.syncMetadata).insertOnConflictUpdate( - SyncMetadataCompanion.insert( - lastSyncAt: drift.Value(DateTime.now()), - ), - ); - final r = SyncResult(upserts: 0, deletes: 0, cursor: cursor); - state = AsyncData(r); - return r; - } - - // 410 Gone — cursor too old, server has compacted past it. Reset - // local state and retry once with cursor=0. - if (resp.statusCode == 410) { - await db.transaction(() async { - await db.delete(db.cachedArtists).go(); - await db.delete(db.cachedAlbums).go(); - await db.delete(db.cachedTracks).go(); - await db.delete(db.cachedLikes).go(); - await db.delete(db.cachedPlaylists).go(); - await db.delete(db.cachedPlaylistTracks).go(); - await db.into(db.syncMetadata).insertOnConflictUpdate( - SyncMetadataCompanion.insert(cursor: const drift.Value(0)), - ); - }); - return sync(); - } - - final body = resp.data as Map; - final newCursor = (body['cursor'] as num).toInt(); - final upserts = body['upserts'] as Map? ?? {}; - final deletes = body['deletes'] as Map? ?? {}; - - var upsertCount = 0; - var deleteCount = 0; - // IDs of upserted entities whose covers we'll pre-warm after the - // transaction commits. Filled inside the loops; consumed by - // _prewarmCovers fire-and-forget below. Artist covers are - // server-derived from "most-recent album" and aren't - // reconstructible client-side — album pre-warm already covers an - // artist's primary visual via its detail-page header. - final albumCoverIds = []; - final playlistCoverIds = []; - - await db.transaction(() async { - // ---- Upserts ---- - for (final a in (upserts['artist'] as List? ?? const [])) { - await db.into(db.cachedArtists).insertOnConflictUpdate( - _artistFromJson(a as Map), - ); - upsertCount++; - } - for (final a in (upserts['album'] as List? ?? const [])) { - final m = a as Map; - await db.into(db.cachedAlbums).insertOnConflictUpdate( - _albumFromJson(m), - ); - final id = m['id']; - if (id is String && id.isNotEmpty) albumCoverIds.add(id); - upsertCount++; - } - for (final t in (upserts['track'] as List? ?? const [])) { - await db.into(db.cachedTracks).insertOnConflictUpdate( - _trackFromJson(t as Map), - ); - upsertCount++; - } - for (final l in (upserts['like_track'] as List? ?? const [])) { - final m = l as Map; - await db.into(db.cachedLikes).insertOnConflictUpdate( - CachedLikesCompanion.insert( - userId: m['user_id'] as String, - entityType: 'track', - entityId: m['track_id'] as String, - ), - ); - upsertCount++; - } - for (final l in (upserts['like_album'] as List? ?? const [])) { - final m = l as Map; - await db.into(db.cachedLikes).insertOnConflictUpdate( - CachedLikesCompanion.insert( - userId: m['user_id'] as String, - entityType: 'album', - entityId: m['album_id'] as String, - ), - ); - upsertCount++; - } - for (final l in (upserts['like_artist'] as List? ?? const [])) { - final m = l as Map; - await db.into(db.cachedLikes).insertOnConflictUpdate( - CachedLikesCompanion.insert( - userId: m['user_id'] as String, - entityType: 'artist', - entityId: m['artist_id'] as String, - ), - ); - upsertCount++; - } - for (final p in (upserts['playlist'] as List? ?? const [])) { - final m = p as Map; - await db.into(db.cachedPlaylists).insertOnConflictUpdate( - _playlistFromJson(m), - ); - final id = m['id']; - if (id is String && id.isNotEmpty) playlistCoverIds.add(id); - upsertCount++; - } - for (final pt in (upserts['playlist_track'] as List? ?? const [])) { - final m = pt as Map; - await db.into(db.cachedPlaylistTracks).insertOnConflictUpdate( - CachedPlaylistTracksCompanion.insert( - playlistId: m['playlist_id'] as String, - trackId: m['track_id'] as String, - ), - ); - upsertCount++; - } - - // ---- Deletes ---- - for (final id in (deletes['artist'] as List? ?? const [])) { - await (db.delete(db.cachedArtists) - ..where((t) => t.id.equals(id as String))) - .go(); - deleteCount++; - } - for (final id in (deletes['album'] as List? ?? const [])) { - await (db.delete(db.cachedAlbums) - ..where((t) => t.id.equals(id as String))) - .go(); - deleteCount++; - } - for (final id in (deletes['track'] as List? ?? const [])) { - await (db.delete(db.cachedTracks) - ..where((t) => t.id.equals(id as String))) - .go(); - deleteCount++; - } - for (final id in (deletes['like_track'] as List? ?? const [])) { - final parts = (id as String).split(':'); - if (parts.length != 2) continue; - await (db.delete(db.cachedLikes) - ..where((t) => - t.userId.equals(parts[0]) & - t.entityType.equals('track') & - t.entityId.equals(parts[1]))) - .go(); - deleteCount++; - } - for (final id in (deletes['like_album'] as List? ?? const [])) { - final parts = (id as String).split(':'); - if (parts.length != 2) continue; - await (db.delete(db.cachedLikes) - ..where((t) => - t.userId.equals(parts[0]) & - t.entityType.equals('album') & - t.entityId.equals(parts[1]))) - .go(); - deleteCount++; - } - for (final id in (deletes['like_artist'] as List? ?? const [])) { - final parts = (id as String).split(':'); - if (parts.length != 2) continue; - await (db.delete(db.cachedLikes) - ..where((t) => - t.userId.equals(parts[0]) & - t.entityType.equals('artist') & - t.entityId.equals(parts[1]))) - .go(); - deleteCount++; - } - for (final id in (deletes['playlist'] as List? ?? const [])) { - await (db.delete(db.cachedPlaylists) - ..where((t) => t.id.equals(id as String))) - .go(); - deleteCount++; - } - for (final id in (deletes['playlist_track'] as List? ?? const [])) { - final parts = (id as String).split(':'); - if (parts.length != 2) continue; - await (db.delete(db.cachedPlaylistTracks) - ..where((t) => - t.playlistId.equals(parts[0]) & - t.trackId.equals(parts[1]))) - .go(); - deleteCount++; - } - - // ---- Cursor + lastSyncAt ---- - await db.into(db.syncMetadata).insertOnConflictUpdate( - SyncMetadataCompanion.insert( - cursor: drift.Value(newCursor), - lastSyncAt: drift.Value(DateTime.now()), - ), - ); - }); - - // Fire-and-forget cover pre-warm so a cold-start scroll through - // the home grid paints from disk on the very first frame instead - // of firing one HTTP request per visible tile. Unawaited because - // the sync's "done" signal should fire as soon as the metadata - // delta is durable; cover downloads can finish in the background. - if (albumCoverIds.isNotEmpty || playlistCoverIds.isNotEmpty) { - unawaited(_prewarmCovers(albumCoverIds, playlistCoverIds)); - } - - final result = SyncResult( - upserts: upsertCount, - deletes: deleteCount, - cursor: newCursor, - ); - state = AsyncData(result); - return result; - } catch (e, st) { - state = AsyncError(e, st); - return null; - } - } - - /// Downloads cover bytes for each id into the shared flutter_cache_ - /// manager disk cache that cached_network_image reads from. - /// Best-effort: per-URL failures (404 collage-not-built-yet, 401 - /// during a token refresh race, network blip) are swallowed so one - /// missing cover doesn't abort the rest. - /// - /// Concurrency 3 balances disk-warming throughput against starving - /// foreground UI work — covers are ~30-200KB each, so three in - /// flight saturates most LAN connections without pinning the radio. - Future _prewarmCovers( - List albumIds, - List playlistIds, - ) async { - final baseUrl = await ref.read(serverUrlProvider.future); - if (baseUrl == null || baseUrl.isEmpty) return; - final token = await ref.read(sessionTokenProvider.future); - final headers = (token != null && token.isNotEmpty) - ? {'Authorization': 'Bearer $token'} - : {}; - final base = - baseUrl.endsWith('/') ? baseUrl.substring(0, baseUrl.length - 1) : baseUrl; - final urls = [ - for (final id in albumIds) '$base/api/albums/$id/cover', - for (final id in playlistIds) '$base/api/playlists/$id/cover', - ]; - final mgr = DefaultCacheManager(); - var index = 0; - Future worker() async { - while (index < urls.length) { - final i = index++; - try { - await mgr.downloadFile(urls[i], authHeaders: headers); - } catch (_) { - // Best-effort prewarm — failures are expected for system - // playlists whose collage hasn't been built yet, and for - // any transient auth race. Don't surface or abort. - } - } - } - - await Future.wait(List.generate(3, (_) => worker())); - } - - CachedArtistsCompanion _artistFromJson(Map j) => - CachedArtistsCompanion.insert( - id: j['id'] as String, - name: (j['name'] as String?) ?? '', - sortName: (j['sort_name'] as String?) ?? '', - mbid: drift.Value(j['mbid'] as String?), - artistThumbPath: drift.Value(j['artist_thumb_path'] as String?), - artistFanartPath: drift.Value(j['artist_fanart_path'] as String?), - ); - - CachedAlbumsCompanion _albumFromJson(Map j) => - CachedAlbumsCompanion.insert( - id: j['id'] as String, - artistId: j['artist_id'] as String, - title: (j['title'] as String?) ?? '', - sortTitle: (j['sort_title'] as String?) ?? '', - releaseDate: drift.Value(j['release_date'] as String?), - coverPath: drift.Value(j['cover_art_path'] as String?), - mbid: drift.Value(j['mbid'] as String?), - ); - - CachedTracksCompanion _trackFromJson(Map j) => - CachedTracksCompanion.insert( - id: j['id'] as String, - albumId: j['album_id'] as String, - artistId: j['artist_id'] as String, - title: (j['title'] as String?) ?? '', - durationMs: drift.Value((j['duration_ms'] as num?)?.toInt() ?? 0), - trackNumber: drift.Value((j['track_number'] as num?)?.toInt()), - discNumber: drift.Value((j['disc_number'] as num?)?.toInt()), - filePath: drift.Value(j['file_path'] as String?), - fileFormat: drift.Value(j['file_format'] as String?), - genre: drift.Value(j['genre'] as String?), - ); - - CachedPlaylistsCompanion _playlistFromJson(Map j) => - CachedPlaylistsCompanion.insert( - id: j['id'] as String, - userId: j['user_id'] as String, - name: (j['name'] as String?) ?? '', - description: drift.Value((j['description'] as String?) ?? ''), - isPublic: drift.Value((j['is_public'] as bool?) ?? false), - coverPath: drift.Value(j['cover_path'] as String?), - trackCount: drift.Value((j['track_count'] as num?)?.toInt() ?? 0), - durationSec: drift.Value((j['duration_sec'] as num?)?.toInt() ?? 0), - systemVariant: drift.Value(j['system_variant'] as String?), - ); -} - -final syncControllerProvider = - AsyncNotifierProvider(SyncController.new); diff --git a/flutter_client/lib/cache/tile_providers.dart b/flutter_client/lib/cache/tile_providers.dart deleted file mode 100644 index 6fb8e1fa..00000000 --- a/flutter_client/lib/cache/tile_providers.dart +++ /dev/null @@ -1,144 +0,0 @@ -// Per-entity tile providers for the per-item rendering architecture -// (see docs/superpowers/specs/2026-05-13-per-item-rendering-design.md). -// -// Each provider is a StreamProvider.family keyed -// by entity id. The stream: -// 1. Watches the entity's drift row -// 2. Yields the populated row on every emission -// 3. Yields null while the row is absent (UI shows skeleton) -// 4. On the first missing-row emission, enqueues a hydration via -// HydrationQueue so the row eventually lands -// -// The tile widget reacts to AsyncValue: -// - loading or data:null → skeleton -// - data:non-null → real card -// - error → error placeholder -// -// Subscriptions are per-tile. A 50-tile home screen creates 50 drift -// subscriptions; drift handles this fine in practice but worth -// measuring if a future surface scales to hundreds. - -import 'package:drift/drift.dart' as drift show OrderingTerm; -import 'package:drift/drift.dart' show leftOuterJoin; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../cache/adapters.dart'; -import '../cache/audio_cache_manager.dart' show appDbProvider; -import '../models/album.dart'; -import '../models/artist.dart'; -import '../models/track.dart'; -import 'hydration_queue.dart'; - -/// Watches the cached_albums row for [id]. Triggers background -/// hydration on miss; yields the row once it lands. -final albumTileProvider = - StreamProvider.family((ref, id) async* { - if (id.isEmpty) { - yield null; - return; - } - final db = ref.watch(appDbProvider); - final query = (db.select(db.cachedAlbums)..where((t) => t.id.equals(id))) - .join([ - leftOuterJoin(db.cachedArtists, - db.cachedArtists.id.equalsExp(db.cachedAlbums.artistId)), - ]); - var enqueued = false; - await for (final rows in query.watch()) { - if (rows.isEmpty) { - if (!enqueued) { - enqueued = true; - ref - .read(hydrationQueueProvider) - .enqueue(entityType: 'album', entityId: id); - } - yield null; - continue; - } - final r = rows.first; - final album = r.readTable(db.cachedAlbums); - final artist = r.readTableOrNull(db.cachedArtists); - yield album.toRef(artistName: artist?.name ?? ''); - } -}); - -/// Watches the cached_artists row for [id]. Triggers background -/// hydration on miss; yields the row once it lands. -/// -/// LEFT JOIN cached_albums ordered by sort_title so the first row per -/// artist carries a representative album id; toRef() reconstructs -/// `/api/albums//cover` from it. Without this join the ArtistRef -/// has an empty coverUrl and tiles fall back to the music-notes -/// placeholder even though the server has the cover available. -final artistTileProvider = - StreamProvider.family((ref, id) async* { - if (id.isEmpty) { - yield null; - return; - } - final db = ref.watch(appDbProvider); - final query = (db.select(db.cachedArtists)..where((t) => t.id.equals(id))) - .join([ - leftOuterJoin(db.cachedAlbums, - db.cachedAlbums.artistId.equalsExp(db.cachedArtists.id)), - ]) - ..orderBy([drift.OrderingTerm.asc(db.cachedAlbums.sortTitle)]); - var enqueued = false; - await for (final rows in query.watch()) { - if (rows.isEmpty) { - if (!enqueued) { - enqueued = true; - ref - .read(hydrationQueueProvider) - .enqueue(entityType: 'artist', entityId: id); - } - yield null; - continue; - } - // First row has the alphabetically-first album (or null if artist - // has no albums in drift yet — yields a coverless ArtistRef which - // the UI falls back to the placeholder icon for). - final artist = rows.first.readTable(db.cachedArtists); - final firstAlbum = rows.first.readTableOrNull(db.cachedAlbums); - yield artist.toRef(coverAlbumId: firstAlbum?.id ?? ''); - } -}); - -/// Watches the cached_tracks row for [id]. Triggers background -/// hydration on miss; yields the row once it lands. -final trackTileProvider = - StreamProvider.family((ref, id) async* { - if (id.isEmpty) { - yield null; - return; - } - final db = ref.watch(appDbProvider); - final query = (db.select(db.cachedTracks)..where((t) => t.id.equals(id))) - .join([ - leftOuterJoin(db.cachedArtists, - db.cachedArtists.id.equalsExp(db.cachedTracks.artistId)), - leftOuterJoin(db.cachedAlbums, - db.cachedAlbums.id.equalsExp(db.cachedTracks.albumId)), - ]); - var enqueued = false; - await for (final rows in query.watch()) { - if (rows.isEmpty) { - if (!enqueued) { - enqueued = true; - ref - .read(hydrationQueueProvider) - .enqueue(entityType: 'track', entityId: id); - } - yield null; - continue; - } - final r = rows.first; - final t = r.readTable(db.cachedTracks); - final a = r.readTableOrNull(db.cachedArtists); - final al = r.readTableOrNull(db.cachedAlbums); - yield t.toRef( - artistName: a?.name ?? '', - albumTitle: al?.title ?? '', - ); - } -}); diff --git a/flutter_client/lib/discover/discover_screen.dart b/flutter_client/lib/discover/discover_screen.dart deleted file mode 100644 index bee54a48..00000000 --- a/flutter_client/lib/discover/discover_screen.dart +++ /dev/null @@ -1,463 +0,0 @@ -import 'package:cached_network_image/cached_network_image.dart'; -import 'package:dio/dio.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_lucide/flutter_lucide.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; - -import '../api/endpoints/discover.dart'; -import '../api/errors.dart'; -import '../cache/mutation_queue.dart'; -import '../library/library_providers.dart' show dioProvider; -import '../models/artist_suggestion.dart'; -import '../models/lidarr.dart'; -import '../shared/widgets/main_app_bar_actions.dart'; -import '../theme/theme_extension.dart'; - -final _discoverApiProvider = FutureProvider((ref) async { - return DiscoverApi(await ref.watch(dioProvider.future)); -}); - -class DiscoverScreen extends ConsumerStatefulWidget { - const DiscoverScreen({super.key}); - - @override - ConsumerState createState() => _DiscoverScreenState(); -} - -class _DiscoverScreenState extends ConsumerState { - final _ctrl = TextEditingController(); - LidarrRequestKind _kind = LidarrRequestKind.artist; - Future>? _resultsFuture; - // Default (empty-search) surface: LB-derived out-of-library artist - // suggestions, mirroring web's SuggestionFeed. - Future>? _suggestionsFuture; - final _requested = {}; - - @override - void initState() { - super.initState(); - _loadSuggestions(); - // Clearing the box returns to suggestions (web swaps live too). - _ctrl.addListener(() { - if (_ctrl.text.trim().isEmpty && _resultsFuture != null) { - setState(() => _resultsFuture = null); - } - }); - } - - @override - void dispose() { - _ctrl.dispose(); - super.dispose(); - } - - // Assigns the future only; callers trigger the rebuild (initState - // runs before first build, so setState here would be a no-op/warn). - void _loadSuggestions() { - _suggestionsFuture = ref - .read(_discoverApiProvider.future) - .then((api) => api.listSuggestions()); - } - - Future _requestSuggestion(ArtistSuggestion s) async { - final fs = Theme.of(context).extension()!; - final args = { - 'kind': LidarrRequestKind.artist.wire, - 'artistMbid': s.mbid, - 'artistName': s.name, - 'albumMbid': null, - 'albumTitle': null, - }; - try { - final api = await ref.read(_discoverApiProvider.future); - await api.createRequest( - kind: LidarrRequestKind.artist, - artistMbid: s.mbid, - artistName: s.name, - ); - if (mounted) { - // Reassign the future first; the setState below rebuilds and - // the FutureBuilder picks up the fresh fetch (server now - // filters this candidate out). _requested hides it meanwhile. - _loadSuggestions(); - setState(() => _requested.add(s.mbid)); - ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: Text('Requested: ${s.name}'), - backgroundColor: fs.iron, - )); - } - } on DioException catch (_) { - await ref - .read(mutationQueueProvider) - .enqueue(MutationKinds.requestCreate, args); - if (mounted) { - setState(() => _requested.add(s.mbid)); - ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: Text('Request queued: ${s.name}'), - backgroundColor: fs.iron, - )); - } - } - } - - void _runSearch() { - final q = _ctrl.text.trim(); - if (q.isEmpty) { - setState(() => _resultsFuture = null); - return; - } - setState(() { - _resultsFuture = ref - .read(_discoverApiProvider.future) - .then((api) => api.search(q, _kind)); - }); - } - - Future _request(LidarrSearchResult row) async { - final fs = Theme.of(context).extension()!; - final args = { - 'kind': _kind.wire, - 'artistMbid': - _kind == LidarrRequestKind.artist ? row.mbid : row.artistMbid, - 'artistName': - _kind == LidarrRequestKind.artist ? row.name : row.secondaryText, - 'albumMbid': _kind == LidarrRequestKind.album ? row.mbid : null, - 'albumTitle': _kind == LidarrRequestKind.album ? row.name : null, - }; - try { - final api = await ref.read(_discoverApiProvider.future); - await api.createRequest( - kind: _kind, - artistMbid: args['artistMbid'] as String, - artistName: args['artistName'] as String, - albumMbid: args['albumMbid'], - albumTitle: args['albumTitle'], - ); - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: Text('Requested: ${row.name}'), - backgroundColor: fs.iron, - )); - // Re-run search to refresh the `requested` flag on the row. - _runSearch(); - } - } on DioException catch (_) { - // Queue for replay so the user's request persists across - // network loss. We don't have a drift table for in-flight - // requests yet, so the row won't show on the Requests screen - // until replay succeeds — accept that for v1. - await ref - .read(mutationQueueProvider) - .enqueue(MutationKinds.requestCreate, args); - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: Text('Request queued: ${row.name}'), - backgroundColor: fs.iron, - )); - } - } - } - - @override - Widget build(BuildContext context) { - final fs = Theme.of(context).extension()!; - return Scaffold( - backgroundColor: fs.obsidian, - appBar: AppBar( - backgroundColor: fs.obsidian, - elevation: 0, - leading: IconButton( - icon: Icon(LucideIcons.arrow_left, color: fs.parchment), - onPressed: () => context.pop(), - ), - title: Text('Discover', style: TextStyle(color: fs.parchment)), - actions: const [MainAppBarActions(currentRoute: '/discover')], - ), - body: Column(children: [ - Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 4), - child: Row(children: [ - Expanded( - child: TextField( - controller: _ctrl, - style: TextStyle(color: fs.parchment), - cursorColor: fs.accent, - onSubmitted: (_) => _runSearch(), - decoration: InputDecoration( - hintText: 'Search Lidarr for new music', - hintStyle: TextStyle(color: fs.ash), - enabledBorder: OutlineInputBorder( - borderSide: BorderSide(color: fs.iron), - ), - focusedBorder: OutlineInputBorder( - borderSide: BorderSide(color: fs.accent), - ), - suffixIcon: IconButton( - icon: Icon(LucideIcons.search, color: fs.ash), - onPressed: _runSearch, - ), - ), - ), - ), - ]), - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), - child: SegmentedButton( - segments: const [ - ButtonSegment(value: LidarrRequestKind.artist, label: Text('Artists')), - ButtonSegment(value: LidarrRequestKind.album, label: Text('Albums')), - ], - selected: {_kind}, - onSelectionChanged: (s) { - setState(() { - _kind = s.first; - if (_ctrl.text.trim().isNotEmpty) _runSearch(); - }); - }, - ), - ), - Expanded( - child: _resultsFuture == null - ? _buildSuggestions(fs) - : FutureBuilder>( - future: _resultsFuture, - builder: (ctx, snap) { - if (snap.connectionState != ConnectionState.done) { - return const Center(child: CircularProgressIndicator()); - } - if (snap.hasError) { - final err = snap.error; - final code = err is DioException - ? ApiError.fromDio(err).code - : 'unknown'; - return Center( - child: Text('Search failed: $code', - style: TextStyle(color: fs.error)), - ); - } - final rows = snap.data ?? const []; - if (rows.isEmpty) { - return Center( - child: Text('Nothing to add for that search yet.', - style: TextStyle(color: fs.ash), - textAlign: TextAlign.center), - ); - } - return ListView.separated( - itemCount: rows.length, - separatorBuilder: (_, __) => - Divider(height: 1, color: fs.iron), - itemBuilder: (ctx, i) => _ResultTile( - row: rows[i], - onRequest: () => _request(rows[i]), - ), - ); - }, - ), - ), - ]), - ); - } - - Widget _buildSuggestions(FabledSwordTheme fs) { - return FutureBuilder>( - future: _suggestionsFuture, - builder: (ctx, snap) { - if (snap.connectionState != ConnectionState.done) { - return const Center(child: CircularProgressIndicator()); - } - final items = (snap.data ?? const []) - .where((s) => !_requested.contains(s.mbid)) - .toList(growable: false); - return ListView( - padding: const EdgeInsets.only(bottom: 16), - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Suggested for you', - style: TextStyle( - color: fs.parchment, - fontSize: 20, - fontWeight: FontWeight.w500)), - const SizedBox(height: 2), - Text( - "Out-of-library artists drawn from what you've liked and played.", - style: TextStyle(color: fs.ash, fontSize: 13), - ), - ], - ), - ), - if (snap.hasError) - Padding( - padding: const EdgeInsets.all(16), - child: Text("Couldn't load suggestions.", - style: TextStyle(color: fs.ash)), - ) - else if (items.isEmpty) - Padding( - padding: const EdgeInsets.all(16), - child: Text( - 'Listen to something or like an artist to start getting suggestions.', - style: TextStyle(color: fs.ash), - ), - ) - else - ...items.map((s) => _SuggestionTile( - s: s, - onRequest: () => _requestSuggestion(s), - )), - ], - ); - }, - ); - } -} - -class _ResultTile extends StatelessWidget { - const _ResultTile({required this.row, required this.onRequest}); - final LidarrSearchResult row; - final VoidCallback onRequest; - - @override - Widget build(BuildContext context) { - final fs = Theme.of(context).extension()!; - final disabled = row.inLibrary || row.requested; - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), - child: Row(children: [ - ClipRRect( - borderRadius: BorderRadius.circular(6), - child: Container( - width: 56, - height: 56, - color: fs.slate, - child: row.imageUrl.isEmpty - ? Icon(LucideIcons.disc_3, color: fs.ash) - : CachedNetworkImage( - imageUrl: row.imageUrl, - fit: BoxFit.cover, - fadeInDuration: const Duration(milliseconds: 120), - fadeOutDuration: Duration.zero, - errorWidget: (_, __, ___) => - Icon(LucideIcons.disc_3, color: fs.ash), - ), - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(row.name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle(color: fs.parchment, fontSize: 14)), - if (row.secondaryText.isNotEmpty) - Text(row.secondaryText, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle(color: fs.ash, fontSize: 12)), - ], - ), - ), - const SizedBox(width: 8), - if (row.inLibrary) - _Pill(label: 'In library', color: fs.ash) - else if (row.requested) - _Pill(label: 'Requested', color: fs.ash) - else - FilledButton( - onPressed: disabled ? null : onRequest, - style: FilledButton.styleFrom( - backgroundColor: fs.accent, - foregroundColor: fs.parchment, - ), - child: const Text('Request'), - ), - ]), - ); - } -} - -class _Pill extends StatelessWidget { - const _Pill({required this.label, required this.color}); - final String label; - final Color color; - @override - Widget build(BuildContext context) { - final fs = Theme.of(context).extension()!; - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: fs.iron, - borderRadius: BorderRadius.circular(4), - ), - child: Text(label, style: TextStyle(color: color, fontSize: 11)), - ); - } -} - -class _SuggestionTile extends StatelessWidget { - const _SuggestionTile({required this.s, required this.onRequest}); - final ArtistSuggestion s; - final VoidCallback onRequest; - - @override - Widget build(BuildContext context) { - final fs = Theme.of(context).extension()!; - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), - child: Row(children: [ - ClipRRect( - borderRadius: BorderRadius.circular(6), - child: Container( - width: 56, - height: 56, - color: fs.slate, - child: s.imageUrl.isEmpty - ? Icon(LucideIcons.user, color: fs.ash) - : CachedNetworkImage( - imageUrl: s.imageUrl, - fit: BoxFit.cover, - fadeInDuration: const Duration(milliseconds: 120), - fadeOutDuration: Duration.zero, - errorWidget: (_, __, ___) => - Icon(LucideIcons.user, color: fs.ash), - ), - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(s.name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle(color: fs.parchment, fontSize: 14)), - if (s.attributionText.isNotEmpty) - Text(s.attributionText, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: TextStyle(color: fs.ash, fontSize: 12)), - ], - ), - ), - const SizedBox(width: 8), - FilledButton( - onPressed: onRequest, - style: FilledButton.styleFrom( - backgroundColor: fs.accent, - foregroundColor: fs.parchment, - ), - child: const Text('Request'), - ), - ]), - ); - } -} diff --git a/flutter_client/lib/library/album_detail_screen.dart b/flutter_client/lib/library/album_detail_screen.dart deleted file mode 100644 index 621a8fcd..00000000 --- a/flutter_client/lib/library/album_detail_screen.dart +++ /dev/null @@ -1,139 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_lucide/flutter_lucide.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../api/endpoints/likes.dart'; -import '../models/album.dart'; -import '../likes/like_button.dart'; -import '../player/player_provider.dart'; -import '../shared/widgets/server_image.dart'; -import '../theme/theme_extension.dart'; -import 'library_providers.dart'; -import 'widgets/track_row.dart'; - -class AlbumDetailScreen extends ConsumerWidget { - const AlbumDetailScreen({required this.id, this.seed, super.key}); - final String id; - - /// Optional album reference passed by the caller (typically the - /// tile they tapped) so the header can render immediately while - /// the full provider loads tracks. Hydration only — provider data - /// still wins once it arrives. - final AlbumRef? seed; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final fs = Theme.of(context).extension()!; - final live = ref.watch(albumProvider(id)); - - // Pick the best header info available: live data > seed > nothing. - final headerTitle = live.value?.album.title.isNotEmpty == true - ? live.value!.album.title - : seed?.title ?? ''; - final headerArtist = live.value?.album.artistName.isNotEmpty == true - ? live.value!.album.artistName - : seed?.artistName ?? ''; - - Widget header() => Padding( - padding: const EdgeInsets.all(16), - child: Row(children: [ - ClipRRect( - borderRadius: BorderRadius.circular(6), - child: SizedBox( - width: 96, - height: 96, - child: ServerImage( - url: '/api/albums/$id/cover', - fit: BoxFit.cover, - fallback: Container(color: fs.slate), - ), - ), - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - headerTitle, - style: TextStyle( - color: fs.parchment, - fontFamily: fs.display.fontFamily, - fontSize: 22, - ), - ), - if (headerArtist.isNotEmpty) - Text(headerArtist, style: TextStyle(color: fs.ash)), - ], - ), - ), - ]), - ); - - return Scaffold( - appBar: AppBar(), - backgroundColor: fs.obsidian, - body: live.when( - error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))), - loading: () => seed != null - ? ListView(children: [ - header(), - const Padding( - padding: EdgeInsets.symmetric(vertical: 24), - child: Center(child: CircularProgressIndicator()), - ), - ]) - : const Center(child: CircularProgressIndicator()), - data: (r) => ListView(children: [ - Padding( - padding: const EdgeInsets.all(16), - child: Row(children: [ - ClipRRect( - borderRadius: BorderRadius.circular(6), - child: SizedBox( - width: 96, - height: 96, - child: ServerImage( - url: '/api/albums/$id/cover', - fit: BoxFit.cover, - fallback: Container(color: fs.slate), - ), - ), - ), - const SizedBox(width: 16), - Expanded(child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(r.album.title, style: TextStyle( - color: fs.parchment, - fontFamily: fs.display.fontFamily, - fontSize: 22, - )), - Text(r.album.artistName, style: TextStyle(color: fs.ash)), - ], - )), - Container( - width: 48, height: 48, - decoration: BoxDecoration(color: fs.accent, shape: BoxShape.circle), - child: IconButton( - icon: Icon(LucideIcons.play, color: fs.parchment), - onPressed: () => ref.read(playerActionsProvider).playTracks(r.tracks), - ), - ), - LikeButton(kind: LikeKind.album, id: r.album.id, size: 28), - ]), - ), - for (final t in r.tracks) - TrackRow( - track: t, - onTap: () { - final start = r.tracks.indexOf(t); - ref.read(playerActionsProvider).playTracks(r.tracks, initialIndex: start); - }, - trailing: LikeButton(kind: LikeKind.track, id: t.id), - ), - ]), - ), - ); - } -} diff --git a/flutter_client/lib/library/artist_detail_screen.dart b/flutter_client/lib/library/artist_detail_screen.dart deleted file mode 100644 index f893c4fc..00000000 --- a/flutter_client/lib/library/artist_detail_screen.dart +++ /dev/null @@ -1,209 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_lucide/flutter_lucide.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; - -import '../api/endpoints/likes.dart'; -import '../likes/like_button.dart'; -import '../models/album.dart'; -import '../models/artist.dart'; -import '../player/player_provider.dart'; -import '../shared/widgets/server_image.dart'; -import '../theme/theme_extension.dart'; -import 'library_providers.dart'; -import 'widgets/album_card.dart'; - -class ArtistDetailScreen extends ConsumerWidget { - const ArtistDetailScreen({required this.id, this.seed, super.key}); - final String id; - - /// Optional artist reference from the caller. Lets the screen render - /// the name + avatar immediately while albums load. - final ArtistRef? seed; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final fs = Theme.of(context).extension()!; - final artist = ref.watch(artistProvider(id)); - final albums = ref.watch(artistAlbumsProvider(id)); - - // Resolve which artist info to render in the header. Live wins - // when present and populated; seed fills the gap during the - // first frame after navigation. - final liveArtist = artist.value; - final hasLiveName = liveArtist != null && liveArtist.name.isNotEmpty; - final effective = hasLiveName ? liveArtist : (seed ?? liveArtist); - - return Scaffold( - appBar: AppBar(), - backgroundColor: fs.obsidian, - body: artist.when( - error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))), - // While loading: render header from seed if available so the - // page isn't blank. - loading: () => seed == null - ? const Center(child: CircularProgressIndicator()) - : _artistBody(context, ref, seed!, albums, fs), - data: (_) => _artistBody(context, ref, effective ?? seed!, albums, fs), - ), - ); - } - - Widget _artistBody( - BuildContext context, - WidgetRef ref, - ArtistRef a, - AsyncValue> albums, - FabledSwordTheme fs, - ) => - ListView(children: [ - Padding( - padding: const EdgeInsets.all(16), - child: Row(children: [ - ClipOval( - child: SizedBox( - width: 96, - height: 96, - // Server derives artist cover from a representative - // album. Drift cache doesn't persist that pointer, so - // mirror the trick client-side: reuse the first album - // returned by artistAlbumsProvider. Falls back to - // slate while albums are loading or empty. - child: _ArtistAvatar( - serverCoverUrl: a.coverUrl, - albums: albums, - fs: fs, - ), - ), - ), - const SizedBox(width: 16), - Expanded( - child: Text( - a.name, - style: TextStyle( - color: fs.parchment, - fontFamily: fs.display.fontFamily, - fontSize: 24, - ), - ), - ), - Container( - width: 48, height: 48, - decoration: BoxDecoration(color: fs.accent, shape: BoxShape.circle), - child: IconButton( - icon: Icon(LucideIcons.play, color: fs.parchment), - onPressed: () async { - try { - final tracks = - await ref.read(artistTracksProvider(id).future); - if (tracks.isEmpty) { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text( - 'No tracks found for this artist yet.')), - ); - } - return; - } - final shuffled = [...tracks]..shuffle(); - await ref - .read(playerActionsProvider) - .playTracks(shuffled); - } catch (e) { - debugPrint('artist_detail: play failed: $e'); - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text("Couldn't start playback: $e")), - ); - } - } - }, - ), - ), - LikeButton(kind: LikeKind.artist, id: a.id, size: 28), - ]), - ), - const Padding( - padding: EdgeInsets.fromLTRB(16, 16, 16, 8), - child: Text('Albums', style: TextStyle(fontSize: 16)), - ), - albums.when( - error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))), - loading: () => const Padding(padding: EdgeInsets.all(16), child: Center(child: CircularProgressIndicator())), - data: (list) { - return LayoutBuilder(builder: (ctx, constraints) { - const cols = 3; - const sidePad = 8.0; - const gap = 8.0; - final cellW = - (constraints.maxWidth - sidePad * 2 - gap * (cols - 1)) / - cols; - // Card content: cover (cellW - 16) + 8 + title (≤2 lines - // ≈ 36) + slack. Artist line is suppressed in this - // grid (showArtist: false) since the page header already - // names the artist. Slack is generous on purpose — line- - // height variations would otherwise overflow by 1px. - final cellH = (cellW - 16) + 8 + 36 + 8; - return GridView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - padding: const EdgeInsets.fromLTRB(sidePad, 0, sidePad, 16), - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: cols, - mainAxisExtent: cellH, - mainAxisSpacing: gap, - crossAxisSpacing: gap, - ), - itemCount: list.length, - itemBuilder: (_, i) { - final AlbumRef album = list[i]; - return AlbumCard( - album: album, - width: cellW, - titleMaxLines: 2, - showArtist: false, - onTap: () => - context.push('/albums/${album.id}', extra: album), - ); - }, - ); - }); - }, - ), - ]); -} - -/// Renders the artist's avatar. Server-emitted coverUrl wins when -/// non-empty; otherwise we mirror the server's "use the first album's -/// cover" rule client-side via the loaded album list. -class _ArtistAvatar extends StatelessWidget { - const _ArtistAvatar({ - required this.serverCoverUrl, - required this.albums, - required this.fs, - }); - final String serverCoverUrl; - final AsyncValue> albums; - final FabledSwordTheme fs; - - @override - Widget build(BuildContext context) { - if (serverCoverUrl.isNotEmpty) { - return ServerImage( - url: serverCoverUrl, - fit: BoxFit.cover, - fallback: Container(color: fs.slate), - ); - } - final firstId = albums.value?.isNotEmpty == true ? albums.value!.first.id : null; - if (firstId == null) { - return Container(color: fs.slate); - } - return ServerImage( - url: '/api/albums/$firstId/cover', - fit: BoxFit.cover, - fallback: Container(color: fs.slate), - ); - } -} diff --git a/flutter_client/lib/library/home_screen.dart b/flutter_client/lib/library/home_screen.dart deleted file mode 100644 index df488102..00000000 --- a/flutter_client/lib/library/home_screen.dart +++ /dev/null @@ -1,609 +0,0 @@ -import 'package:dio/dio.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_lucide/flutter_lucide.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; - -import '../api/errors.dart'; -import '../cache/offline_provider.dart'; -import '../cache/shuffle_source.dart'; -import '../cache/tile_providers.dart'; -import '../models/playlist.dart'; -import '../models/system_playlists_status.dart'; -import '../models/track.dart'; -import '../player/player_provider.dart'; -import '../playlists/playlists_provider.dart'; -import '../playlists/widgets/playlist_card.dart'; -import '../playlists/widgets/playlist_placeholder_card.dart'; -import '../shared/widgets/connection_error_banner.dart'; -import '../shared/widgets/main_app_bar_actions.dart'; -import '../shared/widgets/skeletons.dart'; -import '../theme/theme_extension.dart'; -import 'library_providers.dart'; -import 'widgets/album_card.dart'; -import 'widgets/artist_card.dart'; -import 'widgets/compact_track_card.dart'; -import 'widgets/horizontal_scroll_row.dart'; - -/// Home screen. Per-item rendering: a tiny /api/home/index discovery -/// fetch returns just section→IDs, then each tile hydrates itself -/// against the per-entity drift tables (sync-populated) with REST -/// fallback via the HydrationQueue. Cold-visit dead air shrinks to a -/// single small round-trip; tiles materialize as their data lands. -class HomeScreen extends ConsumerWidget { - const HomeScreen({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final fs = Theme.of(context).extension()!; - final index = ref.watch(homeIndexProvider); - final allPlaylists = ref.watch(playlistsListProvider('all')); - final status = ref.watch(systemPlaylistsStatusProvider); - return Scaffold( - backgroundColor: fs.obsidian, - appBar: AppBar( - backgroundColor: fs.obsidian, - elevation: 0, - title: Text('Minstrel', style: TextStyle(color: fs.parchment)), - actions: const [MainAppBarActions(currentRoute: '/home')], - ), - body: SafeArea( - child: index.when( - error: (e, _) { - final code = e is DioException ? ApiError.fromDio(e).code : 'unknown'; - if (code == 'connection_refused') { - return ConnectionErrorBanner( - onRetry: () => ref.refresh(homeIndexProvider), - ); - } - return Center(child: Text('$e', style: TextStyle(color: fs.error))); - }, - loading: () => _HomeSkeleton(fs: fs), - data: (h) => RefreshIndicator( - onRefresh: () async => ref.refresh(homeIndexProvider.future), - child: ListView( - physics: const ClampingScrollPhysics(), - children: [ - _PlaylistsSection( - playlists: allPlaylists.value?.owned ?? const [], - status: status.value ?? SystemPlaylistsStatus.empty(), - offline: ref.watch(offlineProvider), - ), - _RecentlyAddedSection(ids: h.recentlyAddedAlbums), - _RediscoverSection( - albumIds: h.rediscoverAlbums, - artistIds: h.rediscoverArtists, - ), - _MostPlayedSection(ids: h.mostPlayedTracks), - _LastPlayedSection(ids: h.lastPlayedArtists), - const SizedBox(height: 140), - ], - ), - ), - ), - ), - ); - } -} - -// ─── Per-tile widgets ──────────────────────────────────────────────── - -/// Duration of the skeleton→content cross-fade. 220ms reads as "tile -/// settled into place" — longer drags, shorter feels like a hard cut. -/// Each tile cross-fades independently when its data lands, so the -/// natural cascade from the hydration queue's bounded concurrency -/// produces a staged-reveal feel without any per-tile delay math. -const Duration _tileRevealDuration = Duration(milliseconds: 220); - -/// Album tile: skeleton until albumTileProvider yields a populated row. -class _AlbumTile extends ConsumerWidget { - const _AlbumTile({required this.id}); - final String id; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final asyncAlbum = ref.watch(albumTileProvider(id)); - final album = asyncAlbum.asData?.value; - return AnimatedSwitcher( - duration: _tileRevealDuration, - switchInCurve: Curves.easeOut, - child: album == null - ? const SkeletonAlbumTile(key: ValueKey('skeleton')) - : AlbumCard( - key: ValueKey('album-${album.id}'), - album: album, - onTap: () => - context.push('/albums/${album.id}', extra: album), - ), - ); - } -} - -/// Artist tile: skeleton until artistTileProvider yields a populated row. -class _ArtistTile extends ConsumerWidget { - const _ArtistTile({required this.id}); - final String id; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final asyncArtist = ref.watch(artistTileProvider(id)); - final artist = asyncArtist.asData?.value; - return AnimatedSwitcher( - duration: _tileRevealDuration, - switchInCurve: Curves.easeOut, - child: artist == null - ? const SkeletonArtistTile(key: ValueKey('skeleton')) - : ArtistCard( - key: ValueKey('artist-${artist.id}'), - artist: artist, - onTap: () => - context.push('/artists/${artist.id}', extra: artist), - ), - ); - } -} - -/// Track tile (used by Most-Played). On tap, gathers the currently- -/// hydrated TrackRefs for the whole section so playback flows like -/// it did with the bulk-loaded list. Tracks that haven't hydrated yet -/// are skipped from the play list — typically transient on a cold -/// visit since hydration runs in parallel and finishes quickly. -class _TrackTile extends ConsumerWidget { - const _TrackTile({required this.id, required this.sectionIds}); - final String id; - final List sectionIds; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final asyncTrack = ref.watch(trackTileProvider(id)); - final track = asyncTrack.asData?.value; - return AnimatedSwitcher( - duration: _tileRevealDuration, - switchInCurve: Curves.easeOut, - child: track == null - ? const _CompactTrackSkeleton(key: ValueKey('skeleton')) - : CompactTrackCard( - key: ValueKey('track-${track.id}'), - track: track, - sectionTracks: _resolveSectionTracks(ref, sectionIds), - index: - sectionIds.indexOf(id).clamp(0, sectionIds.length - 1), - ), - ); - } - - /// Snapshots the section's current track states. Used at tile - /// construction time — CompactTrackCard's onTap uses it as the play - /// queue. Tracks still hydrating are dropped; once they land, a - /// rebuild re-runs this lookup so the queue grows naturally. - static List _resolveSectionTracks(WidgetRef ref, List ids) { - final out = []; - for (final i in ids) { - final v = ref.read(trackTileProvider(i)).asData?.value; - if (v != null) out.add(v); - } - return out; - } -} - -/// Compact-track placeholder. 56dp cover + two text lines, matched to -/// CompactTrackCard so swapping in the real card doesn't shift the -/// row's height. -class _CompactTrackSkeleton extends StatelessWidget { - const _CompactTrackSkeleton({super.key}); - @override - Widget build(BuildContext context) { - final fs = Theme.of(context).extension()!; - return Container( - width: 240, - margin: const EdgeInsets.symmetric(horizontal: 4), - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), - child: Row(children: [ - Container( - width: 56, - height: 56, - decoration: BoxDecoration( - color: fs.slate, - borderRadius: BorderRadius.circular(6), - ), - ), - const SizedBox(width: 8), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Container(width: 120, height: 12, color: fs.slate), - const SizedBox(height: 6), - Container(width: 80, height: 10, color: fs.slate), - ], - ), - ), - ]), - ); - } -} - -// ─── Sections ──────────────────────────────────────────────────────── - -class _PlaylistsSection extends StatelessWidget { - const _PlaylistsSection({ - required this.playlists, - required this.status, - required this.offline, - }); - final List playlists; - final SystemPlaylistsStatus status; - final bool offline; - - @override - Widget build(BuildContext context) { - final items = _buildPlaylistsRow(playlists, status); - final children = [ - // Offline: surface the cache-backed pools where the (now - // play-disabled) system playlists sit, so there's something - // to play. #427 S4b. - if (offline) ...const [ - _OfflinePoolCard( - label: 'Recently played', - icon: LucideIcons.history, - kind: _OfflinePoolKind.recentlyPlayed, - ), - _OfflinePoolCard( - label: 'Liked', - icon: LucideIcons.heart, - kind: _OfflinePoolKind.liked, - ), - ], - for (final item in items) - if (item is _RealPlaylist) - PlaylistCard(playlist: item.playlist) - else - PlaylistPlaceholderCard( - label: (item as _PlaceholderPlaylist).label, - variant: item.variant, - ), - ]; - return HorizontalScrollRow( - title: 'Playlists', - height: 220, - children: children, - ); - } -} - -enum _OfflinePoolKind { recentlyPlayed, liked } - -/// Home tile for an offline cache-backed pool. Tapping shuffles + -/// plays that pool from the local cache. Sized to match -/// PlaylistCard so the row stays visually consistent. -class _OfflinePoolCard extends ConsumerWidget { - const _OfflinePoolCard({ - required this.label, - required this.icon, - required this.kind, - }); - final String label; - final IconData icon; - final _OfflinePoolKind kind; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final fs = Theme.of(context).extension()!; - return SizedBox( - width: 176, - child: Material( - color: Colors.transparent, - child: InkWell( - onTap: () async { - final messenger = ScaffoldMessenger.of(context); - final src = ref.read(shuffleSourceProvider); - final refs = await switch (kind) { - _OfflinePoolKind.recentlyPlayed => src.recentlyPlayed(), - _OfflinePoolKind.liked => src.liked(), - }; - if (refs.isEmpty) { - messenger.showSnackBar( - SnackBar(content: Text('No cached $label tracks yet')), - ); - return; - } - await ref - .read(playerActionsProvider) - .playTracks(refs, shuffle: true); - }, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - width: 144, - height: 144, - decoration: BoxDecoration( - color: fs.slate, - borderRadius: BorderRadius.circular(6), - ), - child: Icon(icon, color: fs.accent, size: 56), - ), - const SizedBox(height: 8), - Text( - label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle(color: fs.parchment, fontSize: 14), - ), - Padding( - padding: const EdgeInsets.only(top: 2), - child: Text( - 'Offline', - style: TextStyle(color: fs.ash, fontSize: 11), - ), - ), - ], - ), - ), - ), - ), - ); - } -} - -abstract class _PlaylistRowItem {} -class _RealPlaylist extends _PlaylistRowItem { - _RealPlaylist(this.playlist); - final Playlist playlist; -} -class _PlaceholderPlaylist extends _PlaylistRowItem { - _PlaceholderPlaylist(this.label, this.variant); - final String label; - final String variant; -} - -List<_PlaylistRowItem> _buildPlaylistsRow( - List ownedAll, - SystemPlaylistsStatus status, -) { - final out = <_PlaylistRowItem>[]; - - Playlist? findFirst(bool Function(Playlist) test) { - for (final p in ownedAll) { - if (test(p)) return p; - } - return null; - } - - final forYou = findFirst((p) => p.systemVariant == 'for_you'); - out.add(forYou != null - ? _RealPlaylist(forYou) - : _PlaceholderPlaylist('For You', _variantFor('for-you', status))); - - final discover = findFirst((p) => p.systemVariant == 'discover'); - out.add(discover != null - ? _RealPlaylist(discover) - : _PlaceholderPlaylist('Discover', _variantFor('discover', status))); - - final songsLike = ownedAll - .where((p) => p.systemVariant == 'songs_like_artist') - .take(3) - .toList(); - for (var i = 0; i < 3; i++) { - out.add(i < songsLike.length - ? _RealPlaylist(songsLike[i]) - : _PlaceholderPlaylist('Songs like…', _variantFor('songs-like', status))); - } - - for (final p in ownedAll.where((p) => p.systemVariant == null)) { - out.add(_RealPlaylist(p)); - } - - return out; -} - -String _variantFor(String slot, SystemPlaylistsStatus s) { - if (s.inFlight) return 'building'; - if (s.lastError != null) return 'failed'; - if (slot == 'songs-like' && s.lastRunAt != null) return 'seed-needed'; - return 'pending'; -} - -class _RecentlyAddedSection extends StatelessWidget { - const _RecentlyAddedSection({required this.ids}); - final List ids; - - @override - Widget build(BuildContext context) { - if (ids.isEmpty) { - return const _EmptySection( - title: 'Recently added', - message: "Nothing added yet. Scan a folder via the server's config.", - ); - } - final controller = ScrollController(); - final rows = _chunk(ids, 25); - return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - for (var i = 0; i < rows.length; i++) - HorizontalScrollRow( - title: i == 0 ? 'Recently added' : '', - controller: controller, - children: rows[i].map((id) => _AlbumTile(id: id)).toList(), - ), - ]); - } -} - -class _RediscoverSection extends StatelessWidget { - const _RediscoverSection({required this.albumIds, required this.artistIds}); - final List albumIds; - final List artistIds; - - @override - Widget build(BuildContext context) { - if (albumIds.isEmpty && artistIds.isEmpty) { - return const _EmptySection( - title: 'Rediscover', - message: - 'No forgotten favourites yet. Like some albums or artists to fill this in.', - ); - } - return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (albumIds.isNotEmpty) - HorizontalScrollRow( - title: 'Rediscover', - children: albumIds.map((id) => _AlbumTile(id: id)).toList(), - ), - if (artistIds.isNotEmpty) - HorizontalScrollRow( - title: albumIds.isEmpty ? 'Rediscover' : '', - height: 168, - children: artistIds.map((id) => _ArtistTile(id: id)).toList(), - ), - ]); - } -} - -class _MostPlayedSection extends StatelessWidget { - const _MostPlayedSection({required this.ids}); - final List ids; - - @override - Widget build(BuildContext context) { - if (ids.isEmpty) { - return const _EmptySection( - title: 'Most played', - message: 'No plays to draw from. Listen to something.', - ); - } - final controller = ScrollController(); - final rows = _chunk(ids, 25); - return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - for (var i = 0; i < rows.length; i++) - HorizontalScrollRow( - title: i == 0 ? 'Most played' : '', - height: 64, - controller: controller, - children: [ - for (final id in rows[i]) _TrackTile(id: id, sectionIds: ids), - ], - ), - ]); - } -} - -class _LastPlayedSection extends StatelessWidget { - const _LastPlayedSection({required this.ids}); - final List ids; - - @override - Widget build(BuildContext context) { - if (ids.isEmpty) { - return const _EmptySection( - title: 'Last played', - message: 'No recent plays.', - ); - } - return HorizontalScrollRow( - title: 'Last played', - height: 168, - children: ids.map((id) => _ArtistTile(id: id)).toList(), - ); - } -} - -class _EmptySection extends StatelessWidget { - const _EmptySection({required this.title, required this.message}); - final String title; - final String message; - - @override - Widget build(BuildContext context) { - final fs = Theme.of(context).extension()!; - return Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), - child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - title, - style: TextStyle( - fontFamily: fs.display.fontFamily, - fontSize: 18, - color: fs.parchment, - ), - ), - const SizedBox(height: 8), - Text(message, style: TextStyle(color: fs.ash)), - ]), - ); - } -} - -/// Cold-start skeleton — shown only between mount and the first -/// homeIndexProvider emission (typically a single drift query + small -/// REST round-trip). Each section then renders its own per-tile -/// skeletons internally, so this widget's role is just the very first -/// pre-discovery frame. -class _HomeSkeleton extends StatelessWidget { - const _HomeSkeleton({required this.fs}); - final FabledSwordTheme fs; - - @override - Widget build(BuildContext context) { - return ListView( - physics: const ClampingScrollPhysics(), - children: [ - _SkeletonSection(fs: fs, title: 'Playlists', cardWidth: 176), - _SkeletonSection(fs: fs, title: 'Recently added', cardWidth: 140), - _SkeletonSection(fs: fs, title: 'Rediscover', cardWidth: 140), - _SkeletonSection(fs: fs, title: 'Most played', cardWidth: 140), - _SkeletonSection(fs: fs, title: 'Last played', cardWidth: 140), - const SizedBox(height: 140), - ], - ); - } -} - -class _SkeletonSection extends StatelessWidget { - const _SkeletonSection({ - required this.fs, - required this.title, - required this.cardWidth, - }); - final FabledSwordTheme fs; - final String title; - final double cardWidth; - - @override - Widget build(BuildContext context) { - final coverSize = cardWidth - 16; - return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), - child: Text( - title, - style: TextStyle( - fontFamily: fs.display.fontFamily, - fontSize: 18, - color: fs.parchment, - ), - ), - ), - SizedBox( - height: coverSize + 40, - child: ListView.builder( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric(horizontal: 16), - physics: const NeverScrollableScrollPhysics(), - itemCount: 6, - itemBuilder: (_, __) => SkeletonAlbumTile(width: cardWidth), - ), - ), - ]); - } -} - -List> _chunk(List items, int size) { - final out = >[]; - for (var i = 0; i < items.length; i += size) { - out.add(items.sublist(i, i + size > items.length ? items.length : i + size)); - } - return out; -} diff --git a/flutter_client/lib/library/library_providers.dart b/flutter_client/lib/library/library_providers.dart deleted file mode 100644 index b97e5946..00000000 --- a/flutter_client/lib/library/library_providers.dart +++ /dev/null @@ -1,407 +0,0 @@ -import 'dart:async'; - -import 'package:dio/dio.dart'; -import 'package:drift/drift.dart' as drift; -import 'package:flutter/foundation.dart' show debugPrint; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../api/client.dart'; -import '../api/endpoints/library.dart'; -import '../auth/auth_provider.dart'; -import '../cache/adapters.dart'; -import '../cache/audio_cache_manager.dart' show appDbProvider; -import '../cache/cache_first.dart'; -import '../cache/connectivity_provider.dart'; -import '../cache/db.dart'; -import '../models/album.dart'; -import '../models/artist.dart'; -import '../models/home_index.dart'; -import '../models/track.dart'; - -/// Shared authenticated dio. This is the ONLY place tokenResolver is wired -/// to the secure-storage `session_token` read — every other endpoint -/// awaits `dioProvider.future` rather than constructing its own dio. -/// -/// Riverpod will invalidate any FutureProvider that watches this when -/// the server URL changes; auth state changes don't need to invalidate -/// the provider because the resolver re-reads the token on every request. -final dioProvider = FutureProvider((ref) async { - final url = await ref.watch(serverUrlProvider.future); - if (url == null || url.isEmpty) { - throw StateError('no server URL set'); - } - final storage = ref.watch(secureStorageProvider); - return ApiClient.buildDio( - baseUrl: url, - tokenResolver: () async => storage.read(key: 'session_token'), - on401: () async => ref.read(authControllerProvider.notifier).clearSession(), - ); -}); - -final libraryApiProvider = FutureProvider((ref) async { - return LibraryApi(await ref.watch(dioProvider.future)); -}); - -/// Drift-first per-item home index. Reads from cached_home_index -/// (populated by /api/home/index discovery) and yields a HomeIndex -/// the screen consumes to lay out section→tile slots. Each tile is -/// rendered by a per-entity tile provider that hydrates itself. -/// -/// Section keys mirror the server's response shape so the encode / -/// decode round-trip is straightforward — the table stores -/// (section, position, entityType, entityId), and toResult reassembles -/// the parallel ID lists. -final homeIndexProvider = StreamProvider((ref) { - final db = ref.watch(appDbProvider); - final query = (db.select(db.cachedHomeIndex) - ..orderBy([ - (t) => drift.OrderingTerm.asc(t.section), - (t) => drift.OrderingTerm.asc(t.position), - ])) - .watch(); - return cacheFirst( - driftStream: query, - fetchAndPopulate: () async { - final api = await ref.read(libraryApiProvider.future); - final fresh = await api.getHomeIndex(); - // Full replace in a transaction so the watch() sees exactly one - // post-fetch emission with the merged state. Section ordering is - // re-asserted on read (orderBy above) so the write order doesn't - // matter — letting us batch flat instead of section-by-section. - await db.transaction(() async { - await db.delete(db.cachedHomeIndex).go(); - await db.batch((b) { - void rows(String section, String entityType, List ids) { - for (var i = 0; i < ids.length; i++) { - b.insert( - db.cachedHomeIndex, - CachedHomeIndexCompanion.insert( - section: section, - position: i, - entityType: entityType, - entityId: ids[i], - ), - ); - } - } - - rows('recently_added_albums', 'album', fresh.recentlyAddedAlbums); - rows('rediscover_albums', 'album', fresh.rediscoverAlbums); - rows('rediscover_artists', 'artist', fresh.rediscoverArtists); - rows('most_played_tracks', 'track', fresh.mostPlayedTracks); - rows('last_played_artists', 'artist', fresh.lastPlayedArtists); - }); - }); - }, - toResult: (rows) { - final recentlyAddedAlbums = []; - final rediscoverAlbums = []; - final rediscoverArtists = []; - final mostPlayedTracks = []; - final lastPlayedArtists = []; - for (final r in rows) { - switch (r.section) { - case 'recently_added_albums': - recentlyAddedAlbums.add(r.entityId); - case 'rediscover_albums': - rediscoverAlbums.add(r.entityId); - case 'rediscover_artists': - rediscoverArtists.add(r.entityId); - case 'most_played_tracks': - mostPlayedTracks.add(r.entityId); - case 'last_played_artists': - lastPlayedArtists.add(r.entityId); - } - } - return HomeIndex( - recentlyAddedAlbums: recentlyAddedAlbums, - rediscoverAlbums: rediscoverAlbums, - rediscoverArtists: rediscoverArtists, - mostPlayedTracks: mostPlayedTracks, - lastPlayedArtists: lastPlayedArtists, - ); - }, - isOnline: () async => (await ref - .read(connectivityProvider.future) - .timeout(const Duration(seconds: 3), onTimeout: () => true)), - // SWR: cached layout shows instantly; fresh /api/home/index lands - // in the background and tiles re-resolve as the table mutates. - alwaysRefresh: true, - tag: 'homeIndex', - ); -}); - -/// Drift-first per #357 plan C. Watches cached_artists for the row; -/// when empty + online, fetches via REST + populates drift, which -/// re-emits via watch(). -final artistProvider = - StreamProvider.family((ref, id) { - final db = ref.watch(appDbProvider); - // LEFT JOIN cached_albums for cover-URL reconstruction (see - // CachedArtistAdapter.toRef). First row carries the alphabetically- - // first album. - final query = (db.select(db.cachedArtists)..where((t) => t.id.equals(id))) - .join([ - drift.leftOuterJoin(db.cachedAlbums, - db.cachedAlbums.artistId.equalsExp(db.cachedArtists.id)), - ]) - ..orderBy([drift.OrderingTerm.asc(db.cachedAlbums.sortTitle)]); - return cacheFirst( - driftStream: query.watch(), - fetchAndPopulate: () async { - final api = await ref.read(libraryApiProvider.future); - final fresh = await api.getArtist(id); - await db.into(db.cachedArtists).insertOnConflictUpdate(fresh.toDrift()); - }, - toResult: (rows) { - if (rows.isEmpty) return const ArtistRef(id: '', name: ''); - final artist = rows.first.readTable(db.cachedArtists); - final firstAlbum = rows.first.readTableOrNull(db.cachedAlbums); - return artist.toRef(coverAlbumId: firstAlbum?.id ?? ''); - }, - isOnline: () async => (await ref - .read(connectivityProvider.future) - .timeout(const Duration(seconds: 3), onTimeout: () => true)), - // No alwaysRefresh: artist rows don't change frequently, and the - // metadata prefetcher creates many subscriptions in parallel — - // each silently re-fetching once would saturate the request - // pipeline behind the user's actual playback request. - tag: 'artist($id)', - ); -}); - -final artistAlbumsProvider = - StreamProvider.family, String>((ref, artistId) { - final db = ref.watch(appDbProvider); - // Join cached_albums + cached_artists to fill artist_name on each row. - final query = db.select(db.cachedAlbums).join([ - drift.leftOuterJoin(db.cachedArtists, - db.cachedArtists.id.equalsExp(db.cachedAlbums.artistId)), - ])..where(db.cachedAlbums.artistId.equals(artistId)); - - return cacheFirst>( - driftStream: query.watch(), - fetchAndPopulate: () async { - final api = await ref.read(libraryApiProvider.future); - final fresh = await api.getArtistAlbums(artistId); - await db.batch((b) { - b.insertAllOnConflictUpdate( - db.cachedAlbums, fresh.map((a) => a.toDrift()).toList()); - }); - }, - toResult: (rows) => rows.map((r) { - final album = r.readTable(db.cachedAlbums); - final artist = r.readTableOrNull(db.cachedArtists); - return album.toRef(artistName: artist?.name ?? ''); - }).toList(), - isOnline: () async => (await ref - .read(connectivityProvider.future) - .timeout(const Duration(seconds: 3), onTimeout: () => true)), - // SWR: re-fetch the full album list on every visit. Without this, - // a previously-incomplete drift cache (e.g. user had only opened - // one album by this artist, so cachedAlbums had just that row) - // would render forever as a partial list. The prefetcher only - // warms artistProvider (single row), so this provider isn't - // mass-instantiated and the storm risk that motivated dropping - // alwaysRefresh elsewhere doesn't apply here. - alwaysRefresh: true, - tag: 'artistAlbums($artistId)', - ); -}); - -final artistTracksProvider = - StreamProvider.family, String>((ref, artistId) { - final db = ref.watch(appDbProvider); - final query = db.select(db.cachedTracks).join([ - drift.leftOuterJoin(db.cachedArtists, - db.cachedArtists.id.equalsExp(db.cachedTracks.artistId)), - drift.leftOuterJoin(db.cachedAlbums, - db.cachedAlbums.id.equalsExp(db.cachedTracks.albumId)), - ])..where(db.cachedTracks.artistId.equals(artistId)); - - return cacheFirst>( - driftStream: query.watch(), - fetchAndPopulate: () async { - final api = await ref.read(libraryApiProvider.future); - final fresh = await api.getArtistTracks(artistId); - await db.batch((b) { - b.insertAllOnConflictUpdate( - db.cachedTracks, fresh.map((t) => t.toDrift()).toList()); - }); - }, - toResult: (rows) => rows.map((r) { - final track = r.readTable(db.cachedTracks); - final artist = r.readTableOrNull(db.cachedArtists); - final album = r.readTableOrNull(db.cachedAlbums); - return track.toRef( - artistName: artist?.name ?? '', - albumTitle: album?.title ?? '', - ); - }).toList(), - isOnline: () async => (await ref - .read(connectivityProvider.future) - .timeout(const Duration(seconds: 3), onTimeout: () => true)), - tag: 'artistTracks($artistId)', - ); -}); - -/// Composite shape (album + tracks) — uses async* + Stream.combineLatest -/// for reactive updates over both rows. -final albumProvider = StreamProvider.family< - ({AlbumRef album, List tracks}), String>((ref, albumId) async* { - final db = ref.watch(appDbProvider); - - final albumQuery = (db.select(db.cachedAlbums) - ..where((t) => t.id.equals(albumId))) - .join([ - drift.leftOuterJoin(db.cachedArtists, - db.cachedArtists.id.equalsExp(db.cachedAlbums.artistId)), - ]); - - final tracksQuery = (db.select(db.cachedTracks) - ..where((t) => t.albumId.equals(albumId)) - ..orderBy([ - (t) => drift.OrderingTerm.asc(t.discNumber), - (t) => drift.OrderingTerm.asc(t.trackNumber), - ])) - .join([ - drift.leftOuterJoin(db.cachedArtists, - db.cachedArtists.id.equalsExp(db.cachedTracks.artistId)), - ]); - - // Once-per-subscription guard so we don't re-fetch in a loop if the - // server genuinely returns zero tracks (or if the fetch fails). - var fetchAttempted = false; - // (revalidated flag removed; see SWR note below the yield.) - - Future isOnline() async { - try { - return await ref - .read(connectivityProvider.future) - .timeout(const Duration(seconds: 3), onTimeout: () => true); - } catch (_) { - return true; - } - } - - // Cold-cache fetch: pulls the album + its tracks + any unique - // artists referenced and writes all three tables in one batch. - // Returns true on success (drift watch will re-emit), false on - // failure so the caller can yield an empty result. - Future fetchAndPopulate() async { - try { - final api = await ref.read(libraryApiProvider.future); - final fresh = await api - .getAlbum(albumId) - .timeout(const Duration(seconds: 10)); - // Collect every artist mentioned by the album + its tracks so - // the JOINs that drive both the album header and the track rows - // have something to bind to. Without this, drift returns null - // for the artist row and artistName surfaces empty — visible - // as a missing artist line in the mini player and row list. - final artists = {}; - if (fresh.album.artistId.isNotEmpty && - fresh.album.artistName.isNotEmpty) { - artists[fresh.album.artistId] = ArtistRef( - id: fresh.album.artistId, - name: fresh.album.artistName, - ); - } - for (final t in fresh.tracks) { - if (t.artistId.isNotEmpty && - t.artistName.isNotEmpty && - !artists.containsKey(t.artistId)) { - artists[t.artistId] = ArtistRef( - id: t.artistId, - name: t.artistName, - ); - } - } - await db.batch((b) { - if (artists.isNotEmpty) { - b.insertAllOnConflictUpdate( - db.cachedArtists, artists.values.map((a) => a.toDrift()).toList()); - } - b.insertAllOnConflictUpdate(db.cachedAlbums, [fresh.album.toDrift()]); - b.insertAllOnConflictUpdate(db.cachedTracks, - fresh.tracks.map((t) => t.toDrift()).toList()); - }); - return true; - } catch (e, st) { - debugPrint('albumProvider($albumId): cold-cache fetch failed: $e\n$st'); - return false; - } - } - - await for (final albumRows in albumQuery.watch()) { - // Case 1: no album row at all → cold-fetch. - if (albumRows.isEmpty) { - if (fetchAttempted) { - // Already tried and got nothing back. - yield ( - album: const AlbumRef(id: '', title: '', artistId: ''), - tracks: const [], - ); - continue; - } - fetchAttempted = true; - if (!await isOnline()) { - yield ( - album: const AlbumRef(id: '', title: '', artistId: ''), - tracks: const [], - ); - continue; - } - final ok = await fetchAndPopulate(); - if (!ok) { - yield ( - album: const AlbumRef(id: '', title: '', artistId: ''), - tracks: const [], - ); - } - // On success, drift watch re-emits with rows; loop continues. - continue; - } - - final albumRow = albumRows.first; - final album = albumRow.readTable(db.cachedAlbums).toRef( - artistName: albumRow.readTableOrNull(db.cachedArtists)?.name ?? '', - ); - - final trackRows = await tracksQuery.get(); - - // Case 2: album row exists but no tracks. This happens when an - // upstream provider (artistAlbumsProvider, sync, etc.) populated - // album rows without their track lists. Trigger the same - // fetchAndPopulate so the album becomes complete; drift watch - // re-emits and we land in the populated branch on the next pass. - if (trackRows.isEmpty && !fetchAttempted) { - fetchAttempted = true; - if (await isOnline()) { - final ok = await fetchAndPopulate(); - if (ok) continue; // wait for watch re-emit - } - // Fall through and yield with the album + empty tracks if the - // fetch failed or we're offline. - } - - final tracks = trackRows.map((r) { - final track = r.readTable(db.cachedTracks); - final artist = r.readTableOrNull(db.cachedArtists); - return track.toRef( - artistName: artist?.name ?? '', - albumTitle: album.title, - ); - }).toList(); - - // Note: NO SWR here on purpose. Prior code kicked a background - // refresh on every first cache hit, which combined with the - // metadata prefetcher meant every prewarmed album id triggered - // an extra fetch even when drift was already populated. Tracks - // and album metadata don't change on the same timescale as - // playlists; a stale read is fine until the user invalidates - // (pull-to-refresh) or the album is genuinely re-fetched. - - yield (album: album, tracks: tracks); - } -}); diff --git a/flutter_client/lib/library/library_screen.dart b/flutter_client/lib/library/library_screen.dart deleted file mode 100644 index 4695bccb..00000000 --- a/flutter_client/lib/library/library_screen.dart +++ /dev/null @@ -1,936 +0,0 @@ -import 'dart:convert'; - -import 'package:drift/drift.dart' as drift; -import 'package:flutter/material.dart'; -import 'package:flutter_lucide/flutter_lucide.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; - -import '../api/endpoints/library_lists.dart'; -import '../api/endpoints/likes.dart'; -import '../api/endpoints/me.dart'; -import '../auth/auth_provider.dart' show authControllerProvider; -import '../cache/adapters.dart'; -import '../cache/audio_cache_manager.dart' show appDbProvider; -import '../cache/cache_first.dart'; -import '../cache/connectivity_provider.dart'; -import '../cache/db.dart'; -import '../cache/metadata_prefetcher.dart'; -import '../cache/shuffle_source.dart'; -import '../cache/tile_providers.dart'; -import '../library/library_providers.dart' show dioProvider; -import '../models/album.dart'; -import '../models/artist.dart'; -import '../models/history_event.dart'; -import '../models/quarantine_mine.dart'; -import '../models/track.dart'; -import '../player/player_provider.dart'; -import '../quarantine/quarantine_provider.dart'; -import '../shared/live_events_provider.dart'; -import '../shared/widgets/main_app_bar_actions.dart'; -import '../shared/widgets/skeletons.dart'; -import '../shared/widgets/server_image.dart'; -import '../theme/theme_extension.dart'; -import 'widgets/album_card.dart'; -import 'widgets/artist_card.dart'; -import 'widgets/track_row.dart'; - -// Providers scoped to this screen. Each tab gets its own first page. -// Infinite scroll is a future enhancement; for v1 phone testing the -// first 50 rows per tab is enough. - -final _libraryListsApiProvider = FutureProvider((ref) async { - return LibraryListsApi(await ref.watch(dioProvider.future)); -}); - -final _likesApiProvider = FutureProvider((ref) async { - return LikesApi(await ref.watch(dioProvider.future)); -}); - -final _meApiProvider = FutureProvider((ref) async { - return MeApi(await ref.watch(dioProvider.future)); -}); - -/// Drift-first all-artists list. Reads cached_artists ordered by -/// sortName; GridView.builder takes care of lazy widget construction -/// so loading the full set up front is fine for typical library sizes. -/// SWR refresh on every subscription hits /api/artists with a generous -/// limit so newly-scanned-but-not-yet-synced rows land soon after. -/// -/// The old AsyncNotifier+loadMore infinite-scroll path went away: the -/// previous "fetch one page at a time as you scroll" felt like the -/// rest of the app was buffering when this tab was the slow surface, -/// and SyncController already populates the full set of artist rows -/// via /api/library/sync. -final _libraryArtistsProvider = StreamProvider>((ref) { - final db = ref.watch(appDbProvider); - // LEFT JOIN cached_albums so each artist row carries a representative - // album id for cover-URL reconstruction. Sorted by artist sortName - // then album sortTitle: the first row per artist gets the - // alphabetically-first album. Dedup happens in toResult. - final query = db.select(db.cachedArtists).join([ - drift.leftOuterJoin(db.cachedAlbums, - db.cachedAlbums.artistId.equalsExp(db.cachedArtists.id)), - ]) - ..orderBy([ - drift.OrderingTerm.asc(db.cachedArtists.sortName), - drift.OrderingTerm.asc(db.cachedAlbums.sortTitle), - ]); - return cacheFirst>( - driftStream: query.watch(), - fetchAndPopulate: () async { - // Cold-cache fallback: sync should have populated drift already, - // but a fresh install + first Library visit before sync completes - // will be empty. Fetch a generous chunk via /api/artists and - // persist; subsequent SWR refreshes keep things current. - final api = await ref.read(_libraryListsApiProvider.future); - final fresh = await api.listArtists(limit: 1000, offset: 0); - await db.batch((b) { - b.insertAllOnConflictUpdate( - db.cachedArtists, - fresh.items.map((a) => a.toDrift()).toList(), - ); - }); - }, - toResult: (rows) { - // The join multiplies rows by album count; dedup by artist id - // keeping the first occurrence so we get one ArtistRef per - // artist with the alphabetically-first album's id for the - // cover-URL projection. Artists with no albums in drift yet - // come through as a single row with null cachedAlbums and an - // empty coverUrl — UI falls back to the placeholder icon. - final seen = {}; - final out = []; - for (final r in rows) { - final a = r.readTable(db.cachedArtists); - if (!seen.add(a.id)) continue; - final album = r.readTableOrNull(db.cachedAlbums); - out.add(a.toRef(coverAlbumId: album?.id ?? '')); - } - return out; - }, - isOnline: () async => (await ref - .read(connectivityProvider.future) - .timeout(const Duration(seconds: 3), onTimeout: () => true)), - alwaysRefresh: true, - tag: 'libraryArtists', - ); -}); - -/// Drift-first all-albums list. Joins cached_albums with cached_artists -/// for the artistName field. Same cold-cache fallback + SWR refresh -/// pattern as libraryArtistsProvider. -final _libraryAlbumsProvider = StreamProvider>((ref) { - final db = ref.watch(appDbProvider); - final query = db.select(db.cachedAlbums).join([ - drift.leftOuterJoin(db.cachedArtists, - db.cachedArtists.id.equalsExp(db.cachedAlbums.artistId)), - ]) - ..orderBy([drift.OrderingTerm.asc(db.cachedAlbums.sortTitle)]); - return cacheFirst>( - driftStream: query.watch(), - fetchAndPopulate: () async { - final api = await ref.read(_libraryListsApiProvider.future); - final fresh = await api.listAlbums(limit: 1000, offset: 0); - await db.batch((b) { - b.insertAllOnConflictUpdate( - db.cachedAlbums, - fresh.items.map((a) => a.toDrift()).toList(), - ); - }); - }, - toResult: (rows) => rows.map((r) { - final album = r.readTable(db.cachedAlbums); - final artist = r.readTableOrNull(db.cachedArtists); - return album.toRef(artistName: artist?.name ?? ''); - }).toList(), - isOnline: () async => (await ref - .read(connectivityProvider.future) - .timeout(const Duration(seconds: 3), onTimeout: () => true)), - alwaysRefresh: true, - tag: 'libraryAlbums', - ); -}); - -// Drift-first History tab. Mirrors homeProvider's pattern: store the -// last /api/me/history page as JSON in a single-row drift table, yield -// it immediately on subscribe (so the tab paints from disk on cold -// open), then SWR-refresh in the background. Also gives basic offline -// scrollback — the last fetched page survives connectivity loss. -// -// JSON blob (vs columnar) because the page is small, always read whole, -// and the HistoryPage.fromJson constructor already accepts the wire -// shape — no schema-evolution pain when server-side fields change. -final _historyProvider = StreamProvider((ref) { - final db = ref.watch(appDbProvider); - return cacheFirst( - driftStream: db.select(db.cachedHistorySnapshot).watch(), - fetchAndPopulate: () async { - final api = await ref.read(_meApiProvider.future); - final fresh = await api.history(); - await db.into(db.cachedHistorySnapshot).insertOnConflictUpdate( - CachedHistorySnapshotCompanion.insert( - json: _encodeHistoryPage(fresh), - updatedAt: drift.Value(DateTime.now()), - ), - ); - }, - toResult: (rows) => rows.isEmpty - ? const HistoryPage(events: [], hasMore: false) - : HistoryPage.fromJson( - jsonDecode(rows.first.json) as Map), - isOnline: () async => (await ref - .read(connectivityProvider.future) - .timeout(const Duration(seconds: 3), onTimeout: () => true)), - // SWR: yield cache instantly, then refresh in the background so the - // tab reflects the freshest plays. Matches homeProvider behavior. - alwaysRefresh: true, - tag: 'history', - ); -}); - -/// Encodes HistoryPage back to the wire-format JSON shape -/// /api/me/history emits, so HistoryPage.fromJson can round-trip -/// through the drift cache. -String _encodeHistoryPage(HistoryPage h) => jsonEncode({ - 'events': h.events - .map((e) => { - 'id': e.id, - 'played_at': e.playedAt, - 'track': { - 'id': e.track.id, - 'title': e.track.title, - 'album_id': e.track.albumId, - 'album_title': e.track.albumTitle, - 'artist_id': e.track.artistId, - 'artist_name': e.track.artistName, - 'track_number': e.track.trackNumber, - 'disc_number': e.track.discNumber, - 'duration_sec': e.track.durationSec, - 'stream_url': e.track.streamUrl, - }, - }) - .toList(), - 'has_more': h.hasMore, - }); - -// Per-item Liked tabs (Slice E of the per-item rendering pass). -// Each provider yields just the ordered list of entity IDs; the UI -// then renders per-tile widgets that hydrate each entity individually -// via albumTileProvider / artistTileProvider / trackTileProvider. -// -// Reads come from cached_likes (sync- and optimistic-write-populated), -// projected with ORDER BY likedAt DESC. fetchAndPopulate hits the -// cheap /api/likes/ids endpoint — the bulk /api/likes/* endpoints -// that returned fully denormalized entities are no longer needed for -// this path since tile providers handle entity hydration themselves. -// -// likedAt ordering note: cached_likes.likedAt is whatever drift -// assigned via currentDateAndTime when the row was first inserted -// (either by sync or LikesController). insertOrIgnore on subsequent -// fetches preserves the existing likedAt so ordering stays stable. -// Approximate but acceptable — matches prior Slice 3 behavior. - -List _idsForEntity(List rows) => - rows.map((r) => r.entityId).toList(growable: false); - -final _likedTrackIdsProvider = StreamProvider>((ref) { - final db = ref.watch(appDbProvider); - final query = (db.select(db.cachedLikes) - ..where((t) => t.entityType.equals('track')) - ..orderBy([(t) => drift.OrderingTerm.desc(t.likedAt)])) - .watch(); - return cacheFirst>( - driftStream: query, - fetchAndPopulate: () => _populateLikeIds(ref), - toResult: _idsForEntity, - isOnline: () async => (await ref - .read(connectivityProvider.future) - .timeout(const Duration(seconds: 3), onTimeout: () => true)), - alwaysRefresh: true, - tag: 'likedTrackIds', - ); -}); - -final _likedAlbumIdsProvider = StreamProvider>((ref) { - final db = ref.watch(appDbProvider); - final query = (db.select(db.cachedLikes) - ..where((t) => t.entityType.equals('album')) - ..orderBy([(t) => drift.OrderingTerm.desc(t.likedAt)])) - .watch(); - return cacheFirst>( - driftStream: query, - fetchAndPopulate: () => _populateLikeIds(ref), - toResult: _idsForEntity, - isOnline: () async => (await ref - .read(connectivityProvider.future) - .timeout(const Duration(seconds: 3), onTimeout: () => true)), - alwaysRefresh: true, - tag: 'likedAlbumIds', - ); -}); - -final _likedArtistIdsProvider = StreamProvider>((ref) { - final db = ref.watch(appDbProvider); - final query = (db.select(db.cachedLikes) - ..where((t) => t.entityType.equals('artist')) - ..orderBy([(t) => drift.OrderingTerm.desc(t.likedAt)])) - .watch(); - return cacheFirst>( - driftStream: query, - fetchAndPopulate: () => _populateLikeIds(ref), - toResult: _idsForEntity, - isOnline: () async => (await ref - .read(connectivityProvider.future) - .timeout(const Duration(seconds: 3), onTimeout: () => true)), - alwaysRefresh: true, - tag: 'likedArtistIds', - ); -}); - -/// Shared cold-cache populator: hits /api/likes/ids once and writes -/// rows for all three entity types via insertOrIgnore. The three -/// providers above all trigger this on their first empty-drift -/// emission; the dedup in cacheFirst's revalidate state plus drift's -/// insertOrIgnore semantics make the multiple-trigger case cheap. -Future _populateLikeIds(Ref ref) async { - final api = await ref.read(_likesApiProvider.future); - final user = ref.read(authControllerProvider).value; - if (user == null) return; - final fresh = await api.ids(); - final db = ref.read(appDbProvider); - await db.batch((b) { - for (final id in fresh.tracks) { - b.insert( - db.cachedLikes, - CachedLikesCompanion.insert( - userId: user.id, - entityType: 'track', - entityId: id, - ), - mode: drift.InsertMode.insertOrIgnore, - ); - } - for (final id in fresh.albums) { - b.insert( - db.cachedLikes, - CachedLikesCompanion.insert( - userId: user.id, - entityType: 'album', - entityId: id, - ), - mode: drift.InsertMode.insertOrIgnore, - ); - } - for (final id in fresh.artists) { - b.insert( - db.cachedLikes, - CachedLikesCompanion.insert( - userId: user.id, - entityType: 'artist', - entityId: id, - ), - mode: drift.InsertMode.insertOrIgnore, - ); - } - }); -} - -// Hidden tab uses the canonical `myQuarantineProvider` (AsyncNotifier with -// optimistic flag/unflag) so flagging from any kebab and unflagging from -// the Hidden tab keep one source of truth. - -class LibraryScreen extends ConsumerStatefulWidget { - const LibraryScreen({super.key}); - - @override - ConsumerState createState() => _LibraryScreenState(); -} - -class _LibraryScreenState extends ConsumerState - with TickerProviderStateMixin { - late final TabController _ctrl = TabController(length: 5, vsync: this); - - @override - void dispose() { - _ctrl.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final fs = Theme.of(context).extension()!; - // Pre-warm every tab's provider on Library mount so swiping - // between tabs feels instant rather than each tab paying its - // own cold-cache cost on first visit. ref.listen subscribes - // without rebuilding LibraryScreen on emit; subscriptions stay - // alive for the lifetime of this widget. cacheFirst handles - // dedupe of concurrent fetchAndPopulate triggers. - ref.listen(_libraryArtistsProvider, (_, __) {}); - ref.listen(_libraryAlbumsProvider, (_, __) {}); - ref.listen(_historyProvider, (_, __) {}); - ref.listen(_likedTrackIdsProvider, (_, __) {}); - ref.listen(_likedAlbumIdsProvider, (_, __) {}); - ref.listen(_likedArtistIdsProvider, (_, __) {}); - ref.listen(myQuarantineProvider, (_, __) {}); - return Scaffold( - backgroundColor: fs.obsidian, - appBar: AppBar( - backgroundColor: fs.obsidian, - elevation: 0, - title: Text('Library', style: TextStyle(color: fs.parchment)), - actions: [ - IconButton( - key: const Key('shuffle_all_button'), - tooltip: 'Shuffle all', - icon: Icon(LucideIcons.shuffle, color: fs.parchment), - onPressed: () async { - final messenger = ScaffoldMessenger.of(context); - final refs = await ref.read(shuffleSourceProvider).tracks(); - if (refs.isEmpty) { - messenger.showSnackBar(const SnackBar( - content: Text('Nothing to shuffle yet'), - )); - return; - } - await ref - .read(playerActionsProvider) - .playTracks(refs, shuffle: true); - }, - ), - const MainAppBarActions(currentRoute: '/library'), - ], - bottom: TabBar( - controller: _ctrl, - isScrollable: true, - tabAlignment: TabAlignment.start, - labelColor: fs.parchment, - unselectedLabelColor: fs.ash, - indicatorColor: fs.accent, - tabs: const [ - Tab(text: 'Artists'), - Tab(text: 'Albums'), - Tab(text: 'History'), - Tab(text: 'Liked'), - Tab(text: 'Hidden'), - ], - ), - ), - body: TabBarView( - controller: _ctrl, - children: const [ - _ArtistsTab(), - _AlbumsTab(), - _HistoryTab(), - _LikedTab(), - _HiddenTab(), - ], - ), - ); - } -} - -class _ArtistsTab extends ConsumerWidget { - const _ArtistsTab(); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final fs = Theme.of(context).extension()!; - return ref.watch(_libraryArtistsProvider).when( - loading: () => const Center(child: CircularProgressIndicator()), - error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))), - data: (artists) { - // Warm details for the first screenful so taps are instant. - ref - .read(metadataPrefetcherProvider) - .warmArtists(artists.map((a) => a.id)); - return artists.isEmpty - ? Center(child: Text("No artists yet — scan a library folder via the server's config.", style: TextStyle(color: fs.ash), textAlign: TextAlign.center)) - : RefreshIndicator( - onRefresh: () async => ref.refresh(_libraryArtistsProvider.future), - // LayoutBuilder + cell-aware ArtistCard width mirrors - // the AlbumsTab pattern so the circular avatar stays - // a true circle on narrow grid cells. - // - // Drift-first: GridView holds the full sorted list; - // .builder lazily realizes only visible cells, so - // even on large libraries the up-front cost is just - // a sort over cached_artists, not N network round - // trips. - child: LayoutBuilder(builder: (ctx, constraints) { - const cols = 3; - const sidePad = 8.0; - const gap = 8.0; - final cellW = (constraints.maxWidth - - sidePad * 2 - - gap * (cols - 1)) / - cols; - // avatar (cellW - 16) + gap (8) + 1 line name (~18) - // + slack matched to AlbumsTab's overflow guard. - final cellH = (cellW - 16) + 8 + 18 + 8; - return GridView.builder( - padding: const EdgeInsets.all(sidePad), - gridDelegate: - SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: cols, - mainAxisExtent: cellH, - mainAxisSpacing: gap, - crossAxisSpacing: gap, - ), - itemCount: artists.length, - itemBuilder: (ctx, i) { - final artist = artists[i]; - return ArtistCard( - artist: artist, - width: cellW, - onTap: () => ctx.push('/artists/${artist.id}', - extra: artist), - ); - }, - ); - }), - ); - }, - ); - } -} - -class _AlbumsTab extends ConsumerWidget { - const _AlbumsTab(); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final fs = Theme.of(context).extension()!; - return ref.watch(_libraryAlbumsProvider).when( - loading: () => const Center(child: CircularProgressIndicator()), - error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))), - data: (albums) { - return albums.isEmpty - ? Center(child: Text("No albums yet — scan a library folder via the server's config.", style: TextStyle(color: fs.ash), textAlign: TextAlign.center)) - : RefreshIndicator( - onRefresh: () async => ref.refresh(_libraryAlbumsProvider.future), - // Same responsive 3-up grid as the artist detail - // album list — LayoutBuilder + AlbumCard sized to the - // cell, mainAxisExtent matched to actual card height. - // Drift-first; full sorted list, lazy realization via - // GridView.builder. - child: LayoutBuilder(builder: (ctx, constraints) { - const cols = 3; - const sidePad = 8.0; - const gap = 8.0; - final cellW = (constraints.maxWidth - - sidePad * 2 - - gap * (cols - 1)) / - cols; - // cover (cellW - 16) + gap (8) + 2-line title (~36) - // + artist line (~16) + slack. Slack is generous on - // purpose — line-height + font scaling variations - // would otherwise overflow the cell by a pixel - // (logged as a noisy RenderFlex warning). - final cellH = (cellW - 16) + 8 + 36 + 16 + 8; - return GridView.builder( - padding: const EdgeInsets.all(sidePad), - gridDelegate: - SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: cols, - mainAxisExtent: cellH, - mainAxisSpacing: gap, - crossAxisSpacing: gap, - ), - itemCount: albums.length, - itemBuilder: (ctx, i) { - final album = albums[i]; - return AlbumCard( - album: album, - width: cellW, - titleMaxLines: 2, - onTap: () => ctx.push('/albums/${album.id}', - extra: album), - ); - }, - ); - }), - ); - }, - ); - } -} - -class _HistoryTab extends ConsumerWidget { - const _HistoryTab(); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final fs = Theme.of(context).extension()!; - return ref.watch(_historyProvider).when( - loading: () => const Center(child: CircularProgressIndicator()), - error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))), - data: (page) => page.events.isEmpty - ? Center(child: Text('No listening history yet.', style: TextStyle(color: fs.ash))) - : RefreshIndicator( - onRefresh: () async => ref.refresh(_historyProvider.future), - child: ListView.separated( - itemCount: page.events.length, - separatorBuilder: (_, __) => Divider(height: 1, color: fs.iron), - itemBuilder: (ctx, i) { - final event = page.events[i]; - return TrackRow( - track: event.track, - onTap: () => ref - .read(playerActionsProvider) - .playTracks([event.track]), - trailing: Text( - _relativeTime(event.playedAt), - style: TextStyle(color: fs.ash, fontSize: 12), - ), - ); - }, - ), - ), - ); - } -} - -class _LikedTab extends ConsumerWidget { - const _LikedTab(); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final fs = Theme.of(context).extension()!; - // SSE wire-up: any cross-device like / unlike triggers a refresh - // of the discovery providers. LikesController handles local - // mutations optimistically through the same cached_likes table, - // so toggling a like locally re-emits the streams instantly - // without needing an invalidate here. - ref.listen>(liveEventsProvider, (_, next) { - final e = next.asData?.value; - if (e == null) return; - switch (e.kind) { - case 'track.liked': - case 'track.unliked': - case 'album.liked': - case 'album.unliked': - case 'artist.liked': - case 'artist.unliked': - ref.invalidate(_likedTrackIdsProvider); - ref.invalidate(_likedAlbumIdsProvider); - ref.invalidate(_likedArtistIdsProvider); - } - }); - final tracksA = ref.watch(_likedTrackIdsProvider); - final albumsA = ref.watch(_likedAlbumIdsProvider); - final artistsA = ref.watch(_likedArtistIdsProvider); - - if (tracksA.isLoading || albumsA.isLoading || artistsA.isLoading) { - return const Center(child: CircularProgressIndicator()); - } - final trackIds = tracksA.value; - final albumIds = albumsA.value; - final artistIds = artistsA.value; - if (trackIds == null || albumIds == null || artistIds == null) { - return Center(child: Text("Couldn't load liked items.", style: TextStyle(color: fs.error))); - } - if (trackIds.isEmpty && albumIds.isEmpty && artistIds.isEmpty) { - return Center(child: Text('No liked artists, albums, or tracks yet.', style: TextStyle(color: fs.ash), textAlign: TextAlign.center)); - } - return RefreshIndicator( - onRefresh: () async { - await Future.wait([ - ref.refresh(_likedTrackIdsProvider.future), - ref.refresh(_likedAlbumIdsProvider.future), - ref.refresh(_likedArtistIdsProvider.future), - ]); - }, - child: ListView(children: [ - if (artistIds.isNotEmpty) ...[ - _SectionHeader(label: 'Artists', count: artistIds.length), - SizedBox( - height: 168, - child: ListView.builder( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric(horizontal: 8), - itemCount: artistIds.length, - itemBuilder: (ctx, i) => - _LikedArtistTile(id: artistIds[i]), - ), - ), - ], - if (albumIds.isNotEmpty) ...[ - _SectionHeader(label: 'Albums', count: albumIds.length), - SizedBox( - height: 200, - child: ListView.builder( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric(horizontal: 8), - itemCount: albumIds.length, - itemBuilder: (ctx, i) => - _LikedAlbumTile(id: albumIds[i]), - ), - ), - ], - if (trackIds.isNotEmpty) ...[ - _SectionHeader(label: 'Tracks', count: trackIds.length), - ...trackIds.map( - (id) => _LikedTrackRow(id: id, sectionIds: trackIds), - ), - ], - const SizedBox(height: 96), - ]), - ); - } -} - -/// Skeleton→content cross-fade duration. Matches home_screen so the -/// reveal feel is consistent across surfaces. See _tileRevealDuration -/// in home_screen.dart for the rationale. -const Duration _likedTileReveal = Duration(milliseconds: 220); - -/// Liked-Artists carousel tile. Skeleton until artistTileProvider -/// yields a populated row. -class _LikedArtistTile extends ConsumerWidget { - const _LikedArtistTile({required this.id}); - final String id; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final artist = ref.watch(artistTileProvider(id)).asData?.value; - return AnimatedSwitcher( - duration: _likedTileReveal, - switchInCurve: Curves.easeOut, - child: artist == null - ? const SkeletonArtistTile(key: ValueKey('skeleton')) - : ArtistCard( - key: ValueKey('artist-${artist.id}'), - artist: artist, - onTap: () => - context.push('/artists/${artist.id}', extra: artist), - ), - ); - } -} - -/// Liked-Albums carousel tile. Skeleton until albumTileProvider -/// yields a populated row. -class _LikedAlbumTile extends ConsumerWidget { - const _LikedAlbumTile({required this.id}); - final String id; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final album = ref.watch(albumTileProvider(id)).asData?.value; - return AnimatedSwitcher( - duration: _likedTileReveal, - switchInCurve: Curves.easeOut, - child: album == null - ? const SkeletonAlbumTile(key: ValueKey('skeleton')) - : AlbumCard( - key: ValueKey('album-${album.id}'), - album: album, - onTap: () => context.push('/albums/${album.id}', extra: album), - ), - ); - } -} - -/// Liked-Tracks list row. Skeleton until trackTileProvider yields a -/// populated row. Tap plays the section starting at this track, -/// using whichever tracks are currently hydrated; still-loading -/// tracks are skipped from the play queue and join on next rebuild. -class _LikedTrackRow extends ConsumerWidget { - const _LikedTrackRow({required this.id, required this.sectionIds}); - final String id; - final List sectionIds; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final track = ref.watch(trackTileProvider(id)).asData?.value; - return AnimatedSwitcher( - duration: _likedTileReveal, - switchInCurve: Curves.easeOut, - child: track == null - ? const SkeletonTrackRow(key: ValueKey('skeleton')) - : TrackRow( - key: ValueKey('track-${track.id}'), - track: track, - onTap: () { - final hydrated = []; - for (final i in sectionIds) { - final v = ref.read(trackTileProvider(i)).asData?.value; - if (v != null) hydrated.add(v); - } - final start = hydrated.indexWhere((t) => t.id == id); - ref.read(playerActionsProvider).playTracks( - hydrated, - initialIndex: start < 0 ? 0 : start, - ); - }, - ), - ); - } -} - -class _HiddenTab extends ConsumerWidget { - const _HiddenTab(); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final fs = Theme.of(context).extension()!; - return ref.watch(myQuarantineProvider).when( - loading: () => const Center(child: CircularProgressIndicator()), - error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))), - data: (rows) => rows.isEmpty - ? Center( - child: Text( - 'No hidden tracks.\nUse a track\'s menu to flag bad rips or wrong tags.', - textAlign: TextAlign.center, - style: TextStyle(color: fs.ash), - ), - ) - : RefreshIndicator( - onRefresh: () async => ref.refresh(myQuarantineProvider.future), - child: ListView.separated( - itemCount: rows.length, - separatorBuilder: (_, __) => Divider(height: 1, color: fs.iron), - itemBuilder: (ctx, i) => _QuarantineTile(row: rows[i]), - ), - ), - ); - } -} - -class _QuarantineTile extends ConsumerWidget { - const _QuarantineTile({required this.row}); - final QuarantineMineRow row; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final fs = Theme.of(context).extension()!; - final reasonLabel = switch (row.reason) { - 'bad_rip' => 'Bad rip', - 'wrong_file' => 'Wrong file', - 'wrong_tags' => 'Wrong tags', - 'duplicate' => 'Duplicate', - _ => 'Other', - }; - final coverUrl = row.albumId.isNotEmpty ? '/api/albums/${row.albumId}/cover' : ''; - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), - child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ - ClipRRect( - borderRadius: BorderRadius.circular(4), - child: SizedBox( - width: 56, - height: 56, - child: coverUrl.isEmpty - ? Container(color: fs.slate) - : ServerImage( - url: coverUrl, - fit: BoxFit.cover, - fallback: Container(color: fs.slate), - ), - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - row.trackTitle, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle(color: fs.parchment, fontSize: 14), - ), - Text( - '${row.artistName} · ${row.albumTitle}', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle(color: fs.ash, fontSize: 12), - ), - Padding( - padding: const EdgeInsets.only(top: 4), - child: Row(children: [ - Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: fs.iron, - borderRadius: BorderRadius.circular(4), - ), - child: Text(reasonLabel, style: TextStyle(color: fs.ash, fontSize: 11)), - ), - if (row.createdAt.isNotEmpty) ...[ - const SizedBox(width: 8), - Text(_relativeTime(row.createdAt), - style: TextStyle(color: fs.ash, fontSize: 11)), - ], - ]), - ), - ]), - ), - IconButton( - tooltip: 'Unhide', - icon: Icon(LucideIcons.archive_restore, color: fs.ash, size: 20), - onPressed: () async { - try { - await ref.read(myQuarantineProvider.notifier).unflag(row.trackId); - } catch (_) { - // Optimistic rollback handled by the notifier; surface - // the failure only if the user kept the tab open. - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Could not unhide; try again.')), - ); - } - } - }, - ), - ]), - ); - } -} - -class _SectionHeader extends StatelessWidget { - const _SectionHeader({required this.label, required this.count}); - final String label; - final int count; - - @override - Widget build(BuildContext context) { - final fs = Theme.of(context).extension()!; - return Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), - child: Row(children: [ - Text(label, - style: TextStyle( - color: fs.parchment, fontSize: 18, fontWeight: FontWeight.w500)), - const SizedBox(width: 8), - Text('$count', style: TextStyle(color: fs.ash, fontSize: 13)), - ]), - ); - } -} - -/// Lightweight relative-time formatter: <1h "23m ago" / <24h "3h ago" -/// / <7d "Tue 14:32" / older "May 1" / older diff-year "May 1, 2025". -/// Mirrors web/src/lib/components/HistoryRow.svelte's relativeTime. -String _relativeTime(String iso) { - final t = DateTime.tryParse(iso); - if (t == null) return ''; - final now = DateTime.now(); - final diff = now.difference(t); - if (diff.inMinutes < 60) { - final m = diff.inMinutes < 1 ? 1 : diff.inMinutes; - return '${m}m ago'; - } - if (diff.inHours < 24) return '${diff.inHours}h ago'; - if (diff.inDays < 7) { - const days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; - final hh = t.hour.toString().padLeft(2, '0'); - final mm = t.minute.toString().padLeft(2, '0'); - return '${days[t.weekday - 1]} $hh:$mm'; - } - const months = [ - 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', - 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec', - ]; - if (t.year == now.year) return '${months[t.month - 1]} ${t.day}'; - return '${months[t.month - 1]} ${t.day}, ${t.year}'; -} diff --git a/flutter_client/lib/library/widgets/album_card.dart b/flutter_client/lib/library/widgets/album_card.dart deleted file mode 100644 index 533b84a8..00000000 --- a/flutter_client/lib/library/widgets/album_card.dart +++ /dev/null @@ -1,112 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_svg/flutter_svg.dart'; - -import '../../models/album.dart'; -import '../../player/player_provider.dart'; -import '../../shared/widgets/server_image.dart'; -import '../../theme/theme_extension.dart'; -import '../library_providers.dart'; -import 'play_circle_button.dart'; - -class AlbumCard extends ConsumerWidget { - const AlbumCard({ - required this.album, - required this.onTap, - this.width = 140, - this.titleMaxLines = 1, - this.showArtist = true, - super.key, - }); - final AlbumRef album; - final VoidCallback onTap; - - /// Outer width of the card. Cover is square at width - 16 (8dp - /// horizontal padding either side). Default suits horizontal lists; - /// grids should pass the cell width so the card scales down. - final double width; - - /// Lets callers (e.g. the artist detail grid) allow the title to - /// wrap to a second line so it isn't truncated to a single - /// ellipsized character at narrower widths. - final int titleMaxLines; - - /// Suppress the artist name. Useful on surfaces where the artist is - /// already implied by the page header (e.g. artist detail). - final bool showArtist; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final fs = Theme.of(context).extension()!; - final coverSize = width - 16; - return SizedBox( - width: width, - child: Material( - color: Colors.transparent, - child: InkWell( - onTap: onTap, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - // Stack: cover image + overlaid play button at bottom-right. - Stack( - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(6), - child: Container( - width: coverSize, - height: coverSize, - color: fs.slate, - child: album.coverUrl.isEmpty - ? SvgPicture.asset('assets/svg/album-fallback.svg', - fit: BoxFit.cover) - : ServerImage(url: album.coverUrl, fit: BoxFit.cover), - ), - ), - Positioned( - bottom: 6, - right: 6, - child: PlayCircleButton( - onPressed: () => _playAlbum(ref), - ), - ), - ], - ), - const SizedBox(height: 8), - Text( - album.title, - maxLines: titleMaxLines, - overflow: TextOverflow.ellipsis, - style: TextStyle(color: fs.parchment, fontSize: 14), - ), - if (showArtist && album.artistName.isNotEmpty) - Text( - album.artistName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle(color: fs.ash, fontSize: 12), - ), - ], - ), - ), - ), - ), - ); - } - - /// Fetches the album's tracks via /api/albums/{id} and starts playback - /// from the first track. Errors are swallowed by the button's outer - /// try/finally; callers don't surface them — failed fetches just keep - /// the spinner visible until the button is retapped. - Future _playAlbum(WidgetRef ref) async { - final api = await ref.read(libraryApiProvider.future); - final result = await api.getAlbum(album.id); - if (result.tracks.isEmpty) return; - await ref - .read(playerActionsProvider) - .playTracks(result.tracks, initialIndex: 0); - } -} diff --git a/flutter_client/lib/library/widgets/artist_card.dart b/flutter_client/lib/library/widgets/artist_card.dart deleted file mode 100644 index fed8c74f..00000000 --- a/flutter_client/lib/library/widgets/artist_card.dart +++ /dev/null @@ -1,101 +0,0 @@ -import 'dart:math'; - -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_svg/flutter_svg.dart'; - -import '../../models/artist.dart'; -import '../../player/player_provider.dart'; -import '../../shared/widgets/server_image.dart'; -import '../../theme/theme_extension.dart'; -import '../library_providers.dart'; -import 'play_circle_button.dart'; - -class ArtistCard extends ConsumerWidget { - const ArtistCard({ - required this.artist, - required this.onTap, - this.width = 140, - super.key, - }); - final ArtistRef artist; - final VoidCallback onTap; - - /// Outer width of the card. Avatar is a circle of width-16 (8dp - /// horizontal padding either side). Default suits horizontal lists; - /// grids should pass the cell width so the avatar shrinks - /// proportionally and stays a true circle. - final double width; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final fs = Theme.of(context).extension()!; - final coverSize = width - 16; - return SizedBox( - width: width, - child: Material( - color: Colors.transparent, - child: InkWell( - onTap: onTap, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Stack: circular avatar + overlaid play button at bottom-right. - // Avatar size derives from the [width] parameter so the - // Library Artists 3-column grid can pass its cell width - // (~109dp on typical phones) and the avatar stays a - // perfect circle. Previous hardcoded 124×124 got squeezed - // horizontally in the grid (cell narrower than 124+16 - // padding) but kept its 124dp height — ClipOval produced - // a visible vertical ellipse. Mirrors AlbumCard's width- - // parameter convention. - Stack( - children: [ - ClipOval( - child: Container( - width: coverSize, - height: coverSize, - color: fs.slate, - child: artist.coverUrl.isEmpty - ? SvgPicture.asset('assets/svg/album-fallback.svg', - fit: BoxFit.cover) - : ServerImage(url: artist.coverUrl, fit: BoxFit.cover), - ), - ), - Positioned( - bottom: 4, - right: 4, - child: PlayCircleButton( - onPressed: () => _playArtistShuffle(ref), - ), - ), - ], - ), - const SizedBox(height: 8), - Text( - artist.name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle(color: fs.parchment, fontSize: 14), - ), - ]), - ), - ), - ), - ); - } - - /// Fetches the artist's tracks via /api/artists/{id}/tracks, shuffles - /// them (Fisher-Yates, default Random), and plays from index 0. - /// Matches the web ArtistCard's `playQueue(shuffle(tracks), 0)`. - Future _playArtistShuffle(WidgetRef ref) async { - final api = await ref.read(libraryApiProvider.future); - final tracks = await api.getArtistTracks(artist.id); - if (tracks.isEmpty) return; - final shuffled = List.of(tracks); - shuffled.shuffle(Random()); - await ref - .read(playerActionsProvider) - .playTracks(shuffled, initialIndex: 0); - } -} diff --git a/flutter_client/lib/library/widgets/cached_indicator.dart b/flutter_client/lib/library/widgets/cached_indicator.dart deleted file mode 100644 index 685aabc5..00000000 --- a/flutter_client/lib/library/widgets/cached_indicator.dart +++ /dev/null @@ -1,30 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_lucide/flutter_lucide.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../../cache/audio_cache_manager.dart'; -import '../../theme/theme_extension.dart'; - -/// Small download glyph shown next to a track row when the track is -/// cached locally. FutureBuilder one-shot — track rows are short-lived -/// and cache state rarely changes mid-render. -class CachedIndicator extends ConsumerWidget { - const CachedIndicator({required this.trackId, super.key}); - final String trackId; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final mgr = ref.read(audioCacheManagerProvider); - final fs = Theme.of(context).extension()!; - return FutureBuilder( - future: mgr.isCached(trackId), - builder: (ctx, snap) { - if (snap.data != true) return const SizedBox.shrink(); - return Padding( - padding: const EdgeInsets.only(left: 4), - child: Icon(LucideIcons.circle_check_big, size: 14, color: fs.accent), - ); - }, - ); - } -} diff --git a/flutter_client/lib/library/widgets/compact_track_card.dart b/flutter_client/lib/library/widgets/compact_track_card.dart deleted file mode 100644 index 4e53d7b3..00000000 --- a/flutter_client/lib/library/widgets/compact_track_card.dart +++ /dev/null @@ -1,84 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../../models/track.dart'; -import '../../player/player_provider.dart'; -import '../../shared/widgets/server_image.dart'; -import '../../shared/widgets/track_actions/track_actions_button.dart'; -import '../../theme/theme_extension.dart'; - -/// Small horizontal track cell used by the home Most-played section. -/// Mirrors the web CompactTrackCard sizing (~176dp wide, ~56dp tall). -/// Tap plays from this track within [sectionTracks] starting at [index]. -class CompactTrackCard extends ConsumerWidget { - const CompactTrackCard({ - super.key, - required this.track, - required this.sectionTracks, - required this.index, - }); - - final TrackRef track; - - /// All tracks in the home section so play-from-here can queue them - /// in order. - final List sectionTracks; - final int index; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final fs = Theme.of(context).extension()!; - // Cover URL derives from the track's album. Mirrors web - // CompactTrackCard which calls coverUrl(track.album_id). - final coverUrl = track.albumId.isNotEmpty - ? '/api/albums/${track.albumId}/cover' - : ''; - return SizedBox( - width: 176, - child: InkWell( - onTap: () => ref - .read(playerActionsProvider) - .playTracks(sectionTracks, initialIndex: index), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - child: Row(children: [ - ClipRRect( - borderRadius: BorderRadius.circular(4), - child: SizedBox( - width: 48, - height: 48, - child: ServerImage( - url: coverUrl, - fit: BoxFit.cover, - fallback: Container(color: fs.slate), - ), - ), - ), - const SizedBox(width: 8), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - track.title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle(color: fs.parchment, fontSize: 13), - ), - Text( - track.artistName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle(color: fs.ash, fontSize: 11), - ), - ], - ), - ), - TrackActionsButton(track: track), - ]), - ), - ), - ); - } -} diff --git a/flutter_client/lib/library/widgets/horizontal_scroll_row.dart b/flutter_client/lib/library/widgets/horizontal_scroll_row.dart deleted file mode 100644 index b20f87d5..00000000 --- a/flutter_client/lib/library/widgets/horizontal_scroll_row.dart +++ /dev/null @@ -1,49 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../../theme/theme_extension.dart'; - -/// Mirrors the web HorizontalScrollRow: a labeled section that scrolls -/// horizontally. Multi-row sections share one [controller] so they -/// scroll together. -class HorizontalScrollRow extends StatelessWidget { - const HorizontalScrollRow({ - required this.title, - required this.children, - this.height = 200, - this.controller, - super.key, - }); - - final String title; - final List children; - final double height; - final ScrollController? controller; - - @override - Widget build(BuildContext context) { - final fs = Theme.of(context).extension()!; - return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (title.isNotEmpty) - Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), - child: Text( - title, - style: TextStyle( - fontFamily: fs.display.fontFamily, - fontSize: 18, - color: fs.parchment, - ), - ), - ), - SizedBox( - height: height, - child: ListView( - controller: controller, - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric(horizontal: 16), - children: children, - ), - ), - ]); - } -} diff --git a/flutter_client/lib/library/widgets/play_circle_button.dart b/flutter_client/lib/library/widgets/play_circle_button.dart deleted file mode 100644 index acfb0562..00000000 --- a/flutter_client/lib/library/widgets/play_circle_button.dart +++ /dev/null @@ -1,97 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_lucide/flutter_lucide.dart'; - -import '../../theme/theme_extension.dart'; - -/// Always-visible 44dp circular play button overlaid on home-screen -/// card art (AlbumCard / ArtistCard / PlaylistCard). Mirrors the -/// hover-revealed `.play-overlay` on the web cards, but always shown -/// because hover is not a real interaction on touch. -/// -/// Manages its own loading state via [_starting] so the caller's tap -/// handler can be `Future Function()` without worrying about -/// re-entrancy. Disabled state suppresses the tap (used for empty -/// playlists). -class PlayCircleButton extends StatefulWidget { - const PlayCircleButton({ - required this.onPressed, - this.enabled = true, - this.size = 44, - super.key, - }); - - /// Tap handler. Returns a Future so the button can show a spinner - /// during the play setup (fetch detail tracks, etc.). - final Future Function() onPressed; - - /// When false, the button is rendered semi-transparent and taps are - /// ignored. Empty playlists / artists with no tracks should disable. - final bool enabled; - - /// Outer diameter in logical pixels. 44 is the iOS / Android - /// touch-target minimum; matches the design doc. - final double size; - - @override - State createState() => _PlayCircleButtonState(); -} - -class _PlayCircleButtonState extends State { - bool _starting = false; - - Future _handleTap() async { - if (_starting || !widget.enabled) return; - setState(() => _starting = true); - try { - await widget.onPressed(); - } finally { - if (mounted) setState(() => _starting = false); - } - } - - @override - Widget build(BuildContext context) { - final fs = Theme.of(context).extension()!; - final iconSize = widget.size * 0.5; - return Material( - color: Colors.transparent, - shape: const CircleBorder(), - child: InkResponse( - onTap: widget.enabled ? _handleTap : null, - radius: widget.size / 2, - containedInkWell: true, - customBorder: const CircleBorder(), - child: Container( - width: widget.size, - height: widget.size, - decoration: BoxDecoration( - color: fs.accent.withValues(alpha: widget.enabled ? 1.0 : 0.5), - shape: BoxShape.circle, - boxShadow: const [ - BoxShadow( - color: Color(0x66000000), - blurRadius: 6, - offset: Offset(0, 2), - ), - ], - ), - alignment: Alignment.center, - child: _starting - ? SizedBox( - width: iconSize, - height: iconSize, - child: CircularProgressIndicator( - strokeWidth: 2, - valueColor: AlwaysStoppedAnimation(fs.parchment), - ), - ) - : Icon( - LucideIcons.play, - color: fs.parchment, - size: iconSize, - ), - ), - ), - ); - } -} diff --git a/flutter_client/lib/library/widgets/track_row.dart b/flutter_client/lib/library/widgets/track_row.dart deleted file mode 100644 index 48c97e4e..00000000 --- a/flutter_client/lib/library/widgets/track_row.dart +++ /dev/null @@ -1,100 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../../models/track.dart'; -import '../../player/player_provider.dart'; -import '../../shared/widgets/track_actions/track_actions_button.dart'; -import '../../theme/theme_extension.dart'; -import 'cached_indicator.dart'; - -class TrackRow extends ConsumerWidget { - const TrackRow({ - required this.track, - required this.onTap, - this.trailing, - this.actions = true, - super.key, - }); - final TrackRef track; - final VoidCallback onTap; - final Widget? trailing; - - /// Render the 3-dot TrackActionsButton at the end of the row. Default - /// true; pass false in surfaces that don't want the menu (e.g. when - /// showing a static read-only list). - final bool actions; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final fs = Theme.of(context).extension()!; - final mins = (track.durationSec ~/ 60).toString().padLeft(2, '0'); - final secs = (track.durationSec % 60).toString().padLeft(2, '0'); - // Watch the currently-playing media item so the row's accent - // highlight tracks playback state. Matches _QueueRow's visual - // treatment in queue_screen.dart so the "you are here" cue is - // consistent across album / playlist / queue surfaces. - final currentId = ref.watch(mediaItemProvider).value?.id; - final isCurrent = currentId != null && currentId == track.id; - return Container( - decoration: BoxDecoration( - color: isCurrent ? fs.iron : null, - border: isCurrent - ? Border(left: BorderSide(color: fs.accent, width: 2)) - : null, - ), - child: InkWell( - onTap: onTap, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), - child: Row(children: [ - if (track.trackNumber != null) - SizedBox( - width: 22, - child: Text( - track.trackNumber.toString(), - style: TextStyle(color: fs.ash, fontSize: 13), - ), - ), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - track.title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: isCurrent ? fs.accent : fs.parchment, - fontSize: 14, - fontWeight: - isCurrent ? FontWeight.w500 : FontWeight.w400, - ), - ), - // Skip the artist line entirely when empty so the row - // height collapses to a single line of title — keeps - // dense album views from looking padded. - if (track.artistName.isNotEmpty) - Text( - track.artistName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle(color: fs.ash, fontSize: 12), - ), - ], - ), - ), - CachedIndicator(trackId: track.id), - Text('$mins:$secs', style: TextStyle(color: fs.ash, fontSize: 12)), - if (trailing != null) - Padding( - padding: const EdgeInsets.only(left: 8), - child: trailing!, - ), - if (actions) TrackActionsButton(track: track), - ]), - ), - ), - ); - } -} diff --git a/flutter_client/lib/likes/like_button.dart b/flutter_client/lib/likes/like_button.dart deleted file mode 100644 index 729d15da..00000000 --- a/flutter_client/lib/likes/like_button.dart +++ /dev/null @@ -1,37 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../api/endpoints/likes.dart'; -import '../shared/widgets/lucide_heart.dart'; -import '../theme/theme_extension.dart'; -import 'likes_provider.dart'; - -class LikeButton extends ConsumerWidget { - const LikeButton({ - required this.kind, - required this.id, - this.size = 22, - super.key, - }); - final LikeKind kind; - final String id; - final double size; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final fs = Theme.of(context).extension()!; - final state = ref.watch(likedIdsProvider); - final liked = state.maybeWhen( - data: (s) => s.has(kind, id), - orElse: () => false, - ); - return IconButton( - icon: LucideHeart( - filled: liked, - color: liked ? fs.accent : fs.ash, - size: size, - ), - onPressed: () => ref.read(likesControllerProvider).toggle(kind, id), - ); - } -} diff --git a/flutter_client/lib/likes/likes_provider.dart b/flutter_client/lib/likes/likes_provider.dart deleted file mode 100644 index ed0bcb3a..00000000 --- a/flutter_client/lib/likes/likes_provider.dart +++ /dev/null @@ -1,174 +0,0 @@ -import 'package:drift/drift.dart' as drift; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../api/endpoints/likes.dart'; -import '../auth/auth_provider.dart'; -import '../cache/audio_cache_manager.dart' show appDbProvider; -import '../cache/cache_first.dart'; -import '../cache/connectivity_provider.dart'; -import '../cache/db.dart'; -import '../cache/mutation_queue.dart'; -import '../library/library_providers.dart'; - -final likesApiProvider = FutureProvider((ref) async { - return LikesApi(await ref.watch(dioProvider.future)); -}); - -class LikedIds { - const LikedIds({ - required this.artists, - required this.albums, - required this.tracks, - }); - final Set artists; - final Set albums; - final Set tracks; - - bool has(LikeKind kind, String id) => switch (kind) { - LikeKind.artist => artists.contains(id), - LikeKind.album => albums.contains(id), - LikeKind.track => tracks.contains(id), - }; - - static const empty = LikedIds(artists: {}, albums: {}, tracks: {}); -} - -/// Drift-first per #357 plan C. Reads from cached_likes (populated by -/// SyncController). Reactive — sync writes propagate via watch(). -/// Empty + online triggers REST cold-cache fallback that re-populates. -final likedIdsProvider = StreamProvider((ref) { - final db = ref.watch(appDbProvider); - return cacheFirst( - driftStream: db.select(db.cachedLikes).watch(), - fetchAndPopulate: () async { - final api = await ref.read(likesApiProvider.future); - final fresh = await api.ids(); - // Need a userId for the composite primary key. The auth controller - // exposes the current user. - final user = ref.read(authControllerProvider).value; - if (user == null) return; // not logged in; skip - await db.batch((b) { - for (final id in fresh.tracks) { - b.insert( - db.cachedLikes, - CachedLikesCompanion.insert( - userId: user.id, - entityType: 'track', - entityId: id, - ), - mode: drift.InsertMode.insertOrIgnore, - ); - } - for (final id in fresh.albums) { - b.insert( - db.cachedLikes, - CachedLikesCompanion.insert( - userId: user.id, - entityType: 'album', - entityId: id, - ), - mode: drift.InsertMode.insertOrIgnore, - ); - } - for (final id in fresh.artists) { - b.insert( - db.cachedLikes, - CachedLikesCompanion.insert( - userId: user.id, - entityType: 'artist', - entityId: id, - ), - mode: drift.InsertMode.insertOrIgnore, - ); - } - }); - }, - toResult: (rows) => LikedIds( - tracks: rows - .where((r) => r.entityType == 'track') - .map((r) => r.entityId) - .toSet(), - albums: rows - .where((r) => r.entityType == 'album') - .map((r) => r.entityId) - .toSet(), - artists: rows - .where((r) => r.entityType == 'artist') - .map((r) => r.entityId) - .toSet(), - ), - isOnline: () async => (await ref.read(connectivityProvider.future)), - ); -}); - -/// Mutation controller. Writes optimistically to drift first (so the -/// likedIdsProvider stream re-emits immediately for snappy UI), then to -/// REST. Rolls back drift on REST failure. -class LikesController { - LikesController(this._ref); - final Ref _ref; - - Future toggle(LikeKind kind, String id) async { - final user = _ref.read(authControllerProvider).value; - if (user == null) return; - final db = _ref.read(appDbProvider); - final entityType = _entityType(kind); - - final existing = await (db.select(db.cachedLikes) - ..where((t) => - t.userId.equals(user.id) & - t.entityType.equals(entityType) & - t.entityId.equals(id))) - .getSingleOrNull(); - final wasLiked = existing != null; - - // Optimistic mutation — drift watch() re-emits immediately. - if (wasLiked) { - await (db.delete(db.cachedLikes) - ..where((t) => - t.userId.equals(user.id) & - t.entityType.equals(entityType) & - t.entityId.equals(id))) - .go(); - } else { - await db.into(db.cachedLikes).insert( - CachedLikesCompanion.insert( - userId: user.id, - entityType: entityType, - entityId: id, - ), - mode: drift.InsertMode.insertOrIgnore, - ); - } - - try { - final api = await _ref.read(likesApiProvider.future); - if (wasLiked) { - await api.unlike(kind, id); - } else { - await api.like(kind, id); - } - } catch (_) { - // REST failed (network or HTTP). Don't roll back drift — the - // user's intent is to like/unlike, and we want that visible to - // them even when offline. Queue the call for replay; the - // MutationReplayer will retry on next connectivity transition. - // If retries exhaust (5 attempts), the row drops and the next - // SyncController.sync brings drift back in line with the - // server's authoritative state. - await _ref.read(mutationQueueProvider).enqueue( - wasLiked ? MutationKinds.likeRemove : MutationKinds.likeAdd, - {'kind': entityType, 'id': id}, - ); - } - } - - String _entityType(LikeKind kind) => switch (kind) { - LikeKind.artist => 'artist', - LikeKind.album => 'album', - LikeKind.track => 'track', - }; -} - -final likesControllerProvider = - Provider((ref) => LikesController(ref)); diff --git a/flutter_client/lib/main.dart b/flutter_client/lib/main.dart deleted file mode 100644 index 88d5cec9..00000000 --- a/flutter_client/lib/main.dart +++ /dev/null @@ -1,30 +0,0 @@ -import 'package:audio_service/audio_service.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import 'app.dart'; -import 'player/audio_handler.dart'; -import 'player/player_provider.dart'; - -Future main() async { - WidgetsFlutterBinding.ensureInitialized(); - final handler = await AudioService.init( - builder: () => MinstrelAudioHandler(), - config: const AudioServiceConfig( - androidNotificationChannelId: 'com.fabledsword.minstrel.audio', - androidNotificationChannelName: 'Minstrel playback', - androidNotificationOngoing: true, - // 8a: hand external surfaces (notification / lock screen / Wear) - // a downscaled cover instead of full-res album art — smaller - // payload, faster paint, lower memory. 300px is ample for those - // targets. preloadArtwork warms it so the first paint isn't blank. - artDownscaleWidth: 300, - artDownscaleHeight: 300, - preloadArtwork: true, - ), - ); - runApp(ProviderScope( - overrides: [audioHandlerProvider.overrideWithValue(handler)], - child: const MinstrelApp(), - )); -} diff --git a/flutter_client/lib/models/admin_quarantine_item.dart b/flutter_client/lib/models/admin_quarantine_item.dart deleted file mode 100644 index a27599a7..00000000 --- a/flutter_client/lib/models/admin_quarantine_item.dart +++ /dev/null @@ -1,92 +0,0 @@ -/// One user's report under an aggregated quarantine row. -class AdminQuarantineReport { - const AdminQuarantineReport({ - required this.userId, - required this.username, - required this.reason, - this.notes, - required this.createdAt, - }); - - final String userId; - final String username; - final String reason; - final String? notes; - final String createdAt; - - factory AdminQuarantineReport.fromJson(Map j) => - AdminQuarantineReport( - userId: j['user_id'] as String? ?? '', - username: j['username'] as String? ?? '', - reason: j['reason'] as String? ?? '', - notes: j['notes'] as String?, - createdAt: j['created_at'] as String? ?? '', - ); -} - -/// Mirrors `adminQueueRowView` from internal/api/admin_quarantine.go. -/// One row aggregates all reports against a single track, with -/// per-reason counts and the underlying per-user reports nested. -class AdminQuarantineItem { - const AdminQuarantineItem({ - required this.trackId, - required this.trackTitle, - required this.artistName, - required this.albumTitle, - required this.albumId, - this.lidarrAlbumMbid, - required this.reportCount, - required this.latestAt, - required this.reasonCounts, - required this.reports, - }); - - final String trackId; - final String trackTitle; - final String artistName; - final String albumTitle; - final String albumId; - final String? lidarrAlbumMbid; - final int reportCount; - final String latestAt; - - /// Map of reason → number of reports citing that reason. - final Map reasonCounts; - - final List reports; - - /// One-line summary of the most-cited reason. Returns just the top - /// reason when only one reason exists, or `top (+N more)` when there - /// are additional distinct reasons. - String get topReasonSummary { - if (reasonCounts.isEmpty) return ''; - final entries = reasonCounts.entries.toList() - ..sort((a, b) => b.value.compareTo(a.value)); - final top = entries.first.key; - return entries.length > 1 - ? '$top (+${entries.length - 1} more)' - : top; - } - - factory AdminQuarantineItem.fromJson(Map j) { - final reasonCountsRaw = - (j['reason_counts'] as Map?)?.cast() ?? const {}; - final reportsRaw = (j['reports'] as List?) ?? const []; - return AdminQuarantineItem( - trackId: j['track_id'] as String? ?? '', - trackTitle: j['track_title'] as String? ?? '', - artistName: j['artist_name'] as String? ?? '', - albumTitle: j['album_title'] as String? ?? '', - albumId: j['album_id'] as String? ?? '', - lidarrAlbumMbid: j['lidarr_album_mbid'] as String?, - reportCount: (j['report_count'] as int?) ?? 0, - latestAt: j['latest_at'] as String? ?? '', - reasonCounts: - reasonCountsRaw.map((k, v) => MapEntry(k, (v as int?) ?? 0)), - reports: reportsRaw - .map((e) => - AdminQuarantineReport.fromJson((e as Map).cast())) - .toList(growable: false), - ); - } -} diff --git a/flutter_client/lib/models/admin_request.dart b/flutter_client/lib/models/admin_request.dart deleted file mode 100644 index 19306d2e..00000000 --- a/flutter_client/lib/models/admin_request.dart +++ /dev/null @@ -1,77 +0,0 @@ -/// Mirrors `requestView` from internal/api/requests.go. Server returns -/// these as a flat list (no envelope) from GET /api/admin/requests. -/// -/// Note the requester is exposed only as a UUID (`user_id`) — the -/// response does not include the username. The Flutter Requests screen -/// joins client-side against the AdminUser list for display. -class AdminRequest { - const AdminRequest({ - required this.id, - required this.userId, - required this.status, - required this.kind, - required this.artistName, - this.albumTitle, - this.trackTitle, - required this.requestedAt, - this.decidedAt, - this.notes, - required this.importedAlbumCount, - required this.importedTrackCount, - this.matchedTrackId, - this.matchedAlbumId, - this.matchedArtistId, - }); - - final String id; - final String userId; - - /// One of: pending, approved, rejected, completed, failed. - final String status; - - /// One of: artist, album, track. - final String kind; - - final String artistName; - final String? albumTitle; - final String? trackTitle; - final String requestedAt; - final String? decidedAt; - final String? notes; - final int importedAlbumCount; - final int importedTrackCount; - - /// Set when the ingest matched into the local library. The user-side - /// "Listen" CTA on a completed request links to whichever id is set - /// (most-specific first: track → album → artist). - final String? matchedTrackId; - final String? matchedAlbumId; - final String? matchedArtistId; - - /// Display label depending on the kind of request — what the user - /// asked for. For an album request it's the album title; for an - /// artist request it's the artist name; etc. - String get displayName => switch (kind) { - 'album' => albumTitle ?? artistName, - 'track' => trackTitle ?? artistName, - _ => artistName, - }; - - factory AdminRequest.fromJson(Map j) => AdminRequest( - id: j['id'] as String? ?? '', - userId: j['user_id'] as String? ?? '', - status: j['status'] as String? ?? 'pending', - kind: j['kind'] as String? ?? 'artist', - artistName: j['artist_name'] as String? ?? '', - albumTitle: j['album_title'] as String?, - trackTitle: j['track_title'] as String?, - requestedAt: j['requested_at'] as String? ?? '', - decidedAt: j['decided_at'] as String?, - notes: j['notes'] as String?, - importedAlbumCount: (j['imported_album_count'] as int?) ?? 0, - importedTrackCount: (j['imported_track_count'] as int?) ?? 0, - matchedTrackId: j['matched_track_id'] as String?, - matchedAlbumId: j['matched_album_id'] as String?, - matchedArtistId: j['matched_artist_id'] as String?, - ); -} diff --git a/flutter_client/lib/models/admin_user.dart b/flutter_client/lib/models/admin_user.dart deleted file mode 100644 index 46c4a80b..00000000 --- a/flutter_client/lib/models/admin_user.dart +++ /dev/null @@ -1,33 +0,0 @@ -/// Mirrors `adminUserView` from internal/api/admin_users.go. Distinct -/// from MyProfile because admin endpoints expose `auto_approve_requests` -/// and the canonical `created_at` that the user-scoped /me response -/// intentionally omits. -/// -/// The list endpoint wraps these in `{"users": [...]}`; the parsing of -/// the envelope is done in `AdminUsersApi`, not here. -class AdminUser { - const AdminUser({ - required this.id, - required this.username, - this.displayName, - required this.isAdmin, - required this.autoApproveRequests, - required this.createdAt, - }); - - final String id; - final String username; - final String? displayName; - final bool isAdmin; - final bool autoApproveRequests; - final String createdAt; - - factory AdminUser.fromJson(Map j) => AdminUser( - id: j['id'] as String? ?? '', - username: j['username'] as String? ?? '', - displayName: j['display_name'] as String?, - isAdmin: j['is_admin'] as bool? ?? false, - autoApproveRequests: j['auto_approve_requests'] as bool? ?? false, - createdAt: j['created_at'] as String? ?? '', - ); -} diff --git a/flutter_client/lib/models/album.dart b/flutter_client/lib/models/album.dart deleted file mode 100644 index a8e12800..00000000 --- a/flutter_client/lib/models/album.dart +++ /dev/null @@ -1,40 +0,0 @@ -// Mirrors internal/api/types.go AlbumRef and web/src/lib/api/types.ts. -// -// `cover_url` (NOT cover_art_url) and `duration_sec` (NOT duration_ms) match -// the server contract. `year` is omitempty on the server so we keep it -// nullable. CoverURL is non-null but may be "" — UI branches on isEmpty. -class AlbumRef { - const AlbumRef({ - required this.id, - required this.title, - required this.artistId, - this.sortTitle = '', - this.artistName = '', - this.year, - this.trackCount = 0, - this.durationSec = 0, - this.coverUrl = '', - }); - - final String id; - final String title; - final String sortTitle; - final String artistId; - final String artistName; - final int? year; - final int trackCount; - final int durationSec; - final String coverUrl; - - factory AlbumRef.fromJson(Map j) => AlbumRef( - id: j['id'] as String, - title: j['title'] as String, - sortTitle: j['sort_title'] as String? ?? '', - artistId: j['artist_id'] as String, - artistName: j['artist_name'] as String? ?? '', - year: (j['year'] as num?)?.toInt(), - trackCount: (j['track_count'] as num?)?.toInt() ?? 0, - durationSec: (j['duration_sec'] as num?)?.toInt() ?? 0, - coverUrl: j['cover_url'] as String? ?? '', - ); -} diff --git a/flutter_client/lib/models/artist.dart b/flutter_client/lib/models/artist.dart deleted file mode 100644 index 48eae240..00000000 --- a/flutter_client/lib/models/artist.dart +++ /dev/null @@ -1,30 +0,0 @@ -// Mirrors internal/api/types.go ArtistRef and web/src/lib/api/types.ts. -// -// `cover_url` is the server's field name (NOT cover_art_url). Server emits -// "" when the artist has no representative album cover, so we keep it -// non-null here and let UI code branch on isEmpty. `sort_name` and -// `album_count` are exposed by the server contract; we accept their -// absence defensively (older fixtures, partial mocks). -class ArtistRef { - const ArtistRef({ - required this.id, - required this.name, - this.sortName = '', - this.albumCount = 0, - this.coverUrl = '', - }); - - final String id; - final String name; - final String sortName; - final int albumCount; - final String coverUrl; - - factory ArtistRef.fromJson(Map j) => ArtistRef( - id: j['id'] as String, - name: j['name'] as String, - sortName: j['sort_name'] as String? ?? '', - albumCount: (j['album_count'] as num?)?.toInt() ?? 0, - coverUrl: j['cover_url'] as String? ?? '', - ); -} diff --git a/flutter_client/lib/models/artist_suggestion.dart b/flutter_client/lib/models/artist_suggestion.dart deleted file mode 100644 index a636a4b7..00000000 --- a/flutter_client/lib/models/artist_suggestion.dart +++ /dev/null @@ -1,51 +0,0 @@ -/// Mirrors web/src/lib/api/types.ts ArtistSuggestion / SeedContribution — -/// one out-of-library artist from GET /api/discover/suggestions. image_url -/// is resolved on-demand from Lidarr server-side (may be empty). -class SeedContribution { - const SeedContribution({required this.name, required this.isLiked}); - - final String name; - final bool isLiked; - - factory SeedContribution.fromJson(Map j) => SeedContribution( - name: j['name'] as String? ?? '', - isLiked: j['is_liked'] as bool? ?? false, - ); -} - -class ArtistSuggestion { - const ArtistSuggestion({ - required this.mbid, - required this.name, - required this.imageUrl, - required this.attribution, - }); - - final String mbid; - final String name; - final String imageUrl; - final List attribution; - - factory ArtistSuggestion.fromJson(Map j) => ArtistSuggestion( - mbid: j['mbid'] as String? ?? '', - name: j['name'] as String? ?? '', - imageUrl: j['image_url'] as String? ?? '', - attribution: ((j['attribution'] as List?) ?? const []) - .map((e) => - SeedContribution.fromJson((e as Map).cast())) - .toList(growable: false), - ); - - /// Mirrors web SuggestionFeed.attributionText (Oxford comma, max 3). - String get attributionText { - if (attribution.isEmpty) return ''; - final phrases = attribution - .map((s) => '${s.isLiked ? 'liked' : 'played'} ${s.name}') - .toList(growable: false); - if (phrases.length == 1) return 'Because you ${phrases[0]}.'; - if (phrases.length == 2) { - return 'Because you ${phrases[0]} and ${phrases[1]}.'; - } - return 'Because you ${phrases[0]}, ${phrases[1]}, and ${phrases[2]}.'; - } -} diff --git a/flutter_client/lib/models/history_event.dart b/flutter_client/lib/models/history_event.dart deleted file mode 100644 index 5482eb00..00000000 --- a/flutter_client/lib/models/history_event.dart +++ /dev/null @@ -1,44 +0,0 @@ -import 'track.dart'; - -/// Mirrors web/src/lib/api/history.ts HistoryEvent. Server emits -/// `{events, has_more}` rather than the standard `Page` envelope — -/// history is timestamp-keyed so total isn't meaningful, only "more -/// pages exist below." -class HistoryEvent { - const HistoryEvent({ - required this.id, - required this.playedAt, - required this.track, - }); - - final String id; - /// RFC3339 timestamp; UI formats relative ("3h ago" / "Tue 14:32" / - /// "May 1, 2025") via formatRelative helper. - final String playedAt; - final TrackRef track; - - factory HistoryEvent.fromJson(Map j) => HistoryEvent( - id: j['id'] as String? ?? '', - playedAt: j['played_at'] as String? ?? '', - track: TrackRef.fromJson( - (j['track'] as Map?)?.cast() ?? const {}, - ), - ); -} - -class HistoryPage { - const HistoryPage({required this.events, required this.hasMore}); - - final List events; - final bool hasMore; - - factory HistoryPage.fromJson(Map j) { - final raw = (j['events'] as List?) ?? const []; - return HistoryPage( - events: raw - .map((e) => HistoryEvent.fromJson((e as Map).cast())) - .toList(growable: false), - hasMore: j['has_more'] as bool? ?? false, - ); - } -} diff --git a/flutter_client/lib/models/home_data.dart b/flutter_client/lib/models/home_data.dart deleted file mode 100644 index 1e9c5afc..00000000 --- a/flutter_client/lib/models/home_data.dart +++ /dev/null @@ -1,46 +0,0 @@ -import 'album.dart'; -import 'artist.dart'; -import 'track.dart'; - -// Mirrors internal/api/types.go HomePayload and web/src/lib/api/types.ts. -// Section keys MUST stay in sync with the server's JSON tags: -// recently_added_albums / rediscover_albums / rediscover_artists / -// most_played_tracks / last_played_artists. -// -// Server contract guarantees all five slices are non-nil at JSON encode -// time (empty sections render as []), but we still tolerate missing keys -// so partial fixtures and offline-degraded responses can be parsed. -class HomeData { - const HomeData({ - required this.recentlyAddedAlbums, - required this.rediscoverAlbums, - required this.rediscoverArtists, - required this.mostPlayedTracks, - required this.lastPlayedArtists, - }); - - final List recentlyAddedAlbums; - final List rediscoverAlbums; - final List rediscoverArtists; - final List mostPlayedTracks; - final List lastPlayedArtists; - - factory HomeData.fromJson(Map j) => HomeData( - recentlyAddedAlbums: _list(j, 'recently_added_albums', AlbumRef.fromJson), - rediscoverAlbums: _list(j, 'rediscover_albums', AlbumRef.fromJson), - rediscoverArtists: _list(j, 'rediscover_artists', ArtistRef.fromJson), - mostPlayedTracks: _list(j, 'most_played_tracks', TrackRef.fromJson), - lastPlayedArtists: _list(j, 'last_played_artists', ArtistRef.fromJson), - ); - - static List _list( - Map j, - String key, - T Function(Map) parse, - ) { - final raw = j[key] as List? ?? const []; - return raw - .map((e) => parse((e as Map).cast())) - .toList(growable: false); - } -} diff --git a/flutter_client/lib/models/home_index.dart b/flutter_client/lib/models/home_index.dart deleted file mode 100644 index d6f4c8c8..00000000 --- a/flutter_client/lib/models/home_index.dart +++ /dev/null @@ -1,41 +0,0 @@ -/// Mirrors internal/api/types.go HomeIndexPayload. Five flat slices of -/// entity ID strings — the per-item rendering variant of HomeData. -/// Section name implies entity type; no per-entry type tag is needed. -/// -/// Slices are non-null after fromJson so callers can branch on `length` -/// instead of dealing with null sections. -class HomeIndex { - const HomeIndex({ - required this.recentlyAddedAlbums, - required this.rediscoverAlbums, - required this.rediscoverArtists, - required this.mostPlayedTracks, - required this.lastPlayedArtists, - }); - - final List recentlyAddedAlbums; - final List rediscoverAlbums; - final List rediscoverArtists; - final List mostPlayedTracks; - final List lastPlayedArtists; - - static const empty = HomeIndex( - recentlyAddedAlbums: [], - rediscoverAlbums: [], - rediscoverArtists: [], - mostPlayedTracks: [], - lastPlayedArtists: [], - ); - - factory HomeIndex.fromJson(Map j) { - List ids(String key) => - ((j[key] as List?) ?? const []).map((e) => e.toString()).toList(); - return HomeIndex( - recentlyAddedAlbums: ids('recently_added_albums'), - rediscoverAlbums: ids('rediscover_albums'), - rediscoverArtists: ids('rediscover_artists'), - mostPlayedTracks: ids('most_played_tracks'), - lastPlayedArtists: ids('last_played_artists'), - ); - } -} diff --git a/flutter_client/lib/models/invite.dart b/flutter_client/lib/models/invite.dart deleted file mode 100644 index 446a4394..00000000 --- a/flutter_client/lib/models/invite.dart +++ /dev/null @@ -1,40 +0,0 @@ -/// Mirrors `inviteResp` from internal/api/admin_invites.go. The list -/// endpoint wraps these in `{"invites": [...]}`; the create endpoint -/// returns a single bare invite. Envelope parsing is done in -/// `AdminInvitesApi`, not here. -/// -/// `invitedBy` is the UUID of the inviting admin (not their username); -/// the server doesn't currently denormalize it. Likewise `redeemedBy`. -/// TTL is hardcoded server-side at 24h, so admins can't customise the -/// expiry — only the optional `note`. -class Invite { - const Invite({ - required this.token, - required this.invitedBy, - this.note, - required this.createdAt, - required this.expiresAt, - this.redeemedAt, - this.redeemedBy, - }); - - final String token; - final String invitedBy; - final String? note; - final String createdAt; - final String expiresAt; - final String? redeemedAt; - final String? redeemedBy; - - bool get isRedeemed => redeemedAt != null; - - factory Invite.fromJson(Map j) => Invite( - token: j['token'] as String? ?? '', - invitedBy: j['invited_by'] as String? ?? '', - note: j['note'] as String?, - createdAt: j['created_at'] as String? ?? '', - expiresAt: j['expires_at'] as String? ?? '', - redeemedAt: j['redeemed_at'] as String?, - redeemedBy: j['redeemed_by'] as String?, - ); -} diff --git a/flutter_client/lib/models/lidarr.dart b/flutter_client/lib/models/lidarr.dart deleted file mode 100644 index 8a8e7f6f..00000000 --- a/flutter_client/lib/models/lidarr.dart +++ /dev/null @@ -1,47 +0,0 @@ -/// Mirrors web/src/lib/api/types.ts LidarrSearchResult — one row in -/// `/api/lidarr/search` results. `in_library` and `requested` let the -/// UI gray out rows the user can't act on (already imported / already -/// awaiting review). -class LidarrSearchResult { - const LidarrSearchResult({ - required this.mbid, - required this.name, - required this.secondaryText, - required this.imageUrl, - required this.artistMbid, - required this.albumMbid, - required this.inLibrary, - required this.requested, - }); - - final String mbid; - final String name; - final String secondaryText; - final String imageUrl; - final String artistMbid; - final String albumMbid; - final bool inLibrary; - final bool requested; - - factory LidarrSearchResult.fromJson(Map j) => - LidarrSearchResult( - mbid: j['mbid'] as String? ?? '', - name: j['name'] as String? ?? '', - secondaryText: j['secondary_text'] as String? ?? '', - imageUrl: j['image_url'] as String? ?? '', - artistMbid: j['artist_mbid'] as String? ?? '', - albumMbid: j['album_mbid'] as String? ?? '', - inLibrary: j['in_library'] as bool? ?? false, - requested: j['requested'] as bool? ?? false, - ); -} - -enum LidarrRequestKind { artist, album, track } - -extension LidarrRequestKindStr on LidarrRequestKind { - String get wire => switch (this) { - LidarrRequestKind.artist => 'artist', - LidarrRequestKind.album => 'album', - LidarrRequestKind.track => 'track', - }; -} diff --git a/flutter_client/lib/models/my_profile.dart b/flutter_client/lib/models/my_profile.dart deleted file mode 100644 index 0c6ceb5c..00000000 --- a/flutter_client/lib/models/my_profile.dart +++ /dev/null @@ -1,49 +0,0 @@ -/// Mirrors web/src/lib/api/me.ts MyProfile. The `display_name` and -/// `email` are nullable — server returns null when the user hasn't -/// set them yet (registration only requires a username). -class MyProfile { - const MyProfile({ - required this.id, - required this.username, - this.displayName, - this.email, - required this.isAdmin, - }); - - final String id; - final String username; - final String? displayName; - final String? email; - final bool isAdmin; - - factory MyProfile.fromJson(Map j) => MyProfile( - id: j['id'] as String? ?? '', - username: j['username'] as String? ?? '', - displayName: j['display_name'] as String?, - email: j['email'] as String?, - isAdmin: j['is_admin'] as bool? ?? false, - ); -} - -/// Mirrors web/src/lib/api/listenbrainz.ts LBStatus. -class ListenBrainzStatus { - const ListenBrainzStatus({ - required this.enabled, - required this.tokenSet, - this.lastScrobbledAt, - }); - - final bool enabled; - /// True when the user has stored a token (token itself is never read - /// back from the server). UI uses this to show "token saved" vs - /// "no token" without exposing the value. - final bool tokenSet; - final String? lastScrobbledAt; - - factory ListenBrainzStatus.fromJson(Map j) => - ListenBrainzStatus( - enabled: j['enabled'] as bool? ?? false, - tokenSet: j['token_set'] as bool? ?? false, - lastScrobbledAt: j['last_scrobbled_at'] as String?, - ); -} diff --git a/flutter_client/lib/models/page.dart b/flutter_client/lib/models/page.dart deleted file mode 100644 index 29df33db..00000000 --- a/flutter_client/lib/models/page.dart +++ /dev/null @@ -1,44 +0,0 @@ -// Mirrors the server's Page[T] envelope used by paged endpoints -// (/api/search, /api/library/artists, /api/library/albums, -// /api/likes/*). Class is named `Paged` rather than `Page` -// to avoid ambiguous imports with Flutter Material's Navigator-2.0 -// `Page` class. -// -// Wire shape from internal/api/types.go Page[T]: -// { items: T[], total: int, limit: int, offset: int } -// -// Dart generics can't auto-infer T from JSON, so callers pass an -// itemFromJson function alongside the raw map. Parse logic stays in -// each entity's own fromJson; this class only handles the envelope. -class Paged { - const Paged({ - required this.items, - required this.total, - required this.limit, - required this.offset, - }); - - final List items; - final int total; - final int limit; - final int offset; - - factory Paged.fromJson( - Map j, - T Function(Map) itemFromJson, - ) { - final raw = (j['items'] as List?) ?? const []; - return Paged( - items: raw - .map((e) => itemFromJson((e as Map).cast())) - .toList(growable: false), - total: (j['total'] as num?)?.toInt() ?? 0, - limit: (j['limit'] as num?)?.toInt() ?? 0, - offset: (j['offset'] as num?)?.toInt() ?? 0, - ); - } - - /// Convenience for endpoints that always return the first page or - /// when the caller just wants the items. - bool get hasMore => offset + items.length < total; -} diff --git a/flutter_client/lib/models/playlist.dart b/flutter_client/lib/models/playlist.dart deleted file mode 100644 index 1ae01860..00000000 --- a/flutter_client/lib/models/playlist.dart +++ /dev/null @@ -1,127 +0,0 @@ -// Mirrors internal/api/playlists.go Playlist + PlaylistDetail wire shapes. -// -// Server distinguishes user playlists (created by the caller) from -// system playlists (server-generated mixes like for_you / discover). -// system_variant is null for user playlists; "for_you" / "discover" -// otherwise. Read-only operations work the same; PATCH/POST/DELETE -// are gated on user-owned playlists. - -class Playlist { - const Playlist({ - required this.id, - required this.userId, - required this.name, - required this.description, - required this.isPublic, - required this.systemVariant, - required this.trackCount, - required this.coverUrl, - required this.ownerUsername, - required this.createdAt, - required this.updatedAt, - }); - - final String id; - final String userId; - final String name; - final String description; - final bool isPublic; - /// "for_you" / "discover" / null - final String? systemVariant; - final int trackCount; - /// Server-emitted URL to the cover; empty when no tracks yet. - final String coverUrl; - /// Display name of the owner — for showing other users' public playlists. - final String ownerUsername; - final String createdAt; - final String updatedAt; - - bool get isSystem => systemVariant != null; - - /// Whether this playlist supports the generic by-kind refresh/ - /// shuffle endpoints (#411 R2) — i.e. a singleton system kind. - /// The server exposes a `refreshable` flag for JSON-sourced - /// playlists, but the list tiles are drift-cache-sourced (no - /// migration just for this), so derive it: every system kind is a - /// singleton except songs_like_artist (multi-per-user). This rule - /// holds for For-You/Discover and all planned discovery mixes; if - /// a future non-singleton kind appears, extend the exclusion. - bool get refreshable => isSystem && systemVariant != 'songs_like_artist'; - - factory Playlist.fromJson(Map j) => Playlist( - id: j['id'] as String, - userId: j['user_id'] as String? ?? '', - name: j['name'] as String? ?? '', - description: j['description'] as String? ?? '', - isPublic: j['is_public'] as bool? ?? false, - systemVariant: j['system_variant'] as String?, - trackCount: (j['track_count'] as num?)?.toInt() ?? 0, - coverUrl: j['cover_url'] as String? ?? '', - ownerUsername: j['owner_username'] as String? ?? '', - createdAt: j['created_at'] as String? ?? '', - updatedAt: j['updated_at'] as String? ?? '', - ); -} - -/// Playlist row as returned in PlaylistDetail.tracks. Each row carries -/// the track's display fields directly so the client doesn't need a -/// separate /api/tracks/{id} fetch per row. -/// -/// track_id is nullable: when the upstream track has been removed from -/// the library, the row remains in the playlist with its title/artist -/// preserved but track_id=null and stream_url=null. UI should render -/// these greyed-out and unplayable. -class PlaylistTrack { - const PlaylistTrack({ - required this.position, - required this.trackId, - required this.title, - required this.albumId, - required this.albumTitle, - required this.artistId, - required this.artistName, - required this.durationSec, - required this.streamUrl, - }); - - final int position; - final String? trackId; - final String title; - final String? albumId; - final String albumTitle; - final String? artistId; - final String artistName; - final int durationSec; - final String? streamUrl; - - bool get isAvailable => trackId != null; - - factory PlaylistTrack.fromJson(Map j) => PlaylistTrack( - position: (j['position'] as num?)?.toInt() ?? 0, - trackId: j['track_id'] as String?, - title: j['title'] as String? ?? '', - albumId: j['album_id'] as String?, - albumTitle: j['album_title'] as String? ?? '', - artistId: j['artist_id'] as String?, - artistName: j['artist_name'] as String? ?? '', - durationSec: (j['duration_sec'] as num?)?.toInt() ?? 0, - streamUrl: j['stream_url'] as String?, - ); -} - -class PlaylistDetail { - const PlaylistDetail({required this.playlist, required this.tracks}); - - final Playlist playlist; - final List tracks; - - factory PlaylistDetail.fromJson(Map j) { - final tracksRaw = (j['tracks'] as List?) ?? const []; - return PlaylistDetail( - playlist: Playlist.fromJson(j), - tracks: tracksRaw - .map((e) => PlaylistTrack.fromJson((e as Map).cast())) - .toList(growable: false), - ); - } -} diff --git a/flutter_client/lib/models/quarantine_mine.dart b/flutter_client/lib/models/quarantine_mine.dart deleted file mode 100644 index d9358d6b..00000000 --- a/flutter_client/lib/models/quarantine_mine.dart +++ /dev/null @@ -1,46 +0,0 @@ -/// Mirrors web/src/lib/api/types.ts LidarrQuarantineMineRow. One row -/// per quarantined track owned by the caller. Server returns a flat -/// list (no Page envelope) at /api/quarantine/mine. -class QuarantineMineRow { - const QuarantineMineRow({ - required this.trackId, - required this.reason, - this.notes, - required this.createdAt, - required this.trackTitle, - required this.trackDurationMs, - required this.albumId, - required this.albumTitle, - this.albumCoverArtPath, - required this.artistId, - required this.artistName, - }); - - final String trackId; - /// One of: bad_rip / wrong_file / wrong_tags / duplicate / other. - final String reason; - final String? notes; - final String createdAt; - final String trackTitle; - final int trackDurationMs; - final String albumId; - final String albumTitle; - final String? albumCoverArtPath; - final String artistId; - final String artistName; - - factory QuarantineMineRow.fromJson(Map j) => - QuarantineMineRow( - trackId: j['track_id'] as String? ?? '', - reason: j['reason'] as String? ?? 'other', - notes: j['notes'] as String?, - createdAt: j['created_at'] as String? ?? '', - trackTitle: j['track_title'] as String? ?? '', - trackDurationMs: (j['track_duration_ms'] as num?)?.toInt() ?? 0, - albumId: j['album_id'] as String? ?? '', - albumTitle: j['album_title'] as String? ?? '', - albumCoverArtPath: j['album_cover_art_path'] as String?, - artistId: j['artist_id'] as String? ?? '', - artistName: j['artist_name'] as String? ?? '', - ); -} diff --git a/flutter_client/lib/models/search_response.dart b/flutter_client/lib/models/search_response.dart deleted file mode 100644 index 2d1e505b..00000000 --- a/flutter_client/lib/models/search_response.dart +++ /dev/null @@ -1,38 +0,0 @@ -import 'album.dart'; -import 'artist.dart'; -import 'page.dart'; -import 'track.dart'; - -/// Mirrors internal/api/search.go SearchResponse — three pages keyed by -/// facet, each independently paged. The mobile UI usually only walks -/// the first page of each facet (limit 20-50); infinite scroll within a -/// facet is a future enhancement, not v1. -class SearchResponse { - const SearchResponse({ - required this.artists, - required this.albums, - required this.tracks, - }); - - final Paged artists; - final Paged albums; - final Paged tracks; - - factory SearchResponse.fromJson(Map j) => SearchResponse( - artists: Paged.fromJson( - (j['artists'] as Map?)?.cast() ?? const {}, - ArtistRef.fromJson, - ), - albums: Paged.fromJson( - (j['albums'] as Map?)?.cast() ?? const {}, - AlbumRef.fromJson, - ), - tracks: Paged.fromJson( - (j['tracks'] as Map?)?.cast() ?? const {}, - TrackRef.fromJson, - ), - ); - - bool get isEmpty => - artists.items.isEmpty && albums.items.isEmpty && tracks.items.isEmpty; -} diff --git a/flutter_client/lib/models/system_playlists_status.dart b/flutter_client/lib/models/system_playlists_status.dart deleted file mode 100644 index b8588196..00000000 --- a/flutter_client/lib/models/system_playlists_status.dart +++ /dev/null @@ -1,24 +0,0 @@ -/// Mirrors `systemPlaylistsStatusResp` from internal/api/me_system_playlists.go. -/// Reflects the caller's most recent system_playlist_runs row, or zero -/// values when no row exists yet (the user has never had a build attempted). -class SystemPlaylistsStatus { - const SystemPlaylistsStatus({ - required this.inFlight, - this.lastRunAt, - this.lastError, - }); - - final bool inFlight; - final String? lastRunAt; - final String? lastError; - - factory SystemPlaylistsStatus.empty() => - const SystemPlaylistsStatus(inFlight: false); - - factory SystemPlaylistsStatus.fromJson(Map j) => - SystemPlaylistsStatus( - inFlight: j['in_flight'] as bool? ?? false, - lastRunAt: j['last_run_at'] as String?, - lastError: j['last_error'] as String?, - ); -} diff --git a/flutter_client/lib/models/track.dart b/flutter_client/lib/models/track.dart deleted file mode 100644 index 1e5adc58..00000000 --- a/flutter_client/lib/models/track.dart +++ /dev/null @@ -1,58 +0,0 @@ -// Mirrors internal/api/types.go TrackRef and web/src/lib/api/types.ts. -// -// Server uses `duration_sec` (NOT duration_ms). `track_number` and -// `disc_number` are omitempty server-side so they're nullable here. -// `stream_url` points at /api/tracks/{id}/stream. -class TrackRef { - const TrackRef({ - required this.id, - required this.title, - required this.albumId, - required this.artistId, - this.albumTitle = '', - this.artistName = '', - this.trackNumber, - this.discNumber, - this.durationSec = 0, - this.streamUrl = '', - }); - - final String id; - final String title; - final String albumId; - final String albumTitle; - final String artistId; - final String artistName; - final int? trackNumber; - final int? discNumber; - final int durationSec; - final String streamUrl; - - factory TrackRef.fromJson(Map j) => TrackRef( - id: j['id'] as String, - title: j['title'] as String, - albumId: j['album_id'] as String, - albumTitle: j['album_title'] as String? ?? '', - artistId: j['artist_id'] as String, - artistName: j['artist_name'] as String? ?? '', - trackNumber: (j['track_number'] as num?)?.toInt(), - discNumber: (j['disc_number'] as num?)?.toInt(), - durationSec: (j['duration_sec'] as num?)?.toInt() ?? 0, - streamUrl: j['stream_url'] as String? ?? '', - ); - - /// Round-trips through [TrackRef.fromJson] (same server snake_case - /// keys). Used to persist the playback queue for resume-on-launch. - Map toJson() => { - 'id': id, - 'title': title, - 'album_id': albumId, - 'album_title': albumTitle, - 'artist_id': artistId, - 'artist_name': artistName, - if (trackNumber != null) 'track_number': trackNumber, - if (discNumber != null) 'disc_number': discNumber, - 'duration_sec': durationSec, - 'stream_url': streamUrl, - }; -} diff --git a/flutter_client/lib/models/user.dart b/flutter_client/lib/models/user.dart deleted file mode 100644 index 9c674f3b..00000000 --- a/flutter_client/lib/models/user.dart +++ /dev/null @@ -1,24 +0,0 @@ -// Mirrors internal/api/types.go UserView. The /api/* user shape — narrower -// than dbq.User (no password hash, no api_token, no subsonic_password). -// -// `id` is a pgtype.UUID server-side which marshals to a plain string when -// Valid, so we accept it as String here. `is_admin` is always present in -// the server response but we tolerate its absence (default false) to keep -// older fixtures and partial mocks loadable. -class User { - const User({ - required this.id, - required this.username, - required this.isAdmin, - }); - - final String id; - final String username; - final bool isAdmin; - - factory User.fromJson(Map j) => User( - id: j['id'] as String, - username: j['username'] as String, - isAdmin: j['is_admin'] as bool? ?? false, - ); -} diff --git a/flutter_client/lib/player/album_color_extractor.dart b/flutter_client/lib/player/album_color_extractor.dart deleted file mode 100644 index 32db2ab1..00000000 --- a/flutter_client/lib/player/album_color_extractor.dart +++ /dev/null @@ -1,92 +0,0 @@ -// Dominant-color extraction from album cover art (#396 item 1). -// The full-screen Now Playing screen uses this to paint a top-to-bottom -// gradient backdrop that grounds each track in its album's palette. -// -// Implementation: reuses AlbumCoverCache to get a local file path for -// the cover, then runs PaletteGenerator over it. Results are cached -// in-memory keyed by album_id so back-to-back plays of the same album -// don't repeat the work. Cache is process-lifetime; album-art changes -// are rare enough that LRU eviction isn't worth the complexity. -// -// Returns null on any failure (no cover, empty album_id, palette -// extraction returned nothing). Callers fall back to the FabledSword -// obsidian color for the gradient when null. - -import 'dart:io'; - -import 'package:flutter/painting.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:palette_generator/palette_generator.dart'; - -import 'player_provider.dart'; - -/// Caches dominant colors keyed by album_id. Process-lifetime; refilled -/// on app restart. -class AlbumColorCache { - AlbumColorCache(this._ref); - - final Ref _ref; - final Map _cache = {}; - - /// Returns the dominant color for the album's cover, or null if no - /// cover is available or extraction fails. Same call for the same - /// album_id is cached after the first successful resolution. - Future getOrExtract(String albumId) async { - if (albumId.isEmpty) return null; - if (_cache.containsKey(albumId)) return _cache[albumId]; - final color = await _extract(albumId); - _cache[albumId] = color; - return color; - } - - /// Synchronous peek. Returns the previously-extracted color if this - /// album has been resolved this process; otherwise null. Used by - /// the now-playing fast-path swap so a warm cache transitions the - /// gradient in lockstep with the audio, instead of awaiting the - /// async [getOrExtract] future. A null return means "either no - /// extraction yet or extraction returned null" — caller falls back - /// to the async path. - Color? peekColor(String albumId) => _cache[albumId]; - - Future _extract(String albumId) async { - try { - final coverCache = _ref.read(albumCoverCacheProvider); - final path = await coverCache.getOrFetch(albumId); - if (path == null) return null; - final file = File(path); - if (!await file.exists()) return null; - final palette = await PaletteGenerator.fromImageProvider( - FileImage(file), - // Small target size: palette extraction is CPU-bound and the - // gradient only needs a single dominant color, so we don't - // need full-resolution sampling. - size: const Size(80, 80), - maximumColorCount: 8, - ); - // Prefer the explicit dominant color; fall back to the strongest - // muted swatch (which tends to read better as a background than - // a hot vibrant pick), then any populated swatch. - final swatch = palette.dominantColor ?? - palette.darkMutedColor ?? - palette.darkVibrantColor ?? - palette.mutedColor; - return swatch?.color; - } catch (_) { - return null; - } - } -} - -final albumColorCacheProvider = Provider( - (ref) => AlbumColorCache(ref), -); - -/// Family provider: dominant color for the given album_id, or null if -/// no cover / extraction failed. The Now Playing screen watches this -/// keyed by the current MediaItem's album_id. -final albumColorProvider = FutureProvider.family( - (ref, albumId) async { - final cache = ref.watch(albumColorCacheProvider); - return cache.getOrExtract(albumId); - }, -); diff --git a/flutter_client/lib/player/album_cover_cache.dart b/flutter_client/lib/player/album_cover_cache.dart deleted file mode 100644 index ac2534c4..00000000 --- a/flutter_client/lib/player/album_cover_cache.dart +++ /dev/null @@ -1,94 +0,0 @@ -import 'dart:async'; -import 'dart:io'; - -import 'package:dio/dio.dart'; -import 'package:flutter/foundation.dart'; -import 'package:path_provider/path_provider.dart'; - -/// Caches album cover bytes to disk so MediaItem.artUri can point at a -/// file:// URI. Android's MediaSession framework fetches artUri itself -/// and doesn't carry our Bearer header, so we pre-fetch via the -/// authenticated dio and hand the system a local file path instead. -/// -/// Cache layout: `{applicationCacheDirectory}/album_covers/{albumId}.jpg`. -/// No explicit eviction — covers are tiny and OS clears app cache when -/// space is tight. -class AlbumCoverCache { - AlbumCoverCache({ - required Future Function() dioFactory, - Future Function()? cacheDirFactory, - }) : _dioFactory = dioFactory, - _cacheDirFactory = cacheDirFactory ?? getApplicationCacheDirectory; - - final Future Function() _dioFactory; - final Future Function() _cacheDirFactory; - - /// In-flight requests keyed by albumId so concurrent callers for the - /// same album dedupe to one fetch. - final Map> _inflight = {}; - - /// Cached covers directory path resolved once on the first - /// async cacheDir call, then reused for sync existsSync() checks - /// in [peekCached]. Without this every "is the cover already on - /// disk?" check would have to await path_provider, blocking the - /// MediaItem broadcast on every track change. - String? _coversDirPath; - - /// Returns the local file path for [albumId]'s cover if it's - /// already cached on disk, or null otherwise. Synchronous — uses - /// File.existsSync() against a path computed from the directory - /// resolved by an earlier async [getOrFetch] call. Returns null - /// until at least one getOrFetch has populated _coversDirPath. - /// - /// The audio handler uses this to seed MediaItem.artUri on the - /// initial broadcast so external media controllers (Wear, Android - /// Auto, Bluetooth) see the cover on the first frame for warm-cache - /// tracks. Cold-cache tracks still fall back to the async - /// _loadArtForCurrentItem path. - String? peekCached(String albumId) { - if (albumId.isEmpty) return null; - final dir = _coversDirPath; - if (dir == null) return null; - final path = '$dir/$albumId.jpg'; - return File(path).existsSync() ? path : null; - } - - /// Returns local file path to the album cover, or null on any - /// failure (network, 4xx/5xx, disk full, empty albumId). - Future getOrFetch(String albumId) { - if (albumId.isEmpty) return Future.value(null); - final existing = _inflight[albumId]; - if (existing != null) return existing; - final fut = _doFetch(albumId); - _inflight[albumId] = fut; - fut.whenComplete(() => _inflight.remove(albumId)); - return fut; - } - - Future _doFetch(String albumId) async { - try { - final dir = await _cacheDirFactory(); - final coversDir = Directory('${dir.path}/album_covers'); - await coversDir.create(recursive: true); - // Cache the resolved directory so [peekCached] can do its - // existsSync() check without re-awaiting path_provider. - _coversDirPath = coversDir.path; - final filePath = '${coversDir.path}/$albumId.jpg'; - final file = File(filePath); - if (await file.exists()) return filePath; - - final dio = await _dioFactory(); - final r = await dio.get>( - '/api/albums/$albumId/cover', - options: Options(responseType: ResponseType.bytes), - ); - final bytes = r.data; - if (bytes == null || bytes.isEmpty) return null; - await file.writeAsBytes(bytes, flush: true); - return filePath; - } catch (e) { - debugPrint('AlbumCoverCache: fetch failed for $albumId: $e'); - return null; - } - } -} diff --git a/flutter_client/lib/player/audio_handler.dart b/flutter_client/lib/player/audio_handler.dart deleted file mode 100644 index 38614724..00000000 --- a/flutter_client/lib/player/audio_handler.dart +++ /dev/null @@ -1,1035 +0,0 @@ -import 'dart:async'; -import 'dart:io'; - -import 'package:audio_service/audio_service.dart'; -import 'package:audio_session/audio_session.dart'; -import 'package:flutter/foundation.dart'; -import 'package:just_audio/just_audio.dart'; -import 'package:path_provider/path_provider.dart'; - -import '../cache/audio_cache_manager.dart'; -import '../models/track.dart'; -import 'album_cover_cache.dart'; - -class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler { - MinstrelAudioHandler() { - _player.playbackEventStream.listen( - _broadcastState, - // ExoPlayer surfaces stream errors (404, premature EOS, decoder - // failure, network drop) here. Logging alone leaves the UI - // claiming "now playing X" while audio is silent — the user- - // observable mismatch between visual and audio state. Skip - // forward so the queue advances past the failed track and - // mediaItem updates to whatever's actually playing. If the - // failure is at the queue tail, just_audio will go idle on its - // own and _broadcastState reflects that. - onError: (Object e, StackTrace st) { - debugPrint('audio_handler: playbackEventStream error: $e\n$st'); - unawaited(_handlePlaybackError()); - }, - ); - _player.currentIndexStream.listen(_onCurrentIndexChanged); - // Re-broadcast on shuffle/repeat changes so the PlaybackState's - // shuffleMode + repeatMode fields stay current for UI subscribers. - _player.shuffleModeEnabledStream.listen((_) => _broadcastState(null)); - _player.loopModeStream.listen((_) => _broadcastState(null)); - // Watch buffered-position so we can register stream-cached files - // in the audio cache index once they're fully downloaded. Without - // this, files written by LockCachingAudioSource never appear in - // the index and the eviction loop can't reclaim them. - _player.bufferedPositionStream - .listen((_) => unawaited(_maybeRegisterStreamCache())); - unawaited(_configureAudioSession()); - } - - final AudioPlayer _player = AudioPlayer(); - String _baseUrl = ''; - String? _token; - AlbumCoverCache? _coverCache; - AudioCacheManager? _audioCacheManager; - LikeBridge? _likeBridge; - - /// Trackers to dedupe registration — once we've inserted an index row - /// for a trackId, don't repeat the work on every buffered-position - /// emit. Cleared on dispose only; surviving across queue rebuilds is - /// fine because the index is itself the source of truth. - final Set _streamCacheRegistered = {}; - - /// Cached on first use so we don't hit the platform channel every - /// time the buffered-position stream emits (~200ms cadence). - String? _cacheDirPath; - - /// How long the session may sit not-actively-playing (paused, or a - /// finished queue) before we tear it down so the Wear tile / lock - /// screen / notification don't linger on a stale track. The - /// notification is configured ongoing (main.dart), so nothing else - /// ever drives the MediaSession to a terminal state. - static const _idleStopTimeout = Duration(minutes: 5); - - /// Single-shot cleanup timer, armed while not actively playing and - /// cancelled the moment playback resumes or a new queue is set. - Timer? _idleStopTimer; - - /// Periodic PlaybackState re-broadcast while actively playing so - /// external surfaces (lock screen, Wear, Android Auto) interpolate the - /// scrubber smoothly. The in-app bar uses positionStream and doesn't - /// need this. Active only while playing; cancelled otherwise. - Timer? _positionBroadcastTimer; - - /// Buffering-stall watchdog. On poor coverage a stream hangs in - /// ProcessingState.buffering with NO error event, so the onError - /// recovery never fires. Armed while playing+buffering; on fire, if - /// buffered position hasn't advanced it's a dead stream → recover. - /// Re-arms a fresh window while buffering-but-progressing (slow-but- - /// downloading is not a stall). - static const _stallTimeout = Duration(seconds: 15); - Timer? _stallTimer; - Duration _stallMarkBuffered = Duration.zero; - - /// Per-track recovery budget. Reset when a track reaches - /// ready+playing. One retry of the same source (rebuilt fresh), then - /// skip to the next cached track (or pause) instead of thrashing - /// through unreachable streams. - static const _maxRecoverRetries = 1; - String? _recoveringTrackId; - int _recoverAttempts = 0; - - /// Player volume captured when an OS duck interruption begins, - /// restored when it ends. Null when not currently ducked. - double? _volumeBeforeDuck; - - /// True when an interruption (call / other media) paused playback that - /// was actively going, so a transient (pause-type) interruption end - /// auto-resumes. Cleared on resume or a non-resumable end. - bool _interruptedWhilePlaying = false; - - /// True while _fillRemainingSources is doing backward-fill inserts - /// at index 0..initialIndex-1. Each insert shifts the player's - /// currentIndex (it tracks the actively-playing source through - /// list mutations), and the resulting _onCurrentIndexChanged - /// callbacks would push the wrong MediaItem onto the stream - /// (queue.value[shifted_idx] != actively-playing track). When - /// the fill completes, currentIndex == initialIndex, mediaItem - /// is already correct, and we re-enable normal listener behavior. - bool _suppressIndexUpdates = false; - - /// Increments on every setQueueFromTracks call. The background - /// fill task captures the value at start and bails if the live - /// counter has moved on — without this, a stale fill will keep - /// addAudioSource'ing tracks from the previous playlist into the - /// new queue, leaving the player "locked" to a corrupted state. - int _queueGeneration = 0; - - /// Logical-queue index that just_audio player-index 0 currently maps - /// to. setQueueFromTracks fast-starts with a SINGLE source at player - /// index 0 while queue.value already holds the full list, so the - /// playing track's logical index is clampedInitial, not 0. The - /// transient currentIndexStream→0 emission that arrives just after - /// setAudioSources resolves (and after _suppressIndexUpdates is back - /// to false) would otherwise make _onCurrentIndexChanged broadcast - /// queue.value[0] — the FIRST track — over the correct item, pinning - /// the mini bar / playlist marker to the wrong track until a later - /// index event. Decremented in lockstep with the backward fill's - /// front inserts so player-index → logical-index stays correct and - /// lands at 0 once the player list fully matches queue.value. - int _logicalIndexBase = 0; - - /// Tracks the most recent setQueueFromTracks() input so - /// skipToQueueItem can reconstruct the source list. just_audio - /// requires every source to be built before it can be a skip - /// target, but setQueueFromTracks only builds the initial source - /// and fills the rest in the background — so a skip to an index - /// past the fill front needs to rebuild from the stored tracks. - List _lastTracks = const []; - - /// #415: which system playlist this queue was seeded from - /// ('for_you' | 'discover'), or null for library / user-playlist / - /// radio. The play-events reporter reads this so play_started - /// carries `source` and the server advances that rotation. A fresh - /// setQueueFromTracks from a non-system surface clears it; internal - /// rebuilds (skipToQueueItem) preserve it. - String? _queueSource; - String? get queueSource => _queueSource; - - /// The full TrackRef list backing the current queue (even when only - /// part is built as just_audio sources). Empty when nothing is - /// queued. Exposed for resume-state persistence (#54). - List get queuedTracks => _lastTracks; - - /// Volume stream for UI subscribers. Mirrors the just_audio player's - /// volume directly; set via setVolume(double). - Stream get volumeStream => _player.volumeStream; - double get volume => _player.volume; - - /// Position stream for UI subscribers. just_audio emits roughly every - /// 200ms while playing, which is what makes the seek bar advance - /// smoothly. PlaybackState.updatePosition only changes on event - /// transitions (play/pause/buffer), so it's too coarse for live - /// scrubbing UI. - Stream get positionStream => _player.positionStream; - Duration get position => _player.position; - - /// Broadcasts the title of a track that just failed to play (404, - /// decoder failure, premature EOS, network drop) and was auto-skipped - /// or paused by _handlePlaybackError. The app listens and surfaces a - /// debounced/coalesced SnackBar so a silent skip isn't mysterious. - /// App-lifetime singleton handler — intentionally never closed. - final _playbackErrors = StreamController.broadcast(); - Stream get playbackErrorStream => _playbackErrors.stream; - - void configure({ - required String baseUrl, - required String? token, - AlbumCoverCache? coverCache, - AudioCacheManager? audioCacheManager, - LikeBridge? likeBridge, - }) { - _baseUrl = baseUrl; - _token = token; - if (coverCache != null) _coverCache = coverCache; - if (likeBridge != null) _likeBridge = likeBridge; - if (audioCacheManager != null) _audioCacheManager = audioCacheManager; - } - - /// Invoked by play() when nothing is loaded (mediaItem == null) so a - /// media-button press after the #52 teardown resumes the last - /// persisted session (#448). Registered by ResumeController.start(); - /// null until then (then it's a no-op fall-through to _player.play()). - Future Function()? _resumeHook; - void setResumeHook(Future Function() hook) => _resumeHook = hook; - - Future setQueueFromTracks( - List tracks, { - int initialIndex = 0, - String? source, - }) async { - if (tracks.isEmpty) return; - // New playback supersedes any pending idle cleanup. play() below - // re-broadcasts and _reconcileIdleTimer would cancel anyway; doing - // it up front avoids a race during the pause→swap window. - _idleStopTimer?.cancel(); - _idleStopTimer = null; - _queueSource = source; - final clampedInitial = initialIndex.clamp(0, tracks.length - 1); - - // Bump the generation FIRST. Any in-flight _fillRemainingSources - // from a previous play will see the mismatch on its next gen - // check and stop calling player mutations — important so a stale - // fill doesn't append old-playlist tracks into the new queue. - final myGen = ++_queueGeneration; - _lastTracks = tracks; - - // Pause the old source immediately so the previous track stops - // audibly the moment the user taps, instead of bleeding through - // until the new source finishes building. setAudioSources below - // will swap the source list cleanly; pause is the simplest way - // to silence the player during the (possibly multi-100ms) build. - if (_player.playing) { - await _player.pause(); - } - - // Build MediaItems up front (pure — no side effects); we'll - // broadcast queue/mediaItem only AFTER setAudioSources resolves - // so the audio engine and the UI flip together. If the build - // throws, the UI stays on the previous track (correct: audio - // also stays on the previous track since setAudioSources never - // ran). - final items = tracks.map(_toMediaItem).toList(); - - // Build only the initial source for fast start. Remaining - // sources stream in via _fillRemainingSources() — addAudioSource - // for next/auto-advance tracks, insertAudioSource for skipPrev. - final AudioSource initial; - try { - initial = await _buildAudioSource(tracks[clampedInitial]); - } catch (e, st) { - // Source build failed (bad URL, missing baseUrl, etc.). Don't - // broadcast the new state — leaving queue/mediaItem on the - // previous track keeps UI in sync with what the player is - // actually doing (which is "still on the previous track, - // paused"). - debugPrint('audio_handler: _buildAudioSource failed: $e\n$st'); - return; - } - if (myGen != _queueGeneration) { - debugPrint('audio_handler: superseded before setAudioSources (gen=$myGen)'); - return; - } - - // Suppress _onCurrentIndexChanged side effects while the source - // list is being swapped — without this, a transient currentIndex - // emission during setAudioSources could broadcast the OLD queue's - // entry at the NEW index. Re-enabled after the broadcasts land. - _suppressIndexUpdates = true; - try { - await _player.setAudioSources([initial], initialIndex: 0); - // Player-index 0 holds the single fast-start source, which is - // logical-queue index clampedInitial. Record the offset before - // broadcasting so the post-resolve currentIndexStream→0 emission - // maps back to the correct item instead of queue.value[0]. - _logicalIndexBase = clampedInitial; - // Broadcast in this order: queue first (so any consumer that - // reacts to mediaItem and reads queue.value sees the consistent - // pair), then mediaItem. - queue.add(items); - mediaItem.add(items[clampedInitial]); - } finally { - _suppressIndexUpdates = false; - } - - unawaited(_loadArtForCurrentItem()); - unawaited(_fillRemainingSources(tracks, clampedInitial, myGen)); - } - - /// Switches playback to the [index]th queue item. The full - /// just_audio source list isn't necessarily built yet - /// (_fillRemainingSources runs in the background), so we - /// reconstruct from the stored TrackRef list rather than calling - /// _player.seek(index: ...) on a possibly-missing source. Calling - /// setQueueFromTracks again is the safe path: it bumps the - /// generation, cancels any in-flight fill, rebuilds source[0] as - /// the target, and re-fills around. play() restarts playback so - /// queue taps feel like "jump to this song" rather than "set the - /// pointer and wait for me to press play." - @override - Future skipToQueueItem(int index) async { - if (index < 0 || index >= _lastTracks.length) return; - // Preserve the system-playlist source across an internal rebuild - // — a queue-item skip is still playing from the same playlist. - await setQueueFromTracks(_lastTracks, initialIndex: index, source: _queueSource); - await play(); - } - - /// Background fill of the rest of the just_audio source list after - /// the initial source is playing. Forward direction first (most - /// common skipNext target). Backward inserts shift the player's - /// currentIndex; we suppress _onCurrentIndexChanged side effects - /// for those so the mediaItem stream doesn't bounce to the wrong - /// queue entry. - /// - /// `gen` is the queue generation captured when this fill started. - /// Every loop iteration checks against _queueGeneration; if a - /// newer setQueueFromTracks has run (user tapped play on something - /// else), bail immediately so we don't pollute the new queue with - /// addAudioSource calls from this stale fill. - Future _fillRemainingSources( - List tracks, int initialIndex, int gen) async { - for (var i = initialIndex + 1; i < tracks.length; i++) { - if (gen != _queueGeneration) return; - try { - final src = await _buildAudioSource(tracks[i]); - if (gen != _queueGeneration) return; - await _player.addAudioSource(src); - } catch (e) { - debugPrint('audio_handler: forward fill failed for ${tracks[i].id}: $e'); - } - } - if (initialIndex > 0) { - _suppressIndexUpdates = true; - try { - for (var i = 0; i < initialIndex; i++) { - if (gen != _queueGeneration) return; - final src = await _buildAudioSource(tracks[i]); - if (gen != _queueGeneration) return; - await _player.insertAudioSource(i, src); - // Each front insert shifts the playing source's player index - // up by one; drop the base in lockstep so player-index → - // logical-index stays correct (and reaches 0 once the player - // list fully matches queue.value). - _logicalIndexBase -= 1; - } - } catch (e) { - debugPrint('audio_handler: backward fill failed: $e'); - } finally { - // Only release the flag if we're still the active gen — a - // newer setQueueFromTracks already reset it for itself. - if (gen == _queueGeneration) _suppressIndexUpdates = false; - } - } - } - - String _resolveStreamUrl(TrackRef t) { - if (t.streamUrl.isEmpty) { - return '$_baseUrl/api/tracks/${t.id}/stream'; - } - final parsed = Uri.tryParse(t.streamUrl); - if (parsed != null && parsed.hasScheme) { - return t.streamUrl; - } - return '$_baseUrl${t.streamUrl}'; - } - - /// Builds an AudioSource for a track. Cache-aware: - /// 1. If the track is fully cached on disk, returns a file:// source. - /// 2. Else returns a LockCachingAudioSource that streams + caches as - /// it plays (subsequent plays will hit the cache). - /// - /// Without an audio cache manager configured (e.g. in older code paths - /// that pre-date #357), falls back to a plain network AudioSource.uri. - Future _buildAudioSource(TrackRef t) async { - final headers = _token == null ? null : {'Authorization': 'Bearer $_token'}; - final mgr = _audioCacheManager; - - // 1. Cache hit: play from disk, no headers needed. - if (mgr != null) { - final path = await mgr.pathFor(t.id); - if (path != null) { - return AudioSource.uri(Uri.file(path)); - } - } - - final url = _resolveStreamUrl(t); - final parsed = Uri.parse(url); - if (!parsed.hasScheme || parsed.host.isEmpty) { - throw StateError( - 'audio_handler: refused to play scheme-less URL "$url" ' - '(baseUrl="$_baseUrl", track.streamUrl="${t.streamUrl}", ' - 'track.id="${t.id}"). configure() must be called with a ' - 'non-empty baseUrl before setQueueFromTracks().', - ); - } - - // 2. Cache miss WITH manager: stream + cache-as-you-play. Future - // plays of this track will hit the cache. If the cache write - // completes, we don't currently register an index row — that would - // require a download-complete hook from just_audio that's not - // exposed cleanly. Acceptable for v1: prefetcher / explicit - // pin / Download buttons cover the index path; LockCaching handles - // the network optimization. - if (mgr != null) { - _cacheDirPath ??= (await getApplicationCacheDirectory()).path; - final cacheFile = File('${_cacheDirPath!}/audio_cache/${t.id}.mp3'); - // ignore: experimental_member_use - return LockCachingAudioSource(parsed, - headers: headers, cacheFile: cacheFile); - } - - // 3. No manager configured: plain network source (legacy path). - return AudioSource.uri(parsed, headers: headers); - } - - /// Inserts [track] right after the currently-playing item so it plays - /// next. If nothing is playing, appends to the end. - Future playNext(TrackRef track) async { - final source = await _buildAudioSource(track); - final item = _toMediaItem(track); - final currentIdx = _player.currentIndex; - final insertAt = currentIdx == null ? queue.value.length : currentIdx + 1; - await _player.insertAudioSource(insertAt, source); - final current = queue.value; - queue.add([ - ...current.sublist(0, insertAt), - item, - ...current.sublist(insertAt), - ]); - } - - /// Appends [track] to the end of the queue. - Future enqueue(TrackRef track) async { - final source = await _buildAudioSource(track); - final item = _toMediaItem(track); - await _player.addAudioSource(source); - queue.add([...queue.value, item]); - } - - MediaItem _toMediaItem(TrackRef t) { - // Stash album_id + artist_id in extras so widgets reconstructing - // a TrackRef from the MediaItem (player kebab → "Go to artist", - // "Go to album") have the IDs they need to navigate. Earlier code - // only carried album_id which left "Go to artist" pushing - // /artists/ (empty id, route 404). - final extras = {}; - if (t.albumId.isNotEmpty) extras['album_id'] = t.albumId; - if (t.artistId.isNotEmpty) extras['artist_id'] = t.artistId; - // Sync-peek the album cover cache so warm-cache tracks broadcast - // with artUri populated on the first frame. External media - // controllers (Android Wear, Bluetooth dashes, Auto, lock screen) - // can only render the cover bytes that audio_service hands them - // at MediaItem broadcast time; the later async _loadArtForCurrent - // Item path repopulates for cold-cache tracks. Without this seed, - // every track change starts with a generic icon on the watch and - // only gets the real cover after one or two seconds. - final coverPath = (t.albumId.isNotEmpty && _coverCache != null) - ? _coverCache!.peekCached(t.albumId) - : null; - // MediaItem.rating intentionally NOT set: audio_service propagates - // it to MediaSession.setRating(), but the Android session also - // needs setRatingType(RATING_HEART) configured to expose that to - // controllers — audio_service doesn't surface that config knob, - // and broadcasting an unanchored rating made Wear OS reject the - // session entirely. The LikeBridge wiring stays in place so - // setRating can still fire from any surface that DOES route it, - // we just don't advertise it. - return MediaItem( - id: t.id, - title: t.title, - artist: t.artistName, - album: t.albumTitle, - duration: Duration(seconds: t.durationSec), - artUri: coverPath != null ? Uri.file(coverPath) : null, - extras: extras.isEmpty ? null : extras, - ); - } - - /// Once a track is fully buffered (LockCaching has written the whole - /// file to disk), insert an audio_cache_index row so the file shows - /// up to AudioCacheManager.evict() and clearAll(). No-op if the - /// cache manager isn't configured, no current track, the file isn't - /// fully buffered yet, the on-disk file is missing, or we already - /// registered this trackId during this subscription. - Future _maybeRegisterStreamCache() async { - final mgr = _audioCacheManager; - if (mgr == null) return; - final current = mediaItem.value; - if (current == null) return; - final trackId = current.id; - if (_streamCacheRegistered.contains(trackId)) return; - - final dur = _player.duration; - if (dur == null) return; - final buf = _player.bufferedPosition; - // 200ms slack for header bytes / encoding rounding. - if (buf < dur - const Duration(milliseconds: 200)) return; - - _cacheDirPath ??= (await getApplicationCacheDirectory()).path; - final path = '${_cacheDirPath!}/audio_cache/$trackId.mp3'; - final file = File(path); - if (!await file.exists()) return; - final size = await file.length(); - if (size <= 0) return; - - _streamCacheRegistered.add(trackId); - await mgr.registerStreamCache(trackId, path, size); - } - - /// Called when playbackEventStream emits an error. Skips past the - /// failing track so the UI and audio re-converge — without this, - /// _player goes silent on a 404 / decoder failure / EOS but - /// mediaItem stays on the failed track and the user sees a "now - /// playing" header for something that isn't. - /// - /// If we're at the last track, seekToNext is a no-op; the state - /// drops to idle and _broadcastState reflects that. - /// onError entrypoint (404 / decoder / premature EOS / network drop). - /// Routes into the unified recovery path (retry the track once, then - /// skip to the next cached track or pause) instead of immediately - /// skipping the literal next — possibly also-unreachable — source. - Future _handlePlaybackError() async { - await _recoverPlayback(); - } - - void _onCurrentIndexChanged(int? idx) { - if (idx == null) return; - if (_suppressIndexUpdates) return; - // Push the new track's MediaItem onto the mediaItem stream so - // the player UI rebuilds with the new title/artist/album/cover. - // Without this, the bar and full player stayed pinned to whichever - // track was passed via setQueueFromTracks(initialIndex:) regardless - // of skip/auto-advance. - final items = queue.value; - // Map the just_audio player index back to the logical queue index. - // During the fast-start/fill window the player list is a moving - // window offset from queue.value by _logicalIndexBase; mapping the - // raw player index straight in here is what previously broadcast - // queue.value[0] (the first track) over the correct item on every - // fast-start with initialIndex > 0. - final logical = idx + _logicalIndexBase; - if (logical >= 0 && logical < items.length) { - mediaItem.add(items[logical]); - } - unawaited(_loadArtForCurrentItem()); - } - - /// Async-fetches the cover for whichever item is currently active and - /// pushes a MediaItem update with artUri set. No-op if no cache is - /// configured, no current item, the item has no album_id in extras, - /// or the fetch returns null. - Future _loadArtForCurrentItem() async { - final cache = _coverCache; - if (cache == null) return; - final current = mediaItem.value; - if (current == null) return; - final albumId = current.extras?['album_id'] as String?; - if (albumId == null || albumId.isEmpty) return; - if (current.artUri != null) return; // already set - final path = await cache.getOrFetch(albumId); - if (path == null) return; - // Discard if the user advanced to another track while we waited. - if (mediaItem.value?.id != current.id) return; - mediaItem.add(current.copyWith(artUri: Uri.file(path))); - } - - /// Display-state-only teardown. Stops playback, clears the queue and - /// current track, and broadcasts idle so external surfaces (Wear tile, - /// notification, in-app mini bar) drop their visible state — but does - /// NOT call super.stop(), so the FGS and MediaSessionCompat remain - /// alive and addressable. - /// - /// Why this matters (Fable #472): the paired Wear OS companion app - /// caches a MediaController bound to our MediaSession's token. If - /// the system destroys our service (super.stop() → stopSelf() makes - /// it eligible under memory pressure), the next play() spins up a - /// new MediaSession with a fresh token; the companion's cached - /// controller still points at the dead one and transport taps from - /// notification + watch silently no-op. Keeping the service alive - /// across idle periods preserves the binding. - /// - /// Used by the #52 idle timeout. Full termination (super.stop) is - /// reserved for the explicit-close path (onTaskRemoved while idle). - Future _softTeardown() async { - _idleStopTimer?.cancel(); - _idleStopTimer = null; - _positionBroadcastTimer?.cancel(); - _positionBroadcastTimer = null; - _stallTimer?.cancel(); - _stallTimer = null; - try { - await _player.stop(); - } catch (_) {} - playbackState.add(playbackState.value.copyWith( - playing: false, - processingState: AudioProcessingState.idle, - )); - mediaItem.add(null); - queue.add([]); - } - - /// Full termination — soft teardown plus super.stop(), which calls - /// stopSelf() on the AudioService and lets the OS reclaim it. Reserved - /// for the explicit-close path (onTaskRemoved when not playing). On the - /// idle path we use _softTeardown instead to preserve the Wear OS - /// MediaController binding (Fable #472). - @override - Future stop() async { - await _softTeardown(); - await super.stop(); - } - - /// App swiped away from recents. Keep playing if audio is active - /// (standard media behaviour — music shouldn't die because the app - /// left recents); otherwise stop so the watch tile / notification - /// don't linger on a stale paused track. - @override - Future onTaskRemoved() async { - if (_player.playing && - _player.processingState != ProcessingState.completed) { - await super.onTaskRemoved(); - return; - } - await stop(); - } - - @override - Future play() async { - // Nothing loaded (fresh, or torn down by the #52 idle/dismiss - // teardown): a media-button press resumes the last persisted - // session instead of no-op'ing (#448). The hook restores the queue - // and starts playback itself, so we return without calling - // _player.play() on an empty player. - if (mediaItem.value == null && _resumeHook != null) { - await _resumeHook!(); - return; - } - await _player.play(); - } - - @override - Future pause() => _player.pause(); - - @override - Future seek(Duration position) => _player.seek(position); - - @override - Future skipToNext() => _player.seekToNext(); - - @override - Future skipToPrevious() => _player.seekToPrevious(); - - /// Heart rating from external surfaces (Wear's favorite button, - /// lock-screen like) → LikesController.toggle(track). We only - /// route through the bridge when the rating actually flips relative - /// to the current state, so repeated taps from a flaky controller - /// don't ping-pong the like. - @override - Future setRating(Rating rating, [Map? extras]) async { - final media = mediaItem.value; - final bridge = _likeBridge; - if (media == null || bridge == null) return; - final currentlyLiked = bridge.isTrackLiked(media.id); - final wantLiked = rating.hasHeart(); - if (currentlyLiked == wantLiked) return; - try { - await bridge.toggleTrackLike(media.id); - } catch (_) { - // LikesController already rolls back on REST failure; nothing - // to do here beyond letting the broadcast skip. - return; - } - // Re-emit so the watch's heart icon updates immediately. - mediaItem.add(media.copyWith(rating: Rating.newHeartRating(wantLiked))); - } - - /// Re-emits the current mediaItem with a fresh rating pulled from - /// the LikeBridge. Called by PlayerActions on likedIdsProvider - /// changes so the watch / lock-screen heart icon updates when the - /// user toggles a like from TrackRow, the kebab menu, or another - /// device's playback (SSE-routed). No-op if no track is playing - /// or the like state didn't change. - void refreshCurrentRating() { - final media = mediaItem.value; - final bridge = _likeBridge; - if (media == null || bridge == null) return; - final liked = bridge.isTrackLiked(media.id); - if (media.rating?.hasHeart() == liked) return; - mediaItem.add(media.copyWith(rating: Rating.newHeartRating(liked))); - } - - /// Re-broadcasts PlaybackState so the notification favorite control's - /// icon/label reflects a like toggled from elsewhere (TrackRow, kebab, - /// another device via SSE). Sibling to refreshCurrentRating, which - /// updates the Wear/lock heart via MediaItem.rating. - void refreshFavoriteControl() => _broadcastState(null); - - @override - Future setShuffleMode(AudioServiceShuffleMode shuffleMode) async { - await _player - .setShuffleModeEnabled(shuffleMode != AudioServiceShuffleMode.none); - // _broadcastState picks up the change via shuffleModeEnabledStream. - } - - @override - Future setRepeatMode(AudioServiceRepeatMode repeatMode) async { - final loop = switch (repeatMode) { - AudioServiceRepeatMode.none => LoopMode.off, - AudioServiceRepeatMode.one => LoopMode.one, - AudioServiceRepeatMode.all || AudioServiceRepeatMode.group => LoopMode.all, - }; - await _player.setLoopMode(loop); - } - - /// Sets player volume in [0.0, 1.0]. Note: most mobile browsers tie - /// page audio to system volume — this is the in-app slider for parity - /// with desktop/web; on mobile the system volume is the real control. - Future setVolume(double v) async { - await _player.setVolume(v.clamp(0.0, 1.0)); - } - - /// Handles the notification favorite control (and any future custom - /// actions). Toggles the current track's like via the LikeBridge, - /// then re-broadcasts so the heart icon/label flips. Signature - /// matches `AudioHandler.customAction` (`Future`). - @override - Future customAction(String name, - [Map? extras]) async { - if (name == 'minstrel.favorite') { - final media = mediaItem.value; - final bridge = _likeBridge; - if (media == null || bridge == null) return null; - try { - await bridge.toggleTrackLike(media.id); - } catch (_) {} - _broadcastState(null); - return null; - } - return super.customAction(name, extras); - } - - // _broadcastState accepts a nullable event because the shuffle/repeat - // listeners don't have one — we just want to re-emit PlaybackState - // with up-to-date shuffleMode/repeatMode fields. - void _broadcastState(PlaybackEvent? event) { - final playing = _player.playing; - // No custom favorite MediaControl here. audio_service builds a - // PlaybackStateCompat.CustomAction for it and throws - // "You must specify an icon resource id to build a CustomAction" - // on real builds (the androidIcon doesn't resolve to a usable id), - // and that exception aborts the ENTIRE media notification — no - // tray or Wear controls at all. Removed; like/favorite remains - // available in-app and via the standard lock-screen surface. - playbackState.add(PlaybackState( - controls: [ - MediaControl.skipToPrevious, - if (playing) MediaControl.pause else MediaControl.play, - MediaControl.skipToNext, - ], - // androidCompactActionIndices tells the system which controls - // appear in the collapsed/lock-screen view. Without this, some - // Android versions render the player without working buttons. - androidCompactActionIndices: const [0, 1, 2], - // systemActions enumerates which actions the system can invoke - // on us — without play/pause/skip in here, taps on lock-screen - // controls don't route back to the handler on Android 13+. - // - // v2026.05.13.3 added stop, skipToQueueItem, setShuffleMode, - // setRepeatMode, and setRating to this set; reverted because - // Pixel Watch 2 stopped showing controls entirely after that - // change. The MediaSession contract on Wear OS requires more - // setup than just advertising the action (e.g. setRatingType - // for setRating) and audio_service doesn't expose those knobs. - // Keep the override methods themselves (skipToQueueItem is - // still routed via QueueScreen's direct handler call; - // setRating is harmless if never invoked) so we don't lose - // the underlying functionality — just don't tell the system - // we support them. - systemActions: const { - MediaAction.play, - MediaAction.pause, - MediaAction.skipToNext, - MediaAction.skipToPrevious, - MediaAction.seek, - }, - processingState: switch (_player.processingState) { - ProcessingState.idle => AudioProcessingState.idle, - ProcessingState.loading => AudioProcessingState.loading, - ProcessingState.buffering => AudioProcessingState.buffering, - ProcessingState.ready => AudioProcessingState.ready, - ProcessingState.completed => AudioProcessingState.completed, - }, - playing: playing, - updatePosition: _player.position, - bufferedPosition: _player.bufferedPosition, - speed: _player.speed, - queueIndex: event?.currentIndex ?? _player.currentIndex, - shuffleMode: _player.shuffleModeEnabled - ? AudioServiceShuffleMode.all - : AudioServiceShuffleMode.none, - repeatMode: switch (_player.loopMode) { - LoopMode.off => AudioServiceRepeatMode.none, - LoopMode.one => AudioServiceRepeatMode.one, - LoopMode.all => AudioServiceRepeatMode.all, - }, - )); - _reconcileIdleTimer(); - _reconcilePositionBroadcast(); - _reconcileStallWatchdog(); - } - - /// Arms (or cancels) the idle-cleanup timer based on the current - /// player state. Driven from _broadcastState, which fires on every - /// play/pause/buffer/complete transition, so paused AND finished-queue - /// both arm it. Idle (already stopped) never re-arms — otherwise stop() - /// would loop every _idleStopTimeout. - void _reconcileIdleTimer() { - final ps = _player.processingState; - if (ps == ProcessingState.idle) { - _idleStopTimer?.cancel(); - _idleStopTimer = null; - return; - } - final activelyPlaying = - _player.playing && ps != ProcessingState.completed; - if (activelyPlaying) { - _idleStopTimer?.cancel(); - _idleStopTimer = null; - return; - } - _idleStopTimer ??= Timer(_idleStopTimeout, _onIdleTimeout); - } - - void _onIdleTimeout() { - _idleStopTimer = null; - final ps = _player.processingState; - if (ps == ProcessingState.idle) return; // already torn down - if (_player.playing && ps != ProcessingState.completed) { - return; // resumed between arm and fire - } - // _softTeardown — not full stop() — to preserve the MediaSession - // binding for the paired Wear OS companion (Fable #472). - unawaited(_softTeardown()); - } - - /// While actively playing, keeps a 1s periodic PlaybackState re- - /// broadcast running so updateTime/updatePosition stay fresh and - /// external surfaces interpolate the scrubber smoothly. Idempotent and - /// driven from _broadcastState — which the periodic tick itself calls, - /// so the "already running" guard prevents pile-up. Cancels the moment - /// playback is no longer active. - void _reconcilePositionBroadcast() { - final ps = _player.processingState; - final active = _player.playing && - ps != ProcessingState.idle && - ps != ProcessingState.completed; - if (!active) { - _positionBroadcastTimer?.cancel(); - _positionBroadcastTimer = null; - return; - } - _positionBroadcastTimer ??= Timer.periodic( - const Duration(seconds: 1), - (_) => _broadcastState(null), - ); - } - - /// Buffering-stall watchdog. Armed while playing+buffering/loading. - /// On fire: still stuck with no >=1s buffered progress → dead stream, - /// recover; progressing → re-arm a fresh window (slow-but-downloading - /// is fine). When the track is happily ready+playing, clears the - /// per-track recovery budget. Driven from _broadcastState; mirrors - /// the other reconcile helpers. - void _reconcileStallWatchdog() { - final ps = _player.processingState; - if (ps == ProcessingState.ready && _player.playing) { - _recoveringTrackId = null; - _recoverAttempts = 0; - } - final buffering = _player.playing && - (ps == ProcessingState.buffering || ps == ProcessingState.loading); - if (!buffering) { - _stallTimer?.cancel(); - _stallTimer = null; - return; - } - if (_stallTimer != null) return; // window already running - _stallMarkBuffered = _player.bufferedPosition; - _stallTimer = Timer(_stallTimeout, () { - _stallTimer = null; - final ps2 = _player.processingState; - final stillBuffering = _player.playing && - (ps2 == ProcessingState.buffering || - ps2 == ProcessingState.loading); - if (!stillBuffering) return; - final progressed = (_player.bufferedPosition - _stallMarkBuffered) >= - const Duration(seconds: 1); - if (progressed) { - _reconcileStallWatchdog(); // slow but downloading — re-arm - } else { - unawaited(_recoverPlayback()); - } - }); - } - - /// Unified recovery for a failed OR stalled stream. Retries the - /// current track once (rebuilt from scratch via skipToQueueItem so a - /// transient blip doesn't lose it); on exhaustion, surfaces the - /// failure (#58 SnackBar) and skips to the next cached track — or - /// pauses — instead of blindly walking into more unreachable streams. - Future _recoverPlayback() async { - _stallTimer?.cancel(); - _stallTimer = null; - final media = mediaItem.value; - if (media == null) return; - final trackId = media.id; - final curIdx = _lastTracks.indexWhere((t) => t.id == trackId); - if (curIdx < 0) { - try { - await _player.pause(); - } catch (_) {} - return; - } - if (_recoveringTrackId != trackId) { - _recoveringTrackId = trackId; - _recoverAttempts = 0; - } - if (_recoverAttempts < _maxRecoverRetries) { - _recoverAttempts++; - // Rebuild + restart the SAME track (fresh source / HTTP). - await skipToQueueItem(curIdx); - return; - } - if (media.title.isNotEmpty) _playbackErrors.add(media.title); - _recoveringTrackId = null; - _recoverAttempts = 0; - await _skipToNextCachedOrPause(curIdx); - } - - /// Skips to the first track after [fromIdx] that is fully cached on - /// disk (plays with zero network). If none, pauses rather than - /// thrashing through unreachable streams on a dead connection. - Future _skipToNextCachedOrPause(int fromIdx) async { - final mgr = _audioCacheManager; - if (mgr != null) { - for (var i = fromIdx + 1; i < _lastTracks.length; i++) { - if (await mgr.pathFor(_lastTracks[i].id) != null) { - await skipToQueueItem(i); - return; - } - } - } - try { - await _player.pause(); - } catch (_) {} - } - - /// Configures the OS audio session for music playback and wires - /// interruption + becoming-noisy handling. just_audio auto-activates / - /// deactivates the session around playback once it's configured, but - /// does NOT handle interruptions or the headphones-unplugged event - /// itself — that's done here. Best effort: a failure must not break - /// playback. - Future _configureAudioSession() async { - try { - final session = await AudioSession.instance; - await session.configure(const AudioSessionConfiguration.music()); - session.interruptionEventStream.listen(_onInterruption); - session.becomingNoisyEventStream.listen((_) { - // Headphones unplugged / Bluetooth disconnected — never blast the - // phone speaker; pause like every other media app. - unawaited(_player.pause()); - }); - } catch (e, st) { - debugPrint('audio_handler: audio session configure failed: $e\n$st'); - } - } - - void _onInterruption(AudioInterruptionEvent event) { - if (event.begin) { - switch (event.type) { - case AudioInterruptionType.duck: - // Transient: another app wants the foreground briefly. Lower - // our volume rather than stopping (OS may also auto-duck). - _volumeBeforeDuck = _player.volume; - unawaited(_player.setVolume(0.3)); - case AudioInterruptionType.pause: - case AudioInterruptionType.unknown: - // Call / other media took focus. Remember whether we were - // actively playing so a transient end can resume us. - _interruptedWhilePlaying = _player.playing && - _player.processingState != ProcessingState.completed; - unawaited(_player.pause()); - } - } else { - switch (event.type) { - case AudioInterruptionType.duck: - unawaited(_player.setVolume(_volumeBeforeDuck ?? 1.0)); - _volumeBeforeDuck = null; - case AudioInterruptionType.pause: - // Transient interruption ended — resume only if WE paused it - // and the session is still alive (a long call may have let the - // #52 idle timer tear it down; re-init is resume-last-session - // territory, out of scope here). - if (_interruptedWhilePlaying && - _player.processingState != ProcessingState.idle) { - unawaited(_player.play()); - } - _interruptedWhilePlaying = false; - case AudioInterruptionType.unknown: - // Unknown end — do not auto-resume (could surprise the user). - _interruptedWhilePlaying = false; - } - } - } -} - -/// Adapter that lets the audio handler call into the app's -/// LikesController without depending on Riverpod directly. Constructed -/// in PlayerActions where ref is available; consumed inside the audio -/// handler's setRating override and MediaItem builder to keep external -/// media controllers (Wear, lock screen, Auto) in sync with the -/// likedIds drift cache. -class LikeBridge { - const LikeBridge({ - required this.toggleTrackLike, - required this.isTrackLiked, - }); - - /// Flip the like state for [trackId]. Returns a Future that resolves - /// once the underlying LikesController has both updated drift - /// optimistically and rolled the change through the REST API - /// (errors result in drift rollback inside LikesController). - final Future Function(String trackId) toggleTrackLike; - - /// Read the current like state for [trackId] from the drift-backed - /// likedIdsProvider. Synchronous because the audio handler needs - /// to populate MediaItem.rating on the broadcast hot path. - final bool Function(String trackId) isTrackLiked; -} diff --git a/flutter_client/lib/player/now_playing_screen.dart b/flutter_client/lib/player/now_playing_screen.dart deleted file mode 100644 index 9301e477..00000000 --- a/flutter_client/lib/player/now_playing_screen.dart +++ /dev/null @@ -1,655 +0,0 @@ -import 'dart:io'; - -import 'package:audio_service/audio_service.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_lucide/flutter_lucide.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; - -import '../api/endpoints/likes.dart' show LikeKind; -import '../likes/like_button.dart'; -import '../models/track.dart'; -import '../shared/widgets/server_image.dart'; -import '../shared/widgets/track_actions/track_actions_button.dart'; -import '../theme/theme_extension.dart'; -import 'album_color_extractor.dart'; -import 'player_provider.dart'; - -/// Hero tag shared between the mini player's cover and the full-screen -/// _AlbumArt's cover so tapping the mini bar animates the artwork from -/// the bar's footprint to the full-screen size. Stable per-route (not -/// keyed by media.id) so the transition works regardless of what's -/// playing. -const String kPlayerCoverHeroTag = 'player-cover'; - -/// Duration for the AnimatedSwitcher / AnimatedContainer track-change -/// crossfade. ~300ms reads as a smooth transition without dragging. -const Duration _trackChangeDuration = Duration(milliseconds: 300); - -/// Full-screen player. Mounted on /now-playing. Pushed via a slide-up -/// transition (see routing.dart). Dismisses three ways: -/// -/// - System back / leading button → Navigator.pop -/// - Vertical drag down past threshold → Navigator.pop (matches the -/// slide-down animation users expect from a "pull down to close" -/// sheet) -/// - Tap of the mini player ascends here; reverse motion descends back. -class NowPlayingScreen extends ConsumerStatefulWidget { - const NowPlayingScreen({super.key}); - - @override - ConsumerState createState() => _NowPlayingScreenState(); -} - -class _NowPlayingScreenState extends ConsumerState { - // Cumulative drag distance used to decide if a vertical drag should - // dismiss. Reset on each drag start. - double _dragOffset = 0; - - /// The MediaItem currently displayed on the screen. Held separately - /// from the live mediaItemProvider so we can preload the new track's - /// cover bytes + dominant color BEFORE flipping the visible state. - /// Without this gate the full-player UI swapped to the new track's - /// title/cover slot immediately while the image was still decoding, - /// producing a visible "pop in" once the bytes arrived. - MediaItem? _displayedMedia; - - /// Dominant color of [_displayedMedia]'s cover. Tweened by the - /// backdrop AnimatedContainer when it changes. - Color? _displayedDominant; - - /// The id of the most-recent track we kicked a preload for. Used to - /// drop stale preload completions when the user skips rapidly past - /// a track whose cover hadn't finished decoding yet. - String? _pendingPreloadId; - - @override - void initState() { - super.initState(); - // Seed displayed state from the current mediaItem if a track is - // already playing when the full player is opened. ref.listen - // below only fires on CHANGES after subscription, so without - // this seed the screen sits on "Nothing playing." even though - // the mini bar shows a live track — the listener never gets a - // mediaItem emission because the stream's current value hasn't - // changed. - final current = ref.read(mediaItemProvider).value; - if (current == null) return; - _displayedMedia = current; - // Best-effort synchronous color seed: if albumColorProvider has - // already extracted this album's dominant color earlier in the - // session, pull it now so the backdrop renders correctly on - // first frame. Otherwise the post-frame _scheduleSwap below - // resolves it asynchronously and AnimatedContainer tweens. - final albumId = current.extras?['album_id'] as String?; - if (albumId != null && albumId.isNotEmpty) { - _displayedDominant = - ref.read(albumColorProvider(albumId)).asData?.value; - } - // Kick the preload pipeline post-frame so the cover bytes are - // decoded + the dominant color is resolved even when the user - // opens the player to a track that hasn't yet flowed through - // ref.listen. - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) return; - _scheduleSwap(current); - }); - } - - /// Preload the new track's cover bytes + dominant color, then - /// atomically flip _displayedMedia / _displayedDominant. Until both - /// resolve, the screen continues to render the previous track — - /// the new title/cover/gradient appear together in one frame. - /// - /// Concurrency: if a second track change arrives while the first - /// preload is still in flight, the older preload's - /// _pendingPreloadId check fails and its completion is dropped. - Future _scheduleSwap(MediaItem newMedia) async { - _pendingPreloadId = newMedia.id; - - // Fast path: when Prefetcher has already warmed both the cover - // bytes (file:// artUri populated via _toMediaItem's peekCached) - // AND the palette color (AlbumColorCache.peekColor returns - // non-null), we can commit synchronously. The slow async path - // exists for genuine cold-cache moments (first play of an album - // that sync hasn't seen yet); the fast path is what makes the - // common in-queue auto-advance flip the visual in lockstep with - // the audio transition instead of lagging a tick behind. - final artUri = newMedia.artUri; - final albumId = newMedia.extras?['album_id'] as String?; - if (artUri != null && - artUri.isScheme('file') && - albumId != null && - albumId.isNotEmpty) { - final cachedColor = ref.read(albumColorCacheProvider).peekColor(albumId); - if (cachedColor != null) { - if (!mounted) return; - if (_pendingPreloadId != newMedia.id) return; - setState(() { - _displayedMedia = newMedia; - _displayedDominant = cachedColor; - }); - return; - } - } - - // Slow path: cover and/or color are not yet cached. Hold the - // current displayed state, preload, then atomic-commit. - // - // 1. Precache the cover image bytes so when _AlbumArt mounts with - // the new media, FileImage paints synchronously. Non-file - // artUris fall through to ServerImage which handles its own - // network load + 120ms fade — they won't snap. - if (artUri != null && artUri.isScheme('file') && context.mounted) { - try { - await precacheImage(FileImage(File.fromUri(artUri)), context); - } catch (_) { - // Decode failed (missing file, corrupt bytes) — proceed - // anyway; _AlbumArt's errorBuilder shows the slate fallback. - } - } - - // 2. Wait for the dominant-color extraction so the gradient is - // populated when we swap. PaletteGenerator runs against the - // same FileImage, typically resolving within ~50ms once the - // decode completes. - Color? newDominant; - if (albumId != null && albumId.isNotEmpty) { - try { - newDominant = await ref.read(albumColorProvider(albumId).future); - } catch (_) { - // Color extraction failed — keep the previous dominant so the - // backdrop doesn't drop to obsidian on the swap. - } - } - - // 3. Atomic flip. Bail if a newer swap superseded us, or the - // screen was disposed mid-preload. - if (!mounted) return; - if (_pendingPreloadId != newMedia.id) return; - setState(() { - _displayedMedia = newMedia; - if (newDominant != null) _displayedDominant = newDominant; - }); - } - - void _onDragStart(DragStartDetails _) { - _dragOffset = 0; - } - - void _onDragUpdate(DragUpdateDetails d) { - _dragOffset += d.delta.dy; - } - - void _onDragEnd(DragEndDetails d) { - // Pop when either the user has dragged > 80px down OR flicked - // downward at speed (>500 px/s). Either should feel responsive - // without dismissing on accidental drags. - final flicked = d.primaryVelocity != null && d.primaryVelocity! > 500; - if (_dragOffset > 80 || flicked) { - Navigator.of(context).maybePop(); - } - _dragOffset = 0; - } - - @override - Widget build(BuildContext context) { - final fs = Theme.of(context).extension()!; - - // Listen for mediaItem changes and schedule a preload-then-swap. - // Build renders _displayedMedia / _displayedDominant; the live - // mediaItemProvider only drives the swap pipeline. - // - // Decision process: - // - On the first non-null mediaItem after mount, seed - // _displayedMedia immediately so the screen has something to - // paint. - // - On a track id change, kick off a preload. The new track's - // cover bytes + dominant color must both resolve before we - // flip the displayed state. The user sees the previous track - // in full until the new one is fully ready, then a clean - // atomic swap (no fade, no placeholder flash). - // - On the same track id but the artUri-bearing rebroadcast - // (audio_handler sends MediaItem twice on track change), - // refresh through the same preload pipeline so the displayed - // cover reflects the freshly-written AlbumCoverCache file. - ref.listen>(mediaItemProvider, (prev, next) { - final newMedia = next.asData?.value; - if (newMedia == null) { - if (_displayedMedia != null) { - setState(() { - _displayedMedia = null; - _displayedDominant = null; - _pendingPreloadId = null; - }); - // Session was torn down (#52 idle/dismiss) while the full - // player was open. Don't strand the user on an empty - // "Nothing playing." screen — minimize back (the mini bar is - // already gone too). maybePop is a no-op if this is somehow - // the root route. - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) Navigator.of(context).maybePop(); - }); - } - return; - } - if (_displayedMedia == null) { - // First mount with a track playing — seed immediately, then - // kick the preload pipeline to update the dominant color and - // ensure the cover is decoded. - setState(() => _displayedMedia = newMedia); - _scheduleSwap(newMedia); - return; - } - final isNewTrack = newMedia.id != _displayedMedia!.id; - final coverGotPopulated = - newMedia.id == _displayedMedia!.id && - newMedia.artUri != null && - _displayedMedia!.artUri == null; - if (isNewTrack || coverGotPopulated) { - _scheduleSwap(newMedia); - } - }); - - final playback = ref.watch(playbackStateProvider).value; - final displayedMedia = _displayedMedia; - - if (displayedMedia == null) { - // Either nothing playing, or the very first mount before a - // mediaItem has been emitted. The ref.listen above will seed - // _displayedMedia as soon as a non-null MediaItem arrives. - return const Scaffold(body: Center(child: Text('Nothing playing.'))); - } - - // Use positionProvider (just_audio's positionStream, ~200ms) for - // the seek bar so it scrubs live; PlaybackState.updatePosition - // only fires on state transitions and would leave the bar frozen - // between them. - final pos = ref.watch(positionProvider).value ?? Duration.zero; - final dur = displayedMedia.duration ?? Duration.zero; - final isPlaying = playback?.playing == true; - final shuffleOn = playback?.shuffleMode == AudioServiceShuffleMode.all; - final repeatMode = playback?.repeatMode ?? AudioServiceRepeatMode.none; - final actions = ref.read(playerActionsProvider); - final albumId = (displayedMedia.extras?['album_id'] as String?) ?? ''; - - // Backdrop color: render the dominant we've already committed to - // _displayedDominant. AnimatedContainer tweens between successive - // values, so the only color changes the user sees are the - // atomic-with-cover swaps from _scheduleSwap. 0.55 alpha keeps - // the gradient present without overwhelming the title/artist - // text below. - final dominant = _displayedDominant ?? fs.obsidian; - final gradientTop = dominant.withValues(alpha: 0.55); - - return Scaffold( - backgroundColor: fs.obsidian, - // The whole screen accepts vertical drag for dismissal. Buttons - // and the seek slider are still tappable since gesture arena - // gives priority to their child gesture detectors. - body: GestureDetector( - onVerticalDragStart: _onDragStart, - onVerticalDragUpdate: _onDragUpdate, - onVerticalDragEnd: _onDragEnd, - behavior: HitTestBehavior.translucent, - child: AnimatedContainer( - duration: _trackChangeDuration, - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [gradientTop, fs.obsidian], - stops: const [0.0, 0.7], - ), - ), - child: SafeArea( - child: Column( - children: [ - _TopBar(fs: fs), - Expanded( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 24), - child: Column( - children: [ - const Spacer(), - // No AnimatedSwitcher: _displayedMedia only - // advances after the preload pipeline has the - // new cover + color ready, so a track change - // is an atomic swap. Fading would just smear a - // transition the user explicitly asked us not - // to add. - _AlbumArt( - media: displayedMedia, albumId: albumId, fs: fs), - const SizedBox(height: 28), - _TitleRow(media: displayedMedia, fs: fs), - const SizedBox(height: 4), - Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - displayedMedia.artist ?? '', - style: TextStyle(color: fs.ash, fontSize: 14), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - if ((displayedMedia.album ?? '').isNotEmpty) ...[ - const SizedBox(height: 2), - Text( - displayedMedia.album!, - style: TextStyle(color: fs.ash, fontSize: 12), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - ], - ), - const SizedBox(height: 24), - _SecondaryControls( - fs: fs, - actions: actions, - shuffleOn: shuffleOn, - repeatMode: repeatMode, - media: displayedMedia, - ), - const SizedBox(height: 8), - _SeekRow(position: pos, duration: dur, fs: fs, ref: ref), - const SizedBox(height: 24), - _PrimaryControls( - fs: fs, - ref: ref, - isPlaying: isPlaying, - ), - const SizedBox(height: 24), - ], - ), - ), - ), - ], - ), - ), - ), - ), - ); - } -} - -class _TopBar extends StatelessWidget { - const _TopBar({required this.fs}); - final FabledSwordTheme fs; - - @override - Widget build(BuildContext context) { - return SizedBox( - height: 48, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 4), - child: Row(children: [ - IconButton( - icon: Icon(LucideIcons.chevron_down, color: fs.parchment), - tooltip: 'Close', - onPressed: () => Navigator.of(context).maybePop(), - ), - const Spacer(), - IconButton( - icon: Icon(LucideIcons.list_music, color: fs.parchment), - tooltip: 'Queue', - onPressed: () => GoRouter.of(context).push('/queue'), - ), - ]), - ), - ); - } -} - -class _AlbumArt extends StatelessWidget { - const _AlbumArt({required this.media, required this.albumId, required this.fs}); - final MediaItem media; - final String albumId; - final FabledSwordTheme fs; - - @override - Widget build(BuildContext context) { - // Pick the best available source. AlbumCoverCache writes a file:// - // URI to media.artUri once the cover lands on disk; before then, - // fall back to fetching via ServerImage from /api/albums//cover - // (which carries the auth header and falls back gracefully on - // failure). - Widget cover; - final artUri = media.artUri; - if (artUri != null && artUri.isScheme('file')) { - cover = Image( - image: FileImage(File.fromUri(artUri)), - fit: BoxFit.cover, - errorBuilder: (_, __, ___) => Container(color: fs.slate), - ); - } else if (albumId.isNotEmpty) { - cover = ServerImage( - url: '/api/albums/$albumId/cover', - fit: BoxFit.cover, - fallback: Container(color: fs.slate), - ); - } else { - cover = Container(color: fs.slate); - } - return AspectRatio( - aspectRatio: 1, - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 320, maxHeight: 320), - child: Hero( - tag: kPlayerCoverHeroTag, - // flightShuttleBuilder ensures the in-flight Hero renders the - // destination's cover image (not the mini bar's small one) - // during the entire animation, which reads as a smooth grow - // rather than a swap mid-flight. - flightShuttleBuilder: - (_, __, ___, ____, toHeroContext) => toHeroContext.widget, - child: ClipRRect( - borderRadius: BorderRadius.circular(8), - child: cover, - ), - ), - ), - ); - } -} - -class _TitleRow extends StatelessWidget { - const _TitleRow({required this.media, required this.fs}); - final MediaItem media; - final FabledSwordTheme fs; - - @override - Widget build(BuildContext context) { - // Title-only — like + kebab moved into _SecondaryControls above - // the seek bar so they share the same row as shuffle/repeat/queue. - return Text( - media.title, - style: TextStyle(color: fs.parchment, fontSize: 22), - maxLines: 1, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.center, - ); - } -} - -class _SeekRow extends StatelessWidget { - const _SeekRow({ - required this.position, - required this.duration, - required this.fs, - required this.ref, - }); - final Duration position; - final Duration duration; - final FabledSwordTheme fs; - final WidgetRef ref; - - String _fmt(Duration d) { - final m = d.inMinutes.remainder(60).toString(); - final s = d.inSeconds.remainder(60).toString().padLeft(2, '0'); - return '$m:$s'; - } - - @override - Widget build(BuildContext context) { - final maxMs = duration.inMilliseconds.toDouble().clamp(1.0, double.infinity); - return Column(children: [ - SliderTheme( - data: SliderTheme.of(context).copyWith( - trackHeight: 3, - thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 7), - overlayShape: const RoundSliderOverlayShape(overlayRadius: 14), - ), - child: Slider( - activeColor: fs.accent, - inactiveColor: fs.slate, - min: 0, - max: maxMs, - value: position.inMilliseconds - .toDouble() - .clamp(0.0, duration.inMilliseconds.toDouble()), - onChanged: (v) => ref - .read(audioHandlerProvider) - .seek(Duration(milliseconds: v.toInt())), - ), - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 4), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text(_fmt(position), style: TextStyle(color: fs.ash, fontSize: 11)), - Text(_fmt(duration), style: TextStyle(color: fs.ash, fontSize: 11)), - ], - ), - ), - ]); - } -} - -class _PrimaryControls extends StatelessWidget { - const _PrimaryControls({ - required this.fs, - required this.ref, - required this.isPlaying, - }); - final FabledSwordTheme fs; - final WidgetRef ref; - final bool isPlaying; - - @override - Widget build(BuildContext context) { - return Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - IconButton( - iconSize: 36, - icon: Icon(LucideIcons.skip_back, color: fs.parchment), - onPressed: () => ref.read(audioHandlerProvider).skipToPrevious(), - ), - IconButton( - iconSize: 72, - icon: Icon( - isPlaying ? LucideIcons.circle_pause : LucideIcons.circle_play, - color: fs.accent, - ), - onPressed: () { - final h = ref.read(audioHandlerProvider); - if (isPlaying) { - h.pause(); - } else { - h.play(); - } - }, - ), - IconButton( - iconSize: 36, - icon: Icon(LucideIcons.skip_forward, color: fs.parchment), - onPressed: () => ref.read(audioHandlerProvider).skipToNext(), - ), - ], - ); - } -} - -/// Action row sitting just above the seek bar. Holds shuffle / repeat -/// / queue plus the like + kebab that used to live in the title row, -/// so the title can sit truly centered above and this row carries -/// every per-track action in one place. -class _SecondaryControls extends StatelessWidget { - const _SecondaryControls({ - required this.fs, - required this.actions, - required this.shuffleOn, - required this.repeatMode, - required this.media, - }); - final FabledSwordTheme fs; - final PlayerActions actions; - final bool shuffleOn; - final AudioServiceRepeatMode repeatMode; - final MediaItem media; - - @override - Widget build(BuildContext context) { - return Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - IconButton( - tooltip: shuffleOn ? 'Shuffle on' : 'Shuffle off', - icon: Icon( - LucideIcons.shuffle, - color: shuffleOn ? fs.accent : fs.ash, - ), - onPressed: actions.toggleShuffle, - ), - IconButton( - tooltip: switch (repeatMode) { - AudioServiceRepeatMode.none => 'Repeat off', - AudioServiceRepeatMode.one => 'Repeat one', - _ => 'Repeat all', - }, - icon: Icon( - repeatMode == AudioServiceRepeatMode.one - ? LucideIcons.repeat_1 - : LucideIcons.repeat, - color: repeatMode == AudioServiceRepeatMode.none - ? fs.ash - : fs.accent, - ), - onPressed: actions.cycleRepeat, - ), - IconButton( - tooltip: 'Queue', - icon: Icon(LucideIcons.list_music, color: fs.ash), - onPressed: () => GoRouter.of(context).push('/queue'), - ), - LikeButton(kind: LikeKind.track, id: media.id, size: 22), - TrackActionsButton( - track: TrackRef( - id: media.id, - title: media.title, - albumId: (media.extras?['album_id'] as String?) ?? '', - albumTitle: media.album ?? '', - artistId: (media.extras?['artist_id'] as String?) ?? '', - artistName: media.artist ?? '', - durationSec: media.duration?.inSeconds ?? 0, - streamUrl: '', - ), - hideQueueActions: true, - // /now-playing lives outside the ShellRoute. Pushing - // /artists/:id or /albums/:id (both shell-children) on top - // of it would make go_router try to mount a duplicate - // ShellRoute (the same _debugCheckDuplicatedPageKeys crash - // we hit before with /queue). Await our own pop fully - // before pushing the destination so go_router never sees - // both pages active in the same frame. - onNavigate: (path) async { - await Navigator.of(context).maybePop(); - if (context.mounted) GoRouter.of(context).push(path); - }, - ), - ], - ); - } -} diff --git a/flutter_client/lib/player/play_events_reporter.dart b/flutter_client/lib/player/play_events_reporter.dart deleted file mode 100644 index a06d2630..00000000 --- a/flutter_client/lib/player/play_events_reporter.dart +++ /dev/null @@ -1,277 +0,0 @@ -// Flutter play-event lifecycle reporter (#415 stage 3). -// -// The Flutter client previously reported NO plays — listening on -// mobile never reached the server's play_events, so history, -// recommendation scoring, ListenBrainz scrobbles, and (since #415) -// system-playlist rotation all missed mobile activity entirely. This -// closes that gap and is the path that carries the #415 `source` tag. -// -// State machine over (current track id, playing). Mirrors the web -// events dispatcher, with one deliberate divergence: when a track -// changes inside a queue we classify ended-vs-skipped by whether the -// prior track reached (near) its duration, instead of the web -// dispatcher's blanket "track change = skip". Blanket-skip would mark -// every naturally-finished in-queue track as a skip and dilute the -// recommendation skip-ratio — the exact failure mode that motivated -// doing this properly. (The web dispatcher likely has the same -// false-skip issue; flagged separately, not fixed here.) - -import 'dart:async'; -import 'dart:math'; - -import 'package:audio_service/audio_service.dart'; -import 'package:flutter/widgets.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../api/endpoints/events.dart'; -import '../auth/auth_provider.dart' show secureStorageProvider; -import '../cache/mutation_queue.dart' - show MutationKinds, mutationQueueProvider; -import '../library/library_providers.dart' show dioProvider; -import 'audio_handler.dart' show MinstrelAudioHandler; -import 'player_provider.dart' show audioHandlerProvider; - -const _clientIdKey = 'play_events_client_id'; - -/// Tolerance for "the track basically finished": within 3s of the -/// known duration counts as a natural completion, not a skip. -const _completionToleranceMs = 3000; - -class PlayEventsReporter with WidgetsBindingObserver { - PlayEventsReporter(this._ref); - final Ref _ref; - - final _subs = >[]; - bool _disposed = false; - - String? _clientId; - EventsApi? _api; - - // Server play_event_id when the live play_started succeeded; null - // if start failed / offline — then the close is captured into the - // offline mutation queue instead of a live ended/skipped call. - String? _openPlayEventId; - - // The play currently being tracked, captured independently of - // connectivity so an offline play is still a complete record. - String? _curTrackId; - DateTime? _curStartedAt; - String? _curSource; - int _curLastPositionMs = 0; - int _curDurationMs = 0; - bool _curReachedEnd = false; - - String? _prevTrackId; - - Future start() async { - final MinstrelAudioHandler handler; - try { - // audioHandlerProvider throws until main() overrides it (real - // app always does). In tests / no-audio environments there's - // nothing to report — fail safe and stay inert rather than - // surfacing an unhandled async error. - handler = _ref.read(audioHandlerProvider); - _clientId = await _resolveClientId(); - final dio = await _ref.read(dioProvider.future); - _api = EventsApi(dio); - } catch (_) { - return; - } - if (_disposed) return; - WidgetsBinding.instance.addObserver(this); - - _subs.add(handler.positionStream.listen((p) { - final ms = p.inMilliseconds; - // Advance the tracked play's progress ONLY while its track is - // the current one. A track change resets position to 0; gating - // on _curTrackId keeps the finishing track's last-known values - // intact for the close branch. - final mi = handler.mediaItem.value; - if (mi != null && mi.id == _curTrackId) { - _curLastPositionMs = ms; - final d = mi.duration; - if (d != null && d.inMilliseconds > 0) { - _curDurationMs = d.inMilliseconds; - if (ms >= _curDurationMs - _completionToleranceMs) { - _curReachedEnd = true; - } - } - } - })); - _subs.add(handler.mediaItem.listen((_) => _evaluate(handler))); - _subs.add(handler.playbackState.listen((_) => _evaluate(handler))); - } - - void _evaluate(MinstrelAudioHandler handler) { - if (_disposed) return; - final mi = handler.mediaItem.value; - final st = handler.playbackState.value; - final tid = mi?.id; - final playing = st.playing; - final completed = st.processingState == AudioProcessingState.completed; - - // Track changed → close the prior tracked play. - if (tid != _prevTrackId && _curTrackId != null) { - _closeCurrent(viaOffline: false); - } - - // Entered playing for a new track → begin tracking it. - if (tid != null && playing && _curTrackId != tid) { - _beginTrack(handler, tid); - } - - // Whole-queue natural end (just_audio only emits `completed` at - // the end of the sequence, not between items) → close it. - if (completed && _curTrackId != null) { - _curReachedEnd = true; - _closeCurrent(viaOffline: false); - } - - _prevTrackId = tid; - } - - void _beginTrack(MinstrelAudioHandler handler, String trackId) { - _curTrackId = trackId; - _curStartedAt = DateTime.now().toUtc(); - _curSource = handler.queueSource; - _curLastPositionMs = 0; - _curReachedEnd = false; - final d = handler.mediaItem.value?.duration; - _curDurationMs = d?.inMilliseconds ?? 0; - _openPlayEventId = null; - // Fire the live play_started; adopt the server id only if we're - // still on this track when the response lands. Failure is fine — - // the close path captures the whole play into the offline queue. - _startLive(handler, trackId); - } - - Future _startLive(MinstrelAudioHandler handler, String trackId) async { - final api = _api; - final cid = _clientId; - if (api == null || cid == null) return; - try { - final id = await api.playStarted( - trackId: trackId, - clientId: cid, - source: _curSource, - ); - if (id != null && _curTrackId == trackId) { - _openPlayEventId = id; - } - } catch (_) { - // Offline / flaky — _openPlayEventId stays null; the close path - // enqueues the completed play for replay. - } - } - - /// Closes the currently-tracked play. `finished` is derived from - /// whether it reached ~its duration. If the live start registered a - /// server id we attempt the live ended/skipped close and fall back - /// to the offline queue on failure; with no server id (offline - /// start) — or viaOffline (app teardown, must be durable) — the - /// completed play is enqueued directly. The server's RecordOffline - /// Play applies the canonical skip rule, so the offline payload - /// only needs duration, not our finished/skipped guess. - void _closeCurrent({required bool viaOffline}) { - final trackId = _curTrackId; - final startedAt = _curStartedAt; - if (trackId == null || startedAt == null) { - _resetCurrent(); - return; - } - final reached = _curReachedEnd; - final lastPos = _curLastPositionMs; - final durationMs = (reached && _curDurationMs > 0) - ? _curDurationMs - : lastPos; - final source = _curSource; - final id = _openPlayEventId; - - if (!viaOffline && id != null) { - // Live close; on failure, fall back to the durable offline path - // so a transient blip at close time doesn't lose the play. - final fut = reached - ? _api?.playEnded(playEventId: id, durationPlayedMs: durationMs) - : _api?.playSkipped(playEventId: id, positionMs: lastPos); - fut?.catchError((_) { - _enqueueOffline(trackId, startedAt, source, durationMs); - }); - } else { - _enqueueOffline(trackId, startedAt, source, durationMs); - } - _resetCurrent(); - } - - void _resetCurrent() { - _curTrackId = null; - _curStartedAt = null; - _curSource = null; - _curLastPositionMs = 0; - _curDurationMs = 0; - _curReachedEnd = false; - _openPlayEventId = null; - } - - void _enqueueOffline( - String trackId, - DateTime startedAt, - String? source, - int durationPlayedMs, - ) { - final cid = _clientId; - if (cid == null) return; - // ignore: unawaited_futures - _ref.read(mutationQueueProvider).enqueue(MutationKinds.playOffline, { - 'trackId': trackId, - 'clientId': cid, - 'at': startedAt.toIso8601String(), - 'durationPlayedMs': durationPlayedMs, - if (source != null && source.isNotEmpty) 'source': source, - }); - } - - @override - void didChangeAppLifecycleState(AppLifecycleState state) { - // App backgrounded / killed mid-play: close durably via the - // offline queue (a fire-and-forget POST during teardown is - // unreliable; the queue survives a process kill and drains on - // next launch). Mirrors the intent of web's pagehide beacon. - if (state == AppLifecycleState.paused || - state == AppLifecycleState.detached) { - if (_curTrackId != null) { - _closeCurrent(viaOffline: true); - } - } - } - - Future _resolveClientId() async { - final storage = _ref.read(secureStorageProvider); - final existing = await storage.read(key: _clientIdKey); - if (existing != null && existing.isNotEmpty) return existing; - final rnd = Random.secure(); - final bytes = List.generate(16, (_) => rnd.nextInt(256)); - final id = - bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); - await storage.write(key: _clientIdKey, value: id); - return id; - } - - void dispose() { - _disposed = true; - WidgetsBinding.instance.removeObserver(this); - for (final s in _subs) { - s.cancel(); - } - _subs.clear(); - } -} - -/// Read once at app start (app.dart postFrame) to activate reporting. -/// Disposed via ref.onDispose when the scope tears down. -final playEventsReporterProvider = Provider((ref) { - final r = PlayEventsReporter(ref); - ref.onDispose(r.dispose); - // ignore: unawaited_futures - r.start(); - return r; -}); diff --git a/flutter_client/lib/player/playback_error_reporter.dart b/flutter_client/lib/player/playback_error_reporter.dart deleted file mode 100644 index b4f8b67d..00000000 --- a/flutter_client/lib/player/playback_error_reporter.dart +++ /dev/null @@ -1,78 +0,0 @@ -// Surfaces playback errors (#58). _handlePlaybackError in the audio -// handler silently skips a dead track (404 / decoder failure / premature -// EOS / network drop) with only a debugPrint — which hides exactly the -// signal that distinguishes "this track is broken" from "the app is -// flaky" (server file moved, auth expired, cache miss, transcode fail). -// -// This listens to the handler's playbackErrorStream and shows a -// transient SnackBar via a global ScaffoldMessenger key. Bursts are -// coalesced: a debounce window collects errors and emits one message -// ("Skipped N unplayable tracks") instead of stacking N toasts. - -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import 'player_provider.dart'; - -/// Set on MaterialApp.router so SnackBars can be shown from outside any -/// widget's BuildContext (the handler's error stream is isolate-side). -final scaffoldMessengerKey = GlobalKey(); - -class PlaybackErrorReporter { - PlaybackErrorReporter(this._ref); - final Ref _ref; - - StreamSubscription? _sub; - Timer? _debounce; - final _buffer = []; - bool _disposed = false; - - void start() { - try { - // audioHandlerProvider throws until main() overrides it (the real - // app always does). In tests / no-audio environments there's - // nothing to report — stay inert. - final h = _ref.read(audioHandlerProvider); - _sub = h.playbackErrorStream.listen(_onError); - } catch (_) { - return; - } - } - - void _onError(String title) { - if (_disposed) return; - _buffer.add(title); - _debounce?.cancel(); - _debounce = Timer(const Duration(seconds: 2), _flush); - } - - void _flush() { - if (_disposed || _buffer.isEmpty) return; - final n = _buffer.length; - final first = _buffer.first; - _buffer.clear(); - final msg = n == 1 - ? 'Couldn’t play “$first” — skipping' - : 'Skipped $n unplayable tracks'; - scaffoldMessengerKey.currentState?.showSnackBar( - SnackBar(content: Text(msg)), - ); - } - - void dispose() { - _disposed = true; - _debounce?.cancel(); - _sub?.cancel(); - } -} - -/// Read once at app start (app.dart postFrame). Disposed via -/// ref.onDispose when the scope tears down. -final playbackErrorReporterProvider = Provider((ref) { - final r = PlaybackErrorReporter(ref); - ref.onDispose(r.dispose); - r.start(); - return r; -}); diff --git a/flutter_client/lib/player/player_bar.dart b/flutter_client/lib/player/player_bar.dart deleted file mode 100644 index 3c8be42d..00000000 --- a/flutter_client/lib/player/player_bar.dart +++ /dev/null @@ -1,346 +0,0 @@ -import 'dart:io'; - -import 'package:audio_service/audio_service.dart'; -import 'package:cached_network_image/cached_network_image.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_lucide/flutter_lucide.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; - -import '../api/endpoints/likes.dart' show LikeKind; -import '../likes/like_button.dart'; -import '../models/track.dart'; -import '../shared/widgets/track_actions/track_actions_button.dart'; -import '../theme/theme_extension.dart'; -import 'now_playing_screen.dart' show kPlayerCoverHeroTag; -import 'player_provider.dart'; - -/// Compact player bar mounted at the bottom of the app shell. Mini -/// view only — the heavyweight shuffle/repeat/queue/volume controls -/// live in NowPlayingScreen, accessible by tap or swipe-up. -/// -/// ┌─────────────────────────────────────┬──────────┐ -/// │ [art] Title ♥ ⋮ │ ⏮ ⏯ ⏭ │ -/// │ Artist │ │ -/// ├─────────────────────────────────────────────────┤ -/// │ 0:00 ━━●━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 3:06 │ -/// └─────────────────────────────────────────────────┘ -/// -/// Tap anywhere on the bar (including art/title) ascends to the full -/// player. A vertical drag upward also ascends, so the gesture mirrors -/// the drag-down dismissal on the full screen. -class PlayerBar extends ConsumerStatefulWidget { - const PlayerBar({super.key}); - - @override - ConsumerState createState() => _PlayerBarState(); -} - -class _PlayerBarState extends ConsumerState { - /// Last non-null artUri we've seen from the mediaItem stream. Held - /// across the audio_handler's two-broadcast track-change pattern - /// (bare MediaItem first, then with artUri once AlbumCoverCache - /// resolves) so the mini bar keeps showing the previous track's - /// cover until the new one is known. Without this hold the bar - /// flickered to the slate placeholder for ~100ms on every track - /// change. - Uri? _lastArtUri; - - @override - Widget build(BuildContext context) { - final fs = Theme.of(context).extension()!; - final media = ref.watch(mediaItemProvider).value; - final playback = ref.watch(playbackStateProvider).value; - if (media == null) return const SizedBox.shrink(); - - // Capture the latest non-null artUri so _TrackInfo's cover stays - // continuous across track changes. - if (media.artUri != null) _lastArtUri = media.artUri; - final displayMedia = media.artUri == null && _lastArtUri != null - ? media.copyWith(artUri: _lastArtUri) - : media; - - // positionProvider is just_audio's positionStream (~200ms cadence) - // so the mini bar's seek crawls forward smoothly. PlaybackState - // only updates on event transitions and would leave it frozen. - final pos = ref.watch(positionProvider).value ?? Duration.zero; - final dur = media.duration ?? Duration.zero; - - return Material( - color: fs.iron, - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () => context.push('/now-playing'), - // Swipe up anywhere on the bar to expand into the full player. - // We only act on a clear upward flick (>200 px/s) so a slow - // tap-with-tiny-jitter doesn't accidentally open the screen. - onVerticalDragEnd: (d) { - final v = d.primaryVelocity ?? 0; - if (v < -200) context.push('/now-playing'); - }, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - IntrinsicHeight( - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Expanded(child: _TrackInfo(media: displayMedia)), - const SizedBox(width: 8), - _PlayControls(playback: playback, ref: ref), - ], - ), - ), - const SizedBox(height: 4), - _SeekRow(position: pos, duration: dur), - ], - ), - ), - ), - ); - } -} - -class _TrackInfo extends StatelessWidget { - const _TrackInfo({required this.media}); - final MediaItem media; - - @override - Widget build(BuildContext context) { - final fs = Theme.of(context).extension()!; - final artistName = (media.artist ?? '').trim(); - - // Cover is wrapped in a Hero with the shared kPlayerCoverHeroTag so - // tapping the mini bar to expand into NowPlayingScreen animates the - // artwork from this 48dp footprint to the full-screen size rather - // than fade-cutting. Tag is stable per-route (not keyed by media.id) - // so the transition works regardless of what's playing. - // - // Why no transition / placeholder handling: the audio_handler - // broadcasts MediaItem twice on track change — once with artUri - // null (the new track's bare metadata), then with artUri set once - // AlbumCoverCache resolves. The widget tree above hands us the - // most-recent non-null artUri (`displayArtUri`), so the previous - // track's cover stays visible across that null gap and the new - // cover snaps in the moment its artUri arrives. Rapid change is - // fine here per operator preference; AnimatedSwitcher previously - // smeared the swap and made the slate placeholder visible. - final displayArtUri = media.artUri; - final Widget cover; - if (displayArtUri != null) { - // CachedNetworkImageProvider for HTTPS art URIs so the mini bar - // hits the same disk cache the rest of the UI uses (ServerImage, - // discover thumbnails). FileImage stays for the file:// branch - // populated by AlbumCoverCache — bytes are already on disk and a - // second cache layer would only burn duplicate space. - cover = Image( - image: displayArtUri.isScheme('file') - ? FileImage(File.fromUri(displayArtUri)) as ImageProvider - : CachedNetworkImageProvider(displayArtUri.toString()), - width: 48, - height: 48, - // Without a fit, Image paints the source at its intrinsic - // resolution inside the 48dp box — a thumbnail-sized cover - // would render as a tiny inset. Cover stretches/crops to fill - // the box uniformly. - fit: BoxFit.cover, - errorBuilder: (_, __, ___) => - Container(width: 48, height: 48, color: fs.slate), - ); - } else { - cover = Container(width: 48, height: 48, color: fs.slate); - } - return Row( - // Default centering vertically aligns the like/kebab buttons - // against the album art (48dp) — visually they span the title + - // artist block instead of sitting on the title baseline. - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Hero( - tag: kPlayerCoverHeroTag, - child: cover, - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - media.title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle(color: fs.parchment, fontSize: 14), - ), - if (artistName.isNotEmpty) - Text( - artistName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle(color: fs.ash, fontSize: 12), - ), - ], - ), - ), - // Like + kebab as siblings to the title/artist column rather - // than nested inside the title row. Width stays 32dp each so - // horizontal footprint matches what we had; height stretches - // to the row's full 48dp so the icons sit at vertical center - // against the title+artist block. - SizedBox( - width: 32, - height: 48, - child: LikeButton( - kind: LikeKind.track, - id: media.id, - size: 20, - ), - ), - SizedBox( - width: 32, - height: 48, - child: TrackActionsButton( - track: _trackRefFromMediaItem(media), - hideQueueActions: true, - ), - ), - ], - ); - } -} - -class _PlayControls extends StatelessWidget { - const _PlayControls({required this.playback, required this.ref}); - final PlaybackState? playback; - final WidgetRef ref; - - @override - Widget build(BuildContext context) { - final fs = Theme.of(context).extension()!; - final isPlaying = playback?.playing == true; - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - SizedBox( - width: 36, - height: 36, - child: IconButton( - padding: EdgeInsets.zero, - iconSize: 22, - icon: Icon(LucideIcons.skip_back, color: fs.parchment), - onPressed: () => ref.read(audioHandlerProvider).skipToPrevious(), - ), - ), - SizedBox( - width: 44, - height: 44, - child: IconButton( - padding: EdgeInsets.zero, - iconSize: 32, - icon: Icon( - isPlaying ? LucideIcons.circle_pause : LucideIcons.circle_play, - color: fs.accent, - ), - onPressed: () { - final h = ref.read(audioHandlerProvider); - if (isPlaying) { - h.pause(); - } else { - h.play(); - } - }, - ), - ), - SizedBox( - width: 36, - height: 36, - child: IconButton( - padding: EdgeInsets.zero, - iconSize: 22, - icon: Icon(LucideIcons.skip_forward, color: fs.parchment), - onPressed: () => ref.read(audioHandlerProvider).skipToNext(), - ), - ), - ], - ); - } -} - -class _SeekRow extends ConsumerWidget { - const _SeekRow({required this.position, required this.duration}); - final Duration position; - final Duration duration; - - String _fmt(Duration d) { - final m = d.inMinutes.remainder(60).toString(); - final s = d.inSeconds.remainder(60).toString().padLeft(2, '0'); - return '$m:$s'; - } - - @override - Widget build(BuildContext context, WidgetRef ref) { - final fs = Theme.of(context).extension()!; - final maxMs = - duration.inMilliseconds.toDouble().clamp(1.0, double.infinity); - return Row( - children: [ - SizedBox( - width: 36, - child: Text( - _fmt(position), - textAlign: TextAlign.right, - style: TextStyle(color: fs.ash, fontSize: 10), - ), - ), - Expanded( - child: SliderTheme( - data: SliderTheme.of(context).copyWith( - trackHeight: 2, - thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 6), - overlayShape: const RoundSliderOverlayShape(overlayRadius: 12), - ), - child: Slider( - activeColor: fs.accent, - inactiveColor: fs.slate, - min: 0, - max: maxMs, - value: position.inMilliseconds - .toDouble() - .clamp(0.0, duration.inMilliseconds.toDouble()), - onChanged: (v) => ref - .read(audioHandlerProvider) - .seek(Duration(milliseconds: v.toInt())), - ), - ), - ), - SizedBox( - width: 36, - child: Text( - _fmt(duration), - style: TextStyle(color: fs.ash, fontSize: 10), - ), - ), - ], - ); - } -} - -/// Reconstructs a minimal TrackRef from a MediaItem so the -/// TrackActionsButton has the fields its sheet expects. Mirrors the -/// pattern used by NowPlayingScreen — extras['album_id'] is what -/// audio_handler stashed at queue-build time. -TrackRef _trackRefFromMediaItem(MediaItem media) => TrackRef( - id: media.id, - title: media.title, - albumId: (media.extras?['album_id'] as String?) ?? '', - albumTitle: media.album ?? '', - // artist_id is stashed in extras by audio_handler — without it - // the kebab's "Go to artist" pushes /artists/ (empty id) and 404s. - artistId: (media.extras?['artist_id'] as String?) ?? '', - artistName: media.artist ?? '', - durationSec: media.duration?.inSeconds ?? 0, - streamUrl: '', - ); diff --git a/flutter_client/lib/player/player_provider.dart b/flutter_client/lib/player/player_provider.dart deleted file mode 100644 index 8a8be4db..00000000 --- a/flutter_client/lib/player/player_provider.dart +++ /dev/null @@ -1,198 +0,0 @@ -import 'dart:math' show Random; - -import 'package:audio_service/audio_service.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../api/endpoints/likes.dart' show LikeKind; -import '../api/endpoints/radio.dart'; -import '../auth/auth_provider.dart'; -import '../cache/audio_cache_manager.dart'; -import '../library/library_providers.dart' show dioProvider; -import '../likes/likes_provider.dart'; -import '../models/track.dart'; -import 'album_cover_cache.dart'; -import 'audio_handler.dart'; - -final audioHandlerProvider = Provider((ref) { - throw UnimplementedError('overridden in main()'); -}); - -final albumCoverCacheProvider = Provider((ref) { - return AlbumCoverCache( - dioFactory: () => ref.read(dioProvider.future), - ); -}); - -final playbackStateProvider = StreamProvider( - (ref) => ref.watch(audioHandlerProvider).playbackState, -); - -final mediaItemProvider = StreamProvider( - (ref) => ref.watch(audioHandlerProvider).mediaItem, -); - -final queueProvider = StreamProvider>( - (ref) => ref.watch(audioHandlerProvider).queue, -); - -/// Player volume in [0.0, 1.0]. Mirrors just_audio's player.volume; -/// modify via PlayerActions.setVolume(). Surfaced separately from -/// PlaybackState because audio_service's PlaybackState doesn't carry -/// volume — it's a player-side concern. -final volumeProvider = StreamProvider( - (ref) => ref.watch(audioHandlerProvider).volumeStream, -); - -/// Live playback position. just_audio emits at ~200ms cadence so seek -/// bars driven by this provider scrub smoothly. Don't use -/// PlaybackState.updatePosition for that — it only changes on state -/// transitions (play/pause/buffer/seek) and the bar would appear -/// frozen between events. -final positionProvider = StreamProvider( - (ref) => ref.watch(audioHandlerProvider).positionStream, -); - -class PlayerActions { - PlayerActions(this._ref) { - // Keep MediaItem.rating in sync with the drift-backed likedIds - // cache so external media surfaces (Wear's heart button, lock- - // screen favorite, Auto's like icon) reflect the current state - // even when the user toggles a like from somewhere other than - // the watch itself — e.g. tapping the heart on a TrackRow, the - // kebab menu, or another logged-in device propagating via SSE. - _ref.listen>(likedIdsProvider, (_, next) { - if (next.value == null) return; - _ref.read(audioHandlerProvider) - ..refreshCurrentRating() - ..refreshFavoriteControl(); - }); - } - final Ref _ref; - - Future playTracks( - List tracks, { - int initialIndex = 0, - bool shuffle = false, - String? source, - }) async { - // shuffle=true means "play this pool randomly, starting at a - // random track" (client-side; used for non-system surfaces that - // want shuffle). System playlists instead fetch a server-ordered - // list and pass source — no client shuffle, the order is already - // rotation-aware (#415). - var startAt = initialIndex; - if (shuffle && tracks.length > 1) { - startAt = Random().nextInt(tracks.length); - } - final url = await _ref.read(serverUrlProvider.future); - final token = await _ref.read(secureStorageProvider).read(key: 'session_token'); - final cache = _ref.read(albumCoverCacheProvider); - final audioCache = _ref.read(audioCacheManagerProvider); - final h = _ref.read(audioHandlerProvider) - ..configure( - baseUrl: url ?? '', - token: token, - coverCache: cache, - audioCacheManager: audioCache, - likeBridge: _buildLikeBridge(), - ); - await h.setQueueFromTracks(tracks, initialIndex: startAt, source: source); - if (shuffle) { - await h.setShuffleMode(AudioServiceShuffleMode.all); - } - await h.play(); - } - - /// Rebuilds a previously-persisted queue WITHOUT auto-playing, then - /// seeks to [position]. The resume-on-launch path (#54): the user - /// sees their last track in the mini bar, paused, and continues with - /// play / a media button. Mirrors playTracks' configure step so - /// streaming works after restore; intentionally no h.play(). - Future restoreQueue( - List tracks, { - int initialIndex = 0, - Duration position = Duration.zero, - String? source, - }) async { - if (tracks.isEmpty) return; - final url = await _ref.read(serverUrlProvider.future); - final token = await _ref.read(secureStorageProvider).read(key: 'session_token'); - final cache = _ref.read(albumCoverCacheProvider); - final audioCache = _ref.read(audioCacheManagerProvider); - final h = _ref.read(audioHandlerProvider) - ..configure( - baseUrl: url ?? '', - token: token, - coverCache: cache, - audioCacheManager: audioCache, - likeBridge: _buildLikeBridge(), - ); - await h.setQueueFromTracks(tracks, initialIndex: initialIndex, source: source); - if (position > Duration.zero) { - await h.seek(position); - } - } - - /// Builds the adapter the audio handler uses to read + flip the - /// current track's like state in response to external media- - /// controller events (Wear's heart button, lock-screen favorite). - /// Constructed here because the audio handler is initialized in - /// main.dart before the ProviderScope exists; passing the bridge - /// in via configure() keeps the handler Riverpod-agnostic while - /// still letting it call into LikesController + likedIdsProvider. - LikeBridge _buildLikeBridge() { - return LikeBridge( - toggleTrackLike: (id) => - _ref.read(likesControllerProvider).toggle(LikeKind.track, id), - isTrackLiked: (id) => - _ref.read(likedIdsProvider).value?.has(LikeKind.track, id) ?? false, - ); - } - - Future playNext(TrackRef track) async { - final h = _ref.read(audioHandlerProvider); - await h.playNext(track); - } - - Future enqueue(TrackRef track) async { - final h = _ref.read(audioHandlerProvider); - await h.enqueue(track); - } - - /// Toggles shuffle on/off. Pre-existing audio_service convention. - Future toggleShuffle() async { - final h = _ref.read(audioHandlerProvider); - final state = await h.playbackState.first; - final next = state.shuffleMode == AudioServiceShuffleMode.none - ? AudioServiceShuffleMode.all - : AudioServiceShuffleMode.none; - await h.setShuffleMode(next); - } - - /// Cycles repeat mode: none → all → one → none. - Future cycleRepeat() async { - final h = _ref.read(audioHandlerProvider); - final state = await h.playbackState.first; - final next = switch (state.repeatMode) { - AudioServiceRepeatMode.none => AudioServiceRepeatMode.all, - AudioServiceRepeatMode.all => AudioServiceRepeatMode.one, - _ => AudioServiceRepeatMode.none, - }; - await h.setRepeatMode(next); - } - - Future setVolume(double v) async { - await _ref.read(audioHandlerProvider).setVolume(v); - } - - /// Fetches `/api/radio?seed_track=` and starts playing the - /// returned track list (seed at index 0 + recommended picks). - Future startRadio(String trackId) async { - final dio = await _ref.read(dioProvider.future); - final tracks = await RadioApi(dio).seedTrack(trackId); - if (tracks.isEmpty) return; - await playTracks(tracks); - } -} - -final playerActionsProvider = Provider((ref) => PlayerActions(ref)); diff --git a/flutter_client/lib/player/queue_screen.dart b/flutter_client/lib/player/queue_screen.dart deleted file mode 100644 index 87eff48f..00000000 --- a/flutter_client/lib/player/queue_screen.dart +++ /dev/null @@ -1,119 +0,0 @@ -import 'package:audio_service/audio_service.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_lucide/flutter_lucide.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; - -import '../theme/theme_extension.dart'; -import 'player_provider.dart'; - -class QueueScreen extends ConsumerWidget { - const QueueScreen({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final fs = Theme.of(context).extension()!; - final queue = ref.watch(queueProvider).value ?? const []; - final current = ref.watch(mediaItemProvider).value; - return Scaffold( - backgroundColor: fs.obsidian, - appBar: AppBar( - backgroundColor: fs.obsidian, - elevation: 0, - leading: IconButton( - icon: Icon(LucideIcons.arrow_left, color: fs.parchment), - onPressed: () => context.pop(), - ), - title: Text('Queue', style: TextStyle(color: fs.parchment)), - ), - body: queue.isEmpty - ? Center( - child: Text('Queue is empty.', style: TextStyle(color: fs.ash)), - ) - : ListView.separated( - itemCount: queue.length, - separatorBuilder: (_, __) => Divider(height: 1, color: fs.iron), - itemBuilder: (ctx, i) => _QueueRow( - item: queue[i], - index: i, - isCurrent: current?.id == queue[i].id, - onTap: () async { - final h = ref.read(audioHandlerProvider); - await h.skipToQueueItem(i); - await h.play(); - }, - ), - ), - ); - } -} - -class _QueueRow extends StatelessWidget { - const _QueueRow({ - required this.item, - required this.index, - required this.isCurrent, - required this.onTap, - }); - - final MediaItem item; - final int index; - final bool isCurrent; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - final fs = Theme.of(context).extension()!; - final dur = item.duration; - String? durLabel; - if (dur != null) { - final m = (dur.inSeconds ~/ 60).toString().padLeft(2, '0'); - final s = (dur.inSeconds % 60).toString().padLeft(2, '0'); - durLabel = '$m:$s'; - } - return InkWell( - onTap: isCurrent ? null : onTap, - child: Container( - decoration: BoxDecoration( - border: isCurrent - ? Border(left: BorderSide(color: fs.accent, width: 2)) - : null, - color: isCurrent ? fs.iron : null, - ), - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), - child: Row(children: [ - if (isCurrent) - Padding( - padding: const EdgeInsets.only(right: 8), - child: Icon(LucideIcons.audio_lines, color: fs.accent, size: 16), - ), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - item.title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: isCurrent ? fs.accent : fs.parchment, - fontSize: 14, - fontWeight: isCurrent ? FontWeight.w500 : FontWeight.w400, - ), - ), - Text( - '${item.artist ?? ''} · ${item.album ?? ''}', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle(color: fs.ash, fontSize: 12), - ), - ], - ), - ), - if (durLabel != null) - Text(durLabel, style: TextStyle(color: fs.ash, fontSize: 12)), - ]), - ), - ); - } -} diff --git a/flutter_client/lib/playlists/playlist_detail_screen.dart b/flutter_client/lib/playlists/playlist_detail_screen.dart deleted file mode 100644 index c91e733e..00000000 --- a/flutter_client/lib/playlists/playlist_detail_screen.dart +++ /dev/null @@ -1,396 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_lucide/flutter_lucide.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; - -import '../models/playlist.dart'; -import '../models/track.dart'; -import '../player/player_provider.dart'; -import '../shared/live_events_provider.dart'; -import '../shared/widgets/track_actions/track_actions_button.dart'; -import '../theme/theme_extension.dart'; -import 'playlists_provider.dart'; - -class PlaylistDetailScreen extends ConsumerWidget { - const PlaylistDetailScreen({required this.id, this.seed, super.key}); - final String id; - - /// Optional Playlist passed via go_router extra so the header - /// (title, cover, track count, play/download CTAs) can render - /// before the full detail fetch resolves. Same pattern as album - /// + artist nav hydration. - final Playlist? seed; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final fs = Theme.of(context).extension()!; - // #402 wire-up: invalidate the detail provider on playlist.updated / - // .tracks_changed events whose payload matches the visible id. On - // .deleted matching this id, navigate back so the user isn't left - // staring at a gone-from-server playlist. - ref.listen>(liveEventsProvider, (_, next) { - final e = next.asData?.value; - if (e == null) return; - final eventPlaylistId = e.data['playlist_id'] as String?; - if (eventPlaylistId != id) return; - switch (e.kind) { - case 'playlist.updated': - case 'playlist.tracks_changed': - ref.invalidate(playlistDetailProvider(id)); - case 'playlist.deleted': - if (context.mounted) context.pop(); - } - }); - final detail = ref.watch(playlistDetailProvider(id)); - - // Resolve the best playlist info available for the AppBar title. - final livePlaylist = detail.value?.playlist; - final headerName = (livePlaylist != null && livePlaylist.name.isNotEmpty) - ? livePlaylist.name - : (seed?.name ?? ''); - - return Scaffold( - backgroundColor: fs.obsidian, - appBar: AppBar( - backgroundColor: fs.obsidian, - elevation: 0, - leading: IconButton( - icon: Icon(LucideIcons.arrow_left, color: fs.parchment), - onPressed: () => context.pop(), - ), - title: headerName.isEmpty - ? const SizedBox.shrink() - : Text( - headerName, - style: TextStyle(color: fs.parchment), - overflow: TextOverflow.ellipsis, - ), - ), - // AnimatedSwitcher between the skeleton body and the real body - // smooths the cold-visit moment when bulk detail lands. 220ms - // matches the per-tile reveal feel used on home / liked tabs. - body: AnimatedSwitcher( - duration: const Duration(milliseconds: 220), - switchInCurve: Curves.easeOut, - child: detail.when( - loading: () => _SkeletonBody( - key: const ValueKey('skeleton'), - seed: seed, - ), - error: (e, _) => Center( - key: const ValueKey('error'), - child: Text('$e', style: TextStyle(color: fs.error)), - ), - data: (d) => _Body(key: const ValueKey('body'), detail: d), - ), - ), - ); - } -} - -/// Cold-visit body: header from the seed (if any) + N skeleton rows. -/// N comes from the seed's trackCount so the row count matches the -/// real list when it lands — no layout jump on swap. Without a seed -/// (deep link straight to a playlist with no prior cache) the -/// skeleton renders a small default and grows when real data arrives. -class _SkeletonBody extends StatelessWidget { - const _SkeletonBody({super.key, this.seed}); - final Playlist? seed; - - static const _defaultSkeletonCount = 8; - - @override - Widget build(BuildContext context) { - final fs = Theme.of(context).extension()!; - final count = seed?.trackCount ?? _defaultSkeletonCount; - return ListView.builder( - itemCount: count + 1, // +1 for header - itemBuilder: (ctx, i) { - if (i == 0) { - return Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (seed != null && seed!.description.isNotEmpty) - Padding( - padding: const EdgeInsets.only(bottom: 12), - child: Text( - seed!.description, - style: TextStyle(color: fs.ash, fontSize: 13), - ), - ), - Text( - seed == null - ? 'Loading…' - : '${seed!.trackCount} ${seed!.trackCount == 1 ? "track" : "tracks"}', - style: TextStyle(color: fs.ash, fontSize: 12), - ), - ], - ), - ); - } - return const _PlaylistTrackSkeleton(); - }, - ); - } -} - -/// Skeleton matched to _PlaylistTrackRow's layout (no cover image — -/// playlist rows are text-only). Two text-shaped placeholders for -/// title + secondary line, plus a duration block on the right. -class _PlaylistTrackSkeleton extends StatelessWidget { - const _PlaylistTrackSkeleton(); - - @override - Widget build(BuildContext context) { - final fs = Theme.of(context).extension()!; - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), - child: Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Container(width: 200, height: 14, color: fs.slate), - const SizedBox(height: 6), - Container(width: 140, height: 12, color: fs.slate), - ], - ), - ), - const SizedBox(width: 16), - Container(width: 32, height: 12, color: fs.slate), - ], - ), - ); - } -} - -class _Body extends ConsumerWidget { - const _Body({super.key, required this.detail}); - final PlaylistDetail detail; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final fs = Theme.of(context).extension()!; - final tracks = detail.tracks; - final playable = tracks.where((t) => t.isAvailable).toList(); - - return RefreshIndicator( - onRefresh: () async => - ref.refresh(playlistDetailProvider(detail.playlist.id).future), - child: ListView.builder( - // Header + (track rows | empty hint). - itemCount: tracks.isEmpty ? 2 : tracks.length + 1, - itemBuilder: (ctx, i) { - if (i == 0) return _Header(detail: detail, playable: playable); - if (tracks.isEmpty) { - return Padding( - padding: const EdgeInsets.all(24), - child: Center( - child: Text( - 'No tracks yet. Add some via the "Add to playlist…" entry on any track row.', - style: TextStyle(color: fs.ash), - textAlign: TextAlign.center, - ), - ), - ); - } - final t = tracks[i - 1]; - return _PlaylistTrackRow( - row: t, - onTap: t.isAvailable - ? () { - final ref = ProviderScope.containerOf(ctx); - final liveTrack = _toTrackRef(t); - final playableRefs = - playable.map(_toTrackRef).toList(growable: false); - final startIdx = playable.indexWhere((p) => p.trackId == t.trackId); - ref.read(playerActionsProvider).playTracks( - playableRefs, - initialIndex: startIdx >= 0 ? startIdx : 0, - source: detail.playlist.refreshable - ? detail.playlist.systemVariant - : null, - ); - // Keep liveTrack referenced to avoid an unused-variable - // warning while we leave hooks for menu wiring later. - assert(liveTrack.id == t.trackId); - } - : null, - ); - }, - ), - ); - } -} - -TrackRef _toTrackRef(PlaylistTrack t) => TrackRef( - id: t.trackId ?? '', - title: t.title, - albumId: t.albumId ?? '', - albumTitle: t.albumTitle, - artistId: t.artistId ?? '', - artistName: t.artistName, - durationSec: t.durationSec, - streamUrl: t.streamUrl ?? '', - ); - -class _Header extends ConsumerWidget { - const _Header({required this.detail, required this.playable}); - final PlaylistDetail detail; - final List playable; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final fs = Theme.of(context).extension()!; - final p = detail.playlist; - return Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), - child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (p.description.isNotEmpty) - Padding( - padding: const EdgeInsets.only(bottom: 12), - child: Text( - p.description, - style: TextStyle(color: fs.ash, fontSize: 13), - ), - ), - Row(children: [ - Text( - '${p.trackCount} ${p.trackCount == 1 ? "track" : "tracks"}', - style: TextStyle(color: fs.ash, fontSize: 12), - ), - const Spacer(), - if (playable.isNotEmpty) ...[ - if (p.refreshable) ...[ - OutlinedButton.icon( - key: const Key('regenerate_playlist_button'), - onPressed: () => _regenerate(context, ref), - icon: const Icon(LucideIcons.refresh_cw, size: 16), - label: const Text('Regenerate'), - ), - const SizedBox(width: 8), - ], - FilledButton.icon( - onPressed: () { - final refs = playable.map(_toTrackRef).toList(growable: false); - ref.read(playerActionsProvider).playTracks( - refs, - source: p.refreshable ? p.systemVariant : null, - ); - }, - icon: const Icon(LucideIcons.play), - label: const Text('Play'), - style: FilledButton.styleFrom( - backgroundColor: fs.accent, - foregroundColor: fs.parchment, - ), - ), - ], - ]), - ]), - ); - } - - /// Forces a server-side rebuild of this system playlist. The - /// rebuild rotates the playlist UUID, so the old detail route now - /// 404s — rebind by pushReplacement-ing to the new id. The - /// aggregate list is invalidated too so the home row's tile points - /// at the fresh UUID. ScaffoldMessenger + router captured before - /// the await so we don't touch a stale BuildContext after. - Future _regenerate(BuildContext context, WidgetRef ref) async { - final messenger = ScaffoldMessenger.of(context); - final router = GoRouter.of(context); - final p = detail.playlist; - try { - final api = await ref.read(playlistsApiProvider.future); - final newId = await api.refreshSystem(p.systemVariant!); - ref.invalidate(playlistsListProvider); - if (newId == null) { - messenger.showSnackBar(const SnackBar( - content: Text('Nothing to build yet — library is empty.'), - )); - return; - } - messenger.showSnackBar( - SnackBar(content: Text('${p.name} regenerated')), - ); - router.pushReplacement('/playlists/$newId'); - } catch (e) { - messenger.showSnackBar( - SnackBar(content: Text("Couldn't regenerate: $e")), - ); - } - } -} - -class _PlaylistTrackRow extends ConsumerWidget { - const _PlaylistTrackRow({required this.row, required this.onTap}); - final PlaylistTrack row; - final VoidCallback? onTap; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final fs = Theme.of(context).extension()!; - final mins = (row.durationSec ~/ 60).toString().padLeft(2, '0'); - final secs = (row.durationSec % 60).toString().padLeft(2, '0'); - // "Now playing" highlight — matches _QueueRow and TrackRow so the - // user sees which playlist row is current without reading the - // player bar. Unavailable rows never match. - final currentId = ref.watch(mediaItemProvider).value?.id; - final isCurrent = row.isAvailable && - currentId != null && - currentId == row.trackId; - final baseColor = row.isAvailable ? fs.parchment : fs.ash; - final titleColor = isCurrent ? fs.accent : baseColor; - return Container( - decoration: BoxDecoration( - color: isCurrent ? fs.iron : null, - border: isCurrent - ? Border(left: BorderSide(color: fs.accent, width: 2)) - : null, - ), - child: InkWell( - onTap: onTap, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), - child: Row(children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - row.title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: titleColor, - fontSize: 14, - fontWeight: - isCurrent ? FontWeight.w500 : FontWeight.w400, - decoration: row.isAvailable - ? null - : TextDecoration.lineThrough, - ), - ), - Text( - '${row.artistName} · ${row.albumTitle}', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle(color: fs.ash, fontSize: 12), - ), - ], - ), - ), - Text('$mins:$secs', style: TextStyle(color: fs.ash, fontSize: 12)), - if (row.trackId != null) - TrackActionsButton(track: _toTrackRef(row)), - ]), - ), - ), - ); - } -} diff --git a/flutter_client/lib/playlists/playlists_list_screen.dart b/flutter_client/lib/playlists/playlists_list_screen.dart deleted file mode 100644 index 23410842..00000000 --- a/flutter_client/lib/playlists/playlists_list_screen.dart +++ /dev/null @@ -1,137 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_lucide/flutter_lucide.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; - -import '../models/playlist.dart'; -import '../shared/widgets/main_app_bar_actions.dart'; -import '../shared/widgets/server_image.dart'; -import '../theme/theme_extension.dart'; -import 'playlists_provider.dart'; - -class PlaylistsListScreen extends ConsumerWidget { - const PlaylistsListScreen({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final fs = Theme.of(context).extension()!; - // "all" returns user-created + system mixes. Most useful default - // on mobile where the user wants to see for-you/discover alongside - // their own playlists in one tap. - final list = ref.watch(playlistsListProvider('all')); - return Scaffold( - backgroundColor: fs.obsidian, - appBar: AppBar( - backgroundColor: fs.obsidian, - elevation: 0, - title: Text('Playlists', style: TextStyle(color: fs.parchment)), - actions: const [MainAppBarActions(currentRoute: '/playlists')], - ), - body: list.when( - loading: () => const Center(child: CircularProgressIndicator()), - error: (e, _) => Center( - child: Text('$e', style: TextStyle(color: fs.error)), - ), - data: (lists) { - final items = lists.all; - if (items.isEmpty) { - return Center( - child: Text( - 'No playlists yet.', - style: TextStyle(color: fs.ash), - ), - ); - } - return RefreshIndicator( - onRefresh: () async => ref.refresh(playlistsListProvider('all').future), - child: ListView.separated( - itemCount: items.length, - separatorBuilder: (_, __) => - Divider(height: 1, color: fs.iron), - itemBuilder: (ctx, i) { - final p = items[i]; - return _PlaylistTile( - playlist: p, - onTap: () => ctx.push('/playlists/${p.id}', extra: p), - ); - }, - ), - ); - }, - ), - ); - } -} - -class _PlaylistTile extends StatelessWidget { - const _PlaylistTile({required this.playlist, required this.onTap}); - final Playlist playlist; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - final fs = Theme.of(context).extension()!; - return InkWell( - onTap: onTap, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - child: Row(children: [ - ClipRRect( - borderRadius: BorderRadius.circular(6), - child: Container( - width: 56, - height: 56, - color: fs.slate, - child: playlist.coverUrl.isEmpty - ? Icon(LucideIcons.list_music, color: fs.ash) - : ServerImage( - url: playlist.coverUrl, - fit: BoxFit.cover, - fallback: Icon(LucideIcons.list_music, color: fs.ash), - ), - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row(children: [ - Flexible( - child: Text( - playlist.name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle(color: fs.parchment, fontSize: 15), - ), - ), - if (playlist.isSystem) ...[ - const SizedBox(width: 8), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: fs.iron, - borderRadius: BorderRadius.circular(4), - ), - child: Text( - playlist.systemVariant!.replaceAll('_', ' '), - style: TextStyle(color: fs.accent, fontSize: 11), - ), - ), - ], - ]), - const SizedBox(height: 2), - Text( - '${playlist.trackCount} ${playlist.trackCount == 1 ? "track" : "tracks"}', - style: TextStyle(color: fs.ash, fontSize: 12), - ), - ], - ), - ), - Icon(LucideIcons.chevron_right, color: fs.ash), - ]), - ), - ); - } -} diff --git a/flutter_client/lib/playlists/playlists_provider.dart b/flutter_client/lib/playlists/playlists_provider.dart deleted file mode 100644 index 2c8be2df..00000000 --- a/flutter_client/lib/playlists/playlists_provider.dart +++ /dev/null @@ -1,403 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; - -import 'package:dio/dio.dart'; -import 'package:drift/drift.dart' as drift; -import 'package:flutter/foundation.dart' show debugPrint; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../api/endpoints/me.dart'; -import '../api/endpoints/playlists.dart'; -import '../auth/auth_provider.dart'; -import '../cache/adapters.dart'; -import '../cache/audio_cache_manager.dart' show appDbProvider; -import '../cache/cache_first.dart'; -import '../cache/connectivity_provider.dart'; -import '../cache/db.dart'; -import '../library/library_providers.dart' show dioProvider; -import '../models/playlist.dart'; -import '../models/system_playlists_status.dart'; - -final playlistsApiProvider = FutureProvider((ref) async { - return PlaylistsApi(await ref.watch(dioProvider.future)); -}); - -/// Drift-first per #357 plan C. Returns cached playlists filtered to -/// match the `kind` family arg: -/// - 'user' → only user-created (systemVariant null), owned by current user -/// - 'system' → only system-generated (systemVariant non-null), owned -/// - 'all' → everything: own user playlists + own system + others' public -/// -/// systemVariant column added in drift schema v2 (#357 follow-up); previous -/// behavior leaked system playlists into add-to-playlist sheet because we -/// couldn't filter locally. -final playlistsListProvider = - StreamProvider.family((ref, kind) { - final db = ref.watch(appDbProvider); - final user = ref.watch(authControllerProvider).value; - - return cacheFirst( - driftStream: db.select(db.cachedPlaylists).watch(), - fetchAndPopulate: () async { - final api = await ref.read(playlistsApiProvider.future); - final fresh = await api.list(kind: kind); - - // Reconcile: BuildSystemPlaylists rotates system-playlist UUIDs - // every rebuild, so insertOrReplace alone leaves stale rows in - // drift. Tapping one of those stale tiles 404s. Delete every - // owned drift row whose id isn't in the fresh response, then - // upsert the fresh set. - final freshOwnedIds = - fresh.owned.map((p) => p.id).toSet(); - await db.batch((b) { - if (user != null) { - b.deleteWhere(db.cachedPlaylists, (t) { - return t.userId.equals(user.id) & - t.id.isNotIn(freshOwnedIds); - }); - } - for (final p in fresh.all) { - b.insert(db.cachedPlaylists, p.toDrift(), - mode: drift.InsertMode.insertOrReplace); - } - }); - }, - toResult: (rows) { - if (user == null) return PlaylistsList.empty(); - final filtered = rows.where((r) { - if (kind == 'user') return r.systemVariant == null; - if (kind == 'system') return r.systemVariant != null; - return true; // 'all' - }).toList(); - final owned = filtered - .where((r) => r.userId == user.id) - .map((r) => r.toRef()) - .toList(); - final pub = filtered - .where((r) => r.userId != user.id && r.isPublic) - .map((r) => r.toRef()) - .toList(); - return PlaylistsList(owned: owned, public: pub); - }, - isOnline: () async => (await ref - .read(connectivityProvider.future) - .timeout(const Duration(seconds: 3), onTimeout: () => true)), - // Aggregate list — server may add system playlists (For-You / - // Discover) that the per-user delta sync doesn't always emit. - // Stale-while-revalidate ensures the home tile row reflects the - // current server state on every screen visit. - alwaysRefresh: true, - ); -}); - -/// Composite shape (playlist + tracks). async* over the playlist watch -/// stream + a one-shot tracks fetch per emission. -final playlistDetailProvider = - StreamProvider.family((ref, id) async* { - final db = ref.watch(appDbProvider); - final user = ref.watch(authControllerProvider).value; - - final playlistQuery = db.select(db.cachedPlaylists) - ..where((t) => t.id.equals(id)); - - final tracksQuery = (db.select(db.cachedPlaylistTracks) - ..where((t) => t.playlistId.equals(id)) - ..orderBy([(t) => drift.OrderingTerm.asc(t.position)])) - .join([ - drift.leftOuterJoin(db.cachedTracks, - db.cachedTracks.id.equalsExp(db.cachedPlaylistTracks.trackId)), - drift.leftOuterJoin(db.cachedArtists, - db.cachedArtists.id.equalsExp(db.cachedTracks.artistId)), - drift.leftOuterJoin(db.cachedAlbums, - db.cachedAlbums.id.equalsExp(db.cachedTracks.albumId)), - ]); - - PlaylistDetail emptyDetail() => PlaylistDetail( - playlist: Playlist( - id: id, - userId: user?.id ?? '', - name: '', - description: '', - isPublic: false, - systemVariant: null, - trackCount: 0, - coverUrl: '', - ownerUsername: '', - createdAt: '', - updatedAt: '', - ), - tracks: const [], - ); - - Future isOnline() async { - try { - return await ref - .read(connectivityProvider.future) - .timeout(const Duration(seconds: 3), onTimeout: () => true); - } catch (_) { - return true; - } - } - - Future fetchAndPopulate() async { - try { - final api = await ref.read(playlistsApiProvider.future); - final fresh = await api.get(id).timeout(const Duration(seconds: 10)); - - // Collect the track + artist + album rows referenced by these - // playlist entries. Without writing the cachedTracks rows - // themselves, the detail-screen JOIN against cachedTracks - // returns null on every row (only the playlist_tracks join - // succeeds), so the UI shows a list with empty titles and - // unplayable tracks. - final tracks = {}; - final artists = {}; - final albums = {}; - for (final t in fresh.tracks) { - final tId = t.trackId; - if (tId != null && tId.isNotEmpty) { - tracks.putIfAbsent( - tId, - () => _TrackRefRow( - id: tId, - title: t.title, - albumId: t.albumId ?? '', - artistId: t.artistId ?? '', - durationSec: t.durationSec, - ), - ); - } - final aId = t.artistId; - final aName = t.artistName; - if (aId != null && aId.isNotEmpty && aName.isNotEmpty) { - artists.putIfAbsent(aId, () => ArtistRefRow(aId, aName)); - } - final lbId = t.albumId; - final lbTitle = t.albumTitle; - if (lbId != null && lbId.isNotEmpty && lbTitle.isNotEmpty) { - albums.putIfAbsent(lbId, () => AlbumRefRow(lbId, lbTitle, aId ?? '')); - } - } - - await db.batch((b) { - b.insert(db.cachedPlaylists, fresh.playlist.toDrift(), - mode: drift.InsertMode.insertOrReplace); - - // Wipe + re-insert this playlist's track positions so deletions - // propagate. Without the wipe, removed tracks would linger in - // drift after the playlist mutated server-side. - b.deleteWhere( - db.cachedPlaylistTracks, (t) => t.playlistId.equals(id)); - - for (var i = 0; i < fresh.tracks.length; i++) { - final t = fresh.tracks[i]; - if (t.trackId == null) continue; - b.insert( - db.cachedPlaylistTracks, - CachedPlaylistTracksCompanion.insert( - playlistId: id, - trackId: t.trackId!, - position: drift.Value(i), - ), - mode: drift.InsertMode.insertOrReplace, - ); - } - - if (artists.isNotEmpty) { - b.insertAllOnConflictUpdate( - db.cachedArtists, - artists.values - .map((a) => CachedArtistsCompanion.insert( - id: a.id, - name: a.name, - sortName: a.name, - )) - .toList(), - ); - } - if (tracks.isNotEmpty) { - // Insert/update cachedTracks so the detail screen's JOIN - // produces real titles + durations. We don't have - // track_number / disc_number on the wire (PlaylistTrack - // omits them), so they default to 0 — UI doesn't surface - // them on this screen so it's fine. - b.insertAllOnConflictUpdate( - db.cachedTracks, - tracks.values - .map((t) => CachedTracksCompanion.insert( - id: t.id, - albumId: t.albumId, - artistId: t.artistId, - title: t.title, - durationMs: drift.Value(t.durationSec * 1000), - )) - .toList(), - ); - } - if (albums.isNotEmpty) { - b.insertAllOnConflictUpdate( - db.cachedAlbums, - albums.values - .map((a) => CachedAlbumsCompanion.insert( - id: a.id, - artistId: a.artistId, - title: a.title, - sortTitle: a.title, - )) - .toList(), - ); - } - }); - return true; - } catch (e, st) { - // 404 = the playlist row in drift is stale (BuildSystemPlaylists - // rotates UUIDs on each rebuild, so old For-You / Songs-Like - // tiles can outlive the actual server-side playlist). Wipe the - // stale row + its track positions so the home tile disappears - // on next render and we don't keep trying. - if (e is DioException && e.response?.statusCode == 404) { - await db.batch((b) { - b.deleteWhere( - db.cachedPlaylistTracks, (t) => t.playlistId.equals(id)); - b.deleteWhere(db.cachedPlaylists, (t) => t.id.equals(id)); - }); - return false; - } - debugPrint('playlistDetailProvider($id): fetch failed: $e\n$st'); - return false; - } - } - - // Once-per-subscription guard so an empty server response doesn't - // cause repeated re-fetches. - var fetchAttempted = false; - - await for (final playlistRows in playlistQuery.watch()) { - if (playlistRows.isEmpty) { - if (fetchAttempted) { - yield emptyDetail(); - continue; - } - fetchAttempted = true; - if (!await isOnline()) { - yield emptyDetail(); - continue; - } - final ok = await fetchAndPopulate(); - if (!ok) yield emptyDetail(); - continue; - } - - final playlist = playlistRows.first.toRef(); - final trackRows = await tracksQuery.get(); - - // Same pattern as albumProvider: playlist row exists but no tracks - // (e.g., playlistsListProvider wrote the row, sync hasn't carried - // tracks for system playlists). Trigger the same fetch, drift - // watch re-emits with populated tracks. - if (trackRows.isEmpty && !fetchAttempted) { - fetchAttempted = true; - if (await isOnline()) { - final ok = await fetchAndPopulate(); - if (ok) continue; // wait for watch re-emit - } - } - - final tracks = trackRows.asMap().entries.map((e) { - final r = e.value; - final track = r.readTableOrNull(db.cachedTracks); - final artist = r.readTableOrNull(db.cachedArtists); - final album = r.readTableOrNull(db.cachedAlbums); - return PlaylistTrack( - position: e.key, - trackId: track?.id, - title: track?.title ?? '', - albumId: album?.id, - albumTitle: album?.title ?? '', - artistId: artist?.id, - artistName: artist?.name ?? '', - durationSec: track == null ? 0 : track.durationMs ~/ 1000, - streamUrl: null, - ); - }).toList(); - - yield PlaylistDetail(playlist: playlist, tracks: tracks); - - // No SWR refresh here. The aggregate playlistsListProvider does - // alwaysRefresh (system playlists rotate UUIDs), but per-detail - // refresh on every visit was multiplying with the prefetcher's - // parallel fetches and starving user-initiated playback. Pull- - // to-refresh on the detail page invalidates the provider, which - // is the right path for an explicit refresh. - } -}); - -/// Lightweight tuples used inside fetchAndPopulate to dedupe artist -/// and album rows referenced by playlist tracks before we batch-write -/// them to drift. -class ArtistRefRow { - ArtistRefRow(this.id, this.name); - final String id; - final String name; -} - -class AlbumRefRow { - AlbumRefRow(this.id, this.title, this.artistId); - final String id; - final String title; - final String artistId; -} - -class _TrackRefRow { - _TrackRefRow({ - required this.id, - required this.title, - required this.albumId, - required this.artistId, - required this.durationSec, - }); - final String id; - final String title; - final String albumId; - final String artistId; - final int durationSec; -} - -/// Drift-first per #357 pattern. Reads from cached_system_playlists_ -/// status (single-row JSON blob populated by /api/me/system-playlists- -/// status). Home Playlists row reads this every render to choose -/// between real cards and "building / pending / failed" placeholders -/// — drift-first means the row paints with the prior status instantly -/// rather than flickering through SystemPlaylistsStatus.empty() while -/// the REST round-trip resolves. SWR refresh on every visit keeps it -/// current. -final systemPlaylistsStatusProvider = - StreamProvider((ref) { - final db = ref.watch(appDbProvider); - return cacheFirst( - driftStream: db.select(db.cachedSystemPlaylistsStatus).watch(), - fetchAndPopulate: () async { - final dio = await ref.read(dioProvider.future); - final fresh = await MeApi(dio).systemPlaylistsStatus(); - await db.into(db.cachedSystemPlaylistsStatus).insertOnConflictUpdate( - CachedSystemPlaylistsStatusCompanion.insert( - json: jsonEncode({ - 'in_flight': fresh.inFlight, - 'last_run_at': fresh.lastRunAt, - 'last_error': fresh.lastError, - }), - updatedAt: drift.Value(DateTime.now()), - ), - ); - }, - toResult: (rows) => rows.isEmpty - ? SystemPlaylistsStatus.empty() - : SystemPlaylistsStatus.fromJson( - jsonDecode(rows.first.json) as Map), - isOnline: () async => (await ref - .read(connectivityProvider.future) - .timeout(const Duration(seconds: 3), onTimeout: () => true)), - alwaysRefresh: true, - tag: 'systemPlaylistsStatus', - ); -}); diff --git a/flutter_client/lib/playlists/widgets/playlist_card.dart b/flutter_client/lib/playlists/widgets/playlist_card.dart deleted file mode 100644 index 7c948b41..00000000 --- a/flutter_client/lib/playlists/widgets/playlist_card.dart +++ /dev/null @@ -1,249 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:flutter_lucide/flutter_lucide.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; - -import '../../cache/offline_provider.dart'; -import '../../library/widgets/play_circle_button.dart'; -import '../../models/playlist.dart'; -import '../../models/track.dart'; -import '../../player/player_provider.dart'; -import '../../shared/widgets/server_image.dart'; -import '../../theme/theme_extension.dart'; -import '../playlists_provider.dart'; - -/// Mirrors the web PlaylistCard. ~176dp wide, square cover (~144dp), -/// name + optional system-variant badge below. Tap pushes -/// `/playlists/{id}`. The bottom-right play button fetches the -/// playlist and starts playback from track 0; disabled when the -/// playlist has no tracks. -class PlaylistCard extends ConsumerWidget { - const PlaylistCard({super.key, required this.playlist}); - - final Playlist playlist; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final fs = Theme.of(context).extension()!; - // #427 S4: refreshable system playlists need the live build/ - // shuffle endpoints — unavailable offline. Disable their play - // (use Shuffle all instead). User playlists play from cache. - final offline = ref.watch(offlineProvider); - final hasTracks = playlist.trackCount > 0 && - !(offline && playlist.refreshable); - return SizedBox( - width: 176, - child: Material( - color: Colors.transparent, - child: InkWell( - onTap: () => - context.push('/playlists/${playlist.id}', extra: playlist), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Stack: collage + overlaid play button at bottom-right. - Stack( - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(6), - child: Container( - width: 144, - height: 144, - color: fs.slate, - // coverUrl is deterministic per playlist (see - // CachedPlaylistAdapter.toRef). When the server's - // collage isn't built yet, ServerImage's - // errorBuilder shows the queue_music icon over the - // slate background. - child: playlist.coverUrl.isEmpty - ? Icon(LucideIcons.list_music, color: fs.ash, size: 56) - : ServerImage( - url: playlist.coverUrl, - fit: BoxFit.cover, - fallback: - Icon(LucideIcons.list_music, color: fs.ash, size: 56), - ), - ), - ), - Positioned( - bottom: 6, - right: 6, - child: PlayCircleButton( - enabled: hasTracks, - onPressed: () => _playPlaylist(context, ref), - ), - ), - // System playlists get a refresh affordance so the - // user can force a fresh mix instead of waiting for - // the daily 03:00 rebuild. Mirrors the web kebab. - if (playlist.refreshable) - Positioned( - top: 2, - right: 2, - child: PopupMenuButton( - icon: Icon(LucideIcons.ellipsis_vertical, color: fs.parchment, size: 18), - tooltip: 'Playlist actions', - color: fs.iron, - onSelected: (_) => _refresh(context, ref), - itemBuilder: (_) => [ - PopupMenuItem( - value: 'refresh', - child: Row(children: [ - Icon(LucideIcons.refresh_cw, color: fs.parchment, size: 18), - const SizedBox(width: 8), - Text(_refreshLabel, - style: TextStyle(color: fs.parchment)), - ]), - ), - ], - ), - ), - ], - ), - const SizedBox(height: 8), - Text( - playlist.name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle(color: fs.parchment, fontSize: 14), - ), - if (playlist.isSystem) - Padding( - padding: const EdgeInsets.only(top: 2), - child: Container( - padding: - const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: fs.iron, - borderRadius: BorderRadius.circular(4), - ), - child: Text( - playlist.systemVariant!.replaceAll('_', ' '), - style: TextStyle(color: fs.accent, fontSize: 11), - ), - ), - ), - if (playlist.isSystem && _refreshedLabel(playlist.createdAt) != '') - Padding( - padding: const EdgeInsets.only(top: 2), - child: Text( - _refreshedLabel(playlist.createdAt), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle(color: fs.ash, fontSize: 11), - ), - ), - ]), - ), - ), - ), - ); - } - - String get _refreshLabel => 'Refresh ${playlist.name}'; - - /// #417: system playlists atomic-replace on rebuild, so createdAt - /// is the last-rotated time. Friendly wording mirrors the web - /// PlaylistCard. Empty string when the timestamp is unparseable. - String _refreshedLabel(String iso) { - final t = DateTime.tryParse(iso); - if (t == null) return ''; - final now = DateTime.now(); - final mins = now.difference(t).inMinutes; - if (mins < 5) return 'Refreshed just now'; - final startOfToday = DateTime(now.year, now.month, now.day); - if (!t.isBefore(startOfToday)) return 'Refreshed today'; - final days = startOfToday.difference(DateTime(t.year, t.month, t.day)).inDays; - if (days <= 1) return 'Refreshed yesterday'; - if (days < 7) return 'Refreshed $days days ago'; - const months = [ - 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', - 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec', - ]; - return 'Refreshed ${months[t.month - 1]} ${t.day}'; - } - - /// Forces a server-side rebuild of this system playlist, then - /// invalidates the aggregate list so the rotated UUID + new tracks - /// land on the next read. ScaffoldMessenger is captured before the - /// await so we don't touch a stale BuildContext afterward. - Future _refresh(BuildContext context, WidgetRef ref) async { - final messenger = ScaffoldMessenger.of(context); - try { - final api = await ref.read(playlistsApiProvider.future); - await api.refreshSystem(playlist.systemVariant!); - ref.invalidate(playlistsListProvider); - messenger.showSnackBar( - SnackBar(content: Text('${playlist.name} refreshed')), - ); - } catch (e) { - messenger.showSnackBar( - SnackBar(content: Text("Couldn't refresh: $e")), - ); - } - } - - /// Fetches the playlist via /api/playlists/{id}, materializes each - /// PlaylistTrack into a TrackRef (filtering out unavailable rows - /// whose `trackId` is null after a track-delete), and plays from - /// index 0. Mirrors the web PlaylistCard's onPlayClick. - /// - /// Failure modes are surfaced via SnackBar so a not-yet-built mix - /// or a slow server doesn't look like the tap was ignored. - Future _playPlaylist(BuildContext context, WidgetRef ref) async { - final messenger = ScaffoldMessenger.of(context); - final api = await ref.read(playlistsApiProvider.future); - final PlaylistDetail detail; - try { - // Refreshable (singleton) system playlists: fetch the server's - // rotation-aware order (#415) and play as-is, tagged with the - // source so the reporter advances rotation. User playlists AND - // non-singleton system kinds (songs_like_artist — no by-kind - // endpoint) play the stored order via get(), untagged. - detail = await (playlist.refreshable - ? api.systemShuffle(playlist.systemVariant!) - : api.get(playlist.id)) - .timeout(const Duration(seconds: 8)); - } on TimeoutException { - messenger.showSnackBar(const SnackBar( - content: Text("Couldn't load playlist — check your connection"), - )); - return; - } catch (e) { - messenger.showSnackBar(SnackBar( - content: Text('Playlist load failed: $e'), - )); - return; - } - final refs = []; - for (final t in detail.tracks) { - if (t.trackId == null) continue; - refs.add(TrackRef( - id: t.trackId!, - title: t.title, - albumId: t.albumId ?? '', - albumTitle: t.albumTitle, - artistId: t.artistId ?? '', - artistName: t.artistName, - durationSec: t.durationSec, - streamUrl: t.streamUrl ?? '', - )); - } - if (refs.isEmpty) { - messenger.showSnackBar(const SnackBar( - content: Text("Mix isn't ready yet — try again in a moment"), - )); - return; - } - // Rotation-aware kinds arrive pre-ordered from the server — play - // as-is, tagged so the reporter advances rotation. No client - // shuffle. Everything else plays stored order, untagged. - await ref.read(playerActionsProvider).playTracks( - refs, - initialIndex: 0, - source: playlist.refreshable ? playlist.systemVariant : null, - ); - } -} diff --git a/flutter_client/lib/playlists/widgets/playlist_placeholder_card.dart b/flutter_client/lib/playlists/widgets/playlist_placeholder_card.dart deleted file mode 100644 index d2192c26..00000000 --- a/flutter_client/lib/playlists/widgets/playlist_placeholder_card.dart +++ /dev/null @@ -1,87 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_lucide/flutter_lucide.dart'; - -import '../../theme/theme_extension.dart'; - -/// Same dimensions as PlaylistCard so the home Playlists row keeps -/// visual rhythm whether real or placeholder. Mirrors the web -/// PlaylistPlaceholderCard. Variant decides the state copy below -/// the label. -class PlaylistPlaceholderCard extends StatelessWidget { - const PlaylistPlaceholderCard({ - super.key, - required this.label, - required this.variant, - }); - - /// "For You" or "Songs like…" — what the slot is reserved for. - final String label; - - /// One of: 'building' | 'failed' | 'pending' | 'seed-needed'. - final String variant; - - @override - Widget build(BuildContext context) { - final fs = Theme.of(context).extension()!; - return SizedBox( - width: 176, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Container( - width: 144, - height: 144, - decoration: BoxDecoration( - color: fs.iron, - borderRadius: BorderRadius.circular(6), - border: Border.all(color: fs.slate, width: 1), - ), - child: Center(child: _stateIcon(fs)), - ), - const SizedBox(height: 8), - Text( - label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle(color: fs.parchment, fontSize: 14), - ), - Padding( - padding: const EdgeInsets.only(top: 2), - child: Text( - _stateText(), - style: TextStyle(color: fs.ash, fontSize: 11), - ), - ), - ]), - ), - ); - } - - Widget _stateIcon(FabledSwordTheme fs) { - switch (variant) { - case 'building': - return SizedBox( - width: 28, - height: 28, - child: CircularProgressIndicator(strokeWidth: 2, color: fs.accent), - ); - case 'failed': - return Icon(LucideIcons.triangle_alert, color: fs.error, size: 32); - default: - return Icon(LucideIcons.list_music, color: fs.ash, size: 40); - } - } - - String _stateText() { - switch (variant) { - case 'building': - return 'Building…'; - case 'failed': - return "Couldn't generate"; - case 'seed-needed': - return 'Like more music'; - default: - return 'Coming soon'; - } - } -} diff --git a/flutter_client/lib/quarantine/quarantine_provider.dart b/flutter_client/lib/quarantine/quarantine_provider.dart deleted file mode 100644 index d84fa334..00000000 --- a/flutter_client/lib/quarantine/quarantine_provider.dart +++ /dev/null @@ -1,184 +0,0 @@ -import 'dart:async'; - -import 'package:drift/drift.dart' as drift; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../api/endpoints/me.dart'; -import '../api/endpoints/quarantine.dart'; -import '../cache/audio_cache_manager.dart' show appDbProvider; -import '../cache/connectivity_provider.dart'; -import '../cache/db.dart'; -import '../cache/mutation_queue.dart'; -import '../library/library_providers.dart' show dioProvider; -import '../models/quarantine_mine.dart'; -import '../models/track.dart'; - -final quarantineApiProvider = FutureProvider((ref) async { - return QuarantineApi(await ref.watch(dioProvider.future)); -}); - -/// Drift-first ("hidden") tab controller. Reads from cached_quarantine_ -/// mine via a drift watch() subscription set up in build(), so any drift -/// mutation (incoming sync, optimistic flag/unflag, SSE-triggered -/// refresh) re-emits the list automatically. -/// -/// API surface unchanged from the prior FutureProvider-backed controller -/// — call sites still do `ref.read(myQuarantineProvider.notifier).flag()` -/// / `.unflag()` / `.isHidden()`. Internal storage moved from in-memory -/// AsyncNotifier state to drift so the Hidden tab paints from disk on -/// cold open and the quarantine list is queryable offline. -class MyQuarantineController extends AsyncNotifier> { - @override - Future> build() async { - final db = ref.watch(appDbProvider); - - // Subscribe to drift first so any mutation (incoming sync, flag/ - // unflag, etc.) propagates without needing a manual invalidate. - // Disposed when the provider rebuilds or the listener detaches. - final sub = db.select(db.cachedQuarantineMine).watch().listen((rows) { - state = AsyncData(rows.map(_rowToModel).toList()); - }); - ref.onDispose(sub.cancel); - - // SWR refresh on every build so the freshest server-side state - // overtakes drift in the background. Don't await — UI gets the - // cached snapshot first, freshness lands later via the watch(). - unawaited(_refreshFromServer()); - - final initial = await db.select(db.cachedQuarantineMine).get(); - return initial.map(_rowToModel).toList(); - } - - /// Hits /api/quarantine/mine and replaces the local table with the - /// authoritative server set. Best-effort: offline / 5xx / unresolved - /// dependencies (e.g. uninitialised serverUrl in tests) all collapse - /// to a no-op — drift stays on the prior cached state. - Future _refreshFromServer() async { - try { - final online = await ref - .read(connectivityProvider.future) - .timeout(const Duration(seconds: 3), onTimeout: () => true); - if (!online) return; - final dio = await ref.read(dioProvider.future); - final fresh = await MeApi(dio).quarantineMine(); - final db = ref.read(appDbProvider); - await db.transaction(() async { - // Full replace — quarantine list is small and we want server - // unflags from other devices to take effect. Doing this in a - // transaction means the watch() sees exactly one emission with - // the merged state, not a delete-then-insert flicker. - await db.delete(db.cachedQuarantineMine).go(); - for (final r in fresh) { - await db - .into(db.cachedQuarantineMine) - .insertOnConflictUpdate(_modelToCompanion(r)); - } - }); - } catch (_) { - // Swallow — cached state stays visible. - } - } - - /// True when this track is in the caller's quarantine list. - bool isHidden(String trackId) => - (state.value ?? const []).any((r) => r.trackId == trackId); - - /// Optimistic flag: inserts the synthetic row into drift (watch fires - /// immediately so the UI updates), calls server, rolls back on error. - Future flag(TrackRef track, String reason, String notes) async { - final db = ref.read(appDbProvider); - final existed = await (db.select(db.cachedQuarantineMine) - ..where((t) => t.trackId.equals(track.id))) - .getSingleOrNull(); - if (existed != null) return; // already hidden - - final companion = CachedQuarantineMineCompanion.insert( - trackId: track.id, - reason: reason, - notes: notes.isEmpty - ? const drift.Value.absent() - : drift.Value(notes), - createdAt: DateTime.now().toUtc().toIso8601String(), - trackTitle: track.title, - trackDurationMs: drift.Value(track.durationSec * 1000), - albumId: track.albumId, - albumTitle: track.albumTitle, - artistId: track.artistId, - artistName: track.artistName, - ); - await db.into(db.cachedQuarantineMine).insertOnConflictUpdate(companion); - - try { - final api = await ref.read(quarantineApiProvider.future); - await api.flag(track.id, reason, notes: notes); - } catch (_) { - // REST failed — don't roll back; queue for replay so the user's - // "hide this track" intent persists offline. - await ref.read(mutationQueueProvider).enqueue( - MutationKinds.quarantineFlag, - {'trackId': track.id, 'reason': reason, 'notes': notes}, - ); - } - } - - /// Optimistic unflag: removes the drift row, calls server, queues - /// the call on failure so the unhide intent persists offline. - Future unflag(String trackId) async { - final db = ref.read(appDbProvider); - final existing = await (db.select(db.cachedQuarantineMine) - ..where((t) => t.trackId.equals(trackId))) - .getSingleOrNull(); - if (existing == null) return; - - await (db.delete(db.cachedQuarantineMine) - ..where((t) => t.trackId.equals(trackId))) - .go(); - - try { - final api = await ref.read(quarantineApiProvider.future); - await api.unflag(trackId); - } catch (_) { - // Don't restore the drift row; queue the unflag for replay. - await ref - .read(mutationQueueProvider) - .enqueue(MutationKinds.quarantineUnflag, {'trackId': trackId}); - } - } - - static QuarantineMineRow _rowToModel(CachedQuarantineMineData r) => - QuarantineMineRow( - trackId: r.trackId, - reason: r.reason, - notes: r.notes, - createdAt: r.createdAt, - trackTitle: r.trackTitle, - trackDurationMs: r.trackDurationMs, - albumId: r.albumId, - albumTitle: r.albumTitle, - albumCoverArtPath: r.albumCoverArtPath, - artistId: r.artistId, - artistName: r.artistName, - ); - - static CachedQuarantineMineCompanion _modelToCompanion(QuarantineMineRow r) => - CachedQuarantineMineCompanion.insert( - trackId: r.trackId, - reason: r.reason, - notes: r.notes == null ? const drift.Value.absent() : drift.Value(r.notes), - createdAt: r.createdAt, - trackTitle: r.trackTitle, - trackDurationMs: drift.Value(r.trackDurationMs), - albumId: r.albumId, - albumTitle: r.albumTitle, - albumCoverArtPath: r.albumCoverArtPath == null - ? const drift.Value.absent() - : drift.Value(r.albumCoverArtPath), - artistId: r.artistId, - artistName: r.artistName, - ); -} - -final myQuarantineProvider = - AsyncNotifierProvider>( - MyQuarantineController.new, -); diff --git a/flutter_client/lib/requests/requests_provider.dart b/flutter_client/lib/requests/requests_provider.dart deleted file mode 100644 index a00c47d4..00000000 --- a/flutter_client/lib/requests/requests_provider.dart +++ /dev/null @@ -1,65 +0,0 @@ -import 'dart:async'; - -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../api/endpoints/requests.dart'; -import '../cache/mutation_queue.dart'; -import '../library/library_providers.dart' show dioProvider; -import '../models/admin_request.dart'; - -final requestsApiProvider = FutureProvider((ref) async { - return RequestsApi(await ref.watch(dioProvider.future)); -}); - -/// Mirrors the web's createMyRequestsQuery auto-poll (#369): refresh -/// every 12s while any row is mid-ingest (status='approved'), stop when -/// all rows settle. Polls regardless of app foreground/background — a -/// future enhancement could pause via WidgetsBindingObserver, but for -/// v1 the small extra refresh is acceptable. -class MyRequestsController extends AsyncNotifier> { - static const Duration _pollInterval = Duration(seconds: 12); - - Timer? _pollTimer; - - @override - Future> build() async { - ref.onDispose(() { - _pollTimer?.cancel(); - _pollTimer = null; - }); - final api = await ref.watch(requestsApiProvider.future); - final rows = await api.listMine(); - _maybeStartPolling(rows); - return rows; - } - - void _maybeStartPolling(List rows) { - _pollTimer?.cancel(); - if (rows.any((r) => r.status == 'approved')) { - _pollTimer = Timer.periodic(_pollInterval, (_) { - ref.invalidateSelf(); - }); - } - } - - /// Optimistic remove + REST cancel. On failure the row stays - /// removed in-memory and the cancel is queued for replay — this - /// keeps the user's intent visible across network loss instead of - /// restoring a row they explicitly asked to remove. - Future cancel(String id) async { - final api = await ref.read(requestsApiProvider.future); - final current = state.value ?? const []; - state = AsyncData(current.where((r) => r.id != id).toList()); - try { - await api.cancel(id); - } catch (_) { - await ref - .read(mutationQueueProvider) - .enqueue(MutationKinds.requestCancel, {'id': id}); - } - } -} - -final myRequestsProvider = - AsyncNotifierProvider>( - MyRequestsController.new); diff --git a/flutter_client/lib/requests/requests_screen.dart b/flutter_client/lib/requests/requests_screen.dart deleted file mode 100644 index d4a15d38..00000000 --- a/flutter_client/lib/requests/requests_screen.dart +++ /dev/null @@ -1,253 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_lucide/flutter_lucide.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; - -import '../models/admin_request.dart'; -import '../shared/live_events_provider.dart'; -import '../shared/widgets/main_app_bar_actions.dart'; -import '../theme/theme_extension.dart'; -import 'requests_provider.dart'; - -class RequestsScreen extends ConsumerWidget { - const RequestsScreen({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final fs = Theme.of(context).extension()!; - // #402 wire-up: refresh the user's own requests list on - // request.status_changed. Server-side events are user-scoped - // (admin actions on someone else's request route to that other - // user's stream); the dispatcher's filter already drops mismatches. - ref.listen>(liveEventsProvider, (_, next) { - final e = next.asData?.value; - if (e?.kind == 'request.status_changed') { - ref.invalidate(myRequestsProvider); - } - }); - final requests = ref.watch(myRequestsProvider); - - return Scaffold( - backgroundColor: fs.obsidian, - appBar: AppBar( - backgroundColor: fs.obsidian, - elevation: 0, - leading: IconButton( - icon: Icon(LucideIcons.arrow_left, color: fs.parchment), - onPressed: () => context.pop(), - ), - title: Text('Your requests', style: TextStyle(color: fs.parchment)), - actions: const [MainAppBarActions(currentRoute: '/requests')], - ), - body: SafeArea( - child: requests.when( - loading: () => const Center(child: CircularProgressIndicator()), - error: (e, _) => Center( - child: Padding( - padding: const EdgeInsets.all(24), - child: Text('$e', style: TextStyle(color: fs.error)), - ), - ), - data: (rows) { - if (rows.isEmpty) { - return Center( - child: Text('Nothing requested yet.', - style: TextStyle(color: fs.ash)), - ); - } - final notifier = ref.read(myRequestsProvider.notifier); - return RefreshIndicator( - onRefresh: () async => ref.refresh(myRequestsProvider.future), - child: ListView.separated( - itemCount: rows.length, - padding: const EdgeInsets.symmetric(vertical: 8), - separatorBuilder: (_, __) => Divider(color: fs.iron, height: 1), - itemBuilder: (_, i) => _RequestRow( - key: Key('request_row_${rows[i].id}'), - request: rows[i], - onCancel: () => notifier.cancel(rows[i].id), - ), - ), - ); - }, - ), - ), - ); - } -} - -class _RequestRow extends StatelessWidget { - const _RequestRow({ - super.key, - required this.request, - required this.onCancel, - }); - - final AdminRequest request; - final VoidCallback onCancel; - - @override - Widget build(BuildContext context) { - final fs = Theme.of(context).extension()!; - final href = _listenHref(request); - return ListTile( - leading: _kindAvatar(fs), - title: Text( - request.displayName, - style: TextStyle( - color: fs.parchment, - fontFamily: 'Fraunces', - fontSize: 16, - ), - ), - subtitle: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox(height: 4), - Row( - children: [ - _KindPill(label: request.kind), - const SizedBox(width: 6), - _StatusPill(status: request.status), - ], - ), - if (request.importedAlbumCount > 0 || - request.importedTrackCount > 0) ...[ - const SizedBox(height: 4), - Text( - _ingestProgressText(request), - style: TextStyle(color: fs.accent, fontSize: 13), - ), - ], - if (request.status == 'rejected' && (request.notes ?? '').isNotEmpty) ...[ - const SizedBox(height: 4), - Text(request.notes!, style: TextStyle(color: fs.ash, fontSize: 13)), - ], - ], - ), - trailing: _trailing(context, fs, href), - ); - } - - Widget _kindAvatar(FabledSwordTheme fs) { - final icon = switch (request.kind) { - 'artist' => LucideIcons.disc_3, - 'album' => LucideIcons.library_big, - _ => LucideIcons.music, - }; - return CircleAvatar( - backgroundColor: fs.iron, - child: Icon(icon, size: 20, color: fs.ash), - ); - } - - Widget? _trailing(BuildContext context, FabledSwordTheme fs, String? href) { - if (request.status == 'pending') { - return TextButton.icon( - onPressed: () async { - final ok = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: const Text('Cancel request?'), - content: Text('Cancel "${request.displayName}"?'), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx, false), - child: const Text('Keep'), - ), - TextButton( - onPressed: () => Navigator.pop(ctx, true), - style: TextButton.styleFrom(foregroundColor: fs.oxblood), - child: const Text('Cancel request'), - ), - ], - ), - ); - if (ok == true) onCancel(); - }, - icon: const Icon(LucideIcons.x, size: 16), - label: const Text('Cancel'), - style: TextButton.styleFrom(foregroundColor: fs.ash), - ); - } - if (request.status == 'completed' && href != null) { - return TextButton.icon( - onPressed: () => context.push(href), - icon: const Icon(LucideIcons.play, size: 16), - label: const Text('Listen'), - style: TextButton.styleFrom(foregroundColor: fs.accent), - ); - } - return null; - } - - String _ingestProgressText(AdminRequest r) { - if (r.kind == 'artist') { - final albums = '${r.importedAlbumCount} ' - '${r.importedAlbumCount == 1 ? 'album' : 'albums'}'; - final tracks = '${r.importedTrackCount} ' - '${r.importedTrackCount == 1 ? 'track' : 'tracks'} ingested'; - return '$albums · $tracks'; - } - if (r.kind == 'album') { - return '${r.importedTrackCount} ' - '${r.importedTrackCount == 1 ? 'track' : 'tracks'} ingested'; - } - return 'Track ingested'; - } - - String? _listenHref(AdminRequest r) { - if (r.matchedTrackId != null) return '/albums/${r.matchedAlbumId ?? r.matchedTrackId}'; - if (r.matchedAlbumId != null) return '/albums/${r.matchedAlbumId}'; - if (r.matchedArtistId != null) return '/artists/${r.matchedArtistId}'; - return null; - } -} - -class _KindPill extends StatelessWidget { - const _KindPill({required this.label}); - final String label; - @override - Widget build(BuildContext context) { - final fs = Theme.of(context).extension()!; - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), - decoration: BoxDecoration( - color: fs.accent.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(999), - ), - child: Text( - label, - style: TextStyle(color: fs.accent, fontSize: 11), - ), - ); - } -} - -class _StatusPill extends StatelessWidget { - const _StatusPill({required this.status}); - final String status; - @override - Widget build(BuildContext context) { - final fs = Theme.of(context).extension()!; - final color = switch (status) { - 'pending' => fs.ash, - 'approved' => fs.bronze, - 'completed' => fs.moss, - 'rejected' => fs.oxblood, - 'failed' => fs.oxblood, - _ => fs.ash, - }; - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), - decoration: BoxDecoration( - color: color.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(999), - ), - child: Text( - status, - style: TextStyle(color: color, fontSize: 11), - ), - ); - } -} diff --git a/flutter_client/lib/search/search_provider.dart b/flutter_client/lib/search/search_provider.dart deleted file mode 100644 index b02021d1..00000000 --- a/flutter_client/lib/search/search_provider.dart +++ /dev/null @@ -1,37 +0,0 @@ -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../api/endpoints/search.dart'; -import '../library/library_providers.dart' show dioProvider; -import '../models/search_response.dart'; - -final searchApiProvider = FutureProvider((ref) async { - return SearchApi(await ref.watch(dioProvider.future)); -}); - -/// Current search query text. The screen's TextField writes here on -/// every keystroke; the resultsProvider below debounces. -class SearchQueryNotifier extends Notifier { - @override - String build() => ''; - void set(String q) => state = q; -} - -final searchQueryProvider = - NotifierProvider(SearchQueryNotifier.new); - -/// Debounced search results. Returns null for empty queries so the -/// screen can show a neutral empty state instead of "0 results." -/// -/// Debounce: 250ms. After the wait, re-reads the live query — if the -/// user has typed more in that window, this attempt is silently -/// abandoned (returns null) and a newer attempt fires on the next -/// rebuild. No request hits the server while the user is mid-typing. -final searchResultsProvider = FutureProvider((ref) async { - final q = ref.watch(searchQueryProvider).trim(); - if (q.isEmpty) return null; - await Future.delayed(const Duration(milliseconds: 250)); - // Re-read the (possibly newer) query; if the user typed more, bail. - if (ref.read(searchQueryProvider).trim() != q) return null; - final api = await ref.watch(searchApiProvider.future); - return api.search(q); -}); diff --git a/flutter_client/lib/search/search_screen.dart b/flutter_client/lib/search/search_screen.dart deleted file mode 100644 index 06315eb6..00000000 --- a/flutter_client/lib/search/search_screen.dart +++ /dev/null @@ -1,201 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_lucide/flutter_lucide.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; - -import '../library/widgets/album_card.dart'; -import '../library/widgets/artist_card.dart'; -import '../library/widgets/track_row.dart'; -import '../models/search_response.dart'; -import '../player/player_provider.dart'; -import '../shared/widgets/main_app_bar_actions.dart'; -import '../theme/theme_extension.dart'; -import 'search_provider.dart'; - -class SearchScreen extends ConsumerStatefulWidget { - const SearchScreen({super.key}); - - @override - ConsumerState createState() => _SearchScreenState(); -} - -class _SearchScreenState extends ConsumerState { - final _controller = TextEditingController(); - final _focus = FocusNode(); - - @override - void initState() { - super.initState(); - // Autofocus the field on screen entry; keyboard pops up so the - // user can start typing immediately. - WidgetsBinding.instance.addPostFrameCallback((_) => _focus.requestFocus()); - } - - @override - void dispose() { - _controller.dispose(); - _focus.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final fs = Theme.of(context).extension()!; - final results = ref.watch(searchResultsProvider); - return Scaffold( - backgroundColor: fs.obsidian, - appBar: AppBar( - backgroundColor: fs.obsidian, - elevation: 0, - leading: IconButton( - icon: Icon(LucideIcons.arrow_left, color: fs.parchment), - onPressed: () => context.pop(), - ), - title: TextField( - controller: _controller, - focusNode: _focus, - autofocus: true, - style: TextStyle(color: fs.parchment), - cursorColor: fs.accent, - decoration: InputDecoration( - hintText: 'Search artists, albums, tracks', - hintStyle: TextStyle(color: fs.ash), - border: InputBorder.none, - ), - onChanged: (v) => ref.read(searchQueryProvider.notifier).set(v), - textInputAction: TextInputAction.search, - ), - actions: [ - if (_controller.text.isNotEmpty) - IconButton( - icon: Icon(LucideIcons.x, color: fs.ash), - onPressed: () { - _controller.clear(); - ref.read(searchQueryProvider.notifier).set(''); - _focus.requestFocus(); - }, - ), - const MainAppBarActions(currentRoute: '/search'), - ], - ), - body: results.when( - loading: () => const Center(child: CircularProgressIndicator()), - error: (e, _) => Center( - child: Padding( - padding: const EdgeInsets.all(24), - child: Text('$e', style: TextStyle(color: fs.error)), - ), - ), - data: (r) => r == null - ? const _Hint(message: 'Type to search your library.') - : r.isEmpty - ? const _Hint(message: 'No matches for that query.') - : _Results(results: r), - ), - ); - } -} - -class _Hint extends StatelessWidget { - const _Hint({required this.message}); - final String message; - - @override - Widget build(BuildContext context) { - final fs = Theme.of(context).extension()!; - return Center( - child: Text(message, style: TextStyle(color: fs.ash)), - ); - } -} - -class _Results extends ConsumerWidget { - const _Results({required this.results}); - final SearchResponse results; - - @override - Widget build(BuildContext context, WidgetRef ref) { - return ListView( - children: [ - if (results.artists.items.isNotEmpty) ...[ - _Header(label: 'Artists', count: results.artists.total), - SizedBox( - height: 168, - child: ListView.builder( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric(horizontal: 8), - itemCount: results.artists.items.length, - itemBuilder: (ctx, i) { - final a = results.artists.items[i]; - return ArtistCard( - artist: a, - onTap: () => ctx.push('/artists/${a.id}'), - ); - }, - ), - ), - ], - if (results.albums.items.isNotEmpty) ...[ - _Header(label: 'Albums', count: results.albums.total), - SizedBox( - height: 200, - child: ListView.builder( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric(horizontal: 8), - itemCount: results.albums.items.length, - itemBuilder: (ctx, i) { - final a = results.albums.items[i]; - return AlbumCard( - album: a, - onTap: () => ctx.push('/albums/${a.id}'), - ); - }, - ), - ), - ], - if (results.tracks.items.isNotEmpty) ...[ - _Header(label: 'Tracks', count: results.tracks.total), - ...results.tracks.items.asMap().entries.map((e) { - final i = e.key; - final t = e.value; - return TrackRow( - track: t, - onTap: () => ref - .read(playerActionsProvider) - .playTracks(results.tracks.items, initialIndex: i), - ); - }), - ], - const SizedBox(height: 96), - ], - ); - } -} - -class _Header extends StatelessWidget { - const _Header({required this.label, required this.count}); - final String label; - final int count; - - @override - Widget build(BuildContext context) { - final fs = Theme.of(context).extension()!; - return Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), - child: Row( - children: [ - Text( - label, - style: TextStyle( - color: fs.parchment, - fontSize: 18, - fontWeight: FontWeight.w500, - ), - ), - const SizedBox(width: 8), - Text('$count', style: TextStyle(color: fs.ash, fontSize: 13)), - ], - ), - ); - } -} diff --git a/flutter_client/lib/settings/about_section.dart b/flutter_client/lib/settings/about_section.dart deleted file mode 100644 index 9a6131be..00000000 --- a/flutter_client/lib/settings/about_section.dart +++ /dev/null @@ -1,228 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_lucide/flutter_lucide.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:package_info_plus/package_info_plus.dart'; - -import '../theme/theme_extension.dart'; -import '../update/client_update_provider.dart'; -import '../update/update_info.dart'; - -final _packageInfoProvider = FutureProvider((_) { - return PackageInfo.fromPlatform(); -}); - -class AboutSection extends ConsumerStatefulWidget { - const AboutSection({super.key}); - - @override - ConsumerState createState() => _AboutSectionState(); -} - -enum _InstallStage { idle, downloading, error } - -class _AboutSectionState extends ConsumerState { - DateTime? _lastChecked; - bool _checking = false; - - _InstallStage _installStage = _InstallStage.idle; - double _installProgress = 0; - String? _installError; - - Future _checkNow() async { - setState(() => _checking = true); - ref.invalidate(clientUpdateProvider); - try { - await ref.read(clientUpdateProvider.future); - } finally { - if (mounted) { - setState(() { - _checking = false; - _lastChecked = DateTime.now(); - }); - } - } - } - - Future _install(UpdateInfo info) async { - setState(() { - _installStage = _InstallStage.downloading; - _installProgress = 0; - _installError = null; - }); - try { - final installer = await ref.read(updateInstallerProvider.future); - final path = await installer.download( - info.apkUrl, - onProgress: (p) { - if (mounted) setState(() => _installProgress = p); - }, - ); - await installer.install(path); - } catch (e) { - if (!mounted) return; - setState(() { - _installStage = _InstallStage.error; - _installError = '$e'; - }); - } - } - - @override - Widget build(BuildContext context) { - final fs = Theme.of(context).extension()!; - final pkg = ref.watch(_packageInfoProvider); - final update = ref.watch(clientUpdateProvider); - - final installed = pkg.value == null - ? '…' - : '${pkg.value!.version}+${pkg.value!.buildNumber}'; - - final UpdateInfo? available = update.value; - final hasUpdate = available != null; - - return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Padding( - padding: EdgeInsets.fromLTRB(16, 4, 16, 8), - child: _SectionHeader('About'), - ), - Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 4), - child: Row( - children: [ - Icon(LucideIcons.info, color: fs.parchment, size: 18), - const SizedBox(width: 8), - Text('Installed version', - style: TextStyle(color: fs.parchment, fontSize: 14)), - const Spacer(), - Text(installed, - style: TextStyle( - color: fs.ash, - fontSize: 13, - fontFamily: 'JetBrainsMono')), - ], - ), - ), - Padding( - padding: const EdgeInsets.fromLTRB(16, 4, 16, 4), - child: Row( - children: [ - Icon(LucideIcons.cloud, color: fs.parchment, size: 18), - const SizedBox(width: 8), - Text('Latest version', - style: TextStyle(color: fs.parchment, fontSize: 14)), - const Spacer(), - Text( - hasUpdate ? available.version : _statusFor(update, installed), - style: TextStyle( - color: hasUpdate ? fs.accent : fs.ash, - fontSize: 13, - fontFamily: 'JetBrainsMono'), - ), - ], - ), - ), - if (_lastChecked != null) - Padding( - padding: const EdgeInsets.fromLTRB(16, 4, 16, 0), - child: Text( - 'Last checked ${_formatTime(_lastChecked!)}', - style: TextStyle(color: fs.ash, fontSize: 11), - ), - ), - Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), - child: Row( - children: [ - FilledButton.icon( - key: const Key('about_check_for_updates'), - onPressed: _checking ? null : _checkNow, - style: FilledButton.styleFrom( - backgroundColor: fs.accent, - foregroundColor: fs.parchment, - ), - icon: _checking - ? SizedBox( - width: 14, - height: 14, - child: CircularProgressIndicator( - strokeWidth: 2, - valueColor: AlwaysStoppedAnimation(fs.parchment), - ), - ) - : const Icon(LucideIcons.refresh_cw, size: 18), - label: Text(_checking ? 'Checking…' : 'Check for updates'), - ), - if (hasUpdate) ...[ - const SizedBox(width: 12), - FilledButton.icon( - key: const Key('about_install_update'), - onPressed: _installStage == _InstallStage.downloading - ? null - : () => _install(available), - style: FilledButton.styleFrom( - backgroundColor: fs.moss, - foregroundColor: fs.parchment, - ), - icon: _installStage == _InstallStage.downloading - ? SizedBox( - width: 14, - height: 14, - child: CircularProgressIndicator( - strokeWidth: 2, - value: _installProgress > 0 ? _installProgress : null, - valueColor: AlwaysStoppedAnimation(fs.parchment), - ), - ) - : const Icon(LucideIcons.download, size: 18), - label: Text( - _installStage == _InstallStage.downloading - ? 'Downloading…' - : _installStage == _InstallStage.error - ? 'Retry install' - : 'Install ${available.version}', - ), - ), - ], - ], - ), - ), - if (_installStage == _InstallStage.error && _installError != null) - Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), - child: Text(_installError!, - style: TextStyle(color: fs.error, fontSize: 12), - maxLines: 2, - overflow: TextOverflow.ellipsis), - ), - ]); - } - - String _statusFor(AsyncValue update, String installed) { - if (update.isLoading) return 'Checking…'; - if (update.hasError) return 'Check failed'; - return 'Up to date'; - } - - String _formatTime(DateTime t) { - final h = t.hour.toString().padLeft(2, '0'); - final m = t.minute.toString().padLeft(2, '0'); - return '$h:$m'; - } -} - -class _SectionHeader extends StatelessWidget { - const _SectionHeader(this.label); - final String label; - @override - Widget build(BuildContext context) { - final fs = Theme.of(context).extension()!; - return Text( - label, - style: TextStyle( - color: fs.parchment, - fontSize: 16, - fontWeight: FontWeight.w500, - ), - ); - } -} diff --git a/flutter_client/lib/settings/settings_screen.dart b/flutter_client/lib/settings/settings_screen.dart deleted file mode 100644 index ba611366..00000000 --- a/flutter_client/lib/settings/settings_screen.dart +++ /dev/null @@ -1,575 +0,0 @@ -import 'package:dio/dio.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_lucide/flutter_lucide.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; - -import '../api/endpoints/settings.dart'; -import '../api/errors.dart'; -import '../library/library_providers.dart' show dioProvider; -import '../models/my_profile.dart'; -import '../shared/widgets/main_app_bar_actions.dart'; -import '../theme/theme_extension.dart'; -import 'about_section.dart'; -import 'storage_section.dart'; -import '../theme/theme_mode_provider.dart'; - -final _settingsApiProvider = FutureProvider((ref) async { - return SettingsApi(await ref.watch(dioProvider.future)); -}); - -final _profileProvider = FutureProvider((ref) async { - return (await ref.watch(_settingsApiProvider.future)).getProfile(); -}); - -final _lbStatusProvider = FutureProvider((ref) async { - return (await ref.watch(_settingsApiProvider.future)).getListenBrainz(); -}); - -class SettingsScreen extends ConsumerWidget { - const SettingsScreen({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final fs = Theme.of(context).extension()!; - return Scaffold( - backgroundColor: fs.obsidian, - appBar: AppBar( - backgroundColor: fs.obsidian, - elevation: 0, - leading: IconButton( - icon: Icon(LucideIcons.arrow_left, color: fs.parchment), - onPressed: () => context.pop(), - ), - title: Text('Settings', style: TextStyle(color: fs.parchment)), - actions: const [MainAppBarActions(currentRoute: '/settings')], - ), - body: ListView( - padding: const EdgeInsets.symmetric(vertical: 8), - children: const [ - _ProfileSection(), - _Divider(), - _RequestsSection(), - _Divider(), - _AppearanceSection(), - _Divider(), - StorageSection(), - _Divider(), - _PasswordSection(), - _Divider(), - _ListenBrainzSection(), - _Divider(), - AboutSection(), - _AdminSection(), - SizedBox(height: 96), - ], - ), - ); - } -} - -class _Divider extends StatelessWidget { - const _Divider(); - @override - Widget build(BuildContext context) { - final fs = Theme.of(context).extension()!; - return Padding( - padding: const EdgeInsets.symmetric(vertical: 16), - child: Container(height: 1, color: fs.iron), - ); - } -} - -class _SectionHeader extends StatelessWidget { - const _SectionHeader(this.label); - final String label; - @override - Widget build(BuildContext context) { - final fs = Theme.of(context).extension()!; - return Padding( - padding: const EdgeInsets.fromLTRB(16, 4, 16, 8), - child: Text( - label, - style: TextStyle( - color: fs.parchment, - fontSize: 16, - fontWeight: FontWeight.w500, - ), - ), - ); - } -} - -class _RequestsSection extends StatelessWidget { - const _RequestsSection(); - - @override - Widget build(BuildContext context) { - final fs = Theme.of(context).extension()!; - return ListTile( - key: const Key('settings_requests_card'), - leading: Icon(LucideIcons.list_music, color: fs.parchment), - title: Text( - 'My requests', - style: TextStyle( - color: fs.parchment, - fontFamily: 'Fraunces', - fontSize: 18, - ), - ), - subtitle: Text( - "Track what you've asked Minstrel to add", - style: TextStyle(color: fs.ash), - ), - trailing: Icon(LucideIcons.chevron_right, color: fs.ash), - onTap: () => context.push('/requests'), - ); - } -} - -/// Renders an "Admin" entry only when the current profile has -/// `is_admin = true`. Returns an empty SizedBox otherwise so the -/// const-children layout above stays valid. -class _AdminSection extends ConsumerWidget { - const _AdminSection(); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final fs = Theme.of(context).extension()!; - final profile = ref.watch(_profileProvider).value; - if (profile == null || !profile.isAdmin) return const SizedBox.shrink(); - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const _Divider(), - ListTile( - key: const Key('settings_admin_card'), - leading: Icon(LucideIcons.shield, color: fs.parchment), - title: Text( - 'Admin', - style: TextStyle( - color: fs.parchment, - fontFamily: 'Fraunces', - fontSize: 18, - ), - ), - subtitle: Text( - 'Manage requests, quarantine, and users', - style: TextStyle(color: fs.ash), - ), - trailing: Icon(LucideIcons.chevron_right, color: fs.ash), - onTap: () => context.push('/admin'), - ), - ], - ); - } -} - -class _ProfileSection extends ConsumerStatefulWidget { - const _ProfileSection(); - @override - ConsumerState<_ProfileSection> createState() => _ProfileSectionState(); -} - -class _ProfileSectionState extends ConsumerState<_ProfileSection> { - final _displayName = TextEditingController(); - final _email = TextEditingController(); - bool _initialized = false; - bool _saving = false; - - @override - void dispose() { - _displayName.dispose(); - _email.dispose(); - super.dispose(); - } - - Future _save() async { - final fs = Theme.of(context).extension()!; - setState(() => _saving = true); - try { - final api = await ref.read(_settingsApiProvider.future); - await api.updateProfile( - displayName: _displayName.text.trim(), - email: _email.text.trim(), - ); - ref.invalidate(_profileProvider); - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: const Text('Profile saved.'), - backgroundColor: fs.iron, - ), - ); - } - } on DioException catch (e) { - if (mounted) { - final msg = ApiError.fromDio(e).code; - ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: Text('Save failed: $msg'), - backgroundColor: fs.error, - )); - } - } finally { - if (mounted) setState(() => _saving = false); - } - } - - @override - Widget build(BuildContext context) { - final fs = Theme.of(context).extension()!; - final profile = ref.watch(_profileProvider); - return profile.when( - loading: () => const Padding( - padding: EdgeInsets.all(16), - child: Center(child: CircularProgressIndicator()), - ), - error: (e, _) => Padding( - padding: const EdgeInsets.all(16), - child: Text('$e', style: TextStyle(color: fs.error)), - ), - data: (p) { - if (!_initialized) { - _displayName.text = p.displayName ?? ''; - _email.text = p.email ?? ''; - _initialized = true; - } - return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - const _SectionHeader('Profile'), - Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 4), - child: Text( - 'Signed in as ${p.username}${p.isAdmin ? " · admin" : ""}', - style: TextStyle(color: fs.ash, fontSize: 12), - ), - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: TextField( - controller: _displayName, - style: TextStyle(color: fs.parchment), - decoration: _inputDecoration(fs, 'Display name'), - ), - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: TextField( - controller: _email, - style: TextStyle(color: fs.parchment), - keyboardType: TextInputType.emailAddress, - decoration: _inputDecoration(fs, 'Email (for password reset)'), - ), - ), - Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), - child: FilledButton( - onPressed: _saving ? null : _save, - style: FilledButton.styleFrom( - backgroundColor: fs.accent, - foregroundColor: fs.parchment, - ), - child: Text(_saving ? 'Saving…' : 'Save profile'), - ), - ), - ]); - }, - ); - } -} - -class _PasswordSection extends ConsumerStatefulWidget { - const _PasswordSection(); - @override - ConsumerState<_PasswordSection> createState() => _PasswordSectionState(); -} - -class _PasswordSectionState extends ConsumerState<_PasswordSection> { - final _current = TextEditingController(); - final _next = TextEditingController(); - final _confirm = TextEditingController(); - bool _saving = false; - - @override - void dispose() { - _current.dispose(); - _next.dispose(); - _confirm.dispose(); - super.dispose(); - } - - Future _change() async { - final fs = Theme.of(context).extension()!; - if (_next.text != _confirm.text) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: const Text('New passwords do not match.'), - backgroundColor: fs.error, - )); - return; - } - if (_next.text.length < 8) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: const Text('Password must be at least 8 characters.'), - backgroundColor: fs.error, - )); - return; - } - setState(() => _saving = true); - try { - final api = await ref.read(_settingsApiProvider.future); - await api.changePassword(current: _current.text, next: _next.text); - _current.clear(); - _next.clear(); - _confirm.clear(); - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: const Text('Password changed.'), - backgroundColor: fs.iron, - )); - } - } on DioException catch (e) { - if (mounted) { - final msg = ApiError.fromDio(e).code; - ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: Text('Change failed: $msg'), - backgroundColor: fs.error, - )); - } - } finally { - if (mounted) setState(() => _saving = false); - } - } - - @override - Widget build(BuildContext context) { - final fs = Theme.of(context).extension()!; - return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - const _SectionHeader('Password'), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: TextField( - controller: _current, - obscureText: true, - style: TextStyle(color: fs.parchment), - decoration: _inputDecoration(fs, 'Current password'), - ), - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: TextField( - controller: _next, - obscureText: true, - style: TextStyle(color: fs.parchment), - decoration: _inputDecoration(fs, 'New password'), - ), - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: TextField( - controller: _confirm, - obscureText: true, - style: TextStyle(color: fs.parchment), - decoration: _inputDecoration(fs, 'Confirm new password'), - ), - ), - Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), - child: FilledButton( - onPressed: _saving ? null : _change, - style: FilledButton.styleFrom( - backgroundColor: fs.accent, - foregroundColor: fs.parchment, - ), - child: Text(_saving ? 'Changing…' : 'Change password'), - ), - ), - ]); - } -} - -class _ListenBrainzSection extends ConsumerStatefulWidget { - const _ListenBrainzSection(); - @override - ConsumerState<_ListenBrainzSection> createState() => - _ListenBrainzSectionState(); -} - -class _ListenBrainzSectionState extends ConsumerState<_ListenBrainzSection> { - final _token = TextEditingController(); - bool _saving = false; - - @override - void dispose() { - _token.dispose(); - super.dispose(); - } - - Future _saveToken() async { - final fs = Theme.of(context).extension()!; - if (_token.text.trim().isEmpty) return; - setState(() => _saving = true); - try { - final api = await ref.read(_settingsApiProvider.future); - await api.setListenBrainzToken(_token.text.trim()); - _token.clear(); - ref.invalidate(_lbStatusProvider); - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: const Text('Token saved.'), - backgroundColor: fs.iron, - )); - } - } on DioException catch (e) { - if (mounted) { - final msg = ApiError.fromDio(e).code; - ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: Text('Save failed: $msg'), - backgroundColor: fs.error, - )); - } - } finally { - if (mounted) setState(() => _saving = false); - } - } - - Future _toggleEnabled(bool value) async { - final fs = Theme.of(context).extension()!; - try { - final api = await ref.read(_settingsApiProvider.future); - await api.setListenBrainzEnabled(value); - ref.invalidate(_lbStatusProvider); - } on DioException catch (e) { - if (mounted) { - final msg = ApiError.fromDio(e).code; - ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: Text('Toggle failed: $msg'), - backgroundColor: fs.error, - )); - } - } - } - - @override - Widget build(BuildContext context) { - final fs = Theme.of(context).extension()!; - final status = ref.watch(_lbStatusProvider); - return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - const _SectionHeader('ListenBrainz'), - Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), - child: Text( - 'Get a token at listenbrainz.org/profile. Tokens are stored ' - 'unencrypted on this server — treat as sensitive.', - style: TextStyle(color: fs.ash, fontSize: 12), - ), - ), - status.when( - loading: () => const Padding( - padding: EdgeInsets.all(16), - child: Center(child: CircularProgressIndicator()), - ), - error: (e, _) => Padding( - padding: const EdgeInsets.all(16), - child: Text('$e', style: TextStyle(color: fs.error)), - ), - data: (s) => Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: TextField( - controller: _token, - obscureText: true, - style: TextStyle(color: fs.parchment), - decoration: _inputDecoration( - fs, - s.tokenSet ? 'Update token (current is set)' : 'Paste token', - ), - ), - ), - Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), - child: FilledButton( - onPressed: _saving ? null : _saveToken, - style: FilledButton.styleFrom( - backgroundColor: fs.accent, - foregroundColor: fs.parchment, - ), - child: Text(_saving ? 'Saving…' : 'Save token'), - ), - ), - SwitchListTile( - title: Text( - 'Send my plays to ListenBrainz', - style: TextStyle(color: fs.parchment), - ), - subtitle: s.lastScrobbledAt != null - ? Text( - 'Last scrobble: ${s.lastScrobbledAt}', - style: TextStyle(color: fs.ash, fontSize: 11), - ) - : null, - value: s.enabled, - activeThumbColor: fs.accent, - onChanged: s.tokenSet ? _toggleEnabled : null, - ), - ], - ), - ), - ]); - } -} - -InputDecoration _inputDecoration(FabledSwordTheme fs, String label) { - return InputDecoration( - labelText: label, - labelStyle: TextStyle(color: fs.ash), - enabledBorder: OutlineInputBorder( - borderSide: BorderSide(color: fs.iron), - ), - focusedBorder: OutlineInputBorder( - borderSide: BorderSide(color: fs.accent), - ), - ); -} - -class _AppearanceSection extends ConsumerWidget { - const _AppearanceSection(); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final fs = Theme.of(context).extension()!; - final current = ref.watch(themeModeProvider).value ?? AppThemeMode.system; - // RadioGroup is the post-Flutter-3.32 API: the ancestor owns - // groupValue/onChanged so individual RadioListTiles don't have - // to repeat them. Pre-3.32 RadioListTile.groupValue/onChanged - // are deprecated. - return RadioGroup( - groupValue: current, - onChanged: (m) { - if (m != null) { - ref.read(themeModeProvider.notifier).set(m); - } - }, - child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - const _SectionHeader('Appearance'), - for (final mode in AppThemeMode.values) - RadioListTile( - key: Key('appearance_${mode.name}'), - title: Text(_label(mode), style: TextStyle(color: fs.parchment)), - subtitle: mode == AppThemeMode.system - ? Text('Match the device setting', - style: TextStyle(color: fs.ash)) - : null, - value: mode, - activeColor: fs.accent, - ), - ]), - ); - } - - String _label(AppThemeMode m) => switch (m) { - AppThemeMode.system => 'System', - AppThemeMode.light => 'Light', - AppThemeMode.dark => 'Dark', - }; -} diff --git a/flutter_client/lib/settings/storage_section.dart b/flutter_client/lib/settings/storage_section.dart deleted file mode 100644 index 8bee9d7a..00000000 --- a/flutter_client/lib/settings/storage_section.dart +++ /dev/null @@ -1,255 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_lucide/flutter_lucide.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../cache/audio_cache_manager.dart'; -import '../cache/cache_settings_provider.dart'; -import '../cache/sync_controller.dart'; -import '../likes/likes_provider.dart' show likedIdsProvider; -import '../theme/theme_extension.dart'; - -/// Settings card: per-bucket usage, two cap selectors (Liked + -/// Recently-played), prefetch window, cache-liked toggle, Clear -/// cache + Sync now. #427 S2/S3: two independent budgets. -class StorageSection extends ConsumerStatefulWidget { - const StorageSection({super.key}); - - @override - ConsumerState createState() => _StorageSectionState(); -} - -class _StorageSectionState extends ConsumerState { - BucketUsage? _usage; - bool _syncing = false; - - @override - void initState() { - super.initState(); - _refreshUsage(); - } - - Set _likedSet() => - ref.read(likedIdsProvider).value?.tracks ?? const {}; - - Future _refreshUsage() async { - final mgr = ref.read(audioCacheManagerProvider); - final u = await mgr.bucketUsage(_likedSet()); - if (mounted) setState(() => _usage = u); - } - - String _fmtBytes(int? n) { - if (n == null) return '—'; - if (n == 0) return '0 B'; - if (n < 1024) return '$n B'; - if (n < 1024 * 1024) return '${(n / 1024).toStringAsFixed(1)} KB'; - if (n < 1024 * 1024 * 1024) { - return '${(n / 1024 / 1024).toStringAsFixed(1)} MB'; - } - return '${(n / 1024 / 1024 / 1024).toStringAsFixed(2)} GB'; - } - - String _cap(int bytes) => bytes == 0 ? 'unlimited' : _fmtBytes(bytes); - - @override - Widget build(BuildContext context) { - final fs = Theme.of(context).extension()!; - final settings = ref.watch(cacheSettingsProvider); - return Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), - child: Card( - color: fs.iron, - child: Padding( - padding: const EdgeInsets.all(16), - child: settings.when( - loading: () => const Padding( - padding: EdgeInsets.all(8), - child: Center(child: CircularProgressIndicator()), - ), - error: (e, _) => Text('$e', style: TextStyle(color: fs.error)), - data: (s) => Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Storage', - style: TextStyle( - color: fs.parchment, - fontSize: 18, - fontWeight: FontWeight.w500)), - const SizedBox(height: 12), - _usageRow('Liked', _usage?.liked, s.likedCapBytes, fs), - const SizedBox(height: 4), - _usageRow('Recently played', _usage?.rolling, - s.rollingCapBytes, fs), - const SizedBox(height: 16), - _capSelector( - 'Liked cache limit', - const Key('liked_cap_selector'), - s.likedCapBytes, - (v) => ref - .read(cacheSettingsProvider.notifier) - .setLikedCapBytes(v), - s, - fs, - ), - const SizedBox(height: 8), - _capSelector( - 'Recently-played cache limit', - const Key('rolling_cap_selector'), - s.rollingCapBytes, - (v) => ref - .read(cacheSettingsProvider.notifier) - .setRollingCapBytes(v), - s, - fs, - ), - const SizedBox(height: 8), - _prefetchSelector(s, fs), - const SizedBox(height: 8), - SwitchListTile( - key: const Key('cache_liked_toggle'), - contentPadding: EdgeInsets.zero, - title: Text('Cache liked tracks', - style: TextStyle(color: fs.parchment)), - value: s.cacheLikedTracks, - onChanged: (v) => ref - .read(cacheSettingsProvider.notifier) - .setCacheLikedTracks(v), - ), - const SizedBox(height: 12), - Wrap(spacing: 8, runSpacing: 8, children: [ - OutlinedButton( - key: const Key('clear_cache_button'), - onPressed: _confirmClear, - child: const Text('Clear cache'), - ), - OutlinedButton.icon( - key: const Key('sync_now_button'), - onPressed: _syncing - ? null - : () async { - setState(() => _syncing = true); - try { - await ref - .read(syncControllerProvider.notifier) - .sync(); - } finally { - if (mounted) setState(() => _syncing = false); - } - }, - icon: _syncing - ? const SizedBox( - width: 14, - height: 14, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(LucideIcons.refresh_cw, size: 16), - label: const Text('Sync now'), - ), - ]), - ], - ), - ), - ), - ), - ); - } - - Widget _usageRow(String label, int? used, int cap, FabledSwordTheme fs) { - return Row(children: [ - Text(label, style: TextStyle(color: fs.ash)), - const Spacer(), - Text('${_fmtBytes(used)} / ${_cap(cap)}', - style: TextStyle(color: fs.parchment)), - ]); - } - - static const _capOptions = [ - (1024 * 1024 * 1024, '1 GB'), - (5 * 1024 * 1024 * 1024, '5 GB'), - (10 * 1024 * 1024 * 1024, '10 GB'), - (25 * 1024 * 1024 * 1024, '25 GB'), - (0, 'Unlimited'), - ]; - - Widget _capSelector( - String label, - Key key, - int current, - Future Function(int) setCap, - CacheSettings s, - FabledSwordTheme fs, - ) { - return Row(children: [ - Expanded(child: Text(label, style: TextStyle(color: fs.ash))), - DropdownButton( - key: key, - value: _capOptions.any((o) => o.$1 == current) - ? current - : 5 * 1024 * 1024 * 1024, - items: _capOptions - .map((o) => DropdownMenuItem(value: o.$1, child: Text(o.$2))) - .toList(), - onChanged: (v) async { - if (v == null) return; - await setCap(v); - // Enforce immediately against the freshest settings. - final fresh = ref.read(cacheSettingsProvider).value; - if (fresh != null) { - await ref.read(audioCacheManagerProvider).evictBuckets( - likedCap: fresh.likedCapBytes, - rollingCap: fresh.rollingCapBytes, - liked: _likedSet(), - ); - } - await _refreshUsage(); - }, - ), - ]); - } - - Widget _prefetchSelector(CacheSettings s, FabledSwordTheme fs) { - const options = [1, 3, 5, 7, 10]; - return Row(children: [ - Expanded( - child: Text('Pre-fetch ahead', style: TextStyle(color: fs.ash))), - DropdownButton( - key: const Key('prefetch_selector'), - value: options.contains(s.prefetchWindow) ? s.prefetchWindow : 5, - items: options - .map((n) => DropdownMenuItem(value: n, child: Text('$n tracks'))) - .toList(), - onChanged: (v) async { - if (v == null) return; - await ref.read(cacheSettingsProvider.notifier).setPrefetchWindow(v); - }, - ), - ]); - } - - Future _confirmClear() async { - final fs = Theme.of(context).extension()!; - final confirmed = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: const Text('Clear cache?'), - content: const Text( - 'This deletes all cached audio (liked and recently-played). ' - 'The next play of any track re-downloads from the server.', - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx, false), - child: const Text('Cancel'), - ), - FilledButton( - style: FilledButton.styleFrom(backgroundColor: fs.error), - onPressed: () => Navigator.pop(ctx, true), - child: const Text('Clear'), - ), - ], - ), - ); - if (confirmed != true) return; - await ref.read(audioCacheManagerProvider).clearAll(); - await _refreshUsage(); - } -} diff --git a/flutter_client/lib/shared/delayed_loading.dart b/flutter_client/lib/shared/delayed_loading.dart deleted file mode 100644 index 01b7eebc..00000000 --- a/flutter_client/lib/shared/delayed_loading.dart +++ /dev/null @@ -1,66 +0,0 @@ -import 'dart:async'; -import 'package:flutter/widgets.dart'; - -/// Renders [whileDelayed] only after [isLoading] has been true -/// continuously for [delay], then renders [whenReady] once loading -/// completes. Mirrors the web `useDelayed` hook: a brief loading flash -/// doesn't trigger a skeleton; sustained loading does. -/// -/// Once [isLoading] flips back to false, the timer resets. -class DelayedLoading extends StatefulWidget { - const DelayedLoading({ - super.key, - required this.isLoading, - required this.whileDelayed, - required this.whenReady, - this.delay = const Duration(milliseconds: 200), - }); - - final bool isLoading; - final Widget whileDelayed; - final Widget whenReady; - final Duration delay; - - @override - State createState() => _DelayedLoadingState(); -} - -class _DelayedLoadingState extends State { - Timer? _timer; - bool _delayPassed = false; - - @override - void initState() { - super.initState(); - _maybeStart(); - } - - @override - void didUpdateWidget(DelayedLoading old) { - super.didUpdateWidget(old); - if (widget.isLoading != old.isLoading) { - _timer?.cancel(); - _delayPassed = false; - _maybeStart(); - } - } - - void _maybeStart() { - if (!widget.isLoading) return; - _timer = Timer(widget.delay, () { - if (mounted) setState(() => _delayPassed = true); - }); - } - - @override - void dispose() { - _timer?.cancel(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - if (!widget.isLoading) return widget.whenReady; - return _delayPassed ? widget.whileDelayed : const SizedBox.shrink(); - } -} diff --git a/flutter_client/lib/shared/live_events_dispatcher.dart b/flutter_client/lib/shared/live_events_dispatcher.dart deleted file mode 100644 index d38d75ae..00000000 --- a/flutter_client/lib/shared/live_events_dispatcher.dart +++ /dev/null @@ -1,104 +0,0 @@ -// Maps incoming LiveEvent kinds to provider invalidations. -// -// Activated by ref.read(liveEventsDispatcherProvider) in app.dart; once -// activated, the dispatcher listens to liveEventsProvider for the -// lifetime of the ProviderScope and invalidates the small set of -// public-scoped providers we know about. -// -// Screen-scoped providers (file-private providers in library_screen.dart, -// admin handlers, etc.) opt in to live-refresh by themselves listening -// to liveEventsProvider — the dispatcher only handles cross-screen, -// publicly-importable providers. This keeps the dispatcher small and -// avoids a back-edge dependency from /shared onto every feature folder. -// -// Includes an AppLifecycleState resume handler that defensively -// invalidates the same set on app foreground. SSE will catch up on its -// own, but on cold-start or after a long background period the -// re-invalidate is fast and avoids stale data flashing before the -// stream reconnects. - -import 'package:flutter/widgets.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../library/library_providers.dart' show homeIndexProvider; -import '../quarantine/quarantine_provider.dart'; -import 'live_events_provider.dart'; - -/// Activates the SSE → invalidation dispatcher for the lifetime of the -/// containing ProviderScope. Read this once at startup (from app.dart); -/// the provider's body runs once, sets up listeners, and returns a -/// sentinel value. -final liveEventsDispatcherProvider = Provider<_LiveEventsDispatcher>((ref) { - final d = _LiveEventsDispatcher(ref); - ref.onDispose(d.dispose); - return d; -}); - -class _LiveEventsDispatcher with WidgetsBindingObserver { - _LiveEventsDispatcher(this._ref) { - // Subscribe to the event stream. Each emitted event invokes - // _handle. Errors / disconnects auto-rebuild the underlying - // provider; we don't need to react to them here. - _sub = _ref.listen>( - liveEventsProvider, - (_, next) => next.whenData(_handle), - fireImmediately: false, - ); - WidgetsBinding.instance.addObserver(this); - } - - final Ref _ref; - late final ProviderSubscription> _sub; - - void _handle(LiveEvent e) { - // Map event kinds to provider invalidations. Keep this list short: - // only providers reachable from /shared. Screen-private providers - // listen to liveEventsProvider themselves. - switch (e.kind) { - case 'quarantine.flagged': - case 'quarantine.unflagged': - case 'quarantine.resolved': - case 'quarantine.file_deleted': - case 'quarantine.deleted_via_lidarr': - _ref.invalidate(myQuarantineProvider); - // Hidden / liked / album rows referencing a deleted track may - // now be stale — homeIndexProvider re-fetch refreshes the cards. - _ref.invalidate(homeIndexProvider); - case 'playlist.created': - case 'playlist.updated': - case 'playlist.deleted': - case 'playlist.tracks_changed': - // Home renders a Playlists row; refresh it so a delete or add - // is reflected immediately. Detail screens that need the - // mutated row will get a separate invalidate from their own - // listener (filed as a follow-up). - _ref.invalidate(homeIndexProvider); - case 'scan.run_started': - case 'scan.run_finished': - // Admin scan card provider lives in the admin feature folder - // and is file-private today; will be invalidated by the admin - // dashboard's own listener once that exists. - break; - default: - // track.liked / track.unliked / request.status_changed reach - // screen-private providers; their screens listen directly. - break; - } - } - - @override - void didChangeAppLifecycleState(AppLifecycleState state) { - if (state == AppLifecycleState.resumed) { - // Defensive cold-start invalidation. SSE will catch up on its - // own, but the re-invalidate flushes any stale data that - // landed while the app was backgrounded. - _ref.invalidate(myQuarantineProvider); - _ref.invalidate(homeIndexProvider); - } - } - - void dispose() { - WidgetsBinding.instance.removeObserver(this); - _sub.close(); - } -} diff --git a/flutter_client/lib/shared/live_events_provider.dart b/flutter_client/lib/shared/live_events_provider.dart deleted file mode 100644 index 22b840bd..00000000 --- a/flutter_client/lib/shared/live_events_provider.dart +++ /dev/null @@ -1,126 +0,0 @@ -// Subscribes to the server's Server-Sent Events stream -// (GET /api/events/stream, see Fable #392) and exposes a parsed event -// stream as a Riverpod StreamProvider. Consumers wire invalidation -// behavior in live_events_dispatcher.dart. -// -// On disconnect (network blip, server restart, token rotation), the -// provider's StreamController completes; the dispatcher's auto-rebuild -// when the auth state changes re-subscribes. Explicit exponential -// backoff lives at the dispatcher layer so we don't re-create the dio -// connection too aggressively. - -import 'dart:async'; -import 'dart:convert'; - -import 'package:dio/dio.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../auth/auth_provider.dart'; -import '../library/library_providers.dart' show dioProvider; - -/// Parsed event from the server's SSE stream. `kind` follows -/// "domain.action" naming; `data` carries the payload map. -class LiveEvent { - const LiveEvent({required this.kind, required this.userId, required this.data}); - - final String kind; - final String userId; - final Map data; - - @override - String toString() => 'LiveEvent($kind, user=$userId, data=$data)'; -} - -/// Streams parsed LiveEvent values from /api/events/stream. Errors and -/// disconnects surface as stream errors; the dispatcher decides whether -/// to retry. -final liveEventsProvider = StreamProvider((ref) async* { - // Gate the subscription on having both a server URL and a session - // token. If either is missing, emit nothing and let the provider - // auto-rebuild when auth state lands. - final token = await ref.watch(sessionTokenProvider.future); - if (token == null || token.isEmpty) { - return; - } - final dio = await ref.watch(dioProvider.future); - - final controller = StreamController(); - ref.onDispose(controller.close); - - // ignore: unawaited_futures - _runSubscription(dio, controller); - - yield* controller.stream; -}); - -/// Runs the dio streaming request and pushes parsed events into -/// [controller]. Closes the controller when the stream ends or errors. -Future _runSubscription(Dio dio, StreamController controller) async { - try { - final resp = await dio.get( - '/api/events/stream', - options: Options( - responseType: ResponseType.stream, - // No timeout — the server emits 15s heartbeats; idle timeouts - // on the client side would tear down a healthy connection. - receiveTimeout: Duration.zero, - headers: const {'Accept': 'text/event-stream'}, - ), - ); - - // SSE frames are delimited by blank lines. Accumulate raw bytes - // into a string buffer; flush parsed events on each "\n\n". - var buffer = ''; - await for (final chunk in resp.data!.stream) { - buffer += utf8.decode(chunk, allowMalformed: true); - while (true) { - final i = buffer.indexOf('\n\n'); - if (i < 0) break; - final frame = buffer.substring(0, i); - buffer = buffer.substring(i + 2); - final event = _parseFrame(frame); - if (event != null && !controller.isClosed) { - controller.add(event); - } - } - } - if (!controller.isClosed) { - await controller.close(); - } - } catch (e, st) { - if (!controller.isClosed) { - controller.addError(e, st); - await controller.close(); - } - } -} - -/// Parses one SSE frame. Heartbeat comments (starting with ":") and -/// frames without a `data:` line return null. Frames with a `data:` -/// payload that doesn't parse as JSON are also dropped (logged at -/// debug level by the caller if needed). -LiveEvent? _parseFrame(String frame) { - String? kind; - String? dataLine; - for (final line in frame.split('\n')) { - if (line.isEmpty || line.startsWith(':')) { - continue; - } - if (line.startsWith('event:')) { - kind = line.substring(6).trim(); - } else if (line.startsWith('data:')) { - dataLine = line.substring(5).trim(); - } - } - if (dataLine == null) return null; - try { - final decoded = jsonDecode(dataLine) as Map; - return LiveEvent( - kind: kind ?? (decoded['kind'] as String? ?? ''), - userId: decoded['user_id'] as String? ?? '', - data: (decoded['data'] as Map?) ?? const {}, - ); - } catch (_) { - return null; - } -} diff --git a/flutter_client/lib/shared/routing.dart b/flutter_client/lib/shared/routing.dart deleted file mode 100644 index ef14e7a2..00000000 --- a/flutter_client/lib/shared/routing.dart +++ /dev/null @@ -1,174 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; - -import '../auth/auth_provider.dart'; -import '../auth/login_screen.dart'; -import '../auth/server_url_screen.dart'; -import '../library/album_detail_screen.dart'; -import '../library/artist_detail_screen.dart'; -import '../models/album.dart'; -import '../models/artist.dart'; -import '../models/playlist.dart'; -import '../discover/discover_screen.dart'; -import '../library/home_screen.dart'; -import '../library/library_screen.dart'; -import '../player/now_playing_screen.dart'; -import '../player/player_bar.dart'; -import '../player/queue_screen.dart'; -import '../playlists/playlist_detail_screen.dart'; -import '../playlists/playlists_list_screen.dart'; -import '../requests/requests_screen.dart'; -import '../search/search_screen.dart'; -import '../settings/settings_screen.dart'; -import '../update/client_update_provider.dart'; -import '../update/update_banner.dart'; -import '../admin/admin_landing_screen.dart'; -import '../admin/admin_requests_screen.dart'; -import '../admin/admin_quarantine_screen.dart'; -import '../admin/admin_users_screen.dart'; -import 'widgets/version_gate.dart'; - -/// Exposed as a Provider so its single argument is a real `Ref` (the -/// constructor's redirect closures use `ref.read`). Widgets consume the -/// router via `ref.watch(routerProvider)` instead of constructing it -/// themselves with a `WidgetRef`, which can't satisfy the `Ref` type. -final routerProvider = Provider((ref) => buildRouter(ref)); - -GoRouter buildRouter(Ref ref) { - return GoRouter( - initialLocation: '/', - redirect: (ctx, state) async { - final url = await ref.read(serverUrlProvider.future); - if (url == null) return '/server-url'; - final user = await ref.read(authControllerProvider.future); - final loc = state.matchedLocation; - if (user == null && loc != '/login' && loc != '/server-url') { - return '/login'; - } - if (user != null && (loc == '/login' || loc == '/server-url')) { - return '/home'; - } - if (loc.startsWith('/admin') && !user!.isAdmin) { - return '/home'; - } - return null; - }, - routes: [ - GoRoute(path: '/', redirect: (_, __) => '/home'), - GoRoute(path: '/server-url', builder: (_, __) => const ServerUrlScreen()), - GoRoute(path: '/login', builder: (_, __) => const LoginScreen()), - // /now-playing lives outside the ShellRoute on purpose. The full - // player IS the player UI when active, and we don't want the mini - // bar from the shell underneath fighting for the bottom strip. - // Pushing this route unmounts the shell entirely; the slide-up - // transition still feels right because the shell stays painted - // for the duration of the animation. - GoRoute( - path: '/now-playing', - pageBuilder: (_, __) => CustomTransitionPage( - child: const NowPlayingScreen(), - transitionDuration: const Duration(milliseconds: 280), - reverseTransitionDuration: const Duration(milliseconds: 240), - transitionsBuilder: (_, anim, __, child) { - final eased = CurvedAnimation( - parent: anim, - curve: Curves.easeOutCubic, - reverseCurve: Curves.easeInCubic, - ); - return SlideTransition( - position: Tween( - begin: const Offset(0, 1), - end: Offset.zero, - ).animate(eased), - child: child, - ); - }, - ), - ), - // /queue lives outside the ShellRoute too. Why: pushing /queue - // from /now-playing (which is also outside the shell) used to - // cause go_router to mount a second ShellRoute instance under - // the existing one, producing a duplicate page-key assertion - // (NavigatorState._debugCheckDuplicatedPageKeys). Top-level - // routes can stack on each other freely; shell-children can't - // when something on top of the shell is already routing. - GoRoute(path: '/queue', builder: (_, __) => const QueueScreen()), - ShellRoute( - builder: (ctx, state, child) => VersionGate(child: _ShellWithPlayerBar(child: child)), - routes: [ - GoRoute(path: '/home', builder: (_, __) => const HomeScreen()), - GoRoute( - path: '/artists/:id', - // `extra` carries an optional ArtistRef so the detail - // header renders immediately while tracks/albums load. - builder: (_, s) => ArtistDetailScreen( - id: s.pathParameters['id']!, - seed: s.extra is ArtistRef ? s.extra as ArtistRef : null, - ), - ), - GoRoute( - path: '/albums/:id', - builder: (_, s) => AlbumDetailScreen( - id: s.pathParameters['id']!, - seed: s.extra is AlbumRef ? s.extra as AlbumRef : null, - ), - ), - GoRoute(path: '/search', builder: (_, __) => const SearchScreen()), - GoRoute(path: '/library', builder: (_, __) => const LibraryScreen()), - GoRoute(path: '/discover', builder: (_, __) => const DiscoverScreen()), - GoRoute(path: '/settings', builder: (_, __) => const SettingsScreen()), - GoRoute(path: '/playlists', builder: (_, __) => const PlaylistsListScreen()), - GoRoute( - path: '/playlists/:id', - builder: (_, s) => PlaylistDetailScreen( - id: s.pathParameters['id']!, - seed: s.extra is Playlist ? s.extra as Playlist : null, - ), - ), - GoRoute(path: '/requests', builder: (_, __) => const RequestsScreen()), - GoRoute(path: '/admin', builder: (_, __) => const AdminLandingScreen()), - GoRoute(path: '/admin/requests', builder: (_, __) => const AdminRequestsScreen()), - GoRoute(path: '/admin/quarantine', builder: (_, __) => const AdminQuarantineScreen()), - GoRoute(path: '/admin/users', builder: (_, __) => const AdminUsersScreen()), - ], - ), - ], - ); -} - -class _ShellWithPlayerBar extends ConsumerWidget { - const _ShellWithPlayerBar({required this.child}); - final Widget child; - @override - Widget build(BuildContext context, WidgetRef ref) { - // The banner sits above the routed child and uses SafeArea to clear - // the system status bar. MediaQuery.padding is screen-relative - // though, so the child's Scaffold/AppBar would still apply its own - // top status-bar inset on top of the banner — doubling the gap. - // When the banner is showing, strip that inset from the child so - // its AppBar lands directly under the banner. - final hasBanner = ref.watch(shouldShowUpdateBannerProvider) != null; - return Column( - children: [ - // VersionTooOldBanner above UpdateBanner: a server-rejects-you - // soft warning carries more user-relevant urgency than the APK - // download prompt below it. The two banners can coexist - // (server says you're too old AND a local APK is queued). - const VersionTooOldBanner(), - const UpdateBanner(), - Expanded( - child: hasBanner - ? MediaQuery.removePadding( - context: context, - removeTop: true, - child: child, - ) - : child, - ), - const PlayerBar(), - ], - ); - } -} - diff --git a/flutter_client/lib/shared/widgets/connection_error_banner.dart b/flutter_client/lib/shared/widgets/connection_error_banner.dart deleted file mode 100644 index 57e62961..00000000 --- a/flutter_client/lib/shared/widgets/connection_error_banner.dart +++ /dev/null @@ -1,31 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:go_router/go_router.dart'; - -import '../../theme/theme_extension.dart'; - -class ConnectionErrorBanner extends StatelessWidget { - const ConnectionErrorBanner({required this.onRetry, super.key}); - final VoidCallback onRetry; - - @override - Widget build(BuildContext context) { - final fs = Theme.of(context).extension()!; - return Container( - color: fs.iron, - padding: const EdgeInsets.all(12), - child: Row(children: [ - Expanded( - child: Text( - "Couldn't reach the server.", - style: TextStyle(color: fs.parchment), - ), - ), - TextButton(onPressed: onRetry, child: const Text('Retry')), - TextButton( - onPressed: () => context.go('/server-url'), - child: const Text('Change URL'), - ), - ]), - ); - } -} diff --git a/flutter_client/lib/shared/widgets/lucide_heart.dart b/flutter_client/lib/shared/widgets/lucide_heart.dart deleted file mode 100644 index 9e103435..00000000 --- a/flutter_client/lib/shared/widgets/lucide_heart.dart +++ /dev/null @@ -1,43 +0,0 @@ -import 'package:flutter/widgets.dart'; -import 'package:flutter_svg/flutter_svg.dart'; - -/// The Lucide "heart" silhouette rendered as either an outline (stroke) -/// or a solid fill. Lucide ships only an outline heart, so the liked -/// state fills the same authoritative Lucide path (verified verbatim -/// from lucide-icons/lucide) — keeping both states visually Lucide -/// rather than introducing a Material filled heart. Used by LikeButton -/// (and, re-derived to a VectorDrawable, by the media notification). -class LucideHeart extends StatelessWidget { - const LucideHeart({ - required this.filled, - required this.color, - this.size = 22, - super.key, - }); - - final bool filled; - final Color color; - final double size; - - static const _path = - 'M2 9.5a5.5 5.5 0 0 1 9.591-3.676.56.56 0 0 0 .818 0A5.49 5.49 0 0 1 ' - '22 9.5c0 2.29-1.5 4-3 5.5l-5.492 5.313a2 2 0 0 1-3 .019L5 15c-1.5-1.5' - '-3-3.2-3-5.5'; - - @override - Widget build(BuildContext context) { - final svg = filled - ? '' - '' - : '' - ''; - return SvgPicture.string( - svg, - width: size, - height: size, - colorFilter: ColorFilter.mode(color, BlendMode.srcIn), - ); - } -} diff --git a/flutter_client/lib/shared/widgets/main_app_bar_actions.dart b/flutter_client/lib/shared/widgets/main_app_bar_actions.dart deleted file mode 100644 index 741cd4db..00000000 --- a/flutter_client/lib/shared/widgets/main_app_bar_actions.dart +++ /dev/null @@ -1,64 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_lucide/flutter_lucide.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; - -import '../../auth/auth_provider.dart'; -import '../../theme/theme_extension.dart'; - -/// Shared AppBar `actions` for top-level screens. Renders Home / Library / -/// Search as primary icons (suppressing the icon for [currentRoute]) plus -/// a kebab overflow with Playlists / Discover / Settings and (for admins) -/// Admin. -class MainAppBarActions extends ConsumerWidget { - const MainAppBarActions({super.key, required this.currentRoute}); - - final String currentRoute; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final fs = Theme.of(context).extension()!; - final user = ref.watch(authControllerProvider).value; - final isAdmin = user?.isAdmin ?? false; - - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (currentRoute != '/home') - IconButton( - key: const Key('app_bar_home'), - icon: Icon(LucideIcons.house, color: fs.parchment), - tooltip: 'Home', - onPressed: () => context.go('/home'), - ), - if (currentRoute != '/library') - IconButton( - key: const Key('app_bar_library'), - icon: Icon(LucideIcons.library_big, color: fs.parchment), - tooltip: 'Library', - onPressed: () => context.push('/library'), - ), - if (currentRoute != '/search') - IconButton( - key: const Key('app_bar_search'), - icon: Icon(LucideIcons.search, color: fs.parchment), - tooltip: 'Search', - onPressed: () => context.push('/search'), - ), - PopupMenuButton( - key: const Key('app_bar_overflow'), - icon: Icon(LucideIcons.ellipsis_vertical, color: fs.parchment), - tooltip: 'More', - onSelected: (route) => context.push(route), - itemBuilder: (_) => [ - const PopupMenuItem(value: '/playlists', child: Text('Playlists')), - const PopupMenuItem(value: '/discover', child: Text('Discover')), - const PopupMenuItem(value: '/settings', child: Text('Settings')), - if (isAdmin) - const PopupMenuItem(value: '/admin', child: Text('Admin')), - ], - ), - ], - ); - } -} diff --git a/flutter_client/lib/shared/widgets/server_image.dart b/flutter_client/lib/shared/widgets/server_image.dart deleted file mode 100644 index 92b854e4..00000000 --- a/flutter_client/lib/shared/widgets/server_image.dart +++ /dev/null @@ -1,104 +0,0 @@ -import 'package:cached_network_image/cached_network_image.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_cache_manager/flutter_cache_manager.dart' - show HttpExceptionWithStatus; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../../auth/auth_provider.dart'; - -/// CachedNetworkImage wrapper that resolves server-relative URLs (e.g. -/// `/api/albums//cover`) against the configured server base URL -/// from [serverUrlProvider]. Absolute URLs (with scheme) pass through -/// unchanged. Empty/null base or empty URL render the [fallback] -/// (defaults to a transparent SizedBox so callers can supply their own -/// surrounding placeholder). -/// -/// Mirrors the absolute-or-relative logic the audio handler uses for -/// stream URLs, so cover art and audio resolve URLs the same way. -/// -/// Uses CachedNetworkImage so cover bytes land on disk (path_provider -/// temp dir, keyed by URL) and survive scroll-off + app restart. The -/// previous Image.network behavior cached only in-memory, so the cold- -/// start home grid re-downloaded every cover. -class ServerImage extends ConsumerWidget { - const ServerImage({ - super.key, - required this.url, - this.fit, - this.fallback, - }); - - final String url; - final BoxFit? fit; - final Widget? fallback; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final empty = fallback ?? const SizedBox.shrink(); - if (url.isEmpty) return empty; - final base = ref.watch(serverUrlProvider).value; - final resolved = _resolve(base, url); - if (resolved == null) return empty; - // Cover endpoints are gated by RequireUser server-side. The image - // loader doesn't carry cookies/Bearer tokens automatically the way - // the browser's tag does, so we forward the session token as - // a header. Without this, the server returns 401 and the image - // fails. - // - // sessionTokenProvider is a FutureProvider — on first read after a - // hot restart its .value is null until the secure-storage read - // resolves. If we mounted the image with httpHeaders:null at that - // moment, the disk cache would lock in the 401 response and never - // retry. Hold the fallback until the token is available, then - // mount the image with the auth header attached. - final tokenAsync = ref.watch(sessionTokenProvider); - return tokenAsync.when( - loading: () => empty, - error: (_, __) => empty, - data: (token) { - final headers = (token != null && token.isNotEmpty) - ? {'Authorization': 'Bearer $token'} - : {}; - return CachedNetworkImage( - imageUrl: resolved, - httpHeaders: headers, - fit: fit, - // 120ms feels like cover bytes settling in on a cache miss - // (smoother than the abrupt zero-fade); on cache hits the - // image is decoded synchronously so the fade is imperceptible. - // The default 500ms is too long — looks like a regression on - // a populated grid. - fadeInDuration: const Duration(milliseconds: 120), - fadeOutDuration: Duration.zero, - // Keep failures local — a single 401/timeout shouldn't dump - // a stack trace or replace the parent Container's background. - errorWidget: (_, __, ___) => empty, - // Filter expected 404s out of dev console noise: playlist - // collages aren't built until the playlist has tracks and - // the build job runs (system playlists like For-You / - // Discover hit this on first-render). The errorWidget - // already renders the fallback for the user; this just - // keeps the dev console clean. Non-404 errors still - // surface so auth/connectivity issues remain visible. - errorListener: (err) { - if (err is HttpExceptionWithStatus && err.statusCode == 404) { - return; - } - debugPrint('ServerImage: $err'); - }, - ); - }, - ); - } - - static String? _resolve(String? baseUrl, String url) { - final parsed = Uri.tryParse(url); - if (parsed != null && parsed.hasScheme) return url; - if (baseUrl == null || baseUrl.isEmpty) return null; - final base = baseUrl.endsWith('/') - ? baseUrl.substring(0, baseUrl.length - 1) - : baseUrl; - final path = url.startsWith('/') ? url : '/$url'; - return '$base$path'; - } -} diff --git a/flutter_client/lib/shared/widgets/skeletons.dart b/flutter_client/lib/shared/widgets/skeletons.dart deleted file mode 100644 index edbbbbf8..00000000 --- a/flutter_client/lib/shared/widgets/skeletons.dart +++ /dev/null @@ -1,199 +0,0 @@ -// Skeleton placeholder widgets for the per-item rendering architecture -// (see docs/superpowers/specs/2026-05-13-per-item-rendering-design.md). -// -// Each skeleton matches the exact dimensions of its corresponding real -// card so the layout doesn't shift when content lands. A subtle -// shimmer sweep makes the page feel alive while individual tiles -// hydrate against drift / REST in the background. -// -// Self-contained shimmer (no `shimmer` package dep) so we can keep -// pubspec lean and tune the sweep colors against FabledSword tokens. - -import 'package:flutter/material.dart'; - -import '../../theme/theme_extension.dart'; - -/// Period of one full shimmer sweep across the skeleton. -const Duration _shimmerPeriod = Duration(milliseconds: 1200); - -/// Wraps a child with a slow-moving highlight band on top of the -/// skeleton's base color. Cheap — uses a single AnimationController -/// per surface and a LinearGradient shader. -class _Shimmer extends StatefulWidget { - const _Shimmer({required this.child}); - final Widget child; - - @override - State<_Shimmer> createState() => _ShimmerState(); -} - -class _ShimmerState extends State<_Shimmer> - with SingleTickerProviderStateMixin { - late final AnimationController _ctrl = AnimationController( - vsync: this, - duration: _shimmerPeriod, - )..repeat(); - - @override - void dispose() { - _ctrl.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final fs = Theme.of(context).extension()!; - return AnimatedBuilder( - animation: _ctrl, - builder: (context, child) { - return ShaderMask( - blendMode: BlendMode.srcATop, - shaderCallback: (rect) { - // Sweep starts off-screen left, ends off-screen right. - final dx = (_ctrl.value * 2 - 1) * rect.width; - return LinearGradient( - begin: Alignment.centerLeft, - end: Alignment.centerRight, - colors: [ - fs.slate, - fs.iron, - fs.slate, - ], - stops: const [0.0, 0.5, 1.0], - transform: _SlideGradient(dx), - ).createShader(rect); - }, - child: child, - ); - }, - child: widget.child, - ); - } -} - -/// Helper for translating a gradient horizontally inside its rect. -class _SlideGradient extends GradientTransform { - const _SlideGradient(this.dx); - final double dx; - - @override - Matrix4 transform(Rect bounds, {TextDirection? textDirection}) { - return Matrix4.translationValues(dx, 0, 0); - } -} - -/// Skeleton matched to AlbumCard's 140px outer width / 124px cover / -/// title + artist text rows. Used in horizontal carousels where the -/// real card lives. -class SkeletonAlbumTile extends StatelessWidget { - const SkeletonAlbumTile({super.key, this.width = 140}); - final double width; - - @override - Widget build(BuildContext context) { - final fs = Theme.of(context).extension()!; - final coverSize = width - 16; - return _Shimmer( - child: SizedBox( - width: width, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(6), - child: Container( - width: coverSize, - height: coverSize, - color: fs.slate, - ), - ), - const SizedBox(height: 8), - Container(width: coverSize * 0.8, height: 14, color: fs.slate), - const SizedBox(height: 4), - Container(width: coverSize * 0.6, height: 12, color: fs.slate), - ], - ), - ), - ), - ); - } -} - -/// Skeleton matched to ArtistCard's 140px / 124px round-thumb shape. -class SkeletonArtistTile extends StatelessWidget { - const SkeletonArtistTile({super.key, this.width = 140}); - final double width; - - @override - Widget build(BuildContext context) { - final fs = Theme.of(context).extension()!; - final coverSize = width - 16; - return _Shimmer( - child: SizedBox( - width: width, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: coverSize, - height: coverSize, - decoration: BoxDecoration( - color: fs.slate, - shape: BoxShape.circle, - ), - ), - const SizedBox(height: 8), - Container(width: coverSize * 0.7, height: 14, color: fs.slate), - ], - ), - ), - ), - ); - } -} - -/// Skeleton matched to TrackRow's vertical-list layout. 56dp cover + -/// title + artist line, similar to the real row. -class SkeletonTrackRow extends StatelessWidget { - const SkeletonTrackRow({super.key}); - - @override - Widget build(BuildContext context) { - final fs = Theme.of(context).extension()!; - return _Shimmer( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), - child: Row( - children: [ - Container( - width: 56, - height: 56, - decoration: BoxDecoration( - color: fs.slate, - borderRadius: BorderRadius.circular(6), - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Container(width: 180, height: 14, color: fs.slate), - const SizedBox(height: 6), - Container(width: 120, height: 12, color: fs.slate), - ], - ), - ), - ], - ), - ), - ); - } -} diff --git a/flutter_client/lib/shared/widgets/track_actions/add_to_playlist_sheet.dart b/flutter_client/lib/shared/widgets/track_actions/add_to_playlist_sheet.dart deleted file mode 100644 index d57c1a7c..00000000 --- a/flutter_client/lib/shared/widgets/track_actions/add_to_playlist_sheet.dart +++ /dev/null @@ -1,101 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_lucide/flutter_lucide.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../../../playlists/playlists_provider.dart'; -import '../../../theme/theme_extension.dart'; - -/// Modal bottom sheet listing the caller's user-created playlists. -/// Returns the picked playlistId via Navigator.pop, or null on cancel. -class AddToPlaylistSheet extends ConsumerWidget { - const AddToPlaylistSheet({super.key}); - - static Future show(BuildContext context) { - return showModalBottomSheet( - context: context, - isScrollControlled: true, - builder: (_) => const AddToPlaylistSheet(), - ); - } - - @override - Widget build(BuildContext context, WidgetRef ref) { - final fs = Theme.of(context).extension()!; - final lists = ref.watch(playlistsListProvider('user')); - return SafeArea( - child: Container( - color: fs.iron, - padding: const EdgeInsets.symmetric(vertical: 8), - constraints: BoxConstraints( - maxHeight: MediaQuery.of(context).size.height * 0.6, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(20, 8, 20, 8), - child: Text( - 'Add to playlist', - style: TextStyle( - color: fs.parchment, - fontFamily: 'Fraunces', - fontSize: 20, - ), - ), - ), - Flexible( - child: lists.when( - loading: () => const Center(child: CircularProgressIndicator()), - error: (e, _) => Padding( - padding: const EdgeInsets.all(16), - child: Text('$e', style: TextStyle(color: fs.error)), - ), - data: (data) { - final owned = data.owned - .where((p) => p.systemVariant == null) - .toList(); - if (owned.isEmpty) { - return Padding( - padding: const EdgeInsets.all(20), - child: Text( - "You haven't created any playlists yet.", - style: TextStyle(color: fs.ash), - ), - ); - } - return ListView.builder( - shrinkWrap: true, - itemCount: owned.length, - itemBuilder: (_, i) { - final p = owned[i]; - // Material(transparency) gives ListTile an ink target - // beneath the outer Container's color paint — required - // by the Flutter 3.44 ListTile/ColoredBox assertion. - return Material( - type: MaterialType.transparency, - child: ListTile( - key: Key('add_to_playlist_${p.id}'), - leading: Icon(LucideIcons.list_music, color: fs.parchment), - title: Text( - p.name, - style: TextStyle(color: fs.parchment), - ), - subtitle: Text( - '${p.trackCount} ${p.trackCount == 1 ? "track" : "tracks"}', - style: TextStyle(color: fs.ash, fontSize: 12), - ), - onTap: () => Navigator.pop(context, p.id), - ), - ); - }, - ); - }, - ), - ), - ], - ), - ), - ); - } -} diff --git a/flutter_client/lib/shared/widgets/track_actions/hide_track_sheet.dart b/flutter_client/lib/shared/widgets/track_actions/hide_track_sheet.dart deleted file mode 100644 index 2c3ee4d0..00000000 --- a/flutter_client/lib/shared/widgets/track_actions/hide_track_sheet.dart +++ /dev/null @@ -1,122 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../../../theme/theme_extension.dart'; - -/// Modal bottom sheet for picking a hide reason + optional notes. -/// Returns ({reason, notes}) on submit, null on cancel. -class HideTrackSheet extends StatefulWidget { - const HideTrackSheet({super.key}); - - static Future<({String reason, String notes})?> show(BuildContext context) { - return showModalBottomSheet<({String reason, String notes})>( - context: context, - isScrollControlled: true, - builder: (_) => const HideTrackSheet(), - ); - } - - @override - State createState() => _HideTrackSheetState(); -} - -class _HideTrackSheetState extends State { - String _reason = 'bad_rip'; - final _notesCtrl = TextEditingController(); - - // Wire values mirror the server vocabulary; display labels mirror the - // existing _QuarantineTile in library_screen.dart. - static const _options = [ - ('bad_rip', 'Bad rip'), - ('wrong_file', 'Wrong file'), - ('wrong_tags', 'Wrong tags'), - ('duplicate', 'Duplicate'), - ('other', 'Other'), - ]; - - @override - void dispose() { - _notesCtrl.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final fs = Theme.of(context).extension()!; - return SafeArea( - child: Padding( - padding: EdgeInsets.only( - bottom: MediaQuery.of(context).viewInsets.bottom, - ), - child: Container( - color: fs.iron, - padding: const EdgeInsets.all(20), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Hide this track', - style: TextStyle( - color: fs.parchment, - fontFamily: 'Fraunces', - fontSize: 20, - ), - ), - const SizedBox(height: 12), - Text( - 'Pick a reason. Optional notes are visible to admins.', - style: TextStyle(color: fs.ash), - ), - const SizedBox(height: 12), - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - for (final (value, label) in _options) - ChoiceChip( - key: Key('hide_reason_$value'), - label: Text(label), - selected: _reason == value, - onSelected: (_) => setState(() => _reason = value), - ), - ], - ), - const SizedBox(height: 12), - TextField( - key: const Key('hide_notes_input'), - controller: _notesCtrl, - decoration: const InputDecoration( - labelText: 'Notes (optional)', - ), - maxLines: 2, - ), - const SizedBox(height: 16), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('Cancel'), - ), - const SizedBox(width: 8), - ElevatedButton( - key: const Key('hide_confirm'), - style: ElevatedButton.styleFrom( - backgroundColor: fs.oxblood, - foregroundColor: fs.parchment, - ), - onPressed: () => Navigator.pop( - context, - (reason: _reason, notes: _notesCtrl.text.trim()), - ), - child: const Text('Hide'), - ), - ], - ), - ], - ), - ), - ), - ); - } -} diff --git a/flutter_client/lib/shared/widgets/track_actions/track_actions_button.dart b/flutter_client/lib/shared/widgets/track_actions/track_actions_button.dart deleted file mode 100644 index e5c58772..00000000 --- a/flutter_client/lib/shared/widgets/track_actions/track_actions_button.dart +++ /dev/null @@ -1,43 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_lucide/flutter_lucide.dart'; - -import '../../../models/track.dart'; -import '../../../theme/theme_extension.dart'; -import 'track_actions_sheet.dart'; - -/// Small 3-dot trigger that opens TrackActionsSheet. Drop into any -/// track row / card to expose the canonical 7-action menu. -class TrackActionsButton extends StatelessWidget { - const TrackActionsButton({ - super.key, - required this.track, - this.hideQueueActions = false, - this.onNavigate, - }); - - final TrackRef track; - - /// Suppresses Play next / Add to queue. Set true on the Now Playing - /// screen where the menu's track IS the currently-playing one. - final bool hideQueueActions; - - /// Forwarded to TrackActionsSheet.onNavigate. The host receives the - /// destination path AFTER the sheet has popped and is responsible - /// for navigating to it (typically: pop self first, then push). - final Future Function(String path)? onNavigate; - - @override - Widget build(BuildContext context) { - final fs = Theme.of(context).extension()!; - return IconButton( - icon: Icon(LucideIcons.ellipsis_vertical, color: fs.ash, size: 18), - tooltip: 'Track actions', - onPressed: () => TrackActionsSheet.show( - context, - track, - hideQueueActions: hideQueueActions, - onNavigate: onNavigate, - ), - ); - } -} diff --git a/flutter_client/lib/shared/widgets/track_actions/track_actions_sheet.dart b/flutter_client/lib/shared/widgets/track_actions/track_actions_sheet.dart deleted file mode 100644 index bf29f1a4..00000000 --- a/flutter_client/lib/shared/widgets/track_actions/track_actions_sheet.dart +++ /dev/null @@ -1,304 +0,0 @@ -import 'package:drift/drift.dart' as drift; -import 'package:flutter/material.dart'; -import 'package:flutter_lucide/flutter_lucide.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; - -import '../../../api/endpoints/likes.dart'; -import '../../../cache/audio_cache_manager.dart' show appDbProvider; -import '../../../cache/db.dart'; -import '../../../cache/mutation_queue.dart'; -import '../../../likes/likes_provider.dart'; -import '../../../models/track.dart'; -import '../../../player/player_provider.dart'; -import '../../../playlists/playlists_provider.dart'; -import '../../../quarantine/quarantine_provider.dart'; -import '../../../theme/theme_extension.dart'; -import 'add_to_playlist_sheet.dart'; -import 'hide_track_sheet.dart'; - -/// Modal bottom sheet that lists the 7 canonical track actions. -/// Pops first when an item is tapped (immediate visual feedback) and -/// then runs the action — for actions that open a sub-sheet, the -/// sub-sheet opens after the parent pops. -class TrackActionsSheet extends ConsumerWidget { - const TrackActionsSheet({ - super.key, - required this.track, - required this.hideQueueActions, - this.onNavigate, - }); - - final TrackRef track; - final bool hideQueueActions; - - /// Optional hook for "Go to album" / "Go to artist". Called with - /// the destination route path AFTER the sheet has popped. Lets - /// callers that live OUTSIDE the ShellRoute (e.g. the full player - /// at /now-playing) pop themselves first then push, so go_router - /// doesn't try to mount a second ShellRoute on top of the active - /// top-level route. When null, the sheet does its own - /// context.push, which is correct for surfaces already inside the - /// shell (mini player, track rows on detail screens, etc.). - final Future Function(String path)? onNavigate; - - static Future show( - BuildContext context, - TrackRef track, { - bool hideQueueActions = false, - Future Function(String path)? onNavigate, - }) { - return showModalBottomSheet( - context: context, - isScrollControlled: true, - builder: (_) => TrackActionsSheet( - track: track, - hideQueueActions: hideQueueActions, - onNavigate: onNavigate, - ), - ); - } - - @override - Widget build(BuildContext context, WidgetRef ref) { - final fs = Theme.of(context).extension()!; - final liked = ref.watch(likedIdsProvider).value?.has(LikeKind.track, track.id) ?? false; - final hidden = ref.watch(myQuarantineProvider).value?.any((r) => r.trackId == track.id) ?? false; - return SafeArea( - child: Container( - color: fs.iron, - padding: const EdgeInsets.symmetric(vertical: 8), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (!hideQueueActions) ...[ - _MenuItem( - key: const Key('track_actions_play_next'), - icon: LucideIcons.list_video, - label: 'Play next', - onTap: () async { - Navigator.pop(context); - await ref.read(playerActionsProvider).playNext(track); - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Added to queue (next)')), - ); - } - }, - ), - _MenuItem( - key: const Key('track_actions_enqueue'), - icon: LucideIcons.list_music, - label: 'Add to queue', - onTap: () async { - Navigator.pop(context); - await ref.read(playerActionsProvider).enqueue(track); - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Added to queue')), - ); - } - }, - ), - const _Divider(), - ], - _MenuItem( - key: const Key('track_actions_like'), - icon: LucideIcons.heart, - label: liked ? 'Unlike' : 'Like', - onTap: () async { - Navigator.pop(context); - await ref.read(likesControllerProvider).toggle(LikeKind.track, track.id); - }, - ), - _MenuItem( - key: const Key('track_actions_add_to_playlist'), - icon: LucideIcons.list_plus, - label: 'Add to playlist…', - onTap: () async { - Navigator.pop(context); - final playlistId = await AddToPlaylistSheet.show(context); - if (playlistId == null || !context.mounted) return; - try { - await ref.read(addToPlaylistActionProvider).call(playlistId, track.id); - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Added to playlist')), - ); - } - } catch (e) { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text("Couldn't add to playlist: $e")), - ); - } - } - }, - ), - _MenuItem( - key: const Key('track_actions_start_radio'), - icon: LucideIcons.radio, - label: 'Start radio', - onTap: () async { - Navigator.pop(context); - try { - await ref.read(playerActionsProvider).startRadio(track.id); - } catch (e) { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text("Couldn't start radio: $e")), - ); - } - } - }, - ), - const _Divider(), - _MenuItem( - key: const Key('track_actions_go_to_album'), - icon: LucideIcons.disc_3, - label: 'Go to album', - onTap: () { - Navigator.pop(context); - final path = '/albums/${track.albumId}'; - if (onNavigate != null) { - onNavigate!(path); - } else { - context.push(path); - } - }, - ), - _MenuItem( - key: const Key('track_actions_go_to_artist'), - icon: LucideIcons.user, - label: 'Go to artist', - onTap: () { - Navigator.pop(context); - final path = '/artists/${track.artistId}'; - if (onNavigate != null) { - onNavigate!(path); - } else { - context.push(path); - } - }, - ), - const _Divider(), - _MenuItem( - key: const Key('track_actions_hide'), - icon: hidden ? LucideIcons.eye : LucideIcons.eye_off, - label: hidden ? 'Unhide' : 'Hide', - onTap: () async { - Navigator.pop(context); - if (hidden) { - try { - await ref.read(myQuarantineProvider.notifier).unflag(track.id); - } catch (e) { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text("Couldn't unhide: $e")), - ); - } - } - return; - } - final result = await HideTrackSheet.show(context); - if (result == null || !context.mounted) return; - try { - await ref - .read(myQuarantineProvider.notifier) - .flag(track, result.reason, result.notes); - } catch (e) { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text("Couldn't hide: $e")), - ); - } - } - }, - ), - ], - ), - ), - ); - } -} - -class _MenuItem extends StatelessWidget { - const _MenuItem({ - super.key, - required this.icon, - required this.label, - required this.onTap, - }); - final IconData icon; - final String label; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - final fs = Theme.of(context).extension()!; - // Transparency-typed Material sits between the outer Container's color - // paint and the ListTile so ListTile can paint its own ink splashes. - // Flutter 3.44 promoted the "ListTile inside ColoredBox without Material" - // warning to a hard assertion. - return Material( - type: MaterialType.transparency, - child: ListTile( - leading: Icon(icon, color: fs.parchment), - title: Text(label, style: TextStyle(color: fs.parchment)), - onTap: onTap, - ), - ); - } -} - -class _Divider extends StatelessWidget { - const _Divider(); - @override - Widget build(BuildContext context) { - final fs = Theme.of(context).extension()!; - return Divider(height: 1, color: fs.slate); - } -} - -/// Convenience callable for "append a single track to a playlist." -/// Lives here (not in playlists_provider) because it's purely a -/// menu-flow concern — no other caller. -typedef AddToPlaylistAction = Future Function(String playlistId, String trackId); - -final addToPlaylistActionProvider = Provider((ref) { - return (playlistId, trackId) async { - final db = ref.read(appDbProvider); - // Optimistic drift write so the playlist detail screen shows the - // new track instantly. The position is best-effort — append after - // the current max for this playlist. The server's authoritative - // position lands on next playlistDetailProvider fetch (SWR), or - // when the queued mutation replays. - final maxPos = await (db.selectOnly(db.cachedPlaylistTracks) - ..addColumns([db.cachedPlaylistTracks.position.max()]) - ..where(db.cachedPlaylistTracks.playlistId.equals(playlistId))) - .getSingleOrNull(); - final nextPos = - ((maxPos?.read(db.cachedPlaylistTracks.position.max()) ?? -1) + 1); - await db.into(db.cachedPlaylistTracks).insertOnConflictUpdate( - CachedPlaylistTracksCompanion.insert( - playlistId: playlistId, - trackId: trackId, - position: drift.Value(nextPos), - ), - ); - - try { - final api = await ref.read(playlistsApiProvider.future); - await api.appendTracks(playlistId, [trackId]); - } catch (_) { - // REST failed — keep the optimistic drift row; queue the call. - await ref.read(mutationQueueProvider).enqueue( - MutationKinds.playlistAppend, - { - 'playlistId': playlistId, - 'trackIds': [trackId], - }, - ); - } - }; -}); diff --git a/flutter_client/lib/shared/widgets/version_gate.dart b/flutter_client/lib/shared/widgets/version_gate.dart deleted file mode 100644 index ab57acc6..00000000 --- a/flutter_client/lib/shared/widgets/version_gate.dart +++ /dev/null @@ -1,265 +0,0 @@ -import 'dart:async'; - -import 'package:dio/dio.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_lucide/flutter_lucide.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:package_info_plus/package_info_plus.dart'; -import 'package:pub_semver/pub_semver.dart'; - -import '../../api/client.dart'; -import '../../api/endpoints/health.dart'; -import '../../auth/auth_provider.dart'; -import '../../theme/theme_extension.dart'; - -/// Result of the most recent /healthz version compatibility check. -/// -/// `skipped` means the server didn't emit a `min_client_version` field -/// (older servers, partial deploys). Treat as compatible. -enum VersionResult { ok, tooOld, skipped } - -VersionResult _resultFromString(String? s) { - switch (s) { - case 'ok': - return VersionResult.ok; - case 'tooOld': - return VersionResult.tooOld; - default: - return VersionResult.skipped; - } -} - -String _stringFromResult(VersionResult r) { - switch (r) { - case VersionResult.ok: - return 'ok'; - case VersionResult.tooOld: - return 'tooOld'; - case VersionResult.skipped: - return 'skipped'; - } -} - -/// Drives the version-compatibility check against /healthz. Non-blocking: -/// the controller hydrates from a 1h-throttled cache on boot, exposes -/// the current `VersionResult`, and refreshes itself in the background. -/// UI (VersionGate + _VersionTooOldBanner) reads this state to decide -/// whether to surface the "too old" banner — never blocks rendering. -/// -/// Cache keys live in flutter_secure_storage so the cadence survives -/// app restarts. 1h throttle bounds /healthz traffic; explicit -/// `recheck()` (from the banner's "Check now" button or app resume) -/// bypasses the throttle. -class VersionCheckController extends AsyncNotifier { - static const _kResult = 'version_check_result'; - static const _kAtMs = 'version_check_at_ms'; - // 1 minute. The /healthz response is sub-1KB and the call is - // non-blocking, so a tight cadence buys faster recovery from - // server-side min_client_version bumps without measurable cost. The - // gate also acts as a safety net against duplicate calls when the - // periodic timer and a resume event fire close together. - static const _staleMs = 60 * 1000; - - @override - Future build() async { - final storage = ref.read(secureStorageProvider); - final resultStr = await storage.read(key: _kResult); - final atStr = await storage.read(key: _kAtMs); - final cached = _resultFromString(resultStr); - final at = int.tryParse(atStr ?? '') ?? 0; - final nowMs = DateTime.now().millisecondsSinceEpoch; - - // Fire background recheck on cold-start when cache is missing or stale. - // Doesn't block: build returns the cached result immediately; the - // recheck updates state asynchronously when it lands. - if (nowMs - at > _staleMs) { - Future.microtask(_runCheck); - } - return cached; - } - - /// Force a fresh check, bypassing the 1h staleness gate. Used by the - /// "Check now" banner button and (indirectly) by the AppLifecycleState - /// resume observer in VersionGate. - Future recheck() async => _runCheck(); - - /// Recheck only if the cache is older than the 1h throttle. No-op - /// when fresh. Used on app resume to avoid hammering /healthz when - /// the user is briefly switching between apps. - Future recheckIfStale() async { - final storage = ref.read(secureStorageProvider); - final atStr = await storage.read(key: _kAtMs); - final at = int.tryParse(atStr ?? '') ?? 0; - if (DateTime.now().millisecondsSinceEpoch - at > _staleMs) { - await _runCheck(); - } - } - - Future _runCheck() async { - try { - final url = await ref.read(serverUrlProvider.future); - if (url == null || url.isEmpty) return; - // Bounded timeouts for a health probe — the default - // ApiClient.buildDio dio is tuned for actual data fetches - // (8s connect + 30s receive). /healthz should resolve in - // tens of milliseconds; failing fast unblocks slow networks. - final dio = ApiClient.buildDio( - baseUrl: url, - tokenResolver: () async => null, - ); - dio.options.connectTimeout = const Duration(seconds: 3); - dio.options.receiveTimeout = const Duration(seconds: 2); - final body = await HealthApi(dio).check(); - final min = body['min_client_version']; - VersionResult result; - if (min == null || min.isEmpty) { - result = VersionResult.skipped; - } else { - final info = await PackageInfo.fromPlatform(); - final mine = Version.parse(info.version); - final required = Version.parse(min); - result = mine < required ? VersionResult.tooOld : VersionResult.ok; - } - // Persist + flip state. Storage write before state assignment so - // a hot reload immediately after won't see a fresh state with a - // stale cache. - final storage = ref.read(secureStorageProvider); - await storage.write(key: _kResult, value: _stringFromResult(result)); - await storage.write( - key: _kAtMs, - value: DateTime.now().millisecondsSinceEpoch.toString(), - ); - state = AsyncData(result); - } on DioException catch (_) { - // Network error / timeout. Keep the cached state; don't bump the - // timestamp so the next staleness check still triggers a retry. - } catch (_) { - // Other errors (PackageInfo, parsing, etc.). Same handling — - // soft-fail so the UI never sees an error state from a defensive - // background check. - } - } -} - -final versionCheckProvider = - AsyncNotifierProvider( - VersionCheckController.new, -); - -/// Wraps the shell. Non-blocking: always renders the child. Activates -/// the version check controller on mount and reruns it on app resume -/// (gated by the controller's 1h staleness throttle). -class VersionGate extends ConsumerStatefulWidget { - const VersionGate({required this.child, super.key}); - final Widget child; - - @override - ConsumerState createState() => _VersionGateState(); -} - -class _VersionGateState extends ConsumerState - with WidgetsBindingObserver { - // Polling interval during active foreground use. Paired with the - // controller's 1m staleness gate so concurrent fires (timer + resume) - // dedupe to a single network call. Tight cadence is affordable — - // /healthz is sub-1KB and the call is non-blocking. - static const _pollInterval = Duration(minutes: 1); - Timer? _pollTimer; - - @override - void initState() { - super.initState(); - // Read once so the provider's build() runs; the controller - // hydrates from cache + fires a background check when stale. - ref.read(versionCheckProvider); - WidgetsBinding.instance.addObserver(this); - _startPolling(); - } - - @override - void dispose() { - _stopPolling(); - WidgetsBinding.instance.removeObserver(this); - super.dispose(); - } - - void _startPolling() { - _pollTimer?.cancel(); - _pollTimer = Timer.periodic(_pollInterval, (_) { - // ignore: unawaited_futures - ref.read(versionCheckProvider.notifier).recheckIfStale(); - }); - } - - void _stopPolling() { - _pollTimer?.cancel(); - _pollTimer = null; - } - - @override - void didChangeAppLifecycleState(AppLifecycleState state) { - switch (state) { - case AppLifecycleState.resumed: - // Re-arm the timer + fire one immediate (staleness-gated) check - // so users who foreground after a long away period get a fresh - // result without waiting for the next tick. - // ignore: unawaited_futures - ref.read(versionCheckProvider.notifier).recheckIfStale(); - _startPolling(); - case AppLifecycleState.paused: - case AppLifecycleState.inactive: - case AppLifecycleState.hidden: - case AppLifecycleState.detached: - // Stop firing while backgrounded — no need to burn battery on - // health probes the user can't see the result of. - _stopPolling(); - } - } - - @override - Widget build(BuildContext context) => widget.child; -} - -/// Banner that surfaces when the server reports our client is too old. -/// Non-blocking: the rest of the app keeps working (offline-mode-style -/// — locally cached metadata + audio still play). Tap "Check now" to -/// force a re-check after installing a new build. -class VersionTooOldBanner extends ConsumerWidget { - const VersionTooOldBanner({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final asyncResult = ref.watch(versionCheckProvider); - final result = asyncResult.asData?.value; - if (result != VersionResult.tooOld) return const SizedBox.shrink(); - final fs = Theme.of(context).extension()!; - return SafeArea( - bottom: false, - child: Container( - color: fs.error.withValues(alpha: 0.15), - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), - child: Row(children: [ - Icon(LucideIcons.triangle_alert, color: fs.error, size: 18), - const SizedBox(width: 8), - Expanded( - child: Text( - "This app is older than the server requires. " - "You can keep using cached content; install an update when ready.", - style: TextStyle(color: fs.parchment, fontSize: 13), - ), - ), - TextButton( - onPressed: () => - ref.read(versionCheckProvider.notifier).recheck(), - style: TextButton.styleFrom( - foregroundColor: fs.parchment, - minimumSize: const Size(0, 36), - padding: const EdgeInsets.symmetric(horizontal: 12), - ), - child: const Text('Check now'), - ), - ]), - ), - ); - } -} diff --git a/flutter_client/lib/theme/theme_data.dart b/flutter_client/lib/theme/theme_data.dart deleted file mode 100644 index 506d5809..00000000 --- a/flutter_client/lib/theme/theme_data.dart +++ /dev/null @@ -1,58 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:google_fonts/google_fonts.dart'; - -import 'theme_extension.dart'; -import 'tokens.dart'; - -ThemeData buildDarkTheme() { - final fs = FabledSwordTheme.dark(); - final colorScheme = ColorScheme.dark( - surface: fs.iron, - onSurface: fs.parchment, - primary: fs.accent, - onPrimary: fs.parchment, - secondary: fs.moss, - error: fs.error, - ); - return ThemeData( - useMaterial3: true, - brightness: Brightness.dark, - colorScheme: colorScheme, - scaffoldBackgroundColor: fs.obsidian, - textTheme: GoogleFonts.interTextTheme().apply( - bodyColor: fs.parchment, - displayColor: fs.parchment, - ), - fontFamily: FabledSwordFlatTokens.fontBody, - extensions: >[fs], - ); -} - -ThemeData buildLightTheme() { - final fs = FabledSwordTheme.light(); - final colorScheme = ColorScheme.light( - surface: fs.iron, - onSurface: fs.parchment, // semantic — light's "parchment" is dark text - primary: fs.accent, - onPrimary: FabledSwordFlatTokens.onAction, - secondary: fs.moss, - error: fs.error, - ); - return ThemeData( - useMaterial3: true, - brightness: Brightness.light, - colorScheme: colorScheme, - scaffoldBackgroundColor: fs.obsidian, // semantic — light's obsidian is the bg - textTheme: GoogleFonts.interTextTheme().apply( - bodyColor: fs.parchment, - displayColor: fs.parchment, - ), - fontFamily: FabledSwordFlatTokens.fontBody, - extensions: >[fs], - ); -} - -/// Back-compat alias. Existing tests + code call buildThemeData(); it -/// returns the dark theme to preserve current behaviour. New code -/// should prefer buildDarkTheme()/buildLightTheme() explicitly. -ThemeData buildThemeData() => buildDarkTheme(); diff --git a/flutter_client/lib/theme/theme_extension.dart b/flutter_client/lib/theme/theme_extension.dart deleted file mode 100644 index 0f82e168..00000000 --- a/flutter_client/lib/theme/theme_extension.dart +++ /dev/null @@ -1,93 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'tokens.dart'; - -class FabledSwordTheme extends ThemeExtension { - const FabledSwordTheme({ - required this.accent, - required this.obsidian, - required this.iron, - required this.slate, - required this.pewter, - required this.parchment, - required this.vellum, - required this.ash, - required this.moss, - required this.bronze, - required this.oxblood, - required this.warning, - required this.error, - required this.info, - required this.display, - required this.body, - required this.mono, - }); - - final Color accent; - final Color obsidian, iron, slate, pewter; - final Color parchment, vellum, ash; - final Color moss, bronze, oxblood; - final Color warning, error, info; - final TextStyle display, body, mono; - - factory FabledSwordTheme.dark() => const FabledSwordTheme( - accent: FabledSwordFlatTokens.accent, - obsidian: FabledSwordDarkTokens.obsidian, - iron: FabledSwordDarkTokens.iron, - slate: FabledSwordDarkTokens.slate, - pewter: FabledSwordDarkTokens.pewter, - parchment: FabledSwordDarkTokens.parchment, - vellum: FabledSwordDarkTokens.vellum, - ash: FabledSwordDarkTokens.ash, - moss: FabledSwordFlatTokens.moss, - bronze: FabledSwordFlatTokens.bronze, - oxblood: FabledSwordFlatTokens.oxblood, - warning: FabledSwordFlatTokens.warning, - error: FabledSwordFlatTokens.error, - info: FabledSwordFlatTokens.info, - display: TextStyle(fontFamily: FabledSwordFlatTokens.fontDisplay), - body: TextStyle(fontFamily: FabledSwordFlatTokens.fontBody), - mono: TextStyle(fontFamily: FabledSwordFlatTokens.fontMono), - ); - - factory FabledSwordTheme.light() => const FabledSwordTheme( - accent: FabledSwordFlatTokens.accent, - obsidian: FabledSwordLightTokens.obsidian, - iron: FabledSwordLightTokens.iron, - slate: FabledSwordLightTokens.slate, - pewter: FabledSwordLightTokens.pewter, - parchment: FabledSwordLightTokens.parchment, - vellum: FabledSwordLightTokens.vellum, - ash: FabledSwordLightTokens.ash, - moss: FabledSwordFlatTokens.moss, - bronze: FabledSwordFlatTokens.bronze, - oxblood: FabledSwordFlatTokens.oxblood, - warning: FabledSwordFlatTokens.warning, - error: FabledSwordFlatTokens.error, - info: FabledSwordFlatTokens.info, - display: TextStyle(fontFamily: FabledSwordFlatTokens.fontDisplay), - body: TextStyle(fontFamily: FabledSwordFlatTokens.fontBody), - mono: TextStyle(fontFamily: FabledSwordFlatTokens.fontMono), - ); - - /// Back-compat alias for the original API. Returns the dark theme. - /// Existing call sites can keep using fromTokens() until they're - /// migrated to .dark() / .light() explicitly. - static FabledSwordTheme fromTokens() => FabledSwordTheme.dark(); - - @override - FabledSwordTheme copyWith({ - Color? accent, - }) => - FabledSwordTheme( - accent: accent ?? this.accent, - obsidian: obsidian, iron: iron, slate: slate, pewter: pewter, - parchment: parchment, vellum: vellum, ash: ash, - moss: moss, bronze: bronze, oxblood: oxblood, - warning: warning, error: error, info: info, - display: display, body: body, mono: mono, - ); - - @override - FabledSwordTheme lerp(ThemeExtension? other, double t) => this; -} diff --git a/flutter_client/lib/theme/theme_mode_provider.dart b/flutter_client/lib/theme/theme_mode_provider.dart deleted file mode 100644 index 58ae0a07..00000000 --- a/flutter_client/lib/theme/theme_mode_provider.dart +++ /dev/null @@ -1,40 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../auth/auth_provider.dart'; - -const _kThemeModeKey = 'theme_mode'; - -enum AppThemeMode { system, dark, light } - -extension AppThemeModeMaterial on AppThemeMode { - ThemeMode get materialMode => switch (this) { - AppThemeMode.system => ThemeMode.system, - AppThemeMode.dark => ThemeMode.dark, - AppThemeMode.light => ThemeMode.light, - }; -} - -class ThemeModeController extends AsyncNotifier { - @override - Future build() async { - final storage = ref.watch(secureStorageProvider); - final raw = await storage.read(key: _kThemeModeKey); - return switch (raw) { - 'dark' => AppThemeMode.dark, - 'light' => AppThemeMode.light, - _ => AppThemeMode.system, - }; - } - - Future set(AppThemeMode mode) async { - final storage = ref.read(secureStorageProvider); - await storage.write(key: _kThemeModeKey, value: mode.name); - state = AsyncData(mode); - } -} - -final themeModeProvider = - AsyncNotifierProvider( - ThemeModeController.new, -); diff --git a/flutter_client/lib/theme/tokens.dart b/flutter_client/lib/theme/tokens.dart deleted file mode 100644 index e603ad50..00000000 --- a/flutter_client/lib/theme/tokens.dart +++ /dev/null @@ -1,68 +0,0 @@ -// GENERATED — do not edit. Source: shared/fabledsword.tokens.json -// Run `dart run tool/gen_tokens.dart` to regenerate. -import 'package:flutter/material.dart'; - -class FabledSwordDarkTokens { - static const Color obsidian = Color(0xFF14171A); - static const Color iron = Color(0xFF1E2228); - static const Color slate = Color(0xFF2C313A); - static const Color pewter = Color(0xFF3F4651); - static const Color parchment = Color(0xFFE8E4D8); - static const Color vellum = Color(0xFFC2BFB4); - static const Color ash = Color(0xFF9C9A92); -} - -class FabledSwordLightTokens { - static const Color obsidian = Color(0xFFF8F5EE); - static const Color iron = Color(0xFFECE6D5); - static const Color slate = Color(0xFFDCD3BD); - static const Color pewter = Color(0xFFB8AE94); - static const Color parchment = Color(0xFF14171A); - static const Color vellum = Color(0xFF2C313A); - static const Color ash = Color(0xFF5C6068); -} - -class FabledSwordFlatTokens { - static const Color moss = Color(0xFF4A5D3F); - static const Color bronze = Color(0xFF8B7355); - static const Color oxblood = Color(0xFF6B2118); - static const Color warning = Color(0xFF8B6F1E); - static const Color error = Color(0xFFC04A1F); - static const Color info = Color(0xFF3D5A6E); - static const Color accent = Color(0xFF4A6B5C); - static const Color onAction = Color(0xFFE8E4D8); - static const double radiusSm = 4; - static const double radiusMd = 8; - static const double radiusLg = 12; - static const double radiusXl = 16; - static const String fontDisplay = "Fraunces"; - static const String fontBody = "Inter"; - static const String fontMono = "JetBrains Mono"; -} - -/// Back-compat alias — dark surface tokens + flat. Prefer the explicit -/// FabledSwordDarkTokens / FabledSwordLightTokens / FabledSwordFlatTokens. -class FabledSwordTokens { - static const Color obsidian = Color(0xFF14171A); - static const Color iron = Color(0xFF1E2228); - static const Color slate = Color(0xFF2C313A); - static const Color pewter = Color(0xFF3F4651); - static const Color parchment = Color(0xFFE8E4D8); - static const Color vellum = Color(0xFFC2BFB4); - static const Color ash = Color(0xFF9C9A92); - static const Color moss = Color(0xFF4A5D3F); - static const Color bronze = Color(0xFF8B7355); - static const Color oxblood = Color(0xFF6B2118); - static const Color warning = Color(0xFF8B6F1E); - static const Color error = Color(0xFFC04A1F); - static const Color info = Color(0xFF3D5A6E); - static const Color accent = Color(0xFF4A6B5C); - static const Color onAction = Color(0xFFE8E4D8); - static const double radiusSm = 4; - static const double radiusMd = 8; - static const double radiusLg = 12; - static const double radiusXl = 16; - static const String fontDisplay = "Fraunces"; - static const String fontBody = "Inter"; - static const String fontMono = "JetBrains Mono"; -} diff --git a/flutter_client/lib/update/client_update_provider.dart b/flutter_client/lib/update/client_update_provider.dart deleted file mode 100644 index 514cd2d8..00000000 --- a/flutter_client/lib/update/client_update_provider.dart +++ /dev/null @@ -1,143 +0,0 @@ -import 'dart:async'; - -import 'package:dio/dio.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:package_info_plus/package_info_plus.dart'; - -import '../library/library_providers.dart' show dioProvider; -import 'installer.dart'; -import 'update_info.dart'; - -/// Tracks the bundled-server APK version vs. the installed app version. -/// Polls /api/client/version on startup + every 24h. Returns null when -/// no update is available (or the channel is unreachable / disabled). -/// -/// Server returns 404 when no APK is bundled (dev environments, pre-CI -/// images) — we treat that as "no update channel" and stay silent. -class ClientUpdateController extends AsyncNotifier { - static const Duration _pollInterval = Duration(hours: 24); - - Timer? _pollTimer; - - @override - Future build() async { - ref.onDispose(() { - _pollTimer?.cancel(); - _pollTimer = null; - }); - _pollTimer ??= Timer.periodic(_pollInterval, (_) => ref.invalidateSelf()); - return _check(); - } - - Future _check() async { - final dio = await ref.read(dioProvider.future); - final Response> r; - try { - r = await dio.get>('/api/client/version'); - } on DioException catch (e) { - // 404 = no APK bundled; any other error = treat as silent. - if (e.response?.statusCode == 404) return null; - return null; - } - if (r.data == null) return null; - - final info = UpdateInfo.fromJson(r.data!); - final installed = (await PackageInfo.fromPlatform()).version; - if (!isVersionNewer(info.version, installed)) return null; - return info; - } -} - -/// True when `serverVersion` is strictly newer than `installedVersion`. -/// -/// Comparison strategy: split both strings on `.`, parse each component -/// as an int (non-numeric = 0), pad the shorter list with zeros, then -/// compare component-wise. This handles our date-style versions -/// (2026.05.10.1) which exceed the 3-part semver shape that -/// `pub_semver.Version.parse` accepts, and treats "2026.05.10" as -/// equal to "2026.05.10.0" rather than "different = newer". -/// -/// Falls back to pub_semver as a secondary attempt when the components -/// look semver-like (handles pre-release suffixes, build metadata, etc). -/// -/// Exposed for testing; the polling logic in ClientUpdateController -/// is the only production caller. -bool isVersionNewer(String serverVersion, String installedVersion) { - List parts(String v) => v - .replaceFirst(RegExp(r'^v'), '') - .split('.') - .map((p) => int.tryParse(p) ?? 0) - .toList(); - - final svr = parts(serverVersion); - final ins = parts(installedVersion); - - // Safety net for non-numeric versions (e.g. branch-name builds like - // "main" vs "dev"): if neither side parsed any non-zero component, - // fall back to string inequality so an operator on a dev build - // still gets the banner instead of silently matching everything. - final svrAllZero = svr.every((c) => c == 0); - final insAllZero = ins.every((c) => c == 0); - if (svrAllZero && insAllZero) { - return serverVersion.replaceFirst(RegExp(r'^v'), '') != - installedVersion.replaceFirst(RegExp(r'^v'), ''); - } - - final n = svr.length > ins.length ? svr.length : ins.length; - while (svr.length < n) { - svr.add(0); - } - while (ins.length < n) { - ins.add(0); - } - for (var i = 0; i < n; i++) { - if (svr[i] > ins[i]) return true; - if (svr[i] < ins[i]) return false; - } - return false; -} - -final clientUpdateProvider = - AsyncNotifierProvider( - ClientUpdateController.new); - -/// In-memory dismissed-versions set. Keyed by version string so a -/// later release's banner re-appears even if the operator dismissed -/// the previous one. Not persisted — restart re-shows the banner, -/// which is acceptable nudging for v1. -class _DismissedVersionsNotifier extends Notifier> { - @override - Set build() => {}; - - void add(String version) { - state = {...state, version}; - } -} - -final _dismissedVersionsProvider = - NotifierProvider<_DismissedVersionsNotifier, Set>( - _DismissedVersionsNotifier.new); - -/// True when the update banner should render: an UpdateInfo is -/// available AND the operator hasn't dismissed this specific version. -final shouldShowUpdateBannerProvider = Provider((ref) { - final info = ref.watch(clientUpdateProvider).value; - if (info == null) return null; - final dismissed = ref.watch(_dismissedVersionsProvider); - if (dismissed.contains(info.version)) return null; - return info; -}); - -/// Dismiss controller: marks the given version as dismissed so the -/// banner stops showing for this session. -final dismissUpdateProvider = Provider((ref) { - return (version) => - ref.read(_dismissedVersionsProvider.notifier).add(version); -}); - -/// Installer provider — depends on dio so the install download uses -/// the same authenticated client (though /api/client/apk is unauthed, -/// reusing dio keeps configuration consistent). -final updateInstallerProvider = FutureProvider((ref) async { - return UpdateInstaller(await ref.watch(dioProvider.future)); -}); diff --git a/flutter_client/lib/update/installer.dart b/flutter_client/lib/update/installer.dart deleted file mode 100644 index 94cca2ee..00000000 --- a/flutter_client/lib/update/installer.dart +++ /dev/null @@ -1,46 +0,0 @@ -import 'package:dio/dio.dart'; -import 'package:flutter/services.dart'; -import 'package:path_provider/path_provider.dart'; - -/// Bridges to MainActivity.kt's MethodChannel for the Android -/// PackageInstaller intent (#397). Web / iOS don't support self- -/// install; this class is Android-only at v1. -class UpdateInstaller { - UpdateInstaller(this._dio); - final Dio _dio; - - static const _channel = MethodChannel('com.fabledsword.minstrel/installer'); - static const _filename = 'minstrel-update.apk'; - - /// Streams the APK from `apkUrl` into the cache directory. `onProgress` - /// receives 0..1 fractions; emits 1.0 once when the download completes. - /// Returns the local path the install intent will read from. - Future download( - String apkUrl, { - void Function(double progress)? onProgress, - }) async { - final cacheDir = await getApplicationCacheDirectory(); - final path = '${cacheDir.path}/$_filename'; - await _dio.download( - apkUrl, - path, - onReceiveProgress: (received, total) { - if (total > 0 && onProgress != null) { - onProgress(received / total); - } - }, - ); - return path; - } - - /// Hands the downloaded APK to Android's PackageInstaller via a - /// FileProvider content:// URI. The system shows the install confirm - /// dialog; user must tap Install. App restarts on the new version. - /// - /// First-ever install attempt prompts the user to flip "Install - /// unknown apps" for Minstrel in Settings → Apps → Special access. - /// One-time grant; persists across updates. - Future install(String apkPath) async { - await _channel.invokeMethod('install', {'path': apkPath}); - } -} diff --git a/flutter_client/lib/update/update_banner.dart b/flutter_client/lib/update/update_banner.dart deleted file mode 100644 index 559777a9..00000000 --- a/flutter_client/lib/update/update_banner.dart +++ /dev/null @@ -1,146 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_lucide/flutter_lucide.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../theme/theme_extension.dart'; -import 'client_update_provider.dart'; -import 'update_info.dart'; - -/// Soft banner mounted at the top of the shell. Renders nothing when -/// no update is available or the user has dismissed this version's -/// banner. Tapping Install downloads the APK and fires the system -/// install intent (Android only). -class UpdateBanner extends ConsumerStatefulWidget { - const UpdateBanner({super.key}); - - @override - ConsumerState createState() => _UpdateBannerState(); -} - -enum _Stage { idle, downloading, error } - -class _UpdateBannerState extends ConsumerState { - _Stage _stage = _Stage.idle; - double _progress = 0; - String? _error; - - Future _onInstall(UpdateInfo info) async { - setState(() { - _stage = _Stage.downloading; - _progress = 0; - _error = null; - }); - try { - final installer = await ref.read(updateInstallerProvider.future); - final path = await installer.download( - info.apkUrl, - onProgress: (p) => setState(() => _progress = p), - ); - await installer.install(path); - // Stage stays 'downloading' — Android system install dialog has - // taken over. If user cancels, the banner is still here. - } catch (e) { - if (!mounted) return; - setState(() { - _stage = _Stage.error; - _error = '$e'; - }); - } - } - - void _onDismiss(UpdateInfo info) { - ref.read(dismissUpdateProvider)(info.version); - } - - @override - Widget build(BuildContext context) { - final info = ref.watch(shouldShowUpdateBannerProvider); - if (info == null) return const SizedBox.shrink(); - final fs = Theme.of(context).extension()!; - - return Material( - color: fs.iron, - child: SafeArea( - bottom: false, - child: Padding( - padding: const EdgeInsets.fromLTRB(12, 4, 4, 4), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Icon(LucideIcons.download, color: fs.parchment, size: 16), - const SizedBox(width: 8), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - _stage == _Stage.error - ? 'Update failed' - : 'Update Minstrel · ${info.version} available', - style: TextStyle(color: fs.parchment, fontSize: 12), - overflow: TextOverflow.ellipsis, - ), - if (_stage == _Stage.downloading) ...[ - const SizedBox(height: 2), - LinearProgressIndicator( - value: _progress > 0 ? _progress : null, - minHeight: 2, - backgroundColor: fs.obsidian, - valueColor: AlwaysStoppedAnimation(fs.accent), - ), - ], - if (_stage == _Stage.error && _error != null) ...[ - const SizedBox(height: 1), - Text( - _error!, - style: TextStyle(color: fs.ash, fontSize: 11), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - ], - ), - ), - if (_stage == _Stage.idle) - TextButton( - onPressed: () => _onInstall(info), - style: TextButton.styleFrom( - foregroundColor: fs.accent, - minimumSize: const Size(56, 28), - padding: const EdgeInsets.symmetric(horizontal: 8), - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - visualDensity: VisualDensity.compact, - ), - child: const Text('Install', style: TextStyle(fontSize: 13)), - ), - if (_stage == _Stage.error) - TextButton( - onPressed: () => _onInstall(info), - style: TextButton.styleFrom( - foregroundColor: fs.accent, - minimumSize: const Size(56, 28), - padding: const EdgeInsets.symmetric(horizontal: 8), - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - visualDensity: VisualDensity.compact, - ), - child: const Text('Retry', style: TextStyle(fontSize: 13)), - ), - SizedBox( - width: 32, - height: 32, - child: IconButton( - tooltip: 'Dismiss', - padding: EdgeInsets.zero, - iconSize: 16, - icon: Icon(LucideIcons.x, color: fs.ash), - onPressed: () => _onDismiss(info), - ), - ), - ], - ), - ), - ), - ); - } -} diff --git a/flutter_client/lib/update/update_info.dart b/flutter_client/lib/update/update_info.dart deleted file mode 100644 index fdd8335a..00000000 --- a/flutter_client/lib/update/update_info.dart +++ /dev/null @@ -1,21 +0,0 @@ -// Wire shape returned by GET /api/client/version. `version` is the -// server-bundled APK version (raw — may have a "v" prefix from the -// git tag); `apkUrl` is server-relative (e.g. "/api/client/apk"). - -class UpdateInfo { - const UpdateInfo({ - required this.version, - required this.apkUrl, - required this.sizeBytes, - }); - - final String version; - final String apkUrl; - final int sizeBytes; - - factory UpdateInfo.fromJson(Map j) => UpdateInfo( - version: (j['version'] as String?) ?? '', - apkUrl: (j['apk_url'] as String?) ?? '/api/client/apk', - sizeBytes: (j['size_bytes'] as num?)?.toInt() ?? 0, - ); -} diff --git a/flutter_client/pubspec.lock b/flutter_client/pubspec.lock deleted file mode 100644 index ee7f11dc..00000000 --- a/flutter_client/pubspec.lock +++ /dev/null @@ -1,1274 +0,0 @@ -# Generated by pub -# See https://dart.dev/tools/pub/glossary#lockfile -packages: - _fe_analyzer_shared: - dependency: transitive - description: - name: _fe_analyzer_shared - sha256: "8d7ff3948166b8ec5da0fbb5962000926b8e02f2ed9b3e51d1738905fbd4c98d" - url: "https://pub.dev" - source: hosted - version: "93.0.0" - analyzer: - dependency: transitive - description: - name: analyzer - sha256: de7148ed2fcec579b19f122c1800933dfa028f6d9fd38a152b04b1516cec120b - url: "https://pub.dev" - source: hosted - version: "10.0.1" - args: - dependency: transitive - description: - name: args - sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 - url: "https://pub.dev" - source: hosted - version: "2.7.0" - async: - dependency: transitive - description: - name: async - sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 - url: "https://pub.dev" - source: hosted - version: "2.13.1" - audio_service: - dependency: "direct main" - description: - name: audio_service - sha256: cb122c7c2639d2a992421ef96b67948ad88c5221da3365ccef1031393a76e044 - url: "https://pub.dev" - source: hosted - version: "0.18.18" - audio_service_platform_interface: - dependency: transitive - description: - name: audio_service_platform_interface - sha256: "6283782851f6c8b501b60904a32fc7199dc631172da0629d7301e66f672ab777" - url: "https://pub.dev" - source: hosted - version: "0.1.3" - audio_service_web: - dependency: transitive - description: - name: audio_service_web - sha256: b8ea9243201ee53383157fbccf13d5d2a866b5dda922ec19d866d1d5d70424df - url: "https://pub.dev" - source: hosted - version: "0.1.4" - audio_session: - dependency: "direct main" - description: - name: audio_session - sha256: "7217b229db57cc4dc577a8abb56b7429a5a212b978517a5be578704bfe5e568b" - url: "https://pub.dev" - source: hosted - version: "0.2.3" - boolean_selector: - dependency: transitive - description: - name: boolean_selector - sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" - url: "https://pub.dev" - source: hosted - version: "2.1.2" - build: - dependency: transitive - description: - name: build - sha256: a156715e7cd728130c592f30552575908aae5b100005fbc1f0fb16b3c03a3d10 - url: "https://pub.dev" - source: hosted - version: "4.0.6" - build_config: - dependency: transitive - description: - name: build_config - sha256: "4070d2a59f8eec34c97c86ceb44403834899075f66e8a9d59706f8e7834f6f71" - url: "https://pub.dev" - source: hosted - version: "1.3.0" - build_daemon: - dependency: transitive - description: - name: build_daemon - sha256: bf05f6e12cfea92d3c09308d7bcdab1906cd8a179b023269eed00c071004b957 - url: "https://pub.dev" - source: hosted - version: "4.1.1" - build_runner: - dependency: "direct dev" - description: - name: build_runner - sha256: "1523ce62448ebac2c15a8ba5fbad8acac169788658a7dd2a1c2d9c2a9318b9a6" - url: "https://pub.dev" - source: hosted - version: "2.15.0" - built_collection: - dependency: transitive - description: - name: built_collection - sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" - url: "https://pub.dev" - source: hosted - version: "5.1.1" - built_value: - dependency: transitive - description: - name: built_value - sha256: "34e4067d30ce212937df995f03b69992eea683539ceeac7f679a1f1eba055b56" - url: "https://pub.dev" - source: hosted - version: "8.12.6" - cached_network_image: - dependency: "direct main" - description: - name: cached_network_image - sha256: "7c1183e361e5c8b0a0f21a28401eecdbde252441106a9816400dd4c2b2424916" - url: "https://pub.dev" - source: hosted - version: "3.4.1" - cached_network_image_platform_interface: - dependency: transitive - description: - name: cached_network_image_platform_interface - sha256: "35814b016e37fbdc91f7ae18c8caf49ba5c88501813f73ce8a07027a395e2829" - url: "https://pub.dev" - source: hosted - version: "4.1.1" - cached_network_image_web: - dependency: transitive - description: - name: cached_network_image_web - sha256: "980842f4e8e2535b8dbd3d5ca0b1f0ba66bf61d14cc3a17a9b4788a3685ba062" - url: "https://pub.dev" - source: hosted - version: "1.3.1" - characters: - dependency: transitive - description: - name: characters - sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b - url: "https://pub.dev" - source: hosted - version: "1.4.1" - charcode: - dependency: transitive - description: - name: charcode - sha256: fb0f1107cac15a5ea6ef0a6ef71a807b9e4267c713bb93e00e92d737cc8dbd8a - url: "https://pub.dev" - source: hosted - version: "1.4.0" - checked_yaml: - dependency: transitive - description: - name: checked_yaml - sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" - url: "https://pub.dev" - source: hosted - version: "2.0.4" - cli_config: - dependency: transitive - description: - name: cli_config - sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec - url: "https://pub.dev" - source: hosted - version: "0.2.0" - cli_util: - dependency: transitive - description: - name: cli_util - sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c - url: "https://pub.dev" - source: hosted - version: "0.4.2" - clock: - dependency: transitive - description: - name: clock - sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b - url: "https://pub.dev" - source: hosted - version: "1.1.2" - code_assets: - dependency: transitive - description: - name: code_assets - sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687" - url: "https://pub.dev" - source: hosted - version: "1.0.0" - collection: - dependency: transitive - description: - name: collection - sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" - url: "https://pub.dev" - source: hosted - version: "1.19.1" - connectivity_plus: - dependency: "direct main" - description: - name: connectivity_plus - sha256: b5e72753cf63becce2c61fd04dfe0f1c430cc5278b53a1342dc5ad839eab29ec - url: "https://pub.dev" - source: hosted - version: "6.1.5" - connectivity_plus_platform_interface: - dependency: transitive - description: - name: connectivity_plus_platform_interface - sha256: "3c09627c536d22fd24691a905cdd8b14520de69da52c7a97499c8be5284a32ed" - url: "https://pub.dev" - source: hosted - version: "2.1.0" - convert: - dependency: transitive - description: - name: convert - sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 - url: "https://pub.dev" - source: hosted - version: "3.1.2" - coverage: - dependency: transitive - description: - name: coverage - sha256: "5da775aa218eaf2151c721b16c01c7676fbfdd99cebba2bf64e8b807a28ff94d" - url: "https://pub.dev" - source: hosted - version: "1.15.0" - crypto: - dependency: transitive - description: - name: crypto - sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf - url: "https://pub.dev" - source: hosted - version: "3.0.7" - dart_style: - dependency: transitive - description: - name: dart_style - sha256: "29f7ecc274a86d32920b1d9cfc7502fa87220da41ec60b55f329559d5732e2b2" - url: "https://pub.dev" - source: hosted - version: "3.1.7" - dbus: - dependency: transitive - description: - name: dbus - sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270 - url: "https://pub.dev" - source: hosted - version: "0.7.12" - dio: - dependency: "direct main" - description: - name: dio - sha256: aff32c08f92787a557dd5c0145ac91536481831a01b4648136373cddb0e64f8c - url: "https://pub.dev" - source: hosted - version: "5.9.2" - dio_web_adapter: - dependency: transitive - description: - name: dio_web_adapter - sha256: "2f9e64323a7c3c7ef69567d5c800424a11f8337b8b228bad02524c9fb3c1f340" - url: "https://pub.dev" - source: hosted - version: "2.1.2" - drift: - dependency: "direct main" - description: - name: drift - sha256: "970cd188fddb111b26ea6a9b07a62bf5c2432d74147b8122c67044ae3b97e99e" - url: "https://pub.dev" - source: hosted - version: "2.31.0" - drift_dev: - dependency: "direct dev" - description: - name: drift_dev - sha256: "917184b2fb867b70a548a83bf0d36268423b38d39968c06cce4905683da49587" - url: "https://pub.dev" - source: hosted - version: "2.31.0" - drift_flutter: - dependency: "direct main" - description: - name: drift_flutter - sha256: c07120854742a0cae2f7501a0da02493addde550db6641d284983c08762e60a7 - url: "https://pub.dev" - source: hosted - version: "0.2.8" - fake_async: - dependency: transitive - description: - name: fake_async - sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" - url: "https://pub.dev" - source: hosted - version: "1.3.3" - ffi: - dependency: transitive - description: - name: ffi - sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" - url: "https://pub.dev" - source: hosted - version: "2.2.0" - file: - dependency: transitive - description: - name: file - sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 - url: "https://pub.dev" - source: hosted - version: "7.0.1" - fixnum: - dependency: transitive - description: - name: fixnum - sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be - url: "https://pub.dev" - source: hosted - version: "1.1.1" - flutter: - dependency: "direct main" - description: flutter - source: sdk - version: "0.0.0" - flutter_cache_manager: - dependency: "direct main" - description: - name: flutter_cache_manager - sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386" - url: "https://pub.dev" - source: hosted - version: "3.4.1" - flutter_lints: - dependency: "direct dev" - description: - name: flutter_lints - sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" - url: "https://pub.dev" - source: hosted - version: "6.0.0" - flutter_lucide: - dependency: "direct main" - description: - name: flutter_lucide - sha256: d2866c9ba75b2300e73a888489f0d7ef1d225e1c352d7e7f5c7fdff7d04e294c - url: "https://pub.dev" - source: hosted - version: "1.11.0" - flutter_riverpod: - dependency: "direct main" - description: - name: flutter_riverpod - sha256: "4e166be88e1dbbaa34a280bdb744aeae73b7ef25fdf8db7a3bb776760a3648e2" - url: "https://pub.dev" - source: hosted - version: "3.3.1" - flutter_secure_storage: - dependency: "direct main" - description: - name: flutter_secure_storage - sha256: "6848263f9744072d0977347c383fb8b57d9780319a6bf5238b5a2866a029de62" - url: "https://pub.dev" - source: hosted - version: "10.2.0" - flutter_secure_storage_darwin: - dependency: transitive - description: - name: flutter_secure_storage_darwin - sha256: "3af15a3cb2bf5b8b776832bd01776f8018766aece55623176e28b406481fb320" - url: "https://pub.dev" - source: hosted - version: "0.3.0" - flutter_secure_storage_linux: - dependency: transitive - description: - name: flutter_secure_storage_linux - sha256: "2b5c76dce569ab752d55a1cee6a2242bcc11fdba927078fb88c503f150767cda" - url: "https://pub.dev" - source: hosted - version: "3.0.0" - flutter_secure_storage_platform_interface: - dependency: transitive - description: - name: flutter_secure_storage_platform_interface - sha256: "8ceea1223bee3c6ac1a22dabd8feefc550e4729b3675de4b5900f55afcb435d6" - url: "https://pub.dev" - source: hosted - version: "2.0.1" - flutter_secure_storage_web: - dependency: transitive - description: - name: flutter_secure_storage_web - sha256: "073a62b3aeb866ab4ce795f960413948e51e5a42a9b0c8333b6daf5bb3208a1c" - url: "https://pub.dev" - source: hosted - version: "2.1.1" - flutter_secure_storage_windows: - dependency: transitive - description: - name: flutter_secure_storage_windows - sha256: "3b7c8e068875dfd46719ff57c90d8c459c87f2302ed6b00ff006b3c9fcad1613" - url: "https://pub.dev" - source: hosted - version: "4.1.0" - flutter_svg: - dependency: "direct main" - description: - name: flutter_svg - sha256: "1ded017b39c8e15c8948ea855070a5ff8ff8b3d5e83f3446e02d6bb12add7ad9" - url: "https://pub.dev" - source: hosted - version: "2.2.4" - flutter_test: - dependency: "direct dev" - description: flutter - source: sdk - version: "0.0.0" - flutter_timezone: - dependency: "direct main" - description: - name: flutter_timezone - sha256: "13b2109ad75651faced4831bf262e32559e44aa549426eab8a597610d385d934" - url: "https://pub.dev" - source: hosted - version: "4.1.1" - flutter_web_plugins: - dependency: transitive - description: flutter - source: sdk - version: "0.0.0" - frontend_server_client: - dependency: transitive - description: - name: frontend_server_client - sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 - url: "https://pub.dev" - source: hosted - version: "4.0.0" - glob: - dependency: transitive - description: - name: glob - sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de - url: "https://pub.dev" - source: hosted - version: "2.1.3" - go_router: - dependency: "direct main" - description: - name: go_router - sha256: "92d8cee7c57dff0a6c409c05597b460002434eccf7424a712283225b3962d03f" - url: "https://pub.dev" - source: hosted - version: "17.2.3" - google_fonts: - dependency: "direct main" - description: - name: google_fonts - sha256: "4e9391085e524954a51e3625b7c9c7e9851dc3f376603208bb45c24b9a66255d" - url: "https://pub.dev" - source: hosted - version: "8.1.0" - graphs: - dependency: transitive - description: - name: graphs - sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" - url: "https://pub.dev" - source: hosted - version: "2.3.2" - hooks: - dependency: transitive - description: - name: hooks - sha256: "025f060e86d2d4c3c47b56e33caf7f93bf9283340f26d23424ebcfccf34f621e" - url: "https://pub.dev" - source: hosted - version: "1.0.3" - http: - dependency: transitive - description: - name: http - sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" - url: "https://pub.dev" - source: hosted - version: "1.6.0" - http_multi_server: - dependency: transitive - description: - name: http_multi_server - sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 - url: "https://pub.dev" - source: hosted - version: "3.2.2" - http_parser: - dependency: transitive - description: - name: http_parser - sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" - url: "https://pub.dev" - source: hosted - version: "4.1.2" - io: - dependency: transitive - description: - name: io - sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b - url: "https://pub.dev" - source: hosted - version: "1.0.5" - jni: - dependency: transitive - description: - name: jni - sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f - url: "https://pub.dev" - source: hosted - version: "1.0.0" - jni_flutter: - dependency: transitive - description: - name: jni_flutter - sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6" - url: "https://pub.dev" - source: hosted - version: "1.0.1" - js: - dependency: transitive - description: - name: js - sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 - url: "https://pub.dev" - source: hosted - version: "0.6.7" - json_annotation: - dependency: transitive - description: - name: json_annotation - sha256: cb09e7dac6210041fad964ed7fbee004f14258b4eca4040f72d1234062ace4c8 - url: "https://pub.dev" - source: hosted - version: "4.11.0" - just_audio: - dependency: "direct main" - description: - name: just_audio - sha256: "9694e4734f515f2a052493d1d7e0d6de219ee0427c7c29492e246ff32a219908" - url: "https://pub.dev" - source: hosted - version: "0.10.5" - just_audio_platform_interface: - dependency: transitive - description: - name: just_audio_platform_interface - sha256: "2532c8d6702528824445921c5ff10548b518b13f808c2e34c2fd54793b999a6a" - url: "https://pub.dev" - source: hosted - version: "4.6.0" - just_audio_web: - dependency: transitive - description: - name: just_audio_web - sha256: "6ba8a2a7e87d57d32f0f7b42856ade3d6a9fbe0f1a11fabae0a4f00bb73f0663" - url: "https://pub.dev" - source: hosted - version: "0.4.16" - leak_tracker: - dependency: transitive - description: - name: leak_tracker - sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" - url: "https://pub.dev" - source: hosted - version: "11.0.2" - leak_tracker_flutter_testing: - dependency: transitive - description: - name: leak_tracker_flutter_testing - sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" - url: "https://pub.dev" - source: hosted - version: "3.0.10" - leak_tracker_testing: - dependency: transitive - description: - name: leak_tracker_testing - sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" - url: "https://pub.dev" - source: hosted - version: "3.0.2" - lints: - dependency: transitive - description: - name: lints - sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" - url: "https://pub.dev" - source: hosted - version: "6.1.0" - logging: - dependency: transitive - description: - name: logging - sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 - url: "https://pub.dev" - source: hosted - version: "1.3.0" - matcher: - dependency: transitive - description: - name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 - url: "https://pub.dev" - source: hosted - version: "0.12.19" - material_color_utilities: - dependency: transitive - description: - name: material_color_utilities - sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" - url: "https://pub.dev" - source: hosted - version: "0.13.0" - meta: - dependency: transitive - description: - name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" - url: "https://pub.dev" - source: hosted - version: "1.17.0" - mime: - dependency: transitive - description: - name: mime - sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" - url: "https://pub.dev" - source: hosted - version: "2.0.0" - mocktail: - dependency: "direct dev" - description: - name: mocktail - sha256: "5e1bf53cc7baa8062a33b84424deb61513858ea05c601b8509e683815b5914aa" - url: "https://pub.dev" - source: hosted - version: "1.0.5" - native_toolchain_c: - dependency: transitive - description: - name: native_toolchain_c - sha256: "6ba77bb18063eebe9de401f5e6437e95e1438af0a87a3a39084fbd37c90df572" - url: "https://pub.dev" - source: hosted - version: "0.17.6" - nm: - dependency: transitive - description: - name: nm - sha256: "2c9aae4127bdc8993206464fcc063611e0e36e72018696cd9631023a31b24254" - url: "https://pub.dev" - source: hosted - version: "0.5.0" - node_preamble: - dependency: transitive - description: - name: node_preamble - sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" - url: "https://pub.dev" - source: hosted - version: "2.0.2" - objective_c: - dependency: transitive - description: - name: objective_c - sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52" - url: "https://pub.dev" - source: hosted - version: "9.3.0" - octo_image: - dependency: transitive - description: - name: octo_image - sha256: "34faa6639a78c7e3cbe79be6f9f96535867e879748ade7d17c9b1ae7536293bd" - url: "https://pub.dev" - source: hosted - version: "2.1.0" - package_config: - dependency: transitive - description: - name: package_config - sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc - url: "https://pub.dev" - source: hosted - version: "2.2.0" - package_info_plus: - dependency: "direct main" - description: - name: package_info_plus - sha256: "16eee997588c60225bda0488b6dcfac69280a6b7a3cf02c741895dd370a02968" - url: "https://pub.dev" - source: hosted - version: "8.3.1" - package_info_plus_platform_interface: - dependency: transitive - description: - name: package_info_plus_platform_interface - sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086" - url: "https://pub.dev" - source: hosted - version: "3.2.1" - palette_generator: - dependency: "direct main" - description: - name: palette_generator - sha256: "4420f7ccc3f0a4a906144e73f8b6267cd940b64f57a7262e95cb8cec3a8ae0ed" - url: "https://pub.dev" - source: hosted - version: "0.3.3+7" - path: - dependency: transitive - description: - name: path - sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" - url: "https://pub.dev" - source: hosted - version: "1.9.1" - path_parsing: - dependency: transitive - description: - name: path_parsing - sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca" - url: "https://pub.dev" - source: hosted - version: "1.1.0" - path_provider: - dependency: "direct main" - description: - name: path_provider - sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" - url: "https://pub.dev" - source: hosted - version: "2.1.5" - path_provider_android: - dependency: transitive - description: - name: path_provider_android - sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" - url: "https://pub.dev" - source: hosted - version: "2.3.1" - path_provider_foundation: - dependency: transitive - description: - name: path_provider_foundation - sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" - url: "https://pub.dev" - source: hosted - version: "2.6.0" - path_provider_linux: - dependency: transitive - description: - name: path_provider_linux - sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 - url: "https://pub.dev" - source: hosted - version: "2.2.1" - path_provider_platform_interface: - dependency: transitive - description: - name: path_provider_platform_interface - sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" - url: "https://pub.dev" - source: hosted - version: "2.1.2" - path_provider_windows: - dependency: transitive - description: - name: path_provider_windows - sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 - url: "https://pub.dev" - source: hosted - version: "2.3.0" - permission_handler: - dependency: "direct main" - description: - name: permission_handler - sha256: bc917da36261b00137bbc8896bf1482169cd76f866282368948f032c8c1caae1 - url: "https://pub.dev" - source: hosted - version: "12.0.1" - permission_handler_android: - dependency: transitive - description: - name: permission_handler_android - sha256: "1e3bc410ca1bf84662104b100eb126e066cb55791b7451307f9708d4007350e6" - url: "https://pub.dev" - source: hosted - version: "13.0.1" - permission_handler_apple: - dependency: transitive - description: - name: permission_handler_apple - sha256: f000131e755c54cf4d84a5d8bd6e4149e262cc31c5a8b1d698de1ac85fa41023 - url: "https://pub.dev" - source: hosted - version: "9.4.7" - permission_handler_html: - dependency: transitive - description: - name: permission_handler_html - sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24" - url: "https://pub.dev" - source: hosted - version: "0.1.3+5" - permission_handler_platform_interface: - dependency: transitive - description: - name: permission_handler_platform_interface - sha256: eb99b295153abce5d683cac8c02e22faab63e50679b937fa1bf67d58bb282878 - url: "https://pub.dev" - source: hosted - version: "4.3.0" - permission_handler_windows: - dependency: transitive - description: - name: permission_handler_windows - sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e" - url: "https://pub.dev" - source: hosted - version: "0.2.1" - petitparser: - dependency: transitive - description: - name: petitparser - sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" - url: "https://pub.dev" - source: hosted - version: "7.0.2" - platform: - dependency: transitive - description: - name: platform - sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" - url: "https://pub.dev" - source: hosted - version: "3.1.6" - plugin_platform_interface: - dependency: transitive - description: - name: plugin_platform_interface - sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" - url: "https://pub.dev" - source: hosted - version: "2.1.8" - pool: - dependency: transitive - description: - name: pool - sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" - url: "https://pub.dev" - source: hosted - version: "1.5.2" - pub_semver: - dependency: "direct main" - description: - name: pub_semver - sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" - url: "https://pub.dev" - source: hosted - version: "2.2.0" - pubspec_parse: - dependency: transitive - description: - name: pubspec_parse - sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" - url: "https://pub.dev" - source: hosted - version: "1.5.0" - recase: - dependency: transitive - description: - name: recase - sha256: e4eb4ec2dcdee52dcf99cb4ceabaffc631d7424ee55e56f280bc039737f89213 - url: "https://pub.dev" - source: hosted - version: "4.1.0" - record_use: - dependency: transitive - description: - name: record_use - sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" - url: "https://pub.dev" - source: hosted - version: "0.6.0" - riverpod: - dependency: transitive - description: - name: riverpod - sha256: "8c22216be8ad3ef2b44af3a329693558c98eca7b8bd4ef495c92db0bba279f83" - url: "https://pub.dev" - source: hosted - version: "3.2.1" - rxdart: - dependency: transitive - description: - name: rxdart - sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" - url: "https://pub.dev" - source: hosted - version: "0.28.0" - shelf: - dependency: transitive - description: - name: shelf - sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 - url: "https://pub.dev" - source: hosted - version: "1.4.2" - shelf_packages_handler: - dependency: transitive - description: - name: shelf_packages_handler - sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" - url: "https://pub.dev" - source: hosted - version: "3.0.2" - shelf_static: - dependency: transitive - description: - name: shelf_static - sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3 - url: "https://pub.dev" - source: hosted - version: "1.1.3" - shelf_web_socket: - dependency: transitive - description: - name: shelf_web_socket - sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" - url: "https://pub.dev" - source: hosted - version: "3.0.0" - sky_engine: - dependency: transitive - description: flutter - source: sdk - version: "0.0.0" - source_gen: - dependency: transitive - description: - name: source_gen - sha256: ec37cc0e6694374cbef59ed79685572c870a54ede6fa30a3e420feb3adffea02 - url: "https://pub.dev" - source: hosted - version: "4.2.3" - source_map_stack_trace: - dependency: transitive - description: - name: source_map_stack_trace - sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b - url: "https://pub.dev" - source: hosted - version: "2.1.2" - source_maps: - dependency: transitive - description: - name: source_maps - sha256: "190222579a448b03896e0ca6eca5998fa810fda630c1d65e2f78b3f638f54812" - url: "https://pub.dev" - source: hosted - version: "0.10.13" - source_span: - dependency: transitive - description: - name: source_span - sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" - url: "https://pub.dev" - source: hosted - version: "1.10.2" - sqflite: - dependency: transitive - description: - name: sqflite - sha256: "564cfed0746fe53140c23b70b308e045c3b31f17778f2f326ccb7d804ea0250a" - url: "https://pub.dev" - source: hosted - version: "2.4.2+1" - sqflite_android: - dependency: transitive - description: - name: sqflite_android - sha256: "881e28efdcc9950fd8e9bb42713dcf1103e62a2e7168f23c9338d82db13dec40" - url: "https://pub.dev" - source: hosted - version: "2.4.2+3" - sqflite_common: - dependency: transitive - description: - name: sqflite_common - sha256: "5e8377564d95166761a968ed96104e0569b6b6cc611faac92a36ab8a169112c3" - url: "https://pub.dev" - source: hosted - version: "2.5.6+1" - sqflite_darwin: - dependency: transitive - description: - name: sqflite_darwin - sha256: "279832e5cde3fe99e8571879498c9211f3ca6391b0d818df4e17d9fff5c6ccb3" - url: "https://pub.dev" - source: hosted - version: "2.4.2" - sqflite_platform_interface: - dependency: transitive - description: - name: sqflite_platform_interface - sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920" - url: "https://pub.dev" - source: hosted - version: "2.4.0" - sqlite3: - dependency: transitive - description: - name: sqlite3 - sha256: "3145bd74dcdb4fd6f5c6dda4d4e4490a8087d7f286a14dee5d37087290f0f8a2" - url: "https://pub.dev" - source: hosted - version: "2.9.4" - sqlite3_flutter_libs: - dependency: "direct main" - description: - name: sqlite3_flutter_libs - sha256: eeb9e3a45207649076b808f8a5a74d68770d0b7f26ccef6d5f43106eee5375ad - url: "https://pub.dev" - source: hosted - version: "0.5.42" - sqlparser: - dependency: transitive - description: - name: sqlparser - sha256: "337e9997f7141ffdd054259128553c348635fa318f7ca492f07a4ab76f850d19" - url: "https://pub.dev" - source: hosted - version: "0.43.1" - stack_trace: - dependency: transitive - description: - name: stack_trace - sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" - url: "https://pub.dev" - source: hosted - version: "1.12.1" - state_notifier: - dependency: transitive - description: - name: state_notifier - sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb - url: "https://pub.dev" - source: hosted - version: "1.0.0" - stream_channel: - dependency: transitive - description: - name: stream_channel - sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" - url: "https://pub.dev" - source: hosted - version: "2.1.4" - stream_transform: - dependency: transitive - description: - name: stream_transform - sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 - url: "https://pub.dev" - source: hosted - version: "2.1.1" - string_scanner: - dependency: transitive - description: - name: string_scanner - sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" - url: "https://pub.dev" - source: hosted - version: "1.4.1" - synchronized: - dependency: transitive - description: - name: synchronized - sha256: "63896c27e81b28f8cb4e69ead0d3e8f03f1d1e5fc531a3e579cabed6a2c7c9e5" - url: "https://pub.dev" - source: hosted - version: "3.4.0+1" - term_glyph: - dependency: transitive - description: - name: term_glyph - sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" - url: "https://pub.dev" - source: hosted - version: "1.2.2" - test: - dependency: transitive - description: - name: test - sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7" - url: "https://pub.dev" - source: hosted - version: "1.30.0" - test_api: - dependency: transitive - description: - name: test_api - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" - url: "https://pub.dev" - source: hosted - version: "0.7.10" - test_core: - dependency: transitive - description: - name: test_core - sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51" - url: "https://pub.dev" - source: hosted - version: "0.6.16" - typed_data: - dependency: transitive - description: - name: typed_data - sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 - url: "https://pub.dev" - source: hosted - version: "1.4.0" - uuid: - dependency: transitive - description: - name: uuid - sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489" - url: "https://pub.dev" - source: hosted - version: "4.5.3" - vector_graphics: - dependency: transitive - description: - name: vector_graphics - sha256: "6409a25046024f0f8c5d8a59fec314081e81f9d436b66ca4015a8b49772bf445" - url: "https://pub.dev" - source: hosted - version: "1.2.0" - vector_graphics_codec: - dependency: transitive - description: - name: vector_graphics_codec - sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146" - url: "https://pub.dev" - source: hosted - version: "1.1.13" - vector_graphics_compiler: - dependency: transitive - description: - name: vector_graphics_compiler - sha256: "5a88dd14c0954a5398af544651c7fb51b457a2a556949bfb25369b210ef73a74" - url: "https://pub.dev" - source: hosted - version: "1.2.0" - vector_math: - dependency: transitive - description: - name: vector_math - sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b - url: "https://pub.dev" - source: hosted - version: "2.2.0" - vm_service: - dependency: transitive - description: - name: vm_service - sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" - url: "https://pub.dev" - source: hosted - version: "15.2.0" - watcher: - dependency: transitive - description: - name: watcher - sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" - url: "https://pub.dev" - source: hosted - version: "1.2.1" - web: - dependency: transitive - description: - name: web - sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" - url: "https://pub.dev" - source: hosted - version: "1.1.1" - web_socket: - dependency: transitive - description: - name: web_socket - sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" - url: "https://pub.dev" - source: hosted - version: "1.0.1" - web_socket_channel: - dependency: transitive - description: - name: web_socket_channel - sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 - url: "https://pub.dev" - source: hosted - version: "3.0.3" - webkit_inspection_protocol: - dependency: transitive - description: - name: webkit_inspection_protocol - sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" - url: "https://pub.dev" - source: hosted - version: "1.2.1" - win32: - dependency: transitive - description: - name: win32 - sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e - url: "https://pub.dev" - source: hosted - version: "5.15.0" - xdg_directories: - dependency: transitive - description: - name: xdg_directories - sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" - url: "https://pub.dev" - source: hosted - version: "1.1.0" - xml: - dependency: transitive - description: - name: xml - sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" - url: "https://pub.dev" - source: hosted - version: "6.6.1" - yaml: - dependency: transitive - description: - name: yaml - sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce - url: "https://pub.dev" - source: hosted - version: "3.1.3" -sdks: - dart: ">=3.11.0 <4.0.0" - flutter: ">=3.38.4" diff --git a/flutter_client/pubspec.yaml b/flutter_client/pubspec.yaml deleted file mode 100644 index 770406d4..00000000 --- a/flutter_client/pubspec.yaml +++ /dev/null @@ -1,70 +0,0 @@ -name: minstrel -description: Minstrel mobile client -publish_to: 'none' -version: 2026.5.21+14 - -environment: - sdk: '>=3.5.0 <4.0.0' - flutter: '>=3.24.0' - -dependencies: - flutter: - sdk: flutter - flutter_riverpod: ^3.3.1 - dio: ^5.7.0 - just_audio: ^0.10.5 - audio_service: ^0.18.15 - # Audio focus + interruptions + becoming-noisy. just_audio auto-manages - # session activation once configured; the app must still handle - # interruption/becoming-noisy itself (just_audio does not). Same author - # as just_audio/audio_service so versions track together. - audio_session: ^0.2.3 - flutter_secure_storage: ^10.2.0 - go_router: ^17.2.3 - flutter_svg: ^2.0.16 - # Lucide icon set (design system mandates Lucide, not Material). - # Icons exposed as LucideIcons. IconData usable in Icon(). - flutter_lucide: ^1.11.0 - # Runtime POST_NOTIFICATIONS request (Android 13+ denies-by-default - # until asked; the media notification is suppressed without it). - permission_handler: ^12.0.1 - google_fonts: ^8.1.0 - # 10.x conflicts with flutter_secure_storage 10.x on win32. Hold at 8.3.1 - # until either lib bumps win32 to 6.x. - package_info_plus: ^8.3.1 - pub_semver: ^2.1.4 - path_provider: ^2.1.5 - drift: ^2.18.0 - drift_flutter: ^0.2.0 - sqlite3_flutter_libs: ^0.5.24 - connectivity_plus: ^6.0.5 - flutter_timezone: ^4.1.1 - palette_generator: ^0.3.3 - # Disk-persistent image cache. Image.network only caches in memory, so - # cover art repainted after a scroll-off or app restart re-fetches from - # the server. cached_network_image stores the bytes under - # path_provider's temp dir keyed by URL, surviving both. Used directly - # by ServerImage (auth-aware path) and as CachedNetworkImageProvider - # for the mini bar (which composes its own Image widget). - cached_network_image: ^3.4.1 - # flutter_cache_manager is the disk-cache layer cached_network_image - # sits on top of. SyncController uses DefaultCacheManager directly to - # pre-warm covers during metadata sync, so a cold-start home grid - # paints from disk on the very first scroll rather than firing a - # network round-trip per tile. - flutter_cache_manager: ^3.4.1 - -dev_dependencies: - flutter_test: - sdk: flutter - flutter_lints: ^6.0.0 - mocktail: ^1.0.5 - drift_dev: ^2.18.0 - build_runner: ^2.4.13 - -flutter: - uses-material-design: true - assets: - - assets/svg/ - - assets/error-copy.json - - shared/fabledsword.tokens.json diff --git a/flutter_client/test/admin/admin_landing_screen_test.dart b/flutter_client/test/admin/admin_landing_screen_test.dart deleted file mode 100644 index 95eca570..00000000 --- a/flutter_client/test/admin/admin_landing_screen_test.dart +++ /dev/null @@ -1,46 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:minstrel/admin/admin_landing_screen.dart'; -import 'package:minstrel/admin/admin_providers.dart'; -import 'package:minstrel/auth/auth_provider.dart'; -import 'package:minstrel/models/user.dart'; -import 'package:minstrel/theme/theme_data.dart'; - -class _StubAuth extends AuthController { - @override - Future build() async => - const User(id: 'u1', username: 'admin', isAdmin: true); -} - -void main() { - testWidgets('renders three section cards with counts from provider', - (t) async { - await t.pumpWidget( - ProviderScope( - overrides: [ - authControllerProvider.overrideWith(() => _StubAuth()), - adminCountsProvider.overrideWith( - (_) async => const AdminCounts( - requests: 3, - quarantine: 1, - users: 5, - ), - ), - ], - child: MaterialApp( - theme: buildThemeData(), - home: const AdminLandingScreen(), - ), - ), - ); - await t.pumpAndSettle(); - expect(find.byKey(const Key('admin_card_requests')), findsOneWidget); - expect(find.byKey(const Key('admin_card_quarantine')), findsOneWidget); - expect(find.byKey(const Key('admin_card_users')), findsOneWidget); - expect(find.text('3'), findsOneWidget); - expect(find.text('1'), findsOneWidget); - expect(find.text('5'), findsOneWidget); - }); -} diff --git a/flutter_client/test/admin/admin_quarantine_screen_test.dart b/flutter_client/test/admin/admin_quarantine_screen_test.dart deleted file mode 100644 index 2ec85b9e..00000000 --- a/flutter_client/test/admin/admin_quarantine_screen_test.dart +++ /dev/null @@ -1,94 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:minstrel/admin/admin_providers.dart'; -import 'package:minstrel/admin/admin_quarantine_screen.dart'; -import 'package:minstrel/auth/auth_provider.dart'; -import 'package:minstrel/models/admin_quarantine_item.dart'; -import 'package:minstrel/models/user.dart'; -import 'package:minstrel/theme/theme_data.dart'; - -class _StubAuth extends AuthController { - @override - Future build() async => - const User(id: 'u1', username: 'admin', isAdmin: true); -} - -class _StubQuarantine extends AdminQuarantineController { - _StubQuarantine(this._initial); - final List _initial; - @override - Future> build() async => _initial; -} - -const _item = AdminQuarantineItem( - trackId: 't1', - trackTitle: 'Bad Track', - artistName: 'Some Artist', - albumTitle: 'Some Album', - albumId: 'al1', - lidarrAlbumMbid: null, - reportCount: 2, - latestAt: '2026-05-08T00:00:00Z', - reasonCounts: {'wrong_tags': 1, 'bad_rip': 1}, - reports: [ - AdminQuarantineReport( - userId: 'u2', - username: 'alice', - reason: 'wrong_tags', - notes: null, - createdAt: '2026-05-08T00:00:00Z', - ), - AdminQuarantineReport( - userId: 'u3', - username: 'bob', - reason: 'bad_rip', - notes: 'cracks at 2:13', - createdAt: '2026-05-08T00:01:00Z', - ), - ], -); - -Widget _harness(List rows) => ProviderScope( - overrides: [ - authControllerProvider.overrideWith(() => _StubAuth()), - adminQuarantineProvider.overrideWith(() => _StubQuarantine(rows)), - ], - child: MaterialApp( - theme: buildThemeData(), - home: const AdminQuarantineScreen(), - ), - ); - -void main() { - testWidgets('empty state', (t) async { - await t.pumpWidget(_harness(const [])); - await t.pumpAndSettle(); - expect(find.text('No quarantined tracks.'), findsOneWidget); - }); - - testWidgets('row renders aggregate summary + 3-dot menu has three actions', - (t) async { - await t.pumpWidget(_harness(const [_item])); - await t.pumpAndSettle(); - expect(find.byKey(const Key('admin_quarantine_tile_t1')), findsOneWidget); - expect(find.text('Bad Track'), findsOneWidget); - expect(find.textContaining('2 reports'), findsOneWidget); - await t.tap(find.byKey(const Key('admin_quarantine_menu_t1'))); - await t.pumpAndSettle(); - expect(find.text('Resolve'), findsOneWidget); - expect(find.text('Delete file'), findsOneWidget); - expect(find.text('Delete via Lidarr'), findsOneWidget); - }); - - testWidgets('expanding tile reveals per-user reports', (t) async { - await t.pumpWidget(_harness(const [_item])); - await t.pumpAndSettle(); - await t.tap(find.byKey(const Key('admin_quarantine_tile_t1'))); - await t.pumpAndSettle(); - expect(find.textContaining('alice — wrong_tags'), findsOneWidget); - expect(find.textContaining('bob — bad_rip'), findsOneWidget); - expect(find.text('cracks at 2:13'), findsOneWidget); - }); -} diff --git a/flutter_client/test/admin/admin_requests_screen_test.dart b/flutter_client/test/admin/admin_requests_screen_test.dart deleted file mode 100644 index b57031e8..00000000 --- a/flutter_client/test/admin/admin_requests_screen_test.dart +++ /dev/null @@ -1,94 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:minstrel/admin/admin_providers.dart'; -import 'package:minstrel/admin/admin_requests_screen.dart'; -import 'package:minstrel/auth/auth_provider.dart'; -import 'package:minstrel/models/admin_request.dart'; -import 'package:minstrel/models/admin_user.dart'; -import 'package:minstrel/models/user.dart'; -import 'package:minstrel/theme/theme_data.dart'; - -class _StubAuth extends AuthController { - @override - Future build() async => - const User(id: 'u1', username: 'admin', isAdmin: true); -} - -class _StubRequests extends AdminRequestsController { - _StubRequests(this._initial); - final List _initial; - @override - Future> build() async => _initial; -} - -class _StubUsers extends AdminUsersController { - _StubUsers(this._initial); - final List _initial; - @override - Future> build() async => _initial; -} - -const _row = AdminRequest( - id: 'r1', - userId: 'user-uuid-1', - status: 'pending', - kind: 'album', - artistName: 'Some Artist', - albumTitle: 'Test Album', - trackTitle: null, - requestedAt: '2026-05-08T00:00:00Z', - decidedAt: null, - notes: null, - importedAlbumCount: 0, - importedTrackCount: 0, -); - -const _alice = AdminUser( - id: 'user-uuid-1', - username: 'alice', - displayName: null, - isAdmin: false, - autoApproveRequests: false, - createdAt: '2026-05-01T00:00:00Z', -); - -Widget _harness({ - required List requests, - required List users, -}) => - ProviderScope( - overrides: [ - authControllerProvider.overrideWith(() => _StubAuth()), - adminRequestsProvider.overrideWith(() => _StubRequests(requests)), - adminUsersProvider.overrideWith(() => _StubUsers(users)), - ], - child: MaterialApp( - theme: buildThemeData(), - home: const AdminRequestsScreen(), - ), - ); - -void main() { - testWidgets('renders empty state when no requests', (t) async { - await t.pumpWidget(_harness(requests: const [], users: const [])); - await t.pumpAndSettle(); - expect(find.text('No pending requests.'), findsOneWidget); - }); - - testWidgets('renders row with display name + joined requester username', - (t) async { - await t.pumpWidget(_harness(requests: const [_row], users: const [_alice])); - await t.pumpAndSettle(); - expect(find.byKey(const Key('admin_request_row_r1')), findsOneWidget); - expect(find.text('Test Album'), findsOneWidget); - expect(find.textContaining('requested by alice'), findsOneWidget); - }); - - testWidgets('falls back to uuid prefix when username unknown', (t) async { - await t.pumpWidget(_harness(requests: const [_row], users: const [])); - await t.pumpAndSettle(); - expect(find.textContaining('requested by user-uui'), findsOneWidget); - }); -} diff --git a/flutter_client/test/admin/admin_users_screen_test.dart b/flutter_client/test/admin/admin_users_screen_test.dart deleted file mode 100644 index ff296b05..00000000 --- a/flutter_client/test/admin/admin_users_screen_test.dart +++ /dev/null @@ -1,82 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:minstrel/admin/admin_providers.dart'; -import 'package:minstrel/admin/admin_users_screen.dart'; -import 'package:minstrel/auth/auth_provider.dart'; -import 'package:minstrel/models/admin_user.dart'; -import 'package:minstrel/models/invite.dart'; -import 'package:minstrel/models/user.dart'; -import 'package:minstrel/theme/theme_data.dart'; - -class _StubAuth extends AuthController { - @override - Future build() async => - const User(id: 'u1', username: 'admin', isAdmin: true); -} - -class _StubUsers extends AdminUsersController { - _StubUsers(this._initial); - final List _initial; - @override - Future> build() async => _initial; -} - -class _StubInvites extends AdminInvitesController { - _StubInvites(this._initial); - final List _initial; - @override - Future> build() async => _initial; -} - -const _userRow = AdminUser( - id: 'u2', - username: 'alice', - displayName: null, - isAdmin: false, - autoApproveRequests: true, - createdAt: '2026-05-01T00:00:00Z', -); - -const _inviteRow = Invite( - token: 'INV-ABC123', - invitedBy: 'u1', - note: 'for alice', - createdAt: '2026-05-08T00:00:00Z', - expiresAt: '2026-05-09T00:00:00Z', -); - -Widget _harness({ - required List users, - required List invites, -}) => - ProviderScope( - overrides: [ - authControllerProvider.overrideWith(() => _StubAuth()), - adminUsersProvider.overrideWith(() => _StubUsers(users)), - adminInvitesProvider.overrideWith(() => _StubInvites(invites)), - ], - child: MaterialApp( - theme: buildThemeData(), - home: const AdminUsersScreen(), - ), - ); - -void main() { - testWidgets('renders user rows with badges', (t) async { - await t.pumpWidget(_harness(users: const [_userRow], invites: const [])); - await t.pumpAndSettle(); - expect(find.byKey(const Key('admin_user_row_u2')), findsOneWidget); - expect(find.text('alice'), findsOneWidget); - expect(find.text('auto-approve'), findsOneWidget); - }); - - testWidgets('renders invite rows + Generate button', (t) async { - await t.pumpWidget(_harness(users: const [], invites: const [_inviteRow])); - await t.pumpAndSettle(); - expect(find.byKey(const Key('invite_row_INV-ABC123')), findsOneWidget); - expect(find.byKey(const Key('invite_generate_button')), findsOneWidget); - expect(find.textContaining('for alice'), findsOneWidget); - }); -} diff --git a/flutter_client/test/api/client_test.dart b/flutter_client/test/api/client_test.dart deleted file mode 100644 index 6e738df4..00000000 --- a/flutter_client/test/api/client_test.dart +++ /dev/null @@ -1,42 +0,0 @@ -import 'package:dio/dio.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:minstrel/api/client.dart'; - -void main() { - test('client attaches Bearer token from token resolver', () async { - final d = ApiClient.buildDio( - baseUrl: 'http://example/', - tokenResolver: () async => 'tok-123', - ); - Map? captured; - d.interceptors.add( - InterceptorsWrapper(onRequest: (opts, h) { - captured = Map.from(opts.headers); - h.reject(DioException(requestOptions: opts, type: DioExceptionType.cancel)); - }), - ); - try { - await d.get('/whatever'); - } catch (_) {} - expect(captured?['Authorization'], 'Bearer tok-123'); - }); - - test('client omits Authorization when resolver returns null', () async { - final d = ApiClient.buildDio( - baseUrl: 'http://example/', - tokenResolver: () async => null, - ); - Map? captured; - d.interceptors.add( - InterceptorsWrapper(onRequest: (opts, h) { - captured = Map.from(opts.headers); - h.reject(DioException(requestOptions: opts, type: DioExceptionType.cancel)); - }), - ); - try { - await d.get('/whatever'); - } catch (_) {} - expect(captured?.containsKey('Authorization'), isFalse); - }); -} diff --git a/flutter_client/test/api/endpoints/library_test.dart b/flutter_client/test/api/endpoints/library_test.dart deleted file mode 100644 index e1d36831..00000000 --- a/flutter_client/test/api/endpoints/library_test.dart +++ /dev/null @@ -1,170 +0,0 @@ -import 'dart:convert'; -import 'dart:typed_data'; - -import 'package:dio/dio.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:minstrel/api/endpoints/library.dart'; - -/// Stub adapter that returns a fixed JSON response for any request. The -/// body argument can be either a Map or a List (the artist-tracks endpoint -/// emits a top-level JSON array). Per-test wiring keeps the routing trivial. -class _StubAdapter implements HttpClientAdapter { - _StubAdapter(this._body); - final Object _body; - - @override - Future fetch( - RequestOptions options, - Stream? requestStream, - Future? cancelFuture, - ) async { - return ResponseBody.fromString( - jsonEncode(_body), - 200, - headers: const { - Headers.contentTypeHeader: ['application/json'], - }, - ); - } - - @override - void close({bool force = false}) {} -} - -Dio _dioWith(Object body) { - final d = Dio(BaseOptions(baseUrl: 'http://example.test')); - d.httpClientAdapter = _StubAdapter(body); - return d; -} - -void main() { - group('LibraryApi', () { - test('getAlbum parses album fields and tracks list', () async { - final api = LibraryApi(_dioWith({ - 'id': 'al-1', - 'title': 'Geogaddi', - 'sort_title': 'geogaddi', - 'artist_id': 'art-1', - 'artist_name': 'Boards of Canada', - 'year': 2002, - 'track_count': 1, - 'duration_sec': 312, - 'cover_url': '/api/albums/al-1/cover', - 'tracks': [ - { - 'id': 't-1', - 'title': 'Music Is Math', - 'album_id': 'al-1', - 'album_title': 'Geogaddi', - 'artist_id': 'art-1', - 'artist_name': 'Boards of Canada', - 'duration_sec': 312, - 'track_number': 4, - 'stream_url': '/api/tracks/t-1/stream', - }, - ], - })); - final r = await api.getAlbum('al-1'); - expect(r.album.id, 'al-1'); - expect(r.album.title, 'Geogaddi'); - expect(r.album.artistName, 'Boards of Canada'); - expect(r.album.year, 2002); - expect(r.tracks, hasLength(1)); - expect(r.tracks.single.title, 'Music Is Math'); - expect(r.tracks.single.durationSec, 312); - expect(r.tracks.single.trackNumber, 4); - }); - - test('getArtist parses ArtistDetail body as ArtistRef', () async { - final api = LibraryApi(_dioWith({ - 'id': 'art-1', - 'name': 'Boards of Canada', - 'sort_name': 'boards of canada', - 'album_count': 5, - 'cover_url': '/api/albums/al-1/cover', - 'albums': const [], - })); - final a = await api.getArtist('art-1'); - expect(a.id, 'art-1'); - expect(a.name, 'Boards of Canada'); - expect(a.albumCount, 5); - }); - - test('getArtistAlbums extracts the albums array', () async { - final api = LibraryApi(_dioWith({ - 'id': 'art-1', - 'name': 'Boards of Canada', - 'albums': [ - { - 'id': 'al-1', - 'title': 'Geogaddi', - 'artist_id': 'art-1', - 'artist_name': 'Boards of Canada', - }, - { - 'id': 'al-2', - 'title': 'The Campfire Headphase', - 'artist_id': 'art-1', - 'artist_name': 'Boards of Canada', - }, - ], - })); - final albums = await api.getArtistAlbums('art-1'); - expect(albums, hasLength(2)); - expect(albums.first.id, 'al-1'); - expect(albums.last.title, 'The Campfire Headphase'); - }); - - test('getArtistTracks parses a top-level JSON array', () async { - // Server emits a bare []TrackRef, NOT {"tracks": [...]}. - final api = LibraryApi(_dioWith(>[ - { - 'id': 't-1', - 'title': 'Music Is Math', - 'album_id': 'al-1', - 'album_title': 'Geogaddi', - 'artist_id': 'art-1', - 'artist_name': 'Boards of Canada', - 'duration_sec': 312, - }, - { - 'id': 't-2', - 'title': 'Dawn Chorus', - 'album_id': 'al-1', - 'album_title': 'Geogaddi', - 'artist_id': 'art-1', - 'artist_name': 'Boards of Canada', - 'duration_sec': 168, - }, - ])); - final tracks = await api.getArtistTracks('art-1'); - expect(tracks, hasLength(2)); - expect(tracks.first.id, 't-1'); - expect(tracks.last.durationSec, 168); - }); - - test('getHome parses HomePayload sections', () async { - final api = LibraryApi(_dioWith({ - 'recently_added_albums': [ - { - 'id': 'al-1', - 'title': 'Geogaddi', - 'artist_id': 'art-1', - 'artist_name': 'Boards of Canada', - }, - ], - 'rediscover_albums': const [], - 'rediscover_artists': const [], - 'most_played_tracks': const [], - 'last_played_artists': [ - {'id': 'art-1', 'name': 'Boards of Canada'}, - ], - })); - final home = await api.getHome(); - expect(home.recentlyAddedAlbums.single.title, 'Geogaddi'); - expect(home.lastPlayedArtists.single.name, 'Boards of Canada'); - expect(home.rediscoverAlbums, isEmpty); - }); - }); -} diff --git a/flutter_client/test/api/errors_test.dart b/flutter_client/test/api/errors_test.dart deleted file mode 100644 index 605802d8..00000000 --- a/flutter_client/test/api/errors_test.dart +++ /dev/null @@ -1,57 +0,0 @@ -import 'package:dio/dio.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:minstrel/api/errors.dart'; - -void main() { - RequestOptions opts() => RequestOptions(path: '/x'); - - group('ApiError.fromDio', () { - test('flat envelope: {error: "code"}', () { - final d = DioException( - requestOptions: opts(), - response: Response( - requestOptions: opts(), - statusCode: 503, - data: {'error': 'lidarr_unreachable'}, - ), - ); - final e = ApiError.fromDio(d); - expect(e.code, 'lidarr_unreachable'); - expect(e.status, 503); - }); - - test('nested envelope: {error: {code, message}}', () { - final d = DioException( - requestOptions: opts(), - response: Response( - requestOptions: opts(), - statusCode: 400, - data: {'error': {'code': 'bad_request', 'message': 'invalid JSON body'}}, - ), - ); - final e = ApiError.fromDio(d); - expect(e.code, 'bad_request'); - expect(e.message, 'invalid JSON body'); - }); - - test('connection refused: code is connection_refused', () { - final d = DioException( - requestOptions: opts(), - type: DioExceptionType.connectionError, - error: 'Connection refused', - ); - final e = ApiError.fromDio(d); - expect(e.code, 'connection_refused'); - }); - - test('401 with no envelope falls back to unauthenticated', () { - final d = DioException( - requestOptions: opts(), - response: Response(requestOptions: opts(), statusCode: 401), - ); - final e = ApiError.fromDio(d); - expect(e.code, 'unauthenticated'); - }); - }); -} diff --git a/flutter_client/test/auth/auth_provider_test.dart b/flutter_client/test/auth/auth_provider_test.dart deleted file mode 100644 index 1d899441..00000000 --- a/flutter_client/test/auth/auth_provider_test.dart +++ /dev/null @@ -1,52 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:mocktail/mocktail.dart'; - -import 'package:minstrel/auth/auth_provider.dart'; - -class _MockStorage extends Mock implements FlutterSecureStorage {} - -void main() { - late _MockStorage storage; - - setUpAll(() { - registerFallbackValue(''); - }); - - setUp(() { - storage = _MockStorage(); - // Default reads return null; default writes/deletes succeed. - when(() => storage.read(key: any(named: 'key'))).thenAnswer((_) async => null); - when(() => storage.write(key: any(named: 'key'), value: any(named: 'value'))).thenAnswer((_) async {}); - when(() => storage.delete(key: any(named: 'key'))).thenAnswer((_) async {}); - }); - - test('saves server URL via setServerUrl', () async { - final container = ProviderContainer(overrides: [ - secureStorageProvider.overrideWithValue(storage), - ]); - addTearDown(container.dispose); - - await container.read(authControllerProvider.notifier).setServerUrl('http://localhost:8080'); - - verify(() => storage.write(key: 'server_url', value: 'http://localhost:8080')).called(1); - }); - - test('setSession then clearSession transitions auth state to null', () async { - final container = ProviderContainer(overrides: [ - secureStorageProvider.overrideWithValue(storage), - ]); - addTearDown(container.dispose); - - final ctrl = container.read(authControllerProvider.notifier); - await ctrl.setSession(token: 't', userJson: '{"id":"u","username":"u","is_admin":false}'); - expect((await container.read(authControllerProvider.future))?.username, 'u'); - - // clearSession must trigger storage deletes for both keys. - await ctrl.clearSession(); - verify(() => storage.delete(key: 'session_token')).called(1); - verify(() => storage.delete(key: 'current_user')).called(1); - expect(await container.read(authControllerProvider.future), isNull); - }); -} diff --git a/flutter_client/test/auth/login_screen_test.dart b/flutter_client/test/auth/login_screen_test.dart deleted file mode 100644 index e3d8c7f3..00000000 --- a/flutter_client/test/auth/login_screen_test.dart +++ /dev/null @@ -1,19 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:minstrel/auth/login_screen.dart'; -import 'package:minstrel/theme/theme_data.dart'; - -void main() { - testWidgets('login screen renders both fields and the submit button', (tester) async { - await tester.pumpWidget(ProviderScope( - child: MaterialApp( - theme: buildThemeData(), - home: const LoginScreen(), - ), - )); - expect(find.byType(TextField), findsNWidgets(2)); - expect(find.text('Sign in'), findsNWidgets(2)); // header + button - }); -} diff --git a/flutter_client/test/cache/adapters_test.dart b/flutter_client/test/cache/adapters_test.dart deleted file mode 100644 index a17f7354..00000000 --- a/flutter_client/test/cache/adapters_test.dart +++ /dev/null @@ -1,70 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; - -import 'package:minstrel/cache/adapters.dart'; -import 'package:minstrel/models/album.dart'; -import 'package:minstrel/models/artist.dart'; -import 'package:minstrel/models/playlist.dart'; -import 'package:minstrel/models/track.dart'; - -void main() { - test('ArtistRef.toDrift preserves id + name + sortName', () { - const ref = ArtistRef(id: 'a1', name: 'Boards of Canada', sortName: 'Boards of Canada'); - final companion = ref.toDrift(); - expect(companion.id.value, 'a1'); - expect(companion.name.value, 'Boards of Canada'); - expect(companion.sortName.value, 'Boards of Canada'); - }); - - test('ArtistRef.toDrift falls back to name when sortName is empty', () { - const ref = ArtistRef(id: 'a1', name: 'The Album Leaf'); - final companion = ref.toDrift(); - expect(companion.sortName.value, 'The Album Leaf'); - }); - - test('AlbumRef.toDrift preserves id + title + artistId', () { - const ref = AlbumRef(id: 'al1', title: 'Geogaddi', artistId: 'ar1'); - final companion = ref.toDrift(); - expect(companion.id.value, 'al1'); - expect(companion.title.value, 'Geogaddi'); - expect(companion.artistId.value, 'ar1'); - }); - - test('TrackRef.toDrift converts seconds to ms + preserves track/disc', () { - const ref = TrackRef( - id: 't1', - title: 'Roygbiv', - albumId: 'al1', - artistId: 'ar1', - durationSec: 137, - trackNumber: 4, - discNumber: 1, - ); - final companion = ref.toDrift(); - expect(companion.id.value, 't1'); - expect(companion.durationMs.value, 137 * 1000); - expect(companion.trackNumber.value, 4); - expect(companion.discNumber.value, 1); - }); - - test('Playlist.toDrift preserves id + userId + name', () { - const p = Playlist( - id: 'p1', - userId: 'u1', - name: 'My Mix', - description: 'a great mix', - isPublic: true, - systemVariant: null, - trackCount: 12, - coverUrl: '', - ownerUsername: 'alice', - createdAt: '', - updatedAt: '', - ); - final companion = p.toDrift(); - expect(companion.id.value, 'p1'); - expect(companion.userId.value, 'u1'); - expect(companion.name.value, 'My Mix'); - expect(companion.isPublic.value, true); - expect(companion.trackCount.value, 12); - }); -} diff --git a/flutter_client/test/cache/audio_cache_manager_test.dart b/flutter_client/test/cache/audio_cache_manager_test.dart deleted file mode 100644 index 09a7ace5..00000000 --- a/flutter_client/test/cache/audio_cache_manager_test.dart +++ /dev/null @@ -1,159 +0,0 @@ -import 'dart:io'; - -import 'package:dio/dio.dart'; -import 'package:drift/drift.dart' show Value; -import 'package:drift/native.dart' show NativeDatabase; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:minstrel/cache/audio_cache_manager.dart'; -import 'package:minstrel/cache/db.dart'; - -AppDb _testDb() => AppDb(NativeDatabase.memory()); - -void main() { - test('isCached returns false when no row exists', () async { - final db = _testDb(); - addTearDown(db.close); - final mgr = AudioCacheManager( - db: db, - dioFactory: () async => Dio(), - cacheDirFactory: () async => Directory.systemTemp.createTempSync(), - ); - expect(await mgr.isCached('nonexistent'), false); - expect(await mgr.pathFor('nonexistent'), null); - }); - - test('bucketUsage sums drift sizeBytes across rows', () async { - final db = _testDb(); - addTearDown(db.close); - final tmp = Directory.systemTemp.createTempSync(); - final mgr = AudioCacheManager( - db: db, - dioFactory: () async => Dio(), - cacheDirFactory: () async => tmp, - ); - // Each row needs a distinct `path` because path is not the primary - // key, but mapping by trackId only works if rows are distinct. - await db.batch((b) { - b.insertAll(db.audioCacheIndex, [ - AudioCacheIndexCompanion.insert( - trackId: 'a', - path: 'p_a', - sizeBytes: 100, - source: CacheSource.manual), - AudioCacheIndexCompanion.insert( - trackId: 'b', - path: 'p_b', - sizeBytes: 250, - source: CacheSource.incidental), - ]); - }); - // usageBytes() is a directory walk (authoritative on-disk total, - // catches orphan partials); for a drift-sizeBytes sum, bucketUsage - // is the correct API. With empty liked set, both rows go to rolling. - final usage = await mgr.bucketUsage(const {}); - expect(usage.liked + usage.rolling, 350); - }); - - test('rolling cap evicts non-liked LRU; liked protected', () async { - final db = _testDb(); - addTearDown(db.close); - final tmp = Directory.systemTemp.createTempSync(); - final mgr = AudioCacheManager( - db: db, - dioFactory: () async => Dio(), - cacheDirFactory: () async => tmp, - ); - Future mk(String id) async { - final f = File('${tmp.path}/audio_cache/$id.mp3'); - await f.create(recursive: true); - await f.writeAsBytes(List.filled(100, 0)); - } - await mk('old'); - await mk('new'); - await mk('lik'); - await db.batch((b) { - b.insertAll(db.audioCacheIndex, [ - // 'old' has the older lastPlayedAt → evicted first. - AudioCacheIndexCompanion.insert( - trackId: 'old', - path: '${tmp.path}/audio_cache/old.mp3', - sizeBytes: 100, - source: CacheSource.incidental, - lastPlayedAt: Value(DateTime(2020))), - AudioCacheIndexCompanion.insert( - trackId: 'new', - path: '${tmp.path}/audio_cache/new.mp3', - sizeBytes: 100, - source: CacheSource.incidental, - lastPlayedAt: Value(DateTime(2024))), - AudioCacheIndexCompanion.insert( - trackId: 'lik', - path: '${tmp.path}/audio_cache/lik.mp3', - sizeBytes: 100, - source: CacheSource.incidental, - lastPlayedAt: Value(DateTime(2019))), - ]); - }); - final liked = {'lik'}; - final u = await mgr.bucketUsage(liked); - expect(u.liked, 100); - expect(u.rolling, 200); - // Rolling cap fits one 100-byte file; Liked cap huge. - await mgr.evictBuckets( - likedCap: 1 << 30, rollingCap: 100, liked: liked); - expect(await mgr.isCached('old'), false); // oldest rolling, evicted - expect(await mgr.isCached('new'), true); // newer rolling, kept - expect(await mgr.isCached('lik'), true); // liked, protected - }); - - test('clearAll removes everything including manual', () async { - final db = _testDb(); - addTearDown(db.close); - final tmp = Directory.systemTemp.createTempSync(); - final mgr = AudioCacheManager( - db: db, - dioFactory: () async => Dio(), - cacheDirFactory: () async => tmp, - ); - final f = File('${tmp.path}/audio_cache/man.mp3'); - await f.create(recursive: true); - await f.writeAsBytes(List.filled(50, 0)); - await db.into(db.audioCacheIndex).insertOnConflictUpdate( - AudioCacheIndexCompanion.insert( - trackId: 'man', - path: f.path, - sizeBytes: 50, - source: CacheSource.manual), - ); - expect(await mgr.usageBytes(), 50); - await mgr.clearAll(); - expect(await mgr.usageBytes(), 0); - expect(await mgr.isCached('man'), false); - }); - - test('unpin removes index row + deletes file', () async { - final db = _testDb(); - addTearDown(db.close); - final tmp = Directory.systemTemp.createTempSync(); - final mgr = AudioCacheManager( - db: db, - dioFactory: () async => Dio(), - cacheDirFactory: () async => tmp, - ); - final f = File('${tmp.path}/audio_cache/x.mp3'); - await f.create(recursive: true); - await f.writeAsBytes(List.filled(10, 0)); - await db.into(db.audioCacheIndex).insertOnConflictUpdate( - AudioCacheIndexCompanion.insert( - trackId: 'x', - path: f.path, - sizeBytes: 10, - source: CacheSource.autoPrefetch), - ); - expect(await mgr.isCached('x'), true); - await mgr.unpin('x'); - expect(await mgr.isCached('x'), false); - expect(f.existsSync(), false); - }); -} diff --git a/flutter_client/test/cache/cache_first_test.dart b/flutter_client/test/cache/cache_first_test.dart deleted file mode 100644 index ff286873..00000000 --- a/flutter_client/test/cache/cache_first_test.dart +++ /dev/null @@ -1,82 +0,0 @@ -import 'dart:async'; - -import 'package:flutter_test/flutter_test.dart'; -import 'package:minstrel/cache/cache_first.dart'; - -void main() { - test('non-empty drift emission passes through toResult', () async { - final controller = StreamController>(); - final results = cacheFirst( - driftStream: controller.stream, - fetchAndPopulate: () async => fail('should not fetch when rows present'), - toResult: (rows) => rows.fold(0, (a, b) => a + b), - isOnline: () async => true, - ); - - final firstFuture = results.first; - controller.add([1, 2, 3]); - expect(await firstFuture, 6); - await controller.close(); - }); - - test('empty drift emission + online triggers fetchAndPopulate', () async { - final controller = StreamController>(); - var fetchCalled = 0; - final results = cacheFirst( - driftStream: controller.stream, - fetchAndPopulate: () async { - fetchCalled++; - controller.add([42]); // simulate populate causing re-emission - }, - toResult: (rows) => rows.fold(0, (a, b) => a + b), - isOnline: () async => true, - ); - - // Post-coldFetchAttempted guard, cacheFirst yields the current - // (still-empty) rows after the fetch returns, so the stream - // never hangs when populate is a no-op for this filter. The - // simulated drift re-emit then yields the populated rows. - // Capture the Future *before* feeding the controller so the - // listener is subscribed when the first add() lands. - final emissionsFuture = results.take(2).toList(); - controller.add([]); - final emissions = await emissionsFuture; - expect(emissions, [0, 42]); - expect(fetchCalled, 1); - await controller.close(); - }); - - test('empty drift + offline yields empty result without fetch', () async { - final controller = StreamController>(); - var fetchCalled = 0; - final results = cacheFirst( - driftStream: controller.stream, - fetchAndPopulate: () async { - fetchCalled++; - }, - toResult: (rows) => rows.fold(0, (a, b) => a + b), - isOnline: () async => false, - ); - - final firstFuture = results.first; - controller.add([]); - expect(await firstFuture, 0); - expect(fetchCalled, 0); - await controller.close(); - }); - - test('REST failure falls through to empty result', () async { - final controller = StreamController>(); - final results = cacheFirst( - driftStream: controller.stream, - fetchAndPopulate: () async => throw Exception('REST 500'), - toResult: (rows) => rows.fold(0, (a, b) => a + b), - isOnline: () async => true, - ); - - final firstFuture = results.first; - controller.add([]); - expect(await firstFuture, 0); - await controller.close(); - }); -} diff --git a/flutter_client/test/cache/cache_settings_provider_test.dart b/flutter_client/test/cache/cache_settings_provider_test.dart deleted file mode 100644 index 284800e4..00000000 --- a/flutter_client/test/cache/cache_settings_provider_test.dart +++ /dev/null @@ -1,78 +0,0 @@ -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:mocktail/mocktail.dart'; - -import 'package:minstrel/auth/auth_provider.dart'; -import 'package:minstrel/cache/cache_settings_provider.dart'; - -class _MockStorage extends Mock implements FlutterSecureStorage {} - -void main() { - setUpAll(() { - registerFallbackValue(''); - }); - - test('returns defaults when storage is empty', () async { - final storage = _MockStorage(); - when(() => storage.read(key: any(named: 'key'))) - .thenAnswer((_) async => null); - when(() => storage.write( - key: any(named: 'key'), value: any(named: 'value'))) - .thenAnswer((_) async {}); - - final container = ProviderContainer(overrides: [ - secureStorageProvider.overrideWithValue(storage), - ]); - addTearDown(container.dispose); - - final s = await container.read(cacheSettingsProvider.future); - expect(s.likedCapBytes, CacheSettings.defaults.likedCapBytes); - expect(s.rollingCapBytes, CacheSettings.defaults.rollingCapBytes); - expect(s.likedCapBytes, 5 * 1024 * 1024 * 1024); - expect(s.prefetchWindow, 5); - expect(s.cacheLikedTracks, true); - }); - - test('setPrefetchWindow clamps to 1..10', () async { - final storage = _MockStorage(); - when(() => storage.read(key: any(named: 'key'))) - .thenAnswer((_) async => null); - when(() => storage.write( - key: any(named: 'key'), value: any(named: 'value'))) - .thenAnswer((_) async {}); - - final container = ProviderContainer(overrides: [ - secureStorageProvider.overrideWithValue(storage), - ]); - addTearDown(container.dispose); - - await container.read(cacheSettingsProvider.future); - final ctrl = container.read(cacheSettingsProvider.notifier); - await ctrl.setPrefetchWindow(99); - expect(container.read(cacheSettingsProvider).value!.prefetchWindow, 10); - await ctrl.setPrefetchWindow(0); - expect(container.read(cacheSettingsProvider).value!.prefetchWindow, 1); - }); - - test('setCacheLikedTracks toggles persisted', () async { - final storage = _MockStorage(); - when(() => storage.read(key: any(named: 'key'))) - .thenAnswer((_) async => null); - when(() => storage.write( - key: any(named: 'key'), value: any(named: 'value'))) - .thenAnswer((_) async {}); - - final container = ProviderContainer(overrides: [ - secureStorageProvider.overrideWithValue(storage), - ]); - addTearDown(container.dispose); - - await container.read(cacheSettingsProvider.future); - await container.read(cacheSettingsProvider.notifier).setCacheLikedTracks(false); - expect( - container.read(cacheSettingsProvider).value!.cacheLikedTracks, false); - verify(() => storage.write(key: 'cache_liked_tracks', value: 'false')) - .called(1); - }); -} diff --git a/flutter_client/test/cache/connectivity_provider_test.dart b/flutter_client/test/cache/connectivity_provider_test.dart deleted file mode 100644 index 170549e0..00000000 --- a/flutter_client/test/cache/connectivity_provider_test.dart +++ /dev/null @@ -1,14 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; - -import 'package:minstrel/cache/connectivity_provider.dart'; - -void main() { - // The provider relies on connectivity_plus, which uses a platform - // channel — accessing it from a unit test triggers - // "Binding has not yet been initialized" because there's no platform - // implementation available. Real coverage lives in on-device passes. - // Smoke-test the import only. - test('connectivityProvider import smoke', () { - expect(connectivityProvider, isNotNull); - }); -} diff --git a/flutter_client/test/cache/prefetcher_test.dart b/flutter_client/test/cache/prefetcher_test.dart deleted file mode 100644 index f2e8b54f..00000000 --- a/flutter_client/test/cache/prefetcher_test.dart +++ /dev/null @@ -1,12 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; - -void main() { - // The prefetcher's behavior is integration-y: it depends on the audio - // handler's mediaItem + queue streams + the operator's cache settings. - // Mocking those in a unit test would amount to mocking everything. - // Real coverage lives in on-device verification + the audio cache - // manager unit tests (which exercise the underlying pin/evict logic). - test('prefetcher import smoke', () { - expect(1 + 1, 2); - }); -} diff --git a/flutter_client/test/cache/sync_controller_test.dart b/flutter_client/test/cache/sync_controller_test.dart deleted file mode 100644 index 977fd41e..00000000 --- a/flutter_client/test/cache/sync_controller_test.dart +++ /dev/null @@ -1,142 +0,0 @@ -import 'dart:convert'; - -import 'package:dio/dio.dart'; -import 'package:drift/native.dart' show NativeDatabase; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:minstrel/cache/audio_cache_manager.dart' show appDbProvider; -import 'package:minstrel/cache/db.dart'; -import 'package:minstrel/cache/sync_controller.dart'; -import 'package:minstrel/library/library_providers.dart' show dioProvider; - -/// Builds a Dio whose adapter resolves every request to the supplied -/// status code + body. Avoids touching the network in tests. -/// -/// Body is normalized through a JSON round-trip so Map/List literals -/// surface as `Map` / `List` (matching how -/// real Dio responses parse), not the `Map` shape -/// Dart's literal inference would otherwise pick. sync_controller's -/// `as Map` casts are invariant on generics and -/// would throw TypeError on the literal shape. -Dio _stubDio({required int status, dynamic body}) { - final dio = Dio(); - final normalizedBody = - (body is Map || body is List) ? jsonDecode(jsonEncode(body)) : body; - dio.interceptors.add(InterceptorsWrapper(onRequest: (req, h) { - h.resolve(Response( - requestOptions: req, - statusCode: status, - data: normalizedBody, - )); - })); - return dio; -} - -ProviderContainer _container({required AppDb db, required Dio dio}) { - return ProviderContainer(overrides: [ - appDbProvider.overrideWithValue(db), - dioProvider.overrideWith((ref) async => dio), - ]); -} - -void main() { - test('204 advances lastSyncAt without changing cursor', () async { - final db = AppDb(NativeDatabase.memory()); - addTearDown(db.close); - - final container = _container(db: db, dio: _stubDio(status: 204)); - addTearDown(container.dispose); - - final result = await container.read(syncControllerProvider.notifier).sync(); - expect(result?.upserts, 0); - expect(result?.deletes, 0); - final meta = await db.select(db.syncMetadata).getSingleOrNull(); - expect(meta?.lastSyncAt, isNotNull); - }); - - test('200 with artist upsert writes drift row + advances cursor', () async { - final db = AppDb(NativeDatabase.memory()); - addTearDown(db.close); - - final container = _container( - db: db, - dio: _stubDio(status: 200, body: { - 'cursor': 7, - 'upserts': { - 'artist': [ - {'id': 'a1', 'name': 'A', 'sort_name': 'A'}, - ], - }, - 'deletes': {}, - }), - ); - addTearDown(container.dispose); - - final result = await container.read(syncControllerProvider.notifier).sync(); - expect(result?.upserts, 1); - expect(result?.cursor, 7); - final artist = await (db.select(db.cachedArtists) - ..where((t) => t.id.equals('a1'))) - .getSingleOrNull(); - expect(artist?.name, 'A'); - final meta = await db.select(db.syncMetadata).getSingleOrNull(); - expect(meta?.cursor, 7); - }); - - test('200 with track delete removes the row', () async { - final db = AppDb(NativeDatabase.memory()); - addTearDown(db.close); - - // Seed an existing cached track - await db.into(db.cachedTracks).insertOnConflictUpdate( - CachedTracksCompanion.insert( - id: 't1', albumId: 'al1', artistId: 'ar1', title: 'song'), - ); - - final container = _container( - db: db, - dio: _stubDio(status: 200, body: { - 'cursor': 3, - 'upserts': {}, - 'deletes': { - 'track': ['t1'], - }, - }), - ); - addTearDown(container.dispose); - - final result = await container.read(syncControllerProvider.notifier).sync(); - expect(result?.deletes, 1); - final track = await (db.select(db.cachedTracks) - ..where((t) => t.id.equals('t1'))) - .getSingleOrNull(); - expect(track, isNull); - }); - - test('like_track upsert + delete round-trip', () async { - final db = AppDb(NativeDatabase.memory()); - addTearDown(db.close); - - final container = _container( - db: db, - dio: _stubDio(status: 200, body: { - 'cursor': 1, - 'upserts': { - 'like_track': [ - {'user_id': 'u1', 'track_id': 't1'}, - ], - }, - 'deletes': {}, - }), - ); - addTearDown(container.dispose); - - await container.read(syncControllerProvider.notifier).sync(); - final liked = await db.select(db.cachedLikes).get(); - expect(liked.length, 1); - expect(liked.first.userId, 'u1'); - expect(liked.first.entityType, 'track'); - expect(liked.first.entityId, 't1'); - }); -} diff --git a/flutter_client/test/library/album_detail_screen_test.dart b/flutter_client/test/library/album_detail_screen_test.dart deleted file mode 100644 index ee1e0293..00000000 --- a/flutter_client/test/library/album_detail_screen_test.dart +++ /dev/null @@ -1,28 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:minstrel/library/album_detail_screen.dart'; -import 'package:minstrel/library/library_providers.dart'; -import 'package:minstrel/models/album.dart'; -import 'package:minstrel/models/track.dart'; -import 'package:minstrel/theme/theme_data.dart'; - -void main() { - testWidgets('renders album header + track list', (tester) async { - await tester.pumpWidget(ProviderScope( - overrides: [ - albumProvider('al-1').overrideWith((ref) => Stream.value(( - album: const AlbumRef(id: 'al-1', title: 'Drukqs', artistId: 'a-1', artistName: 'Aphex Twin'), - tracks: const [ - TrackRef(id: 't-1', title: 'Avril 14th', albumId: 'al-1', artistId: 'a-1', durationSec: 121, trackNumber: 4), - ], - ))), - ], - child: MaterialApp(theme: buildThemeData(), home: const AlbumDetailScreen(id: 'al-1')), - )); - await tester.pumpAndSettle(); - expect(find.text('Drukqs'), findsOneWidget); - expect(find.text('Avril 14th'), findsOneWidget); - }); -} diff --git a/flutter_client/test/library/artist_detail_screen_test.dart b/flutter_client/test/library/artist_detail_screen_test.dart deleted file mode 100644 index 07ae1037..00000000 --- a/flutter_client/test/library/artist_detail_screen_test.dart +++ /dev/null @@ -1,24 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:minstrel/library/artist_detail_screen.dart'; -import 'package:minstrel/library/library_providers.dart'; -import 'package:minstrel/models/album.dart'; -import 'package:minstrel/models/artist.dart'; -import 'package:minstrel/theme/theme_data.dart'; - -void main() { - testWidgets('renders artist name and Albums header', (tester) async { - await tester.pumpWidget(ProviderScope( - overrides: [ - artistProvider('a-1').overrideWith((ref) => Stream.value(const ArtistRef(id: 'a-1', name: 'Aphex Twin'))), - artistAlbumsProvider('a-1').overrideWith((ref) => Stream.value(const [])), - ], - child: MaterialApp(theme: buildThemeData(), home: const ArtistDetailScreen(id: 'a-1')), - )); - await tester.pumpAndSettle(); - expect(find.text('Aphex Twin'), findsOneWidget); - expect(find.text('Albums'), findsOneWidget); - }); -} diff --git a/flutter_client/test/library/home_screen_test.dart b/flutter_client/test/library/home_screen_test.dart deleted file mode 100644 index da1c1a58..00000000 --- a/flutter_client/test/library/home_screen_test.dart +++ /dev/null @@ -1,108 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:minstrel/api/endpoints/playlists.dart'; -import 'package:minstrel/library/home_screen.dart'; -import 'package:minstrel/library/library_providers.dart'; -import 'package:minstrel/models/home_index.dart'; -import 'package:minstrel/models/playlist.dart'; -import 'package:minstrel/models/system_playlists_status.dart'; -import 'package:minstrel/playlists/playlists_provider.dart'; -import 'package:minstrel/theme/theme_data.dart'; - -// All tests pump an empty HomeIndex unless they care about populated -// section IDs — per-tile hydration is intentionally not exercised here. -// What this suite verifies is the screen's section-level shape: -// placeholders / empty-state copy / section presence given the index. -const _emptyIndex = HomeIndex.empty; - -void main() { - testWidgets('renders 4 placeholder cards in Playlists row when no system playlists exist', - (tester) async { - await tester.pumpWidget(ProviderScope( - overrides: [ - homeIndexProvider.overrideWith((ref) => Stream.value(_emptyIndex)), - playlistsListProvider('all').overrideWith( - (ref) => Stream.value(PlaylistsList.empty()), - ), - systemPlaylistsStatusProvider.overrideWith( - (ref) => Stream.value(SystemPlaylistsStatus.empty()), - ), - ], - child: MaterialApp(theme: buildThemeData(), home: const HomeScreen()), - )); - await tester.pumpAndSettle(); - // For-You placeholder + Discover placeholder + 3 Songs-like placeholders. - expect(find.text('For You'), findsOneWidget); - expect(find.text('Discover'), findsOneWidget); - expect(find.text('Songs like…'), findsNWidgets(3)); - }); - - testWidgets('renders empty-state copy for each section when the index is empty', - (tester) async { - await tester.pumpWidget(ProviderScope( - overrides: [ - homeIndexProvider.overrideWith((ref) => Stream.value(_emptyIndex)), - playlistsListProvider('all').overrideWith( - (ref) => Stream.value(PlaylistsList.empty()), - ), - systemPlaylistsStatusProvider.overrideWith( - (ref) => Stream.value(SystemPlaylistsStatus.empty()), - ), - ], - child: MaterialApp(theme: buildThemeData(), home: const HomeScreen()), - )); - await tester.pumpAndSettle(); - expect( - find.text("Nothing added yet. Scan a folder via the server's config."), - findsOneWidget, - ); - expect( - find.text('No forgotten favourites yet. Like some albums or artists to fill this in.'), - findsOneWidget, - ); - expect(find.text('No plays to draw from. Listen to something.'), - findsOneWidget); - expect(find.text('No recent plays.'), findsOneWidget); - }); - - testWidgets('renders For-You card when system playlist exists', - (tester) async { - const forYou = Playlist( - id: 'fy', - userId: 'u1', - name: 'For You', - description: '', - isPublic: false, - systemVariant: 'for_you', - trackCount: 75, - coverUrl: '', - ownerUsername: 'alice', - createdAt: '2026-05-01T00:00:00Z', - updatedAt: '2026-05-01T00:00:00Z', - ); - await tester.pumpWidget(ProviderScope( - overrides: [ - homeIndexProvider.overrideWith((ref) => Stream.value(_emptyIndex)), - playlistsListProvider('all').overrideWith( - (ref) => Stream.value( - const PlaylistsList(owned: [forYou], public: [])), - ), - systemPlaylistsStatusProvider.overrideWith( - (ref) => Stream.value(SystemPlaylistsStatus.empty()), - ), - ], - child: MaterialApp(theme: buildThemeData(), home: const HomeScreen()), - )); - await tester.pumpAndSettle(); - // The real card carries the playlist name + the "for you" badge. - expect(find.text('For You'), findsOneWidget); - expect(find.text('for you'), findsOneWidget); - }); - - // Section-with-populated-IDs coverage lives in the drift-tagged - // integration suite (Slice F follow-up) because per-tile providers - // require a real (in-memory) drift DB. The flutter-ci runner skips - // drift tests pending libsqlite3. -} diff --git a/flutter_client/test/library/widgets/compact_track_card_test.dart b/flutter_client/test/library/widgets/compact_track_card_test.dart deleted file mode 100644 index d8e789ea..00000000 --- a/flutter_client/test/library/widgets/compact_track_card_test.dart +++ /dev/null @@ -1,38 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:minstrel/library/widgets/compact_track_card.dart'; -import 'package:minstrel/models/track.dart'; -import 'package:minstrel/theme/theme_data.dart'; - -const _track = TrackRef( - id: 't1', - title: 'Roygbiv', - albumId: 'a1', - albumTitle: 'Geogaddi', - artistId: 'ar1', - artistName: 'Boards of Canada', - durationSec: 137, - trackNumber: 4, - streamUrl: '', -); - -void main() { - testWidgets('renders title and artist', (tester) async { - await tester.pumpWidget(ProviderScope( - child: MaterialApp( - theme: buildThemeData(), - home: const Scaffold( - body: CompactTrackCard( - track: _track, - sectionTracks: [_track], - index: 0, - ), - ), - ), - )); - expect(find.text('Roygbiv'), findsOneWidget); - expect(find.text('Boards of Canada'), findsOneWidget); - }); -} diff --git a/flutter_client/test/library/widgets_smoke_test.dart b/flutter_client/test/library/widgets_smoke_test.dart deleted file mode 100644 index 56e39a8b..00000000 --- a/flutter_client/test/library/widgets_smoke_test.dart +++ /dev/null @@ -1,68 +0,0 @@ -import 'package:drift/native.dart' show NativeDatabase; -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:minstrel/cache/audio_cache_manager.dart' show appDbProvider; -import 'package:minstrel/cache/db.dart'; -import 'package:minstrel/library/widgets/album_card.dart'; -import 'package:minstrel/library/widgets/track_row.dart'; -import 'package:minstrel/models/album.dart'; -import 'package:minstrel/models/track.dart'; -import 'package:minstrel/theme/theme_data.dart'; - -void main() { - testWidgets('AlbumCard renders title and artist', (tester) async { - await tester.pumpWidget(MaterialApp( - theme: buildThemeData(), - home: Scaffold( - body: AlbumCard( - album: const AlbumRef( - id: 'a', - title: 'Geogaddi', - artistId: 'x', - artistName: 'Boards of Canada', - ), - onTap: () {}, - ), - ), - )); - expect(find.text('Geogaddi'), findsOneWidget); - expect(find.text('Boards of Canada'), findsOneWidget); - }); - - testWidgets('TrackRow shows mm:ss duration', (tester) async { - // TrackRow contains CachedIndicator (ConsumerWidget) which reaches - // audioCacheManagerProvider → appDbProvider. Override appDbProvider - // with an explicit NativeDatabase.memory() executor — drift_flutter's - // default `driftDatabase()` schedules a deferred-init Timer that - // outlives the test widget tree and trips - // _verifyInvariants("A Timer is still pending after dispose"). - await tester.pumpWidget(ProviderScope( - overrides: [ - appDbProvider.overrideWith((ref) { - final db = AppDb(NativeDatabase.memory()); - ref.onDispose(db.close); - return db; - }), - ], - child: MaterialApp( - theme: buildThemeData(), - home: Scaffold( - body: TrackRow( - track: const TrackRef( - id: 't', - title: 'Roygbiv', - albumId: 'a', - artistId: 'x', - durationSec: 137, - trackNumber: 4, - ), - onTap: () {}, - ), - ), - ), - )); - expect(find.text('02:17'), findsOneWidget); - }); -} diff --git a/flutter_client/test/models/models_test.dart b/flutter_client/test/models/models_test.dart deleted file mode 100644 index d1082091..00000000 --- a/flutter_client/test/models/models_test.dart +++ /dev/null @@ -1,168 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:minstrel/models/album.dart'; -import 'package:minstrel/models/artist.dart'; -import 'package:minstrel/models/home_data.dart'; -import 'package:minstrel/models/track.dart'; -import 'package:minstrel/models/user.dart'; - -void main() { - group('User.fromJson', () { - test('parses canonical UserView shape', () { - final u = User.fromJson({ - 'id': 'usr-1', - 'username': 'alice', - 'is_admin': true, - }); - expect(u.id, 'usr-1'); - expect(u.username, 'alice'); - expect(u.isAdmin, isTrue); - }); - - test('defaults is_admin to false when missing', () { - final u = User.fromJson({'id': 'usr-2', 'username': 'bob'}); - expect(u.isAdmin, isFalse); - }); - }); - - group('ArtistRef.fromJson', () { - test('parses full payload using server cover_url field', () { - final a = ArtistRef.fromJson({ - 'id': 'art-1', - 'name': 'Boards of Canada', - 'sort_name': 'Boards of Canada', - 'album_count': 7, - 'cover_url': '/api/albums/al-1/cover', - }); - expect(a.name, 'Boards of Canada'); - expect(a.sortName, 'Boards of Canada'); - expect(a.albumCount, 7); - expect(a.coverUrl, '/api/albums/al-1/cover'); - }); - - test('tolerates missing optional fields', () { - final a = ArtistRef.fromJson({'id': 'art-2', 'name': 'Aphex Twin'}); - expect(a.sortName, ''); - expect(a.albumCount, 0); - expect(a.coverUrl, ''); - }); - }); - - group('AlbumRef.fromJson', () { - test('parses full payload with year and duration_sec', () { - final a = AlbumRef.fromJson({ - 'id': 'al-1', - 'title': 'Geogaddi', - 'sort_title': 'Geogaddi', - 'artist_id': 'art-1', - 'artist_name': 'Boards of Canada', - 'year': 2002, - 'track_count': 23, - 'duration_sec': 4020, - 'cover_url': '/api/albums/al-1/cover', - }); - expect(a.title, 'Geogaddi'); - expect(a.year, 2002); - expect(a.trackCount, 23); - expect(a.durationSec, 4020); - expect(a.coverUrl, '/api/albums/al-1/cover'); - }); - - test('leaves year null when absent', () { - final a = AlbumRef.fromJson({ - 'id': 'al-2', - 'title': 'Untitled', - 'artist_id': 'art-1', - }); - expect(a.year, isNull); - expect(a.trackCount, 0); - expect(a.durationSec, 0); - }); - }); - - group('TrackRef.fromJson', () { - test('parses full payload including stream_url', () { - final t = TrackRef.fromJson({ - 'id': 'tr-1', - 'title': 'Music Is Math', - 'album_id': 'al-1', - 'album_title': 'Geogaddi', - 'artist_id': 'art-1', - 'artist_name': 'Boards of Canada', - 'track_number': 5, - 'disc_number': 1, - 'duration_sec': 379, - 'stream_url': '/api/tracks/tr-1/stream', - }); - expect(t.title, 'Music Is Math'); - expect(t.trackNumber, 5); - expect(t.discNumber, 1); - expect(t.durationSec, 379); - expect(t.streamUrl, '/api/tracks/tr-1/stream'); - }); - - test('leaves track_number and disc_number null when omitted', () { - final t = TrackRef.fromJson({ - 'id': 'tr-2', - 'title': 'Hidden', - 'album_id': 'al-1', - 'artist_id': 'art-1', - }); - expect(t.trackNumber, isNull); - expect(t.discNumber, isNull); - }); - }); - - group('HomeData.fromJson', () { - test('handles missing sections as empty lists', () { - final h = HomeData.fromJson({}); - expect(h.recentlyAddedAlbums, isEmpty); - expect(h.rediscoverAlbums, isEmpty); - expect(h.rediscoverArtists, isEmpty); - expect(h.mostPlayedTracks, isEmpty); - expect(h.lastPlayedArtists, isEmpty); - }); - - test('parses populated payload across all five sections', () { - final h = HomeData.fromJson({ - 'recently_added_albums': [ - { - 'id': 'al-1', - 'title': 'Geogaddi', - 'artist_id': 'art-1', - 'artist_name': 'Boards of Canada', - 'cover_url': '/api/albums/al-1/cover', - } - ], - 'rediscover_albums': [ - { - 'id': 'al-2', - 'title': 'Music Has the Right to Children', - 'artist_id': 'art-1', - 'artist_name': 'Boards of Canada', - } - ], - 'rediscover_artists': [ - {'id': 'art-2', 'name': 'Aphex Twin'} - ], - 'most_played_tracks': [ - { - 'id': 'tr-1', - 'title': 'Roygbiv', - 'album_id': 'al-2', - 'artist_id': 'art-1', - 'duration_sec': 150, - } - ], - 'last_played_artists': [ - {'id': 'art-3', 'name': 'Tycho'} - ], - }); - expect(h.recentlyAddedAlbums.single.title, 'Geogaddi'); - expect(h.recentlyAddedAlbums.single.coverUrl, '/api/albums/al-1/cover'); - expect(h.rediscoverAlbums.single.id, 'al-2'); - expect(h.rediscoverArtists.single.name, 'Aphex Twin'); - expect(h.mostPlayedTracks.single.durationSec, 150); - expect(h.lastPlayedArtists.single.name, 'Tycho'); - }); - }); -} diff --git a/flutter_client/test/player/album_cover_cache_test.dart b/flutter_client/test/player/album_cover_cache_test.dart deleted file mode 100644 index 79940f89..00000000 --- a/flutter_client/test/player/album_cover_cache_test.dart +++ /dev/null @@ -1,121 +0,0 @@ -import 'dart:io'; -import 'dart:typed_data'; - -import 'package:dio/dio.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:minstrel/player/album_cover_cache.dart'; - -class _RecordingAdapter implements HttpClientAdapter { - _RecordingAdapter(this.body); - final List body; - int callCount = 0; - - @override - Future fetch( - RequestOptions options, - Stream? requestStream, - Future? cancelFuture, - ) async { - callCount++; - return ResponseBody.fromBytes(body, 200, headers: { - Headers.contentTypeHeader: ['image/jpeg'], - }); - } - - @override - void close({bool force = false}) {} -} - -class _FailingAdapter implements HttpClientAdapter { - @override - Future fetch( - RequestOptions options, - Stream? requestStream, - Future? cancelFuture, - ) async { - throw DioException( - requestOptions: options, - type: DioExceptionType.connectionError, - message: 'simulated network failure', - ); - } - - @override - void close({bool force = false}) {} -} - -Future _tmpDirFactory() async => - Directory.systemTemp.createTempSync('cover_cache_test_'); - -void main() { - test('cache miss writes file and returns path', () async { - final adapter = _RecordingAdapter([1, 2, 3, 4]); - final dio = Dio(BaseOptions(baseUrl: 'http://test'))..httpClientAdapter = adapter; - final cache = AlbumCoverCache( - dioFactory: () async => dio, - cacheDirFactory: _tmpDirFactory, - ); - final path = await cache.getOrFetch('alb-1'); - expect(path, isNotNull); - expect(File(path!).readAsBytesSync(), [1, 2, 3, 4]); - expect(adapter.callCount, 1); - }); - - test('cache hit returns same path without re-fetching', () async { - final adapter = _RecordingAdapter([9, 9, 9]); - final dio = Dio(BaseOptions(baseUrl: 'http://test'))..httpClientAdapter = adapter; - final tmp = await _tmpDirFactory(); - final cache = AlbumCoverCache( - dioFactory: () async => dio, - cacheDirFactory: () async => tmp, - ); - final p1 = await cache.getOrFetch('alb-2'); - final p2 = await cache.getOrFetch('alb-2'); - expect(p1, p2); - expect(adapter.callCount, 1); - }); - - test('concurrent calls for same albumId dedupe', () async { - final adapter = _RecordingAdapter([1, 2, 3]); - final dio = Dio(BaseOptions(baseUrl: 'http://test'))..httpClientAdapter = adapter; - final cache = AlbumCoverCache( - dioFactory: () async => dio, - cacheDirFactory: _tmpDirFactory, - ); - final results = await Future.wait([ - cache.getOrFetch('alb-3'), - cache.getOrFetch('alb-3'), - cache.getOrFetch('alb-3'), - ]); - expect(results[0], isNotNull); - expect(results[1], results[0]); - expect(results[2], results[0]); - expect(adapter.callCount, 1); - }); - - test('failure returns null without writing file', () async { - final adapter = _FailingAdapter(); - final dio = Dio(BaseOptions(baseUrl: 'http://test'))..httpClientAdapter = adapter; - final tmp = await _tmpDirFactory(); - final cache = AlbumCoverCache( - dioFactory: () async => dio, - cacheDirFactory: () async => tmp, - ); - final path = await cache.getOrFetch('alb-4'); - expect(path, isNull); - expect(File('${tmp.path}/album_covers/alb-4.jpg').existsSync(), isFalse); - }); - - test('empty albumId returns null without dio call', () async { - final adapter = _RecordingAdapter([1, 2]); - final dio = Dio(BaseOptions(baseUrl: 'http://test'))..httpClientAdapter = adapter; - final cache = AlbumCoverCache( - dioFactory: () async => dio, - cacheDirFactory: _tmpDirFactory, - ); - final path = await cache.getOrFetch(''); - expect(path, isNull); - expect(adapter.callCount, 0); - }); -} diff --git a/flutter_client/test/player/player_provider_test.dart b/flutter_client/test/player/player_provider_test.dart deleted file mode 100644 index 5ba00e0d..00000000 --- a/flutter_client/test/player/player_provider_test.dart +++ /dev/null @@ -1,42 +0,0 @@ -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:minstrel/player/audio_handler.dart'; -import 'package:minstrel/player/player_provider.dart'; - -void main() { - // MinstrelAudioHandler() instantiates just_audio's AudioPlayer, which calls - // setMethodCallHandler on a platform channel during construction. Without an - // initialized binding the call asserts. We're not exercising real audio in - // the test — this just gives us a working binary messenger. - TestWidgetsFlutterBinding.ensureInitialized(); - - test('audioHandlerProvider must be overridden — bare read throws', () { - final container = ProviderContainer(); - addTearDown(container.dispose); - // Riverpod 3 wraps the underlying error in a ProviderException. The - // intent of the test is "bare read fails," so accept the wrapper as - // long as the inner error is the UnimplementedError we threw. - expect( - () => container.read(audioHandlerProvider), - throwsA(predicate((e) { - if (e is UnimplementedError) return true; - // ProviderException is private to riverpod; match by name + by - // walking its toString for the inner UnimplementedError text. - return e.runtimeType.toString().contains('ProviderException') && - e.toString().contains('UnimplementedError'); - })), - ); - }); - - test('overridden audioHandlerProvider exposes playbackState stream', () { - final handler = MinstrelAudioHandler(); - final container = ProviderContainer(overrides: [ - audioHandlerProvider.overrideWithValue(handler), - ]); - addTearDown(container.dispose); - expect(container.read(audioHandlerProvider), same(handler)); - final sub = container.listen(playbackStateProvider, (_, __) {}); - sub.close(); - }); -} diff --git a/flutter_client/test/playlists/widgets/playlist_card_test.dart b/flutter_client/test/playlists/widgets/playlist_card_test.dart deleted file mode 100644 index 186041d1..00000000 --- a/flutter_client/test/playlists/widgets/playlist_card_test.dart +++ /dev/null @@ -1,84 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_lucide/flutter_lucide.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:minstrel/models/playlist.dart'; -import 'package:minstrel/playlists/widgets/playlist_card.dart'; -import 'package:minstrel/theme/theme_data.dart'; - -const _userPlaylist = Playlist( - id: 'p1', - userId: 'u1', - name: 'Road trip', - description: '', - isPublic: false, - systemVariant: null, - trackCount: 12, - coverUrl: '', - ownerUsername: 'alice', - createdAt: '2026-05-01T00:00:00Z', - updatedAt: '2026-05-01T00:00:00Z', -); - -const _forYou = Playlist( - id: 'p2', - userId: 'u1', - name: 'For You', - description: '', - isPublic: false, - systemVariant: 'for_you', - trackCount: 75, - coverUrl: '', - ownerUsername: 'alice', - createdAt: '2026-05-01T00:00:00Z', - updatedAt: '2026-05-01T00:00:00Z', -); - -void main() { - testWidgets('renders name and no badge for user playlist', (tester) async { - await tester.pumpWidget(ProviderScope( - child: MaterialApp( - theme: buildThemeData(), - home: const Scaffold( - body: PlaylistCard(playlist: _userPlaylist), - ), - ))); - expect(find.text('Road trip'), findsOneWidget); - expect(find.text('for you'), findsNothing); - }); - - testWidgets('renders system badge for system playlist', (tester) async { - await tester.pumpWidget(ProviderScope( - child: MaterialApp( - theme: buildThemeData(), - home: const Scaffold( - body: PlaylistCard(playlist: _forYou), - ), - ))); - expect(find.text('For You'), findsOneWidget); - expect(find.text('for you'), findsOneWidget); - }); - - testWidgets('shows refresh kebab on system playlists', (tester) async { - await tester.pumpWidget(ProviderScope( - child: MaterialApp( - theme: buildThemeData(), - home: const Scaffold( - body: PlaylistCard(playlist: _forYou), - ), - ))); - expect(find.byIcon(LucideIcons.ellipsis_vertical), findsOneWidget); - }); - - testWidgets('no refresh kebab on user playlists', (tester) async { - await tester.pumpWidget(ProviderScope( - child: MaterialApp( - theme: buildThemeData(), - home: const Scaffold( - body: PlaylistCard(playlist: _userPlaylist), - ), - ))); - expect(find.byIcon(LucideIcons.ellipsis_vertical), findsNothing); - }); -} diff --git a/flutter_client/test/playlists/widgets/playlist_placeholder_card_test.dart b/flutter_client/test/playlists/widgets/playlist_placeholder_card_test.dart deleted file mode 100644 index e7c43eba..00000000 --- a/flutter_client/test/playlists/widgets/playlist_placeholder_card_test.dart +++ /dev/null @@ -1,49 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:minstrel/playlists/widgets/playlist_placeholder_card.dart'; -import 'package:minstrel/theme/theme_data.dart'; - -void main() { - testWidgets('building variant renders spinner + label', (tester) async { - await tester.pumpWidget(MaterialApp( - theme: buildThemeData(), - home: const Scaffold( - body: PlaylistPlaceholderCard(label: 'For You', variant: 'building'), - ), - )); - expect(find.text('For You'), findsOneWidget); - expect(find.text('Building…'), findsOneWidget); - expect(find.byType(CircularProgressIndicator), findsOneWidget); - }); - - testWidgets('failed variant shows warning + copy', (tester) async { - await tester.pumpWidget(MaterialApp( - theme: buildThemeData(), - home: const Scaffold( - body: PlaylistPlaceholderCard(label: 'Songs like…', variant: 'failed'), - ), - )); - expect(find.text("Couldn't generate"), findsOneWidget); - }); - - testWidgets('seed-needed variant copy', (tester) async { - await tester.pumpWidget(MaterialApp( - theme: buildThemeData(), - home: const Scaffold( - body: PlaylistPlaceholderCard(label: 'Songs like…', variant: 'seed-needed'), - ), - )); - expect(find.text('Like more music'), findsOneWidget); - }); - - testWidgets('pending variant copy', (tester) async { - await tester.pumpWidget(MaterialApp( - theme: buildThemeData(), - home: const Scaffold( - body: PlaylistPlaceholderCard(label: 'For You', variant: 'pending'), - ), - )); - expect(find.text('Coming soon'), findsOneWidget); - }); -} diff --git a/flutter_client/test/quarantine/quarantine_provider_test.dart b/flutter_client/test/quarantine/quarantine_provider_test.dart deleted file mode 100644 index 6acf119d..00000000 --- a/flutter_client/test/quarantine/quarantine_provider_test.dart +++ /dev/null @@ -1,153 +0,0 @@ -import 'dart:async'; - -import 'package:drift/native.dart' show NativeDatabase; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:minstrel/api/endpoints/quarantine.dart'; -import 'package:minstrel/cache/audio_cache_manager.dart' show appDbProvider; -import 'package:minstrel/cache/connectivity_provider.dart'; -import 'package:minstrel/cache/db.dart'; -import 'package:minstrel/models/track.dart'; -import 'package:minstrel/quarantine/quarantine_provider.dart'; - -class _StubQuarantineApi implements QuarantineApi { - _StubQuarantineApi({this.shouldThrow = false}); - bool shouldThrow; - int flagCalls = 0; - int unflagCalls = 0; - - @override - Future flag(String trackId, String reason, {String notes = ''}) async { - flagCalls++; - if (shouldThrow) throw Exception('simulated server failure'); - } - - @override - Future unflag(String trackId) async { - unflagCalls++; - if (shouldThrow) throw Exception('simulated server failure'); - } -} - -const _track = TrackRef( - id: 't1', - title: 'Roygbiv', - albumId: 'a1', - albumTitle: 'Geogaddi', - artistId: 'ar1', - artistName: 'Boards of Canada', - durationSec: 137, - trackNumber: 4, - streamUrl: '', -); - -ProviderContainer _container({required AppDb db, required QuarantineApi api}) { - return ProviderContainer(overrides: [ - appDbProvider.overrideWithValue(db), - quarantineApiProvider.overrideWith((ref) async => api), - // _refreshFromServer() in build() reads connectivityProvider; in tests - // without this override it's a StreamProvider that never emits, so - // tearDown trips Riverpod's "disposed during loading" — visible on - // the throwing-API variant where the catch path's await mutationQueue - // .enqueue advances _refreshFromServer's chain enough to surface it. - // Use a never-closing async* generator instead of Stream.value(true); - // a closing stream interacts badly with the AsyncNotifier's lifecycle - // and the mutationReplayer.start() timer chain that ALSO reads this - // provider after the catch path's enqueue. - connectivityProvider.overrideWith((ref) async* { - yield true; - await Completer().future; // hold open until tearDown - }), - ]); -} - -Future _seed(AppDb db) async { - await db.into(db.cachedQuarantineMine).insert( - CachedQuarantineMineCompanion.insert( - trackId: 't1', - reason: 'bad_rip', - createdAt: '2026-05-01T00:00:00Z', - trackTitle: 'Roygbiv', - albumId: 'a1', - albumTitle: 'Geogaddi', - artistId: 'ar1', - artistName: 'Boards of Canada', - ), - ); -} - -void main() { - test('flag inserts a drift row and calls the server', - () async { - final db = AppDb(NativeDatabase.memory()); - addTearDown(db.close); - final api = _StubQuarantineApi(); - final container = _container(db: db, api: api); - addTearDown(container.dispose); - - await container.read(myQuarantineProvider.future); - await container - .read(myQuarantineProvider.notifier) - .flag(_track, 'bad_rip', ''); - - expect(api.flagCalls, 1); - final rows = await db.select(db.cachedQuarantineMine).get(); - expect(rows, hasLength(1)); - expect(rows.first.trackId, 't1'); - expect(rows.first.reason, 'bad_rip'); - }); - - test('flag keeps drift optimistic + queues mutation on server failure', () async { - final db = AppDb(NativeDatabase.memory()); - addTearDown(db.close); - final api = _StubQuarantineApi(shouldThrow: true); - final container = _container(db: db, api: api); - addTearDown(container.dispose); - - await container.read(myQuarantineProvider.future); - // No longer rethrows — flag swallows the REST failure and queues - // for replay so the user's intent persists offline. - await container - .read(myQuarantineProvider.notifier) - .flag(_track, 'bad_rip', ''); - final rows = await db.select(db.cachedQuarantineMine).get(); - expect(rows, hasLength(1), - reason: 'optimistic drift row should persist across REST failure'); - final mutations = await db.select(db.cachedMutations).get(); - expect(mutations, hasLength(1), - reason: 'failed flag should have been enqueued for replay'); - expect(mutations.first.kind, 'quarantine.flag'); - }, skip: 'Pending Fable #476 — StreamProvider lifecycle in async catch path; see task body for full diagnostic'); - - test('unflag deletes the drift row and calls the server', - () async { - final db = AppDb(NativeDatabase.memory()); - addTearDown(db.close); - await _seed(db); - final api = _StubQuarantineApi(); - final container = _container(db: db, api: api); - addTearDown(container.dispose); - - await container.read(myQuarantineProvider.future); - await container.read(myQuarantineProvider.notifier).unflag('t1'); - - expect(api.unflagCalls, 1); - final rows = await db.select(db.cachedQuarantineMine).get(); - expect(rows, isEmpty); - }); - - test('isHidden reflects drift state', () async { - final db = AppDb(NativeDatabase.memory()); - addTearDown(db.close); - await _seed(db); - final api = _StubQuarantineApi(); - final container = _container(db: db, api: api); - addTearDown(container.dispose); - - await container.read(myQuarantineProvider.future); - final ctrl = container.read(myQuarantineProvider.notifier); - expect(ctrl.isHidden('t1'), isTrue); - expect(ctrl.isHidden('t2'), isFalse); - }); -} diff --git a/flutter_client/test/requests/requests_screen_test.dart b/flutter_client/test/requests/requests_screen_test.dart deleted file mode 100644 index fcd63c8e..00000000 --- a/flutter_client/test/requests/requests_screen_test.dart +++ /dev/null @@ -1,128 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:minstrel/auth/auth_provider.dart'; -import 'package:minstrel/models/admin_request.dart'; -import 'package:minstrel/models/user.dart'; -import 'package:minstrel/requests/requests_provider.dart'; -import 'package:minstrel/requests/requests_screen.dart'; -import 'package:minstrel/theme/theme_data.dart'; - -class _StubAuth extends AuthController { - @override - Future build() async => - const User(id: 'u1', username: 'alice', isAdmin: false); -} - -class _StubRequests extends MyRequestsController { - _StubRequests(this._initial); - final List _initial; - @override - Future> build() async => _initial; -} - -const _pendingAlbum = AdminRequest( - id: 'r1', - userId: 'u1', - status: 'pending', - kind: 'album', - artistName: 'Aphex Twin', - albumTitle: 'Drukqs', - trackTitle: null, - requestedAt: '2026-05-08T00:00:00Z', - decidedAt: null, - notes: null, - importedAlbumCount: 0, - importedTrackCount: 0, -); - -const _completedTrackWithMatch = AdminRequest( - id: 'r2', - userId: 'u1', - status: 'completed', - kind: 'track', - artistName: 'Boards of Canada', - albumTitle: 'Geogaddi', - trackTitle: 'Roygbiv', - requestedAt: '2026-05-01T00:00:00Z', - decidedAt: '2026-05-02T00:00:00Z', - notes: null, - importedAlbumCount: 1, - importedTrackCount: 1, - matchedTrackId: 't1', - matchedAlbumId: 'a1', -); - -const _rejectedWithNotes = AdminRequest( - id: 'r3', - userId: 'u1', - status: 'rejected', - kind: 'artist', - artistName: 'Some Artist', - albumTitle: null, - trackTitle: null, - requestedAt: '2026-05-01T00:00:00Z', - decidedAt: '2026-05-02T00:00:00Z', - notes: 'Not in MusicBrainz', - importedAlbumCount: 0, - importedTrackCount: 0, -); - -Widget _harness(List requests) => ProviderScope( - overrides: [ - authControllerProvider.overrideWith(() => _StubAuth()), - myRequestsProvider.overrideWith(() => _StubRequests(requests)), - ], - child: MaterialApp( - theme: buildThemeData(), - home: const RequestsScreen(), - ), - ); - -void main() { - testWidgets('renders empty state when no requests', (t) async { - await t.pumpWidget(_harness(const [])); - await t.pumpAndSettle(); - expect(find.text('Nothing requested yet.'), findsOneWidget); - }); - - testWidgets('renders pending row with Cancel CTA', (t) async { - await t.pumpWidget(_harness(const [_pendingAlbum])); - await t.pumpAndSettle(); - expect(find.byKey(const Key('request_row_r1')), findsOneWidget); - expect(find.text('Drukqs'), findsOneWidget); - expect(find.text('Cancel'), findsOneWidget); - }); - - testWidgets('renders completed row with Listen CTA', (t) async { - await t.pumpWidget(_harness(const [_completedTrackWithMatch])); - await t.pumpAndSettle(); - expect(find.text('Roygbiv'), findsOneWidget); - expect(find.text('Listen'), findsOneWidget); - // Ingest progress copy - expect(find.text('Track ingested'), findsOneWidget); - }); - - testWidgets('renders rejected row with notes; no CTA', (t) async { - await t.pumpWidget(_harness(const [_rejectedWithNotes])); - await t.pumpAndSettle(); - expect(find.text('Some Artist'), findsOneWidget); - expect(find.text('Not in MusicBrainz'), findsOneWidget); - expect(find.text('Cancel'), findsNothing); - expect(find.text('Listen'), findsNothing); - }); - - testWidgets('Cancel button opens confirm dialog', (t) async { - await t.pumpWidget(_harness(const [_pendingAlbum])); - await t.pumpAndSettle(); - await t.tap(find.text('Cancel')); - await t.pumpAndSettle(); - expect(find.text('Cancel request?'), findsOneWidget); - expect(find.text('Cancel "Drukqs"?'), findsOneWidget); - // Dismiss with Keep - await t.tap(find.text('Keep')); - await t.pumpAndSettle(); - expect(find.text('Cancel request?'), findsNothing); - }); -} diff --git a/flutter_client/test/settings/appearance_section_test.dart b/flutter_client/test/settings/appearance_section_test.dart deleted file mode 100644 index 81f6a183..00000000 --- a/flutter_client/test/settings/appearance_section_test.dart +++ /dev/null @@ -1,82 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:mocktail/mocktail.dart'; - -import 'package:minstrel/auth/auth_provider.dart'; -import 'package:minstrel/settings/settings_screen.dart'; -import 'package:minstrel/theme/theme_data.dart'; -import 'package:minstrel/theme/theme_mode_provider.dart'; - -class _MockStorage extends Mock implements FlutterSecureStorage {} - -class _StubTheme extends ThemeModeController { - _StubTheme(this._initial); - final AppThemeMode _initial; - @override - Future build() async => _initial; -} - -void main() { - late _MockStorage storage; - - setUpAll(() { - registerFallbackValue(''); - }); - - setUp(() { - storage = _MockStorage(); - when(() => storage.read(key: any(named: 'key'))).thenAnswer((_) async => null); - when(() => storage.write(key: any(named: 'key'), value: any(named: 'value'))) - .thenAnswer((_) async {}); - }); - - testWidgets('renders three appearance radios + reflects the current mode', (t) async { - await t.pumpWidget(ProviderScope( - overrides: [ - secureStorageProvider.overrideWithValue(storage), - themeModeProvider.overrideWith(() => _StubTheme(AppThemeMode.dark)), - ], - child: MaterialApp( - theme: buildDarkTheme(), - home: const SettingsScreen(), - ), - )); - await t.pumpAndSettle(); - - expect(find.byKey(const Key('appearance_system')), findsOneWidget); - expect(find.byKey(const Key('appearance_light')), findsOneWidget); - expect(find.byKey(const Key('appearance_dark')), findsOneWidget); - - // The RadioGroup ancestor owns groupValue post-Flutter-3.32; the - // selected mode is read off it rather than off individual tiles. - final group = t.widget>( - find.byType(RadioGroup), - ); - expect(group.groupValue, AppThemeMode.dark); - final darkRadio = t.widget>( - find.byKey(const Key('appearance_dark')), - ); - expect(darkRadio.value, AppThemeMode.dark); - }); - - testWidgets('tapping the light radio writes "light" to storage', (t) async { - await t.pumpWidget(ProviderScope( - overrides: [ - secureStorageProvider.overrideWithValue(storage), - themeModeProvider.overrideWith(() => _StubTheme(AppThemeMode.system)), - ], - child: MaterialApp( - theme: buildDarkTheme(), - home: const SettingsScreen(), - ), - )); - await t.pumpAndSettle(); - - await t.tap(find.byKey(const Key('appearance_light'))); - await t.pumpAndSettle(); - - verify(() => storage.write(key: 'theme_mode', value: 'light')).called(1); - }); -} diff --git a/flutter_client/test/settings/storage_section_test.dart b/flutter_client/test/settings/storage_section_test.dart deleted file mode 100644 index 1722b722..00000000 --- a/flutter_client/test/settings/storage_section_test.dart +++ /dev/null @@ -1,73 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:mocktail/mocktail.dart'; - -import 'package:minstrel/auth/auth_provider.dart'; -import 'package:minstrel/settings/storage_section.dart'; -import 'package:minstrel/theme/theme_data.dart'; - -class _MockStorage extends Mock implements FlutterSecureStorage {} - -void main() { - setUpAll(() { - registerFallbackValue(''); - }); - - testWidgets('renders Storage heading + all controls', (t) async { - final storage = _MockStorage(); - when(() => storage.read(key: any(named: 'key'))) - .thenAnswer((_) async => null); - when(() => storage.write( - key: any(named: 'key'), value: any(named: 'value'))) - .thenAnswer((_) async {}); - - await t.pumpWidget(ProviderScope( - overrides: [secureStorageProvider.overrideWithValue(storage)], - child: MaterialApp( - theme: buildDarkTheme(), - home: const Scaffold(body: StorageSection()), - ), - )); - await t.pumpAndSettle(); - - expect(find.text('Storage'), findsOneWidget); - expect(find.text('Liked cache limit'), findsOneWidget); - expect(find.text('Recently-played cache limit'), findsOneWidget); - expect(find.text('Pre-fetch ahead'), findsOneWidget); - expect(find.byKey(const Key('clear_cache_button')), findsOneWidget); - expect(find.byKey(const Key('sync_now_button')), findsOneWidget); - expect(find.byKey(const Key('cache_liked_toggle')), findsOneWidget); - expect(find.byKey(const Key('liked_cap_selector')), findsOneWidget); - expect(find.byKey(const Key('rolling_cap_selector')), findsOneWidget); - expect(find.byKey(const Key('prefetch_selector')), findsOneWidget); - }); - - testWidgets('Clear cache button shows confirm dialog', (t) async { - final storage = _MockStorage(); - when(() => storage.read(key: any(named: 'key'))) - .thenAnswer((_) async => null); - when(() => storage.write( - key: any(named: 'key'), value: any(named: 'value'))) - .thenAnswer((_) async {}); - - await t.pumpWidget(ProviderScope( - overrides: [secureStorageProvider.overrideWithValue(storage)], - child: MaterialApp( - theme: buildDarkTheme(), - home: const Scaffold(body: StorageSection()), - ), - )); - await t.pumpAndSettle(); - - await t.tap(find.byKey(const Key('clear_cache_button'))); - await t.pumpAndSettle(); - - expect(find.text('Clear cache?'), findsOneWidget); - // Cancel returns to the original state. - await t.tap(find.text('Cancel')); - await t.pumpAndSettle(); - expect(find.text('Clear cache?'), findsNothing); - }); -} diff --git a/flutter_client/test/shared/delayed_loading_test.dart b/flutter_client/test/shared/delayed_loading_test.dart deleted file mode 100644 index 2273633d..00000000 --- a/flutter_client/test/shared/delayed_loading_test.dart +++ /dev/null @@ -1,76 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:minstrel/shared/delayed_loading.dart'; - -void main() { - testWidgets('renders nothing for the first 100ms of loading', (tester) async { - await tester.pumpWidget(const MaterialApp( - home: DelayedLoading( - isLoading: true, - delay: Duration(milliseconds: 200), - whileDelayed: Text('LOADING'), - whenReady: Text('READY'), - ), - )); - await tester.pump(const Duration(milliseconds: 100)); - expect(find.text('LOADING'), findsNothing); - expect(find.text('READY'), findsNothing); - }); - - testWidgets('renders whileDelayed after delay elapses', (tester) async { - await tester.pumpWidget(const MaterialApp( - home: DelayedLoading( - isLoading: true, - delay: Duration(milliseconds: 200), - whileDelayed: Text('LOADING'), - whenReady: Text('READY'), - ), - )); - await tester.pump(const Duration(milliseconds: 250)); - expect(find.text('LOADING'), findsOneWidget); - }); - - testWidgets('renders whenReady when not loading', (tester) async { - await tester.pumpWidget(const MaterialApp( - home: DelayedLoading( - isLoading: false, - whileDelayed: Text('LOADING'), - whenReady: Text('READY'), - ), - )); - expect(find.text('READY'), findsOneWidget); - }); - - testWidgets('resets timer when isLoading flips false then true', (tester) async { - await tester.pumpWidget(const MaterialApp( - home: DelayedLoading( - isLoading: true, - delay: Duration(milliseconds: 200), - whileDelayed: Text('LOADING'), - whenReady: Text('READY'), - ), - )); - await tester.pump(const Duration(milliseconds: 150)); - // Switch to not-loading, then back to loading. - await tester.pumpWidget(const MaterialApp( - home: DelayedLoading( - isLoading: false, - whileDelayed: Text('LOADING'), - whenReady: Text('READY'), - ), - )); - await tester.pumpWidget(const MaterialApp( - home: DelayedLoading( - isLoading: true, - delay: Duration(milliseconds: 200), - whileDelayed: Text('LOADING'), - whenReady: Text('READY'), - ), - )); - // Original 150ms shouldn't carry over — at 100ms past restart - // we should still see nothing. - await tester.pump(const Duration(milliseconds: 100)); - expect(find.text('LOADING'), findsNothing); - }); -} diff --git a/flutter_client/test/shared/main_app_bar_actions_test.dart b/flutter_client/test/shared/main_app_bar_actions_test.dart deleted file mode 100644 index 495369ae..00000000 --- a/flutter_client/test/shared/main_app_bar_actions_test.dart +++ /dev/null @@ -1,70 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:minstrel/auth/auth_provider.dart'; -import 'package:minstrel/models/user.dart'; -import 'package:minstrel/shared/widgets/main_app_bar_actions.dart'; -import 'package:minstrel/theme/theme_data.dart'; - -class _StubAuth extends AuthController { - _StubAuth(this._user); - final User? _user; - @override - Future build() async => _user; -} - -Widget _harness({required User? user, required String currentRoute}) { - return ProviderScope( - overrides: [ - authControllerProvider.overrideWith(() => _StubAuth(user)), - ], - child: MaterialApp( - theme: buildThemeData(), - home: Scaffold( - appBar: AppBar( - actions: [MainAppBarActions(currentRoute: currentRoute)], - ), - ), - ), - ); -} - -void main() { - const adminUser = User(id: 'u1', username: 'admin', isAdmin: true); - const regularUser = User(id: 'u2', username: 'alice', isAdmin: false); - - testWidgets('renders Library + Search primary icons + kebab on /home', - (t) async { - await t.pumpWidget(_harness(user: regularUser, currentRoute: '/home')); - await t.pumpAndSettle(); - expect(find.byKey(const Key('app_bar_library')), findsOneWidget); - expect(find.byKey(const Key('app_bar_search')), findsOneWidget); - expect(find.byKey(const Key('app_bar_home')), findsNothing); - expect(find.byKey(const Key('app_bar_overflow')), findsOneWidget); - }); - - testWidgets('suppresses Library icon when currentRoute is /library', - (t) async { - await t.pumpWidget(_harness(user: regularUser, currentRoute: '/library')); - await t.pumpAndSettle(); - expect(find.byKey(const Key('app_bar_library')), findsNothing); - expect(find.byKey(const Key('app_bar_home')), findsOneWidget); - }); - - testWidgets('overflow includes Admin only for admin users', (t) async { - await t.pumpWidget(_harness(user: adminUser, currentRoute: '/home')); - await t.pumpAndSettle(); - await t.tap(find.byKey(const Key('app_bar_overflow'))); - await t.pumpAndSettle(); - expect(find.text('Admin'), findsOneWidget); - }); - - testWidgets('overflow omits Admin for non-admin users', (t) async { - await t.pumpWidget(_harness(user: regularUser, currentRoute: '/home')); - await t.pumpAndSettle(); - await t.tap(find.byKey(const Key('app_bar_overflow'))); - await t.pumpAndSettle(); - expect(find.text('Admin'), findsNothing); - }); -} diff --git a/flutter_client/test/shared/widgets/track_actions/add_to_playlist_sheet_test.dart b/flutter_client/test/shared/widgets/track_actions/add_to_playlist_sheet_test.dart deleted file mode 100644 index 9c5bb10e..00000000 --- a/flutter_client/test/shared/widgets/track_actions/add_to_playlist_sheet_test.dart +++ /dev/null @@ -1,114 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:minstrel/api/endpoints/playlists.dart'; -import 'package:minstrel/models/playlist.dart'; -import 'package:minstrel/playlists/playlists_provider.dart'; -import 'package:minstrel/shared/widgets/track_actions/add_to_playlist_sheet.dart'; -import 'package:minstrel/theme/theme_data.dart'; - -const _userPlaylist = Playlist( - id: 'p1', - userId: 'u1', - name: 'Road trip', - description: '', - isPublic: false, - systemVariant: null, - trackCount: 12, - coverUrl: '', - ownerUsername: 'alice', - createdAt: '2026-05-01T00:00:00Z', - updatedAt: '2026-05-01T00:00:00Z', -); - -const _systemPlaylist = Playlist( - id: 'p2', - userId: 'u1', - name: 'For You', - description: '', - isPublic: false, - systemVariant: 'for_you', - trackCount: 75, - coverUrl: '', - ownerUsername: 'alice', - createdAt: '2026-05-01T00:00:00Z', - updatedAt: '2026-05-01T00:00:00Z', -); - -Widget _harness(PlaylistsList lists) { - return ProviderScope( - overrides: [ - playlistsListProvider('user').overrideWith((ref) => Stream.value(lists)), - ], - child: MaterialApp( - theme: buildThemeData(), - home: Builder(builder: (ctx) { - return Scaffold( - body: Center( - child: ElevatedButton( - onPressed: () => AddToPlaylistSheet.show(ctx), - child: const Text('open'), - ), - ), - ); - }), - ), - ); -} - -void main() { - testWidgets('renders user playlists, hides system ones', (t) async { - await t.pumpWidget(_harness( - const PlaylistsList(owned: [_userPlaylist, _systemPlaylist], public: []), - )); - await t.tap(find.text('open')); - await t.pumpAndSettle(); - expect(find.text('Road trip'), findsOneWidget); - expect(find.text('For You'), findsNothing); - }); - - testWidgets('empty state when no user playlists', (t) async { - await t.pumpWidget(_harness( - const PlaylistsList(owned: [_systemPlaylist], public: []), - )); - await t.tap(find.text('open')); - await t.pumpAndSettle(); - expect( - find.text("You haven't created any playlists yet."), - findsOneWidget, - ); - }); - - testWidgets('tapping a row pops with playlist id', (t) async { - String? picked; - await t.pumpWidget(ProviderScope( - overrides: [ - playlistsListProvider('user').overrideWith( - (ref) => Stream.value( - const PlaylistsList(owned: [_userPlaylist], public: [])), - ), - ], - child: MaterialApp( - theme: buildThemeData(), - home: Builder(builder: (ctx) { - return Scaffold( - body: Center( - child: ElevatedButton( - onPressed: () async { - picked = await AddToPlaylistSheet.show(ctx); - }, - child: const Text('open'), - ), - ), - ); - }), - ), - )); - await t.tap(find.text('open')); - await t.pumpAndSettle(); - await t.tap(find.byKey(const Key('add_to_playlist_p1'))); - await t.pumpAndSettle(); - expect(picked, 'p1'); - }); -} diff --git a/flutter_client/test/shared/widgets/track_actions/hide_track_sheet_test.dart b/flutter_client/test/shared/widgets/track_actions/hide_track_sheet_test.dart deleted file mode 100644 index 4cca8654..00000000 --- a/flutter_client/test/shared/widgets/track_actions/hide_track_sheet_test.dart +++ /dev/null @@ -1,86 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:minstrel/shared/widgets/track_actions/hide_track_sheet.dart'; -import 'package:minstrel/theme/theme_data.dart'; - -void main() { - Future<({String reason, String notes})?> openAndPick( - WidgetTester t, { - required String chipKey, - String notes = '', - }) async { - Future<({String reason, String notes})?>? future; - await t.pumpWidget(MaterialApp( - theme: buildThemeData(), - home: Builder(builder: (ctx) { - return Scaffold( - body: Center( - child: ElevatedButton( - onPressed: () { - future = HideTrackSheet.show(ctx); - }, - child: const Text('open'), - ), - ), - ); - }), - )); - await t.tap(find.text('open')); - await t.pumpAndSettle(); - if (chipKey != 'hide_reason_bad_rip') { - await t.tap(find.byKey(Key(chipKey))); - await t.pumpAndSettle(); - } - if (notes.isNotEmpty) { - await t.enterText(find.byKey(const Key('hide_notes_input')), notes); - } - await t.tap(find.byKey(const Key('hide_confirm'))); - await t.pumpAndSettle(); - return future; - } - - testWidgets('default reason is bad_rip', (t) async { - final result = await openAndPick(t, chipKey: 'hide_reason_bad_rip'); - expect(result, isNotNull); - expect(result!.reason, 'bad_rip'); - expect(result.notes, ''); - }); - - testWidgets('selecting wrong_tags returns wrong_tags', (t) async { - final result = await openAndPick(t, chipKey: 'hide_reason_wrong_tags'); - expect(result?.reason, 'wrong_tags'); - }); - - testWidgets('notes get trimmed and returned', (t) async { - final result = await openAndPick( - t, - chipKey: 'hide_reason_other', - notes: ' cracks at 2:13 ', - ); - expect(result?.reason, 'other'); - expect(result?.notes, 'cracks at 2:13'); - }); - - testWidgets('cancel returns null', (t) async { - Future<({String reason, String notes})?>? future; - await t.pumpWidget(MaterialApp( - theme: buildThemeData(), - home: Builder(builder: (ctx) { - return Scaffold( - body: Center( - child: ElevatedButton( - onPressed: () { future = HideTrackSheet.show(ctx); }, - child: const Text('open'), - ), - ), - ); - }), - )); - await t.tap(find.text('open')); - await t.pumpAndSettle(); - await t.tap(find.text('Cancel')); - await t.pumpAndSettle(); - expect(await future, isNull); - }); -} diff --git a/flutter_client/test/shared/widgets/track_actions/track_actions_sheet_test.dart b/flutter_client/test/shared/widgets/track_actions/track_actions_sheet_test.dart deleted file mode 100644 index 9ab666e5..00000000 --- a/flutter_client/test/shared/widgets/track_actions/track_actions_sheet_test.dart +++ /dev/null @@ -1,89 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:minstrel/likes/likes_provider.dart'; -import 'package:minstrel/models/quarantine_mine.dart'; -import 'package:minstrel/models/track.dart'; -import 'package:minstrel/quarantine/quarantine_provider.dart'; -import 'package:minstrel/shared/widgets/track_actions/track_actions_sheet.dart'; -import 'package:minstrel/theme/theme_data.dart'; - -const _track = TrackRef( - id: 't1', - title: 'Roygbiv', - albumId: 'a1', - albumTitle: 'Geogaddi', - artistId: 'ar1', - artistName: 'Boards of Canada', - durationSec: 137, - trackNumber: 4, - streamUrl: '', -); - -class _StubQuarantine extends MyQuarantineController { - @override - Future> build() async => const []; -} - -Widget _harness({bool hideQueueActions = false, Set? likedTracks}) { - return ProviderScope( - overrides: [ - likedIdsProvider.overrideWith((ref) => Stream.value(LikedIds( - artists: const {}, - albums: const {}, - tracks: likedTracks ?? const {}, - ))), - myQuarantineProvider.overrideWith(() => _StubQuarantine()), - ], - child: MaterialApp( - theme: buildThemeData(), - home: Builder(builder: (ctx) { - return Scaffold( - body: Center( - child: ElevatedButton( - onPressed: () => TrackActionsSheet.show( - ctx, - _track, - hideQueueActions: hideQueueActions, - ), - child: const Text('open'), - ), - ), - ); - }), - ), - ); -} - -void main() { - testWidgets('renders all 7 items by default', (t) async { - await t.pumpWidget(_harness()); - await t.tap(find.text('open')); - await t.pumpAndSettle(); - expect(find.byKey(const Key('track_actions_play_next')), findsOneWidget); - expect(find.byKey(const Key('track_actions_enqueue')), findsOneWidget); - expect(find.byKey(const Key('track_actions_like')), findsOneWidget); - expect(find.byKey(const Key('track_actions_add_to_playlist')), findsOneWidget); - expect(find.byKey(const Key('track_actions_go_to_album')), findsOneWidget); - expect(find.byKey(const Key('track_actions_go_to_artist')), findsOneWidget); - expect(find.byKey(const Key('track_actions_hide')), findsOneWidget); - }); - - testWidgets('hideQueueActions suppresses Play next + Add to queue', (t) async { - await t.pumpWidget(_harness(hideQueueActions: true)); - await t.tap(find.text('open')); - await t.pumpAndSettle(); - expect(find.byKey(const Key('track_actions_play_next')), findsNothing); - expect(find.byKey(const Key('track_actions_enqueue')), findsNothing); - expect(find.byKey(const Key('track_actions_like')), findsOneWidget); - }); - - testWidgets('like label flips to Unlike when track is liked', (t) async { - await t.pumpWidget(_harness(likedTracks: const {'t1'})); - await t.tap(find.text('open')); - await t.pumpAndSettle(); - expect(find.text('Unlike'), findsOneWidget); - expect(find.text('Like'), findsNothing); - }); -} diff --git a/flutter_client/test/smoke_test.dart b/flutter_client/test/smoke_test.dart deleted file mode 100644 index 2c66efcc..00000000 --- a/flutter_client/test/smoke_test.dart +++ /dev/null @@ -1,33 +0,0 @@ -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:mocktail/mocktail.dart'; - -import 'package:minstrel/app.dart'; -import 'package:minstrel/auth/auth_provider.dart'; - -class _NullStorage extends Mock implements FlutterSecureStorage {} - -void main() { - setUpAll(() { - registerFallbackValue(''); - }); - - testWidgets('cold launch lands on server-url screen when no URL stored', (tester) async { - // Override secureStorageProvider so all reads return null deterministically. - // Without this the real flutter_secure_storage MethodChannel hangs in - // widget tests (no platform channel), so the GoRouter redirect never - // resolves and ServerUrlScreen never renders. - final storage = _NullStorage(); - when(() => storage.read(key: any(named: 'key'))).thenAnswer((_) async => null); - - await tester.pumpWidget(ProviderScope( - overrides: [ - secureStorageProvider.overrideWithValue(storage), - ], - child: const MinstrelApp(), - )); - await tester.pumpAndSettle(); - expect(find.text('Connect to your Minstrel'), findsOneWidget); - }); -} diff --git a/flutter_client/test/theme/theme_extension_test.dart b/flutter_client/test/theme/theme_extension_test.dart deleted file mode 100644 index 873d8f73..00000000 --- a/flutter_client/test/theme/theme_extension_test.dart +++ /dev/null @@ -1,39 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; - -import 'package:minstrel/theme/theme_extension.dart'; -import 'package:minstrel/theme/tokens.dart'; - -void main() { - test('FabledSwordTheme.dark uses dark surface tokens', () { - final fs = FabledSwordTheme.dark(); - expect(fs.obsidian, FabledSwordDarkTokens.obsidian); - expect(fs.iron, FabledSwordDarkTokens.iron); - expect(fs.parchment, FabledSwordDarkTokens.parchment); - }); - - test('FabledSwordTheme.light uses light surface tokens', () { - final fs = FabledSwordTheme.light(); - expect(fs.obsidian, FabledSwordLightTokens.obsidian); - expect(fs.iron, FabledSwordLightTokens.iron); - expect(fs.parchment, FabledSwordLightTokens.parchment); - }); - - test('flat tokens (accent, moss, etc.) are shared between factories', () { - final dark = FabledSwordTheme.dark(); - final light = FabledSwordTheme.light(); - expect(dark.accent, light.accent); - expect(dark.moss, light.moss); - expect(dark.bronze, light.bronze); - expect(dark.oxblood, light.oxblood); - expect(dark.warning, light.warning); - expect(dark.error, light.error); - expect(dark.info, light.info); - }); - - test('fromTokens() back-compat alias returns the dark theme', () { - final alias = FabledSwordTheme.fromTokens(); - final dark = FabledSwordTheme.dark(); - expect(alias.obsidian, dark.obsidian); - expect(alias.parchment, dark.parchment); - }); -} diff --git a/flutter_client/test/theme/theme_mode_provider_test.dart b/flutter_client/test/theme/theme_mode_provider_test.dart deleted file mode 100644 index 05b63da1..00000000 --- a/flutter_client/test/theme/theme_mode_provider_test.dart +++ /dev/null @@ -1,76 +0,0 @@ -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:mocktail/mocktail.dart'; - -import 'package:minstrel/auth/auth_provider.dart'; -import 'package:minstrel/theme/theme_mode_provider.dart'; - -class _MockStorage extends Mock implements FlutterSecureStorage {} - -void main() { - late _MockStorage storage; - - setUpAll(() { - registerFallbackValue(''); - }); - - setUp(() { - storage = _MockStorage(); - when(() => storage.write(key: any(named: 'key'), value: any(named: 'value'))) - .thenAnswer((_) async {}); - }); - - test('defaults to system when nothing stored', () async { - when(() => storage.read(key: 'theme_mode')).thenAnswer((_) async => null); - final container = ProviderContainer(overrides: [ - secureStorageProvider.overrideWithValue(storage), - ]); - addTearDown(container.dispose); - - final mode = await container.read(themeModeProvider.future); - expect(mode, AppThemeMode.system); - }); - - test('reads stored "dark" → AppThemeMode.dark', () async { - when(() => storage.read(key: 'theme_mode')).thenAnswer((_) async => 'dark'); - final container = ProviderContainer(overrides: [ - secureStorageProvider.overrideWithValue(storage), - ]); - addTearDown(container.dispose); - - final mode = await container.read(themeModeProvider.future); - expect(mode, AppThemeMode.dark); - }); - - test('reads stored "light" → AppThemeMode.light', () async { - when(() => storage.read(key: 'theme_mode')).thenAnswer((_) async => 'light'); - final container = ProviderContainer(overrides: [ - secureStorageProvider.overrideWithValue(storage), - ]); - addTearDown(container.dispose); - - final mode = await container.read(themeModeProvider.future); - expect(mode, AppThemeMode.light); - }); - - test('set(.light) writes "light" to storage and updates state', () async { - when(() => storage.read(key: 'theme_mode')).thenAnswer((_) async => null); - final container = ProviderContainer(overrides: [ - secureStorageProvider.overrideWithValue(storage), - ]); - addTearDown(container.dispose); - - await container.read(themeModeProvider.future); - await container.read(themeModeProvider.notifier).set(AppThemeMode.light); - - verify(() => storage.write(key: 'theme_mode', value: 'light')).called(1); - expect(container.read(themeModeProvider).value, AppThemeMode.light); - }); - - test('materialMode extension maps each enum to ThemeMode', () { - expect(AppThemeMode.system.materialMode.name, 'system'); - expect(AppThemeMode.dark.materialMode.name, 'dark'); - expect(AppThemeMode.light.materialMode.name, 'light'); - }); -} diff --git a/flutter_client/test/update/client_update_provider_test.dart b/flutter_client/test/update/client_update_provider_test.dart deleted file mode 100644 index 9433c915..00000000 --- a/flutter_client/test/update/client_update_provider_test.dart +++ /dev/null @@ -1,75 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:minstrel/update/client_update_provider.dart'; - -void main() { - group('isVersionNewer (semver path)', () { - test('strictly newer', () { - expect(isVersionNewer('0.1.1', '0.1.0'), isTrue); - expect(isVersionNewer('0.2.0', '0.1.99'), isTrue); - expect(isVersionNewer('1.0.0', '0.99.99'), isTrue); - }); - - test('equal returns false', () { - expect(isVersionNewer('0.1.0', '0.1.0'), isFalse); - }); - - test('older returns false', () { - expect(isVersionNewer('0.1.0', '0.1.1'), isFalse); - expect(isVersionNewer('0.1.0', '0.2.0'), isFalse); - }); - - test('strips leading v on either side', () { - expect(isVersionNewer('v0.1.1', '0.1.0'), isTrue); - expect(isVersionNewer('0.1.1', 'v0.1.0'), isTrue); - expect(isVersionNewer('v0.1.0', 'v0.1.0'), isFalse); - }); - - test('zero-pads shorter version when comparing', () { - // "1.2" treated as "1.2.0" — shorter side gets zero-padded so - // comparison is component-wise. - expect(isVersionNewer('1.2.1', '1.2'), isTrue); - expect(isVersionNewer('1.2', '1.2.1'), isFalse); - expect(isVersionNewer('1.2', '1.2.0'), isFalse); - }); - }); - - group('isVersionNewer (date-style versions)', () { - // Date tags are 3- or 4-part: 2026.05.10 or 2026.05.10.1. The - // numeric comparator parses each component as int (so "05" == 5) - // and zero-pads the shorter side. - test('newer date → newer', () { - expect(isVersionNewer('2026.05.10', '2026.05.09'), isTrue); - }); - test('older date → not newer', () { - expect(isVersionNewer('2026.05.09', '2026.05.10'), isFalse); - }); - test('equal date → not newer', () { - expect(isVersionNewer('2026.05.10', '2026.05.10'), isFalse); - }); - test('leading zeros do not affect ordering', () { - // "2026.5.11" and "2026.05.11" parse to the same components. - // 12 > 5 numerically so month rollover stays correct. - expect(isVersionNewer('2026.5.11', '2026.05.11'), isFalse); - expect(isVersionNewer('2026.12.1', '2026.5.31'), isTrue); - }); - test('4-part build suffix breaks the tie', () { - // 2026.05.10 vs 2026.05.10.1 — first three equal, server has a - // 4th component (1), client zero-pads → server is newer. - expect(isVersionNewer('2026.05.10.1', '2026.05.10'), isTrue); - expect(isVersionNewer('2026.05.10', '2026.05.10.0'), isFalse); - }); - }); - - group('isVersionNewer (truly non-semver fallback)', () { - // Branch-name-style strings (no version structure) hit the - // try/catch fallback. Any string difference reads as "newer" so - // operators see _something_ rather than silently miss updates. - test('different unparseable strings → newer', () { - expect(isVersionNewer('main', 'dev'), isTrue); - expect(isVersionNewer('dev', 'main'), isTrue); - }); - test('equal unparseable strings → not newer', () { - expect(isVersionNewer('main', 'main'), isFalse); - }); - }); -} diff --git a/flutter_client/tool/gen_tokens.dart b/flutter_client/tool/gen_tokens.dart deleted file mode 100644 index 7540da63..00000000 --- a/flutter_client/tool/gen_tokens.dart +++ /dev/null @@ -1,97 +0,0 @@ -// Reads shared/fabledsword.tokens.json and emits lib/theme/tokens.dart. -// Run via `dart run tool/gen_tokens.dart`. The generated file is -// committed (CI validates it matches the JSON to catch drift). -// -// Emits three flat classes mirroring the source JSON: -// - FabledSwordDarkTokens (surface colors for dark mode) -// - FabledSwordLightTokens (surface colors for light mode) -// - FabledSwordFlatTokens (colors that don't change between modes, -// plus radii + font families) -// -// Plus a back-compat FabledSwordTokens alias that re-exports the dark -// surface colors + flat — kept so any code that still reads -// FabledSwordTokens.obsidian (etc.) continues to work during the -// migration. Safe to delete once theme_extension.dart switches over. -import 'dart:convert'; -import 'dart:io'; - -void main() { - final jsonFile = File('shared/fabledsword.tokens.json'); - final tokens = jsonDecode(jsonFile.readAsStringSync()) as Map; - - final colorsRoot = (tokens['colors'] as Map).cast(); - final dark = (colorsRoot['dark'] as Map).cast(); - final light = (colorsRoot['light'] as Map).cast(); - final flat = (colorsRoot['flat'] as Map).cast(); - final radii = (tokens['radii'] as Map).cast(); - final fonts = (tokens['fonts'] as Map).cast(); - - final out = StringBuffer() - ..writeln('// GENERATED — do not edit. Source: shared/fabledsword.tokens.json') - ..writeln('// Run `dart run tool/gen_tokens.dart` to regenerate.') - ..writeln("import 'package:flutter/material.dart';") - ..writeln(); - - void writeColorClass(String className, Map colors) { - out.writeln('class $className {'); - colors.forEach((name, hex) { - final value = hex.replaceFirst('#', '0xFF'); - out.writeln(' static const Color ${_camel(name)} = Color($value);'); - }); - out.writeln('}'); - out.writeln(); - } - - writeColorClass('FabledSwordDarkTokens', dark); - writeColorClass('FabledSwordLightTokens', light); - - out.writeln('class FabledSwordFlatTokens {'); - flat.forEach((name, hex) { - final value = hex.replaceFirst('#', '0xFF'); - out.writeln(' static const Color ${_camel(name)} = Color($value);'); - }); - radii.forEach((name, px) { - final v = px.replaceAll('px', ''); - out.writeln(' static const double radius${_pascal(name)} = $v;'); - }); - fonts.forEach((name, family) { - out.writeln(' static const String font${_pascal(name)} = ${jsonEncode(family)};'); - }); - out.writeln('}'); - out.writeln(); - - // Back-compat alias: dark surface colors + flat. Lets existing - // call sites that read FabledSwordTokens.obsidian etc. keep working - // until they're migrated to the explicit dark/light/flat classes. - out.writeln('/// Back-compat alias — dark surface tokens + flat. Prefer the explicit'); - out.writeln('/// FabledSwordDarkTokens / FabledSwordLightTokens / FabledSwordFlatTokens.'); - out.writeln('class FabledSwordTokens {'); - dark.forEach((name, hex) { - final value = hex.replaceFirst('#', '0xFF'); - out.writeln(' static const Color ${_camel(name)} = Color($value);'); - }); - flat.forEach((name, hex) { - final value = hex.replaceFirst('#', '0xFF'); - out.writeln(' static const Color ${_camel(name)} = Color($value);'); - }); - radii.forEach((name, px) { - final v = px.replaceAll('px', ''); - out.writeln(' static const double radius${_pascal(name)} = $v;'); - }); - fonts.forEach((name, family) { - out.writeln(' static const String font${_pascal(name)} = ${jsonEncode(family)};'); - }); - out.writeln('}'); - - File('lib/theme/tokens.dart').writeAsStringSync(out.toString()); - stdout.writeln('wrote lib/theme/tokens.dart'); -} - -// Hyphens become camelCase boundaries: on-action → onAction. -String _camel(String s) { - final parts = s.split('-'); - return parts.first + - parts.skip(1).map((p) => p[0].toUpperCase() + p.substring(1)).join(); -} - -String _pascal(String s) => s[0].toUpperCase() + s.substring(1); diff --git a/flutter_client/tool/sync_shared.sh b/flutter_client/tool/sync_shared.sh deleted file mode 100755 index d1931ac0..00000000 --- a/flutter_client/tool/sync_shared.sh +++ /dev/null @@ -1,13 +0,0 @@ -#!/usr/bin/env bash -# Copies shared assets from web/ into flutter_client/. Run before -# `flutter build` and as part of CI. Idempotent. -set -euo pipefail - -cd "$(dirname "$0")/.." - -mkdir -p shared assets/svg -cp ../web/src/lib/styles/tokens.json shared/fabledsword.tokens.json -cp ../web/src/lib/styles/error-copy.json assets/error-copy.json -cp ../web/static/placeholders/album-fallback.svg assets/svg/album-fallback.svg - -echo "shared assets synced from web/" diff --git a/renovate.json b/renovate.json index 3d6ea392..b9663ff3 100644 --- a/renovate.json +++ b/renovate.json @@ -11,8 +11,7 @@ "prConcurrentLimit": 8, "ignorePaths": [ "**/node_modules/**", - "**/vendor/**", - "flutter_client/**" + "**/vendor/**" ], "lockFileMaintenance": { "enabled": true, diff --git a/shared/README.md b/shared/README.md new file mode 100644 index 00000000..e6f862e4 --- /dev/null +++ b/shared/README.md @@ -0,0 +1,16 @@ +# Shared design tokens + +`fabledsword.tokens.json` is the canonical statement of the FabledSword +palette — the dark, light and flat cohorts in one machine-readable place. + +It lived under `flutter_client/shared/` until that client was deleted +(2026-08-16). It was never Flutter-specific: the Android theme +(`FabledSwordTokens.kt`) names it as its source of truth, and the web +client's CSS custom properties carry the same values. It moved here rather +than going with the client, because losing the one place all three cohorts +are written down together would have been collateral damage. + +Nothing generates from it today — both clients hold their own transcription, +so this is the reference they must agree with, not a build input. If the +values and a client ever disagree, this file is what the disagreement is +measured against. diff --git a/flutter_client/shared/fabledsword.tokens.json b/shared/fabledsword.tokens.json similarity index 100% rename from flutter_client/shared/fabledsword.tokens.json rename to shared/fabledsword.tokens.json