Initial Flutter booru app
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../backend/backend.dart';
|
||||
import '../../../core/utils/result.dart';
|
||||
import '../../../shared/widgets/adaptive_scaffold.dart';
|
||||
import '../../../shared/widgets/error_view.dart';
|
||||
import 'providers_controller.dart';
|
||||
import 'widgets/provider_health_tile.dart';
|
||||
|
||||
class ProviderCheckScreen extends ConsumerWidget {
|
||||
const ProviderCheckScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final providers = ref.watch(providersControllerProvider);
|
||||
final health = ref.watch(providerHealthProvider);
|
||||
return AdaptiveScaffold(
|
||||
title: 'Provider Check',
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: 'Check all',
|
||||
onPressed: () => _checkAll(ref),
|
||||
icon: const Icon(Icons.playlist_add_check_rounded),
|
||||
),
|
||||
],
|
||||
body: providers.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, _) => ErrorView(message: error.toString()),
|
||||
data: (items) => ListView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: items.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = items[index];
|
||||
return ProviderHealthTile(
|
||||
config: item,
|
||||
health: health[item.id],
|
||||
onCheck: () => _checkOne(ref, item.id),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _checkAll(WidgetRef ref) async {
|
||||
final result = await ref.read(providerCheckServiceProvider).checkAll();
|
||||
if (result is Success<List<ProviderHealth>>) {
|
||||
ref.read(providerHealthProvider.notifier).state = {
|
||||
for (final item in result.data) item.providerId: item,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _checkOne(WidgetRef ref, String providerId) async {
|
||||
final result =
|
||||
await ref.read(providerCheckServiceProvider).checkOne(providerId);
|
||||
if (result is Success<ProviderHealth>) {
|
||||
ref.read(providerHealthProvider.notifier).state = {
|
||||
...ref.read(providerHealthProvider),
|
||||
providerId: result.data,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../backend/backend.dart';
|
||||
import 'providers_controller.dart';
|
||||
|
||||
class ProviderFormScreen extends ConsumerStatefulWidget {
|
||||
const ProviderFormScreen({this.initialConfig, super.key});
|
||||
|
||||
final ContentProviderConfig? initialConfig;
|
||||
|
||||
@override
|
||||
ConsumerState<ProviderFormScreen> createState() => _ProviderFormScreenState();
|
||||
}
|
||||
|
||||
class _ProviderFormScreenState extends ConsumerState<ProviderFormScreen> {
|
||||
late final TextEditingController _name;
|
||||
late final TextEditingController _baseUrl;
|
||||
late final TextEditingController _priority;
|
||||
late final TextEditingController _timeout;
|
||||
late final TextEditingController _headers;
|
||||
late final TextEditingController _apiKey;
|
||||
late final TextEditingController _userId;
|
||||
late final TextEditingController _login;
|
||||
late String _apiType;
|
||||
late bool _enabled;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final config = widget.initialConfig;
|
||||
_name = TextEditingController(text: config?.name);
|
||||
_baseUrl = TextEditingController(text: config?.baseUrl);
|
||||
_priority =
|
||||
TextEditingController(text: (config?.priority ?? 10).toString());
|
||||
_timeout =
|
||||
TextEditingController(text: (config?.timeoutSeconds ?? 20).toString());
|
||||
_headers = TextEditingController(
|
||||
text: jsonEncode(_visibleHeaders(config?.customHeaders ?? const {})),
|
||||
);
|
||||
_apiKey =
|
||||
TextEditingController(text: config?.customHeaders['query.api_key']);
|
||||
_userId =
|
||||
TextEditingController(text: config?.customHeaders['query.user_id']);
|
||||
_login = TextEditingController(text: config?.customHeaders['query.login']);
|
||||
_apiType = config?.apiType ?? 'gelbooru';
|
||||
_enabled = config?.enabled ?? true;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_name.dispose();
|
||||
_baseUrl.dispose();
|
||||
_priority.dispose();
|
||||
_timeout.dispose();
|
||||
_headers.dispose();
|
||||
_apiKey.dispose();
|
||||
_userId.dispose();
|
||||
_login.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(
|
||||
widget.initialConfig == null ? 'New provider' : 'Edit provider')),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
TextField(
|
||||
controller: _name,
|
||||
decoration: const InputDecoration(labelText: 'Name')),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _baseUrl,
|
||||
decoration: const InputDecoration(labelText: 'Base URL')),
|
||||
const SizedBox(height: 12),
|
||||
DropdownButtonFormField<String>(
|
||||
initialValue: _apiType,
|
||||
decoration: const InputDecoration(labelText: 'API type'),
|
||||
items: const [
|
||||
DropdownMenuItem(
|
||||
value: 'gelbooru', child: Text('gelbooru-compatible')),
|
||||
DropdownMenuItem(
|
||||
value: 'rule34', child: Text('rule34-compatible')),
|
||||
DropdownMenuItem(
|
||||
value: 'danbooru', child: Text('danbooru-compatible')),
|
||||
DropdownMenuItem(value: 'custom', child: Text('custom')),
|
||||
],
|
||||
onChanged: (value) =>
|
||||
setState(() => _apiType = value ?? 'gelbooru'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SwitchListTile(
|
||||
value: _enabled,
|
||||
title: const Text('Enabled'),
|
||||
onChanged: (value) => setState(() => _enabled = value),
|
||||
),
|
||||
TextField(
|
||||
controller: _priority,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(labelText: 'Priority'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _timeout,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(labelText: 'Timeout seconds'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text('Provider API credentials',
|
||||
style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: _apiKey,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'API key',
|
||||
helperText: 'Gelbooru: api_key, Danbooru: api_key',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _userId,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'User ID',
|
||||
helperText: 'Gelbooru-compatible APIs may require user_id',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _login,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Login',
|
||||
helperText: 'Danbooru-compatible APIs may require login',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _headers,
|
||||
minLines: 3,
|
||||
maxLines: 6,
|
||||
decoration: const InputDecoration(labelText: 'Custom headers JSON'),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
FilledButton.icon(
|
||||
onPressed: _save,
|
||||
icon: const Icon(Icons.save_rounded),
|
||||
label: const Text('Save provider'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
final now = DateTime.now();
|
||||
final id = widget.initialConfig?.id ??
|
||||
_name.text.trim().toLowerCase().replaceAll(RegExp(r'[^a-z0-9]+'), '_');
|
||||
final headersJson =
|
||||
jsonDecode(_headers.text.trim().isEmpty ? '{}' : _headers.text);
|
||||
final customHeaders = Map<String, String>.from(headersJson as Map);
|
||||
void putQuery(String key, String value) {
|
||||
final trimmed = value.trim();
|
||||
if (trimmed.isNotEmpty) customHeaders['query.$key'] = trimmed;
|
||||
}
|
||||
|
||||
putQuery('api_key', _apiKey.text);
|
||||
putQuery('user_id', _userId.text);
|
||||
putQuery('login', _login.text);
|
||||
final config = ContentProviderConfig(
|
||||
id: id,
|
||||
name: _name.text.trim(),
|
||||
baseUrl: _baseUrl.text.trim(),
|
||||
apiType: _apiType,
|
||||
enabled: _enabled,
|
||||
priority: int.tryParse(_priority.text) ?? 10,
|
||||
timeoutSeconds: int.tryParse(_timeout.text) ?? 20,
|
||||
customHeaders: customHeaders,
|
||||
createdAt: widget.initialConfig?.createdAt ?? now,
|
||||
updatedAt: now,
|
||||
);
|
||||
await ref.read(providersControllerProvider.notifier).save(config);
|
||||
if (mounted) context.go('/providers');
|
||||
}
|
||||
|
||||
Map<String, String> _visibleHeaders(Map<String, String> headers) {
|
||||
return Map<String, String>.from(headers)
|
||||
..removeWhere((key, _) => key.startsWith('query.'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../backend/backend.dart';
|
||||
import '../../../core/utils/result.dart';
|
||||
|
||||
final providersControllerProvider =
|
||||
AsyncNotifierProvider<ProvidersController, List<ContentProviderConfig>>(
|
||||
ProvidersController.new,
|
||||
);
|
||||
|
||||
class ProvidersController extends AsyncNotifier<List<ContentProviderConfig>> {
|
||||
@override
|
||||
Future<List<ContentProviderConfig>> build() => _load();
|
||||
|
||||
Future<void> toggle(String id, bool enabled) async {
|
||||
await ref.read(providerManagerProvider).enableProvider(id, enabled);
|
||||
state = AsyncData(await _load());
|
||||
}
|
||||
|
||||
Future<void> save(ContentProviderConfig config) async {
|
||||
await ref.read(providerManagerProvider).addCustomProvider(config);
|
||||
state = AsyncData(await _load());
|
||||
}
|
||||
|
||||
Future<void> delete(String id) async {
|
||||
await ref.read(providerManagerProvider).deleteProvider(id);
|
||||
state = AsyncData(await _load());
|
||||
}
|
||||
|
||||
Future<List<ContentProviderConfig>> _load() async {
|
||||
final result =
|
||||
await ref.read(providerManagerProvider).loadConfigs(enabledOnly: false);
|
||||
return result is Success<List<ContentProviderConfig>>
|
||||
? result.data
|
||||
: const [];
|
||||
}
|
||||
}
|
||||
|
||||
final providerHealthProvider =
|
||||
StateProvider<Map<String, ProviderHealth>>((ref) => {});
|
||||
@@ -0,0 +1,63 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../shared/widgets/adaptive_scaffold.dart';
|
||||
import '../../../shared/widgets/confirm_dialog.dart';
|
||||
import '../../../shared/widgets/error_view.dart';
|
||||
import 'providers_controller.dart';
|
||||
import 'widgets/provider_card.dart';
|
||||
|
||||
class ProvidersScreen extends ConsumerWidget {
|
||||
const ProvidersScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final providers = ref.watch(providersControllerProvider);
|
||||
return AdaptiveScaffold(
|
||||
title: 'Providers',
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: 'Check providers',
|
||||
onPressed: () => context.go('/providers/check'),
|
||||
icon: const Icon(Icons.network_check_rounded),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Add provider',
|
||||
onPressed: () => context.go('/providers/new'),
|
||||
icon: const Icon(Icons.add_rounded),
|
||||
),
|
||||
],
|
||||
body: providers.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, _) => ErrorView(message: error.toString()),
|
||||
data: (items) => ListView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: items.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = items[index];
|
||||
return ProviderCard(
|
||||
config: item,
|
||||
onToggle: (enabled) => ref
|
||||
.read(providersControllerProvider.notifier)
|
||||
.toggle(item.id, enabled),
|
||||
onEdit: () => context.go('/providers/new', extra: item),
|
||||
onDelete: () async {
|
||||
final ok = await showConfirmDialog(
|
||||
context,
|
||||
title: 'Delete provider',
|
||||
message: 'Remove ${item.name}?',
|
||||
);
|
||||
if (ok) {
|
||||
await ref
|
||||
.read(providersControllerProvider.notifier)
|
||||
.delete(item.id);
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../backend/backend.dart';
|
||||
|
||||
class ProviderCard extends StatelessWidget {
|
||||
const ProviderCard({
|
||||
required this.config,
|
||||
required this.onToggle,
|
||||
required this.onEdit,
|
||||
required this.onDelete,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final ContentProviderConfig config;
|
||||
final ValueChanged<bool> onToggle;
|
||||
final VoidCallback onEdit;
|
||||
final VoidCallback onDelete;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
child: ListTile(
|
||||
leading: Switch(value: config.enabled, onChanged: onToggle),
|
||||
title: Text(config.name),
|
||||
subtitle: Text(
|
||||
'${config.apiType} · ${config.baseUrl}\nPriority ${config.priority}'),
|
||||
isThreeLine: true,
|
||||
trailing: Wrap(
|
||||
spacing: 4,
|
||||
children: [
|
||||
IconButton(
|
||||
tooltip: 'Edit',
|
||||
onPressed: onEdit,
|
||||
icon: const Icon(Icons.edit_rounded),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Delete',
|
||||
onPressed: onDelete,
|
||||
icon: const Icon(Icons.delete_rounded),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../backend/backend.dart';
|
||||
import '../../../../shared/widgets/provider_status_badge.dart';
|
||||
|
||||
class ProviderHealthTile extends StatelessWidget {
|
||||
const ProviderHealthTile({
|
||||
required this.config,
|
||||
required this.health,
|
||||
required this.onCheck,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final ContentProviderConfig config;
|
||||
final ProviderHealth? health;
|
||||
final VoidCallback onCheck;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
child: ListTile(
|
||||
title: Text(config.name),
|
||||
subtitle: Text(
|
||||
[
|
||||
if (health != null) 'Ping ${health!.pingMs} ms',
|
||||
if (health?.lastCheckedAt != null)
|
||||
'Checked ${health!.lastCheckedAt}',
|
||||
if (health?.errorMessage != null) health!.errorMessage!,
|
||||
].join('\n'),
|
||||
),
|
||||
leading: ProviderStatusBadge(health: health),
|
||||
trailing: IconButton(
|
||||
tooltip: 'Check',
|
||||
onPressed: onCheck,
|
||||
icon: const Icon(Icons.network_check_rounded),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user