mobile-music/lib/screens/connect_sheet.dart
2026-08-05 21:57:06 -04:00

335 lines
11 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../state/providers.dart';
import '../subsonic/credentials.dart';
import '../theme/tokens.dart';
/// Open the add/edit-server sheet. Pass [initial] to edit an existing saved
/// server (its password is reused if the field is left blank).
Future<void> showConnectSheet(BuildContext context,
{SubsonicCredentials? initial}) {
return showModalBottomSheet<void>(
context: context,
backgroundColor: TimbreColors.background,
isScrollControlled: true,
builder: (_) => _ConnectSheet(initial: initial),
);
}
/// Open the server switcher: pick an active server, or add a new one.
Future<void> showServerSheet(BuildContext context) {
return showModalBottomSheet<void>(
context: context,
backgroundColor: TimbreColors.background,
isScrollControlled: true,
builder: (_) => const _ServerSheet(),
);
}
/// Lists saved servers (tap to switch) with an "Add server" action.
class _ServerSheet extends ConsumerWidget {
const _ServerSheet();
@override
Widget build(BuildContext context, WidgetRef ref) {
final conn = ref.watch(connectionProvider);
final accent = Theme.of(context).colorScheme.primary;
return SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: TimbreSpacing.lg),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: TimbreSpacing.xl),
child: Text('Servers',
style: TextStyle(color: accent, fontWeight: FontWeight.w700)),
),
const SizedBox(height: TimbreSpacing.md),
if (conn.servers.isEmpty)
Padding(
padding: EdgeInsets.symmetric(
horizontal: TimbreSpacing.xl, vertical: TimbreSpacing.md),
child: Text('No servers saved yet.',
style: TextStyle(color: TimbreColors.dimmed)),
)
else
for (final s in conn.servers)
_ServerRow(
creds: s,
active: s.id == conn.activeId,
onTap: () {
// Grab the notifier before popping — this widget's `ref` is
// disposed with the sheet.
final notifier = ref.read(connectionProvider.notifier);
Navigator.of(context).pop();
notifier.switchTo(s.id);
},
),
const SizedBox(height: TimbreSpacing.sm),
InkWell(
onTap: () {
// Capture the navigator's (still-mounted) context before popping
// this sheet, so the next sheet has a valid overlay to show in.
final nav = Navigator.of(context);
nav.pop();
showConnectSheet(nav.context);
},
child: Padding(
padding: EdgeInsets.symmetric(
horizontal: TimbreSpacing.xl, vertical: TimbreSpacing.md),
child: Row(
children: [
Icon(Icons.add, size: 16, color: TimbreColors.foreground),
SizedBox(width: TimbreSpacing.sm),
Text('Add server',
style: TextStyle(color: TimbreColors.foreground)),
],
),
),
),
],
),
),
);
}
}
class _ServerRow extends StatelessWidget {
const _ServerRow({
required this.creds,
required this.active,
required this.onTap,
});
final SubsonicCredentials creds;
final bool active;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final accent = Theme.of(context).colorScheme.primary;
return InkWell(
onTap: onTap,
child: Container(
constraints:
const BoxConstraints(minHeight: TimbreSpacing.minTouchTarget),
padding: const EdgeInsets.symmetric(horizontal: TimbreSpacing.xl),
child: Row(
children: [
Text(active ? '● ' : '○ ',
style: TextStyle(
color: active ? accent : TimbreColors.dimmed)),
Expanded(
child: Text(
creds.display,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: TimbreColors.foreground,
fontWeight: active ? FontWeight.w700 : FontWeight.w400,
),
),
),
],
),
),
);
}
}
class _ConnectSheet extends ConsumerStatefulWidget {
const _ConnectSheet({this.initial});
final SubsonicCredentials? initial;
@override
ConsumerState<_ConnectSheet> createState() => _ConnectSheetState();
}
class _ConnectSheetState extends ConsumerState<_ConnectSheet> {
final _url = TextEditingController();
final _user = TextEditingController();
final _pass = TextEditingController();
final _alias = TextEditingController();
bool _busy = false;
String? _localError;
bool get _isEdit => widget.initial != null;
@override
void initState() {
super.initState();
final initial = widget.initial;
if (initial != null) {
_url.text = initial.url;
_user.text = initial.username;
_alias.text = initial.alias ?? '';
}
}
@override
void dispose() {
_url.dispose();
_user.dispose();
_pass.dispose();
_alias.dispose();
super.dispose();
}
/// Build credentials from the form. Returns null (and sets [_localError]) if
/// required fields are missing. On edit, a blank password reuses the saved one.
SubsonicCredentials? _readForm() {
final url = _url.text.trim();
final user = _user.text.trim();
if (url.isEmpty || user.isEmpty) {
setState(() => _localError = 'Server URL and username are required.');
return null;
}
final password =
_pass.text.isEmpty && _isEdit ? widget.initial!.password : _pass.text;
if (password.isEmpty) {
setState(() => _localError = 'Password is required.');
return null;
}
final alias = _alias.text.trim();
return SubsonicCredentials(
url: url,
username: user,
password: password,
alias: alias.isEmpty ? null : alias,
);
}
Future<void> _connect() async {
final creds = _readForm();
if (creds == null) return;
setState(() {
_busy = true;
_localError = null;
});
final ok = await ref.read(connectionProvider.notifier).connect(creds);
if (!mounted) return;
setState(() => _busy = false);
if (ok) Navigator.of(context).pop();
}
Future<void> _save() async {
final creds = _readForm();
if (creds == null) return;
setState(() {
_busy = true;
_localError = null;
});
await ref.read(connectionProvider.notifier).saveServer(creds);
if (!mounted) return;
setState(() => _busy = false);
Navigator.of(context).pop();
}
@override
Widget build(BuildContext context) {
final conn = ref.watch(connectionProvider);
final accent = Theme.of(context).colorScheme.primary;
final error = _localError ??
(conn.status == ConnStatus.error ? conn.error : null);
return Padding(
padding: EdgeInsets.only(
left: TimbreSpacing.xl,
right: TimbreSpacing.xl,
top: TimbreSpacing.xl,
bottom: MediaQuery.of(context).viewInsets.bottom + TimbreSpacing.xl,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(_isEdit ? 'Edit server' : 'Add server',
style: TextStyle(color: accent, fontWeight: FontWeight.w700)),
const SizedBox(height: TimbreSpacing.lg),
_field(_url, 'Server URL', 'https://navidrome.example.com',
keyboard: TextInputType.url),
_field(_user, 'Username', 'you'),
_field(_pass, 'Password',
_isEdit ? '•••••••• (unchanged)' : '••••••••',
obscure: true),
_field(_alias, 'Label (optional)', 'Home server'),
if (error != null) ...[
const SizedBox(height: TimbreSpacing.md),
Text(error, style: const TextStyle(color: Color(0xFFE06C75))),
],
const SizedBox(height: TimbreSpacing.lg),
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: _busy ? null : _save,
style: OutlinedButton.styleFrom(
foregroundColor: TimbreColors.foreground,
side: BorderSide(color: TimbreColors.border),
shape: const RoundedRectangleBorder(),
),
child: const Text('Save'),
),
),
const SizedBox(width: TimbreSpacing.md),
Expanded(
child: FilledButton(
onPressed: _busy ? null : _connect,
style: FilledButton.styleFrom(
backgroundColor: accent,
foregroundColor: TimbreColors.background,
shape: const RoundedRectangleBorder(),
),
child: _busy
? const SizedBox(
height: 16,
width: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Connect'),
),
),
],
),
],
),
);
}
Widget _field(
TextEditingController c,
String label,
String hint, {
bool obscure = false,
TextInputType? keyboard,
}) {
return Padding(
padding: const EdgeInsets.only(bottom: TimbreSpacing.md),
child: TextField(
controller: c,
obscureText: obscure,
keyboardType: keyboard,
autocorrect: false,
enableSuggestions: false,
style: TextStyle(color: TimbreColors.foreground),
decoration: InputDecoration(
labelText: label,
hintText: hint,
labelStyle: TextStyle(color: TimbreColors.dimmed),
hintStyle: TextStyle(color: TimbreColors.dimmed),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.zero,
borderSide: BorderSide(color: TimbreColors.border),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.zero,
borderSide: BorderSide(color: Theme.of(context).colorScheme.primary),
),
),
),
);
}
}