updates
This commit is contained in:
parent
981b4836f9
commit
ed910748cb
34 changed files with 2054 additions and 153 deletions
|
|
@ -5,18 +5,145 @@ import '../state/providers.dart';
|
|||
import '../subsonic/credentials.dart';
|
||||
import '../theme/tokens.dart';
|
||||
|
||||
/// Open the connect-server sheet.
|
||||
Future<void> showConnectSheet(BuildContext context) {
|
||||
/// 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: (_) => const _ConnectSheet(),
|
||||
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)
|
||||
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();
|
||||
const _ConnectSheet({this.initial});
|
||||
|
||||
final SubsonicCredentials? initial;
|
||||
|
||||
@override
|
||||
ConsumerState<_ConnectSheet> createState() => _ConnectSheetState();
|
||||
|
|
@ -26,15 +153,20 @@ 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 creds = ref.read(connectionProvider).credentials;
|
||||
if (creds != null) {
|
||||
_url.text = creds.url;
|
||||
_user.text = creds.username;
|
||||
final initial = widget.initial;
|
||||
if (initial != null) {
|
||||
_url.text = initial.url;
|
||||
_user.text = initial.username;
|
||||
_alias.text = initial.alias ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -43,27 +175,66 @@ class _ConnectSheetState extends ConsumerState<_ConnectSheet> {
|
|||
_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 {
|
||||
setState(() => _busy = true);
|
||||
final ok = await ref.read(connectionProvider.notifier).connect(
|
||||
SubsonicCredentials(
|
||||
url: _url.text.trim(),
|
||||
username: _user.text.trim(),
|
||||
password: _pass.text,
|
||||
),
|
||||
);
|
||||
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,
|
||||
|
|
@ -75,33 +246,53 @@ class _ConnectSheetState extends ConsumerState<_ConnectSheet> {
|
|||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text('Connect to server',
|
||||
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', '••••••••', obscure: true),
|
||||
if (conn.status == ConnStatus.error && conn.error != null) ...[
|
||||
_field(_pass, 'Password',
|
||||
_isEdit ? '•••••••• (unchanged)' : '••••••••',
|
||||
obscure: true),
|
||||
_field(_alias, 'Label (optional)', 'Home server'),
|
||||
if (error != null) ...[
|
||||
const SizedBox(height: TimbreSpacing.md),
|
||||
Text(conn.error!,
|
||||
style: const TextStyle(color: Color(0xFFE06C75))),
|
||||
Text(error, style: const TextStyle(color: Color(0xFFE06C75))),
|
||||
],
|
||||
const SizedBox(height: TimbreSpacing.lg),
|
||||
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'),
|
||||
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'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue