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>
7
.gitattributes
vendored
Normal 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
@ -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
@ -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'
|
||||||
BIN
Nanga Kathai Heritage Music Emblem.png
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
50
README.md
Normal 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
@ -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
@ -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
|
||||||
49
android/app/build.gradle.kts
Normal 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 = "../.."
|
||||||
|
}
|
||||||
7
android/app/src/debug/AndroidManifest.xml
Normal 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>
|
||||||
64
android/app/src/main/AndroidManifest.xml
Normal 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>
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
package com.metatroncubesolutions.nanga_kathai
|
||||||
|
|
||||||
|
import com.ryanheise.audioservice.AudioServiceActivity
|
||||||
|
|
||||||
|
class MainActivity : AudioServiceActivity()
|
||||||
12
android/app/src/main/res/drawable-v21/launch_background.xml
Normal 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>
|
||||||
12
android/app/src/main/res/drawable/launch_background.xml
Normal 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>
|
||||||
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 544 B |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 442 B |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 721 B |
BIN
android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
18
android/app/src/main/res/values-night/styles.xml
Normal 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>
|
||||||
18
android/app/src/main/res/values/styles.xml
Normal 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>
|
||||||
7
android/app/src/profile/AndroidManifest.xml
Normal 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
@ -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)
|
||||||
|
}
|
||||||
9
android/gradle.properties
Normal 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
|
||||||
5
android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal 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
|
||||||
26
android/settings.gradle.kts
Normal 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")
|
||||||
9
assets/brand/nk_app_icon.svg
Normal 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 |
15
assets/catalog/demo_catalog.json
Normal 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" }
|
||||||
|
]
|
||||||
|
}
|
||||||
BIN
assets/demo_audio/demo_1.wav
Normal file
BIN
assets/demo_audio/demo_2.wav
Normal file
BIN
assets/demo_audio/demo_3.wav
Normal file
BIN
assets/images/emblem.png
Normal file
|
After Width: | Height: | Size: 383 KiB |
BIN
assets/images/nilgiris_tea.jpg
Normal file
|
After Width: | Height: | Size: 278 KiB |
BIN
assets/images/nilgiris_valley.jpg
Normal file
|
After Width: | Height: | Size: 247 KiB |
BIN
assets/images/nilgiris_village.jpg
Normal file
|
After Width: | Height: | Size: 246 KiB |
286
docs/NK_IMPLEMENTATION_PLAN.md
Normal 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
@ -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
@ -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
|
||||||
24
ios/Flutter/AppFrameworkInfo.plist
Normal 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>
|
||||||
1
ios/Flutter/Debug.xcconfig
Normal file
@ -0,0 +1 @@
|
|||||||
|
#include "Generated.xcconfig"
|
||||||
1
ios/Flutter/Release.xcconfig
Normal file
@ -0,0 +1 @@
|
|||||||
|
#include "Generated.xcconfig"
|
||||||
647
ios/Runner.xcodeproj/project.pbxproj
Normal 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 */;
|
||||||
|
}
|
||||||
7
ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Workspace
|
||||||
|
version = "1.0">
|
||||||
|
<FileRef
|
||||||
|
location = "self:">
|
||||||
|
</FileRef>
|
||||||
|
</Workspace>
|
||||||
@ -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>
|
||||||
@ -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>
|
||||||
119
ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
Normal 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 "$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh" prepare ">
|
||||||
|
<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>
|
||||||
7
ios/Runner.xcworkspace/contents.xcworkspacedata
generated
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Workspace
|
||||||
|
version = "1.0">
|
||||||
|
<FileRef
|
||||||
|
location = "group:Runner.xcodeproj">
|
||||||
|
</FileRef>
|
||||||
|
</Workspace>
|
||||||
@ -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>
|
||||||
@ -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>
|
||||||
16
ios/Runner/AppDelegate.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
122
ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 295 B |
|
After Width: | Height: | Size: 406 B |
|
After Width: | Height: | Size: 450 B |
|
After Width: | Height: | Size: 282 B |
|
After Width: | Height: | Size: 462 B |
|
After Width: | Height: | Size: 704 B |
|
After Width: | Height: | Size: 406 B |
|
After Width: | Height: | Size: 586 B |
|
After Width: | Height: | Size: 862 B |
|
After Width: | Height: | Size: 862 B |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 762 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
23
ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json
vendored
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png
vendored
Normal file
|
After Width: | Height: | Size: 68 B |
BIN
ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 68 B |
BIN
ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 68 B |
5
ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md
vendored
Normal 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.
|
||||||
37
ios/Runner/Base.lproj/LaunchScreen.storyboard
Normal 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>
|
||||||
26
ios/Runner/Base.lproj/Main.storyboard
Normal 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
@ -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>
|
||||||
1
ios/Runner/Runner-Bridging-Header.h
Normal file
@ -0,0 +1 @@
|
|||||||
|
#import "GeneratedPluginRegistrant.h"
|
||||||
6
ios/Runner/SceneDelegate.swift
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import Flutter
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
class SceneDelegate: FlutterSceneDelegate {
|
||||||
|
|
||||||
|
}
|
||||||
12
ios/RunnerTests/RunnerTests.swift
Normal 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
@ -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
@ -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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
160
lib/audio/nk_audio_handler.dart
Normal 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,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
106
lib/audio/player_providers.dart
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
137
lib/audio/sound_settings.dart
Normal 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
@ -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
@ -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';
|
||||||
|
}
|
||||||
39
lib/core/search_normalize.dart
Normal 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);
|
||||||
|
}
|
||||||
170
lib/data/catalog_repository.dart
Normal 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());
|
||||||
48
lib/data/user_library.dart
Normal 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);
|
||||||
112
lib/features/about/about_screen.dart
Normal 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'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
172
lib/features/album/album_screen.dart
Normal 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)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
136
lib/features/artist/artist_screen.dart
Normal 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)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
386
lib/features/auth/login_screen.dart
Normal 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 [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
217
lib/features/downloads/downloads_screen.dart
Normal 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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
127
lib/features/explore/explore_screen.dart
Normal 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)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
89
lib/features/explore/occasion_screen.dart
Normal 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)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
272
lib/features/home/home_screen.dart
Normal 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)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
136
lib/features/library/library_screen.dart
Normal 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)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
446
lib/features/player/now_playing_screen.dart
Normal 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),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
158
lib/features/player/sound_sheet.dart
Normal 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()}';
|
||||||
|
}
|
||||||
324
lib/features/profile/profile_screen.dart
Normal 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')),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
121
lib/features/search/search_screen.dart
Normal 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)),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
82
lib/features/welcome/splash_screen.dart
Normal 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),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||