mirror of
https://github.com/austinried/subtracks.git
synced 2025-12-27 00:59:28 +01:00
source settings (add/edit)
This commit is contained in:
parent
f7874bcead
commit
7f6ba4776a
@ -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!));
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@ -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<Widget> 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<int>(
|
||||
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');
|
||||
},
|
||||
),
|
||||
],
|
||||
|
||||
307
lib/app/screens/settings_source_screen.dart
Normal file
307
lib/app/screens/settings_source_screen.dart
Normal 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;
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
12
lib/app/util/padding.dart
Normal file
12
lib/app/util/padding.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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<SubtracksDatabase>
|
||||
with _$SourcesDaoMixin {
|
||||
@ -16,7 +18,7 @@ class SourcesDao extends DatabaseAccessor<SubtracksDatabase>
|
||||
.map((row) => row.read(sources.id));
|
||||
}
|
||||
|
||||
Stream<List<(Source, SubsonicSetting)>> listSources() {
|
||||
Stream<List<SourceSetting>> listSources() {
|
||||
final query = select(sources).join([
|
||||
innerJoin(
|
||||
subsonicSettings,
|
||||
@ -24,18 +26,56 @@ class SourcesDao extends DatabaseAccessor<SubtracksDatabase>
|
||||
),
|
||||
]);
|
||||
|
||||
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<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 {
|
||||
await transaction(() async {
|
||||
await db.managers.sources.update((o) => o(isActive: Value(null)));
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user