chore: delete the Flutter client — #2710
android / Build + lint + test (push) Successful in 4m1s

Superseded by the M8 native Android rewrite. Last touched 2026-05-31,
no workflow has built it since flutter.yml was removed, and rule #22
says a replaced path goes rather than lingering as something a reader
has to work out the status of. 245 files, ~24.6k lines.

Config references go with it: the .gitignore block (and its now-empty
"# Flutter" header), the .dockerignore entry, and renovate's ignorePaths
entry, which was suppressing dependency scanning for a directory that
no longer exists.

ci-requirements.md said ci-flutter "will retire once that directory
goes". It has gone, so the doc now says so -- CI-Runner can drop the
image, and nothing in this repo needs a Flutter toolchain.

One thing is kept rather than deleted: shared/fabledsword.tokens.json.
It lived under flutter_client/shared/ but was never Flutter's property
-- it is the canonical statement of the palette, the only place the
dark, light and flat cohorts are written down together, and
FabledSwordTokens.kt names it as its source of truth. Losing it would
have been collateral damage, so it moves to the repo root with a README
saying what it is and that neither client generates from it. That
comment in FabledSwordTokens.kt is repointed here.

What deliberately does NOT change: `runs-on: flutter-ci` in android.yml
and release.yml. That is a runner LABEL, not a path -- the Android jobs
schedule on it while pulling ci-android:36, per the label/image split
ci-requirements.md documents. Removing it would break scheduling for a
cosmetic win, so the doc now spells that out beside the retirement note.

Left for #2710: 64 files whose comments still name flutter_client/
paths. Sweeping them here would have buried the deletion, and each
needs a judgement -- keep the substance and drop the dead path, delete
pure "ported from" bookkeeping, or leave design rationale that happens
to mention the Flutter build.
This commit is contained in:
2026-08-16 22:32:51 -04:00
parent bfb6c9acfe
commit 0036f534db
251 changed files with 29 additions and 24630 deletions
-1
View File
@@ -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/
-14
View File
@@ -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/
@@ -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
+10 -5
View File
@@ -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
-49
View File
@@ -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
-33
View File
@@ -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'
-81
View File
@@ -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.
-12
View File
@@ -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
-14
View File
@@ -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
@@ -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 = "../.."
}
@@ -1,7 +0,0 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
@@ -1,89 +0,0 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Used in all build flavours (debug / profile / release). -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<!-- Android 13+ requires this to be declared AND prompted at runtime
for the media notification (now-playing tile) to appear. Audio
playback works without it; only the system-tray surface breaks. -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- In-app update flow (#397). First update attempt prompts the
user to enable "Install unknown apps" for Minstrel in
Settings → Apps → Special access. One-time grant; persists. -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<application
android:label="minstrel"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:networkSecurityConfig="@xml/network_security_config">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
<service
android:name="com.ryanheise.audioservice.AudioService"
android:foregroundServiceType="mediaPlayback"
android:exported="true">
<intent-filter>
<action android:name="android.media.browse.MediaBrowserService" />
</intent-filter>
</service>
<receiver
android:name="com.ryanheise.audioservice.MediaButtonReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MEDIA_BUTTON" />
</intent-filter>
</receiver>
<!-- FileProvider for the in-app update flow (#397). Maps the
cache directory (where dio.download writes the APK) to a
content:// URI so the PackageInstaller intent can read it
across the process boundary. file:// URIs are blocked
since Android 7. -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>
@@ -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<String>("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)
}
}
@@ -1,12 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>
@@ -1,11 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFFFF">
<!-- Lucide "heart" (filled) — same path verbatim from lucide-icons/lucide -->
<path
android:fillColor="#FFFFFFFF"
android:pathData="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"/>
</vector>
@@ -1,15 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFFFF">
<!-- Lucide "heart" (outline) — path verbatim from lucide-icons/lucide -->
<path
android:strokeColor="#FFFFFFFF"
android:strokeWidth="2"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:fillColor="#00000000"
android:pathData="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"/>
</vector>
@@ -1,12 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

@@ -1,18 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -1,18 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
FileProvider mappings for the in-app update flow (#397).
- cache-path/. exposes the app's getCacheDir() so the downloaded
APK at <cache>/minstrel-update.apk can be read by the
PackageInstaller via the content:// URI built in MainActivity.kt.
-->
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<cache-path name="updates" path="." />
</paths>
@@ -1,21 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Permits cleartext HTTP to 127.0.0.1 ONLY. Required because just_audio +
audio_service spin up a local HTTP proxy on loopback to inject the
Authorization header (Android's MediaSession API can't pass headers down
to ExoPlayer directly). Without this exemption, ExoPlayer hits Android's
cleartext block when connecting to the local proxy and audio playback
fails with "Cleartext HTTP traffic to 127.0.0.1 not permitted".
All other hosts inherit cleartextTrafficPermitted="false" — actual
remote traffic (to your Minstrel server) must still be HTTPS.
Loopback traffic doesn't leave the device, so this exemption doesn't
reduce security for real network endpoints.
-->
<network-security-config>
<base-config cleartextTrafficPermitted="false" />
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="false">127.0.0.1</domain>
</domain-config>
</network-security-config>
@@ -1,7 +0,0 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
-24
View File
@@ -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<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}
-2
View File
@@ -1,2 +0,0 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
@@ -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
@@ -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")
-48
View File
@@ -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."
}
@@ -1,20 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Album cover fallback. Used by AlbumCard, CompactTrackCard, and the
PlayerBar's onerror handler when an album has no cover_art_path. Colors
are FabledSword tokens hard-coded as hex (data-URL / static SVGs can't
resolve CSS variables): iron background to match the card surface, a
pewter border to give the placeholder a visible edge consistent with
real album art, and an ash-tone beamed-pair glyph centered inside.
Beamed-pair path is from SVG Repo (svgrepo.com), CC0; rescaled and
recolored from the original solid-black 800px source.
-->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" fill="#1E2228"/>
<rect x="6" y="6" width="500" height="500"
fill="none" stroke="#3F4651" stroke-width="10"/>
<g fill="#9C9A92" transform="translate(76.8 76.8) scale(0.7)">
<path d="M503.319,5.939c-5.506-4.705-12.783-6.767-19.958-5.635L169.555,49.852c-12.04,1.901-20.909,12.28-20.909,24.47v99.097 v156.903H99.097C44.455,330.323,0,371.073,0,421.161C0,471.25,44.455,512,99.097,512c54.642,0,99.097-40.75,99.097-90.839v-66.065 V194.588l264.258-41.725v136.169h-49.548c-54.642,0-99.097,40.75-99.097,90.839s44.455,90.839,99.097,90.839 S512,429.959,512,379.871v-66.065V123.871V24.774C512,17.529,508.827,10.646,503.319,5.939z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

