commit 178d2d00ab250a0eb3cf1ad813c7b2253a01c739 Author: metatroncubeswdev Date: Fri Oct 30 10:39:26 2026 -0400 Nanga Kathai: Flutter app, self-hosted backend and song tools - Flutter app (Android/iOS): home, explore, albums, search, player with equalizer, mini player, sign up/sign in with email codes, profile, encrypted offline downloads, About page - server/: one-command deploy of self-hosted Supabase behind HestiaCP/nginx (or Caddy), migrations, backups, email templates - supabase/migrations: schema, row-level security, profiles - tools/: song scanner and importer Co-Authored-By: Claude Opus 5.5 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..3aa730b --- /dev/null +++ b/.gitattributes @@ -0,0 +1,7 @@ +* text=auto eol=lf +*.sh text eol=lf +server/** text eol=lf +supabase/** text eol=lf +*.jpg binary +*.png binary +*.wav binary diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d3e45bb --- /dev/null +++ b/.gitignore @@ -0,0 +1,55 @@ +# 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 + +# Widget Preview related +.widget_preview/ + +# Local secrets for --dart-define-from-file +env.json + +# Song collection and import manifest (large, private) +/songs/ +/songs_manifest.csv diff --git a/.metadata b/.metadata new file mode 100644 index 0000000..235886b --- /dev/null +++ b/.metadata @@ -0,0 +1,33 @@ +# 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: "6a19cca56475dbfba1478ee68d7bd0c2ef891da1" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 6a19cca56475dbfba1478ee68d7bd0c2ef891da1 + base_revision: 6a19cca56475dbfba1478ee68d7bd0c2ef891da1 + - platform: android + create_revision: 6a19cca56475dbfba1478ee68d7bd0c2ef891da1 + base_revision: 6a19cca56475dbfba1478ee68d7bd0c2ef891da1 + - platform: ios + create_revision: 6a19cca56475dbfba1478ee68d7bd0c2ef891da1 + base_revision: 6a19cca56475dbfba1478ee68d7bd0c2ef891da1 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/Nanga Kathai Heritage Music Emblem.png b/Nanga Kathai Heritage Music Emblem.png new file mode 100644 index 0000000..de761bb Binary files /dev/null and b/Nanga Kathai Heritage Music Emblem.png differ diff --git a/README.md b/README.md new file mode 100644 index 0000000..926527c --- /dev/null +++ b/README.md @@ -0,0 +1,50 @@ +# Nanga Kathai (NK) + +*Our song*: a music app for the Badaga people of the Nilgiris. Flutter app for iOS and Android. + +- Plan: [docs/NK_IMPLEMENTATION_PLAN.md](docs/NK_IMPLEMENTATION_PLAN.md) +- Design canvas: https://claude.ai/artifact/9ZLCbGn4rUTVTqfRXTtMkq + +## Run + +```sh +flutter pub get +flutter run # bundled demo catalog (3 demo tones), no backend needed +``` + +With Supabase, put the keys in `env.json` (git-ignored): + +```json +{ + "SUPABASE_URL": "https://.supabase.co", + "SUPABASE_PUBLISHABLE_KEY": "", + "MEDIA_BASE_URL": "https://" +} +``` + +```sh +flutter run --dart-define-from-file=env.json +``` + +## Layout + +| Path | What | +|---|---| +| `lib/app/` | Theme (colours from the emblem), routes, onboarding flag | +| `lib/audio/` | `NkAudioHandler` (just_audio + audio_service: background play, lock screen, queue, shuffle, repeat) and its providers | +| `lib/data/` | Catalog repository (demo JSON or Supabase), liked songs and recently played | +| `lib/core/` | Models, occasions, spelling-tolerant search | +| `lib/features/` | Screens: welcome, sign-in, home, explore, occasion, search, now playing, singer, library, downloads | +| `lib/shared/widgets/` | Artwork and photo fade, singer avatar, track row, mini player, tab shell | +| `assets/` | Emblem, Nilgiris photos, demo catalog and demo tones | +| `supabase/migrations/` | Database schema and access rules | + +## Supabase setup + +1. Create a project and run `supabase/migrations/20261027000000_init.sql` (SQL editor, or `supabase db push`). +2. Auth → Email: turn on email sign-in. In the "Magic Link" email template, include `{{ .Token }}` so people receive the 6-digit code. +3. Make yourself admin: `update profiles set role = 'admin' where id = '';` + +## Photo credits + +Landscape photos from Unsplash (Unsplash License): Vaisakh Satheesan (tea estate), Ravi Raj (Ooty village), Harshal More (Ooty valley). diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..0da40e9 --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,34 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +analyzer: + exclude: + - build/** + - android/** + - ios/** + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..be3943c --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,14 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java +.cxx/ + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..bd49c2f --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,49 @@ +plugins { + id("com.android.application") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +android { + namespace = "com.metatroncubesolutions.nanga_kathai" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "com.metatroncubesolutions.nanga_kathai" + // 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 + // Uses the version code from pubspec.yaml. When using split APKs, 1000 * ABI_VERSION + // is added automatically by Flutter. (https://developer.android.com/studio/build/configure-apk-splits#configure-APK-versions) + // You can force using the value of versionCode by specifying the `-P force-version-code-ignoring-abi=true` + // flag during build. + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig = signingConfigs.getByName("debug") + } + } +} + +kotlin { + compilerOptions { + jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + } +} + +flutter { + source = "../.." +} diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..676d363 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/kotlin/com/metatroncubesolutions/nanga_kathai/MainActivity.kt b/android/app/src/main/kotlin/com/metatroncubesolutions/nanga_kathai/MainActivity.kt new file mode 100644 index 0000000..267d37a --- /dev/null +++ b/android/app/src/main/kotlin/com/metatroncubesolutions/nanga_kathai/MainActivity.kt @@ -0,0 +1,5 @@ +package com.metatroncubesolutions.nanga_kathai + +import com.ryanheise.audioservice.AudioServiceActivity + +class MainActivity : AudioServiceActivity() diff --git a/android/app/src/main/res/drawable-v21/launch_background.xml b/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..db77bb4 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..17987b7 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..09d4391 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..d5f1c8d Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..4d6372e Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000..dbee657 --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,24 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = + rootProject.layout.buildDirectory + .dir("../../build") + .get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..8ce48b3 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,9 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true +# This newDsl flag was added by the Flutter template +android.newDsl=false +# This builtInKotlin flag was added by the Flutter template +android.builtInKotlin=false + +# Project (D:) and pub cache (C:) are on different drives; Kotlin incremental caches fail across roots. +kotlin.incremental=false diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..a20f2c4 --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-all.zip diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 0000000..b28021a --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1,26 @@ +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 "9.1.0" apply false + id("org.jetbrains.kotlin.android") version "2.4.0" apply false +} + +include(":app") diff --git a/assets/brand/nk_app_icon.svg b/assets/brand/nk_app_icon.svg new file mode 100644 index 0000000..00e1bd4 --- /dev/null +++ b/assets/brand/nk_app_icon.svg @@ -0,0 +1,9 @@ + + + + + + + + NK + diff --git a/assets/catalog/demo_catalog.json b/assets/catalog/demo_catalog.json new file mode 100644 index 0000000..f94c7ce --- /dev/null +++ b/assets/catalog/demo_catalog.json @@ -0,0 +1,15 @@ +{ + "_note": "Demo catalog used until Supabase is connected. Replace the demo tones with real songs from the legacy collection.", + "artists": [ + { "id": "a1", "name": "Demo Singer A", "hatti": null, "photoUrl": null, "bio": null }, + { "id": "a2", "name": "Demo Singer B", "hatti": null, "photoUrl": null, "bio": null }, + { "id": "a3", "name": "Demo Singer C", "hatti": null, "photoUrl": null, "bio": null } + ], + "tracks": [ + { "id": "t1", "titleLatin": "Demo Song One", "titleTamil": null, "meaning": "A demo tone for testing playback", "artistIds": ["a1"], "occasion": "folk", "durationMs": 18000, "audioUrl": "asset:///assets/demo_audio/demo_1.wav", "coverAsset": "assets/images/nilgiris_village.jpg", "tier": "free" }, + { "id": "t2", "titleLatin": "Demo Song Two", "titleTamil": null, "meaning": "A demo tone for testing playback", "artistIds": ["a2"], "occasion": "madhuvae", "durationMs": 18000, "audioUrl": "asset:///assets/demo_audio/demo_2.wav", "coverAsset": null, "tier": "free" }, + { "id": "t3", "titleLatin": "Demo Song Three", "titleTamil": null, "meaning": "A demo tone for testing playback", "artistIds": ["a3", "a1"], "occasion": "devotional", "durationMs": 18000, "audioUrl": "asset:///assets/demo_audio/demo_3.wav", "coverAsset": "assets/images/nilgiris_tea.jpg", "tier": "free" }, + { "id": "t4", "titleLatin": "Demo Habba Song", "titleTamil": null, "meaning": "A demo tone for testing playback", "artistIds": ["a2"], "occasion": "habba", "durationMs": 18000, "audioUrl": "asset:///assets/demo_audio/demo_1.wav", "coverAsset": "assets/images/nilgiris_valley.jpg", "tier": "free" }, + { "id": "t5", "titleLatin": "Demo Huttu Song", "titleTamil": null, "meaning": "A demo tone for testing playback", "artistIds": ["a3"], "occasion": "huttu", "durationMs": 18000, "audioUrl": "asset:///assets/demo_audio/demo_2.wav", "coverAsset": null, "tier": "free" } + ] +} diff --git a/assets/demo_audio/demo_1.wav b/assets/demo_audio/demo_1.wav new file mode 100644 index 0000000..7e16a8f Binary files /dev/null and b/assets/demo_audio/demo_1.wav differ diff --git a/assets/demo_audio/demo_2.wav b/assets/demo_audio/demo_2.wav new file mode 100644 index 0000000..0e14f23 Binary files /dev/null and b/assets/demo_audio/demo_2.wav differ diff --git a/assets/demo_audio/demo_3.wav b/assets/demo_audio/demo_3.wav new file mode 100644 index 0000000..7e4c63c Binary files /dev/null and b/assets/demo_audio/demo_3.wav differ diff --git a/assets/images/emblem.png b/assets/images/emblem.png new file mode 100644 index 0000000..eb3c360 Binary files /dev/null and b/assets/images/emblem.png differ diff --git a/assets/images/nilgiris_tea.jpg b/assets/images/nilgiris_tea.jpg new file mode 100644 index 0000000..1e1df2f Binary files /dev/null and b/assets/images/nilgiris_tea.jpg differ diff --git a/assets/images/nilgiris_valley.jpg b/assets/images/nilgiris_valley.jpg new file mode 100644 index 0000000..a95f183 Binary files /dev/null and b/assets/images/nilgiris_valley.jpg differ diff --git a/assets/images/nilgiris_village.jpg b/assets/images/nilgiris_village.jpg new file mode 100644 index 0000000..d9d43a3 Binary files /dev/null and b/assets/images/nilgiris_village.jpg differ diff --git a/docs/NK_IMPLEMENTATION_PLAN.md b/docs/NK_IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000..ab6ea32 --- /dev/null +++ b/docs/NK_IMPLEMENTATION_PLAN.md @@ -0,0 +1,286 @@ +# Nanga Kathai (NK) — Implementation Plan + +*"Nanga Kathai" = "Our Song" in Badaga. A modern music app for the Badaga community of the Nilgiris, Tamil Nadu.* + +Last updated: 2026-10-27 + +--- + +## 1. Why this app, and what makes it different + +**The gap** +- No Badaga music app exists on iOS. The one or two on Android are low quality. +- The largest existing archive, ibaduga.com, has 1,000+ Badaga songs, but it's a website and a Mixcloud page, not a native app. +- Badaga has no Unicode script of its own. People write it in Tamil script (most common), in Kannada script, or in Latin transliteration. The Badugu script has been proposed to Unicode but isn't encoded yet. So search and lyrics must handle **all three writing systems**. + +**Positioning:** Spotify/Amazon-level playback and UX, plus features no global app offers: +1. **Occasions:** Badaga songs are tied to life events: *huttu* (birth), *habba* (festivals, e.g. Hethai Habba), *madhuvae* (marriage), *saavu* (funerals), devotional. Browse by occasion is a top-level feature. +2. **Hatti (village) identity:** singers and songs tagged by village, so people can browse "songs from my hatti". +3. **Trilingual lyrics:** Tamil script, Latin transliteration, and English meaning, side by side and synced. +4. **Diaspora:** Badagas in Bengaluru, Chennai, the Gulf, the US and the UK reach the culture through this app. That means offline use, sharing and festival reminders. +5. **Low bandwidth:** the Nilgiris hills have patchy 4G, so offline-first design and a data-saver mode are essential. + +--- + +## 2. Feature research: what the leading apps do, and what NK adopts + +| Capability | Spotify / Amazon Music / Apple Music / YT Music | NK phase | +|---|---|---| +| Background playback, lock screen, notification controls | All | **MVP** | +| Mini player plus full-screen Now Playing with swipe gestures | All | **MVP** | +| Queue (play next, add to queue, reorder by drag) | All | **MVP** | +| Shuffle and repeat (Spotify's 2026 "fewer repeats" shuffle) | Spotify | **MVP** (smart shuffle that avoids recent repeats) | +| Gapless playback and crossfade | Spotify, Apple | **MVP** gapless, v1.1 crossfade | +| Loudness normalization | All | **MVP** (normalized at ingest to −14 LUFS) | +| Search with typo tolerance and instant results | All | **MVP** (trilingual + transliteration-aware) | +| Library: liked songs, playlists, followed artists | All | **MVP** | +| Offline downloads | All (premium) | **MVP** (free; key need in the hills) | +| Data-saver / quality setting | Spotify, Amazon | **MVP** | +| Synced lyrics, karaoke-style highlighting | Spotify, Apple, Amazon | **v1.1** (static lyrics in MVP) | +| Lyrics translation (Spotify 2026) | Spotify | **v1.1** (Badaga → English) | +| Share card to WhatsApp / Instagram stories, deep links | All | **MVP** (WhatsApp first; it's the community's main channel) | +| Sleep timer | Spotify, Amazon | **MVP** | +| Recently played, Continue listening | All | **MVP** | +| Home sections: new releases, trending, editorial picks | All | **MVP** (curated by admins) | +| Personalized mixes ("Daily Mix", "Made for you") | Spotify | **v1.2** (rule-based first, ML later) | +| Radio / auto-play similar songs when the queue ends | All | **v1.1** | +| Mood/prompt playlists (Spotify Prompted Playlists, Alexa+ DJ) | Spotify, Amazon | **v2** (an LLM over NK's tags) | +| Year-end Wrapped / Replay | Spotify, Apple | **v1.2** ("NK Varusha", a yearly recap) | +| Collaborative playlists | Spotify | **v1.2** (wedding playlists) | +| Listening activity / friends | Spotify 2026 | **v2** | +| Equalizer | Spotify | **v1.1** (Android native EQ; iOS limited) | +| Chromecast, AirPlay | All | **v1.1** (AirPlay comes free on iOS) | +| Android Auto, CarPlay | All | **v2** | +| Home-screen widgets, Live Activities | Apple, Spotify | **v2** | +| Podcasts / stories | Spotify, Amazon | **v2** (Badaga folk tales, oral history) | +| Artist profiles with bio and verified badge | All | **MVP** (bio), **v1.1** (artist claims profile) | +| Artist portal and analytics (Spotify for Artists) | Spotify | **v1.1** (upload with approval workflow, §7) | +| Accessibility: screen reader, dynamic type, contrast | All | **MVP** | + +**NK-only features** +- Browse by **Occasion** and by **Hatti**. +- **Festival calendar** (Hethai Habba and others) with push reminders and themed playlists. +- **Dedications**: "Dedicate this song to…", which generates a WhatsApp share card with a message (useful for weddings and birthdays). +- **Trilingual UI**: English, Tamil, and Badaga UI strings in Tamil script. +- **Heritage tag**: archival and rare recordings, marked and preserved. + +--- + +## 3. Tech stack (recommended) + +### App (Flutter, one codebase for iOS, Android and web) +| Concern | Choice | Why | +|---|---|---| +| Framework | Flutter (latest stable), Dart 3, Material 3 with a custom theme | iOS and Android from one codebase | +| State | **Riverpod 2/3** (with codegen) | testable and scales well | +| Routing | **go_router** (with a ShellRoute so the mini player stays visible) | deep links for share URLs | +| Audio engine | **just_audio 0.10.x** | gapless playlists, HLS, clipping, caching (experimental) | +| Background / OS media controls | **audio_service** (AudioHandler) | lock screen, notification, headset buttons, Bluetooth; the foundation for CarPlay/Auto later | +| Audio session | **audio_session** | handles interruptions (calls) and ducking | +| Local DB | **drift** (SQLite) | library, downloads index, play history, offline catalog | +| Key-value | shared_preferences / hive_ce | settings | +| Downloads | background_downloader | resumable, survives app kill, works on iOS and Android | +| Images | cached_network_image, palette_generator (dynamic colours from artwork) | Spotify-style tinted Now Playing | +| Animations | flutter_animate, Rive/Lottie for the brand splash | | +| Networking | dio plus the Supabase client | | +| Analytics / crash | Firebase Crashlytics plus PostHog (or Firebase Analytics) | | +| i18n | flutter_localizations, ARB (en, ta, Badaga-in-Tamil-script) | | +| Fonts | Noto Sans Tamil plus a display face (e.g. Outfit / Manrope) | correct Tamil glyph shaping | + +> Note: audio_service conflicts with other plugins that also manage media notifications. Use **only** just_audio + audio_service + audio_session for playback. + +### Backend +| Concern | Choice | Why | +|---|---|---| +| DB, auth, API | **Supabase** (Postgres, Row-Level Security, Auth with phone OTP, Google and Apple sign-in) | SQL suits a relational music catalog; cheap; open source | +| Search | Postgres `pg_trgm` plus a normalized transliteration column (MVP) → Typesense/Meilisearch (later) | fuzzy and cross-script search | +| Audio and image storage + CDN | **Cloudflare R2** + Cloudflare CDN | **zero egress fees**, which matters most for streaming costs | +| Secure streaming | Signed URLs (short TTL) from a Supabase Edge Function | stops hotlinking and scraping | +| Audio format | AAC (m4a) at 64 kbps (data-saver), 128 kbps (normal), 256 kbps (high). HLS optional in v1.1 | small files, good quality | +| Push | Firebase Cloud Messaging (+ APNs) | festival reminders, new releases | +| Admin / artist portal | Flutter web (shares models) or Next.js + Supabase | | +| Ingest pipeline | Python + ffmpeg scripts (local first, later a worker) | §5 | + +**Estimated cost at launch** (around 5k users): Supabase free/Pro ($25/mo), R2 storage roughly $0.015/GB/month (2,000 songs × 3 bitrates ≈ 15 GB ≈ $0.25/mo, no egress fees), Apple developer account $99/yr, Google Play $25 one-time. + +--- + +## 4. Data model (core tables) + +``` +artists (id, name_latin, name_tamil, hatti_id, bio, photo_url, verified, claimed_by_user_id) +hattis (id, name_latin, name_tamil, region, lat, lng) +albums (id, title_latin, title_tamil, year, cover_url, type[album|single|compilation|archive]) +tracks (id, title_latin, title_tamil, title_en_meaning, album_id, duration_ms, + occasion[], genre[], mood[], year, language='bfq', explicit=false, + audio_key_64, audio_key_128, audio_key_256, loudness_lufs, + status[draft|pending|approved|rejected|taken_down], + rights_status[legacy_unverified|artist_approved|licensed], source_note, + search_vector, created_at) +track_artists (track_id, artist_id, role[singer|lyricist|composer|musician]) +lyrics (track_id, script[tamil|latin|kannada|english], body, synced_lrc) +playlists (id, owner_id, title, cover_url, is_public, is_editorial, is_collaborative) +playlist_items (playlist_id, track_id, position, added_by) +user_likes (user_id, track_id | album_id | artist_id, created_at) +play_events (user_id, track_id, started_at, ms_played, source, offline) -- powers trending, Wrapped, royalties +festivals (id, name, date_rule, playlist_id) +submissions (id, artist_id, track_draft_json, files, consent_doc_url, reviewer_id, status, notes) +consents (artist_id, track_id, consent_type, signed_at, signature_ref) +takedown_requests (id, track_id, requester, reason, status) +``` +`language='bfq'` is the ISO 639-3 code for Badaga. + +--- + +## 5. Phase 0: ingest your existing collection (start here) + +This is the critical-path work. App quality depends on catalog quality. + +1. **Inventory**: run a script over your folders that outputs a CSV with path, file hash, duration, bitrate, existing ID3 tags and embedded art. +2. **Dedupe**: exact matches by hash, near-duplicates by audio fingerprint (Chromaprint / `fpcalc`). +3. **Metadata sheet** (Google Sheet or Excel, filled by you and community volunteers): title (Tamil script), title (Latin), meaning in English, singer(s), lyricist, composer, hatti, occasion, year or approximate era, album/source, and lyrics if known. +4. **Audio processing** (ffmpeg): + - Trim silence, normalize loudness to −14 LUFS (`loudnorm` two-pass), store the measured value. + - Transcode to AAC 64/128/256 kbps. + - Generate a waveform JSON (optional, for the scrubber UI). +5. **Artwork**: use the original cover where one exists; otherwise generate a branded cover from a template (occasion colour + title in Tamil). One consistent style looks premium. +6. **Upload**: push files to R2 and rows to Supabase via script. Every track gets `rights_status = legacy_unverified`. +7. **Rights**: before going public, contact the singers and families you can reach. Publish a clear **takedown/claim** form in the app. Apple App Review may ask for proof that you have rights to the music, so keep a consent record for each track (§7). + +Deliverable: a clean, searchable catalog of N songs with complete metadata and covers. + +--- + +## 6. UX and design direction + +**Brand: "Nilgiris at dusk"** +- Dark-first theme (the music-app norm) with a light theme available. +- Palette: deep tea-estate green (#0F2A1F), misty blue-grey, and an accent drawn from the traditional Badaga white cloth with its coloured border (warm red or ochre accent). Now Playing tints dynamically from the artwork. +- Type: a modern geometric sans for Latin text; Noto Sans Tamil, sized slightly larger for legibility. +- Motifs: subtle hill-contour lines and textile border patterns as dividers. Keep them tasteful, not kitsch. + +**Navigation** (bottom bar, 4 tabs): **Home · Search · Library · Occasions** +- A persistent **mini player** above the bottom bar. Swipe up to open Now Playing; swipe left or right to skip. + +**Key screens** +1. **Splash / onboarding**: pick UI language → pick your hatti (optional) → pick favourite singers/occasions (seeds the home feed) → optional sign-in (guest mode allowed; sign in later to sync). +2. **Home**: greeting by time of day ("Good evening" in Badaga), Continue listening, Festival banner (seasonal), New releases, Trending in Nilgiris, Occasion chips, Featured singers, Heritage collection, Made for you (v1.2). +3. **Search**: instant results as you type. Searches across Tamil script, Latin transliteration and English meaning. Filters: songs, artists, albums, playlists, lyrics. Browse grid of occasions and moods. Recent searches. +4. **Now Playing**: large artwork with a parallax/blurred tinted background, scrubber, controls, like, queue, lyrics panel (pull up), share/dedicate, sleep timer, output device. +5. **Lyrics**: full-screen, script toggle (Tamil / Latin / English), synced highlighting in v1.1. +6. **Artist**: header image, verified badge, play/shuffle, top songs, albums, hatti, bio, "Appears on". +7. **Album / Playlist**: header with a dynamic colour gradient, download toggle, shuffle, track list with swipe actions (queue, like). +8. **Library**: Liked songs, Downloads, Playlists, Artists, Albums, Recently played, with sort and filter. +9. **Occasions**: cards for Huttu, Habba, Madhuvae, Saavu (handled respectfully; muted styling), Devotional, Folk/Dance, Modern. +10. **Settings**: audio quality (Wi-Fi vs mobile), data saver, download quality, storage manager, language, theme, notifications, account, about/credits, report a song. + +**UX quality bar** (what makes the leading apps feel good) +- Under 1 s to first audio on a good network: prefetch the next track, show skeleton loaders (no spinners), use optimistic UI for likes and queue changes. +- Hero animations from artwork to Now Playing; haptics on key actions. +- Playback state survives app kill (restore queue and position). +- Full offline mode: the app opens without a network, shows downloads, and syncs likes later. +- Accessibility: semantic labels on every control, dynamic type, minimum 4.5:1 contrast. + +--- + +## 7. New-song workflow with singer approval (v1.1) + +``` +Singer signs up (phone OTP) ─► Verify identity (admin checks; community reference) + ─► Artist profile created / existing legacy profile CLAIMED + ─► Upload: audio (WAV/FLAC/MP3) + cover + metadata + lyrics + ─► Digital consent: e-sign the distribution agreement (non-exclusive licence, + revocable, credits shown, royalty terms if any) ─► stored in `consents` + ─► Status: PENDING ─► Admin moderation queue (quality, metadata, rights check) + ─► APPROVED ─► automated ffmpeg pipeline ─► scheduled release date ─► push to followers + └─► REJECTED with notes ─► singer edits and resubmits +``` +- **Legacy tracks**: when a singer claims their profile, they review each `legacy_unverified` track and choose **Approve**, **Request edit** or **Take down**. Approval flips `rights_status` to `artist_approved`. +- **Artist dashboard**: plays, listeners, top hattis/cities, saves, playlist adds (from `play_events`). +- **Roles**: listener, artist, moderator, admin, enforced by Supabase Row-Level Security. +- **Future**: royalties or a tip jar (UPI), optional ad-free supporter tier. Have an expert check copyright, IPRS/PPL and payment rules in India before you take any money. + +--- + +## 8. Project structure (Flutter) + +``` +lib/ + main.dart + app/ (router, theme, l10n, DI/providers bootstrap) + core/ (network, db (drift), error, utils, transliteration) + audio/ (audio_handler.dart, queue, playback_repository, cache) + features/ + home/ search/ library/ player/ lyrics/ artist/ album/ playlist/ + occasions/ downloads/ settings/ auth/ onboarding/ share/ + data/ (dto, repository) domain/ (models) presentation/ (screens, widgets, providers) + shared/widgets/ (mini_player, track_tile, artwork, shimmer, section_carousel) +tools/ingest/ (python + ffmpeg scripts for Phase 0) +supabase/ (migrations, RLS policies, edge functions) +admin/ (moderation + artist portal, Flutter web) +``` + +--- + +## 9. Roadmap + +| Phase | Duration (1 dev + you curating) | Scope | Exit criteria | +|---|---|---|---| +| **0. Catalog** | 2–3 wks (runs alongside phase 1) | §5 ingest: metadata sheet, ffmpeg pipeline, R2 + Supabase seed | All legacy songs tagged and playable from the CDN | +| **1. Foundation** | 2 wks | Flutter project, theme/design system, routing, Supabase schema + RLS, auth (guest + phone OTP + Apple/Google) | App shell runs on iOS and Android | +| **2. Playback core** | 2–3 wks | just_audio + audio_service, queue, mini/full player, background + lock screen, gapless, shuffle/repeat, sleep timer, state restore | Plays for 1 hr in background on both OSes, handles calls and Bluetooth | +| **3. Discovery** | 2 wks | Home sections, Search (trilingual, fuzzy), Artist/Album/Playlist screens, Occasions, static lyrics | User can find any song in ≤ 3 taps or by typing | +| **4. Library and offline** | 2 wks | Likes, playlists, downloads manager, offline mode, data saver, recently played, sync | Full use in airplane mode | +| **5. Polish and share** | 1–2 wks | Dynamic colours, animations, WhatsApp share + deep links, dedications, onboarding, accessibility, analytics, crash reporting | Internal beta | +| **6. Beta and launch** | 2 wks | TestFlight + Play internal testing with 50–100 community members, fix issues, store listings (EN/TA screenshots), privacy policy, takedown page | **MVP live on App Store and Play Store** | +| **v1.1** | +4–6 wks | Artist portal + approval workflow, synced lyrics, radio/autoplay, crossfade, EQ, Chromecast, festival push | | +| **v1.2** | +4 wks | Made-for-you mixes, NK Varusha (yearly recap), collaborative playlists | | +| **v2** | later | CarPlay/Android Auto, widgets, stories/podcasts, AI mood playlists, social features, supporter tier | | + +**MVP total: about 11–14 weeks.** + +--- + +## 10. Store and compliance checklist +- iOS: `UIBackgroundModes: audio` in Info.plist; Sign in with Apple is required if you offer Google sign-in; privacy nutrition labels. +- Android: foreground media service permissions (Android 14+ `FOREGROUND_SERVICE_MEDIA_PLAYBACK`), notification permission (Android 13+). +- Apple may ask for proof of music rights. Keep consent records, the source notes and the takedown page ready. +- Privacy policy and terms (Indian DPDP Act 2023), and account deletion in the app (both stores require it). +- Content rating: all ages. + +--- + +## 11. Success metrics +- Day-30 retention ≥ 25%; average listening ≥ 20 min/day for active users. +- Search success rate (a search followed by a play) ≥ 70%. +- Crash-free sessions ≥ 99.5%; time to first audio < 1.5 s at p75. +- Number of singers who claim profiles and approve legacy tracks within 6 months. + +--- + +## 12. Decisions + +**Decided 2026-10-27** +- Catalog: a few hundred legacy songs to start. +- Sign-in: **email** (passwordless 6-digit code through Supabase Auth). Listeners can browse before signing in. +- Approval: admins approve every song before listeners can see it. +- Backend: **Supabase + Cloudflare R2**. +- Pricing: legacy songs are **free** at launch. New songs may later move to paid tiers or subscriptions, so the `tracks` table gets a `tier` column (free | premium) from day one. +- Logo: English wordmark "Nanga Kathai" plus an "NK" mark. +- Design canvas: https://claude.ai/artifact/9ZLCbGn4rUTVTqfRXTtMkq + +**Original questions** +1. **Collection size and format**: how many songs, what formats, and how much metadata already exists? +2. **Backend**: Supabase + R2 (recommended) or Firebase? +3. **Sign-in**: guest-first with optional login (recommended), or login required? +4. **Admins and curators**: who will tag songs and moderate submissions? +5. **Branding**: logo/wordmark for "Nanga Kathai / NK". Tamil-script wordmark, Latin wordmark, or both? +6. **Monetization intent**: free community app forever, donations, or a supporter tier later? + +--- + +### Sources +- [ibaduga Mixcloud: 1,000+ Badaga songs](https://www.mixcloud.com/ibaduga/badauga/) +- [Badaga language (Wikipedia)](https://en.wikipedia.org/wiki/Badaga_language) · [Badagas (Wikipedia)](https://en.wikipedia.org/wiki/Badagas) · [Badugu script proposal (Unicode L2/22-220)](https://www.unicode.org/L2/L2022/22220-introducing-badagu-script.pdf) +- [just_audio on pub.dev](https://pub.dev/packages/just_audio) · [audio_service](https://github.com/michaelspeedcode/audio_service) · [mt_audio (CarPlay/Auto wrapper, beta)](https://pub.dev/packages/mt_audio/versions/0.2.0-beta.5) +- [Best features added to Spotify in 2026 (BGR)](https://www.bgr.com/2179163/best-features-added-spotify-2026/) · [Spotify 2026 changes (inspiredbybeatz)](https://www.inspiredbybeatz.com/spotify-2026-neue-features-aenderungen/) · [TechRadar Spotify coverage](https://www.techradar.com/tag/spotify/page/3) diff --git a/env.example.json b/env.example.json new file mode 100644 index 0000000..1a5297a --- /dev/null +++ b/env.example.json @@ -0,0 +1,5 @@ +{ + "SUPABASE_URL": "https://app.nkbmusic.com", + "SUPABASE_PUBLISHABLE_KEY": "", + "MEDIA_BASE_URL": "https://app.nkbmusic.com/storage/v1/object/public/audio" +} diff --git a/ios/.gitignore b/ios/.gitignore new file mode 100644 index 0000000..7a7f987 --- /dev/null +++ b/ios/.gitignore @@ -0,0 +1,34 @@ +**/dgph +*.mode1v3 +*.mode2v3 +*.moved-aside +*.pbxuser +*.perspectivev3 +**/*sync/ +.sconsign.dblite +.tags* +**/.vagrant/ +**/DerivedData/ +Icon? +**/Pods/ +**/.symlinks/ +profile +xcuserdata +**/.generated/ +Flutter/App.framework +Flutter/Flutter.framework +Flutter/Flutter.podspec +Flutter/Generated.xcconfig +Flutter/ephemeral/ +Flutter/app.flx +Flutter/app.zip +Flutter/flutter_assets/ +Flutter/flutter_export_environment.sh +ServiceDefinitions.json +Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!default.mode1v3 +!default.mode2v3 +!default.pbxuser +!default.perspectivev3 diff --git a/ios/Flutter/AppFrameworkInfo.plist b/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 0000000..391a902 --- /dev/null +++ b/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,24 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + + diff --git a/ios/Flutter/Debug.xcconfig b/ios/Flutter/Debug.xcconfig new file mode 100644 index 0000000..592ceee --- /dev/null +++ b/ios/Flutter/Debug.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/ios/Flutter/Release.xcconfig b/ios/Flutter/Release.xcconfig new file mode 100644 index 0000000..592ceee --- /dev/null +++ b/ios/Flutter/Release.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..97e73e3 --- /dev/null +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,647 @@ +// !$*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 */; }; + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C8082294A63A400263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C807B294A618700263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + 331C8082294A63A400263BE5 /* RunnerTests */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + ); + path = Runner; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C8080294A63A400263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 331C807D294A63A400263BE5 /* Sources */, + 331C807F294A63A400263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C8086294A63A400263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + ); + 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; + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */, + ); + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + 331C8080294A63A400263BE5 /* RunnerTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C807F294A63A400263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C807D294A63A400263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C8086294A63A400263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 249021D3217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + 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.metatroncubesolutions.nangaKathai; + 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.metatroncubesolutions.nangaKathai.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.metatroncubesolutions.nangaKathai.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.metatroncubesolutions.nangaKathai.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 = 15.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + 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 = 15.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + 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.metatroncubesolutions.nangaKathai; + 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.metatroncubesolutions.nangaKathai; + 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 */ + +/* Begin XCLocalSwiftPackageReference section */ + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { + isa = XCSwiftPackageProductDependency; + productName = FlutterGeneratedPluginSwiftPackage; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..c3fedb2 --- /dev/null +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,119 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..1d526a1 --- /dev/null +++ b/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift new file mode 100644 index 0000000..c30b367 --- /dev/null +++ b/ios/Runner/AppDelegate.swift @@ -0,0 +1,16 @@ +import Flutter +import UIKit + +@main +@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } + + func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { + GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) + } +} diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..d36b1fa --- /dev/null +++ b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,122 @@ +{ + "images" : [ + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@3x.png", + "scale" : "3x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@3x.png", + "scale" : "3x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@3x.png", + "scale" : "3x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@2x.png", + "scale" : "2x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@3x.png", + "scale" : "3x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@1x.png", + "scale" : "1x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@1x.png", + "scale" : "1x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@1x.png", + "scale" : "1x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@2x.png", + "scale" : "2x" + }, + { + "size" : "83.5x83.5", + "idiom" : "ipad", + "filename" : "Icon-App-83.5x83.5@2x.png", + "scale" : "2x" + }, + { + "size" : "1024x1024", + "idiom" : "ios-marketing", + "filename" : "Icon-App-1024x1024@1x.png", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 0000000..dc9ada4 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 0000000..7353c41 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 0000000..797d452 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 0000000..6ed2d93 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 0000000..4cd7b00 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 0000000..fe73094 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 0000000..321773c Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 0000000..797d452 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 0000000..502f463 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 0000000..0ec3034 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 0000000..0ec3034 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 0000000..e9f5fea Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 0000000..84ac32a Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 0000000..8953cba Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 0000000..0467bf1 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 0000000..0bedcf2 --- /dev/null +++ b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "LaunchImage.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 0000000..89c2725 --- /dev/null +++ b/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -0,0 +1,5 @@ +# Launch Screen Assets + +You can customize the launch screen with your own desired assets by replacing the image files in this directory. + +You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/ios/Runner/Base.lproj/LaunchScreen.storyboard b/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..f2e259c --- /dev/null +++ b/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner/Base.lproj/Main.storyboard b/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 0000000..f3c2851 --- /dev/null +++ b/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist new file mode 100644 index 0000000..6fe4925 --- /dev/null +++ b/ios/Runner/Info.plist @@ -0,0 +1,74 @@ + + + + + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Nanga Kathai + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + nanga_kathai + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneClassName + UIWindowScene + UISceneConfigurationName + flutter + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + UISceneStoryboardFile + Main + + + + + UIBackgroundModes + + audio + + UIApplicationSupportsIndirectInputEvents + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/ios/Runner/Runner-Bridging-Header.h b/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 0000000..308a2a5 --- /dev/null +++ b/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/ios/Runner/SceneDelegate.swift b/ios/Runner/SceneDelegate.swift new file mode 100644 index 0000000..b9ce8ea --- /dev/null +++ b/ios/Runner/SceneDelegate.swift @@ -0,0 +1,6 @@ +import Flutter +import UIKit + +class SceneDelegate: FlutterSceneDelegate { + +} diff --git a/ios/RunnerTests/RunnerTests.swift b/ios/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..86a7c3b --- /dev/null +++ b/ios/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Flutter +import UIKit +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/lib/app/router.dart b/lib/app/router.dart new file mode 100644 index 0000000..26d76bf --- /dev/null +++ b/lib/app/router.dart @@ -0,0 +1,151 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +import '../core/env.dart'; +import '../core/models.dart'; +import '../data/user_library.dart'; +import '../features/artist/artist_screen.dart'; +import '../features/auth/login_screen.dart'; +import '../features/downloads/downloads_screen.dart'; +import '../features/explore/explore_screen.dart'; +import '../features/explore/occasion_screen.dart'; +import '../features/home/home_screen.dart'; +import '../features/library/library_screen.dart'; +import '../features/profile/profile_screen.dart'; +import '../features/player/now_playing_screen.dart'; +import '../features/about/about_screen.dart'; +import '../features/album/album_screen.dart'; +import '../features/search/search_screen.dart'; +import '../features/welcome/splash_screen.dart'; +import '../shared/widgets/app_shell.dart'; + +/// Whether the welcome + sign-in screens have been seen on this device. +class Onboarding extends Notifier { + static const _key = 'onboarding_done'; + + @override + bool build() => ref.watch(sharedPrefsProvider).getBool(_key) ?? false; + + void complete() { + state = true; + ref.read(sharedPrefsProvider).setBool(_key, true); + } +} + +final onboardingProvider = NotifierProvider(Onboarding.new); + +/// Re-runs the router redirect when someone signs in or out. +class _AuthRefresh extends ChangeNotifier { + _AuthRefresh() { + if (Env.hasSupabase) { + _sub = Supabase.instance.client.auth.onAuthStateChange.listen((_) => notifyListeners()); + } + } + + StreamSubscription? _sub; + + @override + void dispose() { + _sub?.cancel(); + super.dispose(); + } +} + +/// Signed in to the server. Guests (and demo builds with no server) are not. +bool get isSignedIn => Env.hasSupabase && Supabase.instance.client.auth.currentSession != null; + +final routerProvider = Provider((ref) { + final onboarded = ref.read(onboardingProvider); + final authRefresh = _AuthRefresh(); + ref.onDispose(authRefresh.dispose); + return GoRouter( + initialLocation: onboarded ? '/home' : '/welcome', + refreshListenable: authRefresh, + // Guests can browse (with 30-second previews); the profile needs an account. + redirect: (_, state) { + final at = state.matchedLocation; + if (!isSignedIn && at == '/profile') return '/login'; + if (isSignedIn && at == '/login') return '/home'; + return null; + }, + routes: [ + GoRoute(path: '/welcome', builder: (_, _) => const SplashScreen()), + GoRoute( + path: '/login', + builder: (_, state) => LoginScreen(startWithSignUp: state.uri.queryParameters['mode'] == 'signup'), + ), + GoRoute(path: '/profile', builder: (_, _) => const ProfileScreen()), + GoRoute(path: '/about', builder: (_, _) => const AboutScreen()), + StatefulShellRoute.indexedStack( + builder: (_, _, shell) => AppShell(shell: shell), + branches: [ + StatefulShellBranch( + routes: [ + GoRoute(path: '/home', builder: (_, _) => const HomeScreen()), + ], + ), + StatefulShellBranch( + routes: [ + GoRoute( + path: '/explore', + builder: (_, _) => const ExploreScreen(), + ), + ], + ), + StatefulShellBranch( + routes: [ + GoRoute( + path: '/library', + builder: (_, _) => const LibraryScreen(), + ), + ], + ), + StatefulShellBranch( + routes: [ + GoRoute( + path: '/downloads', + builder: (_, _) => const DownloadsScreen(), + ), + ], + ), + ], + ), + GoRoute(path: '/search', builder: (_, _) => const SearchScreen()), + GoRoute( + path: '/artist/:id', + builder: (_, state) => + ArtistScreen(artistId: state.pathParameters['id']!), + ), + GoRoute( + path: '/album/:id', + builder: (_, state) => + AlbumScreen(albumId: state.pathParameters['id']!), + ), + GoRoute( + path: '/occasion/:id', + builder: (_, state) => OccasionScreen( + occasion: Occasion.fromId(state.pathParameters['id']), + ), + ), + GoRoute( + path: '/player', + pageBuilder: (_, state) => CustomTransitionPage( + key: state.pageKey, + fullscreenDialog: true, + child: const NowPlayingScreen(), + transitionsBuilder: (_, animation, _, child) => SlideTransition( + position: Tween( + begin: const Offset(0, 1), + end: Offset.zero, + ).chain(CurveTween(curve: Curves.easeOutCubic)).animate(animation), + child: child, + ), + ), + ), + ], + ); +}); diff --git a/lib/app/theme.dart b/lib/app/theme.dart new file mode 100644 index 0000000..be5cdfe --- /dev/null +++ b/lib/app/theme.dart @@ -0,0 +1,131 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +/// Nanga Kathai palette, taken from the heritage emblem. +abstract final class NkColors { + static const ground = Color(0xFF0B1A14); // night tea + static const surface = Color(0xFF14281F); // estate + static const raised = Color(0xFF1F3A2B); + static const miniPlayer = Color(0xFF1A3326); + static const outline = Color(0xFF2A4537); + static const emblemGreen = Color(0xFF1F4A2E); + static const teaLeaf = Color(0xFF7FA857); + static const sun = Color(0xFFF5B335); // accent + static const roofRed = Color(0xFFC9502F); + static const cream = Color(0xFFFBF7EE); // primary text + static const muted = Color(0xFF9FB3A6); // secondary text + static const mutedOnPhoto = Color(0xFFDCE5DF); + static const like = Color(0xFFE0503F); +} + +ThemeData buildNkTheme() { + final base = ThemeData( + useMaterial3: true, + brightness: Brightness.dark, + scaffoldBackgroundColor: NkColors.ground, + colorScheme: const ColorScheme.dark( + primary: NkColors.sun, + onPrimary: NkColors.ground, + secondary: NkColors.teaLeaf, + surface: NkColors.ground, + onSurface: NkColors.cream, + surfaceContainer: NkColors.surface, + surfaceContainerHigh: NkColors.raised, + outline: NkColors.outline, + error: NkColors.roofRed, + ), + ); + + // google_fonts 9 builds its TextTheme helpers on package:material_ui, so only its + // TextStyle API is used here. + final body = base.textTheme.apply( + fontFamily: GoogleFonts.manrope().fontFamily, + bodyColor: NkColors.cream, + displayColor: NkColors.cream, + ); + TextStyle display(double size, [FontWeight w = FontWeight.w800]) => + GoogleFonts.sora(fontSize: size, fontWeight: w, color: NkColors.cream, height: 1.1); + + return base.copyWith( + textTheme: body.copyWith( + headlineLarge: display(32), + headlineMedium: display(28), + headlineSmall: display(24), + titleLarge: display(20, FontWeight.w700), + titleMedium: body.titleMedium?.copyWith(fontWeight: FontWeight.w700), + bodySmall: body.bodySmall?.copyWith(color: NkColors.muted), + ), + splashFactory: InkSparkle.splashFactory, + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + backgroundColor: NkColors.sun, + foregroundColor: NkColors.ground, + minimumSize: const Size(64, 52), + shape: const StadiumBorder(), + textStyle: GoogleFonts.manrope(fontSize: 16, fontWeight: FontWeight.w700), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: NkColors.cream, + minimumSize: const Size(64, 50), + side: const BorderSide(color: NkColors.outline, width: 1.5), + shape: const StadiumBorder(), + textStyle: GoogleFonts.manrope(fontSize: 15, fontWeight: FontWeight.w700), + ), + ), + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: NkColors.surface, + hintStyle: const TextStyle(color: NkColors.muted), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: const BorderSide(color: NkColors.outline, width: 1.5), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: const BorderSide(color: NkColors.outline, width: 1.5), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: const BorderSide(color: NkColors.sun, width: 1.5), + ), + ), + chipTheme: base.chipTheme.copyWith( + backgroundColor: NkColors.raised, + selectedColor: NkColors.sun, + side: BorderSide.none, + shape: const StadiumBorder(), + labelStyle: GoogleFonts.manrope(fontWeight: FontWeight.w600, color: NkColors.cream), + secondaryLabelStyle: GoogleFonts.manrope(fontWeight: FontWeight.w700, color: NkColors.ground), + showCheckmark: false, + ), + sliderTheme: const SliderThemeData( + activeTrackColor: NkColors.sun, + inactiveTrackColor: Color(0x38FBF7EE), + thumbColor: NkColors.sun, + trackHeight: 4, + overlayShape: RoundSliderOverlayShape(overlayRadius: 18), + ), + navigationBarTheme: NavigationBarThemeData( + backgroundColor: NkColors.ground, + indicatorColor: Colors.transparent, + height: 72, + labelTextStyle: WidgetStateProperty.resolveWith( + (s) => GoogleFonts.manrope( + fontSize: 12, + fontWeight: FontWeight.w600, + color: s.contains(WidgetState.selected) ? NkColors.sun : NkColors.muted, + ), + ), + iconTheme: WidgetStateProperty.resolveWith( + (s) => IconThemeData(color: s.contains(WidgetState.selected) ? NkColors.sun : NkColors.muted), + ), + ), + snackBarTheme: const SnackBarThemeData( + backgroundColor: NkColors.raised, + contentTextStyle: TextStyle(color: NkColors.cream), + behavior: SnackBarBehavior.floating, + ), + ); +} diff --git a/lib/audio/nk_audio_handler.dart b/lib/audio/nk_audio_handler.dart new file mode 100644 index 0000000..08317de --- /dev/null +++ b/lib/audio/nk_audio_handler.dart @@ -0,0 +1,160 @@ +import 'dart:async'; + +import 'package:audio_service/audio_service.dart'; +import 'package:audio_session/audio_session.dart'; +import 'package:just_audio/just_audio.dart'; + +/// Owns the [AudioPlayer] and exposes it to the OS (lock screen, notification, +/// headset and Bluetooth buttons) through audio_service. +class NkAudioHandler extends BaseAudioHandler with SeekHandler { + NkAudioHandler() { + _init(); + } + + /// Sound settings (Android only; iOS has no per-app equalizer in just_audio). + final equalizer = AndroidEqualizer(); + final loudness = AndroidLoudnessEnhancer(); + + late final _player = AudioPlayer( + audioPipeline: AudioPipeline(androidAudioEffects: [loudness, equalizer]), + ); + + Stream get positionStream => _player.positionStream; + Stream get durationStream => _player.durationStream; + + Future _init() async { + final session = await AudioSession.instance; + await session.configure(const AudioSessionConfiguration.music()); + + _player.playbackEventStream.listen( + (_) => playbackState.add(_currentState()), + onError: (Object e, StackTrace st) => playbackState.add( + playbackState.value.copyWith(processingState: AudioProcessingState.error, errorMessage: '$e'), + ), + ); + _player.playingStream.listen((_) => playbackState.add(_currentState())); + _player.shuffleModeEnabledStream.listen((_) => playbackState.add(_currentState())); + _player.loopModeStream.listen((_) => playbackState.add(_currentState())); + + _player.sequenceStateStream.listen((state) { + final tag = state.currentSource?.tag; + if (tag is MediaItem) mediaItem.add(tag); + queue.add([ + for (final s in state.effectiveSequence) + if (s.tag is MediaItem) s.tag as MediaItem, + ]); + }); + + // Fill in the real duration once the file is loaded. + _player.durationStream.listen((d) { + final item = mediaItem.value; + if (d != null && item != null && item.duration != d) mediaItem.add(item.copyWith(duration: d)); + }); + } + + /// Replaces the queue with [items] and starts playing at [index]. + Future playQueue(List items, {int index = 0}) async { + if (items.isEmpty) return; + await _player.setAudioSources( + [for (final item in items) _sourceFor(item)], + initialIndex: index, + ); + await _player.play(); + } + + /// Inserts [item] right after the current song. + Future playNext(MediaItem item) async { + final source = _sourceFor(item); + final current = _player.currentIndex; + if (current == null) return playQueue([item]); + await _player.insertAudioSource(current + 1, source); + } + + @override + Future addQueueItem(MediaItem mediaItem) => + _player.addAudioSource(_sourceFor(mediaItem)); + + /// Set at startup: returns the downloaded (encrypted) copy of a song, if there is one. + AudioSource? Function(MediaItem item)? localSource; + + AudioSource _sourceFor(MediaItem item) => + localSource?.call(item) ?? AudioSource.uri(Uri.parse(item.extras!['url'] as String), tag: item); + + @override + Future play() => _player.play(); + + @override + Future pause() => _player.pause(); + + @override + Future seek(Duration position) => _player.seek(position); + + @override + Future skipToNext() => _player.seekToNext(); + + /// Restarts the song when more than 3 s in, like every major player. + @override + Future skipToPrevious() async { + if (_player.position > const Duration(seconds: 3) || !_player.hasPrevious) { + return _player.seek(Duration.zero); + } + return _player.seekToPrevious(); + } + + @override + Future skipToQueueItem(int index) async { + final indices = _player.shuffleModeEnabled ? _player.shuffleIndices : null; + await _player.seek(Duration.zero, index: indices == null ? index : indices[index]); + } + + @override + Future setShuffleMode(AudioServiceShuffleMode shuffleMode) async { + final enable = shuffleMode != AudioServiceShuffleMode.none; + if (enable) await _player.shuffle(); + await _player.setShuffleModeEnabled(enable); + } + + @override + Future setRepeatMode(AudioServiceRepeatMode repeatMode) => _player.setLoopMode(switch (repeatMode) { + AudioServiceRepeatMode.one => LoopMode.one, + AudioServiceRepeatMode.all || AudioServiceRepeatMode.group => LoopMode.all, + AudioServiceRepeatMode.none => LoopMode.off, + }); + + @override + Future stop() async { + await _player.stop(); + return super.stop(); + } + + PlaybackState _currentState() { + final playing = _player.playing; + return PlaybackState( + controls: [ + MediaControl.skipToPrevious, + if (playing) MediaControl.pause else MediaControl.play, + MediaControl.skipToNext, + ], + systemActions: const {MediaAction.seek, MediaAction.seekForward, MediaAction.seekBackward}, + androidCompactActionIndices: const [0, 1, 2], + processingState: switch (_player.processingState) { + ProcessingState.idle => AudioProcessingState.idle, + ProcessingState.loading => AudioProcessingState.loading, + ProcessingState.buffering => AudioProcessingState.buffering, + ProcessingState.ready => AudioProcessingState.ready, + ProcessingState.completed => AudioProcessingState.completed, + }, + playing: playing, + updatePosition: _player.position, + bufferedPosition: _player.bufferedPosition, + speed: _player.speed, + queueIndex: _player.currentIndex, + shuffleMode: _player.shuffleModeEnabled ? AudioServiceShuffleMode.all : AudioServiceShuffleMode.none, + repeatMode: switch (_player.loopMode) { + LoopMode.one => AudioServiceRepeatMode.one, + LoopMode.all => AudioServiceRepeatMode.all, + LoopMode.off => AudioServiceRepeatMode.none, + }, + ); + } +} diff --git a/lib/audio/player_providers.dart b/lib/audio/player_providers.dart new file mode 100644 index 0000000..af7282c --- /dev/null +++ b/lib/audio/player_providers.dart @@ -0,0 +1,106 @@ +import 'package:audio_service/audio_service.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../core/models.dart'; +import '../data/catalog_repository.dart'; +import '../offline/download_store.dart'; +import 'nk_audio_handler.dart'; + +/// Created in main() by AudioService.init and injected with an override. +final audioHandlerProvider = Provider( + (ref) => throw UnimplementedError('audioHandlerProvider must be overridden in main()'), +); + +final mediaItemProvider = StreamProvider((ref) => ref.watch(audioHandlerProvider).mediaItem); + +final playbackStateProvider = + StreamProvider((ref) => ref.watch(audioHandlerProvider).playbackState); + +final queueProvider = StreamProvider>((ref) => ref.watch(audioHandlerProvider).queue); + +final positionProvider = StreamProvider((ref) => ref.watch(audioHandlerProvider).positionStream); + +/// The catalog track behind the current media item, if any. +final currentTrackProvider = Provider((ref) { + final id = ref.watch(mediaItemProvider).value?.id; + final catalog = ref.watch(catalogProvider).value; + if (id == null || catalog == null) return null; + return catalog.trackById(id); +}); + +final playerControllerProvider = Provider((ref) => PlayerController(ref)); + +class PlayerController { + PlayerController(this._ref); + + final Ref _ref; + + NkAudioHandler get _handler => _ref.read(audioHandlerProvider); + + MediaItem toMediaItem(Track t) { + final catalog = _ref.read(catalogProvider).requireValue; + return MediaItem( + id: t.id, + title: t.titleLatin, + artist: catalog.creditLine(t), + album: t.occasion.label, + duration: t.duration == Duration.zero ? null : t.duration, + artUri: t.coverUrl == null ? null : Uri.parse(t.coverUrl!), + extras: {'url': t.audioUrl, 'coverAsset': t.coverAsset, 'occasion': t.occasion.id}, + ); + } + + Future playTracks(List tracks, {int index = 0, bool shuffle = false}) async { + await _handler.setShuffleMode(AudioServiceShuffleMode.none); + await _handler.playQueue(tracks.map(toMediaItem).toList(), index: index); + if (shuffle) await _handler.setShuffleMode(AudioServiceShuffleMode.all); + } + + /// Plays downloaded songs from their saved details, so it works with no internet. + Future playDownloads(List entries, {int index = 0}) async { + await _handler.setShuffleMode(AudioServiceShuffleMode.none); + await _handler.playQueue([ + for (final e in entries) + MediaItem( + id: e.trackId, + title: e.title, + artist: e.credit, + album: e.album, + duration: e.durationMs == 0 ? null : Duration(milliseconds: e.durationMs), + extras: {'url': '', 'occasion': e.occasion}, + ), + ], index: index); + } + + void download(Iterable tracks) { + final catalog = _ref.read(catalogProvider).requireValue; + final store = _ref.read(downloadStoreProvider); + for (final t in tracks) { + store.enqueue(t, credit: catalog.creditLine(t), album: catalog.albumOf(t)?.title); + } + } + + Future playNext(Track t) => _handler.playNext(toMediaItem(t)); + + Future addToQueue(Track t) => _handler.addQueueItem(toMediaItem(t)); + + Future togglePlay() { + final playing = _ref.read(playbackStateProvider).value?.playing ?? false; + return playing ? _handler.pause() : _handler.play(); + } + + Future toggleShuffle() { + final on = _ref.read(playbackStateProvider).value?.shuffleMode != AudioServiceShuffleMode.none; + return _handler.setShuffleMode(on ? AudioServiceShuffleMode.none : AudioServiceShuffleMode.all); + } + + /// Off -> all -> one -> off. + Future cycleRepeat() { + final mode = _ref.read(playbackStateProvider).value?.repeatMode ?? AudioServiceRepeatMode.none; + return _handler.setRepeatMode(switch (mode) { + AudioServiceRepeatMode.none => AudioServiceRepeatMode.all, + AudioServiceRepeatMode.all || AudioServiceRepeatMode.group => AudioServiceRepeatMode.one, + AudioServiceRepeatMode.one => AudioServiceRepeatMode.none, + }); + } +} diff --git a/lib/audio/sound_settings.dart b/lib/audio/sound_settings.dart new file mode 100644 index 0000000..2ecfbb6 --- /dev/null +++ b/lib/audio/sound_settings.dart @@ -0,0 +1,137 @@ +import 'dart:convert'; +import 'dart:math' as math; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:just_audio/just_audio.dart'; + +import '../data/user_library.dart'; +import 'player_providers.dart'; + +/// Equalizer presets, described as a gain curve over frequency so they fit +/// whatever bands the phone's equalizer has (usually 5). +enum EqPreset { + flat('Flat'), + bass('Bass boost'), + vocal('Vocal'), + festival('Festival'), + treble('Treble'), + custom('Custom'); + + const EqPreset(this.label); + final String label; + + /// Gain in dB for a band centred on [hz]; null for [custom]. + double? gainAt(double hz) => switch (this) { + flat => 0, + bass => hz < 120 ? 6 : hz < 400 ? 3.5 : 0, + vocal => hz < 200 ? -2 : hz < 600 ? 1 : hz < 5000 ? 4 : 1, + festival => hz < 200 ? 5 : hz < 1500 ? 0 : hz < 5000 ? 2 : 4, + treble => hz < 1000 ? 0 : hz < 5000 ? 2.5 : 5, + custom => null, + }; +} + +@immutable +class SoundState { + const SoundState({ + this.eqEnabled = false, + this.preset = EqPreset.flat, + this.customGains = const {}, + this.boostDb = 0, + }); + + final bool eqEnabled; + final EqPreset preset; + + /// Band index -> dB, used when [preset] is custom. + final Map customGains; + + /// Loudness boost for quiet old recordings, 0 to 10 dB. + final double boostDb; + + SoundState copyWith({bool? eqEnabled, EqPreset? preset, Map? customGains, double? boostDb}) => + SoundState( + eqEnabled: eqEnabled ?? this.eqEnabled, + preset: preset ?? this.preset, + customGains: customGains ?? this.customGains, + boostDb: boostDb ?? this.boostDb, + ); + + Map toJson() => { + 'eq': eqEnabled, + 'preset': preset.name, + 'custom': {for (final e in customGains.entries) '${e.key}': e.value}, + 'boost': boostDb, + }; + + static SoundState fromJson(Map j) => SoundState( + eqEnabled: j['eq'] as bool? ?? false, + preset: EqPreset.values.asNameMap()[j['preset']] ?? EqPreset.flat, + customGains: { + for (final e in ((j['custom'] as Map?) ?? {}).entries) int.parse(e.key as String): (e.value as num).toDouble(), + }, + boostDb: (j['boost'] as num?)?.toDouble() ?? 0, + ); +} + +/// Whether this platform supports the in-app equalizer and boost. +bool get soundEffectsSupported => !kIsWeb && defaultTargetPlatform == TargetPlatform.android; + +class SoundSettings extends Notifier { + static const _key = 'sound_settings'; + + @override + SoundState build() { + final raw = ref.read(sharedPrefsProvider).getString(_key); + SoundState state; + try { + state = raw == null ? const SoundState() : SoundState.fromJson(jsonDecode(raw) as Map); + } catch (_) { + state = const SoundState(); + } + _apply(state); + return state; + } + + AndroidEqualizer get _eq => ref.read(audioHandlerProvider).equalizer; + AndroidLoudnessEnhancer get _boost => ref.read(audioHandlerProvider).loudness; + + void setEnabled(bool on) => _update(state.copyWith(eqEnabled: on)); + + void setPreset(EqPreset preset) => _update(state.copyWith(preset: preset, eqEnabled: true)); + + /// Moving a band switches to the custom preset, starting from the current curve. + Future setBandGain(int index, double db) async { + final params = await _eq.parameters; + final gains = {for (final b in params.bands) b.index: b.gain}..[index] = db; + _update(state.copyWith(preset: EqPreset.custom, customGains: gains, eqEnabled: true)); + } + + void setBoost(double db) => _update(state.copyWith(boostDb: db)); + + void reset() => _update(const SoundState()); + + void _update(SoundState next) { + state = next; + ref.read(sharedPrefsProvider).setString(_key, jsonEncode(next.toJson())); + _apply(next); + } + + Future _apply(SoundState s) async { + if (!soundEffectsSupported) return; + await _boost.setEnabled(s.boostDb > 0); + await _boost.setTargetGain(s.boostDb); + await _eq.setEnabled(s.eqEnabled); + // Parameters arrive once the player has an audio session (first song loaded). + final params = await _eq.parameters; + for (final band in params.bands) { + final db = s.preset == EqPreset.custom + ? (s.customGains[band.index] ?? 0) + : s.preset.gainAt(band.centerFrequency)!; + await band.setGain(math.max(params.minDecibels, math.min(params.maxDecibels, db))); + } + } +} + +final soundSettingsProvider = NotifierProvider(SoundSettings.new); diff --git a/lib/core/env.dart b/lib/core/env.dart new file mode 100644 index 0000000..af6bded --- /dev/null +++ b/lib/core/env.dart @@ -0,0 +1,11 @@ +/// Build-time configuration, passed with `--dart-define` (or `--dart-define-from-file=env.json`). +/// When the Supabase values are empty the app runs on the bundled demo catalog. +abstract final class Env { + static const supabaseUrl = String.fromEnvironment('SUPABASE_URL'); + static const supabasePublishableKey = String.fromEnvironment('SUPABASE_PUBLISHABLE_KEY'); + + /// Public base URL of the R2 bucket (custom domain), e.g. https://media.nangakathai.app + static const mediaBaseUrl = String.fromEnvironment('MEDIA_BASE_URL'); + + static bool get hasSupabase => supabaseUrl.isNotEmpty && supabasePublishableKey.isNotEmpty; +} diff --git a/lib/core/models.dart b/lib/core/models.dart new file mode 100644 index 0000000..aeefeb3 --- /dev/null +++ b/lib/core/models.dart @@ -0,0 +1,117 @@ +import 'package:flutter/material.dart'; + +/// Life events Badaga songs belong to. `id` matches the `occasion` column in Supabase. +enum Occasion { + huttu('huttu', 'Huttu', 'Birth & naming', Color(0xFF3B6E8F), Icons.spa_outlined), + habba('habba', 'Habba', 'Festival celebrations', Color(0xFFB5541E), Icons.celebration_outlined), + madhuvae('madhuvae', 'Madhuvae', 'Wedding songs', Color(0xFF9C2F4A), Icons.favorite_border), + devotional('devotional', 'Devotional', 'Hethai & temple songs', Color(0xFF6B4FA0), Icons.temple_hindu_outlined), + folk('folk', 'Folk songs', 'Traditional melodies', Color(0xFF2E7D5B), Icons.music_note_outlined), + saavu('saavu', 'Saavu', 'Remembrance', Color(0xFF4A4F57), Icons.local_florist_outlined), + instrumental('instrumental', 'Instrumental', 'Flute, drums and more', Color(0xFF8A5A2B), Icons.graphic_eq); + + const Occasion(this.id, this.label, this.subtitle, this.color, this.icon); + + final String id; + final String label; + final String subtitle; + final Color color; + final IconData icon; + + static Occasion fromId(String? id) => + Occasion.values.firstWhere((o) => o.id == id, orElse: () => Occasion.folk); +} + +enum Tier { free, premium } + +class Artist { + const Artist({required this.id, required this.name, this.hatti, this.photoUrl, this.bio}); + + final String id; + final String name; + final String? hatti; + + /// Only set once the singer (or family) has given photo permission. + final String? photoUrl; + final String? bio; + + String get initials { + final parts = name.trim().split(RegExp(r'\s+')).where((p) => p.isNotEmpty).toList(); + if (parts.isEmpty) return '?'; + if (parts.length == 1) return parts.first.substring(0, 1).toUpperCase(); + return (parts.first[0] + parts.last[0]).toUpperCase(); + } + + factory Artist.fromJson(Map j) => Artist( + id: j['id'] as String, + name: j['name'] as String, + hatti: j['hatti'] as String?, + photoUrl: j['photoUrl'] as String?, + bio: j['bio'] as String?, + ); +} + +/// A cassette, CD or folder from the collection. +class Album { + const Album({required this.id, required this.title, this.year, this.coverUrl}); + + final String id; + final String title; + final int? year; + final String? coverUrl; +} + +class Track { + const Track({ + required this.id, + required this.titleLatin, + required this.artistIds, + required this.occasion, + required this.duration, + required this.audioUrl, + this.titleTamil, + this.meaning, + this.albumId, + this.coverAsset, + this.coverUrl, + this.tier = Tier.free, + }); + + final String id; + final String titleLatin; + final String? titleTamil; + + /// English meaning of the title, also searchable. + final String? meaning; + final List artistIds; + final String? albumId; + final Occasion occasion; + final Duration duration; + + /// `asset:///...` for bundled audio, otherwise an https URL (signed R2 URL later). + final String audioUrl; + final String? coverAsset; + final String? coverUrl; + final Tier tier; + + factory Track.fromJson(Map j) => Track( + id: j['id'] as String, + titleLatin: j['titleLatin'] as String, + titleTamil: j['titleTamil'] as String?, + meaning: j['meaning'] as String?, + artistIds: (j['artistIds'] as List).cast(), + albumId: j['albumId'] as String?, + occasion: Occasion.fromId(j['occasion'] as String?), + duration: Duration(milliseconds: (j['durationMs'] as num?)?.toInt() ?? 0), + audioUrl: j['audioUrl'] as String, + coverAsset: j['coverAsset'] as String?, + coverUrl: j['coverUrl'] as String?, + tier: j['tier'] == 'premium' ? Tier.premium : Tier.free, + ); +} + +String formatDuration(Duration d) { + final m = d.inMinutes; + final s = d.inSeconds.remainder(60).toString().padLeft(2, '0'); + return '$m:$s'; +} diff --git a/lib/core/search_normalize.dart b/lib/core/search_normalize.dart new file mode 100644 index 0000000..cf853cf --- /dev/null +++ b/lib/core/search_normalize.dart @@ -0,0 +1,39 @@ +/// Badaga has no fixed Latin spelling ("Madhuvae" / "Madhuve", "Hethai" / "Hettai" / "Hethe"). +/// Both the query and the searchable text are folded to a common key before matching. +/// Tamil-script text passes through unchanged (only lower-cased Latin is folded). +String foldForSearch(String input) { + var s = input.toLowerCase().replaceAll(RegExp(r"[^a-z0-9஀-௿ಀ-೿ ]"), ' '); + + const digraphs = { + 'ae': 'e', + 'ee': 'i', + 'oo': 'u', + 'aa': 'a', + 'th': 't', + 'dh': 'd', + 'bh': 'b', + 'ph': 'p', + 'kh': 'k', + 'gh': 'g', + 'sh': 's', + 'zh': 'l', + 'w': 'v', + }; + digraphs.forEach((from, to) => s = s.replaceAll(from, to)); + + // Word-final "ai" and "e" are written interchangeably (Hethai / Hethe). + s = s.replaceAllMapped(RegExp(r'ai\b'), (_) => 'e'); + + // Collapse doubled letters: "hettai" -> "hetai", "madhuvve" -> "madhuve". + s = s.replaceAllMapped(RegExp(r'([a-z])\1+'), (m) => m[1]!); + + return s.replaceAll(RegExp(r'\s+'), ' ').trim(); +} + +/// True when every word of [query] appears in at least one of [fields]. +bool matchesSearch(String query, Iterable fields) { + final words = foldForSearch(query).split(' ').where((w) => w.isNotEmpty).toList(); + if (words.isEmpty) return false; + final haystack = fields.whereType().map(foldForSearch).join(' '); + return words.every(haystack.contains); +} diff --git a/lib/data/catalog_repository.dart b/lib/data/catalog_repository.dart new file mode 100644 index 0000000..2efb5cf --- /dev/null +++ b/lib/data/catalog_repository.dart @@ -0,0 +1,170 @@ +import 'dart:convert'; + +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +import '../core/env.dart'; +import '../core/models.dart'; +import '../core/search_normalize.dart'; + +class Catalog { + Catalog({required List tracks, required List artists, List albums = const []}) + : tracks = List.unmodifiable(tracks), + artistsById = {for (final a in artists) a.id: a}, + albumsById = {for (final a in albums) a.id: a}; + + /// Newest first. + final List tracks; + final Map artistsById; + final Map albumsById; + + /// Albums with songs, biggest first. + late final List albums = albumsById.values.where((a) => byAlbum(a.id).isNotEmpty).toList() + ..sort((a, b) => byAlbum(b.id).length.compareTo(byAlbum(a.id).length)); + + late final Map> _byAlbum = () { + final map = >{}; + // Oldest first, so an album plays in the order it was imported (its track order). + for (final t in tracks.reversed) { + if (t.albumId != null) map.putIfAbsent(t.albumId!, () => []).add(t); + } + return map; + }(); + + List byAlbum(String albumId) => _byAlbum[albumId] ?? const []; + + Album? albumOf(Track t) => t.albumId == null ? null : albumsById[t.albumId]; + + List get artists => artistsById.values.toList(); + + List artistsOf(Track t) => + t.artistIds.map((id) => artistsById[id]).whereType().toList(); + + String artistLine(Track t) => artistsOf(t).map((a) => a.name).join(', '); + + /// For display: many legacy songs have no singer credited yet. + String creditLine(Track t) { + final line = artistLine(t); + return line.isEmpty ? 'Singer not credited yet' : line; + } + + List byOccasion(Occasion o) => tracks.where((t) => t.occasion == o).toList(); + + List byArtist(String artistId) => + tracks.where((t) => t.artistIds.contains(artistId)).toList(); + + Track? trackById(String id) { + for (final t in tracks) { + if (t.id == id) return t; + } + return null; + } + + List searchTracks(String query) => tracks + .where((t) => matchesSearch(query, [ + t.titleLatin, + t.titleTamil, + t.meaning, + t.occasion.label, + artistLine(t), + albumOf(t)?.title, + ])) + .toList(); + + List searchAlbums(String query) => + albums.where((a) => matchesSearch(query, [a.title])).toList(); + + List searchArtists(String query) => + artists.where((a) => matchesSearch(query, [a.name, a.hatti])).toList(); +} + +abstract interface class CatalogRepository { + Future load(); +} + +/// Reads the bundled demo catalog. Used until Supabase is configured. +class AssetCatalogRepository implements CatalogRepository { + const AssetCatalogRepository([this.path = 'assets/catalog/demo_catalog.json']); + + final String path; + + @override + Future load() async => parseCatalogJson(await rootBundle.loadString(path)); +} + +Catalog parseCatalogJson(String raw) { + final json = jsonDecode(raw) as Map; + return Catalog( + tracks: (json['tracks'] as List).map((e) => Track.fromJson(e as Map)).toList(), + artists: (json['artists'] as List).map((e) => Artist.fromJson(e as Map)).toList(), + ); +} + +/// Reads approved songs from Supabase (schema: supabase/migrations/0001_init.sql). +/// Row-level security already hides anything that is not approved. +class SupabaseCatalogRepository implements CatalogRepository { + SupabaseCatalogRepository(this.client); + + final SupabaseClient client; + + @override + Future load() async { + final artistRows = await client.from('artists').select('id, name_latin, hatti:hattis(name_latin), photo_url, bio'); + final albumRows = await client.from('albums').select('id, title_latin, year, cover_url'); + final trackRows = await client + .from('tracks') + .select('id, title_latin, title_tamil, title_en_meaning, album_id, occasion, duration_ms, ' + 'audio_key_128, cover_url, tier, track_artists(artist_id, position, role)') + // Admins may read unapproved songs too; the app only ever lists approved ones. + .eq('status', 'approved') + .order('created_at', ascending: false); + + final artists = [ + for (final r in artistRows) + Artist( + id: r['id'] as String, + name: r['name_latin'] as String, + hatti: (r['hatti'] as Map?)?['name_latin'] as String?, + photoUrl: r['photo_url'] as String?, + bio: r['bio'] as String?, + ), + ]; + final tracks = [ + for (final r in trackRows) + Track( + id: r['id'] as String, + titleLatin: r['title_latin'] as String, + titleTamil: r['title_tamil'] as String?, + meaning: r['title_en_meaning'] as String?, + artistIds: ([...(r['track_artists'] as List).where((ta) => ta['role'] == 'singer')] + ..sort((a, b) => (a['position'] as int).compareTo(b['position'] as int))) + .map((ta) => ta['artist_id'] as String) + .toList(), + albumId: r['album_id'] as String?, + occasion: Occasion.fromId(r['occasion'] as String?), + duration: Duration(milliseconds: (r['duration_ms'] as num?)?.toInt() ?? 0), + audioUrl: '${Env.mediaBaseUrl}/${r['audio_key_128']}', + coverUrl: r['cover_url'] as String?, + tier: r['tier'] == 'premium' ? Tier.premium : Tier.free, + ), + ]; + final albums = [ + for (final r in albumRows) + Album( + id: r['id'] as String, + title: r['title_latin'] as String, + year: (r['year'] as num?)?.toInt(), + coverUrl: r['cover_url'] as String?, + ), + ]; + return Catalog(tracks: tracks, artists: artists, albums: albums); + } +} + +final catalogRepositoryProvider = Provider((ref) { + if (Env.hasSupabase) return SupabaseCatalogRepository(Supabase.instance.client); + return const AssetCatalogRepository(); +}); + +final catalogProvider = FutureProvider((ref) => ref.watch(catalogRepositoryProvider).load()); diff --git a/lib/data/user_library.dart b/lib/data/user_library.dart new file mode 100644 index 0000000..03aa2c1 --- /dev/null +++ b/lib/data/user_library.dart @@ -0,0 +1,48 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../audio/player_providers.dart'; + +/// Overridden in main() once SharedPreferences has loaded. +final sharedPrefsProvider = Provider( + (ref) => throw UnimplementedError('sharedPrefsProvider must be overridden in main()'), +); + +/// Liked song ids, stored on the device. Synced to Supabase once sign-in is wired up. +class LikedSongs extends Notifier> { + static const _key = 'liked_track_ids'; + + @override + Set build() => {...?ref.watch(sharedPrefsProvider).getStringList(_key)}; + + bool isLiked(String id) => state.contains(id); + + void toggle(String id) { + state = state.contains(id) ? ({...state}..remove(id)) : {...state, id}; + ref.read(sharedPrefsProvider).setStringList(_key, state.toList()); + } +} + +final likedSongsProvider = NotifierProvider>(LikedSongs.new); + +/// Most recent first, capped at 30. +class RecentlyPlayed extends Notifier> { + static const _key = 'recent_track_ids'; + static const _max = 30; + + @override + List build() { + ref.listen(mediaItemProvider, (_, next) { + final id = next.value?.id; + if (id != null && (state.isEmpty || state.first != id)) _push(id); + }); + return ref.watch(sharedPrefsProvider).getStringList(_key) ?? const []; + } + + void _push(String id) { + state = [id, ...state.where((e) => e != id)].take(_max).toList(); + ref.read(sharedPrefsProvider).setStringList(_key, state); + } +} + +final recentlyPlayedProvider = NotifierProvider>(RecentlyPlayed.new); diff --git a/lib/features/about/about_screen.dart b/lib/features/about/about_screen.dart new file mode 100644 index 0000000..6299ba4 --- /dev/null +++ b/lib/features/about/about_screen.dart @@ -0,0 +1,112 @@ +import 'package:flutter/material.dart'; + +import '../../app/theme.dart'; +import '../../shared/widgets/artwork.dart'; + +class AboutScreen extends StatelessWidget { + const AboutScreen({super.key}); + + @override + Widget build(BuildContext context) { + final text = Theme.of(context).textTheme; + final body = text.bodyLarge?.copyWith(color: NkColors.mutedOnPhoto, height: 1.6); + Widget p(String s) => Padding(padding: const EdgeInsets.only(bottom: 12), child: Text(s, style: body)); + Widget strong(String s) => Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Text(s, style: body?.copyWith(color: NkColors.cream, fontWeight: FontWeight.w700)), + ); + Widget heading(String s) => Padding( + padding: const EdgeInsets.only(top: 20, bottom: 10), + child: Text(s, style: text.titleLarge), + ); + + return Scaffold( + body: CustomScrollView( + slivers: [ + SliverAppBar( + pinned: true, + expandedHeight: 260, + flexibleSpace: FlexibleSpaceBar( + titlePadding: const EdgeInsetsDirectional.only(start: 56, bottom: 16), + title: Text('About Nanga Kathai', style: text.titleLarge), + background: Stack( + fit: StackFit.expand, + children: [ + Image.asset('assets/images/nilgiris_valley.jpg', fit: BoxFit.cover), + const PhotoScrim(stops: [0.3, 1]), + ], + ), + ), + ), + SliverPadding( + padding: const EdgeInsets.fromLTRB(20, 20, 20, 40), + sliver: SliverList.list( + children: [ + Text('Our Songs. Our Stories. Our Legacy.', style: text.headlineSmall), + const SizedBox(height: 4), + Text('The Home of Badaga Music', style: text.titleMedium?.copyWith(color: NkColors.sun)), + const SizedBox(height: 16), + p('Music has always been at the heart of Badaga life.'), + p('It echoes through our festivals, fills our homes during celebrations, and carries the stories, emotions, and traditions of generations.'), + p('Nanga Kathai, meaning “Our Song” in Badaga, was created to give these voices a home of their own.'), + p('A place where the songs we grew up listening to live alongside the music being created today. Where our culture is celebrated, our artists are recognised, and our musical heritage continues to inspire generations to come.'), + heading('Rooted in Our Community'), + p('Nanga Kathai was born from a deep sense of belonging, gratitude, and responsibility towards the Badaga community.'), + p('Growing up surrounded by the values, traditions, and stories of our people, we witnessed the dedication of generations who worked to nurture and strengthen our community. Their contributions taught us that culture is not simply something we inherit. It is something we have a responsibility to preserve, celebrate, and pass forward.'), + p('Inspired by that spirit, we wanted to contribute in our own way.'), + p('Through Metatroncube Software Solutions, we are bringing together our love for technology and our respect for Badaga heritage to create something meaningful for our people.'), + strong('Nanga Kathai is our small contribution to a community that has given us so much.'), + heading('Celebrating the Artists Behind Our Music'), + p('Behind every memorable song is a voice, a story, and the creativity of talented individuals.'), + p('For generations, Badaga singers, lyricists, composers, and musicians have enriched our culture through their art. Their music has accompanied our celebrations, connected families, and become part of our collective memories.'), + p('Yet, with the transition from cassettes and CDs to digital platforms, opportunities for many regional artists to earn a sustainable income have become increasingly limited.'), + p('We believe our artists deserve more than appreciation alone.'), + p('They deserve recognition, meaningful opportunities, and a platform that values their contribution.'), + p('Our vision is to help build a sustainable ecosystem for Badaga music, where established creators can continue to thrive and emerging artists feel encouraged to share their talent.'), + p('As Nanga Kathai grows, we aim to introduce a subscription-based revenue-sharing model that allows verified artists to benefit from the music their audiences enjoy.'), + heading('Preserving Yesterday. Inspiring Tomorrow.'), + p('Some songs take us back to childhood. Some remind us of our parents and grandparents. Others bring back memories of celebrations, friendships, and the beautiful hills we call home.'), + p('These songs are more than recordings. They are part of our shared cultural memory.'), + p('Our journey begins with a treasured family collection of Badaga songs carefully gathered and preserved over many years.'), + p('We hope to make these melodies accessible to Badagas everywhere while honouring the artists and families who made them possible.'), + p('If you are an artist, rights holder, or family member associated with a recording in our collection, we welcome you to connect with us to ensure appropriate recognition, discuss permissions, or request its removal.'), + heading('A Home for Every Badaga Voice'), + p('Whether you live among the hills of the Nilgiris or thousands of miles away, music has a beautiful way of bringing us closer to our roots.'), + p('We hope Nanga Kathai becomes a place where generations connect, forgotten melodies are rediscovered, new voices find their audience, and our musical traditions continue to flourish.'), + p('This is not just about building a music platform.'), + strong('It is about celebrating where we come from, honouring those who came before us, and creating something meaningful for those who come after us.'), + const SizedBox(height: 20), + Text('Nanga Kathai. Our Song. Our Heritage. Our Future.', + textAlign: TextAlign.center, style: text.titleLarge?.copyWith(color: NkColors.sun)), + heading('Get in touch'), + Text('support@nkbmusic.com', style: body?.copyWith(color: NkColors.sun)), + const SizedBox(height: 28), + const Divider(color: NkColors.outline), + const SizedBox(height: 12), + Text( + 'Landscape photos from Unsplash: Vaisakh Satheesan, Ravi Raj and Harshal More.', + style: text.bodySmall?.copyWith(color: NkColors.muted), + ), + const SizedBox(height: 8), + Row( + children: [ + Text('Version 1.0.0', style: text.bodySmall?.copyWith(color: NkColors.muted)), + const Spacer(), + TextButton( + onPressed: () => showLicensePage( + context: context, + applicationName: 'Nanga Kathai', + applicationVersion: '1.0.0', + ), + child: const Text('Open-source licences'), + ), + ], + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/album/album_screen.dart b/lib/features/album/album_screen.dart new file mode 100644 index 0000000..290dbf5 --- /dev/null +++ b/lib/features/album/album_screen.dart @@ -0,0 +1,172 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:go_router/go_router.dart'; + +import '../../app/router.dart'; +import '../../app/theme.dart'; +import '../../audio/player_providers.dart'; +import '../../core/models.dart'; +import '../../offline/download_store.dart'; +import '../../shared/widgets/artwork.dart'; +import '../../shared/widgets/catalog_builder.dart'; +import '../../shared/widgets/mini_player.dart'; +import '../../shared/widgets/section_header.dart'; +import '../../shared/widgets/track_tile.dart'; + +class AlbumScreen extends ConsumerWidget { + const AlbumScreen({super.key, required this.albumId}); + + final String albumId; + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Scaffold( + bottomNavigationBar: const SafeArea(child: MiniPlayer()), + body: CatalogBuilder(builder: (catalog) { + final album = catalog.albumsById[albumId]; + if (album == null) { + return const Center( + child: EmptyState( + icon: Icons.album_outlined, + title: 'Album not found', + message: 'It may have been removed.', + ), + ); + } + final tracks = catalog.byAlbum(albumId); + final total = tracks.fold(Duration.zero, (sum, t) => sum + t.duration); + final controller = ref.read(playerControllerProvider); + return CustomScrollView( + slivers: [ + const SliverAppBar(pinned: true), + SliverToBoxAdapter( + child: Column( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(12), + child: SizedBox.square(dimension: 200, child: AlbumCover(album: album, size: 200)), + ), + const SizedBox(height: 16), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Text(album.title, + textAlign: TextAlign.center, style: Theme.of(context).textTheme.headlineSmall), + ), + const SizedBox(height: 4), + Text( + [ + if (album.year != null) '${album.year}', + '${tracks.length} songs', + '${total.inMinutes} min', + ].join(' · '), + style: const TextStyle(color: NkColors.muted), + ), + ], + ), + ), + SliverPadding( + padding: const EdgeInsets.fromLTRB(18, 12, 18, 8), + sliver: SliverToBoxAdapter( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _DownloadAlbumButton(tracks: tracks), + const SizedBox(width: 8), + IconButton( + tooltip: 'Shuffle', + icon: const Icon(Icons.shuffle, color: NkColors.sun), + onPressed: tracks.isEmpty ? null : () => controller.playTracks(tracks, shuffle: true), + ), + const SizedBox(width: 16), + IconButton.filled( + tooltip: 'Play all', + style: IconButton.styleFrom(fixedSize: const Size(56, 56)), + onPressed: tracks.isEmpty ? null : () => controller.playTracks(tracks), + icon: const Icon(Icons.play_arrow_rounded, size: 30), + ), + ], + ), + ), + ), + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 18), + sliver: SliverList.builder( + itemCount: tracks.length, + itemBuilder: (_, i) => TrackTile(track: tracks[i], onTap: () => controller.playTracks(tracks, index: i)), + ), + ), + const SliverToBoxAdapter(child: SizedBox(height: 24)), + ], + ); + }), + ); + } +} + +class _DownloadAlbumButton extends ConsumerWidget { + const _DownloadAlbumButton({required this.tracks}); + + final List tracks; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final d = ref.watch(downloadsProvider); + final done = tracks.where((t) => d.isDownloaded(t.id)).length; + final busy = tracks.any((t) => d.progress.containsKey(t.id)); + final all = tracks.isNotEmpty && done == tracks.length; + return IconButton( + tooltip: all ? 'Downloaded' : 'Download album', + icon: busy + ? const SizedBox.square(dimension: 22, child: CircularProgressIndicator(strokeWidth: 2.5)) + : Icon(all ? Icons.download_done_rounded : Icons.download_rounded, + color: all ? NkColors.teaLeaf : NkColors.cream), + onPressed: all || busy || tracks.isEmpty + ? null + : () { + if (!isSignedIn) { + context.push('/login?mode=signup'); + return; + } + ref.read(playerControllerProvider).download(tracks); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Downloading ${tracks.length - done} songs. Find them in Downloads.')), + ); + }, + ); + } +} + +/// Square album card for horizontal rows and grids. +class AlbumCard extends StatelessWidget { + const AlbumCard({super.key, required this.album, required this.songCount, required this.onTap, this.size = 140}); + + final Album album; + final int songCount; + final VoidCallback onTap; + final double size; + + @override + Widget build(BuildContext context) { + return InkWell( + borderRadius: BorderRadius.circular(10), + onTap: onTap, + child: SizedBox( + width: size, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(10), + child: SizedBox.square(dimension: size, child: AlbumCover(album: album, size: size)), + ), + const SizedBox(height: 6), + Text(album.title, maxLines: 1, overflow: TextOverflow.ellipsis, + style: const TextStyle(fontWeight: FontWeight.w600)), + Text('$songCount songs', style: const TextStyle(color: NkColors.muted, fontSize: 12)), + ], + ), + ), + ); + } +} diff --git a/lib/features/artist/artist_screen.dart b/lib/features/artist/artist_screen.dart new file mode 100644 index 0000000..650a89d --- /dev/null +++ b/lib/features/artist/artist_screen.dart @@ -0,0 +1,136 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../app/theme.dart'; +import '../../audio/player_providers.dart'; +import '../../core/models.dart'; +import '../../shared/widgets/artwork.dart'; +import '../../shared/widgets/catalog_builder.dart'; +import '../../shared/widgets/mini_player.dart'; +import '../../shared/widgets/section_header.dart'; +import '../../shared/widgets/track_tile.dart'; + +class ArtistScreen extends ConsumerWidget { + const ArtistScreen({super.key, required this.artistId}); + + final String artistId; + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Scaffold( + bottomNavigationBar: const SafeArea(child: MiniPlayer()), + body: CatalogBuilder(builder: (catalog) { + final artist = catalog.artistsById[artistId]; + if (artist == null) { + return const SafeArea( + child: EmptyState(icon: Icons.person_off_outlined, title: 'Singer not found'), + ); + } + final songs = catalog.byArtist(artistId); + final controller = ref.read(playerControllerProvider); + final subtitle = ['Singer', ...songs.map((s) => s.occasion.label).toSet(), ?artist.hatti].join(' · '); + + return CustomScrollView( + slivers: [ + SliverAppBar( + pinned: true, + expandedHeight: 320, + backgroundColor: NkColors.ground, + flexibleSpace: FlexibleSpaceBar( + background: Stack( + fit: StackFit.expand, + children: [ + if (artist.photoUrl != null) + CachedNetworkImage(imageUrl: artist.photoUrl!, fit: BoxFit.cover) + else + Image.asset('assets/images/nilgiris_tea.jpg', fit: BoxFit.cover, alignment: const Alignment(0, -0.2)), + const PhotoScrim(stops: [0.3, 1]), + if (artist.photoUrl == null) + Positioned( + right: 16, + top: MediaQuery.paddingOf(context).top + 12, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: NkColors.ground.withValues(alpha: 0.7), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: NkColors.teaLeaf), + ), + child: const Text('Singer photo pending', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w700)), + ), + ), + Positioned( + left: 20, + right: 20, + bottom: 16, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(artist.name, style: Theme.of(context).textTheme.headlineLarge), + const SizedBox(height: 6), + Text(subtitle, style: const TextStyle(color: NkColors.mutedOnPhoto, fontSize: 15)), + ], + ), + ), + ], + ), + ), + ), + SliverPadding( + padding: const EdgeInsets.fromLTRB(20, 12, 20, 8), + sliver: SliverToBoxAdapter( + child: Row( + children: [ + Text('${songs.length} songs', style: const TextStyle(color: NkColors.muted)), + const Spacer(), + IconButton( + tooltip: 'Shuffle', + icon: const Icon(Icons.shuffle, color: NkColors.sun), + onPressed: songs.isEmpty ? null : () => controller.playTracks(songs, shuffle: true), + ), + IconButton.filled( + tooltip: 'Play all', + style: IconButton.styleFrom(fixedSize: const Size(56, 56)), + onPressed: songs.isEmpty ? null : () => controller.playTracks(songs), + icon: const Icon(Icons.play_arrow_rounded, size: 30), + ), + ], + ), + ), + ), + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 20), + sliver: SliverList.builder( + itemCount: songs.length, + itemBuilder: (_, i) => TrackTile( + track: songs[i], + subtitle: '${songs[i].occasion.label} · ${formatDuration(songs[i].duration)}', + onTap: () => controller.playTracks(songs, index: i), + ), + ), + ), + if (artist.bio != null) + SliverPadding( + padding: const EdgeInsets.all(20), + sliver: SliverToBoxAdapter( + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration(color: NkColors.surface, borderRadius: BorderRadius.circular(16)), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('About', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 6), + Text(artist.bio!, style: const TextStyle(color: Color(0xFFC9D6CE), height: 1.5)), + ], + ), + ), + ), + ), + ], + ); + }), + ); + } +} diff --git a/lib/features/auth/login_screen.dart b/lib/features/auth/login_screen.dart new file mode 100644 index 0000000..d8d30aa --- /dev/null +++ b/lib/features/auth/login_screen.dart @@ -0,0 +1,386 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +import '../../app/router.dart'; +import '../../app/theme.dart'; +import '../../core/env.dart'; +import '../../shared/widgets/artwork.dart'; + +enum _Step { + signIn, + signUp, + verifySignUp, // code from the sign-up email + codeRequest, // sign in with an emailed code instead of a password + codeVerify, + forgot, + resetVerify, // code from the reset email + new password +} + +/// Sign up (name, email, password, then an email code), sign in with a password, +/// sign in with a code, reset password, or preview without an account. +class LoginScreen extends ConsumerStatefulWidget { + const LoginScreen({super.key, this.startWithSignUp = false}); + + final bool startWithSignUp; + + @override + ConsumerState createState() => _LoginScreenState(); +} + +class _LoginScreenState extends ConsumerState { + final _name = TextEditingController(); + final _email = TextEditingController(); + final _password = TextEditingController(); + final _code = TextEditingController(); + late _Step _step = widget.startWithSignUp ? _Step.signUp : _Step.signIn; + bool _busy = false; + bool _showPassword = false; + String? _error; + String? _info; + + GoTrueClient get _auth => Supabase.instance.client.auth; + String get _emailText => _email.text.trim(); + + @override + void dispose() { + for (final c in [_name, _email, _password, _code]) { + c.dispose(); + } + super.dispose(); + } + + bool get _emailValid => RegExp(r'^[^@\s]+@[^@\s]+\.[^@\s]+$').hasMatch(_emailText); + bool get _passwordValid => _password.text.length >= 8; + bool get _codeValid => RegExp(r'^\d{6}$').hasMatch(_code.text.trim()); + + void _go(_Step step) => setState(() { + _step = step; + _error = null; + _info = null; + _code.clear(); + }); + + Future _run(Future Function() action) async { + if (!Env.hasSupabase) { + setState(() => _error = 'This build is not connected to the server. Tap "Preview without an account".'); + return; + } + setState(() { + _busy = true; + _error = null; + _info = null; + }); + try { + await action(); + } on AuthException catch (e) { + setState(() => _error = _friendly(e)); + } catch (_) { + setState(() => _error = 'Something went wrong. Check your connection and try again.'); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + String _friendly(AuthException e) { + final m = e.message.toLowerCase(); + if (m.contains('invalid login credentials')) { + return 'Email or password is wrong. If you never set a password, use "Email me a code" ' + 'and set one in your profile.'; + } + if (m.contains('email not confirmed')) return 'Please confirm your email first. We can send a new code.'; + if (m.contains('expired') || m.contains('invalid') && m.contains('token')) { + return 'That code is wrong or has expired. Check the latest email, or send a new code.'; + } + if (m.contains('rate limit') || m.contains('security purposes')) { + return 'Please wait a minute before asking for another code.'; + } + return e.message; + } + + Future _signIn() => _run(() async { + await _auth.signInWithPassword(email: _emailText, password: _password.text); + _finish(); + }); + + Future _signUp() => _run(() async { + final res = await _auth.signUp( + email: _emailText, + password: _password.text, + data: {'display_name': _name.text.trim()}, + ); + // An existing, confirmed email comes back as a user with no identities. + if (res.user != null && (res.user!.identities?.isEmpty ?? false)) { + setState(() => _error = 'An account with this email already exists. Sign in instead.'); + return; + } + _go(_Step.verifySignUp); + }); + + Future _verifySignUp() => _run(() async { + await _auth.verifyOTP(type: OtpType.signup, email: _emailText, token: _code.text.trim()); + _finish(); + }); + + Future _resendSignUp() => _run(() async { + await _auth.resend(type: OtpType.signup, email: _emailText); + setState(() => _info = 'New code sent.'); + }); + + Future _requestCode() => _run(() async { + await _auth.signInWithOtp(email: _emailText, shouldCreateUser: false); + _go(_Step.codeVerify); + }); + + Future _verifyCode() => _run(() async { + await _auth.verifyOTP(type: OtpType.email, email: _emailText, token: _code.text.trim()); + _finish(); + }); + + Future _requestReset() => _run(() async { + await _auth.resetPasswordForEmail(_emailText); + _go(_Step.resetVerify); + }); + + Future _verifyReset() => _run(() async { + await _auth.verifyOTP(type: OtpType.recovery, email: _emailText, token: _code.text.trim()); + await _auth.updateUser(UserAttributes(password: _password.text)); + _finish(); + }); + + void _finish() { + ref.read(onboardingProvider.notifier).complete(); + if (mounted) context.go('/home'); + } + + void _preview() { + ref.read(onboardingProvider.notifier).complete(); + context.go('/home'); + } + + ({String title, String subtitle, String action, VoidCallback? onAction}) get _copy => switch (_step) { + _Step.signIn => ( + title: 'Welcome back', + subtitle: 'Sign in to listen to full songs and keep your likes.', + action: 'Sign in', + onAction: _emailValid && _password.text.isNotEmpty ? _signIn : null, + ), + _Step.signUp => ( + title: 'Create your account', + subtitle: 'Free to join. We will email you a code to confirm it is you.', + action: 'Create account', + onAction: _name.text.trim().isNotEmpty && _emailValid && _passwordValid ? _signUp : null, + ), + _Step.verifySignUp => ( + title: 'Check your email', + subtitle: 'Enter the 6-digit code we sent to $_emailText to finish signing up.', + action: 'Verify and continue', + onAction: _codeValid ? _verifySignUp : null, + ), + _Step.codeRequest => ( + title: 'Sign in with a code', + subtitle: 'We will email you a 6-digit code. No password needed.', + action: 'Email me a code', + onAction: _emailValid ? _requestCode : null, + ), + _Step.codeVerify => ( + title: 'Check your email', + subtitle: 'Enter the 6-digit code we sent to $_emailText.', + action: 'Verify and sign in', + onAction: _codeValid ? _verifyCode : null, + ), + _Step.forgot => ( + title: 'Reset your password', + subtitle: 'We will email you a code to set a new password.', + action: 'Email me a reset code', + onAction: _emailValid ? _requestReset : null, + ), + _Step.resetVerify => ( + title: 'Set a new password', + subtitle: 'Enter the code we sent to $_emailText and choose a new password.', + action: 'Save password and sign in', + onAction: _codeValid && _passwordValid ? _verifyReset : null, + ), + }; + + @override + Widget build(BuildContext context) { + final text = Theme.of(context).textTheme; + final copy = _copy; + final askName = _step == _Step.signUp; + final askEmail = const [_Step.signIn, _Step.signUp, _Step.codeRequest, _Step.forgot].contains(_step); + final askPassword = const [_Step.signIn, _Step.signUp, _Step.resetVerify].contains(_step); + final askCode = const [_Step.verifySignUp, _Step.codeVerify, _Step.resetVerify].contains(_step); + final newPassword = _step == _Step.signUp || _step == _Step.resetVerify; + + return Scaffold( + resizeToAvoidBottomInset: true, + body: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SizedBox( + height: 240, + child: Stack( + fit: StackFit.expand, + children: [ + Image.asset('assets/images/nilgiris_village.jpg', + fit: BoxFit.cover, alignment: const Alignment(-0.4, 0.2)), + const PhotoScrim(stops: [0.2, 1]), + SafeArea( + child: Padding( + padding: const EdgeInsets.all(24), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ClipOval(child: Image.asset('assets/images/emblem.png', width: 52, height: 52)), + const SizedBox(width: 12), + Padding( + padding: const EdgeInsets.only(top: 12), + child: Text('Nanga Kathai', style: text.titleLarge?.copyWith(fontWeight: FontWeight.w800)), + ), + ], + ), + ), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 32), + child: AutofillGroup( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (_step == _Step.signIn || _step == _Step.signUp) ...[ + SegmentedButton<_Step>( + segments: const [ + ButtonSegment(value: _Step.signUp, label: Text('Sign up')), + ButtonSegment(value: _Step.signIn, label: Text('Sign in')), + ], + selected: {_step}, + showSelectedIcon: false, + onSelectionChanged: _busy ? null : (s) => _go(s.first), + ), + const SizedBox(height: 20), + ], + Text(copy.title, style: text.headlineMedium), + const SizedBox(height: 6), + Text(copy.subtitle, style: text.bodyLarge?.copyWith(color: NkColors.muted, height: 1.5)), + const SizedBox(height: 20), + if (askName) ...[ + TextField( + controller: _name, + textCapitalization: TextCapitalization.words, + autofillHints: const [AutofillHints.name], + textInputAction: TextInputAction.next, + onChanged: (_) => setState(() {}), + decoration: const InputDecoration(labelText: 'Your name'), + ), + const SizedBox(height: 12), + ], + if (askEmail) ...[ + TextField( + controller: _email, + keyboardType: TextInputType.emailAddress, + autofillHints: const [AutofillHints.email], + textInputAction: askPassword ? TextInputAction.next : TextInputAction.done, + onChanged: (_) => setState(() {}), + decoration: const InputDecoration(labelText: 'Email', hintText: 'you@example.com'), + ), + const SizedBox(height: 12), + ], + if (askCode) ...[ + TextField( + controller: _code, + keyboardType: TextInputType.number, + autofillHints: const [AutofillHints.oneTimeCode], + maxLength: 6, + onChanged: (_) => setState(() {}), + style: const TextStyle(fontSize: 24, letterSpacing: 8, fontWeight: FontWeight.w700), + decoration: const InputDecoration(labelText: 'Code', counterText: ''), + ), + const SizedBox(height: 12), + ], + if (askPassword) + TextField( + controller: _password, + obscureText: !_showPassword, + autofillHints: [newPassword ? AutofillHints.newPassword : AutofillHints.password], + onChanged: (_) => setState(() {}), + onSubmitted: (_) => copy.onAction?.call(), + decoration: InputDecoration( + labelText: newPassword ? 'Choose a password' : 'Password', + helperText: newPassword ? 'At least 8 characters' : null, + suffixIcon: IconButton( + tooltip: _showPassword ? 'Hide password' : 'Show password', + icon: Icon(_showPassword ? Icons.visibility_off : Icons.visibility), + onPressed: () => setState(() => _showPassword = !_showPassword), + ), + ), + ), + if (_error != null) ...[ + const SizedBox(height: 12), + Text(_error!, style: const TextStyle(color: Color(0xFFF2A69B))), + ], + if (_info != null) ...[ + const SizedBox(height: 12), + Text(_info!, style: const TextStyle(color: NkColors.teaLeaf)), + ], + const SizedBox(height: 20), + FilledButton( + onPressed: _busy ? null : copy.onAction, + child: _busy + ? const SizedBox.square(dimension: 22, child: CircularProgressIndicator(strokeWidth: 2.5)) + : Text(copy.action), + ), + const SizedBox(height: 8), + ..._links(), + const SizedBox(height: 16), + const Row(children: [ + Expanded(child: Divider(color: NkColors.outline)), + Padding( + padding: EdgeInsets.symmetric(horizontal: 12), + child: Text('or', style: TextStyle(color: NkColors.muted)), + ), + Expanded(child: Divider(color: NkColors.outline)), + ]), + const SizedBox(height: 16), + OutlinedButton(onPressed: _busy ? null : _preview, child: const Text('Preview without an account')), + const SizedBox(height: 6), + const Text( + 'Guests hear a 30-second preview of each song.', + textAlign: TextAlign.center, + style: TextStyle(color: NkColors.muted, fontSize: 13), + ), + ], + ), + ), + ), + ], + ), + ), + ); + } + + List _links() { + TextButton link(String label, VoidCallback onTap) => + TextButton(onPressed: _busy ? null : onTap, child: Text(label)); + return switch (_step) { + _Step.signIn => [ + link('Forgot password?', () => _go(_Step.forgot)), + link('Email me a code instead', () => _go(_Step.codeRequest)), + ], + _Step.verifySignUp => [ + link('Send a new code', _resendSignUp), + link('Use a different email', () => _go(_Step.signUp)), + ], + _Step.codeVerify || _Step.resetVerify => [ + link('Use a different email', () => _go(_step == _Step.codeVerify ? _Step.codeRequest : _Step.forgot)), + ], + _Step.codeRequest || _Step.forgot => [link('Back to sign in', () => _go(_Step.signIn))], + _Step.signUp => const [], + }; + } +} diff --git a/lib/features/downloads/downloads_screen.dart b/lib/features/downloads/downloads_screen.dart new file mode 100644 index 0000000..6c040a3 --- /dev/null +++ b/lib/features/downloads/downloads_screen.dart @@ -0,0 +1,217 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../app/theme.dart'; +import '../../audio/player_providers.dart'; +import '../../core/models.dart'; +import '../../offline/download_store.dart'; +import '../../shared/widgets/artwork.dart'; +import '../../shared/widgets/section_header.dart'; + +/// Songs saved on this phone. Works with no internet: everything shown here +/// comes from the download index, not the server. +class DownloadsScreen extends ConsumerWidget { + const DownloadsScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final d = ref.watch(downloadsProvider); + final controller = ref.read(playerControllerProvider); + final store = ref.read(downloadStoreProvider); + final text = Theme.of(context).textTheme; + final current = ref.watch(mediaItemProvider).value?.id; + + return SafeArea( + bottom: false, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(18, 12, 8, 4), + child: Row( + children: [ + Expanded(child: Text('Downloads', style: text.headlineMedium)), + if (d.entries.isNotEmpty) + PopupMenuButton( + tooltip: 'More', + itemBuilder: (_) => [ + PopupMenuItem( + onTap: () => _confirmRemoveAll(context, store), + child: const Text('Remove all downloads'), + ), + ], + ), + ], + ), + ), + if (d.entries.isNotEmpty || d.progress.isNotEmpty) + Padding( + padding: const EdgeInsets.fromLTRB(18, 0, 18, 8), + child: Row( + children: [ + Expanded( + child: Text( + '${d.entries.length} songs · ${_size(d.totalBytes)}' + '${d.progress.isEmpty ? '' : ' · ${d.progress.length} downloading'}', + style: const TextStyle(color: NkColors.muted), + ), + ), + if (d.entries.isNotEmpty) ...[ + IconButton( + tooltip: 'Shuffle', + icon: const Icon(Icons.shuffle, color: NkColors.sun), + onPressed: () { + final list = [...d.entries]..shuffle(); + controller.playDownloads(list); + }, + ), + IconButton.filled( + tooltip: 'Play all', + onPressed: () => controller.playDownloads(d.entries), + icon: const Icon(Icons.play_arrow_rounded), + ), + ], + ], + ), + ), + Expanded( + child: d.entries.isEmpty && d.progress.isEmpty + ? const Center( + child: SingleChildScrollView( + child: EmptyState( + icon: Icons.download_for_offline_outlined, + title: 'No downloads yet', + message: 'Tap ⋮ on any song, or "Download album", to listen without signal in the hills. ' + 'Downloads play only inside Nanga Kathai.', + ), + ), + ) + : ListView( + padding: const EdgeInsets.fromLTRB(18, 0, 18, 24), + children: [ + for (final p in d.progress.entries) + Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + children: [ + const SizedBox.square( + dimension: 52, + child: Center(child: Icon(Icons.downloading_rounded, color: NkColors.muted)), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(p.value == 0 ? 'Waiting…' : 'Downloading ${(p.value * 100).round()}%', + style: const TextStyle(color: NkColors.muted)), + const SizedBox(height: 6), + LinearProgressIndicator(value: p.value == 0 ? null : p.value), + ], + ), + ), + ], + ), + ), + for (var i = 0; i < d.entries.length; i++) + _DownloadTile( + entry: d.entries[i], + isCurrent: d.entries[i].trackId == current, + onTap: () => controller.playDownloads(d.entries, index: i), + onRemove: () => store.remove(d.entries[i].trackId), + ), + ], + ), + ), + ], + ), + ); + } + + static String _size(int bytes) => + bytes >= 1 << 30 ? '${(bytes / (1 << 30)).toStringAsFixed(1)} GB' : '${(bytes / (1 << 20)).round()} MB'; + + void _confirmRemoveAll(BuildContext context, DownloadStore store) { + showDialog( + context: context, + builder: (dialog) => AlertDialog( + title: const Text('Remove all downloads?'), + content: const Text('The songs stay in the app; you can download them again any time.'), + actions: [ + TextButton(onPressed: () => Navigator.pop(dialog), child: const Text('Cancel')), + FilledButton( + onPressed: () { + store.removeAll(); + Navigator.pop(dialog); + }, + child: const Text('Remove'), + ), + ], + ), + ); + } +} + +class _DownloadTile extends StatelessWidget { + const _DownloadTile({required this.entry, required this.isCurrent, required this.onTap, required this.onRemove}); + + final DownloadEntry entry; + final bool isCurrent; + final VoidCallback onTap; + final VoidCallback onRemove; + + @override + Widget build(BuildContext context) { + final text = Theme.of(context).textTheme; + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(10), + child: SizedBox.square( + dimension: 52, + child: OccasionCover(occasion: Occasion.fromId(entry.occasion), size: 52), + ), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(entry.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: text.titleMedium?.copyWith(color: isCurrent ? NkColors.sun : NkColors.cream)), + const SizedBox(height: 2), + Row( + children: [ + const Icon(Icons.download_done_rounded, size: 16, color: NkColors.teaLeaf), + const SizedBox(width: 4), + Expanded( + child: Text( + '${entry.credit} · ${formatDuration(Duration(milliseconds: entry.durationMs))}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: text.bodyMedium?.copyWith(color: NkColors.muted), + ), + ), + ], + ), + ], + ), + ), + IconButton( + tooltip: 'Remove download', + icon: const Icon(Icons.delete_outline, color: NkColors.muted), + onPressed: onRemove, + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/explore/explore_screen.dart b/lib/features/explore/explore_screen.dart new file mode 100644 index 0000000..2e6cb9f --- /dev/null +++ b/lib/features/explore/explore_screen.dart @@ -0,0 +1,127 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../app/theme.dart'; +import '../../core/models.dart'; +import '../../data/catalog_repository.dart'; +import '../../shared/widgets/artwork.dart'; +import '../../shared/widgets/section_header.dart'; +import '../album/album_screen.dart'; + +/// Landscape photos used for some occasion tiles; the rest use their occasion colour. +const occasionPhotos = { + Occasion.folk: ('assets/images/nilgiris_village.jpg', Alignment(-0.3, 0.2)), + Occasion.habba: ('assets/images/nilgiris_valley.jpg', Alignment(0.6, 0.7)), + Occasion.huttu: ('assets/images/nilgiris_tea.jpg', Alignment(0, 0.4)), +}; + +class ExploreScreen extends ConsumerWidget { + const ExploreScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final catalog = ref.watch(catalogProvider).value; + final albums = catalog?.albums ?? const []; + return SafeArea( + bottom: false, + child: CustomScrollView( + slivers: [ + SliverPadding( + padding: const EdgeInsets.fromLTRB(18, 12, 8, 8), + sliver: SliverToBoxAdapter( + child: Row( + children: [ + Expanded(child: Text('Explore', style: Theme.of(context).textTheme.headlineMedium)), + IconButton(tooltip: 'Search', icon: const Icon(Icons.search), onPressed: () => context.push('/search')), + ], + ), + ), + ), + SliverPadding( + padding: const EdgeInsets.fromLTRB(18, 8, 18, 24), + sliver: SliverGrid.count( + crossAxisCount: 2, + mainAxisSpacing: 12, + crossAxisSpacing: 12, + childAspectRatio: 1.08, + children: [for (final o in Occasion.values) _CategoryTile(occasion: o)], + ), + ), + if (albums.isNotEmpty) ...[ + const SliverPadding( + padding: EdgeInsets.fromLTRB(18, 0, 18, 8), + sliver: SliverToBoxAdapter(child: SectionHeader('All albums')), + ), + SliverPadding( + padding: const EdgeInsets.fromLTRB(18, 0, 18, 24), + sliver: SliverGrid.builder( + gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 180, + mainAxisSpacing: 16, + crossAxisSpacing: 12, + childAspectRatio: 0.78, + ), + itemCount: albums.length, + itemBuilder: (_, i) => LayoutBuilder( + builder: (context, box) => AlbumCard( + album: albums[i], + size: box.maxWidth, + songCount: catalog!.byAlbum(albums[i].id).length, + onTap: () => context.push('/album/${albums[i].id}'), + ), + ), + ), + ), + ], + ], + ), + ); + } +} + +class _CategoryTile extends StatelessWidget { + const _CategoryTile({required this.occasion}); + + final Occasion occasion; + + @override + Widget build(BuildContext context) { + final photo = occasionPhotos[occasion]; + return ClipRRect( + borderRadius: BorderRadius.circular(18), + child: Material( + color: occasion.color, + child: InkWell( + onTap: () => context.push('/occasion/${occasion.id}'), + child: Stack( + fit: StackFit.expand, + children: [ + if (photo != null) ...[ + Image.asset(photo.$1, fit: BoxFit.cover, alignment: photo.$2, cacheWidth: 500), + const PhotoScrim(stops: [0.35, 1]), + ] else + Positioned( + right: 12, + top: 12, + child: Icon(occasion.icon, size: 52, color: NkColors.cream.withValues(alpha: 0.5)), + ), + Positioned( + left: 14, + right: 14, + bottom: 12, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(occasion.label, style: Theme.of(context).textTheme.titleLarge?.copyWith(fontSize: 18)), + Text(occasion.subtitle, style: const TextStyle(fontSize: 13, color: NkColors.mutedOnPhoto)), + ], + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/features/explore/occasion_screen.dart b/lib/features/explore/occasion_screen.dart new file mode 100644 index 0000000..7edc423 --- /dev/null +++ b/lib/features/explore/occasion_screen.dart @@ -0,0 +1,89 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../app/theme.dart'; +import '../../audio/player_providers.dart'; +import '../../core/models.dart'; +import '../../shared/widgets/catalog_builder.dart'; +import '../../shared/widgets/mini_player.dart'; +import '../../shared/widgets/section_header.dart'; +import '../../shared/widgets/track_tile.dart'; + +class OccasionScreen extends ConsumerWidget { + const OccasionScreen({super.key, required this.occasion}); + + final Occasion occasion; + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Scaffold( + bottomNavigationBar: const SafeArea(child: MiniPlayer()), + body: CatalogBuilder(builder: (catalog) { + final tracks = catalog.byOccasion(occasion); + final controller = ref.read(playerControllerProvider); + return CustomScrollView( + slivers: [ + SliverAppBar( + pinned: true, + expandedHeight: 200, + backgroundColor: occasion.color, + flexibleSpace: FlexibleSpaceBar( + titlePadding: const EdgeInsetsDirectional.only(start: 56, bottom: 16), + title: Text(occasion.label, style: Theme.of(context).textTheme.titleLarge), + background: ColoredBox( + color: occasion.color, + child: Align( + alignment: const Alignment(0.85, 0), + child: Icon(occasion.icon, size: 110, color: NkColors.cream.withValues(alpha: 0.25)), + ), + ), + ), + ), + if (tracks.isEmpty) + const SliverFillRemaining( + hasScrollBody: false, + child: EmptyState( + icon: Icons.library_music_outlined, + title: 'No songs here yet', + message: 'Songs for this occasion appear here once they are approved.', + ), + ) + else ...[ + SliverPadding( + padding: const EdgeInsets.fromLTRB(18, 16, 18, 8), + sliver: SliverToBoxAdapter( + child: Row( + children: [ + Expanded( + child: Text('${occasion.subtitle} · ${tracks.length} songs', + style: const TextStyle(color: NkColors.muted)), + ), + IconButton( + tooltip: 'Shuffle', + icon: const Icon(Icons.shuffle, color: NkColors.sun), + onPressed: () => controller.playTracks(tracks, shuffle: true), + ), + IconButton.filled( + tooltip: 'Play all', + style: IconButton.styleFrom(fixedSize: const Size(56, 56)), + onPressed: () => controller.playTracks(tracks), + icon: const Icon(Icons.play_arrow_rounded, size: 30), + ), + ], + ), + ), + ), + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 18), + sliver: SliverList.builder( + itemCount: tracks.length, + itemBuilder: (_, i) => TrackTile(track: tracks[i], onTap: () => controller.playTracks(tracks, index: i)), + ), + ), + ], + ], + ); + }), + ); + } +} diff --git a/lib/features/home/home_screen.dart b/lib/features/home/home_screen.dart new file mode 100644 index 0000000..c3cb388 --- /dev/null +++ b/lib/features/home/home_screen.dart @@ -0,0 +1,272 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../app/theme.dart'; +import '../../audio/player_providers.dart'; +import '../../core/models.dart'; +import '../../data/user_library.dart'; +import '../album/album_screen.dart'; +import '../../shared/widgets/artwork.dart'; +import '../../shared/widgets/catalog_builder.dart'; +import '../../shared/widgets/section_header.dart'; +import '../../shared/widgets/track_tile.dart'; + +class HomeScreen extends ConsumerWidget { + const HomeScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return CatalogBuilder( + builder: (catalog) { + final recentIds = ref.watch(recentlyPlayedProvider); + final recent = recentIds.map(catalog.trackById).whereType().take(6).toList(); + final controller = ref.read(playerControllerProvider); + // A different mix each day, the same all day. + final now = DateTime.now(); + final picks = ([...catalog.tracks]..shuffle(Random(now.year * 1000 + now.month * 40 + now.day))) + .take(12) + .toList(); + + return CustomScrollView( + slivers: [ + SliverSafeArea( + bottom: false, + sliver: SliverPadding( + padding: const EdgeInsets.fromLTRB(18, 12, 18, 24), + sliver: SliverList.list( + children: [ + const _Header(), + const SizedBox(height: 16), + const _SearchField(), + const SizedBox(height: 20), + _HeroCard(onPlay: () => controller.playTracks(catalog.tracks, shuffle: true)), + if (catalog.albums.isNotEmpty) ...[ + const SizedBox(height: 24), + SectionHeader('Albums', onSeeAll: () => context.go('/explore')), + const SizedBox(height: 8), + SizedBox( + height: 186, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: catalog.albums.length, + separatorBuilder: (_, _) => const SizedBox(width: 12), + itemBuilder: (_, i) { + final album = catalog.albums[i]; + return AlbumCard( + album: album, + songCount: catalog.byAlbum(album.id).length, + onTap: () => context.push('/album/${album.id}'), + ); + }, + ), + ), + ], + const SizedBox(height: 24), + SectionHeader('Explore by occasion', onSeeAll: () => context.go('/explore')), + const SizedBox(height: 8), + SizedBox( + height: 116, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: Occasion.values.length, + separatorBuilder: (_, _) => const SizedBox(width: 12), + itemBuilder: (_, i) => _OccasionTile(occasion: Occasion.values[i]), + ), + ), + const SizedBox(height: 24), + const SectionHeader('Popular singers'), + const SizedBox(height: 8), + SizedBox( + height: 112, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: catalog.artists.length, + separatorBuilder: (_, _) => const SizedBox(width: 14), + itemBuilder: (_, i) { + final a = catalog.artists[i]; + return InkWell( + borderRadius: BorderRadius.circular(12), + onTap: () => context.push('/artist/${a.id}'), + child: SizedBox( + width: 80, + child: Column( + children: [ + SingerAvatar(artist: a), + const SizedBox(height: 8), + Text(a.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600)), + ], + ), + ), + ); + }, + ), + ), + if (recent.isNotEmpty) ...[ + const SizedBox(height: 20), + const SectionHeader('Recently played'), + const SizedBox(height: 4), + for (var i = 0; i < recent.length; i++) + TrackTile(track: recent[i], onTap: () => controller.playTracks(recent, index: i)), + ], + const SizedBox(height: 20), + const SectionHeader('Recommended for you'), + const SizedBox(height: 8), + for (var i = 0; i < picks.length; i++) + Padding( + padding: const EdgeInsets.only(bottom: 10), + child: TrackTile( + card: true, + track: picks[i], + subtitle: '${catalog.albumOf(picks[i])?.title ?? picks[i].occasion.label}' + ' · ${formatDuration(picks[i].duration)}', + onTap: () => controller.playTracks(picks, index: i), + ), + ), + ], + ), + ), + ), + ], + ); + }, + ); + } +} + +class _Header extends StatelessWidget { + const _Header(); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + ClipOval( + child: Container( + color: NkColors.cream, + child: Image.asset('assets/images/emblem.png', width: 40, height: 40, semanticLabel: 'Nanga Kathai'), + ), + ), + const SizedBox(width: 12), + Expanded(child: Text('Nanga Kathai', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w800))), + IconButton( + tooltip: 'Account', + onPressed: () => context.push('/profile'), + icon: const CircleAvatar( + radius: 20, + backgroundColor: NkColors.emblemGreen, + child: Icon(Icons.person_outline, color: NkColors.cream), + ), + ), + ], + ); + } +} + +class _SearchField extends StatelessWidget { + const _SearchField(); + + @override + Widget build(BuildContext context) { + return Material( + color: NkColors.surface, + shape: const StadiumBorder(side: BorderSide(color: NkColors.outline)), + child: InkWell( + customBorder: const StadiumBorder(), + onTap: () => context.push('/search'), + child: const SizedBox( + height: 52, + child: Row( + children: [ + SizedBox(width: 18), + Icon(Icons.search, color: Color(0xFFC9D6CE)), + SizedBox(width: 12), + Expanded(child: Text('Search songs, singers, lyrics…', style: TextStyle(color: NkColors.muted, fontSize: 15))), + ], + ), + ), + ), + ); + } +} + +class _HeroCard extends StatelessWidget { + const _HeroCard({required this.onPlay}); + + final VoidCallback onPlay; + + @override + Widget build(BuildContext context) { + return ClipRRect( + borderRadius: BorderRadius.circular(22), + child: SizedBox( + height: 200, + child: Stack( + fit: StackFit.expand, + children: [ + Image.asset('assets/images/nilgiris_valley.jpg', fit: BoxFit.cover, alignment: const Alignment(0, 0.4)), + const PhotoScrim(begin: Alignment.centerRight, end: Alignment.centerLeft, stops: [0, 0.9]), + Padding( + padding: const EdgeInsets.fromLTRB(18, 22, 96, 18), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Sounds of\nthe Nilgiris', style: Theme.of(context).textTheme.headlineMedium), + const SizedBox(height: 8), + const Text( + 'Songs, stories and traditions of the Badaga people', + style: TextStyle(color: NkColors.mutedOnPhoto, height: 1.4), + ), + ], + ), + ), + Positioned( + right: 18, + bottom: 18, + child: IconButton.filled( + tooltip: 'Shuffle play all songs', + style: IconButton.styleFrom(backgroundColor: NkColors.cream, fixedSize: const Size(56, 56)), + onPressed: onPlay, + icon: const Icon(Icons.play_arrow_rounded, color: NkColors.ground, size: 30), + ), + ), + ], + ), + ), + ); + } +} + +class _OccasionTile extends StatelessWidget { + const _OccasionTile({required this.occasion}); + + final Occasion occasion; + + @override + Widget build(BuildContext context) { + return Material( + color: occasion.color, + borderRadius: BorderRadius.circular(16), + child: InkWell( + borderRadius: BorderRadius.circular(16), + onTap: () => context.push('/occasion/${occasion.id}'), + child: SizedBox( + width: 116, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(occasion.icon, size: 38, color: NkColors.cream), + const SizedBox(height: 10), + Text(occasion.label, style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 15)), + ], + ), + ), + ), + ); + } +} diff --git a/lib/features/library/library_screen.dart b/lib/features/library/library_screen.dart new file mode 100644 index 0000000..69e786a --- /dev/null +++ b/lib/features/library/library_screen.dart @@ -0,0 +1,136 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../app/theme.dart'; +import '../../core/env.dart'; +import '../../audio/player_providers.dart'; +import '../../core/models.dart'; +import '../../data/user_library.dart'; +import '../../shared/widgets/artwork.dart'; +import '../../shared/widgets/catalog_builder.dart'; +import '../../shared/widgets/section_header.dart'; +import '../../shared/widgets/track_tile.dart'; + +enum _LibraryView { liked, recent, singers } + +class LibraryScreen extends ConsumerStatefulWidget { + const LibraryScreen({super.key}); + + @override + ConsumerState createState() => _LibraryScreenState(); +} + +class _LibraryScreenState extends ConsumerState { + _LibraryView _view = _LibraryView.liked; + + @override + Widget build(BuildContext context) { + return SafeArea( + bottom: false, + child: CatalogBuilder(builder: (catalog) { + final liked = ref.watch(likedSongsProvider); + final recent = ref.watch(recentlyPlayedProvider); + final controller = ref.read(playerControllerProvider); + + List resolve(Iterable ids) => ids.map(catalog.trackById).whereType().toList(); + + final Widget content = switch (_view) { + _LibraryView.liked => _TrackList( + tracks: resolve(liked), + empty: const EmptyState( + icon: Icons.favorite_border, + title: 'No liked songs yet', + message: 'Tap the heart on any song to keep it here.', + ), + onPlay: controller.playTracks, + ), + _LibraryView.recent => _TrackList( + tracks: resolve(recent), + empty: const EmptyState(icon: Icons.history, title: 'Nothing played yet'), + onPlay: controller.playTracks, + ), + _LibraryView.singers => ListView( + padding: const EdgeInsets.symmetric(horizontal: 18), + children: [ + for (final a in catalog.artists) + ListTile( + contentPadding: EdgeInsets.zero, + leading: SingerAvatar(artist: a, size: 52), + title: Text(a.name, style: const TextStyle(fontWeight: FontWeight.w700)), + subtitle: Text('${catalog.byArtist(a.id).length} songs', style: const TextStyle(color: NkColors.muted)), + onTap: () => context.push('/artist/${a.id}'), + ), + ], + ), + }; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(18, 12, 8, 12), + child: Row( + children: [ + Expanded(child: Text('Your library', style: Theme.of(context).textTheme.headlineMedium)), + IconButton( + tooltip: 'About Nanga Kathai', + icon: const Icon(Icons.info_outline), + onPressed: () => context.push('/about'), + ), + if (Env.hasSupabase) + IconButton( + tooltip: 'Your profile', + icon: const Icon(Icons.account_circle_outlined), + onPressed: () => context.push('/profile'), + ), + ], + ), + ), + SingleChildScrollView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 18), + child: Row( + children: [ + for (final (view, label) in const [ + (_LibraryView.liked, 'Liked songs'), + (_LibraryView.recent, 'Recently played'), + (_LibraryView.singers, 'Singers'), + ]) + Padding( + padding: const EdgeInsets.only(right: 8), + child: ChoiceChip( + label: Text(label), + selected: _view == view, + onSelected: (_) => setState(() => _view = view), + ), + ), + ], + ), + ), + const SizedBox(height: 8), + Expanded(child: content), + ], + ); + }), + ); + } +} + +class _TrackList extends StatelessWidget { + const _TrackList({required this.tracks, required this.empty, required this.onPlay}); + + final List tracks; + final Widget empty; + final Future Function(List, {int index, bool shuffle}) onPlay; + + @override + Widget build(BuildContext context) { + if (tracks.isEmpty) return Center(child: SingleChildScrollView(child: empty)); + return ListView.builder( + padding: const EdgeInsets.symmetric(horizontal: 18), + itemCount: tracks.length, + itemBuilder: (_, i) => TrackTile(track: tracks[i], onTap: () => onPlay(tracks, index: i)), + ); + } +} diff --git a/lib/features/player/now_playing_screen.dart b/lib/features/player/now_playing_screen.dart new file mode 100644 index 0000000..993d7e1 --- /dev/null +++ b/lib/features/player/now_playing_screen.dart @@ -0,0 +1,446 @@ +import 'dart:async'; + +import 'package:audio_service/audio_service.dart'; +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../app/theme.dart'; +import '../../audio/player_providers.dart'; +import '../../audio/sound_settings.dart'; +import '../../core/models.dart'; +import '../../data/catalog_repository.dart'; +import '../../data/user_library.dart'; +import '../../shared/widgets/artwork.dart'; +import '../explore/explore_screen.dart'; +import 'sound_sheet.dart'; + +/// Pauses playback after the chosen duration. Null when off. +class SleepTimer extends Notifier { + Timer? _timer; + + @override + DateTime? build() { + ref.onDispose(() => _timer?.cancel()); + return null; + } + + void start(Duration d) { + _timer?.cancel(); + _timer = Timer(d, () { + ref.read(audioHandlerProvider).pause(); + state = null; + }); + state = DateTime.now().add(d); + } + + void cancel() { + _timer?.cancel(); + state = null; + } +} + +final sleepTimerProvider = NotifierProvider(SleepTimer.new); + +class NowPlayingScreen extends ConsumerWidget { + const NowPlayingScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final item = ref.watch(mediaItemProvider).value; + if (item == null) { + return Scaffold( + appBar: AppBar(backgroundColor: NkColors.ground), + body: const Center(child: Text('Nothing playing', style: TextStyle(color: NkColors.muted))), + ); + } + + final state = ref.watch(playbackStateProvider).value; + final track = ref.watch(currentTrackProvider); + final liked = ref.watch(likedSongsProvider).contains(item.id); + final occasion = Occasion.fromId(item.extras?['occasion'] as String?); + final handler = ref.read(audioHandlerProvider); + final controller = ref.read(playerControllerProvider); + final text = Theme.of(context).textTheme; + final height = MediaQuery.sizeOf(context).height; + + return Scaffold( + body: Stack( + children: [ + Positioned( + left: 0, + right: 0, + top: 0, + height: height * 0.52, + child: Hero(tag: 'now-playing-art', child: _HeroImage(item: item, occasion: occasion)), + ), + Positioned( + left: 0, + right: 0, + top: 0, + height: height * 0.52 + 2, + child: const PhotoScrim(stops: [0.45, 1]), + ), + SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 22), + child: Column( + children: [ + Row( + children: [ + IconButton( + tooltip: 'Close player', + iconSize: 30, + icon: const Icon(Icons.keyboard_arrow_down_rounded), + onPressed: () => context.pop(), + ), + const Spacer(), + if (track != null) + IconButton( + tooltip: 'Go to singer', + icon: const Icon(Icons.person_outline), + onPressed: () => context.push('/artist/${track.artistIds.first}'), + ), + ], + ), + const Spacer(), + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(item.title, style: text.headlineSmall, maxLines: 2, overflow: TextOverflow.ellipsis), + const SizedBox(height: 4), + Text('${item.artist ?? ''} · ${occasion.label}', + style: const TextStyle(color: NkColors.mutedOnPhoto, fontSize: 15)), + ], + ), + ), + IconButton( + tooltip: liked ? 'Remove from liked songs' : 'Add to liked songs', + iconSize: 30, + icon: Icon(liked ? Icons.favorite : Icons.favorite_border, color: liked ? NkColors.like : NkColors.cream), + onPressed: () => ref.read(likedSongsProvider.notifier).toggle(item.id), + ), + ], + ), + const SizedBox(height: 12), + _SeekBar(duration: item.duration ?? Duration.zero), + const SizedBox(height: 4), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + IconButton( + tooltip: 'Shuffle', + icon: Icon(Icons.shuffle, + color: state?.shuffleMode == AudioServiceShuffleMode.none ? NkColors.cream : NkColors.sun), + onPressed: controller.toggleShuffle, + ), + IconButton( + tooltip: 'Previous', + iconSize: 40, + icon: const Icon(Icons.skip_previous_rounded), + onPressed: handler.skipToPrevious, + ), + IconButton.filled( + tooltip: (state?.playing ?? false) ? 'Pause' : 'Play', + style: IconButton.styleFrom(fixedSize: const Size(76, 76)), + iconSize: 40, + icon: Icon((state?.playing ?? false) ? Icons.pause_rounded : Icons.play_arrow_rounded), + onPressed: controller.togglePlay, + ), + IconButton( + tooltip: 'Next', + iconSize: 40, + icon: const Icon(Icons.skip_next_rounded), + onPressed: handler.skipToNext, + ), + IconButton( + tooltip: 'Repeat', + icon: Icon( + state?.repeatMode == AudioServiceRepeatMode.one ? Icons.repeat_one : Icons.repeat, + color: state?.repeatMode == AudioServiceRepeatMode.none ? NkColors.cream : NkColors.sun, + ), + onPressed: controller.cycleRepeat, + ), + ], + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _Action(icon: Icons.lyrics_outlined, label: 'Lyrics', onTap: () => _showLyrics(context, item.title)), + _Action(icon: Icons.queue_music, label: 'Queue', onTap: () => _showQueue(context)), + _SleepAction(), + _SoundAction(), + _Action( + icon: Icons.more_horiz, + label: 'More', + onTap: track == null ? null : () => _showMore(context, ref, track), + ), + ], + ), + const SizedBox(height: 16), + ], + ), + ), + ), + ], + ), + ); + } + + void _showMore(BuildContext context, WidgetRef ref, Track track) { + final catalog = ref.read(catalogProvider).requireValue; + showModalBottomSheet( + context: context, + backgroundColor: NkColors.surface, + showDragHandle: true, + builder: (sheet) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final a in catalog.artistsOf(track)) + ListTile( + leading: const Icon(Icons.person_outline), + title: Text('Go to ${a.name}'), + onTap: () { + Navigator.of(sheet).pop(); + context.push('/artist/${a.id}'); + }, + ), + ListTile( + leading: Icon(track.occasion.icon), + title: Text('More ${track.occasion.label} songs'), + onTap: () { + Navigator.of(sheet).pop(); + context.push('/occasion/${track.occasion.id}'); + }, + ), + ], + ), + ), + ); + } + + void _showLyrics(BuildContext context, String title) { + showModalBottomSheet( + context: context, + backgroundColor: NkColors.surface, + showDragHandle: true, + builder: (_) => Padding( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 40), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(title, style: Theme.of(context).textTheme.titleLarge), + const SizedBox(height: 12), + const Text( + 'Lyrics for this song haven\'t been added yet. They will appear here in Tamil script, ' + 'English letters and English meaning.', + textAlign: TextAlign.center, + style: TextStyle(color: NkColors.muted, height: 1.5), + ), + ], + ), + ), + ); + } + + void _showQueue(BuildContext context) { + showModalBottomSheet( + context: context, + backgroundColor: NkColors.surface, + showDragHandle: true, + isScrollControlled: true, + builder: (_) => const FractionallySizedBox(heightFactor: 0.7, child: _QueueSheet()), + ); + } +} + +class _HeroImage extends StatelessWidget { + const _HeroImage({required this.item, required this.occasion}); + + final MediaItem item; + final Occasion occasion; + + @override + Widget build(BuildContext context) { + // Singer photo or cover when available, otherwise a Nilgiris landscape. + final art = item.artUri?.toString(); + if (art != null) return CachedNetworkImage(imageUrl: art, fit: BoxFit.cover); + final asset = item.extras?['coverAsset'] as String?; + if (asset != null) return Image.asset(asset, fit: BoxFit.cover); + final fallback = occasionPhotos[occasion] ?? ('assets/images/nilgiris_village.jpg', Alignment.center); + return Image.asset(fallback.$1, fit: BoxFit.cover, alignment: fallback.$2); + } +} + +class _SeekBar extends ConsumerStatefulWidget { + const _SeekBar({required this.duration}); + + final Duration duration; + + @override + ConsumerState<_SeekBar> createState() => _SeekBarState(); +} + +class _SeekBarState extends ConsumerState<_SeekBar> { + double? _dragMs; + + @override + Widget build(BuildContext context) { + final position = ref.watch(positionProvider).value ?? Duration.zero; + final maxMs = widget.duration.inMilliseconds.toDouble(); + final valueMs = (_dragMs ?? position.inMilliseconds.toDouble()).clamp(0.0, maxMs <= 0 ? 1.0 : maxMs); + final shown = Duration(milliseconds: valueMs.round()); + + return Column( + children: [ + Slider( + value: maxMs <= 0 ? 0 : valueMs, + max: maxMs <= 0 ? 1 : maxMs, + semanticFormatterCallback: (_) => formatDuration(shown), + onChanged: maxMs <= 0 ? null : (v) => setState(() => _dragMs = v), + onChangeEnd: (v) { + ref.read(audioHandlerProvider).seek(Duration(milliseconds: v.round())); + setState(() => _dragMs = null); + }, + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(formatDuration(shown), style: const TextStyle(fontSize: 12, color: Color(0xFFC9D6CE))), + Text(formatDuration(widget.duration), style: const TextStyle(fontSize: 12, color: Color(0xFFC9D6CE))), + ], + ), + ), + ], + ); + } +} + +class _Action extends StatelessWidget { + const _Action({required this.icon, required this.label, required this.onTap, this.active = false}); + + final IconData icon; + final String label; + final VoidCallback? onTap; + final bool active; + + @override + Widget build(BuildContext context) { + final color = active ? NkColors.sun : NkColors.mutedOnPhoto; + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: SizedBox( + width: 72, + height: 56, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, color: color), + const SizedBox(height: 4), + Text(label, style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: color)), + ], + ), + ), + ); + } +} + +class _SoundAction extends ConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final s = ref.watch(soundSettingsProvider); + return _Action( + icon: Icons.equalizer_rounded, + label: 'Sound', + active: s.eqEnabled || s.boostDb > 0, + onTap: () => showSoundSettings(context), + ); + } +} + +class _SleepAction extends ConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final endsAt = ref.watch(sleepTimerProvider); + return _Action( + icon: Icons.bedtime_outlined, + label: endsAt == null ? 'Sleep' : 'Until ${TimeOfDay.fromDateTime(endsAt).format(context)}', + active: endsAt != null, + onTap: () => showModalBottomSheet( + context: context, + backgroundColor: NkColors.surface, + showDragHandle: true, + builder: (sheet) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final m in const [15, 30, 45, 60]) + ListTile( + title: Text('$m minutes'), + onTap: () { + ref.read(sleepTimerProvider.notifier).start(Duration(minutes: m)); + Navigator.of(sheet).pop(); + }, + ), + if (endsAt != null) + ListTile( + title: const Text('Turn off sleep timer'), + onTap: () { + ref.read(sleepTimerProvider.notifier).cancel(); + Navigator.of(sheet).pop(); + }, + ), + ], + ), + ), + ), + ); + } +} + +class _QueueSheet extends ConsumerWidget { + const _QueueSheet(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final queue = ref.watch(queueProvider).value ?? const []; + final current = ref.watch(mediaItemProvider).value?.id; + final handler = ref.read(audioHandlerProvider); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 8), + child: Text('Queue', style: Theme.of(context).textTheme.titleLarge), + ), + Expanded( + child: ListView.builder( + itemCount: queue.length, + itemBuilder: (_, i) { + final m = queue[i]; + final isCurrent = m.id == current; + return ListTile( + leading: Icon(isCurrent ? Icons.graphic_eq : Icons.music_note_outlined, + color: isCurrent ? NkColors.sun : NkColors.muted), + title: Text(m.title, + style: TextStyle(fontWeight: FontWeight.w700, color: isCurrent ? NkColors.sun : NkColors.cream)), + subtitle: Text(m.artist ?? '', style: const TextStyle(color: NkColors.muted)), + onTap: () => handler.skipToQueueItem(i), + ); + }, + ), + ), + ], + ); + } +} diff --git a/lib/features/player/sound_sheet.dart b/lib/features/player/sound_sheet.dart new file mode 100644 index 0000000..0218485 --- /dev/null +++ b/lib/features/player/sound_sheet.dart @@ -0,0 +1,158 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:just_audio/just_audio.dart'; + +import '../../app/theme.dart'; +import '../../audio/player_providers.dart'; +import '../../audio/sound_settings.dart'; + +void showSoundSettings(BuildContext context) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + showDragHandle: true, + backgroundColor: NkColors.surface, + builder: (_) => const _SoundSheet(), + ); +} + +class _SoundSheet extends ConsumerWidget { + const _SoundSheet(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final s = ref.watch(soundSettingsProvider); + final settings = ref.read(soundSettingsProvider.notifier); + final text = Theme.of(context).textTheme; + + if (!soundEffectsSupported) { + return const Padding( + padding: EdgeInsets.fromLTRB(24, 0, 24, 40), + child: Text( + 'On iPhone, sound is adjusted in Settings > Apps > Music > EQ, ' + 'which applies to all apps.', + style: TextStyle(color: NkColors.muted), + ), + ); + } + + return SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded(child: Text('Sound', style: text.titleLarge)), + TextButton(onPressed: settings.reset, child: const Text('Reset')), + ], + ), + const SizedBox(height: 8), + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Equalizer', style: TextStyle(fontWeight: FontWeight.w700)), + subtitle: const Text('Shape the sound to your taste', style: TextStyle(color: NkColors.muted)), + value: s.eqEnabled, + onChanged: settings.setEnabled, + ), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final p in EqPreset.values.where((p) => p != EqPreset.custom || s.preset == EqPreset.custom)) + ChoiceChip( + label: Text(p.label), + selected: s.eqEnabled && s.preset == p, + onSelected: (_) => settings.setPreset(p), + ), + ], + ), + const SizedBox(height: 16), + AnimatedOpacity( + duration: const Duration(milliseconds: 200), + opacity: s.eqEnabled ? 1 : 0.4, + child: const _Bands(), + ), + const Divider(height: 32, color: NkColors.outline), + Row( + children: [ + const Icon(Icons.volume_up_rounded, color: NkColors.sun), + const SizedBox(width: 10), + const Expanded(child: Text('Volume boost', style: TextStyle(fontWeight: FontWeight.w700))), + Text(s.boostDb == 0 ? 'Off' : '+${s.boostDb.round()} dB', style: const TextStyle(color: NkColors.muted)), + ], + ), + Slider( + value: s.boostDb, + max: 10, + divisions: 10, + onChanged: settings.setBoost, + ), + const Text( + 'Makes quiet old recordings louder. Keep it low with headphones.', + style: TextStyle(color: NkColors.muted, fontSize: 13), + ), + ], + ), + ), + ); + } +} + +/// One vertical slider per equalizer band. The bands are only known once a song has loaded. +class _Bands extends ConsumerWidget { + const _Bands(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + ref.watch(soundSettingsProvider); // rebuild when a preset changes the gains + final eq = ref.read(audioHandlerProvider).equalizer; + return FutureBuilder( + future: eq.parameters, + builder: (context, snap) { + final p = snap.data; + if (p == null) { + return const SizedBox( + height: 60, + child: Center( + child: Text('Play a song to adjust the bands', style: TextStyle(color: NkColors.muted)), + ), + ); + } + return SizedBox( + height: 210, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + for (final band in p.bands) + StreamBuilder( + stream: band.gainStream, + builder: (context, g) => Column( + children: [ + Text('${(g.data ?? 0) >= 0 ? '+' : ''}${(g.data ?? 0).toStringAsFixed(0)}', + style: const TextStyle(color: NkColors.muted, fontSize: 12)), + Expanded( + child: RotatedBox( + quarterTurns: 3, + child: Slider( + min: p.minDecibels, + max: p.maxDecibels, + value: (g.data ?? 0).clamp(p.minDecibels, p.maxDecibels), + onChanged: (v) => ref.read(soundSettingsProvider.notifier).setBandGain(band.index, v), + ), + ), + ), + Text(_hz(band.centerFrequency), style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600)), + ], + ), + ), + ], + ), + ); + }, + ); + } + + static String _hz(double hz) => hz >= 1000 ? '${(hz / 1000).toStringAsFixed(hz >= 10000 ? 0 : 1)}k' : '${hz.round()}'; +} diff --git a/lib/features/profile/profile_screen.dart b/lib/features/profile/profile_screen.dart new file mode 100644 index 0000000..2e9ebae --- /dev/null +++ b/lib/features/profile/profile_screen.dart @@ -0,0 +1,324 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +import '../../app/theme.dart'; + +class Profile { + const Profile({required this.id, this.name, this.phone, this.hatti, this.avatarUrl, this.role = 'listener'}); + + final String id; + final String? name; + final String? phone; + final String? hatti; + final String? avatarUrl; + final String role; +} + +final profileProvider = FutureProvider.autoDispose((ref) async { + final client = Supabase.instance.client; + final user = client.auth.currentUser; + if (user == null) return null; + final r = await client + .from('profiles') + .select('id, display_name, phone, hatti_name, avatar_url, role') + .eq('id', user.id) + .maybeSingle(); + if (r == null) return Profile(id: user.id); + return Profile( + id: r['id'] as String, + name: r['display_name'] as String?, + phone: r['phone'] as String?, + hatti: r['hatti_name'] as String?, + avatarUrl: r['avatar_url'] as String?, + role: r['role'] as String? ?? 'listener', + ); +}); + +class ProfileScreen extends ConsumerWidget { + const ProfileScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final profile = ref.watch(profileProvider); + return Scaffold( + appBar: AppBar(title: const Text('Your profile')), + body: profile.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (_, _) => Center( + child: TextButton(onPressed: () => ref.invalidate(profileProvider), child: const Text('Could not load. Retry')), + ), + data: (p) => p == null ? const SizedBox.shrink() : _ProfileForm(profile: p), + ), + ); + } +} + +class _ProfileForm extends ConsumerStatefulWidget { + const _ProfileForm({required this.profile}); + + final Profile profile; + + @override + ConsumerState<_ProfileForm> createState() => _ProfileFormState(); +} + +class _ProfileFormState extends ConsumerState<_ProfileForm> { + late final _name = TextEditingController(text: widget.profile.name); + late final _phone = TextEditingController(text: widget.profile.phone); + late final _hatti = TextEditingController(text: widget.profile.hatti); + late String? _avatarUrl = widget.profile.avatarUrl; + bool _saving = false; + bool _uploading = false; + + SupabaseClient get _client => Supabase.instance.client; + + @override + void dispose() { + _name.dispose(); + _phone.dispose(); + _hatti.dispose(); + super.dispose(); + } + + void _toast(String message) => ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message))); + + String? _clean(TextEditingController c) => c.text.trim().isEmpty ? null : c.text.trim(); + + Future _save() async { + setState(() => _saving = true); + try { + await _client.from('profiles').update({ + 'display_name': _clean(_name), + 'phone': _clean(_phone), + 'hatti_name': _clean(_hatti), + 'updated_at': DateTime.now().toUtc().toIso8601String(), + }).eq('id', widget.profile.id); + ref.invalidate(profileProvider); + _toast('Saved'); + } catch (_) { + _toast('Could not save. Check your connection.'); + } finally { + if (mounted) setState(() => _saving = false); + } + } + + Future _pickPhoto() async { + final file = await ImagePicker().pickImage(source: ImageSource.gallery, maxWidth: 512, maxHeight: 512, imageQuality: 85); + if (file == null) return; + setState(() => _uploading = true); + try { + final path = '${widget.profile.id}/avatar-${DateTime.now().millisecondsSinceEpoch}.jpg'; + await _client.storage + .from('avatars') + .uploadBinary(path, await file.readAsBytes(), fileOptions: const FileOptions(contentType: 'image/jpeg')); + final url = _client.storage.from('avatars').getPublicUrl(path); + await _client.from('profiles').update({'avatar_url': url}).eq('id', widget.profile.id); + setState(() => _avatarUrl = url); + ref.invalidate(profileProvider); + } catch (_) { + _toast('Could not upload the photo.'); + } finally { + if (mounted) setState(() => _uploading = false); + } + } + + Future _changePassword() async { + final password = await showDialog(context: context, builder: (_) => const _PasswordDialog()); + if (password == null) return; + try { + await _client.auth.updateUser(UserAttributes(password: password)); + _toast('Password updated. Use it next time you sign in.'); + } on AuthException catch (e) { + _toast(e.message); + } + } + + Future _signOut() async { + await _client.auth.signOut(); + if (mounted) context.go('/login'); + } + + Future _deleteAccount() async { + final sure = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Delete your account?'), + content: const Text( + 'Your profile, likes and playlists are removed for good. Songs in the app are not affected. ' + 'This cannot be undone.', + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')), + FilledButton( + style: FilledButton.styleFrom(backgroundColor: NkColors.like), + onPressed: () => Navigator.pop(context, true), + child: const Text('Delete'), + ), + ], + ), + ); + if (sure != true) return; + try { + await _client.rpc('delete_my_account'); + await _client.auth.signOut(); + if (mounted) context.go('/welcome'); + } catch (_) { + _toast('Could not delete the account. Please try again.'); + } + } + + @override + Widget build(BuildContext context) { + final email = _client.auth.currentUser?.email ?? ''; + final initial = (_name.text.trim().isNotEmpty ? _name.text.trim() : email).characters.first.toUpperCase(); + return ListView( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 32), + children: [ + Center( + child: Stack( + children: [ + CircleAvatar( + radius: 48, + backgroundColor: NkColors.emblemGreen, + backgroundImage: _avatarUrl == null ? null : CachedNetworkImageProvider(_avatarUrl!), + child: _avatarUrl == null + ? Text(initial, style: const TextStyle(fontSize: 36, fontWeight: FontWeight.w800, color: NkColors.cream)) + : null, + ), + Positioned( + right: 0, + bottom: 0, + child: IconButton.filled( + tooltip: 'Change photo', + onPressed: _uploading ? null : _pickPhoto, + icon: _uploading + ? const SizedBox.square(dimension: 18, child: CircularProgressIndicator(strokeWidth: 2)) + : const Icon(Icons.photo_camera_outlined, size: 20), + ), + ), + ], + ), + ), + const SizedBox(height: 8), + Center(child: Text(email, style: const TextStyle(color: NkColors.muted))), + if (widget.profile.role != 'listener') + Center( + child: Padding( + padding: const EdgeInsets.only(top: 8), + child: Chip(label: Text(widget.profile.role == 'admin' ? 'Admin' : 'Verified creator')), + ), + ), + const SizedBox(height: 24), + TextField( + controller: _name, + textCapitalization: TextCapitalization.words, + decoration: const InputDecoration(labelText: 'Name'), + ), + const SizedBox(height: 12), + TextField( + controller: _hatti, + textCapitalization: TextCapitalization.words, + decoration: const InputDecoration(labelText: 'Village / hatti (optional)'), + ), + const SizedBox(height: 12), + TextField( + controller: _phone, + keyboardType: TextInputType.phone, + decoration: const InputDecoration( + labelText: 'Phone number (optional)', + helperText: 'Only visible to the Nanga Kathai team.', + ), + ), + const SizedBox(height: 20), + FilledButton( + onPressed: _saving ? null : _save, + child: _saving + ? const SizedBox.square(dimension: 22, child: CircularProgressIndicator(strokeWidth: 2.5)) + : const Text('Save'), + ), + const Divider(height: 40, color: NkColors.outline), + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.lock_outline), + title: const Text('Set or change password'), + onTap: _changePassword, + ), + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.info_outline), + title: const Text('About Nanga Kathai'), + onTap: () => context.push('/about'), + ), + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.logout), + title: const Text('Sign out'), + onTap: _signOut, + ), + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.delete_outline, color: NkColors.like), + title: const Text('Delete account', style: TextStyle(color: NkColors.like)), + onTap: _deleteAccount, + ), + ], + ); + } +} + +class _PasswordDialog extends StatefulWidget { + const _PasswordDialog(); + + @override + State<_PasswordDialog> createState() => _PasswordDialogState(); +} + +class _PasswordDialogState extends State<_PasswordDialog> { + final _a = TextEditingController(); + final _b = TextEditingController(); + + @override + void dispose() { + _a.dispose(); + _b.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final ok = _a.text.length >= 8 && _a.text == _b.text; + return AlertDialog( + title: const Text('New password'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: _a, + obscureText: true, + autofillHints: const [AutofillHints.newPassword], + onChanged: (_) => setState(() {}), + decoration: const InputDecoration(labelText: 'New password', helperText: 'At least 8 characters'), + ), + const SizedBox(height: 12), + TextField( + controller: _b, + obscureText: true, + onChanged: (_) => setState(() {}), + decoration: InputDecoration( + labelText: 'Repeat password', + errorText: _b.text.isNotEmpty && _a.text != _b.text ? 'Passwords do not match' : null, + ), + ), + ], + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')), + FilledButton(onPressed: ok ? () => Navigator.pop(context, _a.text) : null, child: const Text('Save')), + ], + ); + } +} diff --git a/lib/features/search/search_screen.dart b/lib/features/search/search_screen.dart new file mode 100644 index 0000000..8a79e79 --- /dev/null +++ b/lib/features/search/search_screen.dart @@ -0,0 +1,121 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../app/theme.dart'; +import '../../audio/player_providers.dart'; +import '../../shared/widgets/artwork.dart'; +import '../../shared/widgets/catalog_builder.dart'; +import '../../shared/widgets/mini_player.dart'; +import '../../shared/widgets/section_header.dart'; +import '../../shared/widgets/track_tile.dart'; + +class SearchScreen extends ConsumerStatefulWidget { + const SearchScreen({super.key}); + + @override + ConsumerState createState() => _SearchScreenState(); +} + +class _SearchScreenState extends ConsumerState { + final _controller = TextEditingController(); + String _query = ''; + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + bottomNavigationBar: const SafeArea(child: MiniPlayer()), + appBar: AppBar( + backgroundColor: NkColors.ground, + titleSpacing: 0, + title: Padding( + padding: const EdgeInsets.only(right: 16), + child: TextField( + controller: _controller, + autofocus: true, + textInputAction: TextInputAction.search, + onChanged: (v) => setState(() => _query = v), + decoration: InputDecoration( + hintText: 'Songs, singers, Tamil or English', + prefixIcon: const Icon(Icons.search), + contentPadding: EdgeInsets.zero, + suffixIcon: _query.isEmpty + ? null + : IconButton( + tooltip: 'Clear', + icon: const Icon(Icons.close), + onPressed: () => setState(() { + _controller.clear(); + _query = ''; + }), + ), + ), + ), + ), + ), + body: CatalogBuilder(builder: (catalog) { + if (_query.trim().isEmpty) { + return const EmptyState( + icon: Icons.manage_search, + title: 'Find any Badaga song', + message: 'Type in English letters, Tamil script or the English meaning. Spelling variations are fine.', + ); + } + final songs = catalog.searchTracks(_query); + final singers = catalog.searchArtists(_query); + final albums = catalog.searchAlbums(_query); + if (songs.isEmpty && singers.isEmpty && albums.isEmpty) { + return EmptyState( + icon: Icons.search_off, + title: 'No results for "$_query"', + message: 'Try a shorter word or another spelling.', + ); + } + final controller = ref.read(playerControllerProvider); + return ListView( + padding: const EdgeInsets.fromLTRB(18, 8, 18, 24), + children: [ + if (singers.isNotEmpty) ...[ + const SectionHeader('Singers'), + for (final a in singers) + ListTile( + contentPadding: EdgeInsets.zero, + leading: SingerAvatar(artist: a, size: 48), + title: Text(a.name, style: const TextStyle(fontWeight: FontWeight.w700)), + subtitle: Text(a.hatti ?? 'Singer', style: const TextStyle(color: NkColors.muted)), + onTap: () => context.push('/artist/${a.id}'), + ), + const SizedBox(height: 12), + ], + if (albums.isNotEmpty) ...[ + const SectionHeader('Albums'), + for (final a in albums) + ListTile( + contentPadding: EdgeInsets.zero, + leading: ClipRRect( + borderRadius: BorderRadius.circular(6), + child: SizedBox.square(dimension: 48, child: AlbumCover(album: a, size: 48)), + ), + title: Text(a.title, style: const TextStyle(fontWeight: FontWeight.w700)), + subtitle: Text('${catalog.byAlbum(a.id).length} songs', style: const TextStyle(color: NkColors.muted)), + onTap: () => context.push('/album/${a.id}'), + ), + const SizedBox(height: 12), + ], + if (songs.isNotEmpty) ...[ + const SectionHeader('Songs'), + for (var i = 0; i < songs.length; i++) + TrackTile(track: songs[i], onTap: () => controller.playTracks(songs, index: i)), + ], + ], + ); + }), + ); + } +} diff --git a/lib/features/welcome/splash_screen.dart b/lib/features/welcome/splash_screen.dart new file mode 100644 index 0000000..c0ae5ed --- /dev/null +++ b/lib/features/welcome/splash_screen.dart @@ -0,0 +1,82 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +import '../../app/theme.dart'; +import '../../shared/widgets/artwork.dart'; + +class SplashScreen extends StatelessWidget { + const SplashScreen({super.key}); + + @override + Widget build(BuildContext context) { + final text = Theme.of(context).textTheme; + return Scaffold( + body: Stack( + fit: StackFit.expand, + children: [ + Image.asset('assets/images/nilgiris_valley.jpg', fit: BoxFit.cover, alignment: const Alignment(0.25, 0)), + const PhotoScrim(stops: [0.35, 1]), + DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.center, + colors: [NkColors.ground.withValues(alpha: 0.55), NkColors.ground.withValues(alpha: 0)], + ), + ), + ), + SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Column( + children: [ + const SizedBox(height: 48), + Container( + width: 150, + height: 150, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: NkColors.cream, + boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.35), blurRadius: 32, offset: const Offset(0, 12))], + ), + clipBehavior: Clip.antiAlias, + child: Transform.scale( + scale: 1.08, + child: Image.asset('assets/images/emblem.png', semanticLabel: 'Nanga Kathai emblem'), + ), + ), + const SizedBox(height: 20), + Text('Nanga Kathai', style: text.headlineLarge?.copyWith(fontSize: 42)), + const SizedBox(height: 10), + Text( + 'Our song · music of the\nBadaga people, Nilgiris', + textAlign: TextAlign.center, + style: text.titleMedium?.copyWith(height: 1.4, fontWeight: FontWeight.w600), + ), + const Spacer(), + SizedBox( + width: double.infinity, + height: 58, + child: FilledButton( + onPressed: () => context.go('/login?mode=signup'), + child: const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [Text('Get Started'), SizedBox(width: 12), Icon(Icons.arrow_forward_rounded)], + ), + ), + ), + const SizedBox(height: 20), + const Text( + 'Music · Culture · Our People', + style: TextStyle(color: NkColors.mutedOnPhoto, fontWeight: FontWeight.w600), + ), + const SizedBox(height: 24), + ], + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/main.dart b/lib/main.dart new file mode 100644 index 0000000..b7e90a8 --- /dev/null +++ b/lib/main.dart @@ -0,0 +1,118 @@ +import 'package:audio_service/audio_service.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +import 'app/router.dart'; +import 'app/theme.dart'; +import 'audio/nk_audio_handler.dart'; +import 'audio/player_providers.dart'; +import 'audio/sound_settings.dart'; +import 'core/env.dart'; +import 'data/user_library.dart'; +import 'offline/download_store.dart'; + +Future main() async { + WidgetsFlutterBinding.ensureInitialized(); + + if (Env.hasSupabase) { + await Supabase.initialize(url: Env.supabaseUrl, publishableKey: Env.supabasePublishableKey); + } + + final prefs = await SharedPreferences.getInstance(); + final audioHandler = await AudioService.init( + builder: NkAudioHandler.new, + config: const AudioServiceConfig( + androidNotificationChannelId: 'com.metatroncubesolutions.nanga_kathai.playback', + androidNotificationChannelName: 'Nanga Kathai playback', + androidNotificationOngoing: true, + androidStopForegroundOnPause: true, + ), + ); + + final downloads = await DownloadStore.open(); + audioHandler.localSource = (item) => downloads.sourceFor(item.id, tag: item); + + runApp( + ProviderScope( + overrides: [ + sharedPrefsProvider.overrideWithValue(prefs), + audioHandlerProvider.overrideWithValue(audioHandler), + downloadStoreProvider.overrideWithValue(downloads), + ], + child: const NangaKathaiApp(), + ), + ); +} + +class NangaKathaiApp extends ConsumerWidget { + const NangaKathaiApp({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + ref.watch(soundSettingsProvider); // apply the saved equalizer and boost at startup + final router = ref.watch(routerProvider); + // Guests hear a short preview of each song, then are invited to sign up. + ref.listen(positionProvider, (_, next) { + final position = next.value ?? Duration.zero; + if (!Env.hasSupabase || isSignedIn || position < guestPreview) return; + final handler = ref.read(audioHandlerProvider); + if (!handler.playbackState.value.playing) return; + handler + ..pause() + ..seek(Duration.zero); + final context = router.routerDelegate.navigatorKey.currentContext; + if (context != null) _showPreviewEnded(context); + }); + return MaterialApp.router( + title: 'Nanga Kathai', + debugShowCheckedModeBanner: false, + theme: buildNkTheme(), + routerConfig: router, + ); + } +} + +const guestPreview = Duration(seconds: 30); + +void _showPreviewEnded(BuildContext context) { + showModalBottomSheet( + context: context, + backgroundColor: NkColors.surface, + showDragHandle: true, + builder: (sheet) => SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text('Enjoying the song?', style: Theme.of(sheet).textTheme.headlineSmall), + const SizedBox(height: 8), + const Text( + 'Previews are 30 seconds. Create a free account to listen to every song in full.', + style: TextStyle(color: NkColors.muted, height: 1.5), + ), + const SizedBox(height: 20), + FilledButton( + onPressed: () { + Navigator.pop(sheet); + GoRouter.of(context).push('/login?mode=signup'); + }, + child: const Text('Create free account'), + ), + TextButton( + onPressed: () { + Navigator.pop(sheet); + GoRouter.of(context).push('/login'); + }, + child: const Text('I already have an account'), + ), + ], + ), + ), + ), + ); +} diff --git a/lib/offline/download_store.dart b/lib/offline/download_store.dart new file mode 100644 index 0000000..8f75a8a --- /dev/null +++ b/lib/offline/download_store.dart @@ -0,0 +1,232 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:http/http.dart' as http; +import 'package:just_audio/just_audio.dart'; +import 'package:path_provider/path_provider.dart'; + +import '../core/models.dart'; +import 'encrypted_audio_source.dart'; +import 'song_cipher.dart'; + +/// What we keep about a downloaded song, so the Downloads tab works with no internet. +class DownloadEntry { + const DownloadEntry({ + required this.trackId, + required this.title, + required this.credit, + required this.occasion, + required this.durationMs, + required this.bytes, + this.album, + }); + + final String trackId; + final String title; + final String credit; + final String? album; + final String occasion; + final int durationMs; + final int bytes; + + Map toJson() => { + 'id': trackId, + 'title': title, + 'credit': credit, + 'album': album, + 'occasion': occasion, + 'durationMs': durationMs, + 'bytes': bytes, + }; + + static DownloadEntry fromJson(Map j) => DownloadEntry( + trackId: j['id'] as String, + title: j['title'] as String, + credit: j['credit'] as String, + album: j['album'] as String?, + occasion: j['occasion'] as String, + durationMs: (j['durationMs'] as num).toInt(), + bytes: (j['bytes'] as num).toInt(), + ); +} + +class DownloadSnapshot { + const DownloadSnapshot(this.entries, this.progress); + + /// Newest first. + final List entries; + + /// Track id -> 0..1 for songs downloading or waiting (0). + final Map progress; + + bool isDownloaded(String id) => entries.any((e) => e.trackId == id); + int get totalBytes => entries.fold(0, (sum, e) => sum + e.bytes); +} + +/// Downloads songs into the app's private storage, encrypted with a key that +/// lives only in this phone's secure keystore. Other apps, file managers and +/// USB access can't read them, and a copied file is useless without the key. +class DownloadStore { + DownloadStore._(this._dir, this._cipher, this._entries); + + static const _keyName = 'nk_download_key'; + + final Directory _dir; + final SongCipher _cipher; + final Map _entries; + final Map _progress = {}; + final _queue = <(Track, DownloadEntry Function(int bytes))>[]; + final _changes = StreamController.broadcast(); + bool _working = false; + + Stream get changes => _changes.stream; + + static Future open() async { + const storage = FlutterSecureStorage(); + var keyText = await storage.read(key: _keyName); + if (keyText == null) { + keyText = base64Encode(SongCipher.newKey()); + await storage.write(key: _keyName, value: keyText); + } + final dir = Directory('${(await getApplicationSupportDirectory()).path}/downloads'); + await dir.create(recursive: true); + final entries = {}; + final index = File('${dir.path}/index.json'); + if (await index.exists()) { + try { + for (final j in jsonDecode(await index.readAsString()) as List) { + final e = DownloadEntry.fromJson(j as Map); + if (await File('${dir.path}/${e.trackId}.nks').exists()) entries[e.trackId] = e; + } + } catch (_) { + // A damaged index only loses the list; files are re-downloadable. + } + } + return DownloadStore._(dir, SongCipher(base64Decode(keyText)), entries); + } + + DownloadSnapshot snapshot() => + DownloadSnapshot(_entries.values.toList().reversed.toList(), Map.unmodifiable(_progress)); + + File _file(String id) => File('${_dir.path}/$id.nks'); + + /// An audio source for a downloaded song, or null to stream it. + AudioSource? sourceFor(String trackId, {Object? tag}) { + if (!_entries.containsKey(trackId)) return null; + return EncryptedFileAudioSource(_file(trackId), _cipher, tag: tag); + } + + void enqueue(Track track, {required String credit, String? album}) { + if (_entries.containsKey(track.id) || _progress.containsKey(track.id)) return; + _progress[track.id] = 0; + _queue.add(( + track, + (bytes) => DownloadEntry( + trackId: track.id, + title: track.titleLatin, + credit: credit, + album: album, + occasion: track.occasion.id, + durationMs: track.duration.inMilliseconds, + bytes: bytes, + ), + )); + _changes.add(null); + _pump(); + } + + Future _pump() async { + if (_working) return; + _working = true; + while (_queue.isNotEmpty) { + final (track, entryFor) = _queue.removeAt(0); + try { + final bytes = await _download(track); + _entries[track.id] = entryFor(bytes); + await _saveIndex(); + } catch (_) { + // Failed downloads simply disappear from the queue; the user can tap again. + } + _progress.remove(track.id); + _changes.add(null); + } + _working = false; + } + + Future _download(Track track) async { + final client = http.Client(); + final part = File('${_dir.path}/${track.id}.part'); + try { + final res = await client.send(http.Request('GET', Uri.parse(track.audioUrl))); + if (res.statusCode != 200) throw HttpException('HTTP ${res.statusCode}'); + final total = res.contentLength ?? 0; + final nonce = SongCipher.newNonce(); + final sink = part.openWrite()..add(nonce); + var offset = 0; + var lastReport = 0.0; + await for (final chunk in res.stream) { + final data = Uint8List.fromList(chunk); + _cipher.apply(data, offset, nonce); + sink.add(data); + offset += data.length; + if (total > 0) { + final p = offset / total; + if (p - lastReport > 0.02) { + lastReport = p; + _progress[track.id] = p; + _changes.add(null); + } + } + } + await sink.close(); + await part.rename(_file(track.id).path); + return offset; + } catch (_) { + if (await part.exists()) await part.delete(); + rethrow; + } finally { + client.close(); + } + } + + Future remove(String trackId) async { + _entries.remove(trackId); + final f = _file(trackId); + if (await f.exists()) await f.delete(); + await _saveIndex(); + _changes.add(null); + } + + Future removeAll() async { + for (final id in _entries.keys.toList()) { + final f = _file(id); + if (await f.exists()) await f.delete(); + } + _entries.clear(); + await _saveIndex(); + _changes.add(null); + } + + Future _saveIndex() => File('${_dir.path}/index.json') + .writeAsString(jsonEncode([for (final e in _entries.values) e.toJson()])); +} + +final downloadStoreProvider = Provider( + (ref) => throw UnimplementedError('downloadStoreProvider must be overridden in main()'), +); + +class Downloads extends Notifier { + @override + DownloadSnapshot build() { + final store = ref.watch(downloadStoreProvider); + final sub = store.changes.listen((_) => state = store.snapshot()); + ref.onDispose(sub.cancel); + return store.snapshot(); + } +} + +final downloadsProvider = NotifierProvider(Downloads.new); diff --git a/lib/offline/encrypted_audio_source.dart b/lib/offline/encrypted_audio_source.dart new file mode 100644 index 0000000..60b7cef --- /dev/null +++ b/lib/offline/encrypted_audio_source.dart @@ -0,0 +1,55 @@ +// StreamAudioSource is marked experimental in just_audio but is its supported way to play custom byte streams. +// ignore_for_file: experimental_member_use + +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:just_audio/just_audio.dart'; + +import 'song_cipher.dart'; + +/// Plays a downloaded song straight from its encrypted file, decrypting only the +/// bytes the player asks for. The plain audio never touches the disk. +class EncryptedFileAudioSource extends StreamAudioSource { + EncryptedFileAudioSource(this.file, this.cipher, {this.contentType = 'audio/mpeg', super.tag}); + + final File file; + final SongCipher cipher; + final String contentType; + + @override + Future request([int? start, int? end]) async { + final raf = await file.open(); + final total = await raf.length() - SongCipher.headerLength; + final nonce = Uint8List(SongCipher.headerLength); + await raf.readInto(nonce); + start ??= 0; + end = (end == null || end > total) ? total : end; + + Stream> body() async* { + try { + const chunk = 64 * 1024; + var pos = start!; + await raf.setPosition(SongCipher.headerLength + pos); + while (pos < end!) { + final len = end - pos < chunk ? end - pos : chunk; + final data = await raf.read(len); + if (data.isEmpty) break; + cipher.apply(data, pos, nonce); + yield data; + pos += data.length; + } + } finally { + await raf.close(); + } + } + + return StreamAudioResponse( + sourceLength: total, + contentLength: end - start, + offset: start, + stream: body(), + contentType: contentType, + ); + } +} diff --git a/lib/offline/song_cipher.dart b/lib/offline/song_cipher.dart new file mode 100644 index 0000000..61669e4 --- /dev/null +++ b/lib/offline/song_cipher.dart @@ -0,0 +1,53 @@ +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:pointycastle/export.dart'; + +/// AES-256 in counter mode, so any byte range of a song can be decrypted on +/// its own (seeking without reading the whole file). Encrypting and decrypting +/// are the same operation. +/// +/// File layout: 8-byte random nonce, then the encrypted song. +class SongCipher { + SongCipher(Uint8List key) : _aes = AESEngine()..init(true, KeyParameter(key)); + + static const headerLength = 8; + static const _block = 16; + + final AESEngine _aes; + + static Uint8List newNonce() { + final r = Random.secure(); + return Uint8List.fromList(List.generate(headerLength, (_) => r.nextInt(256))); + } + + static Uint8List newKey() { + final r = Random.secure(); + return Uint8List.fromList(List.generate(32, (_) => r.nextInt(256))); + } + + /// XORs [data] with the key stream for song position [offset] (bytes after the header), in place. + void apply(Uint8List data, int offset, Uint8List nonce) { + final counter = Uint8List(_block)..setRange(0, headerLength, nonce); + final stream = Uint8List(_block); + var blockIndex = -1; + for (var i = 0; i < data.length; i++) { + final pos = offset + i; + final b = pos ~/ _block; + if (b != blockIndex) { + blockIndex = b; + _writeCounter(counter, b); + _aes.processBlock(counter, 0, stream, 0); + } + data[i] ^= stream[pos % _block]; + } + } + + static void _writeCounter(Uint8List counter, int blockIndex) { + var v = blockIndex; + for (var i = _block - 1; i >= headerLength; i--) { + counter[i] = v & 0xff; + v >>= 8; + } + } +} diff --git a/lib/shared/widgets/app_shell.dart b/lib/shared/widgets/app_shell.dart new file mode 100644 index 0000000..abaaa9d --- /dev/null +++ b/lib/shared/widgets/app_shell.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +import 'mini_player.dart'; + +/// Bottom tabs (Home, Explore, Library, Downloads) with the mini player above them. +class AppShell extends StatelessWidget { + const AppShell({super.key, required this.shell}); + + final StatefulNavigationShell shell; + + @override + Widget build(BuildContext context) { + return Scaffold( + body: shell, + bottomNavigationBar: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const MiniPlayer(), + NavigationBar( + selectedIndex: shell.currentIndex, + onDestinationSelected: (i) => shell.goBranch(i, initialLocation: i == shell.currentIndex), + destinations: const [ + NavigationDestination(icon: Icon(Icons.home_outlined), selectedIcon: Icon(Icons.home_rounded), label: 'Home'), + NavigationDestination(icon: Icon(Icons.search), selectedIcon: Icon(Icons.search), label: 'Explore'), + NavigationDestination( + icon: Icon(Icons.library_music_outlined), selectedIcon: Icon(Icons.library_music), label: 'Library'), + NavigationDestination( + icon: Icon(Icons.download_outlined), selectedIcon: Icon(Icons.download_rounded), label: 'Downloads'), + ], + ), + ], + ), + ); + } +} diff --git a/lib/shared/widgets/artwork.dart b/lib/shared/widgets/artwork.dart new file mode 100644 index 0000000..32349ea --- /dev/null +++ b/lib/shared/widgets/artwork.dart @@ -0,0 +1,207 @@ +import 'dart:math' as math; + +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; + +import '../../app/theme.dart'; +import '../../core/models.dart'; + +/// Dark fade behind text on photos so it stays readable on any image. +class PhotoScrim extends StatelessWidget { + const PhotoScrim({super.key, this.begin = Alignment.topCenter, this.end = Alignment.bottomCenter, this.stops}); + + final Alignment begin; + final Alignment end; + final List? stops; + + @override + Widget build(BuildContext context) => DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: begin, + end: end, + stops: stops ?? const [0.3, 1], + colors: [NkColors.ground.withValues(alpha: 0), NkColors.ground.withValues(alpha: 0.9)], + ), + ), + ); +} + +/// Cover for a song: remote cover, bundled photo, or the generated occasion cover. +class TrackArtwork extends StatelessWidget { + const TrackArtwork({ + super.key, + required this.coverUrl, + required this.coverAsset, + required this.occasion, + this.title, + this.size = 56, + this.radius = 10, + }); + + TrackArtwork.track(Track t, {Key? key, double size = 56, double radius = 10, bool showTitle = false}) + : this( + key: key, + coverUrl: t.coverUrl, + coverAsset: t.coverAsset, + occasion: t.occasion, + title: showTitle ? t.titleLatin : null, + size: size, + radius: radius, + ); + + final String? coverUrl; + final String? coverAsset; + final Occasion occasion; + final String? title; + final double size; + final double radius; + + @override + Widget build(BuildContext context) { + final Widget child; + if (coverUrl != null) { + child = CachedNetworkImage( + imageUrl: coverUrl!, + fit: BoxFit.cover, + placeholder: (_, _) => OccasionCover(occasion: occasion, size: size), + errorWidget: (_, _, _) => OccasionCover(occasion: occasion, size: size), + ); + } else if (coverAsset != null) { + child = Image.asset(coverAsset!, fit: BoxFit.cover, cacheWidth: (size * 3).round()); + } else { + child = OccasionCover(occasion: occasion, size: size, title: title); + } + return ClipRRect( + borderRadius: BorderRadius.circular(radius), + child: SizedBox.square(dimension: size, child: child), + ); + } +} + +/// Generated cover: occasion colour, label and the white-cloth border stripe. +class OccasionCover extends StatelessWidget { + const OccasionCover({super.key, required this.occasion, required this.size, this.title, this.label}); + + final Occasion occasion; + final double size; + final String? title; + + /// Top line; defaults to the occasion's name. + final String? label; + + @override + Widget build(BuildContext context) { + final band = math.max(4.0, size * 0.06); + final showText = size >= 96; + return Container( + color: occasion.color, + child: Stack( + fit: StackFit.expand, + children: [ + if (showText) + Padding( + padding: EdgeInsets.fromLTRB(size * 0.08, size * 0.08, size * 0.08, band * 2.5), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + (label ?? occasion.label).toUpperCase(), + style: TextStyle( + fontSize: size * 0.07, + letterSpacing: 1.6, + fontWeight: FontWeight.w800, + color: NkColors.cream.withValues(alpha: 0.85), + ), + ), + if (title != null) + Text( + title!, + maxLines: 3, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleLarge?.copyWith(fontSize: size * 0.11), + ), + ], + ), + ) + else + Center(child: Icon(occasion.icon, color: NkColors.cream.withValues(alpha: 0.7), size: size * 0.4)), + Positioned(left: 0, right: 0, bottom: 0, height: band, child: const ColoredBox(color: NkColors.cream)), + Positioned( + left: 0, + right: 0, + bottom: band * 0.3, + height: band * 0.3, + child: const ColoredBox(color: NkColors.ground), + ), + ], + ), + ); + } +} + +/// Singer photo, or initials in a dashed ring until a permitted photo exists. +/// Generated album cover in the occasion style, with a colour that stays the same per album. +class AlbumCover extends StatelessWidget { + const AlbumCover({super.key, required this.album, required this.size}); + + final Album album; + final double size; + + @override + Widget build(BuildContext context) { + final palette = Occasion.values; + final hash = album.title.codeUnits.fold(7, (h, c) => (h * 31 + c) & 0x7fffffff); + final pick = palette[hash % palette.length]; + return OccasionCover(occasion: pick, size: size, label: 'Album', title: album.title); + } +} + +class SingerAvatar extends StatelessWidget { + const SingerAvatar({super.key, required this.artist, this.size = 76}); + + final Artist artist; + final double size; + + @override + Widget build(BuildContext context) { + if (artist.photoUrl != null) { + return ClipOval( + child: CachedNetworkImage(imageUrl: artist.photoUrl!, width: size, height: size, fit: BoxFit.cover), + ); + } + return CustomPaint( + painter: _DashedRingPainter(), + child: Container( + width: size, + height: size, + alignment: Alignment.center, + decoration: const BoxDecoration(color: NkColors.emblemGreen, shape: BoxShape.circle), + child: Text( + artist.initials, + style: Theme.of(context).textTheme.titleLarge?.copyWith(fontSize: size * 0.3), + ), + ), + ); + } +} + +class _DashedRingPainter extends CustomPainter { + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = NkColors.teaLeaf + ..style = PaintingStyle.stroke + ..strokeWidth = 2; + final rect = Offset.zero & size; + const dashes = 24; + const sweep = 2 * math.pi / dashes; + for (var i = 0; i < dashes; i++) { + canvas.drawArc(rect.deflate(1), i * sweep, sweep * 0.6, false, paint); + } + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => false; +} diff --git a/lib/shared/widgets/catalog_builder.dart b/lib/shared/widgets/catalog_builder.dart new file mode 100644 index 0000000..28faa7a --- /dev/null +++ b/lib/shared/widgets/catalog_builder.dart @@ -0,0 +1,34 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../app/theme.dart'; +import '../../data/catalog_repository.dart'; +import 'section_header.dart'; + +/// Shows a loader / retry state until the catalog is available. +class CatalogBuilder extends ConsumerWidget { + const CatalogBuilder({super.key, required this.builder}); + + final Widget Function(Catalog catalog) builder; + + @override + Widget build(BuildContext context, WidgetRef ref) { + return ref.watch(catalogProvider).when( + data: builder, + loading: () => const Center(child: CircularProgressIndicator(color: NkColors.sun)), + error: (e, _) => Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const EmptyState( + icon: Icons.cloud_off_outlined, + title: "Couldn't load songs", + message: 'Check your connection. Downloaded songs still play offline.', + ), + FilledButton(onPressed: () => ref.invalidate(catalogProvider), child: const Text('Try again')), + ], + ), + ), + ); + } +} diff --git a/lib/shared/widgets/mini_player.dart b/lib/shared/widgets/mini_player.dart new file mode 100644 index 0000000..2960e31 --- /dev/null +++ b/lib/shared/widgets/mini_player.dart @@ -0,0 +1,115 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../app/theme.dart'; +import '../../audio/player_providers.dart'; +import '../../core/models.dart'; +import 'artwork.dart'; + +/// Persistent bar above the tabs. Tap opens Now Playing; swipe left/right skips. +class MiniPlayer extends ConsumerWidget { + const MiniPlayer({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final item = ref.watch(mediaItemProvider).value; + if (item == null) return const SizedBox.shrink(); + + final playing = ref.watch(playbackStateProvider).value?.playing ?? false; + final position = ref.watch(positionProvider).value ?? Duration.zero; + final total = item.duration ?? Duration.zero; + final progress = total.inMilliseconds == 0 ? 0.0 : (position.inMilliseconds / total.inMilliseconds).clamp(0.0, 1.0); + final handler = ref.read(audioHandlerProvider); + + return Padding( + padding: const EdgeInsets.fromLTRB(10, 6, 10, 4), + child: GestureDetector( + onHorizontalDragEnd: (d) { + final v = d.primaryVelocity ?? 0; + if (v < -300) handler.skipToNext(); + if (v > 300) handler.skipToPrevious(); + }, + child: Material( + color: NkColors.miniPlayer, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: const BorderSide(color: NkColors.outline), + ), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: () => context.push('/player'), + child: Stack( + children: [ + Padding( + padding: const EdgeInsets.all(8), + child: Row( + children: [ + Hero( + tag: 'now-playing-art', + child: TrackArtwork( + coverUrl: item.artUri?.toString(), + coverAsset: item.extras?['coverAsset'] as String?, + occasion: Occasion.fromId(item.extras?['occasion'] as String?), + size: 48, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(item.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 15)), + Text(item.artist ?? '', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: NkColors.muted, fontSize: 13)), + ], + ), + ), + IconButton( + tooltip: 'Previous', + visualDensity: VisualDensity.compact, + icon: const Icon(Icons.skip_previous_rounded), + onPressed: handler.skipToPrevious, + ), + IconButton( + tooltip: playing ? 'Pause' : 'Play', + iconSize: 32, + visualDensity: VisualDensity.compact, + icon: Icon(playing ? Icons.pause_rounded : Icons.play_arrow_rounded), + onPressed: () => ref.read(playerControllerProvider).togglePlay(), + ), + IconButton( + tooltip: 'Next', + visualDensity: VisualDensity.compact, + icon: const Icon(Icons.skip_next_rounded), + onPressed: handler.skipToNext, + ), + ], + ), + ), + Positioned( + left: 0, + bottom: 0, + right: 0, + child: Align( + alignment: Alignment.centerLeft, + child: FractionallySizedBox( + widthFactor: progress, + child: Container(height: 3, color: NkColors.sun), + ), + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/shared/widgets/section_header.dart b/lib/shared/widgets/section_header.dart new file mode 100644 index 0000000..9ce8d84 --- /dev/null +++ b/lib/shared/widgets/section_header.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; + +import '../../app/theme.dart'; + +class SectionHeader extends StatelessWidget { + const SectionHeader(this.title, {super.key, this.onSeeAll}); + + final String title; + final VoidCallback? onSeeAll; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Expanded(child: Text(title, style: Theme.of(context).textTheme.titleLarge)), + if (onSeeAll != null) + TextButton( + onPressed: onSeeAll, + style: TextButton.styleFrom(foregroundColor: const Color(0xFFC9D6CE)), + child: const Text('See all', style: TextStyle(fontWeight: FontWeight.w600)), + ), + ], + ); + } +} + +/// Centered message for empty lists and load errors. +class EmptyState extends StatelessWidget { + const EmptyState({super.key, required this.icon, required this.title, this.message}); + + final IconData icon; + final String title; + final String? message; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 48), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 48, color: NkColors.teaLeaf), + const SizedBox(height: 16), + Text(title, textAlign: TextAlign.center, style: Theme.of(context).textTheme.titleLarge), + if (message != null) ...[ + const SizedBox(height: 8), + Text(message!, textAlign: TextAlign.center, style: const TextStyle(color: NkColors.muted, height: 1.4)), + ], + ], + ), + ); + } +} diff --git a/lib/shared/widgets/track_tile.dart b/lib/shared/widgets/track_tile.dart new file mode 100644 index 0000000..43d220a --- /dev/null +++ b/lib/shared/widgets/track_tile.dart @@ -0,0 +1,182 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../app/router.dart'; +import '../../app/theme.dart'; +import '../../offline/download_store.dart'; +import '../../audio/player_providers.dart'; +import '../../core/models.dart'; +import '../../data/catalog_repository.dart'; +import '../../data/user_library.dart'; +import 'artwork.dart'; + +class TrackTile extends ConsumerWidget { + const TrackTile({super.key, required this.track, required this.onTap, this.subtitle, this.card = false}); + + final Track track; + final VoidCallback onTap; + final String? subtitle; + + /// Card style (rounded surface) used on Home; plain rows elsewhere. + final bool card; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final catalog = ref.watch(catalogProvider).requireValue; + final isCurrent = ref.watch(mediaItemProvider).value?.id == track.id; + final downloads = ref.watch(downloadsProvider); + final text = Theme.of(context).textTheme; + + final row = InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(16), + child: Padding( + padding: EdgeInsets.symmetric(horizontal: card ? 12 : 0, vertical: card ? 10 : 6), + child: Row( + children: [ + TrackArtwork.track(track, size: card ? 60 : 52, radius: 10), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + track.titleLatin, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: text.titleMedium?.copyWith(color: isCurrent ? NkColors.sun : NkColors.cream), + ), + const SizedBox(height: 2), + Row( + children: [ + if (downloads.isDownloaded(track.id)) + const Padding( + padding: EdgeInsets.only(right: 4), + child: Icon(Icons.download_done_rounded, size: 16, color: NkColors.teaLeaf), + ) + else if (downloads.progress.containsKey(track.id)) + const Padding( + padding: EdgeInsets.only(right: 6), + child: SizedBox.square(dimension: 12, child: CircularProgressIndicator(strokeWidth: 2)), + ), + Expanded( + child: Text( + subtitle ?? '${catalog.creditLine(track)} · ${formatDuration(track.duration)}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: text.bodyMedium?.copyWith(color: NkColors.muted), + ), + ), + ], + ), + ], + ), + ), + TrackMenuButton(track: track), + ], + ), + ), + ); + + if (!card) return row; + return Material(color: NkColors.surface, borderRadius: BorderRadius.circular(16), child: row); + } +} + +class TrackMenuButton extends ConsumerWidget { + const TrackMenuButton({super.key, required this.track}); + + final Track track; + + @override + Widget build(BuildContext context, WidgetRef ref) { + return IconButton( + tooltip: 'More options', + icon: const Icon(Icons.more_vert, color: NkColors.muted), + onPressed: () => showTrackActions(context, ref, track), + ); + } +} + +Future showTrackActions(BuildContext context, WidgetRef ref, Track track) { + final catalog = ref.read(catalogProvider).requireValue; + final controller = ref.read(playerControllerProvider); + final liked = ref.read(likedSongsProvider).contains(track.id); + final downloaded = ref.read(downloadsProvider).isDownloaded(track.id); + final artists = catalog.artistsOf(track); + + void done(String message) { + Navigator.of(context).pop(); + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message))); + } + + return showModalBottomSheet( + context: context, + backgroundColor: NkColors.surface, + showDragHandle: true, + builder: (sheetContext) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: TrackArtwork.track(track, size: 48), + title: Text(track.titleLatin, style: Theme.of(context).textTheme.titleMedium), + subtitle: Text(catalog.creditLine(track)), + ), + const Divider(color: NkColors.outline), + ListTile( + leading: const Icon(Icons.playlist_play), + title: const Text('Play next'), + onTap: () { + controller.playNext(track); + done('Playing next'); + }, + ), + ListTile( + leading: const Icon(Icons.queue_music), + title: const Text('Add to queue'), + onTap: () { + controller.addToQueue(track); + done('Added to queue'); + }, + ), + ListTile( + leading: Icon(liked ? Icons.favorite : Icons.favorite_border, color: liked ? NkColors.like : null), + title: Text(liked ? 'Remove from liked songs' : 'Add to liked songs'), + onTap: () { + ref.read(likedSongsProvider.notifier).toggle(track.id); + done(liked ? 'Removed from liked songs' : 'Added to liked songs'); + }, + ), + ListTile( + leading: Icon(downloaded ? Icons.download_done_rounded : Icons.download_rounded, + color: downloaded ? NkColors.teaLeaf : null), + title: Text(downloaded ? 'Remove download' : 'Download'), + onTap: () { + if (downloaded) { + ref.read(downloadStoreProvider).remove(track.id); + done('Download removed'); + } else if (!isSignedIn) { + Navigator.of(sheetContext).pop(); + context.push('/login?mode=signup'); + } else { + controller.download([track]); + done('Downloading. Find it in Downloads.'); + } + }, + ), + for (final a in artists) + ListTile( + leading: const Icon(Icons.person_outline), + title: Text('Go to ${a.name}'), + onTap: () { + Navigator.of(sheetContext).pop(); + context.push('/artist/${a.id}'); + }, + ), + ], + ), + ), + ); +} diff --git a/nanga_kathai_01_splash.png b/nanga_kathai_01_splash.png new file mode 100644 index 0000000..6b2c872 Binary files /dev/null and b/nanga_kathai_01_splash.png differ diff --git a/nanga_kathai_02_home.png b/nanga_kathai_02_home.png new file mode 100644 index 0000000..191f9ed Binary files /dev/null and b/nanga_kathai_02_home.png differ diff --git a/nanga_kathai_03_now_playing.png b/nanga_kathai_03_now_playing.png new file mode 100644 index 0000000..e7d501e Binary files /dev/null and b/nanga_kathai_03_now_playing.png differ diff --git a/nanga_kathai_04_explore.png b/nanga_kathai_04_explore.png new file mode 100644 index 0000000..c10cb25 Binary files /dev/null and b/nanga_kathai_04_explore.png differ diff --git a/nanga_kathai_05_artist.png b/nanga_kathai_05_artist.png new file mode 100644 index 0000000..0d5eb2b Binary files /dev/null and b/nanga_kathai_05_artist.png differ diff --git a/pubspec.lock b/pubspec.lock new file mode 100644 index 0000000..be02213 --- /dev/null +++ b/pubspec.lock @@ -0,0 +1,1159 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + app_links: + dependency: transitive + description: + name: app_links + sha256: "08ba88bf19d9edea33130cb71c8ced2cb982b4e00b82efef4a434d1357bc69c3" + url: "https://pub.dev" + source: hosted + version: "7.2.2" + app_links_linux: + dependency: transitive + description: + name: app_links_linux + sha256: "9ccb5802085a1e57bb07b6256fd1d61a78c7ec104944d4965e62938b4fefd9cb" + url: "https://pub.dev" + source: hosted + version: "1.0.4" + app_links_platform_interface: + dependency: transitive + description: + name: app_links_platform_interface + sha256: "7546f09a6e93f4a2df2fe2bd40a5c6c64310ac461b036d82b43033be7a59f809" + url: "https://pub.dev" + source: hosted + version: "2.0.4" + app_links_web: + dependency: transitive + description: + name: app_links_web + sha256: "382d32c6f486f15b1d7f5d6a37a70a481a4fc08382eef419d373169921a3e765" + url: "https://pub.dev" + source: hosted + version: "1.0.5" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + audio_service: + dependency: "direct main" + description: + name: audio_service + sha256: "95f3267f3449eb5cf71c8fcf1d556f57af1e898e2dc5815fb168d1843653edb7" + url: "https://pub.dev" + source: hosted + version: "0.18.19" + audio_service_platform_interface: + dependency: transitive + description: + name: audio_service_platform_interface + sha256: "6283782851f6c8b501b60904a32fc7199dc631172da0629d7301e66f672ab777" + url: "https://pub.dev" + source: hosted + version: "0.1.3" + audio_service_web: + dependency: transitive + description: + name: audio_service_web + sha256: b8ea9243201ee53383157fbccf13d5d2a866b5dda922ec19d866d1d5d70424df + url: "https://pub.dev" + source: hosted + version: "0.1.4" + audio_session: + dependency: "direct main" + description: + name: audio_session + sha256: f9e7711a0e24ca8b40f5d7ac374c3c6e55016f3157912badd18199cdbbca5c4d + url: "https://pub.dev" + source: hosted + version: "0.2.4" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + cached_network_image: + dependency: "direct main" + description: + name: cached_network_image + sha256: "775542110bd3f668c41f9b6d0b4e3c51b1f6c267096739e91667ed4c34479bb9" + url: "https://pub.dev" + source: hosted + version: "4.0.5" + cached_network_image_platform_interface: + dependency: transitive + description: + name: cached_network_image_platform_interface + sha256: "555e287d3882fbb36cde50d24214cb3f6a272d39c4386e8afe00cb188d35b285" + url: "https://pub.dev" + source: hosted + version: "5.0.3" + cached_network_image_web: + dependency: transitive + description: + name: cached_network_image_web + sha256: "38762ec3bd2295f4d79545add118f525bd5a105a53692c06208c4875f2abde62" + url: "https://pub.dev" + source: hosted + version: "2.0.3" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" + clock: + dependency: transitive + description: + name: clock + sha256: e51d50bca3217c9a9fa2b41a30e4a38971133f5f9ec7a3d57bae095007f1d28e + url: "https://pub.dev" + source: hosted + version: "1.1.3" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: f141ea4f277af142a0356955707f6556f37b03947d39d55585981a06ca437bd6 + url: "https://pub.dev" + source: hosted + version: "0.3.5+5" + crypto: + dependency: transitive + description: + name: crypto + sha256: e3626ae6270383c9e3a549f34c9e4cd26c09ae6a9adaab2bf4737a00340efdc4 + url: "https://pub.dev" + source: hosted + version: "3.0.8" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd" + url: "https://pub.dev" + source: hosted + version: "1.0.9" + cupertino_ui: + dependency: transitive + description: + name: cupertino_ui + sha256: a47c4e7c66fd7444a063a602597fceb238f705c3f6b1322bc0053d0a2638ee6e + url: "https://pub.dev" + source: hosted + version: "1.1.4" + dart_jsonwebtoken: + dependency: transitive + description: + name: dart_jsonwebtoken + sha256: ad84e60181696513d04d5f2078e0bbc20365b911f46f647797317414bdc88fbe + url: "https://pub.dev" + source: hosted + version: "3.4.1" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + ffi_leak_tracker: + dependency: transitive + description: + name: ffi_leak_tracker + sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97" + url: "https://pub.dev" + source: hosted + version: "0.1.2" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: da76400e7872ce7637ffdce12749ec24169c25f6195c28372208e65a24bcd2ab + url: "https://pub.dev" + source: hosted + version: "0.9.4+1" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: d785431f1910aac8a50e5c492cae912320158030a88703c67b9ef7f1f96fd7ba + url: "https://pub.dev" + source: hosted + version: "0.9.5+2" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85" + url: "https://pub.dev" + source: hosted + version: "2.7.0" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: fbefc5fb92c6d3cbe8d284a2cd971b593bb07d2cd6da8557b81a862250b4acec + url: "https://pub.dev" + source: hosted + version: "0.9.3+6" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_cache_manager: + dependency: transitive + description: + name: flutter_cache_manager + sha256: "7253cd28eed9b79cbf403d72247459a2f333c8b2ccc702161eaeeac205c4a1cb" + url: "https://pub.dev" + source: hosted + version: "3.4.6" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" + url: "https://pub.dev" + source: hosted + version: "6.0.0" + flutter_localizations: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: "3854fe5e3bff0b113c658f260b90c95dea17c92db0f2addeac2e343dd9969785" + url: "https://pub.dev" + source: hosted + version: "2.0.35" + flutter_riverpod: + dependency: "direct main" + description: + name: flutter_riverpod + sha256: "2b7f9d2a3c730ac1a98221b420cef966b990fca7ab546ede324642ca57a533e3" + url: "https://pub.dev" + source: hosted + version: "3.4.3" + flutter_secure_storage: + dependency: "direct main" + description: + name: flutter_secure_storage + sha256: d4e1fb6b2cb524868929e78dc0282fa000554b22060fb53789dc481c9fc95bb8 + url: "https://pub.dev" + source: hosted + version: "11.2.0" + flutter_secure_storage_darwin: + dependency: transitive + description: + name: flutter_secure_storage_darwin + sha256: a031ceac9b070e62ef183cd7f7d1a8798ebde402d5049f0fe6eb3a51bd797a21 + url: "https://pub.dev" + source: hosted + version: "0.4.3" + flutter_secure_storage_linux: + dependency: transitive + description: + name: flutter_secure_storage_linux + sha256: caa75bd78f017422912e3a904933113b2428eb79f981ac37acd2dd2a700458ea + url: "https://pub.dev" + source: hosted + version: "3.0.3" + flutter_secure_storage_platform_interface: + dependency: transitive + description: + name: flutter_secure_storage_platform_interface + sha256: "64951127f001f546891c86f414972bf49ef2c4c8a4c92c65f5790cc0c9100045" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + flutter_secure_storage_web: + dependency: transitive + description: + name: flutter_secure_storage_web + sha256: "073a62b3aeb866ab4ce795f960413948e51e5a42a9b0c8333b6daf5bb3208a1c" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + flutter_secure_storage_windows: + dependency: transitive + description: + name: flutter_secure_storage_windows + sha256: "471951813a97006d899db4948acc654a4f28c440083ea08178935ce20b173ec1" + url: "https://pub.dev" + source: hosted + version: "4.2.2" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + functions_client: + dependency: transitive + description: + name: functions_client + sha256: "931e822688d2e97867b2300e2fdbb08c9db73f8653fc83efc7a259d2e0cca769" + url: "https://pub.dev" + source: hosted + version: "2.7.1" + go_router: + dependency: "direct main" + description: + name: go_router + sha256: "07089be6dc124eb9596a51b5e359c657f30a59929287f11b126c2cd13eaaece0" + url: "https://pub.dev" + source: hosted + version: "18.1.1" + google_fonts: + dependency: "direct main" + description: + name: google_fonts + sha256: fdbbfbbb1a62f376ab29fa9627561d389751fb984da0b630ce3326e272911c8a + url: "https://pub.dev" + source: hosted + version: "9.0.0" + gotrue: + dependency: transitive + description: + name: gotrue + sha256: cca816226824160e40d149816fd89740cc64dda1eaa3d9161e334330b18bba89 + url: "https://pub.dev" + source: hosted + version: "2.27.3" + hooks: + dependency: transitive + description: + name: hooks + sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + http: + dependency: "direct main" + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + image_picker: + dependency: "direct main" + description: + name: image_picker + sha256: e71b1bd9e40124041128d60fc3034c7b1598e1bba4e570bba0c70301c817013a + url: "https://pub.dev" + source: hosted + version: "1.2.4" + image_picker_android: + dependency: transitive + description: + name: image_picker_android + sha256: "8a7823d5d3e884f7780829d85d92110d072bdcd9ed48e2be158d2abe91a912d4" + url: "https://pub.dev" + source: hosted + version: "0.8.13+25" + image_picker_for_web: + dependency: transitive + description: + name: image_picker_for_web + sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214" + url: "https://pub.dev" + source: hosted + version: "3.1.1" + image_picker_ios: + dependency: transitive + description: + name: image_picker_ios + sha256: b8f508306db2bf71141aba2de4ec13456338bf01e13f034bd2594f341a5f8eb2 + url: "https://pub.dev" + source: hosted + version: "0.8.13+14" + image_picker_linux: + dependency: transitive + description: + name: image_picker_linux + sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + image_picker_macos: + dependency: transitive + description: + name: image_picker_macos + sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91" + url: "https://pub.dev" + source: hosted + version: "0.2.2+1" + image_picker_platform_interface: + dependency: transitive + description: + name: image_picker_platform_interface + sha256: "2dee4b4e3be429db71472c9c559c981498070a61af1ba82e0e4986fc5dadc042" + url: "https://pub.dev" + source: hosted + version: "2.11.2" + image_picker_windows: + dependency: transitive + description: + name: image_picker_windows + sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae + url: "https://pub.dev" + source: hosted + version: "0.2.2" + intl: + dependency: transitive + description: + name: intl + sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867" + url: "https://pub.dev" + source: hosted + version: "0.20.3" + jni: + dependency: transitive + description: + name: jni + sha256: d8df6dab6847855512fe44dd9032b0bbe34dda7bf7597e41d30750879779813f + url: "https://pub.dev" + source: hosted + version: "1.1.1" + jni_flutter: + dependency: transitive + description: + name: jni_flutter + sha256: "0dd33d67797560f66e961a6053882d4df54bd345bf063b8fe7ea133f7ad8b191" + url: "https://pub.dev" + source: hosted + version: "1.0.4+1" + jni_util: + dependency: transitive + description: + name: jni_util + sha256: "1ba86da04a5f2bf18fde2edb235587e70c5b0fc5bd4ba955f46b00942c3fc35f" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + js: + dependency: transitive + description: + name: js + sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" + url: "https://pub.dev" + source: hosted + version: "0.7.2" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80" + url: "https://pub.dev" + source: hosted + version: "4.12.0" + just_audio: + dependency: "direct main" + description: + name: just_audio + sha256: e60aa97b233ddea025e13dfac59195207f528fa5e9e4a4a49a8c0766701e5fe6 + url: "https://pub.dev" + source: hosted + version: "0.10.6" + just_audio_platform_interface: + dependency: transitive + description: + name: just_audio_platform_interface + sha256: "2532c8d6702528824445921c5ff10548b518b13f808c2e34c2fd54793b999a6a" + url: "https://pub.dev" + source: hosted + version: "4.6.0" + just_audio_web: + dependency: transitive + description: + name: just_audio_web + sha256: "6ba8a2a7e87d57d32f0f7b42856ade3d6a9fbe0f1a11fabae0a4f00bb73f0663" + url: "https://pub.dev" + source: hosted + version: "0.4.16" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" + url: "https://pub.dev" + source: hosted + version: "6.1.0" + listen: + dependency: transitive + description: + name: listen + sha256: "47501a08016a43fcad79252439d723f50f14f88fa7bfd8a177e0a417e5c9e1f2" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" + url: "https://pub.dev" + source: hosted + version: "0.12.20" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.dev" + source: hosted + version: "0.13.0" + material_ui: + dependency: transitive + description: + name: material_ui + sha256: e4031746f96c8316864d9b92bce375f1320a36ed56e166307e1e1bda9d90b4ab + url: "https://pub.dev" + source: hosted + version: "1.8.0" + meta: + dependency: transitive + description: + name: meta + sha256: c82594181e3312f3d0695fc95aaaf7758d75b8d4ae2bbecf223b9fd5109a059d + url: "https://pub.dev" + source: hosted + version: "1.18.3" + mime: + dependency: transitive + description: + name: mime + sha256: bd47de35f07e27267e69c8c8b22edf9473bfee170a60d60fcc93730c5144b7f6 + url: "https://pub.dev" + source: hosted + version: "2.1.0" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: b7fb95a6d9a4f009edd63dc5ac69f07420b23a16161c6dd8660290b59c602e8e + url: "https://pub.dev" + source: hosted + version: "9.5.0" + octo_image: + dependency: transitive + description: + name: octo_image + sha256: "49c4f93afc10f1ad4fdef9db88fed9c65a8eb5d9ad0a4fa9b128d7fedeb41418" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + package_config: + dependency: transitive + description: + name: package_config + sha256: ffcf4cf3d6c0b74ac43708d9f56625506e8a68aa935abe9d267a7330f320eb5d + url: "https://pub.dev" + source: hosted + version: "3.0.0" + passkeys_platform_interface: + dependency: transitive + description: + name: passkeys_platform_interface + sha256: "5fca044a6f1a3c5abff645ca5a5417b613c130d1c4e08513ba9182c7f4834619" + url: "https://pub.dev" + source: hosted + version: "2.10.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_provider: + dependency: "direct main" + description: + name: path_provider + sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825 + url: "https://pub.dev" + source: hosted + version: "2.1.6" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" + url: "https://pub.dev" + source: hosted + version: "2.3.1" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + url: "https://pub.dev" + source: hosted + version: "2.6.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16" + url: "https://pub.dev" + source: hosted + version: "2.2.2" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda" + url: "https://pub.dev" + source: hosted + version: "2.1.3" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + platform: + dependency: transitive + description: + name: platform + sha256: a36d119c13416516a7b5913fbe8af8531e11633d784c550b2125f76c758524ec + url: "https://pub.dev" + source: hosted + version: "3.2.0" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + pointycastle: + dependency: "direct main" + description: + name: pointycastle + sha256: "92aa3841d083cc4b0f4709b5c74fd6409a3e6ba833ffc7dc6a8fee096366acf5" + url: "https://pub.dev" + source: hosted + version: "4.0.0" + postgrest: + dependency: transitive + description: + name: postgrest + sha256: b7f957273ba6a1c26cefa987ac524f4138e9ab7542fbb9e6abdfddacb9895a21 + url: "https://pub.dev" + source: hosted + version: "2.9.1" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "261236774e8b1d69cfc6b9eabbc96c40f25e7a2d6b171f3385d4f65d5734fb24" + url: "https://pub.dev" + source: hosted + version: "2.2.1" + realtime_client: + dependency: transitive + description: + name: realtime_client + sha256: "2ef680d83ba2c0f76019d16194ca7067fa2458b2d4091b52323a9ca4a964fd40" + url: "https://pub.dev" + source: hosted + version: "2.13.2" + record_use: + dependency: transitive + description: + name: record_use + sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" + url: "https://pub.dev" + source: hosted + version: "0.6.0" + riverpod: + dependency: transitive + description: + name: riverpod + sha256: "484dfc873ea4c4f4240e5635444fa066f87b07862b7279ebd004a2b71fba4b7a" + url: "https://pub.dev" + source: hosted + version: "3.4.3" + rxdart: + dependency: "direct main" + description: + name: rxdart + sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" + url: "https://pub.dev" + source: hosted + version: "0.28.0" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: "1e85e0dccf5b4a016077fea7dfbb4a7cb39c759ac1596fcf60c06deadb7da91f" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "33b38b07d9ac7d16aff0426c61418752fd3e7a8c7dc8be786c875d6ce673b002" + url: "https://pub.dev" + source: hosted + version: "2.5.4" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "2ec3934efa51e46117f23031cc141b8fc878e8525b94ec1ea4f7f586cf1b47ea" + url: "https://pub.dev" + source: hosted + version: "2.5.7" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + sqflite: + dependency: transitive + description: + name: sqflite + sha256: "21a9750de2deb464946e9f41c7ddf176e25553618afdfc774868cd454990c06a" + url: "https://pub.dev" + source: hosted + version: "2.4.5" + sqflite_android: + dependency: transitive + description: + name: sqflite_android + sha256: "3986f4cbdde19ca538aae877981c5a53400dcc4601499df18e7ed9e848a0bdd4" + url: "https://pub.dev" + source: hosted + version: "2.4.5" + sqflite_common: + dependency: transitive + description: + name: sqflite_common + sha256: b80e94ff0d551d5f1f8c7d14348d29b576cca3ae30f6e195a7c380552c32b1e0 + url: "https://pub.dev" + source: hosted + version: "2.5.14" + sqflite_darwin: + dependency: transitive + description: + name: sqflite_darwin + sha256: "8de68fc306040f992aaf378750c16f95e4298c4bb150dd45091d75e42b1f08ee" + url: "https://pub.dev" + source: hosted + version: "2.4.4+1" + sqflite_platform_interface: + dependency: transitive + description: + name: sqflite_platform_interface + sha256: "5b611dbcb9bab33e7073594ecac111bf0a86ffc342652c2081160d7038a45fb6" + url: "https://pub.dev" + source: hosted + version: "2.4.3" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "277654b3034d17ac6f9f1cb5595db011b1d5d41e8806866db28e0abaa101c490" + url: "https://pub.dev" + source: hosted + version: "1.12.2" + state_notifier: + dependency: transitive + description: + name: state_notifier + sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb + url: "https://pub.dev" + source: hosted + version: "1.0.0" + storage_client: + dependency: transitive + description: + name: storage_client + sha256: b951281b8e2b293460f0e6cc4e866f2546681390f0e37d24fbbd7b4a0deb27a3 + url: "https://pub.dev" + source: hosted + version: "2.8.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + supabase: + dependency: transitive + description: + name: supabase + sha256: "123fe08792b24e5e7c4b9a01145de8e95ebaa9183cd5f3c9d48dd2ff890f28a6" + url: "https://pub.dev" + source: hosted + version: "2.16.5" + supabase_common: + dependency: transitive + description: + name: supabase_common + sha256: a7961a44953cf8d9da049381fc3377516348255042fbb12b9d4f8540816f2fe9 + url: "https://pub.dev" + source: hosted + version: "0.1.2" + supabase_flutter: + dependency: "direct main" + description: + name: supabase_flutter + sha256: "48bbde226e68bdb293020859a1bfa42d0f3bd2239992fba0ec4f9194cbb842e6" + url: "https://pub.dev" + source: hosted + version: "2.18.3" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: b3d516dbd3757ff2a458735e4f6cbac9ff261f576f6e87bcb17feabb39e657c5 + url: "https://pub.dev" + source: hosted + version: "3.5.0" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" + url: "https://pub.dev" + source: hosted + version: "0.7.12" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + url_launcher: + dependency: transitive + description: + name: url_launcher + sha256: e8cc955c97e06df9879140a1fe301f3d493f01b7c62dec7f46834be31d7e9fda + url: "https://pub.dev" + source: hosted + version: "6.3.3" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "611e87fb320b70d1dd721dc46af89c98aceccea9b31fde49e084591414e0c610" + url: "https://pub.dev" + source: hosted + version: "6.3.33" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "8faa1aab294f1ab4040b43660c887b0418d5fa4f0cffef76a484e6aa1092eb4a" + url: "https://pub.dev" + source: hosted + version: "6.4.2" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: "10f86fef4c2c43563fa6c211ff9cf757adf4d3ab762c56bd430664a947d70cd0" + url: "https://pub.dev" + source: hosted + version: "3.2.3" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "5e835a3b869c2d70325349c81c5a45c28e20791265b67b2669da6b08c5cd5201" + url: "https://pub.dev" + source: hosted + version: "3.2.6" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34" + url: "https://pub.dev" + source: hosted + version: "2.4.3" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "6c5ad3f22cd4c38e089b81963b3cd7bb83b111b2df5dce008bb066162f42e429" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + uuid: + dependency: transitive + description: + name: uuid + sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd" + url: "https://pub.dev" + source: hosted + version: "4.6.0" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: "1d774bbdf6b72a0b12122fc1560c9c2d2a67db5a4a4cc2bd8a5c990ab20e3188" + url: "https://pub.dev" + source: hosted + version: "2.4.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "89b11d62e1040ee681af3b4f1c97a803ecbf010c6e8b9c5896ee5b08c3bf7b78" + url: "https://pub.dev" + source: hosted + version: "15.4.0" + web: + dependency: transitive + description: + name: web + sha256: e119e82ac4795c7925103b7cf3768191f33941b99e7ccffbd6c645201d9f606b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" + win32: + dependency: transitive + description: + name: win32 + sha256: a0b93865d5644f11cf6a8c3f6db909f1ec168958b5805f6cc684adea957cd63d + url: "https://pub.dev" + source: hosted + version: "6.4.0" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + yaml: + dependency: transitive + description: + name: yaml + sha256: f67cdd8e07d3c6329146aaef1ba043542b3134c12489f553ca9a7435d1068aea + url: "https://pub.dev" + source: hosted + version: "3.1.4" + yet_another_json_isolate: + dependency: transitive + description: + name: yet_another_json_isolate + sha256: eaa26beb5990b25a49d942374fd5a0c5aa67a837e03b14b4c26134aaa1ed01a9 + url: "https://pub.dev" + source: hosted + version: "2.1.1" +sdks: + dart: ">=3.13.4 <4.0.0" + flutter: ">=3.47.0" diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..1313fbe --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,110 @@ +name: nanga_kathai +description: "Nanga Kathai - music of the Badaga people" +# The following line prevents the package from being accidentally published to +# pub.dev using `flutter pub publish`. This is preferred for private packages. +publish_to: 'none' # Remove this line if you wish to publish to pub.dev + +# The following defines the version and build number for your application. +# A version number is three numbers separated by dots, like 1.2.43 +# followed by an optional build number separated by a +. +# Both the version and the builder number may be overridden in flutter +# build by specifying --build-name and --build-number, respectively. +# In Android, build-name is used as versionName while build-number used as versionCode. +# Read more about Android versioning at https://developer.android.com/studio/publish/versioning +# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. +# Read more about iOS versioning at +# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html +# In Windows, build-name is used as the major, minor, and patch parts +# of the product and file versions while build-number is used as the build suffix. +version: 1.0.0+1 + +environment: + sdk: ^3.13.4 + +# Dependencies specify other packages that your package needs in order to work. +# To automatically upgrade your package dependencies to the latest versions +# consider running `flutter pub upgrade --major-versions`. Alternatively, +# dependencies can be manually updated by changing the version numbers below to +# the latest version available on pub.dev. To see which dependencies have newer +# versions available, run `flutter pub outdated`. +dependencies: + flutter: + sdk: flutter + + # The following adds the Cupertino Icons font to your application. + # Use with the CupertinoIcons class for iOS style icons. + cupertino_icons: ^1.0.8 + flutter_riverpod: ^3.4.3 + go_router: ^18.1.1 + just_audio: ^0.10.6 + audio_service: ^0.18.19 + audio_session: ^0.2.4 + supabase_flutter: ^2.18.3 + cached_network_image: ^4.0.5 + google_fonts: ^9.0.0 + rxdart: ^0.28.0 + shared_preferences: ^2.5.6 + image_picker: ^1.2.4 + flutter_secure_storage: ^11.2.0 + pointycastle: ^4.0.0 + path_provider: ^2.1.6 + http: ^1.6.0 + +dev_dependencies: + flutter_test: + sdk: flutter + + # The "flutter_lints" package below contains a set of recommended lints to + # encourage good coding practices. The lint set provided by the package is + # activated in the `analysis_options.yaml` file located at the root of your + # package. See that file for information about deactivating specific lint + # rules and activating additional ones. + flutter_lints: ^6.0.0 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. +flutter: + + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. + uses-material-design: true + + assets: + - assets/brand/ + - assets/images/ + - assets/catalog/ + - assets/demo_audio/ + + # To add assets to your application, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/to/resolution-aware-images + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/to/asset-from-package + + # To add custom fonts to your application, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts from package dependencies, + # see https://flutter.dev/to/font-from-package diff --git a/server/README.md b/server/README.md new file mode 100644 index 0000000..46120dc --- /dev/null +++ b/server/README.md @@ -0,0 +1,138 @@ +# Nanga Kathai backend server + +Self-hosted [Supabase](https://supabase.com/docs/guides/self-hosting/docker) in Docker, pinned to `self-hosted/v0.8.3`, plus Nanga Kathai's config. One server runs: + +| Service | What it does for NK | +|---|---| +| Postgres | Songs, singers, likes, playlists, approvals, consent records | +| Auth | Email sign-in with a 6-digit code | +| REST API | What the app reads and writes (access rules in the schema) | +| Storage | Song files, covers, singer photos, on the server's disk | +| Studio | Admin dashboard in the browser (password protected) | +| Caddy | HTTPS with automatic Let's Encrypt certificates | + +## Quick deploy (one command) + +On a fresh Ubuntu server, once the DNS A record for `app.nkbmusic.com` points at it: + +```sh +# copy the repo to the server first, e.g. from your PC: +# ssh user@SERVER_IP mkdir -p nanga_kathai +# scp -r server supabase user@SERVER_IP:~/nanga_kathai/ +cd ~/APPS/nanga_kathai/server +NK_DIR=$HOME/APPS/nk-backend sh deploy.sh app.nkbmusic.com you@example.com +``` + +Then set the domain's reverse proxy (HestiaCP or nginx) to **`http://127.0.0.1:18000`**. HTTPS stays with the existing web server; Supabase is never exposed directly. + +It installs Supabase, asks for your SMTP details, starts everything, creates the tables and buckets, makes your email an admin, schedules backups and prints the app's `env.json`. Run it again after any update; secrets are kept. No sudo needed (only docker and git). + +On a fresh server with nothing on ports 80/443, use `sudo NK_PROXY=caddy sh deploy.sh ...` instead: it also installs Docker and a firewall, and Caddy gets the certificate itself. Once the code is on GitHub, add `REPO_URL=` to clone it first. + +The sections below are the same steps done by hand (in Caddy mode). + +## 1. Server + +- Ubuntu 24.04 (or 22.04) VPS: **4 GB RAM, 2 CPU minimum** (8 GB / 4 CPU recommended). +- Disk: 40 GB for the system and database, plus room for songs. A few hundred songs at three qualities is roughly 5–10 GB, so **80 GB** is a comfortable start. +- A domain or subdomain for the API, e.g. `app.nkbmusic.com`. Create a DNS **A record** pointing it at the server's IP **before** installing, so Caddy can get a certificate. + +Open only SSH and web ports: + +```sh +sudo ufw allow 22,80,443/tcp && sudo ufw allow 443/udp && sudo ufw enable +``` + +Install Docker if it isn't installed yet, then log out and back in: + +```sh +curl -fsSL https://get.docker.com | sh +sudo usermod -aG docker $USER +``` + +## 2. Install + +Copy this repository to the server (git clone, or copy the `server/` and `supabase/` folders), then: + +```sh +cd nanga_kathai/server +sh install.sh app.nkbmusic.com +``` + +This fetches the official Supabase Docker files, generates all secrets and keys into `~/nk-backend/supabase-project/.env`, adds the NK config and pulls the images. + +## 3. Email (needed for sign-in codes) + +Edit `~/nk-backend/supabase-project/.env` and fill in a real SMTP account. Any transactional email provider works (Amazon SES, Brevo, Zoho Mail, Google Workspace SMTP relay): + +``` +SMTP_ADMIN_EMAIL=no-reply@nkbmusic.com +SMTP_HOST=... +SMTP_PORT=587 +SMTP_USER=... +SMTP_PASS=... +SMTP_SENDER_NAME=Nanga Kathai +``` + +## 4. Start and set up the database + +```sh +cd ~/nk-backend/supabase-project +sh run.sh start # waits until every service is healthy +docker compose ps # all "Up (healthy)" +sh ~/nanga_kathai/server/apply-migrations.sh +``` + +`apply-migrations.sh` creates the NK tables, access rules and storage buckets. It remembers what it has applied, so run it again whenever new files appear in `supabase/migrations/`. + +## 5. Make yourself admin + +1. Sign in once from the app with your email. +2. Open the dashboard at `https://app.nkbmusic.com`. The user is `nkadmin`; the password is `DASHBOARD_PASSWORD` in `.env`. +3. In the SQL editor run: + ```sql + update profiles set role = 'admin' + where id = (select id from auth.users where email = 'you@example.com'); + ``` + +## 6. Point the app at the server + +`env.json` in the app folder (git-ignored): + +```json +{ + "SUPABASE_URL": "https://app.nkbmusic.com", + "SUPABASE_PUBLISHABLE_KEY": "", + "MEDIA_BASE_URL": "https://app.nkbmusic.com/storage/v1/object/public/audio" +} +``` + +```sh +flutter build apk --release --dart-define-from-file=env.json +``` + +Only the **publishable** key goes in the app. Never put `SUPABASE_SECRET_KEY`, `SERVICE_ROLE_KEY` or the Postgres password in the app. + +## 7. Backups + +```sh +crontab -e +30 2 * * * sh $HOME/nanga_kathai/server/backup.sh >> $HOME/nk-backups/backup.log 2>&1 +``` + +This keeps 14 days of database dumps and song files in `~/nk-backups`. Copy that folder off the server regularly. A backup that lives only on the same server is lost with the server. + +## Everyday commands + +```sh +cd ~/nk-backend/supabase-project +sh run.sh logs auth # logs for one service +sh run.sh stop / start +sh update.sh # upgrade Supabase (read its notes first, back up before) +``` + +## Notes + +- Free legacy songs sit in the public `audio` bucket, so anyone with a song's URL can download it. When premium songs arrive they go in a private bucket with short-lived signed URLs. +- If streaming traffic grows, the song files can move to Cloudflare R2 (no bandwidth fees). The app only needs a new `MEDIA_BASE_URL`. +- Postgres ports 5432/6543 are bound to `127.0.0.1`. To connect from your PC, use an SSH tunnel: `ssh -L 5432:localhost:5432 user@server`. diff --git a/server/apply-migrations.sh b/server/apply-migrations.sh new file mode 100644 index 0000000..bbfd967 --- /dev/null +++ b/server/apply-migrations.sh @@ -0,0 +1,39 @@ +#!/bin/sh +# Applies Nanga Kathai SQL (schema, then storage buckets) that has not been applied yet. +# Safe to run again after adding new migration files. +# +# Usage: sh apply-migrations.sh [supabase-project-dir] +set -eu + +HERE="$(cd "$(dirname "$0")" && pwd)" +PROJECT="${1:-$HOME/nk-backend/supabase-project}" +cd "$PROJECT" + +# Keep the project's copy in step with the repo. +mkdir -p nk/migrations +cp "$HERE/../supabase/migrations/"*.sql nk/migrations/ +cp "$HERE/sql/"*.sql nk/sql/ + +PGPASSWORD="$(grep -E '^POSTGRES_PASSWORD=' .env | cut -d= -f2-)" + +psql_db() { + docker compose exec -T -e PGPASSWORD="$PGPASSWORD" db \ + psql -h localhost -U postgres -d postgres -v ON_ERROR_STOP=1 -q "$@" +} + +psql_db -c "create table if not exists public.nk_schema_migrations ( + name text primary key, applied_at timestamptz not null default now()); + alter table public.nk_schema_migrations enable row level security;" + +for f in nk/migrations/*.sql nk/sql/*.sql; do + name="$(basename "$f")" + done_already="$(psql_db -tA -c "select 1 from public.nk_schema_migrations where name = '$name'")" + if [ "$done_already" = "1" ]; then + echo "skip $name" + continue + fi + echo "apply $name" + { cat "$f"; printf "\ninsert into public.nk_schema_migrations (name) values ('%s');\n" "$name"; } | psql_db -1 -f - +done + +echo "Migrations up to date." diff --git a/server/backup.sh b/server/backup.sh new file mode 100644 index 0000000..3bc7bd6 --- /dev/null +++ b/server/backup.sh @@ -0,0 +1,25 @@ +#!/bin/sh +# Nightly backup: database dump + uploaded files (songs, covers, photos). Keeps 14 days. +# Copy the backups folder off the server too (another machine, or cloud storage). +# +# Cron (as the user that runs docker): +# 30 2 * * * sh /path/to/server/backup.sh >> $HOME/nk-backups/backup.log 2>&1 +set -eu + +PROJECT="${1:-$HOME/nk-backend/supabase-project}" +DEST="${2:-$HOME/nk-backups}" +STAMP="$(date +%Y%m%d-%H%M)" +mkdir -p "$DEST" +cd "$PROJECT" + +PGPASSWORD="$(grep -E '^POSTGRES_PASSWORD=' .env | cut -d= -f2-)" + +docker compose exec -T -e PGPASSWORD="$PGPASSWORD" db \ + pg_dump -h localhost -U postgres -d postgres -Fc > "$DEST/db-$STAMP.dump" + +tar -czf "$DEST/storage-$STAMP.tar.gz" -C volumes storage + +find "$DEST" -name 'db-*.dump' -mtime +14 -delete +find "$DEST" -name 'storage-*.tar.gz' -mtime +14 -delete + +echo "$(date -Is) backup ok: db-$STAMP.dump storage-$STAMP.tar.gz" diff --git a/server/caddy/Caddyfile b/server/caddy/Caddyfile new file mode 100644 index 0000000..47ffff6 --- /dev/null +++ b/server/caddy/Caddyfile @@ -0,0 +1,19 @@ +{$PROXY_DOMAIN} { + @supabase_api path /auth/v1/* /rest/v1/* /graphql/v1 /realtime/v1/* /storage/v1/* /functions/v1/* /mcp /sso/* /.well-known/oauth-authorization-server/auth/v1 + + handle @supabase_api { + reverse_proxy api-gw:8000 + } + + # Supabase Studio (admin dashboard), behind basic auth. + handle { + basic_auth { + # PROXY_AUTH_PASSWORD is overwritten with the bcrypt on startup + {$PROXY_AUTH_USERNAME} {$PROXY_AUTH_PASSWORD} + } + + reverse_proxy studio:3000 + } + + header -server +} diff --git a/server/deploy.sh b/server/deploy.sh new file mode 100644 index 0000000..59e65ff --- /dev/null +++ b/server/deploy.sh @@ -0,0 +1,199 @@ +#!/bin/sh +# One-command deploy of the Nanga Kathai backend. Run it again any time to apply +# updates (new migrations, config changes); existing secrets are kept. +# +# sh deploy.sh app.nkbmusic.com you@example.com +# +# Arguments: [admin-email] +# +# Two modes (NK_PROXY): +# external (default) The server already has a web server (nginx, HestiaCP...). +# Supabase listens on 127.0.0.1:18000 and you point the +# domain's reverse proxy at it. Needs docker + git, no sudo. +# caddy Fresh server with nothing on ports 80/443. Run with sudo: +# installs Docker, a firewall, and Caddy gets HTTPS itself. +# +# Optional environment: +# REPO_URL git URL of this repository: cloned (or pulled) and run from there +# NK_DIR install folder (default ~/nk-backend, /opt/nk-backend with caddy) +# API_GW_HTTP_PORT local port for the reverse proxy (default 18000) +# SMTP_HOST SMTP_PORT SMTP_USER SMTP_PASS SMTP_ADMIN_EMAIL +# email for sign-in codes; asked for if not set +set -eu + +DOMAIN="${1:-}" +ADMIN_EMAIL="${2:-}" +export NK_PROXY="${NK_PROXY:-external}" +export API_GW_HTTP_PORT="${API_GW_HTTP_PORT:-18000}" + +say() { printf '\n==> %s\n' "$*"; } +die() { printf 'Error: %s\n' "$*" >&2; exit 1; } + +[ -n "$DOMAIN" ] || die "usage: sh deploy.sh [admin-email]" + +if [ "$NK_PROXY" = caddy ]; then + [ "$(id -u)" = 0 ] || die "caddy mode installs packages: run with sudo" + NK_DIR="${NK_DIR:-/opt/nk-backend}" + BACKUPS="/opt/nk-backups" + API="https://$DOMAIN" +else + NK_DIR="${NK_DIR:-$HOME/nk-backend}" + BACKUPS="$HOME/nk-backups" + API="http://127.0.0.1:$API_GW_HTTP_PORT" +fi +PROJECT="$NK_DIR/supabase-project" + +# --- 1. System (caddy mode only) --------------------------------------------- +if [ "$NK_PROXY" = caddy ]; then + say "Installing system packages" + export DEBIAN_FRONTEND=noninteractive + apt-get update -qq + apt-get install -y -qq git curl ca-certificates dnsutils ufw cron >/dev/null + command -v docker >/dev/null 2>&1 || curl -fsSL https://get.docker.com | sh >/dev/null + systemctl enable --now docker >/dev/null 2>&1 || true + + say "Firewall: allowing SSH, 80 and 443 only" + SSH_PORT="$(ss -tlnpH 2>/dev/null | awk '/sshd/ {sub(/.*:/, "", $4); print $4; exit}')" + for rule in "${SSH_PORT:-22}/tcp" 22/tcp 80/tcp 443/tcp 443/udp; do ufw allow "$rule" >/dev/null; done + ufw --force enable >/dev/null +fi +for cmd in git curl docker; do + command -v "$cmd" >/dev/null 2>&1 || die "'$cmd' is not installed" +done +docker compose version >/dev/null 2>&1 || die "docker compose plugin missing" +docker ps >/dev/null 2>&1 || die "can't use docker: add this user to the docker group" + +# --- 2. Code ----------------------------------------------------------------- +if [ -n "${REPO_URL:-}" ] && [ "${NK_FROM_CLONE:-}" != 1 ]; then + CODE="${NK_CODE_DIR:-$(dirname "$NK_DIR")/nanga_kathai}" + say "Fetching the code from $REPO_URL into $CODE" + if [ -d "$CODE/.git" ]; then git -C "$CODE" pull --ff-only --quiet + else git clone --quiet "$REPO_URL" "$CODE"; fi + NK_FROM_CLONE=1 exec sh "$CODE/server/deploy.sh" "$@" +fi +HERE="$(cd "$(dirname "$0")" && pwd)" +[ -d "$HERE/../supabase/migrations" ] || die "can't find supabase/migrations next to server/" + +# --- 3. Port / DNS checks ---------------------------------------------------- +if [ ! -f "$PROJECT/.env" ] && [ "$NK_PROXY" != caddy ] \ + && ss -tln | grep -qE "[:.]$API_GW_HTTP_PORT\b"; then + die "port $API_GW_HTTP_PORT is already in use; set API_GW_HTTP_PORT to a free port" +fi +DNS_IP="$(getent ahostsv4 "$DOMAIN" | awk 'NR==1 {print $1}')" +[ -n "$DNS_IP" ] || echo "Warning: $DOMAIN has no DNS record yet." + +# --- 4. Supabase + NK config ------------------------------------------------- +if [ ! -f "$PROJECT/.env" ]; then + say "Installing Supabase (first run) into $PROJECT" + sh "$HERE/install.sh" "$DOMAIN" "$NK_DIR" +else + say "Existing install found: refreshing the NK config" + cp -r "$HERE/caddy" "$HERE/email-templates" "$HERE/sql" "$PROJECT/nk/" + cp "$HERE/docker-compose.nk.yml" "$HERE/docker-compose.nk-caddy.yml" "$PROJECT/" +fi +cd "$PROJECT" + +env_get() { grep -E "^$1=" .env | head -n1 | cut -d= -f2-; } +env_set() { # safe for any characters in the value + K="$1" V="$2" awk 'BEGIN { k = ENVIRON["K"]; v = ENVIRON["V"]; done = 0 } + index($0, k "=") == 1 { print k "=" v; done = 1; next } { print } + END { if (!done) print k "=" v }' .env > .env.tmp + cat .env.tmp > .env && rm .env.tmp +} + +# --- 5. Email (sign-in codes) ------------------------------------------------ +ask() { # ask VAR "question" [default] + eval "cur=\${$1:-}" + if [ -z "$cur" ] && [ -t 0 ]; then + printf '%s%s: ' "$2" "${3:+ [$3]}"; read -r cur + [ -n "$cur" ] || cur="${3:-}" + fi + eval "$1=\$cur" +} +if [ "$(env_get SMTP_HOST)" = "supabase-mail" ] || [ -n "${SMTP_HOST:-}" ]; then + say "Email settings for sign-in codes (Enter on SMTP host to skip for now)" + ask SMTP_HOST "SMTP host" + if [ -n "$SMTP_HOST" ]; then + ask SMTP_PORT "SMTP port" 587 + ask SMTP_USER "SMTP user" "no-reply@${DOMAIN#*.}" + ask SMTP_PASS "SMTP password" + ask SMTP_ADMIN_EMAIL "Send codes from" "$SMTP_USER" + env_set SMTP_HOST "$SMTP_HOST" + env_set SMTP_PORT "$SMTP_PORT" + env_set SMTP_USER "$SMTP_USER" + env_set SMTP_PASS "$SMTP_PASS" + env_set SMTP_ADMIN_EMAIL "$SMTP_ADMIN_EMAIL" + env_set SMTP_SENDER_NAME "Nanga Kathai" + else + echo "Skipped: sign-in emails won't send until you run deploy.sh again with SMTP details." + fi +fi + +# --- 6. Start ---------------------------------------------------------------- +say "Starting the backend (first start takes a few minutes)" +sh run.sh start + +# --- 7. Database ------------------------------------------------------------- +say "Creating tables, access rules and storage buckets" +sh "$HERE/apply-migrations.sh" "$PROJECT" + +# --- 8. API check ------------------------------------------------------------ +say "Checking the API at $API" +n=0 +until curl -fsS -o /dev/null "$API/auth/v1/health" -H "apikey: $(env_get ANON_KEY)"; do + n=$((n + 1)) + [ $n -lt 30 ] || { echo "API not answering (see: sh run.sh logs api-gw)"; break; } + sleep 5 +done + +# --- 9. Admin account -------------------------------------------------------- +if [ -n "$ADMIN_EMAIL" ]; then + say "Making $ADMIN_EMAIL an admin" + SERVICE_KEY="$(env_get SERVICE_ROLE_KEY)" + curl -sS -o /dev/null -X POST "$API/auth/v1/admin/users" \ + -H "apikey: $SERVICE_KEY" -H "Authorization: Bearer $SERVICE_KEY" \ + -H "Content-Type: application/json" \ + -d "{\"email\": \"$ADMIN_EMAIL\", \"email_confirm\": true}" || true # 422 = already exists + docker compose exec -T -e PGPASSWORD="$(env_get POSTGRES_PASSWORD)" db \ + psql -h localhost -U postgres -d postgres -qtA -c \ + "update public.profiles set role = 'admin' + where id = (select id from auth.users where email = lower('$ADMIN_EMAIL')) + returning 'admin set';" | grep -q 'admin set' \ + || echo "Couldn't set admin yet. Sign in once from the app, then run deploy.sh again." +fi + +# --- 10. Backups ------------------------------------------------------------- +say "Scheduling nightly backups to $BACKUPS" +mkdir -p "$BACKUPS" +CRON="30 2 * * * sh $HERE/backup.sh $PROJECT $BACKUPS >> $BACKUPS/backup.log 2>&1" +( crontab -l 2>/dev/null | grep -v 'nk-backups' || true; echo "$CRON" ) | crontab - + +# --- Done -------------------------------------------------------------------- +PROXY_NOTE="" +[ "$NK_PROXY" = caddy ] || PROXY_NOTE=" + Reverse proxy: point https://$DOMAIN at http://127.0.0.1:$API_GW_HTTP_PORT + (HestiaCP: Web > $DOMAIN > Proxy, or a custom nginx proxy template) +" +cat < to 127.0.0.1:${API_GW_HTTP_PORT}. For a server with nothing on +# ports 80/443, install.sh also layers docker-compose.nk-caddy.yml instead. +services: + + # The API gateway, reachable from this server only (the reverse proxy forwards to it). + api-gw: + ports: !override + - 127.0.0.1:${API_GW_HTTP_PORT:-18000}:8000/tcp + + # Serves the email templates inside the Docker network only. + nk-mail-templates: + image: nginx:1.27-alpine + restart: unless-stopped + volumes: + - ./nk/email-templates:/usr/share/nginx/html:ro + + # Send a 6-digit code (not a link) for passwordless email sign-in. + auth: + depends_on: + nk-mail-templates: + condition: service_started + environment: + GOTRUE_MAILER_TEMPLATES_MAGIC_LINK: http://nk-mail-templates/magic_link.html + GOTRUE_MAILER_TEMPLATES_CONFIRMATION: http://nk-mail-templates/confirmation.html + GOTRUE_MAILER_SUBJECTS_MAGIC_LINK: "Your Nanga Kathai sign-in code" + GOTRUE_MAILER_TEMPLATES_RECOVERY: http://nk-mail-templates/recovery.html + GOTRUE_MAILER_SUBJECTS_CONFIRMATION: "Confirm your Nanga Kathai account" + GOTRUE_MAILER_SUBJECTS_RECOVERY: "Reset your Nanga Kathai password" + GOTRUE_PASSWORD_MIN_LENGTH: "8" + + # Postgres is reachable from the server itself only, never from the internet. + supavisor: + ports: !override + - 127.0.0.1:${NK_DB_PORT:-15432}:5432 + - 127.0.0.1:${NK_POOLER_PORT:-16543}:6543 diff --git a/server/email-templates/confirmation.html b/server/email-templates/confirmation.html new file mode 100644 index 0000000..d9871d5 --- /dev/null +++ b/server/email-templates/confirmation.html @@ -0,0 +1,15 @@ + + + + + +
+ + + + + +
Nanga Kathai
Welcome! Confirm your email with this code:
{{ .Token }}
Enter this code in the app. It expires in one hour. If you didn't ask for it, you can ignore this email.
+
+ + diff --git a/server/email-templates/magic_link.html b/server/email-templates/magic_link.html new file mode 100644 index 0000000..a965b1b --- /dev/null +++ b/server/email-templates/magic_link.html @@ -0,0 +1,15 @@ + + + + + +
+ + + + + +
Nanga Kathai
Your sign-in code:
{{ .Token }}
Enter this code in the app. It expires in one hour. If you didn't ask for it, you can ignore this email.
+
+ + diff --git a/server/email-templates/recovery.html b/server/email-templates/recovery.html new file mode 100644 index 0000000..f6e3fef --- /dev/null +++ b/server/email-templates/recovery.html @@ -0,0 +1,15 @@ + + + + + +
+ + + + + +
Nanga Kathai
Use this code to reset your password:
{{ .Token }}
Enter this code in the app. It expires in one hour. If you didn't ask for it, you can ignore this email.
+
+ + diff --git a/server/install.sh b/server/install.sh new file mode 100644 index 0000000..f8b9e4a --- /dev/null +++ b/server/install.sh @@ -0,0 +1,96 @@ +#!/bin/sh +# Sets up the Nanga Kathai backend (self-hosted Supabase) on a Linux server. +# +# Usage: sh install.sh [install-dir] +# e.g. sh install.sh app.nkbmusic.com +# +# NK_PROXY=external (default): the server's nginx/HestiaCP proxies https:// +# to 127.0.0.1:${API_GW_HTTP_PORT:-18000}. +# NK_PROXY=caddy: nothing else uses ports 80/443; Caddy gets the certificate itself. +# +# Needs: git, docker with the compose plugin (v2.24+), and DNS for pointing at this server. +set -eu + +SUPABASE_REF="self-hosted/v0.8.3" # pinned; upgrade later with the project's update.sh +DOMAIN="${1:-}" +TARGET="${2:-$HOME/nk-backend}" +NK_PROXY="${NK_PROXY:-external}" +API_GW_HTTP_PORT="${API_GW_HTTP_PORT:-18000}" +HERE="$(cd "$(dirname "$0")" && pwd)" + +if [ -z "$DOMAIN" ]; then + echo "Usage: sh install.sh [install-dir]" >&2 + exit 1 +fi + +for cmd in git docker; do + command -v "$cmd" >/dev/null 2>&1 || { + echo "Missing '$cmd'. Install Docker with: curl -fsSL https://get.docker.com | sh" >&2 + exit 1 + } +done +docker compose version >/dev/null 2>&1 || { echo "Missing the docker compose plugin." >&2; exit 1; } + +if [ -e "$TARGET/supabase-project/.env" ]; then + echo "$TARGET/supabase-project already exists; refusing to overwrite its secrets." >&2 + exit 1 +fi + +echo "==> Fetching Supabase $SUPABASE_REF" +mkdir -p "$TARGET" +cd "$TARGET" +rm -rf supabase-src +git clone --filter=blob:none --no-checkout --depth=1 --quiet --branch "$SUPABASE_REF" \ + https://github.com/supabase/supabase supabase-src +(cd supabase-src && git sparse-checkout init --cone && git sparse-checkout set docker && git checkout --quiet) +mkdir -p supabase-project +cp -rf supabase-src/docker/. supabase-project +rm -rf supabase-src +cd supabase-project +cp .env.example .env +printf 'ref=%s\n' "$SUPABASE_REF" > .supabase-version + +echo "==> Generating secrets and API keys" +sh utils/generate-keys.sh --update-env >/dev/null +sh utils/add-new-auth-keys.sh --update-env >/dev/null + +set_env() { # set_env KEY VALUE (replaces the line KEY=...) + sed -i "s|^$1=.*|$1=$2|" .env +} +set_env PROXY_DOMAIN "$DOMAIN" +set_env SUPABASE_PUBLIC_URL "https://$DOMAIN" +set_env API_EXTERNAL_URL "https://$DOMAIN/auth/v1" +set_env SITE_URL "https://$DOMAIN" +set_env DASHBOARD_USERNAME "nkadmin" +grep -q '^API_GW_HTTP_PORT=' .env || echo "API_GW_HTTP_PORT=$API_GW_HTTP_PORT" >> .env +set_env API_GW_HTTP_PORT "$API_GW_HTTP_PORT" +set_env DASHBOARD_PASSWORD "$(LC_ALL=C tr -dc 'A-Za-z0-9' Adding Nanga Kathai config" +mkdir -p nk +cp -r "$HERE/caddy" "$HERE/email-templates" "$HERE/sql" nk/ +cp "$HERE/docker-compose.nk.yml" "$HERE/docker-compose.nk-caddy.yml" . +mkdir -p nk/migrations +cp "$HERE/../supabase/migrations/"*.sql nk/migrations/ +if [ "$NK_PROXY" = caddy ]; then + sh run.sh config add caddy nk nk-caddy >/dev/null +else + sh run.sh config add nk >/dev/null +fi + +echo "==> Pulling images (this downloads a few GB the first time)" +docker compose pull --quiet + +cat < http://127.0.0.1:$API_GW_HTTP_PORT + Admin dashboard: https://$DOMAIN (user: nkadmin, password: DASHBOARD_PASSWORD in .env) + 5. App keys: grep -E '^SUPABASE_PUBLISHABLE_KEY=' .env +EOF diff --git a/server/sql/storage_buckets.sql b/server/sql/storage_buckets.sql new file mode 100644 index 0000000..4ab18e4 --- /dev/null +++ b/server/sql/storage_buckets.sql @@ -0,0 +1,15 @@ +-- Storage buckets for Nanga Kathai (files live on the server disk: volumes/storage). +-- Public buckets are readable at https:///storage/v1/object/public//. +-- Free legacy songs are public; premium songs will move to a private bucket with signed URLs. + +insert into storage.buckets (id, name, public, file_size_limit, allowed_mime_types) values + ('audio', 'audio', true, 52428800, array['audio/mpeg','audio/mp4','audio/aac','audio/x-m4a','audio/wav']), + ('covers', 'covers', true, 5242880, array['image/jpeg','image/png','image/webp']), + ('singer-photos', 'singer-photos', true, 5242880, array['image/jpeg','image/png','image/webp']), + ('consent-evidence', 'consent-evidence', false, 10485760, array['image/jpeg','image/png','application/pdf']) +on conflict (id) do nothing; + +-- Only admins upload, replace or delete files. consent-evidence is admin-read only. +create policy nk_admin_manage_files on storage.objects for all to authenticated + using (bucket_id in ('audio','covers','singer-photos','consent-evidence') and public.is_admin()) + with check (bucket_id in ('audio','covers','singer-photos','consent-evidence') and public.is_admin()); diff --git a/supabase/migrations/20261027000000_init.sql b/supabase/migrations/20261027000000_init.sql new file mode 100644 index 0000000..f2268df --- /dev/null +++ b/supabase/migrations/20261027000000_init.sql @@ -0,0 +1,237 @@ +-- Nanga Kathai — initial schema. +-- Every song is hidden from listeners until an admin approves it (status = 'approved'). + +create extension if not exists pg_trgm; + +-- ---------------------------------------------------------------- types +create type user_role as enum ('listener', 'artist', 'admin'); +create type track_status as enum ('draft', 'pending', 'needs_changes', 'approved', 'rejected', 'taken_down'); +create type rights_status as enum ('legacy_unverified', 'artist_approved', 'licensed'); +create type track_tier as enum ('free', 'premium'); +create type lyrics_script as enum ('tamil', 'latin', 'kannada', 'english'); + +-- ---------------------------------------------------------------- users +create table profiles ( + id uuid primary key references auth.users on delete cascade, + display_name text, + role user_role not null default 'listener', + created_at timestamptz not null default now() +); + +create function handle_new_user() returns trigger +language plpgsql security definer set search_path = public as $$ +begin + insert into profiles (id, display_name) values (new.id, split_part(new.email, '@', 1)); + return new; +end $$; + +create trigger on_auth_user_created after insert on auth.users + for each row execute function handle_new_user(); + +create function is_admin() returns boolean +language sql stable security definer set search_path = public as $$ + select exists (select 1 from profiles where id = auth.uid() and role = 'admin'); +$$; + +-- ---------------------------------------------------------------- catalog +create table hattis ( + id uuid primary key default gen_random_uuid(), + name_latin text not null unique, + name_tamil text, + lat double precision, + lng double precision +); + +create table artists ( + id uuid primary key default gen_random_uuid(), + name_latin text not null, + name_tamil text, + hatti_id uuid references hattis, + bio text, + photo_url text, + photo_permission boolean not null default false, + verified boolean not null default false, + claimed_by uuid references profiles, + created_at timestamptz not null default now(), + -- A singer photo is only stored once permission is on record. + constraint photo_needs_permission check (photo_url is null or photo_permission) +); + +create table albums ( + id uuid primary key default gen_random_uuid(), + title_latin text not null, + title_tamil text, + year int, + cover_url text +); + +create table tracks ( + id uuid primary key default gen_random_uuid(), + title_latin text not null, + title_tamil text, + title_en_meaning text, + album_id uuid references albums, + occasion text not null default 'folk' + check (occasion in ('huttu','habba','madhuvae','devotional','folk','saavu','instrumental')), + year int, + duration_ms int, + audio_key_64 text, -- object keys in the R2 bucket + audio_key_128 text, + audio_key_256 text, + loudness_lufs real, + cover_url text, + tier track_tier not null default 'free', + status track_status not null default 'pending', + rights_status rights_status not null default 'legacy_unverified', + source_note text, -- e.g. "Old collection, cassette 12 side A" + reviewed_by uuid references profiles, + reviewed_at timestamptz, + review_note text, + created_at timestamptz not null default now(), + constraint approved_needs_audio check (status <> 'approved' or audio_key_128 is not null) +); + +create index tracks_status_idx on tracks (status); +create index tracks_occasion_idx on tracks (occasion) where status = 'approved'; +create index tracks_title_trgm on tracks using gin (title_latin gin_trgm_ops); + +create table track_artists ( + track_id uuid references tracks on delete cascade, + artist_id uuid references artists on delete cascade, + role text not null default 'singer' check (role in ('singer','lyricist','composer','musician')), + position int not null default 0, + primary key (track_id, artist_id, role) +); + +create table lyrics ( + track_id uuid references tracks on delete cascade, + script lyrics_script not null, + body text not null, + synced_lrc text, + primary key (track_id, script) +); + +create table festivals ( + id uuid primary key default gen_random_uuid(), + name text not null, + next_date date, + description text +); + +-- ---------------------------------------------------------------- rights +create table consents ( + id uuid primary key default gen_random_uuid(), + artist_id uuid references artists on delete cascade, + track_id uuid references tracks on delete cascade, + consent_type text not null check (consent_type in ('song_distribution','photo','bio')), + given_by text not null, -- singer or family member's name + evidence_url text, -- signed form / message screenshot in private storage + recorded_by uuid references profiles, + created_at timestamptz not null default now() +); + +create table takedown_requests ( + id uuid primary key default gen_random_uuid(), + track_id uuid references tracks on delete cascade, + requester text not null, + contact text not null, + reason text not null, + status text not null default 'open' check (status in ('open','actioned','dismissed')), + created_at timestamptz not null default now() +); + +-- ---------------------------------------------------------------- listener data +create table user_likes ( + user_id uuid references profiles on delete cascade, + track_id uuid references tracks on delete cascade, + created_at timestamptz not null default now(), + primary key (user_id, track_id) +); + +create table playlists ( + id uuid primary key default gen_random_uuid(), + owner_id uuid references profiles on delete cascade, + title text not null, + is_public boolean not null default false, + is_editorial boolean not null default false, + created_at timestamptz not null default now() +); + +create table playlist_items ( + playlist_id uuid references playlists on delete cascade, + track_id uuid references tracks on delete cascade, + position int not null, + added_at timestamptz not null default now(), + primary key (playlist_id, track_id) +); + +create table play_events ( + id bigint generated always as identity primary key, + user_id uuid references profiles on delete set null, + track_id uuid references tracks on delete cascade, + started_at timestamptz not null default now(), + ms_played int, + source text, + offline boolean not null default false +); + +-- ---------------------------------------------------------------- row level security +alter table profiles enable row level security; +alter table hattis enable row level security; +alter table artists enable row level security; +alter table albums enable row level security; +alter table tracks enable row level security; +alter table track_artists enable row level security; +alter table lyrics enable row level security; +alter table festivals enable row level security; +alter table consents enable row level security; +alter table takedown_requests enable row level security; +alter table user_likes enable row level security; +alter table playlists enable row level security; +alter table playlist_items enable row level security; +alter table play_events enable row level security; + +-- Profiles: read and edit your own; admins see all. Role changes are admin-only. +create policy own_profile_read on profiles for select using (id = auth.uid() or is_admin()); +create policy own_profile_update on profiles for update using (id = auth.uid()) + with check (id = auth.uid() and role = (select role from profiles where id = auth.uid())); +create policy admin_profiles on profiles for all using (is_admin()) with check (is_admin()); + +-- Public catalog: anyone (including guests) reads approved songs only. +create policy public_read on hattis for select using (true); +create policy public_read on artists for select using (true); +create policy public_read on albums for select using (true); +create policy public_read on festivals for select using (true); +create policy approved_read on tracks for select using (status = 'approved' or is_admin()); +create policy approved_read on track_artists for select + using (exists (select 1 from tracks t where t.id = track_id and (t.status = 'approved' or is_admin()))); +create policy approved_read on lyrics for select + using (exists (select 1 from tracks t where t.id = track_id and (t.status = 'approved' or is_admin()))); + +-- Admins manage the catalog and rights records. +create policy admin_write on hattis for all using (is_admin()) with check (is_admin()); +create policy admin_write on artists for all using (is_admin()) with check (is_admin()); +create policy admin_write on albums for all using (is_admin()) with check (is_admin()); +create policy admin_write on tracks for all using (is_admin()) with check (is_admin()); +create policy admin_write on track_artists for all using (is_admin()) with check (is_admin()); +create policy admin_write on lyrics for all using (is_admin()) with check (is_admin()); +create policy admin_write on festivals for all using (is_admin()) with check (is_admin()); +create policy admin_only on consents for all using (is_admin()) with check (is_admin()); + +-- Anyone may file a takedown request; only admins read them. +create policy file_takedown on takedown_requests for insert with check (true); +create policy admin_takedowns on takedown_requests for select using (is_admin()); +create policy admin_takedowns_update on takedown_requests for update using (is_admin()); + +-- Listener data belongs to the listener. +create policy own_likes on user_likes for all using (user_id = auth.uid()) with check (user_id = auth.uid()); +create policy own_or_public_playlists on playlists for select using (owner_id = auth.uid() or is_public or is_admin()); +create policy own_playlists on playlists for all using (owner_id = auth.uid()) with check (owner_id = auth.uid() and not is_editorial); +create policy admin_playlists on playlists for all using (is_admin()) with check (is_admin()); +create policy playlist_items_read on playlist_items for select + using (exists (select 1 from playlists p where p.id = playlist_id and (p.owner_id = auth.uid() or p.is_public or is_admin()))); +create policy playlist_items_write on playlist_items for all + using (exists (select 1 from playlists p where p.id = playlist_id and (p.owner_id = auth.uid() or is_admin()))) + with check (exists (select 1 from playlists p where p.id = playlist_id and (p.owner_id = auth.uid() or is_admin()))); +create policy log_own_plays on play_events for insert with check (user_id = auth.uid()); +create policy admin_plays on play_events for select using (is_admin()); diff --git a/supabase/migrations/20261030000000_profiles.sql b/supabase/migrations/20261030000000_profiles.sql new file mode 100644 index 0000000..17b528a --- /dev/null +++ b/supabase/migrations/20261030000000_profiles.sql @@ -0,0 +1,40 @@ +-- Profile page: more optional details, the name given at sign-up, photos, and account deletion. + +alter table profiles + add column phone text, + add column hatti_name text, -- village / hatti, free text + add column avatar_url text, + add column updated_at timestamptz not null default now(); + +-- Use the name typed at sign-up (user metadata), falling back to the email's first part. +create or replace function handle_new_user() returns trigger +language plpgsql security definer set search_path = public as $$ +begin + insert into profiles (id, display_name) + values (new.id, coalesce(nullif(trim(new.raw_user_meta_data ->> 'display_name'), ''), split_part(new.email, '@', 1))); + return new; +end $$; + +-- App stores require in-app account deletion. Removes the login; profile, likes, +-- playlists and history go with it (on delete cascade). +create function delete_my_account() returns void +language plpgsql security definer set search_path = public as $$ +begin + if auth.uid() is null then + raise exception 'not signed in'; + end if; + delete from storage.objects where bucket_id = 'avatars' and (storage.foldername(name))[1] = auth.uid()::text; + delete from auth.users where id = auth.uid(); +end $$; + +revoke all on function delete_my_account() from public, anon; +grant execute on function delete_my_account() to authenticated; + +-- Profile photos: avatars//. Public to read, each user writes only their own folder. +insert into storage.buckets (id, name, public, file_size_limit, allowed_mime_types) +values ('avatars', 'avatars', true, 2097152, array['image/jpeg','image/png','image/webp']) +on conflict (id) do nothing; + +create policy nk_own_avatar on storage.objects for all to authenticated + using (bucket_id = 'avatars' and (storage.foldername(name))[1] = auth.uid()::text) + with check (bucket_id = 'avatars' and (storage.foldername(name))[1] = auth.uid()::text); diff --git a/test/song_cipher_test.dart b/test/song_cipher_test.dart new file mode 100644 index 0000000..438b67f --- /dev/null +++ b/test/song_cipher_test.dart @@ -0,0 +1,38 @@ +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:nanga_kathai/offline/song_cipher.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('decrypting any range after a seek returns the original bytes', () { + final r = Random(1); + final plain = Uint8List.fromList(List.generate(100000, (_) => r.nextInt(256))); + final cipher = SongCipher(SongCipher.newKey()); + final nonce = SongCipher.newNonce(); + + // Encrypt in uneven chunks, the way a download arrives. + final enc = Uint8List.fromList(plain); + for (var off = 0; off < enc.length; off += 7777) { + final end = min(off + 7777, enc.length); + final part = Uint8List.sublistView(enc, off, end); + cipher.apply(part, off, nonce); + } + expect(enc, isNot(equals(plain))); + + for (final (start, end) in [(0, 100000), (5, 21), (16, 32), (99990, 100000), (12345, 67891)]) { + final chunk = Uint8List.fromList(enc.sublist(start, end)); + cipher.apply(chunk, start, nonce); + expect(chunk, equals(plain.sublist(start, end)), reason: 'range $start-$end'); + } + }); + + test('a different key cannot decrypt', () { + final plain = Uint8List.fromList(List.generate(64, (i) => i)); + final nonce = SongCipher.newNonce(); + final enc = Uint8List.fromList(plain); + SongCipher(SongCipher.newKey()).apply(enc, 0, nonce); + SongCipher(SongCipher.newKey()).apply(enc, 0, nonce); + expect(enc, isNot(equals(plain))); + }); +} diff --git a/test/widget_test.dart b/test/widget_test.dart new file mode 100644 index 0000000..6e2cae9 --- /dev/null +++ b/test/widget_test.dart @@ -0,0 +1,56 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:nanga_kathai/core/models.dart'; +import 'package:nanga_kathai/core/search_normalize.dart'; +import 'package:nanga_kathai/data/catalog_repository.dart'; + +void main() { + group('foldForSearch', () { + test('treats common Badaga spelling variants as equal', () { + expect(foldForSearch('Madhuvae'), foldForSearch('madhuve')); + expect(foldForSearch('Hethai'), foldForSearch('Hettai')); + expect(foldForSearch('Hethai'), foldForSearch('Hethe')); + expect(foldForSearch('Baduga'), isNot(foldForSearch('Kathai'))); + }); + + test('keeps Tamil script searchable', () { + expect(foldForSearch('பாடல்'), 'பாடல்'); + }); + }); + + group('matchesSearch', () { + test('every query word must match some field', () { + expect(matchesSearch('madhuve song', ['Madhuvae Song', null]), isTrue); + expect(matchesSearch('madhuve habba', ['Madhuvae Song']), isFalse); + expect(matchesSearch(' ', ['anything']), isFalse); + }); + }); + + group('demo catalog', () { + late Catalog catalog; + + setUpAll(() { + catalog = parseCatalogJson(File('assets/catalog/demo_catalog.json').readAsStringSync()); + }); + + test('every track points at known singers and bundled audio', () { + expect(catalog.tracks, isNotEmpty); + for (final t in catalog.tracks) { + expect(catalog.artistsOf(t), hasLength(t.artistIds.length), reason: t.id); + final path = t.audioUrl.replaceFirst('asset:///', ''); + expect(File(path).existsSync(), isTrue, reason: path); + } + }); + + test('search finds songs by occasion and singer', () { + expect(catalog.searchTracks('madhuve').map((t) => t.occasion), everyElement(Occasion.madhuvae)); + expect(catalog.searchTracks('demo singer c'), isNotEmpty); + }); + }); + + test('Artist.initials', () { + expect(const Artist(id: 'x', name: 'Demo Singer A').initials, 'DA'); + expect(const Artist(id: 'x', name: 'Ponnamma').initials, 'P'); + }); +} diff --git a/tools/README.md b/tools/README.md new file mode 100644 index 0000000..d1a1386 --- /dev/null +++ b/tools/README.md @@ -0,0 +1,21 @@ +# Song import + +1. **Scan** (already run once; run again if you add files to `songs/`; it overwrites the CSV, so save your edits under another name first): + ```sh + pip install mutagen + python tools/scan_songs.py songs songs_manifest.csv + ``` +2. **Review** `songs_manifest.csv` in Excel: + - `title`: required. Songs without a title are skipped. + - `singers`: separate names with commas (`Gowtham, Bharathi`). It can stay empty; the app then shows "Singer not credited yet". + - `occasion`: one of `huttu habba madhuvae devotional folk saavu instrumental`. + - `include`: `yes` or `no`. Duplicates and the `old hits` folder (Tamil film songs) start as `no`. + - `notes` says what needs a look. Save as **CSV UTF-8**. +3. **Check**: `python tools/import_songs.py --dry-run` +4. **Import** (the server must be running and the migrations applied): + ```sh + set NK_URL=https://app.nkbmusic.com + set NK_SERVICE_KEY= + python tools/import_songs.py # pending; add --approve to publish now + ``` + Re-running skips songs already imported, so you can import in batches as you finish reviewing. diff --git a/tools/import_songs.py b/tools/import_songs.py new file mode 100644 index 0000000..65fe1bf --- /dev/null +++ b/tools/import_songs.py @@ -0,0 +1,163 @@ +"""Upload reviewed songs to the NK server and create their catalog rows. + +Reads songs_manifest.csv (from scan_songs.py, after you fixed it in Excel), +uploads each included file to the `audio` bucket and creates the album, +singers and track. Safe to run again: songs already imported are skipped. + +Usage: + python tools/import_songs.py --dry-run # check the CSV, upload nothing + python tools/import_songs.py # import as pending (approve in Studio) + python tools/import_songs.py --approve # import straight to approved + +Environment (from the server's .env, never put these in the app): + NK_URL e.g. https://app.nkbmusic.com + NK_SERVICE_KEY SERVICE_ROLE_KEY from the server .env +Only the standard library is used. +""" +import argparse +import csv +import json +import os +import re +import sys +import urllib.error +import urllib.parse +import urllib.request +from pathlib import Path + +OCCASIONS = {"huttu", "habba", "madhuvae", "devotional", "folk", "saavu", "instrumental"} +MIME = {".mp3": "audio/mpeg", ".m4a": "audio/mp4", ".aac": "audio/aac"} +SINGER_SPLIT = re.compile(r"\s*(?:,|&&|&|/|\band\b)\s*", re.I) + + +class Api: + def __init__(self, url, key): + self.url = url.rstrip("/") + self.key = key + + def _call(self, method, path, body=None, headers=None, raw=None): + h = {"apikey": self.key, "Authorization": f"Bearer {self.key}"} + h.update(headers or {}) + data = raw + if body is not None: + data = json.dumps(body).encode() + h["Content-Type"] = "application/json" + req = urllib.request.Request(self.url + path, data=data, method=method, headers=h) + try: + with urllib.request.urlopen(req, timeout=300) as r: + text = r.read().decode() + return json.loads(text) if text else None + except urllib.error.HTTPError as e: + raise RuntimeError(f"{method} {path} -> {e.code}: {e.read().decode()[:300]}") from None + + def select(self, table, query): + return self._call("GET", f"/rest/v1/{table}?{query}") + + def insert(self, table, row): + return self._call("POST", f"/rest/v1/{table}", row, + {"Prefer": "return=representation"})[0] + + def upload(self, key, path): + mime = MIME[path.suffix.lower()] + self._call("POST", f"/storage/v1/object/audio/{urllib.parse.quote(key)}", + raw=path.read_bytes(), headers={"Content-Type": mime, "x-upsert": "true"}) + + +def load_rows(manifest, songs_dir): + rows, problems = [], [] + with open(manifest, encoding="utf-8-sig") as f: + for r in csv.DictReader(f): + if r["include"].strip().lower() not in ("yes", "y", "1"): + continue + path = songs_dir / r["file"] + occasion = (r["occasion"] or "folk").strip().lower() + if not r["title"].strip(): + problems.append(f"{r['id']}: no title ({r['file']})") + elif occasion not in OCCASIONS: + problems.append(f"{r['id']}: occasion '{occasion}' is not one of {sorted(OCCASIONS)}") + elif path.suffix.lower() not in MIME: + problems.append(f"{r['id']}: {path.suffix} must be converted to mp3 first") + elif not path.is_file(): + problems.append(f"{r['id']}: file not found {path}") + else: + r["occasion"] = occasion + rows.append((r, path)) + return rows, problems + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--manifest", default="songs_manifest.csv") + ap.add_argument("--songs", default="songs") + ap.add_argument("--approve", action="store_true", help="publish straight away") + ap.add_argument("--dry-run", action="store_true") + args = ap.parse_args() + + rows, problems = load_rows(args.manifest, Path(args.songs)) + print(f"{len(rows)} songs ready, {len(problems)} skipped") + for p in problems[:30]: + print(" skip", p) + if len(problems) > 30: + print(f" ... and {len(problems) - 30} more") + if args.dry_run or not rows: + return + + url, key = os.environ.get("NK_URL"), os.environ.get("NK_SERVICE_KEY") + if not url or not key: + sys.exit("Set NK_URL and NK_SERVICE_KEY first (see the top of this file).") + api = Api(url, key) + + # Re-runs: source_note starts with "import:". + done = {t["source_note"].split()[0][7:] for t in + api.select("tracks", "select=source_note&source_note=like.import:*")} + artists = {a["name_latin"].lower(): a["id"] for a in api.select("artists", "select=id,name_latin")} + albums = {a["title_latin"].lower(): a["id"] for a in api.select("albums", "select=id,title_latin")} + + def artist_id(name): + if name.lower() not in artists: + artists[name.lower()] = api.insert("artists", {"name_latin": name})["id"] + return artists[name.lower()] + + def album_id(title): + if not title: + return None + if title.lower() not in albums: + albums[title.lower()] = api.insert("albums", {"title_latin": title})["id"] + return albums[title.lower()] + + imported = failed = 0 + for i, (r, path) in enumerate(rows, 1): + if r["id"] in done: + continue + key_name = f"legacy/{r['id']}{path.suffix.lower()}" + try: + api.upload(key_name, path) + track = api.insert("tracks", { + "title_latin": r["title"].strip(), + "album_id": album_id(r["album"].strip()), + "occasion": r["occasion"], + "year": int(r["year"]) if r["year"].strip().isdigit() else None, + "duration_ms": int(float(r["duration_s"] or 0) * 1000) or None, + "audio_key_128": key_name, + "status": "approved" if args.approve else "pending", + "rights_status": "legacy_unverified", + "tier": "free", + "source_note": f"import:{r['id']} {r['file']}", + }) + names = [n for n in SINGER_SPLIT.split(r["singers"].strip()) if n] + for pos, name in enumerate(names): + api.insert("track_artists", {"track_id": track["id"], "artist_id": artist_id(name), + "role": "singer", "position": pos}) + imported += 1 + print(f"[{i}/{len(rows)}] {r['id']} {r['title']}") + except Exception as e: + failed += 1 + print(f"[{i}/{len(rows)}] FAILED {r['id']}: {e}") + + print(f"Imported {imported}, failed {failed}, already there {len(done)}.") + if not args.approve and imported: + print("They are pending. Approve in Studio: update tracks set status='approved' where status='pending';") + + +if __name__ == "__main__": + main() diff --git a/tools/scan_songs.py b/tools/scan_songs.py new file mode 100644 index 0000000..859ca0d --- /dev/null +++ b/tools/scan_songs.py @@ -0,0 +1,158 @@ +"""Scan the songs folder and write a manifest to review before import. + +Reads tags, duration and bitrate, finds exact duplicates (same audio bytes), +and proposes a title, singer and album for every file. Open the CSV in Excel, +fix the columns, set `include` to no for anything to skip, then run +import_songs.py. + +Usage: python tools/scan_songs.py [songs_dir] [out_csv] +Needs: pip install mutagen +""" +import csv +import hashlib +import re +import sys +from pathlib import Path + +from mutagen import File as AudioFile + +AUDIO_EXT = {".mp3", ".m4a", ".wma", ".wav", ".aac"} +# Formats the app streams directly; others must be converted to mp3 first. +PLAYABLE_EXT = {".mp3", ".m4a", ".aac"} + +COLUMNS = [ + "include", "id", "file", "folder", "title", "singers", "album", "occasion", + "year", "duration_s", "bitrate_kbps", "duplicate_of", "notes", + "tag_title", "tag_artist", "tag_album", +] + +JUNK_TITLE = re.compile( + r"^(.*\btrack\s*\d+\w*.*|audio\s*t|\d+|.{1,3}|.*hits|<<<.*>>>|title|unknown.*|new( \(\d+\))?|(aud|wa)[-_ ]?\d.*|z \(\d+\))$", re.I) +# Artist tags that are placeholders, numbers or song titles rather than singers. +JUNK_ARTIST = re.compile( + r"^(\d+|.*\bartists?|unknown.*|track\s*\d+|<<<.*>>>|latest|\d+[.\s_].*|zz.*|.*\bhits)$", re.I) +# Folders of film songs (not Badaga, owned by film labels): excluded by default. +FILM_FOLDERS = {"old hits"} + + +def first_tag(audio, *keys): + tags = getattr(audio, "tags", None) + if not tags: + return "" + for key in keys: + value = tags.get(key) + if value is None: + continue + text = value.text[0] if hasattr(value, "text") else (value[0] if isinstance(value, list) else value) + text = str(text).strip() + if text: + return text + return "" + + +def audio_digest(path): + """Hash of the file minus the first 128 KB, so retagged copies still match.""" + h = hashlib.sha1() + with open(path, "rb") as f: + f.seek(131072) + for chunk in iter(lambda: f.read(1 << 20), b""): + h.update(chunk) + return h.hexdigest() + + +def clean_title(stem): + t = re.sub(r"\.(mp3|m4a|wma)$", "", stem.strip(), flags=re.I) + t = re.sub(r"\(\d+\)$", "", t).strip() # "Song (3)" copies + t = re.sub(r"^(zz|\d+[\s._-]*)", "", t, flags=re.I) # leading track numbers + t = re.sub(r"-?SS$", "", t) # uploader's tag on some files + t = re.sub(r"\bAUDIO\s*T\b", "", t, flags=re.I) + t = re.sub(r"[_]+", " ", t) + t = re.sub(r"\s{2,}", " ", t).strip(" .-") + return t.title() if t.isupper() or t.islower() else t + + +def main(): + root = Path(sys.argv[1] if len(sys.argv) > 1 else "songs") + out = Path(sys.argv[2] if len(sys.argv) > 2 else "songs_manifest.csv") + files = sorted(p for p in root.rglob("*") if p.suffix.lower() in AUDIO_EXT) + + seen = {} + rows = [] + for n, path in enumerate(files, 1): + notes = [] + try: + audio = AudioFile(path) + except Exception as e: # unreadable file + audio = None + notes.append(f"unreadable: {e.__class__.__name__}") + info = getattr(audio, "info", None) + duration = round(info.length) if info and getattr(info, "length", 0) else 0 + bitrate = round(getattr(info, "bitrate", 0) / 1000) if info else 0 + + tag_title = first_tag(audio, "TIT2", "\xa9nam", "Title", "title") + tag_artist = first_tag(audio, "TPE1", "\xa9ART", "Author", "artist") + tag_album = first_tag(audio, "TALB", "\xa9alb", "WM/AlbumTitle", "album") + tag_year = first_tag(audio, "TDRC", "\xa9day", "WM/Year", "date")[:4] + + folder = path.parent.relative_to(root).as_posix() + film = folder in FILM_FOLDERS + title = clean_title(tag_title) if tag_title and not JUNK_TITLE.match(tag_title) else clean_title(path.stem) + if re.search(r"\d{8,}", title): # phone numbers in WhatsApp-forwarded file names + title = "" + if not title or JUNK_TITLE.match(title): + title = "" + notes.append("needs title") + singers = "" if JUNK_ARTIST.match(tag_artist) else tag_artist + if not singers: + notes.append("needs singer") + if film: + notes.append("film song: not Badaga, needs label licence") + if path.suffix.lower() not in PLAYABLE_EXT: + notes.append("convert to mp3") + if duration and duration < 60: + notes.append("very short") + if bitrate and bitrate < 96: + notes.append("low quality") + + digest = audio_digest(path) + duplicate_of = seen.get(digest, "") + if duplicate_of: + notes.append("duplicate") + else: + seen[digest] = f"s{n:04d}" + + rows.append({ + "include": "no" if film or duplicate_of or "unreadable" in " ".join(notes) else "yes", + "id": f"s{n:04d}", + "file": path.relative_to(root).as_posix(), + "folder": folder, + "title": title, + "singers": singers, + "album": tag_album if tag_album and not JUNK_TITLE.match(tag_album) else path.parent.name, + "occasion": "folk", + "year": tag_year if tag_year.isdigit() else "", + "duration_s": duration, + "bitrate_kbps": bitrate, + "duplicate_of": duplicate_of, + "notes": "; ".join(notes), + "tag_title": tag_title, + "tag_artist": tag_artist, + "tag_album": tag_album, + }) + + # utf-8-sig so Excel shows Tamil text correctly. + with open(out, "w", newline="", encoding="utf-8-sig") as f: + w = csv.DictWriter(f, fieldnames=COLUMNS) + w.writeheader() + w.writerows(rows) + + dupes = sum(1 for r in rows if r["duplicate_of"]) + print(f"{len(rows)} files -> {out}") + print(f" duplicates: {dupes}") + for flag in ("needs title", "needs singer", "convert to mp3", "low quality", "very short", "unreadable"): + print(f" {flag}: {sum(1 for r in rows if flag in r['notes'])}") + print(f" total hours: {sum(r['duration_s'] for r in rows) / 3600:.1f}") + + +if __name__ == "__main__": + main()