App: speaker/Bluetooth picker, Settings & help, Privacy Policy, Terms, FAQ, song reports

- Now Playing shows where music plays and opens the phone's output switcher
- Settings hub: Wi-Fi-only downloads (default on), storage, sound, help, legal
- Privacy Policy and Terms bundled as Markdown (assets/legal), shown in-app
- 'Is this your song?' report form feeding takedown_requests (+ kind, user_id)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
metatroncubeswdev 2026-10-30 12:00:16 -04:00
parent f801f91edf
commit ad8c4f1c6b
20 changed files with 917 additions and 20 deletions

View File

@ -60,5 +60,10 @@
<action android:name="android.intent.action.PROCESS_TEXT"/> <action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/> <data android:mimeType="text/plain"/>
</intent> </intent>
<!-- Contact / report emails open the user's mail app. -->
<intent>
<action android:name="android.intent.action.SENDTO"/>
<data android:scheme="mailto"/>
</intent>
</queries> </queries>
</manifest> </manifest>

View File

@ -1,5 +1,43 @@
package com.metatroncubesolutions.nanga_kathai package com.metatroncubesolutions.nanga_kathai
import android.content.Intent
import android.media.MediaRouter2
import android.os.Build
import android.provider.Settings
import com.ryanheise.audioservice.AudioServiceActivity import com.ryanheise.audioservice.AudioServiceActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
class MainActivity : AudioServiceActivity() class MainActivity : AudioServiceActivity() {
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "nk/output").setMethodCallHandler { call, result ->
when (call.method) {
"showOutputSwitcher" -> result.success(showOutputSwitcher())
else -> result.notImplemented()
}
}
}
/** Opens the phone's own "play on" picker (speaker, Bluetooth, cast). */
private fun showOutputSwitcher(): Boolean {
if (Build.VERSION.SDK_INT >= 34 && MediaRouter2.getInstance(this).showSystemOutputSwitcher()) return true
if (Build.VERSION.SDK_INT >= 31) {
try {
sendBroadcast(
Intent("com.android.systemui.action.LAUNCH_MEDIA_OUTPUT_DIALOG")
.setPackage("com.android.systemui")
.putExtra("package_name", packageName)
)
return true
} catch (_: Exception) {
}
}
return try {
startActivity(Intent(Settings.ACTION_BLUETOOTH_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
true
} catch (_: Exception) {
false
}
}
}

79
assets/legal/privacy.md Normal file
View File

@ -0,0 +1,79 @@
# Privacy Policy
Last updated: 1 November 2026
Nanga Kathai ("we", "us") is a music app for Badaga songs, made by Metatroncube Software Solutions. This policy explains what we collect, why, and the choices you have. We wrote it to be read, not skimmed.
## The short version
- We collect only what we need to run the app: your email, the name you give us, and optional profile details.
- We do not sell your data, show ads, or share your details with advertisers.
- Your likes, recently played songs and downloads stay on your phone.
- You can delete your account, and everything linked to it, from the app at any time.
## What we collect
**Account details.** When you sign up we store your email address, the name you enter and a securely hashed password. We never see or store your password in readable form.
**Optional profile details.** If you choose to add them: a profile photo, your village or hatti, and your phone number. Your phone number is visible only to the Nanga Kathai team, never to other listeners.
**Creator applications.** If you apply to become a verified creator, we also collect the details you submit, such as your stage name, Instagram ID, WhatsApp number, links to your work and an identity document. These are used only to verify you, are visible only to our admin team, and are stored privately.
**Reports and messages.** If you report a song, claim a song or contact us, we keep what you send so we can respond.
**Technical information.** Our servers keep standard logs (such as IP address, device type and time of request) for security and to fix problems. Logs are kept for a limited time.
## What stays on your phone
Your liked songs, recently played list, sound settings and downloaded songs are stored on your device. Downloaded songs are encrypted and can only be played inside Nanga Kathai. Removing the app removes them.
## How we use your information
- To create and secure your account, including sending sign-in and verification codes by email.
- To run the app: streaming, your profile and your preferences.
- To review creator applications and song reports.
- To keep the service safe and to fix problems.
- In future, to count song plays so that verified artists can share in subscription revenue. We will update this policy before that starts.
We do not use your information for advertising.
## Who can see your information
- **Our team:** only the people who run Nanga Kathai, and only when needed.
- **Service providers:** our app runs on servers we operate, and email is sent through our own mail server. If we ever use an outside provider for hosting, email or payments, they may only use your data to provide that service to us.
- **The law:** we may disclose information if required by law or to protect the safety of our users.
Other listeners never see your email or phone number.
## How long we keep it
We keep your account details for as long as your account exists. When you delete your account, your profile, likes, playlists and history on our servers are removed. Backups are overwritten within about 14 days. Reports you sent may be kept, without your account, where we need a record of a rights or removal request.
## Your choices and rights
- **See and edit** your profile in the app (Library > your profile).
- **Delete your account** from your profile page. This cannot be undone.
- **Ask us** for a copy of your data, a correction, or anything else about your privacy by writing to support@nkbmusic.com.
These rights apply wherever you live, including your rights under India's Digital Personal Data Protection Act, 2023.
## Children
Nanga Kathai is meant for listeners of all ages, but accounts are intended for people aged 18 and over. Younger listeners should use the app with the consent and supervision of a parent or guardian.
## Security
We use encrypted connections (HTTPS) for everything the app sends, store passwords only in hashed form, restrict data access with database rules, and keep creator documents in private storage. No system is perfectly secure, so please use a password you do not use elsewhere.
## Changes to this policy
If we make important changes, we will tell you in the app before they take effect. The date at the top shows the latest version.
## Contact and grievances
Questions, requests or complaints about privacy:
**Email:** support@nkbmusic.com
**Grievance contact:** Metatroncube Software Solutions, via support@nkbmusic.com
We aim to reply within 7 days.

65
assets/legal/terms.md Normal file
View File

@ -0,0 +1,65 @@
# Terms of Use
Last updated: 1 November 2026
Welcome to Nanga Kathai, a home for Badaga music, provided by Metatroncube Software Solutions ("we", "us"). By using the app you agree to these terms. Please read them; they are short.
## Using Nanga Kathai
- Nanga Kathai is for your personal, non-commercial listening.
- Guests can preview songs. A free account lets you listen in full, keep likes and download songs.
- You are responsible for your account. Keep your password private and tell us at support@nkbmusic.com if you think someone else has used it.
- Accounts are intended for people aged 18 and over. Younger listeners should use the app with a parent or guardian.
## Songs and rights
- The songs in Nanga Kathai belong to their singers, lyricists, composers and other rights holders. Using the app does not give you any ownership of them.
- You may not copy, record, re-upload, sell, broadcast or publicly perform songs from the app, or remove them from the app by any means.
- **Downloads** are for offline listening inside Nanga Kathai only. They are encrypted, cannot be played elsewhere, and may stop working if a song is removed or your account is deleted.
## Our older recordings
Our first songs come from a family collection gathered over many years, and not every artist has been identified yet. We respect the people behind these songs. If you are an artist, rights holder or family member, you can, at any time:
- **claim a song** so we credit you correctly,
- **ask us to correct** its details, or
- **ask us to remove it.**
Use "Is this your song?" on the song in the app, or write to support@nkbmusic.com. We review every request and act on genuine removal requests promptly.
## Creators
Singers, lyricists and music directors can apply to become verified creators. Verification is manual and may require identity documents. Creators who upload songs confirm that they own them or have permission from everyone with rights in them, and accept the separate terms shown when they apply. Plans to share subscription revenue with verified creators will be described in those terms before they begin.
## Acceptable use
Please do not:
- break, overload or try to get around the app's security, including its download protection;
- use bots or scripts to play songs, create accounts or inflate play counts;
- impersonate a singer or anyone else, or submit false claims or reports;
- upload or send anything unlawful, hateful or harmful.
We may suspend or close accounts that break these terms.
## Changes and availability
We are a small team building something for our community. Features may change, and songs may be added or removed, sometimes at an artist's request. We try to keep the app running smoothly but cannot promise it will always be available or error-free.
## Liability
Nanga Kathai is provided "as is". To the extent the law allows, we are not liable for indirect or consequential losses arising from your use of the app. Nothing in these terms limits rights you have under consumer protection law.
## Ending your account
You can delete your account at any time from your profile page. We may close an account that breaks these terms, and will tell you why where we can.
## Changes to these terms
If we make important changes, we will tell you in the app before they take effect. Continuing to use Nanga Kathai afterwards means you accept the new terms.
## Law and contact
These terms are governed by the laws of India.
Questions: **support@nkbmusic.com**

View File

@ -20,6 +20,10 @@ import '../features/player/now_playing_screen.dart';
import '../features/about/about_screen.dart'; import '../features/about/about_screen.dart';
import '../features/album/album_screen.dart'; import '../features/album/album_screen.dart';
import '../features/search/search_screen.dart'; import '../features/search/search_screen.dart';
import '../features/settings/document_screen.dart';
import '../features/settings/help_screen.dart';
import '../features/settings/report_screen.dart';
import '../features/settings/settings_screen.dart';
import '../features/welcome/splash_screen.dart'; import '../features/welcome/splash_screen.dart';
import '../shared/widgets/app_shell.dart'; import '../shared/widgets/app_shell.dart';
@ -80,6 +84,20 @@ final routerProvider = Provider<GoRouter>((ref) {
), ),
GoRoute(path: '/profile', builder: (_, _) => const ProfileScreen()), GoRoute(path: '/profile', builder: (_, _) => const ProfileScreen()),
GoRoute(path: '/about', builder: (_, _) => const AboutScreen()), GoRoute(path: '/about', builder: (_, _) => const AboutScreen()),
GoRoute(path: '/settings', builder: (_, _) => const SettingsScreen()),
GoRoute(path: '/help', builder: (_, _) => const HelpScreen()),
GoRoute(
path: '/privacy',
builder: (_, _) => const DocumentScreen(title: 'Privacy Policy', asset: 'assets/legal/privacy.md'),
),
GoRoute(
path: '/terms',
builder: (_, _) => const DocumentScreen(title: 'Terms of Use', asset: 'assets/legal/terms.md'),
),
GoRoute(
path: '/report',
builder: (_, state) => ReportScreen(trackId: state.uri.queryParameters['track']),
),
StatefulShellRoute.indexedStack( StatefulShellRoute.indexedStack(
builder: (_, _, shell) => AppShell(shell: shell), builder: (_, _, shell) => AppShell(shell: shell),
branches: [ branches: [

View File

@ -0,0 +1,82 @@
import 'package:audio_session/audio_session.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../app/theme.dart';
/// Where music plays right now, as the phone reports it.
class OutputDevice {
const OutputDevice(this.label, this.icon);
final String label;
final IconData icon;
static const speaker = OutputDevice('Phone speaker', Icons.speaker_phone_rounded);
/// Android sends media to the most recently connected external device, so
/// pick Bluetooth, then wired/USB/car, then the built-in speaker.
static OutputDevice from(Set<AudioDevice> devices) {
final outputs = devices.where((d) => d.isOutput).toList();
int rank(AudioDevice d) {
final t = d.type.name.toLowerCase();
if (t.contains('bluetooth') && t != 'bluetoothsco') return 0;
if (t.contains('car') || t.contains('wired') || t.contains('usb') || t.contains('line')) return 1;
if (t.contains('hdmi') || t.contains('airplay') || t.contains('dock')) return 2;
if (t == 'builtinspeaker') return 3;
return 9;
}
outputs.sort((a, b) => rank(a).compareTo(rank(b)));
if (outputs.isEmpty || rank(outputs.first) >= 3) return speaker;
final d = outputs.first;
final t = d.type.name.toLowerCase();
final name = d.name.trim().isEmpty ? null : d.name.trim();
if (t.contains('bluetooth')) return OutputDevice(name ?? 'Bluetooth', Icons.bluetooth_audio_rounded);
if (t.contains('car')) return OutputDevice(name ?? 'Car audio', Icons.directions_car_rounded);
if (t.contains('wired') || t.contains('line')) return const OutputDevice('Headphones', Icons.headphones_rounded);
if (t.contains('usb')) return OutputDevice(name ?? 'USB audio', Icons.usb_rounded);
return OutputDevice(name ?? 'External speaker', Icons.speaker_rounded);
}
}
final outputDeviceProvider = StreamProvider<OutputDevice>((ref) async* {
final session = await AudioSession.instance;
yield OutputDevice.from(await session.getDevices(includeInputs: false));
yield* session.devicesStream.map(OutputDevice.from);
});
const _channel = MethodChannel('nk/output');
/// Shows where music is playing; tap to choose another speaker or Bluetooth device.
class OutputDeviceButton extends ConsumerWidget {
const OutputDeviceButton({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final device = ref.watch(outputDeviceProvider).value ?? OutputDevice.speaker;
final external = device != OutputDevice.speaker;
final color = external ? NkColors.teaLeaf : NkColors.mutedOnPhoto;
return TextButton.icon(
onPressed: () => _choose(context),
style: TextButton.styleFrom(foregroundColor: color, visualDensity: VisualDensity.compact),
icon: Icon(device.icon, size: 20),
label: Text(device.label, maxLines: 1, overflow: TextOverflow.ellipsis),
);
}
Future<void> _choose(BuildContext context) async {
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) {
try {
if (await _channel.invokeMethod<bool>('showOutputSwitcher') == true) return;
} on PlatformException {
// Fall through to the hint below.
}
}
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text('Choose a speaker or Bluetooth device from Control Center (swipe down from the top right).'),
));
}
}

View File

@ -1,8 +1,10 @@
import 'package:audio_service/audio_service.dart'; import 'package:audio_service/audio_service.dart';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/models.dart'; import '../core/models.dart';
import '../data/catalog_repository.dart'; import '../data/catalog_repository.dart';
import '../features/settings/settings_screen.dart';
import '../offline/download_store.dart'; import '../offline/download_store.dart';
import 'nk_audio_handler.dart'; import 'nk_audio_handler.dart';
@ -72,12 +74,20 @@ class PlayerController {
], index: index); ], index: index);
} }
void download(Iterable<Track> tracks) { /// Queues songs for download. Returns why it could not start, or null.
Future<String?> download(Iterable<Track> tracks) async {
if (_ref.read(wifiOnlyDownloadsProvider)) {
final links = await Connectivity().checkConnectivity();
if (!links.contains(ConnectivityResult.wifi) && !links.contains(ConnectivityResult.ethernet)) {
return 'Connect to Wi-Fi to download, or turn off "Download on Wi-Fi only" in Settings.';
}
}
final catalog = _ref.read(catalogProvider).requireValue; final catalog = _ref.read(catalogProvider).requireValue;
final store = _ref.read(downloadStoreProvider); final store = _ref.read(downloadStoreProvider);
for (final t in tracks) { for (final t in tracks) {
store.enqueue(t, credit: catalog.creditLine(t), album: catalog.albumOf(t)?.title); store.enqueue(t, credit: catalog.creditLine(t), album: catalog.albumOf(t)?.title);
} }
return null;
} }
Future<void> playNext(Track t) => _handler.playNext(toMediaItem(t)); Future<void> playNext(Track t) => _handler.playNext(toMediaItem(t));

