source settings (add/edit)

This commit is contained in:
austinried 2025-12-10 20:21:43 +09:00
parent f7874bcead
commit 7f6ba4776a
6 changed files with 411 additions and 43 deletions

View File

@ -8,6 +8,7 @@ import 'screens/playlist_screen.dart';
import 'screens/preload_screen.dart'; import 'screens/preload_screen.dart';
import 'screens/root_shell_screen.dart'; import 'screens/root_shell_screen.dart';
import 'screens/settings_screen.dart'; import 'screens/settings_screen.dart';
import 'screens/settings_source_screen.dart';
final router = GoRouter( final router = GoRouter(
initialLocation: '/preload', initialLocation: '/preload',
@ -50,5 +51,12 @@ final router = GoRouter(
path: '/settings', path: '/settings',
builder: (context, state) => SettingsScreen(), 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!));
},
),
], ],
); );

View File

@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../l10n/generated/app_localizations.dart'; import '../../l10n/generated/app_localizations.dart';
@ -14,15 +15,14 @@ class SettingsScreen extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final l = AppLocalizations.of(context); final l = AppLocalizations.of(context);
final text = TextTheme.of(context); final textTheme = TextTheme.of(context);
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text(l.navigationTabsSettings, style: text.headlineLarge), title: Text(l.navigationTabsSettings, style: textTheme.headlineLarge),
), ),
body: ListView( body: ListView(
children: [ children: [
// const SizedBox(height: 96),
_SectionHeader(l.settingsServersName), _SectionHeader(l.settingsServersName),
const _Sources(), const _Sources(),
// _SectionHeader(l.settingsNetworkName), // _SectionHeader(l.settingsNetworkName),
@ -36,7 +36,9 @@ class SettingsScreen extends HookConsumerWidget {
} }
class _Section extends StatelessWidget { class _Section extends StatelessWidget {
const _Section({required this.children}); const _Section({
required this.children,
});
final List<Widget> children; final List<Widget> children;
@ -46,14 +48,15 @@ class _Section extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
...children, ...children,
const SizedBox(height: 32),
], ],
); );
} }
} }
class _SectionHeader extends StatelessWidget { class _SectionHeader extends StatelessWidget {
const _SectionHeader(this.title); const _SectionHeader(
this.title,
);
final String title; final String title;
@ -61,17 +64,14 @@ class _SectionHeader extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final text = TextTheme.of(context); final text = TextTheme.of(context);
return Column( return Padding(
children: [ padding: EdgeInsetsGeometry.directional(
const SizedBox(height: 16), start: kHorizontalPadding,
SizedBox( end: kHorizontalPadding,
width: double.infinity, top: 32,
child: Padding( bottom: 8,
padding: const EdgeInsets.symmetric(horizontal: kHorizontalPadding), ),
child: Text(title, style: text.headlineMedium), child: Text(title, style: text.headlineMedium),
),
),
],
); );
} }
} }
@ -376,12 +376,12 @@ class _Sources extends HookConsumerWidget {
}, },
child: Column( child: Column(
children: [ children: [
for (final (source, settings) in sources) for (final (:source, :subsonicSetting) in sources)
RadioListTile<int>( RadioListTile<int>(
value: source.id, value: source.id,
title: Text(source.name), title: Text(source.name),
subtitle: Text( subtitle: Text(
settings.address.toString(), subsonicSetting.address.toString(),
maxLines: 1, maxLines: 1,
softWrap: false, softWrap: false,
overflow: TextOverflow.fade, overflow: TextOverflow.fade,
@ -389,7 +389,7 @@ class _Sources extends HookConsumerWidget {
secondary: IconButton( secondary: IconButton(
icon: const Icon(Icons.edit_rounded), icon: const Icon(Icons.edit_rounded),
onPressed: () { 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), icon: const Icon(Icons.add_rounded),
label: Text(l.settingsServersActionsAdd), label: Text(l.settingsServersActionsAdd),
onPressed: () { onPressed: () {
// context.pushRoute(SourceRoute()); context.push('/sources/add');
}, },
), ),
], ],

View File

@ -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<FormState>()).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<String>? 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;
},
),
],
),
);
}
}

View File

@ -11,18 +11,19 @@ ThemeData subtracksTheme([ColorScheme? colorScheme]) {
useMaterial3: true, useMaterial3: true,
); );
final text = theme.textTheme; final text = theme.textTheme.copyWith(
return theme.copyWith( headlineLarge: theme.textTheme.headlineLarge?.copyWith(
textTheme: text.copyWith( fontWeight: FontWeight.w800,
headlineLarge: text.headlineLarge?.copyWith( ),
fontWeight: FontWeight.w800, headlineMedium: theme.textTheme.headlineMedium?.copyWith(
), fontWeight: FontWeight.w700,
headlineMedium: text.headlineMedium?.copyWith( ),
fontWeight: FontWeight.w700, headlineSmall: theme.textTheme.headlineSmall?.copyWith(
), fontWeight: FontWeight.w600,
headlineSmall: text.headlineSmall?.copyWith(
fontWeight: FontWeight.w600,
),
), ),
); );
return theme.copyWith(
textTheme: text,
);
} }

12
lib/app/util/padding.dart Normal file
View File

@ -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);
}
}

View File

@ -4,6 +4,8 @@ import '../database.dart';
part 'sources_dao.g.dart'; part 'sources_dao.g.dart';
typedef SourceSetting = ({Source source, SubsonicSetting subsonicSetting});
@DriftAccessor(include: {'../tables.drift'}) @DriftAccessor(include: {'../tables.drift'})
class SourcesDao extends DatabaseAccessor<SubtracksDatabase> class SourcesDao extends DatabaseAccessor<SubtracksDatabase>
with _$SourcesDaoMixin { with _$SourcesDaoMixin {
@ -16,7 +18,7 @@ class SourcesDao extends DatabaseAccessor<SubtracksDatabase>
.map((row) => row.read(sources.id)); .map((row) => row.read(sources.id));
} }
Stream<List<(Source, SubsonicSetting)>> listSources() { Stream<List<SourceSetting>> listSources() {
final query = select(sources).join([ final query = select(sources).join([
innerJoin( innerJoin(
subsonicSettings, subsonicSettings,
@ -24,18 +26,56 @@ class SourcesDao extends DatabaseAccessor<SubtracksDatabase>
), ),
]); ]);
return query.watch().map( return query
(rows) => rows .map(
.map( (row) => (
(row) => ( source: row.readTable(sources),
row.readTable(sources), subsonicSetting: row.readTable(subsonicSettings),
row.readTable(subsonicSettings), ),
), )
) .watch();
.toList(), }
Selectable<SourceSetting> 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<void> deleteSource(int id) async {
await sources.deleteWhere((f) => f.id.equals(id));
}
Future<void> updateSource(Source source, SubsonicSetting subsonic) async {
await db.transaction(() async {
await sources.update().replace(source);
await subsonicSettings.update().replace(subsonic);
});
}
Future<void> 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<void> setActiveSource(int id) async { Future<void> setActiveSource(int id) async {
await transaction(() async { await transaction(() async {
await db.managers.sources.update((o) => o(isActive: Value(null))); await db.managers.sources.update((o) => o(isActive: Value(null)));