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 <noreply@anthropic.com>
This commit is contained in:
metatroncubeswdev 2026-10-30 10:39:26 -04:00
commit 178d2d00ab
136 changed files with 9499 additions and 0 deletions

7
.gitattributes vendored Normal file
View File

@ -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

55
.gitignore vendored Normal file
View File

@ -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

33
.metadata Normal file
View File

@ -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'

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

50
README.md Normal file
View File

@ -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://<project>.supabase.co",
"SUPABASE_PUBLISHABLE_KEY": "<publishable key>",
"MEDIA_BASE_URL": "https://<r2 public domain>"
}
```
```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 = '<your user id>';`
## Photo credits
Landscape photos from Unsplash (Unsplash License): Vaisakh Satheesan (tea estate), Ravi Raj (Ooty village), Harshal More (Ooty valley).

34
analysis_options.yaml Normal file
View File

@ -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

14
android/.gitignore vendored Normal file
View File

@ -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

View File

@ -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 = "../.."
}

View File

@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

View File

@ -0,0 +1,64 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<application
android:label="Nanga Kathai"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<!-- Background playback and media controls (audio_service) -->
<service android:name="com.ryanheise.audioservice.AudioService"
android:foregroundServiceType="mediaPlayback"
android:exported="true">
<intent-filter>
<action android:name="android.media.browse.MediaBrowserService" />
</intent-filter>
</service>
<receiver android:name="com.ryanheise.audioservice.MediaButtonReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MEDIA_BUTTON" />
</intent-filter>
</receiver>
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>

View File

@ -0,0 +1,5 @@
package com.metatroncubesolutions.nanga_kathai
import com.ryanheise.audioservice.AudioServiceActivity
class MainActivity : AudioServiceActivity()

View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>

View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>

Binary file not shown.

After

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

24
android/build.gradle.kts Normal file
View File

@ -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<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}

View File

@ -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

View File

@ -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

View File

@ -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")

View File

@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" role="img" aria-label="Nanga Kathai app icon">
<rect width="1024" height="1024" rx="228" fill="#FBF7EE"/>
<circle cx="700" cy="300" r="150" fill="#F5B335"/>
<path d="M0 640 C180 520 300 560 430 610 C560 660 700 560 1024 600 V1024 H0Z" fill="#7FA857"/>
<path d="M0 760 C200 660 360 700 520 750 C680 800 820 720 1024 740 V1024 H0Z" fill="#1F4A2E"/>
<path d="M-20 850 C220 780 420 820 560 860 C720 905 860 850 1044 860" fill="none" stroke="#FBF7EE" stroke-width="18" stroke-linecap="round"/>
<path d="M-20 930 C240 870 440 905 600 940 C760 975 880 935 1044 945" fill="none" stroke="#FBF7EE" stroke-width="18" stroke-linecap="round"/>
<text x="512" y="560" text-anchor="middle" font-family="Sora, 'Arial Black', sans-serif" font-weight="800" font-size="400" letter-spacing="-12" fill="#1F4A2E">NK</text>
</svg>

After

Width:  |  Height:  |  Size: 885 B

View File

@ -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" }
]
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
assets/images/emblem.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 383 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 278 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 247 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

View File

@ -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)

5
env.example.json Normal file
View File

@ -0,0 +1,5 @@
{
"SUPABASE_URL": "https://app.nkbmusic.com",
"SUPABASE_PUBLISHABLE_KEY": "<SUPABASE_PUBLISHABLE_KEY from the server .env>",
"MEDIA_BASE_URL": "https://app.nkbmusic.com/storage/v1/object/public/audio"
}

34
ios/.gitignore vendored Normal file
View File

@ -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

View File

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>App</string>
<key>CFBundleIdentifier</key>
<string>io.flutter.flutter.app</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>App</string>
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>1.0</string>
</dict>
</plist>

View File

@ -0,0 +1 @@
#include "Generated.xcconfig"

View File

@ -0,0 +1 @@
#include "Generated.xcconfig"

View File

@ -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 = "<group>"; };
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
97C146EB1CF9000F007C117D /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
331C8082294A63A400263BE5 /* RunnerTests */ = {
isa = PBXGroup;
children = (
331C807B294A618700263BE5 /* RunnerTests.swift */,
);
path = RunnerTests;
sourceTree = "<group>";
};
9740EEB11CF90186004384FC /* Flutter */ = {
isa = PBXGroup;
children = (
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */,
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
9740EEB21CF90195004384FC /* Debug.xcconfig */,
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
9740EEB31CF90195004384FC /* Generated.xcconfig */,
);
name = Flutter;
sourceTree = "<group>";
};
97C146E51CF9000F007C117D = {
isa = PBXGroup;
children = (
9740EEB11CF90186004384FC /* Flutter */,
97C146F01CF9000F007C117D /* Runner */,
97C146EF1CF9000F007C117D /* Products */,
331C8082294A63A400263BE5 /* RunnerTests */,
);
sourceTree = "<group>";
};
97C146EF1CF9000F007C117D /* Products */ = {
isa = PBXGroup;
children = (
97C146EE1CF9000F007C117D /* Runner.app */,
331C8081294A63A400263BE5 /* RunnerTests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
97C146F01CF9000F007C117D /* Runner */ = {
isa = PBXGroup;
children = (
97C146FA1CF9000F007C117D /* Main.storyboard */,
97C146FD1CF9000F007C117D /* Assets.xcassets */,
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,
97C147021CF9000F007C117D /* Info.plist */,
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
);
path = Runner;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
331C8080294A63A400263BE5 /* RunnerTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
buildPhases = (
331C807D294A63A400263BE5 /* Sources */,
331C807F294A63A400263BE5 /* Resources */,
);
buildRules = (
);
dependencies = (
331C8086294A63A400263BE5 /* PBXTargetDependency */,
);
name = RunnerTests;
productName = RunnerTests;
productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
97C146ED1CF9000F007C117D /* Runner */ = {
isa = PBXNativeTarget;
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
buildPhases = (
9740EEB61CF901F6004384FC /* Run Script */,
97C146EA1CF9000F007C117D /* Sources */,
97C146EB1CF9000F007C117D /* Frameworks */,
97C146EC1CF9000F007C117D /* Resources */,
9705A1C41CF9048500538489 /* Embed Frameworks */,
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
);
buildRules = (
);
dependencies = (
);
name = Runner;
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 = "<group>";
};
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = {
isa = PBXVariantGroup;
children = (
97C147001CF9000F007C117D /* Base */,
);
name = LaunchScreen.storyboard;
sourceTree = "<group>";
};
/* End PBXVariantGroup section */
/* Begin XCBuildConfiguration section */
249021D3217E4FDB00AE95B9 /* Profile */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 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 */;
}

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PreviewsEnabled</key>
<false/>
</dict>
</plist>

View File

@ -0,0 +1,119 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1510"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<PreActions>
<ExecutionAction
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
<ActionContent
title = "Run Prepare Flutter Framework Script"
scriptText = "/bin/sh &quot;$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh&quot; prepare&#10;">
<EnvironmentBuildable>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</EnvironmentBuildable>
</ActionContent>
</ExecutionAction>
</PreActions>
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
shouldUseLaunchSchemeArgsEnv = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</MacroExpansion>
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "331C8080294A63A400263BE5"
BuildableName = "RunnerTests.xctest"
BlueprintName = "RunnerTests"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
enableGPUValidationMode = "1"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Profile"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "group:Runner.xcodeproj">
</FileRef>
</Workspace>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PreviewsEnabled</key>
<false/>
</dict>
</plist>

View File

@ -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)
}
}

View File

@ -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"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 450 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 462 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 704 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 586 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 762 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -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"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

View File

@ -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.

View File

@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="12121" systemVersion="16G29" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="12089"/>
</dependencies>
<scenes>
<!--View Controller-->
<scene sceneID="EHf-IW-A2E">
<objects>
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
<layoutGuides>
<viewControllerLayoutGuide type="top" id="Ydg-fD-yQy"/>
<viewControllerLayoutGuide type="bottom" id="xbc-2k-c8Z"/>
</layoutGuides>
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
</imageView>
</subviews>
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<constraints>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/>
</constraints>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="53" y="375"/>
</scene>
</scenes>
<resources>
<image name="LaunchImage" width="168" height="185"/>
</resources>
</document>

View File

@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="10117" systemVersion="15F34" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" initialViewController="BYZ-38-t0r">
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="10085"/>
</dependencies>
<scenes>
<!--Flutter View Controller-->
<scene sceneID="tne-QT-ifu">
<objects>
<viewController id="BYZ-38-t0r" customClass="FlutterViewController" sceneMemberID="viewController">
<layoutGuides>
<viewControllerLayoutGuide type="top" id="y3c-jy-aDJ"/>
<viewControllerLayoutGuide type="bottom" id="wfy-db-euE"/>
</layoutGuides>
<view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC">
<rect key="frame" x="0.0" y="0.0" width="600" height="600"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="calibratedWhite"/>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
</objects>
</scene>
</scenes>
</document>

74
ios/Runner/Info.plist Normal file
View File

@ -0,0 +1,74 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Nanga Kathai</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>nanga_kathai</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
<key>UISceneConfigurations</key>
<dict>
<key>UIWindowSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneClassName</key>
<string>UIWindowScene</string>
<key>UISceneConfigurationName</key>
<string>flutter</string>
<key>UISceneDelegateClassName</key>
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
<key>UISceneStoryboardFile</key>
<string>Main</string>
</dict>
</array>
</dict>
</dict>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>

View File

@ -0,0 +1 @@
#import "GeneratedPluginRegistrant.h"

View File

@ -0,0 +1,6 @@
import Flutter
import UIKit
class SceneDelegate: FlutterSceneDelegate {
}

View File

@ -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.
}
}

151
lib/app/router.dart Normal file
View File

@ -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<bool> {
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, bool>(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<AuthState>? _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<GoRouter>((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,
),
),
),
],
);
});

131
lib/app/theme.dart Normal file
View File

@ -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,
),
);
}

View File

@ -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<Duration> get positionStream => _player.positionStream;
Stream<Duration?> get durationStream => _player.durationStream;
Future<void> _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<void> playQueue(List<MediaItem> 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<void> 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<void> 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<void> play() => _player.play();
@override
Future<void> pause() => _player.pause();
@override
Future<void> seek(Duration position) => _player.seek(position);
@override
Future<void> skipToNext() => _player.seekToNext();
/// Restarts the song when more than 3 s in, like every major player.
@override
Future<void> skipToPrevious() async {
if (_player.position > const Duration(seconds: 3) || !_player.hasPrevious) {
return _player.seek(Duration.zero);
}
return _player.seekToPrevious();
}
@override
Future<void> skipToQueueItem(int index) async {
final indices = _player.shuffleModeEnabled ? _player.shuffleIndices : null;
await _player.seek(Duration.zero, index: indices == null ? index : indices[index]);
}
@override
Future<void> setShuffleMode(AudioServiceShuffleMode shuffleMode) async {
final enable = shuffleMode != AudioServiceShuffleMode.none;
if (enable) await _player.shuffle();
await _player.setShuffleModeEnabled(enable);
}
@override
Future<void> setRepeatMode(AudioServiceRepeatMode repeatMode) => _player.setLoopMode(switch (repeatMode) {
AudioServiceRepeatMode.one => LoopMode.one,
AudioServiceRepeatMode.all || AudioServiceRepeatMode.group => LoopMode.all,
AudioServiceRepeatMode.none => LoopMode.off,
});
@override
Future<void> 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,
},
);
}
}

View File

@ -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<NkAudioHandler>(
(ref) => throw UnimplementedError('audioHandlerProvider must be overridden in main()'),
);
final mediaItemProvider = StreamProvider<MediaItem?>((ref) => ref.watch(audioHandlerProvider).mediaItem);
final playbackStateProvider =
StreamProvider<PlaybackState>((ref) => ref.watch(audioHandlerProvider).playbackState);
final queueProvider = StreamProvider<List<MediaItem>>((ref) => ref.watch(audioHandlerProvider).queue);
final positionProvider = StreamProvider<Duration>((ref) => ref.watch(audioHandlerProvider).positionStream);
/// The catalog track behind the current media item, if any.
final currentTrackProvider = Provider<Track?>((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<PlayerController>((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<void> playTracks(List<Track> 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<void> playDownloads(List<DownloadEntry> 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<Track> 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<void> playNext(Track t) => _handler.playNext(toMediaItem(t));
Future<void> addToQueue(Track t) => _handler.addQueueItem(toMediaItem(t));
Future<void> togglePlay() {
final playing = _ref.read(playbackStateProvider).value?.playing ?? false;
return playing ? _handler.pause() : _handler.play();
}
Future<void> toggleShuffle() {
final on = _ref.read(playbackStateProvider).value?.shuffleMode != AudioServiceShuffleMode.none;
return _handler.setShuffleMode(on ? AudioServiceShuffleMode.none : AudioServiceShuffleMode.all);
}
/// Off -> all -> one -> off.
Future<void> 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,
});
}
}

View File

@ -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<int, double> customGains;
/// Loudness boost for quiet old recordings, 0 to 10 dB.
final double boostDb;
SoundState copyWith({bool? eqEnabled, EqPreset? preset, Map<int, double>? customGains, double? boostDb}) =>
SoundState(
eqEnabled: eqEnabled ?? this.eqEnabled,
preset: preset ?? this.preset,
customGains: customGains ?? this.customGains,
boostDb: boostDb ?? this.boostDb,
);
Map<String, Object> toJson() => {
'eq': eqEnabled,
'preset': preset.name,
'custom': {for (final e in customGains.entries) '${e.key}': e.value},
'boost': boostDb,
};
static SoundState fromJson(Map<String, dynamic> 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<SoundState> {
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<String, dynamic>);
} 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<void> 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<void> _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, SoundState>(SoundSettings.new);

11
lib/core/env.dart Normal file
View File

@ -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;
}

117
lib/core/models.dart Normal file
View File

@ -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<String, dynamic> 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<String> 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<String, dynamic> 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<String>(),
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';
}

View File

@ -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<String?> fields) {
final words = foldForSearch(query).split(' ').where((w) => w.isNotEmpty).toList();
if (words.isEmpty) return false;
final haystack = fields.whereType<String>().map(foldForSearch).join(' ');
return words.every(haystack.contains);
}

View File

@ -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<Track> tracks, required List<Artist> artists, List<Album> 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<Track> tracks;
final Map<String, Artist> artistsById;
final Map<String, Album> albumsById;
/// Albums with songs, biggest first.
late final List<Album> 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<String, List<Track>> _byAlbum = () {
final map = <String, List<Track>>{};
// 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<Track> byAlbum(String albumId) => _byAlbum[albumId] ?? const [];
Album? albumOf(Track t) => t.albumId == null ? null : albumsById[t.albumId];
List<Artist> get artists => artistsById.values.toList();
List<Artist> artistsOf(Track t) =>
t.artistIds.map((id) => artistsById[id]).whereType<Artist>().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<Track> byOccasion(Occasion o) => tracks.where((t) => t.occasion == o).toList();
List<Track> 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<Track> searchTracks(String query) => tracks
.where((t) => matchesSearch(query, [
t.titleLatin,
t.titleTamil,
t.meaning,
t.occasion.label,
artistLine(t),
albumOf(t)?.title,
]))
.toList();
List<Album> searchAlbums(String query) =>
albums.where((a) => matchesSearch(query, [a.title])).toList();
List<Artist> searchArtists(String query) =>
artists.where((a) => matchesSearch(query, [a.name, a.hatti])).toList();
}
abstract interface class CatalogRepository {
Future<Catalog> 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<Catalog> load() async => parseCatalogJson(await rootBundle.loadString(path));
}
Catalog parseCatalogJson(String raw) {
final json = jsonDecode(raw) as Map<String, dynamic>;
return Catalog(
tracks: (json['tracks'] as List).map((e) => Track.fromJson(e as Map<String, dynamic>)).toList(),
artists: (json['artists'] as List).map((e) => Artist.fromJson(e as Map<String, dynamic>)).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<Catalog> 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<CatalogRepository>((ref) {
if (Env.hasSupabase) return SupabaseCatalogRepository(Supabase.instance.client);
return const AssetCatalogRepository();
});
final catalogProvider = FutureProvider<Catalog>((ref) => ref.watch(catalogRepositoryProvider).load());

View File

@ -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<SharedPreferences>(
(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<Set<String>> {
static const _key = 'liked_track_ids';
@override
Set<String> 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, Set<String>>(LikedSongs.new);
/// Most recent first, capped at 30.
class RecentlyPlayed extends Notifier<List<String>> {
static const _key = 'recent_track_ids';
static const _max = 30;
@override
List<String> 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, List<String>>(RecentlyPlayed.new);

View File

@ -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'),
),
],
),
],
),
),
],
),
);
}
}

View File

@ -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<Track> 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)),
],
),
),
);
}
}

View File

@ -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)),
],
),
),
),
),
],
);
}),
);
}
}

View File

@ -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<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends ConsumerState<LoginScreen> {
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<void> _run(Future<void> 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<void> _signIn() => _run(() async {
await _auth.signInWithPassword(email: _emailText, password: _password.text);
_finish();
});
Future<void> _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<void> _verifySignUp() => _run(() async {
await _auth.verifyOTP(type: OtpType.signup, email: _emailText, token: _code.text.trim());
_finish();
});
Future<void> _resendSignUp() => _run(() async {
await _auth.resend(type: OtpType.signup, email: _emailText);
setState(() => _info = 'New code sent.');
});
Future<void> _requestCode() => _run(() async {
await _auth.signInWithOtp(email: _emailText, shouldCreateUser: false);
_go(_Step.codeVerify);
});
Future<void> _verifyCode() => _run(() async {
await _auth.verifyOTP(type: OtpType.email, email: _emailText, token: _code.text.trim());
_finish();
});
Future<void> _requestReset() => _run(() async {
await _auth.resetPasswordForEmail(_emailText);
_go(_Step.resetVerify);
});
Future<void> _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<Widget> _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 [],
};
}
}

View File

@ -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<void>(
tooltip: 'More',
itemBuilder: (_) => [
PopupMenuItem<void>(
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<void>(
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,
),
],
),
),
);
}
}

View File

@ -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)),
],
),
),
],
),
),
),
);
}
}

View File

@ -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)),
),
),
],
],
);
}),
);
}
}

View File

@ -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<Track>().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)),
],
),
),
),
);
}
}

View File

@ -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<LibraryScreen> createState() => _LibraryScreenState();
}
class _LibraryScreenState extends ConsumerState<LibraryScreen> {
_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<Track> resolve(Iterable<String> ids) => ids.map(catalog.trackById).whereType<Track>().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<Track> tracks;
final Widget empty;
final Future<void> Function(List<Track>, {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)),
);
}
}

View File

@ -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<DateTime?> {
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, DateTime?>(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<void>(
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<void>(
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<void>(
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<void>(
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),
);
},
),
),
],
);
}
}

View File

@ -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<void>(
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<AndroidEqualizerParameters>(
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<double>(
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()}';
}

View File

@ -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<Profile?>((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<void> _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<void> _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<void> _changePassword() async {
final password = await showDialog<String>(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<void> _signOut() async {
await _client.auth.signOut();
if (mounted) context.go('/login');
}
Future<void> _deleteAccount() async {
final sure = await showDialog<bool>(
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')),
],
);
}
}

View File

@ -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<SearchScreen> createState() => _SearchScreenState();
}
class _SearchScreenState extends ConsumerState<SearchScreen> {
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)),
],
],
);
}),
);
}
}

View File

@ -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),
],
),
),
),
],
),
);
}
}

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