View File

@ -128,10 +128,12 @@ class _DownloadAlbumButton extends ConsumerWidget {
context.push('/login?mode=signup'); context.push('/login?mode=signup');
return; return;
} }
ref.read(playerControllerProvider).download(tracks); final messenger = ScaffoldMessenger.of(context);
ScaffoldMessenger.of(context).showSnackBar( ref.read(playerControllerProvider).download(tracks).then((problem) => messenger.showSnackBar(
SnackBar(content: Text('Downloading ${tracks.length - done} songs. Find them in Downloads.')), SnackBar(
); content: Text(problem ?? 'Downloading ${tracks.length - done} songs. Find them in Downloads.'),
),
));
}, },
); );
} }

View File

@ -74,9 +74,9 @@ class _LibraryScreenState extends ConsumerState<LibraryScreen> {
children: [ children: [
Expanded(child: Text('Your library', style: Theme.of(context).textTheme.headlineMedium)), Expanded(child: Text('Your library', style: Theme.of(context).textTheme.headlineMedium)),
IconButton( IconButton(
tooltip: 'About Nanga Kathai', tooltip: 'Settings & help',
icon: const Icon(Icons.info_outline), icon: const Icon(Icons.settings_outlined),
onPressed: () => context.push('/about'), onPressed: () => context.push('/settings'),
), ),
if (Env.hasSupabase) if (Env.hasSupabase)
IconButton( IconButton(

View File

@ -7,6 +7,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../../app/theme.dart'; import '../../app/theme.dart';
import '../../audio/output_device.dart';
import '../../audio/player_providers.dart'; import '../../audio/player_providers.dart';
import '../../audio/sound_settings.dart'; import '../../audio/sound_settings.dart';
import '../../core/models.dart'; import '../../core/models.dart';
@ -168,7 +169,9 @@ class NowPlayingScreen extends ConsumerWidget {
), ),
], ],
), ),
const SizedBox(height: 16), const SizedBox(height: 4),
const Center(child: OutputDeviceButton()),
const SizedBox(height: 4),
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceAround, mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [ children: [

View File

@ -249,9 +249,9 @@ class _ProfileFormState extends ConsumerState<_ProfileForm> {
), ),
ListTile( ListTile(
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.info_outline), leading: const Icon(Icons.settings_outlined),
title: const Text('About Nanga Kathai'), title: const Text('Settings & help'),
onTap: () => context.push('/about'), onTap: () => context.push('/settings'),
), ),
ListTile( ListTile(
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,

View File

@ -0,0 +1,99 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../app/theme.dart';
/// Shows a bundled Markdown document (privacy policy, terms). Supports the
/// small subset those files use: #/## headings, "- " bullets, paragraphs,
/// **bold** and email addresses (tappable).
class DocumentScreen extends StatelessWidget {
const DocumentScreen({super.key, required this.title, required this.asset});
final String title;
final String asset;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text(title)),
body: FutureBuilder<String>(
future: rootBundle.loadString(asset),
builder: (context, snap) {
if (!snap.hasData) return const Center(child: CircularProgressIndicator());
return SelectionArea(
child: ListView(
padding: const EdgeInsets.fromLTRB(20, 4, 20, 40),
children: _render(context, snap.data!),
),
);
},
),
);
}
List<Widget> _render(BuildContext context, String md) {
final text = Theme.of(context).textTheme;
final body = text.bodyLarge?.copyWith(color: NkColors.mutedOnPhoto, height: 1.55);
final out = <Widget>[];
for (final block in md.split(RegExp(r'\n\s*\n'))) {
final lines = block.trim().split('\n');
if (lines.first.isEmpty) continue;
if (lines.first.startsWith('# ')) {
continue; // the app bar already shows the title
} else if (lines.first.startsWith('## ')) {
out.add(Padding(
padding: const EdgeInsets.only(top: 18, bottom: 6),
child: Text(lines.first.substring(3), style: text.titleLarge),
));
} else if (lines.every((l) => l.startsWith('- '))) {
for (final l in lines) {
out.add(Padding(
padding: const EdgeInsets.only(bottom: 6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('• ', style: body?.copyWith(color: NkColors.sun)),
Expanded(child: Text.rich(_inline(l.substring(2), body), style: body)),
],
),
));
}
out.add(const SizedBox(height: 6));
} else {
out.add(Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Text.rich(_inline(lines.join('\n'), body), style: body),
));
}
}
return out;
}
/// **bold** and tappable email addresses.
static TextSpan _inline(String s, TextStyle? base) {
final spans = <InlineSpan>[];
final pattern = RegExp(r'\*\*(.+?)\*\*|([\w.+-]+@[\w-]+\.[\w.]+)');
var last = 0;
for (final m in pattern.allMatches(s)) {
if (m.start > last) spans.add(TextSpan(text: s.substring(last, m.start)));
if (m.group(1) != null) {
spans.add(TextSpan(
text: m.group(1),
style: const TextStyle(fontWeight: FontWeight.w700, color: NkColors.cream),
));
} else {
final email = m.group(2)!;
spans.add(TextSpan(
text: email,
style: const TextStyle(color: NkColors.sun),
recognizer: TapGestureRecognizer()..onTap = () => launchUrl(Uri(scheme: 'mailto', path: email)),
));
}
last = m.end;
}
if (last < s.length) spans.add(TextSpan(text: s.substring(last)));
return TextSpan(children: spans, style: base);
}
}