-34
View File
@@ -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
@@ -1,24 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>App</string>
<key>CFBundleIdentifier</key>
<string>io.flutter.flutter.app</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>App</string>
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>1.0</string>
</dict>
</plist>
@@ -1 +0,0 @@
#include "Generated.xcconfig"
@@ -1 +0,0 @@
#include "Generated.xcconfig"
@@ -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 = "<group>"; };
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
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 = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
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 = "<group>"; };
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
/* 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 = "<group>";
};
9740EEB11CF90186004384FC /* Flutter */ = {
isa = PBXGroup;
children = (
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
9740EEB21CF90195004384FC /* Debug.xcconfig */,
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
9740EEB31CF90195004384FC /* Generated.xcconfig */,
);
name = Flutter;
sourceTree = "<group>";
};
97C146E51CF9000F007C117D = {
isa = PBXGroup;
children = (
9740EEB11CF90186004384FC /* Flutter */,
97C146F01CF9000F007C117D /* Runner */,
97C146EF1CF9000F007C117D /* Products */,
331C8082294A63A400263BE5 /* RunnerTests */,
);
sourceTree = "<group>";
};
97C146EF1CF9000F007C117D /* Products */ = {
isa = PBXGroup;
children = (
97C146EE1CF9000F007C117D /* Runner.app */,
331C8081294A63A400263BE5 /* RunnerTests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
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 = "<group>";
};
/* 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 = "<group>";
};
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = {
isa = PBXVariantGroup;
children = (
97C147001CF9000F007C117D /* Base */,
);
name = LaunchScreen.storyboard;
sourceTree = "<group>";
};
/* 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 */;
}
@@ -1,7 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>
@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>
@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PreviewsEnabled</key>
<false/>
</dict>
</plist>
@@ -1,101 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1510"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
shouldUseLaunchSchemeArgsEnv = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</MacroExpansion>
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "331C8080294A63A400263BE5"
BuildableName = "RunnerTests.xctest"
BlueprintName = "RunnerTests"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
enableGPUValidationMode = "1"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Profile"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
@@ -1,7 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "group:Runner.xcodeproj">
</FileRef>
</Workspace>
@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>
@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PreviewsEnabled</key>
<false/>
</dict>
</plist>
@@ -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)
}
}
@@ -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"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 295 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 450 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 462 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 704 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 586 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 762 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

