nkbmusic/lib/features/profile/profile_screen.dart
metatroncubeswdev ad8c4f1c6b 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>
2026-10-30 12:00:16 -04:00

325 lines
11 KiB
Dart

import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:image_picker/image_picker.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import '../../app/theme.dart';
class Profile {
const Profile({required this.id, this.name, this.phone, this.hatti, this.avatarUrl, this.role = 'listener'});
final String id;
final String? name;
final String? phone;
final String? hatti;
final String? avatarUrl;
final String role;
}
final profileProvider = FutureProvider.autoDispose<Profile?>((ref) async {
final client = Supabase.instance.client;
final user = client.auth.currentUser;
if (user == null) return null;
final r = await client
.from('profiles')
.select('id, display_name, phone, hatti_name, avatar_url, role')
.eq('id', user.id)
.maybeSingle();
if (r == null) return Profile(id: user.id);
return Profile(
id: r['id'] as String,
name: r['display_name'] as String?,
phone: r['phone'] as String?,
hatti: r['hatti_name'] as String?,
avatarUrl: r['avatar_url'] as String?,
role: r['role'] as String? ?? 'listener',
);
});
class ProfileScreen extends ConsumerWidget {
const ProfileScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final profile = ref.watch(profileProvider);
return Scaffold(
appBar: AppBar(title: const Text('Your profile')),
body: profile.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (_, _) => Center(
child: TextButton(onPressed: () => ref.invalidate(profileProvider), child: const Text('Could not load. Retry')),
),
data: (p) => p == null ? const SizedBox.shrink() : _ProfileForm(profile: p),
),
);
}
}
class _ProfileForm extends ConsumerStatefulWidget {
const _ProfileForm({required this.profile});
final Profile profile;
@override
ConsumerState<_ProfileForm> createState() => _ProfileFormState();
}
class _ProfileFormState extends ConsumerState<_ProfileForm> {
late final _name = TextEditingController(text: widget.profile.name);
late final _phone = TextEditingController(text: widget.profile.phone);
late final _hatti = TextEditingController(text: widget.profile.hatti);
late String? _avatarUrl = widget.profile.avatarUrl;
bool _saving = false;
bool _uploading = false;
SupabaseClient get _client => Supabase.instance.client;
@override
void dispose() {
_name.dispose();
_phone.dispose();
_hatti.dispose();
super.dispose();
}
void _toast(String message) => ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message)));
String? _clean(TextEditingController c) => c.text.trim().isEmpty ? null : c.text.trim();
Future<void> _save() async {
setState(() => _saving = true);
try {
await _client.from('profiles').update({
'display_name': _clean(_name),
'phone': _clean(_phone),
'hatti_name': _clean(_hatti),
'updated_at': DateTime.now().toUtc().toIso8601String(),
}).eq('id', widget.profile.id);
ref.invalidate(profileProvider);
_toast('Saved');
} catch (_) {
_toast('Could not save. Check your connection.');
} finally {
if (mounted) setState(() => _saving = false);
}
}
Future<void> _pickPhoto() async {
final file = await ImagePicker().pickImage(source: ImageSource.gallery, maxWidth: 512, maxHeight: 512, imageQuality: 85);
if (file == null) return;
setState(() => _uploading = true);
try {
final path = '${widget.profile.id}/avatar-${DateTime.now().millisecondsSinceEpoch}.jpg';
await _client.storage
.from('avatars')
.uploadBinary(path, await file.readAsBytes(), fileOptions: const FileOptions(contentType: 'image/jpeg'));
final url = _client.storage.from('avatars').getPublicUrl(path);
await _client.from('profiles').update({'avatar_url': url}).eq('id', widget.profile.id);
setState(() => _avatarUrl = url);
ref.invalidate(profileProvider);
} catch (_) {
_toast('Could not upload the photo.');
} finally {
if (mounted) setState(() => _uploading = false);
}
}
Future<void> _changePassword() async {
final password = await showDialog<String>(context: context, builder: (_) => const _PasswordDialog());
if (password == null) return;
try {
await _client.auth.updateUser(UserAttributes(password: password));
_toast('Password updated. Use it next time you sign in.');
} on AuthException catch (e) {
_toast(e.message);
}
}
Future<void> _signOut() async {
await _client.auth.signOut();
if (mounted) context.go('/login');
}
Future<void> _deleteAccount() async {
final sure = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Delete your account?'),
content: const Text(
'Your profile, likes and playlists are removed for good. Songs in the app are not affected. '
'This cannot be undone.',
),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')),
FilledButton(
style: FilledButton.styleFrom(backgroundColor: NkColors.like),
onPressed: () => Navigator.pop(context, true),
child: const Text('Delete'),
),
],
),
);
if (sure != true) return;
try {
await _client.rpc('delete_my_account');
await _client.auth.signOut();
if (mounted) context.go('/welcome');
} catch (_) {
_toast('Could not delete the account. Please try again.');
}
}
@override
Widget build(BuildContext context) {
final email = _client.auth.currentUser?.email ?? '';
final initial = (_name.text.trim().isNotEmpty ? _name.text.trim() : email).characters.first.toUpperCase();
return ListView(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 32),
children: [
Center(
child: Stack(
children: [
CircleAvatar(
radius: 48,
backgroundColor: NkColors.emblemGreen,
backgroundImage: _avatarUrl == null ? null : CachedNetworkImageProvider(_avatarUrl!),
child: _avatarUrl == null
? Text(initial, style: const TextStyle(fontSize: 36, fontWeight: FontWeight.w800, color: NkColors.cream))
: null,
),
Positioned(
right: 0,
bottom: 0,
child: IconButton.filled(
tooltip: 'Change photo',
onPressed: _uploading ? null : _pickPhoto,
icon: _uploading
? const SizedBox.square(dimension: 18, child: CircularProgressIndicator(strokeWidth: 2))
: const Icon(Icons.photo_camera_outlined, size: 20),
),
),
],
),
),
const SizedBox(height: 8),
Center(child: Text(email, style: const TextStyle(color: NkColors.muted))),
if (widget.profile.role != 'listener')
Center(
child: Padding(
padding: const EdgeInsets.only(top: 8),
child: Chip(label: Text(widget.profile.role == 'admin' ? 'Admin' : 'Verified creator')),
),
),
const SizedBox(height: 24),
TextField(
controller: _name,
textCapitalization: TextCapitalization.words,
decoration: const InputDecoration(labelText: 'Name'),
),
const SizedBox(height: 12),
TextField(
controller: _hatti,
textCapitalization: TextCapitalization.words,
decoration: const InputDecoration(labelText: 'Village / hatti (optional)'),
),
const SizedBox(height: 12),
TextField(
controller: _phone,
keyboardType: TextInputType.phone,
decoration: const InputDecoration(
labelText: 'Phone number (optional)',
helperText: 'Only visible to the Nanga Kathai team.',
),
),
const SizedBox(height: 20),
FilledButton(
onPressed: _saving ? null : _save,
child: _saving
? const SizedBox.square(dimension: 22, child: CircularProgressIndicator(strokeWidth: 2.5))
: const Text('Save'),
),
const Divider(height: 40, color: NkColors.outline),
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.lock_outline),
title: const Text('Set or change password'),
onTap: _changePassword,
),
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.settings_outlined),
title: const Text('Settings & help'),
onTap: () => context.push('/settings'),
),
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.logout),
title: const Text('Sign out'),
onTap: _signOut,
),
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.delete_outline, color: NkColors.like),
title: const Text('Delete account', style: TextStyle(color: NkColors.like)),
onTap: _deleteAccount,
),
],
);
}
}
class _PasswordDialog extends StatefulWidget {
const _PasswordDialog();
@override
State<_PasswordDialog> createState() => _PasswordDialogState();
}
class _PasswordDialogState extends State<_PasswordDialog> {
final _a = TextEditingController();
final _b = TextEditingController();
@override
void dispose() {
_a.dispose();
_b.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final ok = _a.text.length >= 8 && _a.text == _b.text;
return AlertDialog(
title: const Text('New password'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: _a,
obscureText: true,
autofillHints: const [AutofillHints.newPassword],
onChanged: (_) => setState(() {}),
decoration: const InputDecoration(labelText: 'New password', helperText: 'At least 8 characters'),
),
const SizedBox(height: 12),
TextField(
controller: _b,
obscureText: true,
onChanged: (_) => setState(() {}),
decoration: InputDecoration(
labelText: 'Repeat password',
errorText: _b.text.isNotEmpty && _a.text != _b.text ? 'Passwords do not match' : null,
),
),
],
),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')),
FilledButton(onPressed: ok ? () => Navigator.pop(context, _a.text) : null, child: const Text('Save')),
],
);
}
}