View File

@ -0,0 +1,93 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../app/theme.dart';
import 'settings_screen.dart';
const _faq = [
(
'Is Nanga Kathai free?',
'Yes. The songs in the app today are free to stream with a free account. Guests can hear a 30-second '
'preview of each song. In future, new songs from verified creators may be part of a paid plan that '
'shares revenue with the artists.',
),
(
'How do I listen without internet?',
'Tap ⋮ on a song and choose Download, or use the download button on an album page. Your songs appear in '
'the Downloads tab and play anywhere, even without signal. By default songs download only on Wi-Fi; you '
'can change that in Settings.',
),
(
'Can I copy downloaded songs to another phone or app?',
'No. Downloads are encrypted and play only inside Nanga Kathai on your phone. This protects the singers '
'whose work we share.',
),
(
'Music is playing on the phone speaker instead of my Bluetooth speaker.',
'Open the player and tap the speaker name under the play controls. Your phone will show every speaker and '
'headset you can use; pick the one you want. If your device is not listed, make sure it is switched on '
'and paired in your phone\'s Bluetooth settings.',
),
(
'How do I make songs sound better?',
'In the player, tap Sound. Choose a preset such as Vocal or Bass boost, or move the sliders yourself. '
'Volume boost helps with quiet old cassette recordings. (On iPhone, use Settings > Apps > Music > EQ.)',
),
(
'I forgot my password.',
'On the sign-in screen tap "Forgot password?". We will email you a code to set a new one. You can also '
'choose "Email me a code instead" to sign in without a password.',
),
(
'One of these songs is mine, or my family\'s.',
'Thank you for letting us know. Tap ⋮ on the song and choose "Is this your song?". You can ask us to '
'credit you, correct the details, or remove the song. We review every request.',
),
(
'I am a singer or music director. Can I publish my songs here?',
'Yes, that is why we built Nanga Kathai. A creator application in the app is coming soon. Until then, '
'write to support@nkbmusic.com and we will get in touch.',
),
(
'How do I delete my account?',
'Open your profile (the person icon on Home or in Library) and tap Delete account. Your profile, likes and '
'playlists are removed for good.',
),
];
class HelpScreen extends StatelessWidget {
const HelpScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Help & FAQ')),
body: ListView(
padding: const EdgeInsets.fromLTRB(12, 0, 12, 32),
children: [
for (final (q, a) in _faq)
Theme(
data: Theme.of(context).copyWith(dividerColor: Colors.transparent),
child: ExpansionTile(
title: Text(q, style: const TextStyle(fontWeight: FontWeight.w700)),
childrenPadding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
expandedAlignment: Alignment.centerLeft,
children: [Text(a, style: const TextStyle(color: NkColors.mutedOnPhoto, height: 1.5))],
),
),
const SizedBox(height: 16),
Card(
color: NkColors.surface,
child: ListTile(
leading: const Icon(Icons.mail_outline, color: NkColors.sun),
title: const Text('Still need help?'),
subtitle: const Text('Write to us at support@nkbmusic.com'),
onTap: () => contactSupport(),
),
),
TextButton(onPressed: () => context.push('/report'), child: const Text('Report a problem with a song')),
],
),
);
}
}

