- 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>
325 lines
11 KiB
Dart
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')),
|
|
],
|
|
);
|
|
}
|
|
}
|