@@ -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"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 B

@@ -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.
@@ -1,37 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="12121" systemVersion="16G29" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="12089"/>
</dependencies>
<scenes>
<!--View Controller-->
<scene sceneID="EHf-IW-A2E">
<objects>
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
<layoutGuides>
<viewControllerLayoutGuide type="top" id="Ydg-fD-yQy"/>
<viewControllerLayoutGuide type="bottom" id="xbc-2k-c8Z"/>
</layoutGuides>
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
</imageView>
</subviews>
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<constraints>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/>
</constraints>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="53" y="375"/>
</scene>
</scenes>
<resources>
<image name="LaunchImage" width="168" height="185"/>
</resources>
</document>
@@ -1,26 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="10117" systemVersion="15F34" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" initialViewController="BYZ-38-t0r">
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="10085"/>
</dependencies>
<scenes>
<!--Flutter View Controller-->
<scene sceneID="tne-QT-ifu">
<objects>
<viewController id="BYZ-38-t0r" customClass="FlutterViewController" sceneMemberID="viewController">
<layoutGuides>
<viewControllerLayoutGuide type="top" id="y3c-jy-aDJ"/>
<viewControllerLayoutGuide type="bottom" id="wfy-db-euE"/>
</layoutGuides>
<view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC">
<rect key="frame" x="0.0" y="0.0" width="600" height="600"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="calibratedWhite"/>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
</objects>
</scene>
</scenes>
</document>
-74
View File
@@ -1,74 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Minstrel</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>minstrel</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
<key>UISceneConfigurations</key>
<dict>
<key>UIWindowSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneClassName</key>
<string>UIWindowScene</string>
<key>UISceneConfigurationName</key>
<string>flutter</string>
<key>UISceneDelegateClassName</key>
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
<key>UISceneStoryboardFile</key>
<string>Main</string>
</dict>
</array>
</dict>
</dict>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>
@@ -1 +0,0 @@
#import "GeneratedPluginRegistrant.h"
@@ -1,6 +0,0 @@
import Flutter
import UIKit
class SceneDelegate: FlutterSceneDelegate {
}
@@ -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.
}
}
@@ -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<FabledSwordTheme>()!;
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'),
),
],
),
),
),
),
);
}
}
@@ -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<AdminRequestsApi>((ref) async {
return AdminRequestsApi(await ref.watch(dioProvider.future));
});
final adminQuarantineApiProvider =
FutureProvider<AdminQuarantineApi>((ref) async {
return AdminQuarantineApi(await ref.watch(dioProvider.future));
});
final adminUsersApiProvider = FutureProvider<AdminUsersApi>((ref) async {
return AdminUsersApi(await ref.watch(dioProvider.future));
});
final adminInvitesApiProvider = FutureProvider<AdminInvitesApi>((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<AdminCounts>((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<List<AdminRequest>> {
@override
Future<List<AdminRequest>> build() async {
final api = await ref.watch(adminRequestsApiProvider.future);
return api.list();
}
Future<void> approve(String id) async {
await _decide(id, (api) => api.approve(id));
}
Future<void> reject(String id) async {
await _decide(id, (api) => api.reject(id));
}
Future<void> _decide(
String id,
Future<void> Function(AdminRequestsApi api) action,
) async {
final api = await ref.read(adminRequestsApiProvider.future);
final current = state.value ?? const <AdminRequest>[];
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, List<AdminRequest>>(
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<List<AdminUser>> {
@override
Future<List<AdminUser>> 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<void> setAdmin(String id, bool isAdmin) =>
_patch(id, (u) => _copy(u, isAdmin: isAdmin),
(api) => api.setAdmin(id, isAdmin));
Future<void> setAutoApprove(String id, bool autoApprove) =>
_patch(id, (u) => _copy(u, autoApproveRequests: autoApprove),
(api) => api.setAutoApprove(id, autoApprove));
Future<void> delete(String id) async {
final api = await ref.read(adminUsersApiProvider.future);
final current = state.value ?? const <AdminUser>[];
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<void> resetPassword(String id, String newPassword) async {
final api = await ref.read(adminUsersApiProvider.future);
await api.resetPassword(id, newPassword);
}
Future<void> _patch(
String id,
AdminUser Function(AdminUser) mutate,
Future<void> Function(AdminUsersApi api) action,
) async {
final api = await ref.read(adminUsersApiProvider.future);
final current = state.value ?? const <AdminUser>[];
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, List<AdminUser>>(
AdminUsersController.new);
class AdminQuarantineController
extends AsyncNotifier<List<AdminQuarantineItem>> {
@override
Future<List<AdminQuarantineItem>> build() async {
final api = await ref.watch(adminQuarantineApiProvider.future);
return api.list();
}
Future<void> resolve(String trackId) =>
_act(trackId, (api) => api.resolve(trackId));
Future<void> deleteFile(String trackId) =>
_act(trackId, (api) => api.deleteFile(trackId));
Future<void> deleteViaLidarr(String trackId) =>
_act(trackId, (api) => api.deleteViaLidarr(trackId));
Future<void> _act(
String trackId,
Future<void> Function(AdminQuarantineApi api) action,
) async {
final api = await ref.read(adminQuarantineApiProvider.future);
final current = state.value ?? const <AdminQuarantineItem>[];
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,
List<AdminQuarantineItem>>(AdminQuarantineController.new);
class AdminInvitesController extends AsyncNotifier<List<Invite>> {
@override
Future<List<Invite>> 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<Invite> create({String? note}) async {
final api = await ref.read(adminInvitesApiProvider.future);
final invite = await api.create(note: note);
final current = state.value ?? const <Invite>[];
state = AsyncData([invite, ...current]);
return invite;
}
Future<void> revoke(String token) async {
final api = await ref.read(adminInvitesApiProvider.future);
final current = state.value ?? const <Invite>[];
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, List<Invite>>(
AdminInvitesController.new);
@@ -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<FabledSwordTheme>()!;
// #402 wire-up: any quarantine event (flag from a user / admin
// resolve / file delete / lidarr delete) refreshes the admin queue.
ref.listen<AsyncValue<LiveEvent>>(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),
),
),
);
},
),
),
);
}
}
@@ -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<FabledSwordTheme>()!;
// #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<AsyncValue<LiveEvent>>(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 <AdminUser>[];
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),
);
},
),
);
},
),
),
);
}
}
@@ -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<FabledSwordTheme>()!;
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<void> _showGenerateInvite(BuildContext context, WidgetRef ref) async {
final controller = TextEditingController();
final note = await showDialog<String?>(
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<void>(
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<FabledSwordTheme>()!;
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!,
],
),
);
}
}
@@ -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<FabledSwordTheme>()!;
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<String>(
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,
),
],
);
}
}
@@ -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<FabledSwordTheme>()!;
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<bool>(
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'),
),
],
),
);
}
}
@@ -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<FabledSwordTheme>()!;
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,
),
);
}
}
@@ -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<void> show(BuildContext context, AdminUser user) =>
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
builder: (_) => AdminUserEditSheet(user: user),
);
@override
Widget build(BuildContext context, WidgetRef ref) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
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<void> _resetPassword(
BuildContext context, AdminUsersController users) async {
final controller = TextEditingController();
final newPw = await showDialog<String>(
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<void> _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')));
}
}
}
}
@@ -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<FabledSwordTheme>()!;
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<FabledSwordTheme>()!;
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),
),
);
}
}
@@ -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<FabledSwordTheme>()!;
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,
),
);
}
}
@@ -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<bool> show(
BuildContext context, {
required String title,
required String message,
String confirmWord = 'DELETE',
String confirmLabel = 'Delete',
}) async {
final ok = await showModalBottomSheet<bool>(
context: context,
isScrollControlled: true,
builder: (_) => TypedConfirmSheet(
title: title,
message: message,
confirmWord: confirmWord,
confirmLabel: confirmLabel,
),
);
return ok ?? false;
}
@override
State<TypedConfirmSheet> createState() => _TypedConfirmSheetState();
}
class _TypedConfirmSheetState extends State<TypedConfirmSheet> {
final _controller = TextEditingController();
bool _enabled = false;
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
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),
),
],
),
],
),
),
);
}
}
-41
View File
@@ -1,41 +0,0 @@
import 'package:dio/dio.dart';
typedef TokenResolver = Future<String?> Function();
typedef OnUnauthenticated = Future<void> 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;
}
}
@@ -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<Invite>> list() async {
final r = await _dio.get<Map<String, dynamic>>('/api/admin/invites');
final raw = (r.data?['invites'] as List?) ?? const [];
return raw
.map((e) => Invite.fromJson((e as Map).cast<String, dynamic>()))
.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<Invite> create({String? note}) async {
final r = await _dio.post<Map<String, dynamic>>(
'/api/admin/invites',
data: {if (note != null && note.isNotEmpty) 'note': note},
);
return Invite.fromJson(r.data ?? const {});
}
Future<void> revoke(String token) async {
await _dio.delete<void>('/api/admin/invites/$token');
}
}
@@ -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<AdminQuarantineItem>> list() async {
final r = await _dio.get<List<dynamic>>('/api/admin/quarantine');
final raw = r.data ?? const [];
return raw
.map((e) =>
AdminQuarantineItem.fromJson((e as Map).cast<String, dynamic>()))
.toList(growable: false);
}
Future<void> resolve(String trackId) async {
await _dio.post<void>('/api/admin/quarantine/$trackId/resolve');
}
Future<void> deleteFile(String trackId) async {
await _dio.post<void>('/api/admin/quarantine/$trackId/delete-file');
}
Future<void> deleteViaLidarr(String trackId) async {
await _dio.post<void>('/api/admin/quarantine/$trackId/delete-via-lidarr');
}
}
@@ -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<AdminRequest>> list() async {
final r = await _dio.get<List<dynamic>>('/api/admin/requests');
final raw = r.data ?? const [];
return raw
.map((e) => AdminRequest.fromJson((e as Map).cast<String, dynamic>()))
.toList(growable: false);
}
Future<void> approve(String id) async {
await _dio.post<void>('/api/admin/requests/$id/approve');
}
Future<void> reject(String id) async {
await _dio.post<void>('/api/admin/requests/$id/reject');
}
}
@@ -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<AdminUser>> list() async {
final r = await _dio.get<Map<String, dynamic>>('/api/admin/users');
final raw = (r.data?['users'] as List?) ?? const [];
return raw
.map((e) => AdminUser.fromJson((e as Map).cast<String, dynamic>()))
.toList(growable: false);
}
Future<void> setAdmin(String id, bool isAdmin) async {
await _dio.put<void>(
'/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<void> setAutoApprove(String id, bool autoApprove) async {
await _dio.put<void>(
'/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<void> resetPassword(String id, String newPassword) async {
await _dio.post<void>(
'/api/admin/users/$id/reset-password',
data: {'password': newPassword},
);
}
Future<void> delete(String id) async {
await _dio.delete<void>('/api/admin/users/$id');
}
}
@@ -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<Map<String, dynamic>>(
'/api/auth/login',
data: {'username': username, 'password': password},
);
final body = r.data!;
final userMap = (body['user'] as Map).cast<String, dynamic>();
return (
token: body['token'] as String,
user: User.fromJson(userMap),
rawUserJson: jsonEncode(userMap),
);
}
Future<void> logout() async {
await _dio.post<void>('/api/auth/logout');
}
}
@@ -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<List<ArtistSuggestion>> listSuggestions() async {
final r = await _dio.get<List<dynamic>>('/api/discover/suggestions');
final raw = r.data ?? const [];
return raw
.map((e) =>
ArtistSuggestion.fromJson((e as Map).cast<String, dynamic>()))
.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<List<LidarrSearchResult>> search(
String query,
LidarrRequestKind kind,
) async {
final r = await _dio.get<List<dynamic>>(
'/api/lidarr/search',
queryParameters: {'q': query, 'kind': kind.wire},
);
final raw = r.data ?? const [];
return raw
.map((e) =>
LidarrSearchResult.fromJson((e as Map).cast<String, dynamic>()))
.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<void> createRequest({
required LidarrRequestKind kind,
required String artistMbid,
required String artistName,
String? albumMbid,
String? albumTitle,
String? trackMbid,
String? trackTitle,
}) async {
final body = <String, dynamic>{
'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<Map<String, dynamic>>('/api/requests', data: body);
}
}
@@ -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<String?> playStarted({
required String trackId,
required String clientId,
String? source,
}) async {
final r = await _dio.post<Map<String, dynamic>>(
'/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<void> playEnded({
required String playEventId,
required int durationPlayedMs,
}) async {
await _dio.post<void>(
'/api/events',
data: {
'type': 'play_ended',
'play_event_id': playEventId,
'duration_played_ms': durationPlayedMs,
},
);
}
Future<void> playSkipped({
required String playEventId,
required int positionMs,
}) async {
await _dio.post<void>(
'/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<void> playOffline({
required String trackId,
required String clientId,
required String atIso,
required int durationPlayedMs,
String? source,
}) async {
await _dio.post<void>(
'/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,
},
);
}
}
@@ -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<Map<String, String>> check() async {
final r = await _dio.get<Map<String, dynamic>>('/healthz');
return (r.data ?? const <String, dynamic>{})
.map((k, v) => MapEntry(k, v.toString()));
}
}
@@ -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<HomeData> getHome() async {
final r = await _dio.get<Map<String, dynamic>>('/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<HomeIndex> getHomeIndex() async {
final r = await _dio.get<Map<String, dynamic>>('/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<TrackRef> getTrack(String id) async {
final r = await _dio.get<Map<String, dynamic>>('/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<ArtistRef> getArtist(String id) async {
final r = await _dio.get<Map<String, dynamic>>('/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<AlbumRef> albums})> getArtistDetail(
String id) async {
final r = await _dio.get<Map<String, dynamic>>('/api/artists/$id');
final body = r.data ?? const <String, dynamic>{};
final artist = ArtistRef.fromJson(body);
final albums = ((body['albums'] as List?) ?? const [])
.map((e) => AlbumRef.fromJson((e as Map).cast<String, dynamic>()))
.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<List<AlbumRef>> getArtistAlbums(String id) async {
final r = await _dio.get<Map<String, dynamic>>('/api/artists/$id');
final raw = (r.data?['albums'] as List?) ?? const [];
return raw
.map((e) => AlbumRef.fromJson((e as Map).cast<String, dynamic>()))
.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<List<TrackRef>> shuffle({int limit = 100}) async {
final r = await _dio.get<List<dynamic>>(
'/api/library/shuffle',
queryParameters: {'limit': limit},
);
final raw = r.data ?? const [];
return raw
.map((e) => TrackRef.fromJson((e as Map).cast<String, dynamic>()))
.toList(growable: false);
}
/// GET /api/artists/{id}/tracks. Server emits a bare JSON array, so we
/// type the response as `List<dynamic>` rather than a Map envelope.
Future<List<TrackRef>> getArtistTracks(String id) async {
final r = await _dio.get<List<dynamic>>('/api/artists/$id/tracks');
final raw = r.data ?? const [];
return raw
.map((e) => TrackRef.fromJson((e as Map).cast<String, dynamic>()))
.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<TrackRef> tracks})> getAlbum(String id) async {
final r = await _dio.get<Map<String, dynamic>>('/api/albums/$id');
final body = r.data ?? const <String, dynamic>{};
final tracks = ((body['tracks'] as List?) ?? const [])
.map((e) => TrackRef.fromJson((e as Map).cast<String, dynamic>()))
.toList(growable: false);
return (album: AlbumRef.fromJson(body), tracks: tracks);
}
}
@@ -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<Paged<ArtistRef>> 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<Map<String, dynamic>>(
'/api/artists',
queryParameters: {
'limit': limit,
'offset': offset,
'sort': 'alpha',
},
);
return Paged.fromJson(r.data ?? const {}, ArtistRef.fromJson);
}
Future<Paged<AlbumRef>> listAlbums({int limit = 50, int offset = 0}) async {
final r = await _dio.get<Map<String, dynamic>>(
'/api/library/albums',
queryParameters: {'limit': limit, 'offset': offset},
);
return Paged.fromJson(r.data ?? const {}, AlbumRef.fromJson);
}
}
@@ -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<void> like(LikeKind kind, String id) async {
await _dio.post<void>('/api/likes/${kind.path}/$id');
}
Future<void> unlike(LikeKind kind, String id) async {
await _dio.delete<void>('/api/likes/${kind.path}/$id');
}
/// GET /api/likes/tracks?limit=N&offset=N. Paged.
Future<Paged<TrackRef>> listTracks({int limit = 50, int offset = 0}) async {
final r = await _dio.get<Map<String, dynamic>>(
'/api/likes/tracks',
queryParameters: {'limit': limit, 'offset': offset},
);
return Paged.fromJson(r.data ?? const {}, TrackRef.fromJson);
}
Future<Paged<AlbumRef>> listAlbums({int limit = 50, int offset = 0}) async {
final r = await _dio.get<Map<String, dynamic>>(
'/api/likes/albums',
queryParameters: {'limit': limit, 'offset': offset},
);
return Paged.fromJson(r.data ?? const {}, AlbumRef.fromJson);
}
Future<Paged<ArtistRef>> listArtists({int limit = 50, int offset = 0}) async {
final r = await _dio.get<Map<String, dynamic>>(
'/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<String> artists, Set<String> albums, Set<String> tracks})>
ids() async {
final r = await _dio.get<Map<String, dynamic>>('/api/likes/ids');
final body = r.data ?? const <String, dynamic>{};
Set<String> 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'),
);
}
}
-49
View File
@@ -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<T>` envelope.
Future<HistoryPage> history({int limit = 50, int offset = 0}) async {
final r = await _dio.get<Map<String, dynamic>>(
'/api/me/history',
queryParameters: {'limit': limit, 'offset': offset},
);
return HistoryPage.fromJson(r.data ?? const {});
}
/// GET /api/quarantine/mine — flat list (no envelope).
Future<List<QuarantineMineRow>> quarantineMine() async {
final r = await _dio.get<List<dynamic>>('/api/quarantine/mine');
final raw = r.data ?? const [];
return raw
.map((e) =>
QuarantineMineRow.fromJson((e as Map).cast<String, dynamic>()))
.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> systemPlaylistsStatus() async {
final r = await _dio.get<Map<String, dynamic>>(
'/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<void> putTimezone(String timezone) async {
await _dio.put<void>('/api/me/timezone', data: {'timezone': timezone});
}
}
@@ -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<Playlist> owned;
final List<Playlist> public;
factory PlaylistsList.empty() =>
const PlaylistsList(owned: [], public: []);
/// Concatenated view for callers that don't care about the split.
List<Playlist> 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<PlaylistsList> list({String kind = 'user'}) async {
final r = await _dio.get<Map<String, dynamic>>(
'/api/playlists',
queryParameters: {'kind': kind},
);
final body = r.data ?? const <String, dynamic>{};
List<Playlist> parse(String key) {
final raw = (body[key] as List?) ?? const [];
return raw
.map((e) => Playlist.fromJson((e as Map).cast<String, dynamic>()))
.toList(growable: false);
}
return PlaylistsList(owned: parse('owned'), public: parse('public'));
}
Future<PlaylistDetail> get(String id) async {
final r = await _dio.get<Map<String, dynamic>>('/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<PlaylistDetail> systemShuffle(String variant) async {
final r = await _dio.get<Map<String, dynamic>>(
'/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<void> appendTracks(String playlistId, List<String> trackIds) async {
await _dio.post<void>(
'/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<String?> refreshSystem(String variant) async {
final r = await _dio.post<Map<String, dynamic>>(
'/api/playlists/system/$variant/refresh',
data: const <String, dynamic>{},
);
return (r.data ?? const {})['playlist_id'] as String?;
}
}
@@ -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<void> flag(String trackId, String reason, {String notes = ''}) async {
await _dio.post<void>('/api/quarantine', data: {
'track_id': trackId,
'reason': reason,
'notes': notes,
});
}
/// DELETE /api/quarantine/{track_id}. Server returns 204.
Future<void> unflag(String trackId) async {
await _dio.delete<void>('/api/quarantine/$trackId');
}
}

Some files were not shown because too many files have changed in this diff Show More