View File

@ -0,0 +1,198 @@
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/theme.dart';
import '../../core/env.dart';
import '../../data/catalog_repository.dart';
import '../profile/profile_screen.dart';
enum _Kind {
claim('claim', 'This is my song', 'Credit me (or my family member) as the singer, writer or composer.'),
takedown('takedown', 'Please remove this song', 'I am the artist, a family member or the rights holder.'),
wrongInfo('wrong_info', 'The details are wrong', 'Title, singer, album or occasion needs correcting.'),
other('other', 'Something else', 'Sound problem, offensive content or anything else.');
const _Kind(this.id, this.title, this.subtitle);
final String id;
final String title;
final String subtitle;
}
/// "Is this your song?" / report a problem. Goes to the admin team's queue.
class ReportScreen extends ConsumerStatefulWidget {
const ReportScreen({super.key, this.trackId});
final String? trackId;
@override
ConsumerState<ReportScreen> createState() => _ReportScreenState();
}
class _ReportScreenState extends ConsumerState<ReportScreen> {
_Kind _kind = _Kind.claim;
final _name = TextEditingController();
final _contact = TextEditingController();
final _song = TextEditingController();
final _message = TextEditingController();
bool _sending = false;
bool _sent = false;
@override
void initState() {
super.initState();
if (!Env.hasSupabase) return;
_contact.text = Supabase.instance.client.auth.currentUser?.email ?? '';
ref.read(profileProvider.future).then((p) {
if (mounted && p?.name != null && _name.text.isEmpty) setState(() => _name.text = p!.name!);
}).ignore();
}
@override
void dispose() {
for (final c in [_name, _contact, _song, _message]) {
c.dispose();
}
super.dispose();
}
bool get _valid =>
_name.text.trim().isNotEmpty &&
_contact.text.trim().length >= 6 &&
_message.text.trim().length >= 5 &&
(widget.trackId != null || _song.text.trim().isNotEmpty);
Future<void> _send() async {
setState(() => _sending = true);
try {
await Supabase.instance.client.from('takedown_requests').insert({
'track_id': widget.trackId,
'kind': _kind.id,
'requester': _name.text.trim(),
'contact': _contact.text.trim(),
'reason': widget.trackId == null
? 'Song: ${_song.text.trim()}\n\n${_message.text.trim()}'
: _message.text.trim(),
});
setState(() => _sent = true);
} catch (_) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Could not send. Check your connection, or email support@nkbmusic.com.')),
);
}
} finally {
if (mounted) setState(() => _sending = false);
}
}
@override
Widget build(BuildContext context) {
final text = Theme.of(context).textTheme;
final catalog = ref.watch(catalogProvider).value;
final track = widget.trackId == null ? null : catalog?.trackById(widget.trackId!);
if (_sent) {
return Scaffold(
appBar: AppBar(),
body: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Icon(Icons.favorite_rounded, color: NkColors.sun, size: 56),
const SizedBox(height: 16),
Text('Thank you', textAlign: TextAlign.center, style: text.headlineMedium),
const SizedBox(height: 8),
const Text(
'We have received your message and will get back to you, usually within 7 days. '
'Genuine removal requests are acted on as soon as we have checked them.',
textAlign: TextAlign.center,
style: TextStyle(color: NkColors.muted, height: 1.5),
),
const SizedBox(height: 24),
FilledButton(onPressed: () => context.pop(), child: const Text('Done')),
],
),
),
);
}
return Scaffold(
appBar: AppBar(title: const Text('Is this your song?')),
body: ListView(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 32),
children: [
if (track != null)
Text('About "${track.titleLatin}"', style: text.titleMedium?.copyWith(color: NkColors.sun)),
const SizedBox(height: 8),
const Text(
'We want every artist credited properly. Tell us what is going on and our team will review it.',
style: TextStyle(color: NkColors.muted, height: 1.5),
),
const SizedBox(height: 12),
RadioGroup<_Kind>(
groupValue: _kind,
onChanged: (k) => setState(() => _kind = k ?? _kind),
child: Column(
children: [
for (final k in _Kind.values)
RadioListTile<_Kind>(
value: k,
contentPadding: EdgeInsets.zero,
title: Text(k.title, style: const TextStyle(fontWeight: FontWeight.w700)),
subtitle: Text(k.subtitle, style: const TextStyle(color: NkColors.muted)),
),
],
),
),
const SizedBox(height: 8),
if (track == null) ...[
TextField(
controller: _song,
onChanged: (_) => setState(() {}),
decoration: const InputDecoration(labelText: 'Which song?', hintText: 'Song title and album'),
),
const SizedBox(height: 12),
],
TextField(
controller: _name,
textCapitalization: TextCapitalization.words,
onChanged: (_) => setState(() {}),
decoration: const InputDecoration(labelText: 'Your name'),
),
const SizedBox(height: 12),
TextField(
controller: _contact,
onChanged: (_) => setState(() {}),
decoration: const InputDecoration(
labelText: 'Email or WhatsApp number',
helperText: 'Only our team sees this, to reply to you.',
),
),
const SizedBox(height: 12),
TextField(
controller: _message,
minLines: 4,
maxLines: 8,
onChanged: (_) => setState(() {}),
decoration: const InputDecoration(
labelText: 'Tell us more',
hintText: 'For example: who sang it, who wrote it, the correct title, or why it should be removed.',
alignLabelWithHint: true,
),
),
const SizedBox(height: 20),
FilledButton(
onPressed: _valid && !_sending && Env.hasSupabase ? _send : null,
child: _sending
? const SizedBox.square(dimension: 22, child: CircularProgressIndicator(strokeWidth: 2.5))
: const Text('Send to the Nanga Kathai team'),
),
],
),
);
}
}

