Missing-file lifecycle end to end, UPnP stall recovery, Android browse parity, Flutter client removed #126
@@ -9,7 +9,6 @@ web/build
|
|||||||
# Flutter mobile client — built separately on developer machines / Flutter CI.
|
# Flutter mobile client — built separately on developer machines / Flutter CI.
|
||||||
# Including it in the Go build context wastes ~70 files and invalidates the
|
# Including it in the Go build context wastes ~70 files and invalidates the
|
||||||
# `COPY . .` layer cache on every Flutter-only change.
|
# `COPY . .` layer cache on every Flutter-only change.
|
||||||
flutter_client/
|
|
||||||
|
|
||||||
# Docs and IDE noise
|
# Docs and IDE noise
|
||||||
docs/
|
docs/
|
||||||
|
|||||||
@@ -52,20 +52,6 @@ GEMINI.md
|
|||||||
.windsurfrules
|
.windsurfrules
|
||||||
.aider.conf.yml
|
.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
|
# Native Android (Kotlin/Compose) — M8 rewrite
|
||||||
android/.gradle/
|
android/.gradle/
|
||||||
android/.kotlin/
|
android/.kotlin/
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import androidx.compose.ui.unit.dp
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Raw color values for the dark surface cohort. Source of truth:
|
* 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
|
* Token names are **semantic roles**, not literal colors. In dark
|
||||||
* mode, `parchment` is light text on dark surfaces (#E8E4D8); in
|
* mode, `parchment` is light text on dark surfaces (#E8E4D8); in
|
||||||
|
|||||||
@@ -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-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-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
|
**`ci-flutter` is no longer consumed, and `ci-flutter` can now be retired.**
|
||||||
client with the native Android app and `flutter.yml` was removed; `ci-android`
|
The M8 rewrite replaced the Flutter client with the native Android app and
|
||||||
took its place. `flutter_client/` is still in the tree but nothing builds it.
|
`flutter.yml` was removed; `ci-android` took its place. `flutter_client/`
|
||||||
CI-Runner still publishes `ci-flutter` and will retire it once that directory
|
itself was deleted on 2026-08-16, which was the condition CI-Runner was
|
||||||
goes — so if the Flutter client is ever revived, say so there first.
|
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
|
## Image deps used
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|
||||||
@@ -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'
|
|
||||||
@@ -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.
|
|
||||||
@@ -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
|
|
||||||
@@ -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>
|
|
||||||
|
Before Width: | Height: | Size: 544 B |
|
Before Width: | Height: | Size: 442 B |
|
Before Width: | Height: | Size: 721 B |
|
Before Width: | Height: | Size: 1.0 KiB |
|
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>
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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")
|
|
||||||
@@ -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 |
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 295 B |
|
Before Width: | Height: | Size: 406 B |
|
Before Width: | Height: | Size: 450 B |
|
Before Width: | Height: | Size: 282 B |
|
Before Width: | Height: | Size: 462 B |
|
Before Width: | Height: | Size: 704 B |
|
Before Width: | Height: | Size: 406 B |
|
Before Width: | Height: | Size: 586 B |
|
Before Width: | Height: | Size: 862 B |
|
Before Width: | Height: | Size: 862 B |
|
Before Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 762 B |
|
Before Width: | Height: | Size: 1.2 KiB |
|
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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 68 B |
|
Before Width: | Height: | Size: 68 B |
|
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>
|
|
||||||
@@ -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),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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'),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||