From 7f6ba4776a7e68f498c80187579a58554f7ff3b4 Mon Sep 17 00:00:00 2001 From: austinried <4966622+austinried@users.noreply.github.com> Date: Wed, 10 Dec 2025 20:21:43 +0900 Subject: [PATCH] source settings (add/edit) --- lib/app/router.dart | 8 + lib/app/screens/settings_screen.dart | 42 +-- lib/app/screens/settings_source_screen.dart | 307 ++++++++++++++++++++ lib/app/ui/theme.dart | 25 +- lib/app/util/padding.dart | 12 + lib/database/dao/sources_dao.dart | 60 +++- 6 files changed, 411 insertions(+), 43 deletions(-) create mode 100644 lib/app/screens/settings_source_screen.dart create mode 100644 lib/app/util/padding.dart diff --git a/lib/app/router.dart b/lib/app/router.dart index d95a78a..cf92d55 100644 --- a/lib/app/router.dart +++ b/lib/app/router.dart @@ -8,6 +8,7 @@ import 'screens/playlist_screen.dart'; import 'screens/preload_screen.dart'; import 'screens/root_shell_screen.dart'; import 'screens/settings_screen.dart'; +import 'screens/settings_source_screen.dart'; final router = GoRouter( initialLocation: '/preload', @@ -50,5 +51,12 @@ final router = GoRouter( path: '/settings', builder: (context, state) => SettingsScreen(), ), + GoRoute( + path: '/sources/:id', + builder: (context, state) { + final id = state.pathParameters['id']; + return SettingsSourceScreen(id: id == 'add' ? null : int.parse(id!)); + }, + ), ], ); diff --git a/lib/app/screens/settings_screen.dart b/lib/app/screens/settings_screen.dart index cf611f7..54b5807 100644 --- a/lib/app/screens/settings_screen.dart +++ b/lib/app/screens/settings_screen.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../l10n/generated/app_localizations.dart'; @@ -14,15 +15,14 @@ class SettingsScreen extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final l = AppLocalizations.of(context); - final text = TextTheme.of(context); + final textTheme = TextTheme.of(context); return Scaffold( appBar: AppBar( - title: Text(l.navigationTabsSettings, style: text.headlineLarge), + title: Text(l.navigationTabsSettings, style: textTheme.headlineLarge), ), body: ListView( children: [ - // const SizedBox(height: 96), _SectionHeader(l.settingsServersName), const _Sources(), // _SectionHeader(l.settingsNetworkName), @@ -36,7 +36,9 @@ class SettingsScreen extends HookConsumerWidget { } class _Section extends StatelessWidget { - const _Section({required this.children}); + const _Section({ + required this.children, + }); final List children; @@ -46,14 +48,15 @@ class _Section extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ ...children, - const SizedBox(height: 32), ], ); } } class _SectionHeader extends StatelessWidget { - const _SectionHeader(this.title); + const _SectionHeader( + this.title, + ); final String title; @@ -61,17 +64,14 @@ class _SectionHeader extends StatelessWidget { Widget build(BuildContext context) { final text = TextTheme.of(context); - return Column( - children: [ - const SizedBox(height: 16), - SizedBox( - width: double.infinity, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: kHorizontalPadding), - child: Text(title, style: text.headlineMedium), - ), - ), - ], + return Padding( + padding: EdgeInsetsGeometry.directional( + start: kHorizontalPadding, + end: kHorizontalPadding, + top: 32, + bottom: 8, + ), + child: Text(title, style: text.headlineMedium), ); } } @@ -376,12 +376,12 @@ class _Sources extends HookConsumerWidget { }, child: Column( children: [ - for (final (source, settings) in sources) + for (final (:source, :subsonicSetting) in sources) RadioListTile( value: source.id, title: Text(source.name), subtitle: Text( - settings.address.toString(), + subsonicSetting.address.toString(), maxLines: 1, softWrap: false, overflow: TextOverflow.fade, @@ -389,7 +389,7 @@ class _Sources extends HookConsumerWidget { secondary: IconButton( icon: const Icon(Icons.edit_rounded), onPressed: () { - // context.pushRoute(SourceRoute(id: source.id)); + context.push('/sources/${source.id}'); }, ), ), @@ -404,7 +404,7 @@ class _Sources extends HookConsumerWidget { icon: const Icon(Icons.add_rounded), label: Text(l.settingsServersActionsAdd), onPressed: () { - // context.pushRoute(SourceRoute()); + context.push('/sources/add'); }, ), ], diff --git a/lib/app/screens/settings_source_screen.dart b/lib/app/screens/settings_source_screen.dart new file mode 100644 index 0000000..6c381fa --- /dev/null +++ b/lib/app/screens/settings_source_screen.dart @@ -0,0 +1,307 @@ +import 'package:drift/drift.dart' show Value; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../../database/dao/sources_dao.dart'; +import '../../database/database.dart'; +import '../../l10n/generated/app_localizations.dart'; +import '../state/database.dart'; +import '../util/padding.dart'; + +class SettingsSourceScreen extends HookConsumerWidget { + const SettingsSourceScreen({ + super.key, + this.id, + }); + + final int? id; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final db = ref.watch(databaseProvider); + + final getSource = useMemoized( + () async => id != null + ? (result: await db.sourcesDao.getSource(id!).getSingle()) + : await Future.value((result: null)), + [id], + ); + final sourceResult = useFuture(getSource).data; + + if (sourceResult == null) { + return Scaffold(); + } + + return _SettingsSourceScreen(source: sourceResult.result); + } +} + +class _SettingsSourceScreen extends HookConsumerWidget { + const _SettingsSourceScreen({ + required this.source, + }); + + final SourceSetting? source; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l = AppLocalizations.of(context); + final textTheme = TextTheme.of(context); + final colorScheme = ColorScheme.of(context); + + final form = useState(GlobalKey()).value; + final isSaving = useState(false); + final isDeleting = useState(false); + + final nameController = useTextEditingController(text: source?.source.name); + final addressController = useTextEditingController( + text: source?.subsonicSetting.address.toString(), + ); + final usernameController = useTextEditingController( + text: source?.subsonicSetting.username, + ); + final passwordController = useTextEditingController( + text: source?.subsonicSetting.password, + ); + final forcePlaintextPassword = useState( + !(source?.subsonicSetting.useTokenAuth ?? true), + ); + + final name = LabeledTextField( + label: l.settingsServersFieldsName, + controller: nameController, + required: true, + ); + + final address = LabeledTextField( + label: l.settingsServersFieldsAddress, + controller: addressController, + keyboardType: TextInputType.url, + autofillHints: const [AutofillHints.url], + required: true, + validator: (value, label) { + final uri = Uri.tryParse(value ?? ''); + if (uri?.isAbsolute != true || uri?.host.isNotEmpty != true) { + return '$label must be a valid URL'; + } + + return null; + }, + ); + + final username = LabeledTextField( + label: l.settingsServersFieldsUsername, + controller: usernameController, + autofillHints: const [AutofillHints.username], + required: true, + ); + + final password = LabeledTextField( + label: l.settingsServersFieldsPassword, + controller: passwordController, + autofillHints: const [AutofillHints.password], + obscureText: true, + required: true, + ); + + final forcePlaintextSwitch = SwitchListTile( + value: forcePlaintextPassword.value, + title: Text(l.settingsServersOptionsForcePlaintextPasswordTitle), + subtitle: forcePlaintextPassword.value + ? Text(l.settingsServersOptionsForcePlaintextPasswordDescriptionOn) + : Text(l.settingsServersOptionsForcePlaintextPasswordDescriptionOff), + onChanged: (value) => forcePlaintextPassword.value = value, + ); + + return PopScope( + canPop: !isSaving.value && !isDeleting.value, + child: Scaffold( + appBar: AppBar( + title: Text( + source == null + ? l.settingsServersActionsAdd + : l.settingsServersActionsEdit, + style: textTheme.headlineLarge, + ), + ), + floatingActionButton: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + if (source != null && source?.source.isActive != true) + FloatingActionButton( + backgroundColor: colorScheme.tertiaryContainer, + foregroundColor: colorScheme.onTertiaryContainer, + onPressed: !isSaving.value && !isDeleting.value + ? () async { + try { + isDeleting.value = true; + await ref + .read(databaseProvider) + .sourcesDao + .deleteSource(source!.source.id); + } finally { + isDeleting.value = false; + } + + if (context.mounted) { + context.pop(); + } + } + : null, + child: isDeleting.value + ? SizedBox( + height: 24, + width: 24, + child: CircularProgressIndicator( + color: colorScheme.onTertiaryContainer, + ), + ) + : const Icon(Icons.delete_forever_rounded), + ), + const SizedBox(width: 12), + FloatingActionButton.extended( + heroTag: null, + icon: isSaving.value + ? const SizedBox( + height: 24, + width: 24, + child: CircularProgressIndicator(), + ) + : const Icon(Icons.save_rounded), + label: Text(l.settingsServersActionsSave), + onPressed: !isSaving.value && !isDeleting.value + ? () async { + if (!form.currentState!.validate()) { + return; + } + + var error = false; + try { + isSaving.value = true; + if (source != null) { + await ref + .read(databaseProvider) + .sourcesDao + .updateSource( + source!.source.copyWith( + name: nameController.text, + ), + source!.subsonicSetting.copyWith( + address: Uri.parse(addressController.text), + username: usernameController.text, + password: passwordController.text, + useTokenAuth: !forcePlaintextPassword.value, + ), + ); + } else { + await ref + .read(databaseProvider) + .sourcesDao + .createSource( + SourcesCompanion.insert( + name: nameController.text, + ), + SubsonicSettingsCompanion.insert( + address: Uri.parse(addressController.text), + username: usernameController.text, + password: passwordController.text, + useTokenAuth: Value( + !forcePlaintextPassword.value, + ), + ), + ); + } + } catch (e, _) { + // showErrorSnackbar(context, e.toString()); + // log.severe('Saving source', e, st); + error = true; + } finally { + isSaving.value = false; + } + + if (!error && context.mounted) { + context.pop(); + } + } + : null, + ), + ], + ), + body: Form( + key: form, + child: AutofillGroup( + child: ListView( + children: [ + name, + address, + username, + password, + const SizedBox(height: 24), + forcePlaintextSwitch, + const FabPadding(), + ], + ), + ), + ), + ), + ); + } +} + +class LabeledTextField extends HookConsumerWidget { + const LabeledTextField({ + super.key, + required this.label, + required this.controller, + this.obscureText = false, + this.keyboardType, + this.validator, + this.autofillHints, + this.required = false, + }); + + final String label; + final TextEditingController controller; + final bool obscureText; + final bool required; + final TextInputType? keyboardType; + final Iterable? autofillHints; + final String? Function(String? value, String label)? validator; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final textTheme = TextTheme.of(context); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 32), + Text(label, style: textTheme.titleMedium), + TextFormField( + controller: controller, + obscureText: obscureText, + keyboardType: keyboardType, + autofillHints: autofillHints, + validator: (value) { + if (required) { + if (value == null || value.isEmpty) { + return '$label is required'; + } + } + + if (validator != null) { + return validator!(value, label); + } + + return null; + }, + ), + ], + ), + ); + } +} diff --git a/lib/app/ui/theme.dart b/lib/app/ui/theme.dart index 2fc42d0..3df213b 100644 --- a/lib/app/ui/theme.dart +++ b/lib/app/ui/theme.dart @@ -11,18 +11,19 @@ ThemeData subtracksTheme([ColorScheme? colorScheme]) { useMaterial3: true, ); - final text = theme.textTheme; - return theme.copyWith( - textTheme: text.copyWith( - headlineLarge: text.headlineLarge?.copyWith( - fontWeight: FontWeight.w800, - ), - headlineMedium: text.headlineMedium?.copyWith( - fontWeight: FontWeight.w700, - ), - headlineSmall: text.headlineSmall?.copyWith( - fontWeight: FontWeight.w600, - ), + final text = theme.textTheme.copyWith( + headlineLarge: theme.textTheme.headlineLarge?.copyWith( + fontWeight: FontWeight.w800, + ), + headlineMedium: theme.textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + headlineSmall: theme.textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.w600, ), ); + + return theme.copyWith( + textTheme: text, + ); } diff --git a/lib/app/util/padding.dart b/lib/app/util/padding.dart new file mode 100644 index 0000000..ff959e8 --- /dev/null +++ b/lib/app/util/padding.dart @@ -0,0 +1,12 @@ +import 'package:flutter/material.dart'; + +class FabPadding extends StatelessWidget { + const FabPadding({ + super.key, + }); + + @override + Widget build(BuildContext context) { + return const SizedBox(height: 86); + } +} diff --git a/lib/database/dao/sources_dao.dart b/lib/database/dao/sources_dao.dart index 1c36a05..370b25f 100644 --- a/lib/database/dao/sources_dao.dart +++ b/lib/database/dao/sources_dao.dart @@ -4,6 +4,8 @@ import '../database.dart'; part 'sources_dao.g.dart'; +typedef SourceSetting = ({Source source, SubsonicSetting subsonicSetting}); + @DriftAccessor(include: {'../tables.drift'}) class SourcesDao extends DatabaseAccessor with _$SourcesDaoMixin { @@ -16,7 +18,7 @@ class SourcesDao extends DatabaseAccessor .map((row) => row.read(sources.id)); } - Stream> listSources() { + Stream> listSources() { final query = select(sources).join([ innerJoin( subsonicSettings, @@ -24,18 +26,56 @@ class SourcesDao extends DatabaseAccessor ), ]); - return query.watch().map( - (rows) => rows - .map( - (row) => ( - row.readTable(sources), - row.readTable(subsonicSettings), - ), - ) - .toList(), + return query + .map( + (row) => ( + source: row.readTable(sources), + subsonicSetting: row.readTable(subsonicSettings), + ), + ) + .watch(); + } + + Selectable getSource(int id) { + final query = select(sources).join([ + innerJoin( + subsonicSettings, + sources.id.equalsExp(subsonicSettings.sourceId), + ), + ])..where(sources.id.equals(id)); + + return query.map( + (row) => ( + source: row.readTable(sources), + subsonicSetting: row.readTable(subsonicSettings), + ), ); } + Future deleteSource(int id) async { + await sources.deleteWhere((f) => f.id.equals(id)); + } + + Future updateSource(Source source, SubsonicSetting subsonic) async { + await db.transaction(() async { + await sources.update().replace(source); + await subsonicSettings.update().replace(subsonic); + }); + } + + Future createSource( + SourcesCompanion source, + SubsonicSettingsCompanion subsonic, + ) async { + await db.transaction(() async { + final sourceId = await sources.insertOnConflictUpdate(source); + + await subsonicSettings.insertOnConflictUpdate( + subsonic.copyWith(sourceId: Value(sourceId)), + ); + }); + } + Future setActiveSource(int id) async { await transaction(() async { await db.managers.sources.update((o) => o(isActive: Value(null)));