View File

@ -0,0 +1,117 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../app/router.dart';
import '../../app/theme.dart';
import '../../data/user_library.dart';
import '../../offline/download_store.dart';
import '../player/sound_sheet.dart';
const supportEmail = 'support@nkbmusic.com';
Future<void> contactSupport({String subject = 'Nanga Kathai'}) =>
launchUrl(Uri(scheme: 'mailto', path: supportEmail, query: 'subject=${Uri.encodeComponent(subject)}'));
/// Download only on Wi-Fi (on by default, so nobody burns their mobile data by surprise).
class WifiOnlyDownloads extends Notifier<bool> {
static const _key = 'downloads_wifi_only';
@override
bool build() => ref.read(sharedPrefsProvider).getBool(_key) ?? true;
void set(bool value) {
state = value;
ref.read(sharedPrefsProvider).setBool(_key, value);
}
}
final wifiOnlyDownloadsProvider = NotifierProvider<WifiOnlyDownloads, bool>(WifiOnlyDownloads.new);
class SettingsScreen extends ConsumerWidget {
const SettingsScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final wifiOnly = ref.watch(wifiOnlyDownloadsProvider);
final downloads = ref.watch(downloadsProvider);
final mb = (downloads.totalBytes / (1 << 20)).round();
Widget section(String title) => Padding(
padding: const EdgeInsets.fromLTRB(16, 20, 16, 4),
child: Text(title.toUpperCase(),
style: const TextStyle(color: NkColors.sun, fontSize: 12, fontWeight: FontWeight.w700, letterSpacing: 1.2)),
);
Widget tile(IconData icon, String title, {String? subtitle, VoidCallback? onTap}) => ListTile(
leading: Icon(icon),
title: Text(title),
subtitle: subtitle == null ? null : Text(subtitle, style: const TextStyle(color: NkColors.muted)),
trailing: onTap == null ? null : const Icon(Icons.chevron_right, color: NkColors.muted),
onTap: onTap,
);
return Scaffold(
appBar: AppBar(title: const Text('Settings & help')),
body: ListView(
padding: const EdgeInsets.only(bottom: 32),
children: [
section('Account'),
if (isSignedIn)
tile(Icons.person_outline, 'Your profile', onTap: () => context.push('/profile'))
else
tile(Icons.login, 'Sign in or create an account',
subtitle: 'Listen to full songs and download them', onTap: () => context.push('/login?mode=signup')),
section('Playback'),
tile(Icons.equalizer_rounded, 'Sound and equalizer', onTap: () => showSoundSettings(context)),
section('Downloads'),
SwitchListTile(
secondary: const Icon(Icons.wifi),
title: const Text('Download on Wi-Fi only'),
subtitle: const Text('Saves your mobile data', style: TextStyle(color: NkColors.muted)),
value: wifiOnly,
onChanged: ref.read(wifiOnlyDownloadsProvider.notifier).set,
),
tile(Icons.sd_storage_outlined, 'Storage used',
subtitle: '${downloads.entries.length} songs · $mb MB',
onTap: downloads.entries.isEmpty ? null : () => _removeAll(context, ref)),
section('Help'),
tile(Icons.help_outline, 'Help & FAQ', onTap: () => context.push('/help')),
tile(Icons.flag_outlined, 'Is this your song? Report a song', onTap: () => context.push('/report')),
tile(Icons.mail_outline, 'Contact us', subtitle: supportEmail, onTap: () => contactSupport()),
section('About'),
tile(Icons.info_outline, 'About Nanga Kathai', onTap: () => context.push('/about')),
tile(Icons.privacy_tip_outlined, 'Privacy Policy', onTap: () => context.push('/privacy')),
tile(Icons.description_outlined, 'Terms of Use', onTap: () => context.push('/terms')),
tile(Icons.code, 'Open-source licences',
onTap: () => showLicensePage(context: context, applicationName: 'Nanga Kathai', applicationVersion: '1.0.0')),
const Padding(
padding: EdgeInsets.fromLTRB(16, 16, 16, 0),
child: Text('Nanga Kathai 1.0.0 · Metatroncube Software Solutions',
style: TextStyle(color: NkColors.muted, fontSize: 12)),
),
],
),
);
}
void _removeAll(BuildContext context, WidgetRef ref) {
showDialog<void>(
context: context,
builder: (dialog) => AlertDialog(
title: const Text('Remove all downloads?'),
content: const Text('This frees up space. You can download the songs again any time.'),
actions: [
TextButton(onPressed: () => Navigator.pop(dialog), child: const Text('Cancel')),
FilledButton(
onPressed: () {
ref.read(downloadStoreProvider).removeAll();
Navigator.pop(dialog);
},
child: const Text('Remove'),
),
],
),
);
}
}

