Initial Flutter booru app

This commit is contained in:
2026-05-23 11:46:52 +03:00
commit 63da741295
187 changed files with 24113 additions and 0 deletions
@@ -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),
),
),
);
}
}