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 showConnectSheet(BuildContext context, {SubsonicCredentials? initial}) { return showModalBottomSheet( 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 showServerSheet(BuildContext context) { return showModalBottomSheet( 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) const 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: const 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 _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 _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: const 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: const TextStyle(color: TimbreColors.foreground), decoration: InputDecoration( labelText: label, hintText: hint, labelStyle: const TextStyle(color: TimbreColors.dimmed), hintStyle: const TextStyle(color: TimbreColors.dimmed), enabledBorder: const OutlineInputBorder( borderRadius: BorderRadius.zero, borderSide: BorderSide(color: TimbreColors.border), ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.zero, borderSide: BorderSide(color: Theme.of(context).colorScheme.primary), ), ), ), ); } }