View File

@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../../app/theme.dart'; import '../../app/theme.dart';
import '../../audio/output_device.dart';
import '../../audio/player_providers.dart'; import '../../audio/player_providers.dart';
import '../../core/models.dart'; import '../../core/models.dart';
import 'artwork.dart'; import 'artwork.dart';
@ -21,6 +22,7 @@ class MiniPlayer extends ConsumerWidget {
final total = item.duration ?? Duration.zero; final total = item.duration ?? Duration.zero;
final progress = total.inMilliseconds == 0 ? 0.0 : (position.inMilliseconds / total.inMilliseconds).clamp(0.0, 1.0); final progress = total.inMilliseconds == 0 ? 0.0 : (position.inMilliseconds / total.inMilliseconds).clamp(0.0, 1.0);
final handler = ref.read(audioHandlerProvider); final handler = ref.read(audioHandlerProvider);
final output = ref.watch(outputDeviceProvider).value;
return Padding( return Padding(
padding: const EdgeInsets.fromLTRB(10, 6, 10, 4), padding: const EdgeInsets.fromLTRB(10, 6, 10, 4),
@ -64,10 +66,29 @@ class MiniPlayer extends ConsumerWidget {
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 15)), style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 15)),
Text(item.artist ?? '', Row(
children: [
if (output != null && output != OutputDevice.speaker) ...[
Icon(output.icon, size: 14, color: NkColors.teaLeaf),
const SizedBox(width: 4),
],
Expanded(
child: Text(
output != null && output != OutputDevice.speaker
? output.label
: (item.artist ?? ''),
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: const TextStyle(color: NkColors.muted, fontSize: 13)), style: TextStyle(
color: output != null && output != OutputDevice.speaker
? NkColors.teaLeaf
: NkColors.muted,
fontSize: 13,
),
),
),
],
),
], ],
), ),
), ),

