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:
parent
f801f91edf
commit
ad8c4f1c6b
@ -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>
|
||||||
|
|||||||
@ -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
79
assets/legal/privacy.md
Normal 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
65
assets/legal/terms.md
Normal 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**
|
||||||
@ -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: [
|
||||||
|
|||||||
82
lib/audio/output_device.dart
Normal file
82
lib/audio/output_device.dart
Normal 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).'),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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));
|
||||||
|
|||||||
@ -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.'),
|
||||||
|
),
|
||||||
|
));
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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: [
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
99
lib/features/settings/document_screen.dart
Normal file
99
lib/features/settings/document_screen.dart
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
93
lib/features/settings/help_screen.dart
Normal file
93
lib/features/settings/help_screen.dart
Normal 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')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
198
lib/features/settings/report_screen.dart
Normal file
198
lib/features/settings/report_screen.dart
Normal 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'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
117
lib/features/settings/settings_screen.dart
Normal file
117
lib/features/settings/settings_screen.dart
Normal 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'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@ -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),
|
||||||
|
|||||||
50
pubspec.lock
50
pubspec.lock
@ -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:
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
9
supabase/migrations/20261101000000_reports.sql
Normal file
9
supabase/migrations/20261101000000_reports.sql
Normal 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());
|
||||||
Loading…
x
Reference in New Issue
Block a user