View File

@ -161,11 +161,18 @@ Future<void> showTrackActions(BuildContext context, WidgetRef ref, Track track)
Navigator.of(sheetContext).pop(); Navigator.of(sheetContext).pop();
context.push('/login?mode=signup'); context.push('/login?mode=signup');
} else { } else {
controller.download([track]); controller.download([track]).then((problem) => done(problem ?? 'Downloading. Find it in Downloads.'));
done('Downloading. Find it in Downloads.');
} }
}, },
), ),
ListTile(
leading: const Icon(Icons.flag_outlined),
title: const Text('Is this your song? Report'),
onTap: () {
Navigator.of(sheetContext).pop();
context.push('/report?track=${track.id}');
},
),
for (final a in artists) for (final a in artists)
ListTile( ListTile(
leading: const Icon(Icons.person_outline), leading: const Icon(Icons.person_outline),

View File

@ -145,6 +145,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.19.1" version: "1.19.1"
connectivity_plus:
dependency: "direct main"
description:
name: connectivity_plus
sha256: d7749fd36d8b404a4935612b94eca7c3ce3705377ab678e24ff2a4301436f741
url: "https://pub.dev"
source: hosted
version: "7.3.2"
connectivity_plus_platform_interface:
dependency: transitive
description:
name: connectivity_plus_platform_interface
sha256: "3c09627c536d22fd24691a905cdd8b14520de69da52c7a97499c8be5284a32ed"
url: "https://pub.dev"
source: hosted
version: "2.1.0"
convert: convert:
dependency: transitive dependency: transitive
description: description:
@ -193,6 +209,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.4.1" version: "3.4.1"
dbus:
dependency: transitive
description:
name: dbus
sha256: f7db8504e715b835f5ebe5fe74604ccd961d504c73389a5d8e81bafc2e2f3612
url: "https://pub.dev"
source: hosted
version: "0.8.0"
fake_async: fake_async:
dependency: transitive dependency: transitive
description: description:
@ -645,6 +669,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.0" version: "2.1.0"
nm:
dependency: transitive
description:
name: nm
sha256: "2ae480aa3379716b8bb58dfebb168e2919394c92b629efaaa0baaa5d140786e6"
url: "https://pub.dev"
source: hosted
version: "0.6.0"
objective_c: objective_c:
dependency: transitive dependency: transitive
description: description:
@ -733,6 +765,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.3.0" version: "2.3.0"
petitparser:
dependency: transitive
description:
name: petitparser
sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
url: "https://pub.dev"
source: hosted
version: "7.0.2"
platform: platform:
dependency: transitive dependency: transitive
description: description:
@ -1011,7 +1051,7 @@ packages:
source: hosted source: hosted
version: "1.4.0" version: "1.4.0"
url_launcher: url_launcher:
dependency: transitive dependency: "direct main"
description: description:
name: url_launcher name: url_launcher
sha256: e8cc955c97e06df9879140a1fe301f3d493f01b7c62dec7f46834be31d7e9fda sha256: e8cc955c97e06df9879140a1fe301f3d493f01b7c62dec7f46834be31d7e9fda
@ -1138,6 +1178,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.0" version: "1.1.0"
xml:
dependency: transitive
description:
name: xml
sha256: "67f0aff7be013d107995e9b75bf4e7f2c3ef2dfdb2c8e68024bba0a7fd5756a4"
url: "https://pub.dev"
source: hosted
version: "7.0.1"
yaml: yaml:
dependency: transitive dependency: transitive
description: description:

View File

@ -49,6 +49,8 @@ dependencies:
pointycastle: ^4.0.0 pointycastle: ^4.0.0
path_provider: ^2.1.6 path_provider: ^2.1.6
http: ^1.6.0 http: ^1.6.0
url_launcher: ^6.3.3
connectivity_plus: ^7.3.2
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
@ -76,6 +78,7 @@ flutter:
- assets/brand/ - assets/brand/
- assets/images/ - assets/images/
- assets/catalog/ - assets/catalog/
- assets/legal/
- assets/demo_audio/ - assets/demo_audio/
# To add assets to your application, add an assets section, like this: # To add assets to your application, add an assets section, like this:

View File

@ -0,0 +1,9 @@
-- "Is this your song? / Report a song" from the app: what kind of report, and who sent it.
alter table takedown_requests
add column kind text not null default 'takedown'
check (kind in ('claim', 'takedown', 'wrong_info', 'other')),
add column user_id uuid references auth.users on delete set null default auth.uid();
-- Signed-in users can see the reports they sent (admins already see all).
create policy own_takedowns on takedown_requests for select using (user_id = auth.uid());