diff --git a/README.md b/README.md
index 80f06f7..34260fa 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,7 @@
-
+
diff --git a/lib/app/app_version.dart b/lib/app/app_version.dart
index 6fcfe09..2bc1374 100644
--- a/lib/app/app_version.dart
+++ b/lib/app/app_version.dart
@@ -1,3 +1,3 @@
-const appDisplayVersion = '3.1.2';
-const appBuildNumber = 63;
+const appDisplayVersion = '3.1.3';
+const appBuildNumber = 64;
const appReleaseChannel = 'Stable';
diff --git a/lib/app/changelog.dart b/lib/app/changelog.dart
index 7ceb681..47d77aa 100644
--- a/lib/app/changelog.dart
+++ b/lib/app/changelog.dart
@@ -1,4 +1,14 @@
const lunarisChangelog = [
+ LunarisChange(
+ version: '3.1.3',
+ title: 'iPhone Liquid Glass — Источники, Проверка сети и Навигация назад по вкладкам',
+ bullets: [
+ 'Возврат назад по вкладкам (Tab Back Navigation): реализован стек истории переходов между разделами в AppShell — теперь при нажатии кнопки «Назад» или системного жеста происходит возврат к ранее открытым вкладкам и подразделам вместо случайного закрытия приложения.',
+ 'Liquid Glass редизайн списка источников (Providers): стеклянные карточки провайдеров с красивыми сквиркл-иконками движков (Gelbooru, Rule34, Danbooru, e621, Yande.re, Paheal, Kemono), переключателями активности, инфо-бейджами приоритета и зеркальными кнопками управления.',
+ 'Liquid Glass редизайн редактирования и создания источников (Edit / New Provider): секционные стеклянные карточки с градиентными бейджами, полоса быстрых пресетов для мгновенного заполнения URL и типа API, безопасный ввод API-ключей и наглядные подсказки.',
+ 'Liquid Glass редизайн проверки состояния источников (Provider Check): карточка общих метрик сети (онлайн/офлайн, средний пинг), раскрывающиеся аккордеоны детальной диагностики, живые светодиодные LED-индикаторы статуса и кнопка одновременной проверки всех провайдеров.',
+ ],
+ ),
LunarisChange(
version: '3.1.2',
title: 'Оптимизация производительности: устранение лагов при скролле к тегам',
diff --git a/lib/features/providers/presentation/provider_check_screen.dart b/lib/features/providers/presentation/provider_check_screen.dart
index 595e595..0083a46 100644
--- a/lib/features/providers/presentation/provider_check_screen.dart
+++ b/lib/features/providers/presentation/provider_check_screen.dart
@@ -1,56 +1,334 @@
+import 'dart:ui';
+
import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:go_router/go_router.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 {
+class ProviderCheckScreen extends ConsumerStatefulWidget {
const ProviderCheckScreen({super.key});
@override
- Widget build(BuildContext context, WidgetRef ref) {
+ ConsumerState createState() =>
+ _ProviderCheckScreenState();
+}
+
+class _ProviderCheckScreenState extends ConsumerState {
+ bool _isCheckingAll = false;
+
+ @override
+ Widget build(BuildContext context) {
+ final theme = Theme.of(context);
+ final isDark = theme.brightness == Brightness.dark;
+ final isRu = Localizations.maybeLocaleOf(context)?.languageCode == 'ru';
final providers = ref.watch(providersControllerProvider);
final health = ref.watch(providerHealthProvider);
final diagnostics = ref.watch(providerDiagnosticsProvider).value ?? {};
- return AdaptiveScaffold(
- title: 'Provider Check',
- actions: [
- IconButton(
- tooltip: 'Check all',
- onPressed: () => _checkAll(ref),
- icon: const Icon(Icons.playlist_add_check_rounded),
+ final bottomInset = MediaQuery.paddingOf(context).bottom;
+
+ return Scaffold(
+ extendBodyBehindAppBar: true,
+ appBar: PreferredSize(
+ preferredSize: const Size.fromHeight(60),
+ child: ClipRRect(
+ child: BackdropFilter(
+ filter: ImageFilter.blur(sigmaX: 18, sigmaY: 18),
+ child: AppBar(
+ backgroundColor: isDark
+ ? theme.colorScheme.surface.withValues(alpha: 0.75)
+ : Colors.white.withValues(alpha: 0.85),
+ surfaceTintColor: Colors.transparent,
+ elevation: 0,
+ leading: IconButton(
+ icon: const Icon(Icons.arrow_back_ios_new_rounded, size: 20),
+ onPressed: () {
+ if (Navigator.of(context).canPop()) {
+ Navigator.of(context).pop();
+ } else {
+ context.go('/providers');
+ }
+ },
+ tooltip: isRu ? 'Назад' : 'Back',
+ ),
+ title: Text(
+ isRu ? 'Проверка источников' : 'Provider Check',
+ style: theme.textTheme.titleMedium?.copyWith(
+ fontWeight: FontWeight.w700,
+ letterSpacing: -0.2,
+ ),
+ ),
+ actions: [
+ Padding(
+ padding: const EdgeInsets.only(right: 12),
+ child: Material(
+ color: Colors.transparent,
+ borderRadius: BorderRadius.circular(14),
+ child: InkWell(
+ borderRadius: BorderRadius.circular(14),
+ onTap: _isCheckingAll ? null : () => _checkAll(ref),
+ child: Container(
+ padding: const EdgeInsets.symmetric(
+ horizontal: 12, vertical: 6),
+ decoration: BoxDecoration(
+ gradient: const LinearGradient(
+ colors: [Color(0xFF6366F1), Color(0xFF8B5CF6)],
+ begin: Alignment.topLeft,
+ end: Alignment.bottomRight,
+ ),
+ borderRadius: BorderRadius.circular(14),
+ boxShadow: [
+ BoxShadow(
+ color: const Color(0xFF6366F1)
+ .withValues(alpha: 0.35),
+ blurRadius: 8,
+ offset: const Offset(0, 2),
+ ),
+ ],
+ ),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ if (_isCheckingAll)
+ const SizedBox(
+ width: 14,
+ height: 14,
+ child: CircularProgressIndicator(
+ strokeWidth: 2,
+ color: Colors.white,
+ ),
+ )
+ else
+ const Icon(
+ Icons.playlist_add_check_rounded,
+ size: 16,
+ color: Colors.white,
+ ),
+ const SizedBox(width: 5),
+ Text(
+ isRu ? 'Проверить все' : 'Check All',
+ style: const TextStyle(
+ fontSize: 12,
+ fontWeight: FontWeight.w700,
+ color: Colors.white,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
),
- ],
+ ),
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],
- diagnostics: diagnostics[item.id],
- onCheck: () => _checkOne(ref, item.id),
- );
- },
- ),
+ data: (items) {
+ int onlineCount = 0;
+ int offlineCount = 0;
+ int pingSum = 0;
+ int pingCount = 0;
+
+ for (final item in items) {
+ final h = health[item.id];
+ if (h != null) {
+ if (h.status == ProviderStatus.online) {
+ onlineCount++;
+ pingSum += h.pingMs;
+ pingCount++;
+ } else if (h.status == ProviderStatus.offline) {
+ offlineCount++;
+ }
+ }
+ }
+
+ final avgPing =
+ pingCount > 0 ? (pingSum / pingCount).round() : null;
+
+ return Align(
+ alignment: Alignment.topCenter,
+ child: ConstrainedBox(
+ constraints: const BoxConstraints(maxWidth: 860),
+ child: ListView(
+ padding: EdgeInsets.fromLTRB(
+ 16,
+ MediaQuery.paddingOf(context).top + 68,
+ 16,
+ 140 + bottomInset,
+ ),
+ children: [
+ // Status Overview Card
+ Container(
+ margin: const EdgeInsets.only(bottom: 16),
+ padding: const EdgeInsets.all(16),
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(22),
+ gradient: LinearGradient(
+ begin: Alignment.topLeft,
+ end: Alignment.bottomRight,
+ colors: isDark
+ ? [
+ theme.colorScheme.surfaceContainerHigh
+ .withValues(alpha: 0.65),
+ theme.colorScheme.surfaceContainerLow
+ .withValues(alpha: 0.40),
+ ]
+ : [
+ Colors.white.withValues(alpha: 0.90),
+ Colors.white.withValues(alpha: 0.75),
+ ],
+ ),
+ border: Border.all(
+ color: isDark
+ ? Colors.white.withValues(alpha: 0.14)
+ : Colors.white.withValues(alpha: 0.85),
+ width: 1.1,
+ ),
+ boxShadow: [
+ BoxShadow(
+ color: isDark
+ ? Colors.black.withValues(alpha: 0.25)
+ : const Color(0xFF6366F1).withValues(alpha: 0.08),
+ blurRadius: 16,
+ offset: const Offset(0, 4),
+ ),
+ ],
+ ),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Wrap(
+ alignment: WrapAlignment.spaceBetween,
+ crossAxisAlignment: WrapCrossAlignment.center,
+ spacing: 8,
+ runSpacing: 6,
+ children: [
+ Text(
+ isRu ? 'Состояние сети' : 'Network Health',
+ style: theme.textTheme.titleSmall?.copyWith(
+ fontWeight: FontWeight.w700,
+ letterSpacing: -0.2,
+ ),
+ ),
+ if (avgPing != null)
+ Container(
+ padding: const EdgeInsets.symmetric(
+ horizontal: 8, vertical: 3),
+ decoration: BoxDecoration(
+ color: const Color(0xFF10B981).withValues(
+ alpha: isDark ? 0.16 : 0.10),
+ borderRadius: BorderRadius.circular(10),
+ border: Border.all(
+ color: const Color(0xFF10B981).withValues(
+ alpha: isDark ? 0.38 : 0.25),
+ ),
+ ),
+ child: Text(
+ '${isRu ? 'Средний пинг' : 'Avg ping'}: $avgPing ms',
+ style: const TextStyle(
+ fontSize: 11,
+ fontWeight: FontWeight.w700,
+ color: Color(0xFF10B981),
+ fontFamily: 'monospace',
+ ),
+ ),
+ ),
+ ],
+ ),
+ const SizedBox(height: 12),
+ Row(
+ children: [
+ Expanded(
+ child: _MetricPill(
+ label: isRu ? 'Всего' : 'Total',
+ value: '${items.length}',
+ color: theme.colorScheme.primary,
+ isDark: isDark,
+ ),
+ ),
+ const SizedBox(width: 8),
+ Expanded(
+ child: _MetricPill(
+ label: isRu ? 'Онлайн' : 'Online',
+ value: '$onlineCount',
+ color: const Color(0xFF10B981),
+ isDark: isDark,
+ ),
+ ),
+ const SizedBox(width: 8),
+ Expanded(
+ child: _MetricPill(
+ label: isRu ? 'Офлайн' : 'Offline',
+ value: '$offlineCount',
+ color: const Color(0xFFEF4444),
+ isDark: isDark,
+ ),
+ ),
+ ],
+ ),
+ ],
+ ),
+ ),
+
+ // Health Tiles List
+ if (items.isEmpty)
+ Container(
+ padding: const EdgeInsets.all(32),
+ decoration: BoxDecoration(
+ color: isDark
+ ? theme.colorScheme.surfaceContainerHigh
+ .withValues(alpha: 0.40)
+ : Colors.white.withValues(alpha: 0.80),
+ borderRadius: BorderRadius.circular(24),
+ ),
+ child: Center(
+ child: Text(
+ isRu ? 'Нет источников для проверки' : 'No providers to check',
+ style: theme.textTheme.bodyMedium,
+ ),
+ ),
+ )
+ else
+ ...items.map((item) {
+ return ProviderHealthTile(
+ key: ValueKey(item.id),
+ config: item,
+ health: health[item.id],
+ diagnostics: diagnostics[item.id],
+ onCheck: () => _checkOne(ref, item.id),
+ );
+ }),
+ ],
+ ),
+ ),
+ );
+ },
),
);
}
Future _checkAll(WidgetRef ref) async {
- final result = await ref.read(providerCheckServiceProvider).checkAll();
- if (result is Success>) {
- ref.read(providerHealthProvider.notifier).state = {
- for (final item in result.data) item.providerId: item,
- };
+ setState(() => _isCheckingAll = true);
+ HapticFeedback.mediumImpact();
+ try {
+ final result =
+ await ref.read(providerCheckServiceProvider).checkAll();
+ if (result is Success>) {
+ ref.read(providerHealthProvider.notifier).state = {
+ for (final item in result.data) item.providerId: item,
+ };
+ }
+ } finally {
+ if (mounted) setState(() => _isCheckingAll = false);
}
}
@@ -65,3 +343,53 @@ class ProviderCheckScreen extends ConsumerWidget {
}
}
}
+
+class _MetricPill extends StatelessWidget {
+ const _MetricPill({
+ required this.label,
+ required this.value,
+ required this.color,
+ required this.isDark,
+ });
+
+ final String label;
+ final String value;
+ final Color color;
+ final bool isDark;
+
+ @override
+ Widget build(BuildContext context) {
+ return Container(
+ padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
+ decoration: BoxDecoration(
+ color: color.withValues(alpha: isDark ? 0.12 : 0.08),
+ borderRadius: BorderRadius.circular(14),
+ border: Border.all(
+ color: color.withValues(alpha: isDark ? 0.30 : 0.20),
+ width: 1,
+ ),
+ ),
+ child: Column(
+ children: [
+ Text(
+ value,
+ style: TextStyle(
+ fontSize: 16,
+ fontWeight: FontWeight.w800,
+ color: color,
+ ),
+ ),
+ const SizedBox(height: 1),
+ Text(
+ label,
+ style: TextStyle(
+ fontSize: 11,
+ fontWeight: FontWeight.w600,
+ color: color.withValues(alpha: 0.85),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
diff --git a/lib/features/providers/presentation/provider_form_screen.dart b/lib/features/providers/presentation/provider_form_screen.dart
index 16faacd..1e44889 100644
--- a/lib/features/providers/presentation/provider_form_screen.dart
+++ b/lib/features/providers/presentation/provider_form_screen.dart
@@ -1,6 +1,8 @@
import 'dart:convert';
+import 'dart:ui';
import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
@@ -27,6 +29,8 @@ class _ProviderFormScreenState extends ConsumerState {
late final TextEditingController _login;
late String _apiType;
late bool _enabled;
+ bool _obscureApiKey = true;
+ bool _isSaving = false;
@override
void initState() {
@@ -63,140 +67,595 @@ class _ProviderFormScreenState extends ConsumerState {
super.dispose();
}
+ void _applyPreset({
+ required String name,
+ required String baseUrl,
+ required String apiType,
+ }) {
+ HapticFeedback.selectionClick();
+ setState(() {
+ _name.text = name;
+ _baseUrl.text = baseUrl;
+ _apiType = apiType;
+ });
+ }
+
@override
Widget build(BuildContext context) {
+ final theme = Theme.of(context);
+ final isDark = theme.brightness == Brightness.dark;
+ final isRu = Localizations.maybeLocaleOf(context)?.languageCode == 'ru';
+ final isEdit = widget.initialConfig != null;
+ final bottomInset = MediaQuery.paddingOf(context).bottom;
+
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(
- 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: 'paheal', child: Text('Rule34 Paheal HTML')),
- DropdownMenuItem(
- value: 'realbooru_html', child: Text('realbooru HTML')),
- DropdownMenuItem(
- value: 'danbooru', child: Text('danbooru-compatible')),
- DropdownMenuItem(
- value: 'moebooru', child: Text('moebooru-compatible')),
- DropdownMenuItem(value: 'e621', child: Text('e621/e926')),
- DropdownMenuItem(value: 'kemono', child: Text('Kemono artists')),
- DropdownMenuItem(value: 'coomer', child: Text('Coomer artists')),
- DropdownMenuItem(value: 'custom', child: Text('custom')),
+ extendBodyBehindAppBar: true,
+ appBar: PreferredSize(
+ preferredSize: const Size.fromHeight(60),
+ child: ClipRRect(
+ child: BackdropFilter(
+ filter: ImageFilter.blur(sigmaX: 18, sigmaY: 18),
+ child: AppBar(
+ backgroundColor: isDark
+ ? theme.colorScheme.surface.withValues(alpha: 0.75)
+ : Colors.white.withValues(alpha: 0.85),
+ surfaceTintColor: Colors.transparent,
+ elevation: 0,
+ leading: IconButton(
+ icon: const Icon(Icons.arrow_back_ios_new_rounded, size: 20),
+ onPressed: () {
+ if (Navigator.of(context).canPop()) {
+ Navigator.of(context).pop();
+ } else {
+ context.go('/providers');
+ }
+ },
+ tooltip: isRu ? 'Назад' : 'Back',
+ ),
+ title: Text(
+ isEdit
+ ? (isRu ? 'Редактирование источника' : 'Edit Provider')
+ : (isRu ? 'Новый источник' : 'New Provider'),
+ style: theme.textTheme.titleMedium?.copyWith(
+ fontWeight: FontWeight.w700,
+ letterSpacing: -0.2,
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ body: Align(
+ alignment: Alignment.topCenter,
+ child: ConstrainedBox(
+ constraints: const BoxConstraints(maxWidth: 820),
+ child: ListView(
+ padding: EdgeInsets.fromLTRB(
+ 16,
+ MediaQuery.paddingOf(context).top + 68,
+ 16,
+ 140 + bottomInset,
+ ),
+ children: [
+ // Presets bar if creating a new provider
+ if (!isEdit) ...[
+ Text(
+ isRu ? 'Быстрые пресеты' : 'Quick Presets',
+ style: theme.textTheme.labelMedium?.copyWith(
+ fontWeight: FontWeight.w700,
+ color: theme.colorScheme.onSurfaceVariant,
+ letterSpacing: -0.1,
+ ),
+ ),
+ const SizedBox(height: 8),
+ SingleChildScrollView(
+ scrollDirection: Axis.horizontal,
+ child: Row(
+ children: [
+ _PresetChip(
+ name: 'Gelbooru',
+ color: const Color(0xFF10B981),
+ onTap: () => _applyPreset(
+ name: 'Gelbooru',
+ baseUrl: 'https://gelbooru.com',
+ apiType: 'gelbooru',
+ ),
+ ),
+ const SizedBox(width: 8),
+ _PresetChip(
+ name: 'Rule34',
+ color: const Color(0xFF84CC16),
+ onTap: () => _applyPreset(
+ name: 'Rule34',
+ baseUrl: 'https://api.rule34.xxx',
+ apiType: 'rule34',
+ ),
+ ),
+ const SizedBox(width: 8),
+ _PresetChip(
+ name: 'Danbooru',
+ color: const Color(0xFF3B82F6),
+ onTap: () => _applyPreset(
+ name: 'Danbooru',
+ baseUrl: 'https://danbooru.donmai.us',
+ apiType: 'danbooru',
+ ),
+ ),
+ const SizedBox(width: 8),
+ _PresetChip(
+ name: 'e621',
+ color: const Color(0xFFF59E0B),
+ onTap: () => _applyPreset(
+ name: 'e621',
+ baseUrl: 'https://e621.net',
+ apiType: 'e621',
+ ),
+ ),
+ const SizedBox(width: 8),
+ _PresetChip(
+ name: 'Yande.re',
+ color: const Color(0xFF8B5CF6),
+ onTap: () => _applyPreset(
+ name: 'Yande.re',
+ baseUrl: 'https://yande.re',
+ apiType: 'moebooru',
+ ),
+ ),
+ const SizedBox(width: 8),
+ _PresetChip(
+ name: 'Konachan',
+ color: const Color(0xFFEC4899),
+ onTap: () => _applyPreset(
+ name: 'Konachan',
+ baseUrl: 'https://konachan.net',
+ apiType: 'moebooru',
+ ),
+ ),
+ const SizedBox(width: 8),
+ _PresetChip(
+ name: 'Paheal',
+ color: const Color(0xFFF97316),
+ onTap: () => _applyPreset(
+ name: 'Rule34 Paheal',
+ baseUrl: 'https://rule34.paheal.net',
+ apiType: 'paheal',
+ ),
+ ),
+ ],
+ ),
+ ),
+ const SizedBox(height: 16),
+ ],
+
+ // Section 1: General Parameters
+ _FormLiquidCard(
+ icon: Icons.tune_rounded,
+ accentColor: const Color(0xFF6366F1),
+ title: isRu ? 'Основные параметры' : 'General Configuration',
+ subtitle: isRu
+ ? 'Имя, адрес сервера и тип API движка'
+ : 'Name, server endpoint and API engine',
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ _GlassTextField(
+ controller: _name,
+ label: isRu ? 'Название источника' : 'Provider Name',
+ hint: 'e.g. Gelbooru, Rule34',
+ icon: Icons.label_rounded,
+ ),
+ const SizedBox(height: 14),
+ _GlassTextField(
+ controller: _baseUrl,
+ label: isRu ? 'Базовый URL (Base URL)' : 'Base URL',
+ hint: 'https://example.com',
+ icon: Icons.link_rounded,
+ ),
+ const SizedBox(height: 14),
+ // Dropdown API Type
+ _GlassDropdown(
+ label: isRu ? 'Тип API движка' : 'API Engine Type',
+ value: _apiType,
+ onChanged: (val) {
+ if (val != null) setState(() => _apiType = val);
+ },
+ 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: 'e621',
+ child: Text('e621 / e926'),
+ ),
+ DropdownMenuItem(
+ value: 'moebooru',
+ child: Text('Moebooru (Yande.re, Konachan)'),
+ ),
+ DropdownMenuItem(
+ value: 'paheal',
+ child: Text('Rule34 Paheal HTML'),
+ ),
+ DropdownMenuItem(
+ value: 'realbooru_html',
+ child: Text('Realbooru HTML'),
+ ),
+ DropdownMenuItem(
+ value: 'kemono',
+ child: Text('Kemono Party artists'),
+ ),
+ DropdownMenuItem(
+ value: 'coomer',
+ child: Text('Coomer Party artists'),
+ ),
+ DropdownMenuItem(
+ value: 'custom',
+ child: Text('Custom REST API'),
+ ),
+ ],
+ ),
+ const SizedBox(height: 14),
+ // Enabled Switch Tile
+ Container(
+ padding: const EdgeInsets.symmetric(
+ horizontal: 14, vertical: 8),
+ decoration: BoxDecoration(
+ color: isDark
+ ? Colors.black.withValues(alpha: 0.15)
+ : Colors.white.withValues(alpha: 0.50),
+ borderRadius: BorderRadius.circular(16),
+ border: Border.all(
+ color: isDark
+ ? Colors.white.withValues(alpha: 0.08)
+ : Colors.black.withValues(alpha: 0.05),
+ ),
+ ),
+ child: Row(
+ children: [
+ Icon(
+ Icons.power_settings_new_rounded,
+ size: 20,
+ color: _enabled
+ ? const Color(0xFF10B981)
+ : theme.colorScheme.onSurfaceVariant,
+ ),
+ const SizedBox(width: 12),
+ Expanded(
+ child: Text(
+ isRu ? 'Включить этот источник' : 'Enable provider',
+ style: TextStyle(
+ fontSize: 14,
+ fontWeight: FontWeight.w600,
+ color: theme.colorScheme.onSurface,
+ ),
+ ),
+ ),
+ Switch.adaptive(
+ value: _enabled,
+ onChanged: (val) {
+ HapticFeedback.selectionClick();
+ setState(() => _enabled = val);
+ },
+ activeThumbColor: const Color(0xFF10B981),
+ activeTrackColor:
+ const Color(0xFF10B981).withValues(alpha: 0.45),
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ ),
+
+ const SizedBox(height: 18),
+
+ // Section 2: Priority & Timeout
+ _FormLiquidCard(
+ icon: Icons.speed_rounded,
+ accentColor: const Color(0xFF10B981),
+ title: isRu
+ ? 'Приоритет и производительность'
+ : 'Priority & Performance',
+ subtitle: isRu
+ ? 'Порядок выдачи постов и максимальное время ответа'
+ : 'Dispatch priority and maximum network wait time',
+ child: Row(
+ children: [
+ Expanded(
+ child: _GlassTextField(
+ controller: _priority,
+ label: isRu ? 'Приоритет (Priority)' : 'Priority',
+ hint: '10',
+ icon: Icons.trending_up_rounded,
+ keyboardType: TextInputType.number,
+ ),
+ ),
+ const SizedBox(width: 12),
+ Expanded(
+ child: _GlassTextField(
+ controller: _timeout,
+ label: isRu ? 'Таймаут (сек)' : 'Timeout (s)',
+ hint: '20',
+ icon: Icons.timer_outlined,
+ keyboardType: TextInputType.number,
+ ),
+ ),
+ ],
+ ),
+ ),
+
+ const SizedBox(height: 18),
+
+ // Section 3: Credentials & API Keys
+ _FormLiquidCard(
+ icon: Icons.vpn_key_rounded,
+ accentColor: const Color(0xFFF59E0B),
+ title: isRu
+ ? 'Ключи доступа и аккаунт'
+ : 'API Keys & Authentication',
+ subtitle: isRu
+ ? 'Необходимы для снятия ограничений на поиск и теги'
+ : 'Required for unrestricted queries and higher limits',
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ // Info Notice
+ Container(
+ margin: const EdgeInsets.only(bottom: 14),
+ padding: const EdgeInsets.all(12),
+ decoration: BoxDecoration(
+ color: const Color(0xFFF59E0B)
+ .withValues(alpha: isDark ? 0.14 : 0.08),
+ borderRadius: BorderRadius.circular(14),
+ border: Border.all(
+ color: const Color(0xFFF59E0B)
+ .withValues(alpha: isDark ? 0.35 : 0.22),
+ ),
+ ),
+ child: Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ const Icon(
+ Icons.info_outline_rounded,
+ size: 18,
+ color: Color(0xFFF59E0B),
+ ),
+ const SizedBox(width: 10),
+ Expanded(
+ child: Text(
+ isRu
+ ? 'Для Gelbooru и Rule34: User ID и API Key можно найти в настройках профиля (Account -> Options).'
+ : 'For Gelbooru & Rule34: Find your User ID and API Key in Account -> Options.',
+ style: TextStyle(
+ fontSize: 12,
+ color: theme.colorScheme.onSurfaceVariant,
+ height: 1.35,
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+
+ _GlassTextField(
+ controller: _apiKey,
+ label: 'API Key',
+ hint: 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
+ icon: Icons.key_rounded,
+ obscureText: _obscureApiKey,
+ suffixIcon: IconButton(
+ icon: Icon(
+ _obscureApiKey
+ ? Icons.visibility_outlined
+ : Icons.visibility_off_outlined,
+ size: 18,
+ ),
+ onPressed: () =>
+ setState(() => _obscureApiKey = !_obscureApiKey),
+ ),
+ ),
+ const SizedBox(height: 14),
+ _GlassTextField(
+ controller: _userId,
+ label: 'User ID',
+ hint: '123456',
+ icon: Icons.badge_outlined,
+ keyboardType: TextInputType.number,
+ ),
+ const SizedBox(height: 14),
+ _GlassTextField(
+ controller: _login,
+ label: isRu ? 'Логин (Login)' : 'Login Username',
+ hint: 'username',
+ icon: Icons.account_circle_outlined,
+ ),
+ ],
+ ),
+ ),
+
+ const SizedBox(height: 18),
+
+ // Section 4: Custom Headers JSON
+ _FormLiquidCard(
+ icon: Icons.terminal_rounded,
+ accentColor: const Color(0xFF06B6D4),
+ title: isRu ? 'Заголовки JSON' : 'Custom Headers (JSON)',
+ subtitle: isRu
+ ? 'Дополнительные HTTP заголовки в формате JSON'
+ : 'Optional custom request headers in JSON map format',
+ child: _GlassTextField(
+ controller: _headers,
+ label: isRu ? 'Пользовательские заголовки' : 'Headers JSON',
+ hint: '{"User-Agent": "Lunaris/3.0"}',
+ icon: Icons.code_rounded,
+ minLines: 2,
+ maxLines: 5,
+ isMonospace: true,
+ ),
+ ),
+
+ const SizedBox(height: 24),
+
+ // Save Action Button
+ Container(
+ height: 52,
+ decoration: BoxDecoration(
+ gradient: const LinearGradient(
+ colors: [Color(0xFF6366F1), Color(0xFF8B5CF6)],
+ begin: Alignment.topLeft,
+ end: Alignment.bottomRight,
+ ),
+ borderRadius: BorderRadius.circular(18),
+ boxShadow: [
+ BoxShadow(
+ color: const Color(0xFF6366F1).withValues(alpha: 0.38),
+ blurRadius: 16,
+ offset: const Offset(0, 4),
+ ),
+ ],
+ ),
+ child: Material(
+ color: Colors.transparent,
+ borderRadius: BorderRadius.circular(18),
+ child: InkWell(
+ borderRadius: BorderRadius.circular(18),
+ onTap: _isSaving ? null : _save,
+ child: Center(
+ child: _isSaving
+ ? const SizedBox(
+ width: 22,
+ height: 22,
+ child: CircularProgressIndicator(
+ strokeWidth: 2.5,
+ color: Colors.white,
+ ),
+ )
+ : Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ const Icon(
+ Icons.check_rounded,
+ size: 20,
+ color: Colors.white,
+ ),
+ const SizedBox(width: 8),
+ Text(
+ isRu
+ ? 'Сохранить провайдер'
+ : 'Save Provider',
+ style: const TextStyle(
+ fontSize: 16,
+ fontWeight: FontWeight.w700,
+ color: Colors.white,
+ letterSpacing: -0.2,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ ),
+
+ const SizedBox(height: 12),
+
+ // Cancel Button
+ Center(
+ child: TextButton(
+ onPressed: () {
+ if (Navigator.of(context).canPop()) {
+ Navigator.of(context).pop();
+ } else {
+ context.go('/providers');
+ }
+ },
+ child: Text(
+ isRu ? 'Отмена' : 'Cancel',
+ style: TextStyle(
+ fontSize: 14,
+ fontWeight: FontWeight.w600,
+ color: theme.colorScheme.onSurfaceVariant,
+ ),
+ ),
+ ),
+ ),
],
- 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/Rule34/Danbooru API key (Account -> Options)',
- ),
- ),
- const SizedBox(height: 12),
- TextField(
- controller: _userId,
- decoration: const InputDecoration(
- labelText: 'User ID',
- helperText:
- 'Gelbooru/Rule34 User ID (required for API access, see Options)',
- ),
- ),
- 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 _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.from(headersJson as Map);
- void putQuery(String key, String value) {
- final trimmed = value.trim();
- if (trimmed.isNotEmpty) customHeaders['query.$key'] = trimmed;
+ final nameText = _name.text.trim();
+ if (nameText.isEmpty) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(content: Text('Укажите название провайдера')),
+ );
+ return;
}
- 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');
+ setState(() => _isSaving = true);
+ HapticFeedback.lightImpact();
+
+ try {
+ final now = DateTime.now();
+ final id = widget.initialConfig?.id ??
+ nameText.toLowerCase().replaceAll(RegExp(r'[^a-z0-9]+'), '_');
+
+ Map headersJson = {};
+ try {
+ if (_headers.text.trim().isNotEmpty) {
+ headersJson = jsonDecode(_headers.text.trim()) as Map;
+ }
+ } catch (_) {
+ // Fallback to empty if parse error
+ }
+ final customHeaders = Map.from(
+ headersJson.map((k, v) => MapEntry(k.toString(), v.toString())),
+ );
+
+ 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: nameText,
+ 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) {
+ if (Navigator.of(context).canPop()) {
+ Navigator.of(context).pop();
+ } else {
+ context.go('/providers');
+ }
+ }
+ } finally {
+ if (mounted) setState(() => _isSaving = false);
+ }
}
Map _visibleHeaders(Map headers) {
@@ -204,3 +663,334 @@ class _ProviderFormScreenState extends ConsumerState {
..removeWhere((key, _) => key.startsWith('query.'));
}
}
+
+class _PresetChip extends StatelessWidget {
+ const _PresetChip({
+ required this.name,
+ required this.color,
+ required this.onTap,
+ });
+
+ final String name;
+ final Color color;
+ final VoidCallback onTap;
+
+ @override
+ Widget build(BuildContext context) {
+ final isDark = Theme.of(context).brightness == Brightness.dark;
+
+ return Material(
+ color: Colors.transparent,
+ borderRadius: BorderRadius.circular(14),
+ child: InkWell(
+ borderRadius: BorderRadius.circular(14),
+ onTap: onTap,
+ child: Container(
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7),
+ decoration: BoxDecoration(
+ color: color.withValues(alpha: isDark ? 0.16 : 0.10),
+ borderRadius: BorderRadius.circular(14),
+ border: Border.all(
+ color: color.withValues(alpha: isDark ? 0.40 : 0.28),
+ width: 1,
+ ),
+ ),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Container(
+ width: 6,
+ height: 6,
+ decoration: BoxDecoration(
+ color: color,
+ shape: BoxShape.circle,
+ ),
+ ),
+ const SizedBox(width: 6),
+ Text(
+ name,
+ style: TextStyle(
+ fontSize: 12,
+ fontWeight: FontWeight.w700,
+ color: color,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+}
+
+class _FormLiquidCard extends StatelessWidget {
+ const _FormLiquidCard({
+ required this.icon,
+ required this.accentColor,
+ required this.title,
+ required this.subtitle,
+ required this.child,
+ });
+
+ final IconData icon;
+ final Color accentColor;
+ final String title;
+ final String subtitle;
+ final Widget child;
+
+ @override
+ Widget build(BuildContext context) {
+ final theme = Theme.of(context);
+ final isDark = theme.brightness == Brightness.dark;
+
+ return Container(
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(24),
+ boxShadow: [
+ BoxShadow(
+ color: isDark
+ ? Colors.black.withValues(alpha: 0.30)
+ : accentColor.withValues(alpha: 0.08),
+ blurRadius: 18,
+ offset: const Offset(0, 6),
+ spreadRadius: -2,
+ ),
+ ],
+ ),
+ child: ClipRRect(
+ borderRadius: BorderRadius.circular(24),
+ child: Container(
+ padding: const EdgeInsets.all(18),
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(24),
+ gradient: LinearGradient(
+ begin: Alignment.topLeft,
+ end: Alignment.bottomRight,
+ colors: isDark
+ ? [
+ theme.colorScheme.surfaceContainerHigh
+ .withValues(alpha: 0.65),
+ theme.colorScheme.surfaceContainerLow
+ .withValues(alpha: 0.40),
+ ]
+ : [
+ Colors.white.withValues(alpha: 0.90),
+ Colors.white.withValues(alpha: 0.75),
+ ],
+ ),
+ border: Border.all(
+ color: isDark
+ ? Colors.white.withValues(alpha: 0.14)
+ : Colors.white.withValues(alpha: 0.85),
+ width: 1.1,
+ ),
+ ),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ // Header
+ Row(
+ children: [
+ Container(
+ width: 36,
+ height: 36,
+ decoration: BoxDecoration(
+ gradient: LinearGradient(
+ colors: [
+ accentColor,
+ accentColor.withValues(alpha: 0.80),
+ ],
+ ),
+ borderRadius: BorderRadius.circular(11),
+ boxShadow: [
+ BoxShadow(
+ color: accentColor.withValues(alpha: 0.32),
+ blurRadius: 8,
+ offset: const Offset(0, 2),
+ ),
+ ],
+ ),
+ child: Center(
+ child: Icon(icon, size: 18, color: Colors.white),
+ ),
+ ),
+ const SizedBox(width: 12),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ title,
+ style: theme.textTheme.titleSmall?.copyWith(
+ fontWeight: FontWeight.w700,
+ letterSpacing: -0.2,
+ ),
+ ),
+ const SizedBox(height: 2),
+ Text(
+ subtitle,
+ style: theme.textTheme.bodySmall?.copyWith(
+ color: theme.colorScheme.onSurfaceVariant,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ const SizedBox(height: 16),
+ child,
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+}
+
+class _GlassTextField extends StatelessWidget {
+ const _GlassTextField({
+ required this.controller,
+ required this.label,
+ required this.hint,
+ required this.icon,
+ this.keyboardType,
+ this.obscureText = false,
+ this.suffixIcon,
+ this.minLines = 1,
+ this.maxLines = 1,
+ this.isMonospace = false,
+ });
+
+ final TextEditingController controller;
+ final String label;
+ final String hint;
+ final IconData icon;
+ final TextInputType? keyboardType;
+ final bool obscureText;
+ final Widget? suffixIcon;
+ final int minLines;
+ final int maxLines;
+ final bool isMonospace;
+
+ @override
+ Widget build(BuildContext context) {
+ final theme = Theme.of(context);
+ final isDark = theme.brightness == Brightness.dark;
+
+ return TextField(
+ controller: controller,
+ keyboardType: keyboardType,
+ obscureText: obscureText,
+ minLines: minLines,
+ maxLines: maxLines,
+ style: TextStyle(
+ fontSize: 14,
+ fontFamily: isMonospace ? 'monospace' : null,
+ ),
+ decoration: InputDecoration(
+ labelText: label,
+ hintText: hint,
+ hintStyle: TextStyle(
+ fontSize: 13,
+ color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.5),
+ ),
+ prefixIcon: Icon(icon, size: 18),
+ suffixIcon: suffixIcon,
+ filled: true,
+ fillColor: isDark
+ ? Colors.black.withValues(alpha: 0.22)
+ : theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.45),
+ contentPadding:
+ const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
+ border: OutlineInputBorder(
+ borderRadius: BorderRadius.circular(16),
+ borderSide: BorderSide(
+ color: isDark
+ ? Colors.white.withValues(alpha: 0.10)
+ : Colors.black.withValues(alpha: 0.08),
+ ),
+ ),
+ enabledBorder: OutlineInputBorder(
+ borderRadius: BorderRadius.circular(16),
+ borderSide: BorderSide(
+ color: isDark
+ ? Colors.white.withValues(alpha: 0.10)
+ : Colors.black.withValues(alpha: 0.08),
+ ),
+ ),
+ focusedBorder: OutlineInputBorder(
+ borderRadius: BorderRadius.circular(16),
+ borderSide: BorderSide(
+ color: theme.colorScheme.primary,
+ width: 1.4,
+ ),
+ ),
+ ),
+ );
+ }
+}
+
+class _GlassDropdown extends StatelessWidget {
+ const _GlassDropdown({
+ required this.label,
+ required this.value,
+ required this.items,
+ required this.onChanged,
+ });
+
+ final String label;
+ final String value;
+ final List> items;
+ final ValueChanged onChanged;
+
+ @override
+ Widget build(BuildContext context) {
+ final theme = Theme.of(context);
+ final isDark = theme.brightness == Brightness.dark;
+
+ return DropdownButtonFormField(
+ isExpanded: true,
+ initialValue: value,
+ items: items,
+ onChanged: onChanged,
+ dropdownColor: isDark
+ ? theme.colorScheme.surfaceContainerHighest
+ : Colors.white,
+ borderRadius: BorderRadius.circular(16),
+ decoration: InputDecoration(
+ labelText: label,
+ prefixIcon: const Icon(Icons.hub_outlined, size: 18),
+ filled: true,
+ fillColor: isDark
+ ? Colors.black.withValues(alpha: 0.22)
+ : theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.45),
+ contentPadding:
+ const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
+ border: OutlineInputBorder(
+ borderRadius: BorderRadius.circular(16),
+ borderSide: BorderSide(
+ color: isDark
+ ? Colors.white.withValues(alpha: 0.10)
+ : Colors.black.withValues(alpha: 0.08),
+ ),
+ ),
+ enabledBorder: OutlineInputBorder(
+ borderRadius: BorderRadius.circular(16),
+ borderSide: BorderSide(
+ color: isDark
+ ? Colors.white.withValues(alpha: 0.10)
+ : Colors.black.withValues(alpha: 0.08),
+ ),
+ ),
+ focusedBorder: OutlineInputBorder(
+ borderRadius: BorderRadius.circular(16),
+ borderSide: BorderSide(
+ color: theme.colorScheme.primary,
+ width: 1.4,
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/features/providers/presentation/providers_screen.dart b/lib/features/providers/presentation/providers_screen.dart
index 6e0f33e..fbe2acc 100644
--- a/lib/features/providers/presentation/providers_screen.dart
+++ b/lib/features/providers/presentation/providers_screen.dart
@@ -1,8 +1,9 @@
+import 'dart:ui';
import 'package:flutter/material.dart';
+import 'package:flutter/services.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';
@@ -13,50 +14,371 @@ class ProvidersScreen extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
+ final theme = Theme.of(context);
+ final isDark = theme.brightness == Brightness.dark;
+ final isRu = Localizations.maybeLocaleOf(context)?.languageCode == 'ru';
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),
+ final bottomInset = MediaQuery.paddingOf(context).bottom;
+
+ return Scaffold(
+ extendBodyBehindAppBar: true,
+ appBar: PreferredSize(
+ preferredSize: const Size.fromHeight(60),
+ child: ClipRRect(
+ child: BackdropFilter(
+ filter: ImageFilter.blur(sigmaX: 18, sigmaY: 18),
+ child: AppBar(
+ backgroundColor: isDark
+ ? theme.colorScheme.surface.withValues(alpha: 0.72)
+ : Colors.white.withValues(alpha: 0.82),
+ surfaceTintColor: Colors.transparent,
+ elevation: 0,
+ centerTitle: false,
+ leading: Navigator.of(context).canPop()
+ ? IconButton(
+ icon: const Icon(Icons.arrow_back_ios_new_rounded, size: 20),
+ onPressed: () => context.pop(),
+ tooltip: isRu ? 'Назад' : 'Back',
+ )
+ : null,
+ title: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Container(
+ width: 30,
+ height: 30,
+ decoration: BoxDecoration(
+ gradient: const LinearGradient(
+ colors: [Color(0xFF6366F1), Color(0xFF8B5CF6)],
+ begin: Alignment.topLeft,
+ end: Alignment.bottomRight,
+ ),
+ borderRadius: BorderRadius.circular(9),
+ boxShadow: [
+ BoxShadow(
+ color:
+ const Color(0xFF6366F1).withValues(alpha: 0.35),
+ blurRadius: 8,
+ offset: const Offset(0, 2),
+ ),
+ ],
+ ),
+ child: const Center(
+ child:
+ Icon(Icons.hub_rounded, size: 16, color: Colors.white),
+ ),
+ ),
+ const SizedBox(width: 8),
+ Flexible(
+ child: Text(
+ isRu ? 'Провайдеры' : 'Providers',
+ style: theme.textTheme.titleMedium?.copyWith(
+ fontWeight: FontWeight.w700,
+ letterSpacing: -0.2,
+ ),
+ overflow: TextOverflow.ellipsis,
+ ),
+ ),
+ ],
+ ),
+ actions: [
+ // Provider Check Glass Button
+ Padding(
+ padding: const EdgeInsets.only(right: 6),
+ child: Material(
+ color: Colors.transparent,
+ borderRadius: BorderRadius.circular(14),
+ child: InkWell(
+ borderRadius: BorderRadius.circular(14),
+ onTap: () {
+ HapticFeedback.lightImpact();
+ context.push('/providers/check');
+ },
+ child: Container(
+ padding: const EdgeInsets.symmetric(
+ horizontal: 10, vertical: 6),
+ decoration: BoxDecoration(
+ color: theme.colorScheme.primary
+ .withValues(alpha: isDark ? 0.16 : 0.10),
+ borderRadius: BorderRadius.circular(14),
+ border: Border.all(
+ color: theme.colorScheme.primary
+ .withValues(alpha: isDark ? 0.35 : 0.22),
+ width: 1,
+ ),
+ ),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Icon(
+ Icons.network_check_rounded,
+ size: 16,
+ color: theme.colorScheme.primary,
+ ),
+ const SizedBox(width: 5),
+ Text(
+ isRu ? 'Проверка' : 'Check',
+ style: TextStyle(
+ fontSize: 12,
+ fontWeight: FontWeight.w700,
+ color: theme.colorScheme.primary,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ ),
+
+ // Add Provider Accent Button
+ Padding(
+ padding: const EdgeInsets.only(right: 12),
+ child: Material(
+ color: Colors.transparent,
+ borderRadius: BorderRadius.circular(14),
+ child: InkWell(
+ borderRadius: BorderRadius.circular(14),
+ onTap: () {
+ HapticFeedback.lightImpact();
+ context.push('/providers/new');
+ },
+ child: Container(
+ padding: const EdgeInsets.symmetric(
+ horizontal: 12, vertical: 6),
+ decoration: BoxDecoration(
+ gradient: const LinearGradient(
+ colors: [Color(0xFF6366F1), Color(0xFF8B5CF6)],
+ begin: Alignment.topLeft,
+ end: Alignment.bottomRight,
+ ),
+ borderRadius: BorderRadius.circular(14),
+ boxShadow: [
+ BoxShadow(
+ color: const Color(0xFF6366F1)
+ .withValues(alpha: 0.35),
+ blurRadius: 8,
+ offset: const Offset(0, 2),
+ ),
+ ],
+ ),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ const Icon(
+ Icons.add_rounded,
+ size: 16,
+ color: Colors.white,
+ ),
+ const SizedBox(width: 4),
+ Text(
+ isRu ? 'Создать' : 'Add',
+ style: const TextStyle(
+ fontSize: 12,
+ fontWeight: FontWeight.w700,
+ color: Colors.white,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
),
- 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);
- }
- },
- );
- },
- ),
+ data: (items) {
+ final activeCount = items.where((p) => p.enabled).length;
+
+ return Align(
+ alignment: Alignment.topCenter,
+ child: ConstrainedBox(
+ constraints: const BoxConstraints(maxWidth: 860),
+ child: ListView(
+ padding: EdgeInsets.fromLTRB(
+ 16,
+ MediaQuery.paddingOf(context).top + 68,
+ 16,
+ 140 + bottomInset,
+ ),
+ children: [
+ // Overview Glass Card
+ Container(
+ margin: const EdgeInsets.only(bottom: 16),
+ padding: const EdgeInsets.all(16),
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(22),
+ gradient: LinearGradient(
+ begin: Alignment.topLeft,
+ end: Alignment.bottomRight,
+ colors: isDark
+ ? [
+ theme.colorScheme.surfaceContainerHigh
+ .withValues(alpha: 0.65),
+ theme.colorScheme.surfaceContainerLow
+ .withValues(alpha: 0.40),
+ ]
+ : [
+ Colors.white.withValues(alpha: 0.90),
+ Colors.white.withValues(alpha: 0.75),
+ ],
+ ),
+ border: Border.all(
+ color: isDark
+ ? Colors.white.withValues(alpha: 0.14)
+ : Colors.white.withValues(alpha: 0.85),
+ width: 1.1,
+ ),
+ boxShadow: [
+ BoxShadow(
+ color: isDark
+ ? Colors.black.withValues(alpha: 0.25)
+ : const Color(0xFF6366F1).withValues(alpha: 0.08),
+ blurRadius: 16,
+ offset: const Offset(0, 4),
+ ),
+ ],
+ ),
+ child: Row(
+ children: [
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ isRu
+ ? 'Управление источниками'
+ : 'Provider Management',
+ style: theme.textTheme.titleSmall?.copyWith(
+ fontWeight: FontWeight.w700,
+ letterSpacing: -0.2,
+ ),
+ ),
+ const SizedBox(height: 4),
+ Text(
+ isRu
+ ? 'Настройте подключение, API ключи и приоритеты booru-сервисов'
+ : 'Configure connections, API keys and search priorities',
+ style: theme.textTheme.bodySmall?.copyWith(
+ color: theme.colorScheme.onSurfaceVariant,
+ ),
+ ),
+ ],
+ ),
+ ),
+ const SizedBox(width: 12),
+ Container(
+ padding: const EdgeInsets.symmetric(
+ horizontal: 10, vertical: 6),
+ decoration: BoxDecoration(
+ color: const Color(0xFF10B981)
+ .withValues(alpha: isDark ? 0.16 : 0.10),
+ borderRadius: BorderRadius.circular(12),
+ border: Border.all(
+ color: const Color(0xFF10B981)
+ .withValues(alpha: isDark ? 0.40 : 0.28),
+ ),
+ ),
+ child: Text(
+ '$activeCount / ${items.length} ${isRu ? 'вкл' : 'on'}',
+ style: const TextStyle(
+ fontSize: 12,
+ fontWeight: FontWeight.w700,
+ color: Color(0xFF10B981),
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+
+ // Empty State
+ if (items.isEmpty)
+ Container(
+ padding: const EdgeInsets.all(32),
+ decoration: BoxDecoration(
+ color: isDark
+ ? theme.colorScheme.surfaceContainerHigh
+ .withValues(alpha: 0.40)
+ : Colors.white.withValues(alpha: 0.80),
+ borderRadius: BorderRadius.circular(24),
+ border: Border.all(
+ color: isDark
+ ? Colors.white.withValues(alpha: 0.10)
+ : Colors.white,
+ ),
+ ),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Icon(
+ Icons.cloud_off_rounded,
+ size: 48,
+ color: theme.colorScheme.onSurfaceVariant
+ .withValues(alpha: 0.5),
+ ),
+ const SizedBox(height: 12),
+ Text(
+ isRu
+ ? 'Нет настроенных источников'
+ : 'No providers configured',
+ style: theme.textTheme.titleMedium?.copyWith(
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ const SizedBox(height: 6),
+ Text(
+ isRu
+ ? 'Нажмите «Создать», чтобы добавить новый booru-провайдер'
+ : 'Tap "Add" to set up a new booru provider',
+ style: theme.textTheme.bodySmall?.copyWith(
+ color: theme.colorScheme.onSurfaceVariant,
+ ),
+ textAlign: TextAlign.center,
+ ),
+ ],
+ ),
+ )
+ else
+ // List of Provider Cards
+ ...items.map((item) {
+ return ProviderCard(
+ key: ValueKey(item.id),
+ config: item,
+ onToggle: (enabled) => ref
+ .read(providersControllerProvider.notifier)
+ .toggle(item.id, enabled),
+ onEdit: () => context.push(
+ '/providers/new',
+ extra: item,
+ ),
+ onDelete: () async {
+ final ok = await showConfirmDialog(
+ context,
+ title: isRu ? 'Удалить источник' : 'Delete provider',
+ message: isRu
+ ? 'Вы действительно хотите удалить ${item.name}?'
+ : 'Remove ${item.name}?',
+ );
+ if (ok) {
+ await ref
+ .read(providersControllerProvider.notifier)
+ .delete(item.id);
+ }
+ },
+ );
+ }),
+ ],
+ ),
+ ),
+ );
+ },
),
);
}
diff --git a/lib/features/providers/presentation/widgets/provider_card.dart b/lib/features/providers/presentation/widgets/provider_card.dart
index ad62be7..3c1cb67 100644
--- a/lib/features/providers/presentation/widgets/provider_card.dart
+++ b/lib/features/providers/presentation/widgets/provider_card.dart
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
import '../../../../backend/backend.dart';
@@ -18,27 +19,396 @@ class ProviderCard extends StatelessWidget {
@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),
+ final theme = Theme.of(context);
+ final isDark = theme.brightness == Brightness.dark;
+ final isRu = Localizations.maybeLocaleOf(context)?.languageCode == 'ru';
+ final (engineColor, engineIcon, engineTitle) = _engineInfo(config.apiType);
+
+ return Container(
+ margin: const EdgeInsets.only(bottom: 14),
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(24),
+ boxShadow: [
+ BoxShadow(
+ color: isDark
+ ? Colors.black.withValues(alpha: 0.30)
+ : engineColor.withValues(alpha: 0.08),
+ blurRadius: 18,
+ offset: const Offset(0, 6),
+ spreadRadius: -2,
+ ),
+ BoxShadow(
+ color: engineColor.withValues(alpha: isDark ? 0.05 : 0.03),
+ blurRadius: 10,
+ offset: const Offset(0, 2),
+ ),
+ ],
+ ),
+ child: ClipRRect(
+ borderRadius: BorderRadius.circular(24),
+ child: Container(
+ padding: const EdgeInsets.all(16),
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(24),
+ gradient: LinearGradient(
+ begin: Alignment.topLeft,
+ end: Alignment.bottomRight,
+ colors: isDark
+ ? [
+ theme.colorScheme.surfaceContainerHigh
+ .withValues(alpha: config.enabled ? 0.65 : 0.35),
+ theme.colorScheme.surfaceContainerLow
+ .withValues(alpha: config.enabled ? 0.45 : 0.20),
+ ]
+ : [
+ Colors.white
+ .withValues(alpha: config.enabled ? 0.90 : 0.60),
+ Colors.white
+ .withValues(alpha: config.enabled ? 0.75 : 0.45),
+ ],
),
- IconButton(
- tooltip: 'Delete',
- onPressed: onDelete,
- icon: const Icon(Icons.delete_rounded),
+ border: Border.all(
+ color: isDark
+ ? (config.enabled
+ ? engineColor.withValues(alpha: 0.28)
+ : Colors.white.withValues(alpha: 0.08))
+ : (config.enabled
+ ? Colors.white.withValues(alpha: 0.90)
+ : Colors.white.withValues(alpha: 0.50)),
+ width: 1.2,
),
- ],
+ ),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ // Row 1: Icon squircle + Name & details + Switch
+ Row(
+ crossAxisAlignment: CrossAxisAlignment.center,
+ children: [
+ // Engine Icon Squircle Badge
+ Container(
+ width: 44,
+ height: 44,
+ decoration: BoxDecoration(
+ gradient: LinearGradient(
+ begin: Alignment.topLeft,
+ end: Alignment.bottomRight,
+ colors: config.enabled
+ ? [
+ engineColor,
+ engineColor.withValues(alpha: 0.80),
+ ]
+ : [
+ Colors.grey.shade600,
+ Colors.grey.shade700,
+ ],
+ ),
+ borderRadius: BorderRadius.circular(14),
+ boxShadow: [
+ if (config.enabled)
+ BoxShadow(
+ color: engineColor.withValues(alpha: 0.38),
+ blurRadius: 10,
+ offset: const Offset(0, 3),
+ ),
+ ],
+ ),
+ child: Center(
+ child: Icon(
+ engineIcon,
+ size: 22,
+ color: Colors.white,
+ ),
+ ),
+ ),
+ const SizedBox(width: 14),
+
+ // Name & Sub-details
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ children: [
+ Flexible(
+ child: Text(
+ config.name,
+ style: theme.textTheme.titleMedium?.copyWith(
+ fontWeight: FontWeight.w700,
+ letterSpacing: -0.2,
+ color: config.enabled
+ ? theme.colorScheme.onSurface
+ : theme.colorScheme.onSurface
+ .withValues(alpha: 0.6),
+ ),
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ ),
+ ),
+ ],
+ ),
+ const SizedBox(height: 5),
+ Wrap(
+ spacing: 6,
+ runSpacing: 4,
+ children: [
+ // Engine badge
+ _GlassPill(
+ label: engineTitle,
+ color: engineColor,
+ isDark: isDark,
+ ),
+ // Priority badge
+ _GlassPill(
+ label: '${isRu ? 'Приоритет' : 'Priority'} ${config.priority}',
+ color: theme.colorScheme.primary,
+ isDark: isDark,
+ ),
+ ],
+ ),
+ ],
+ ),
+ ),
+
+ // Switch
+ Transform.scale(
+ scale: 0.9,
+ child: Switch.adaptive(
+ value: config.enabled,
+ onChanged: (val) {
+ HapticFeedback.selectionClick();
+ onToggle(val);
+ },
+ activeThumbColor: engineColor,
+ activeTrackColor: engineColor.withValues(alpha: 0.45),
+ ),
+ ),
+ ],
+ ),
+
+ const SizedBox(height: 12),
+
+ // Row 2: Base URL glass chip
+ Container(
+ padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
+ decoration: BoxDecoration(
+ color: isDark
+ ? Colors.black.withValues(alpha: 0.20)
+ : theme.colorScheme.surfaceContainerHighest
+ .withValues(alpha: 0.40),
+ borderRadius: BorderRadius.circular(12),
+ border: Border.all(
+ color: isDark
+ ? Colors.white.withValues(alpha: 0.06)
+ : Colors.black.withValues(alpha: 0.05),
+ ),
+ ),
+ child: Row(
+ children: [
+ Icon(
+ Icons.link_rounded,
+ size: 14,
+ color: theme.colorScheme.onSurfaceVariant
+ .withValues(alpha: 0.8),
+ ),
+ const SizedBox(width: 6),
+ Expanded(
+ child: Text(
+ config.baseUrl,
+ style: TextStyle(
+ fontSize: 12,
+ fontFamily: 'monospace',
+ color: theme.colorScheme.onSurfaceVariant,
+ letterSpacing: -0.1,
+ ),
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ ),
+ ),
+ ],
+ ),
+ ),
+
+ const SizedBox(height: 12),
+
+ // Row 3: Action Buttons (Edit & Delete)
+ Row(
+ mainAxisAlignment: MainAxisAlignment.end,
+ children: [
+ // Edit Action Button
+ _GlassActionButton(
+ icon: Icons.edit_rounded,
+ label: isRu ? 'Изменить' : 'Edit',
+ color: theme.colorScheme.primary,
+ isDark: isDark,
+ onTap: () {
+ HapticFeedback.lightImpact();
+ onEdit();
+ },
+ ),
+ const SizedBox(width: 8),
+
+ // Delete Action Button
+ _GlassActionButton(
+ icon: Icons.delete_outline_rounded,
+ label: isRu ? 'Удалить' : 'Delete',
+ color: const Color(0xFFEF4444),
+ isDark: isDark,
+ onTap: () {
+ HapticFeedback.lightImpact();
+ onDelete();
+ },
+ ),
+ ],
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+
+ static (Color, IconData, String) _engineInfo(String apiType) {
+ return switch (apiType.toLowerCase()) {
+ 'gelbooru' => (
+ const Color(0xFF10B981),
+ Icons.image_search_rounded,
+ 'Gelbooru',
+ ),
+ 'rule34' => (
+ const Color(0xFF84CC16),
+ Icons.photo_library_rounded,
+ 'Rule34',
+ ),
+ 'paheal' => (
+ const Color(0xFFF97316),
+ Icons.filter_hdr_rounded,
+ 'Paheal HTML',
+ ),
+ 'realbooru_html' => (
+ const Color(0xFFEAB308),
+ Icons.camera_alt_rounded,
+ 'Realbooru HTML',
+ ),
+ 'danbooru' => (
+ const Color(0xFF3B82F6),
+ Icons.auto_awesome_rounded,
+ 'Danbooru',
+ ),
+ 'moebooru' => (
+ const Color(0xFF8B5CF6),
+ Icons.face_retouching_natural_rounded,
+ 'Moebooru',
+ ),
+ 'e621' => (
+ const Color(0xFFF59E0B),
+ Icons.pets_rounded,
+ 'e621 / e926',
+ ),
+ 'kemono' => (
+ const Color(0xFFEC4899),
+ Icons.palette_rounded,
+ 'Kemono',
+ ),
+ 'coomer' => (
+ const Color(0xFF06B6D4),
+ Icons.collections_rounded,
+ 'Coomer',
+ ),
+ _ => (
+ const Color(0xFF6366F1),
+ Icons.hub_rounded,
+ apiType.isEmpty ? 'Custom' : apiType,
+ ),
+ };
+ }
+}
+
+class _GlassPill extends StatelessWidget {
+ const _GlassPill({
+ required this.label,
+ required this.color,
+ required this.isDark,
+ });
+
+ final String label;
+ final Color color;
+ final bool isDark;
+
+ @override
+ Widget build(BuildContext context) {
+ return Container(
+ padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
+ decoration: BoxDecoration(
+ color: color.withValues(alpha: isDark ? 0.16 : 0.10),
+ borderRadius: BorderRadius.circular(10),
+ border: Border.all(
+ color: color.withValues(alpha: isDark ? 0.35 : 0.25),
+ width: 1,
+ ),
+ ),
+ child: Text(
+ label,
+ style: TextStyle(
+ fontSize: 11,
+ fontWeight: FontWeight.w700,
+ color: color,
+ letterSpacing: -0.2,
+ ),
+ ),
+ );
+ }
+}
+
+class _GlassActionButton extends StatelessWidget {
+ const _GlassActionButton({
+ required this.icon,
+ required this.label,
+ required this.color,
+ required this.isDark,
+ required this.onTap,
+ });
+
+ final IconData icon;
+ final String label;
+ final Color color;
+ final bool isDark;
+ final VoidCallback onTap;
+
+ @override
+ Widget build(BuildContext context) {
+ return Material(
+ color: Colors.transparent,
+ borderRadius: BorderRadius.circular(12),
+ child: InkWell(
+ borderRadius: BorderRadius.circular(12),
+ onTap: onTap,
+ child: Container(
+ padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
+ decoration: BoxDecoration(
+ color: color.withValues(alpha: isDark ? 0.14 : 0.08),
+ borderRadius: BorderRadius.circular(12),
+ border: Border.all(
+ color: color.withValues(alpha: isDark ? 0.32 : 0.22),
+ width: 1,
+ ),
+ ),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Icon(icon, size: 14, color: color),
+ const SizedBox(width: 5),
+ Text(
+ label,
+ style: TextStyle(
+ fontSize: 12,
+ fontWeight: FontWeight.w600,
+ color: color,
+ letterSpacing: -0.1,
+ ),
+ ),
+ ],
+ ),
),
),
);
diff --git a/lib/features/providers/presentation/widgets/provider_health_tile.dart b/lib/features/providers/presentation/widgets/provider_health_tile.dart
index 6102ec1..57db66f 100644
--- a/lib/features/providers/presentation/widgets/provider_health_tile.dart
+++ b/lib/features/providers/presentation/widgets/provider_health_tile.dart
@@ -1,9 +1,10 @@
import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
import '../../../../backend/backend.dart';
import '../../../../shared/widgets/provider_status_badge.dart';
-class ProviderHealthTile extends StatelessWidget {
+class ProviderHealthTile extends StatefulWidget {
const ProviderHealthTile({
required this.config,
required this.health,
@@ -17,59 +18,408 @@ class ProviderHealthTile extends StatelessWidget {
final ProviderDiagnostics? diagnostics;
final VoidCallback onCheck;
+ @override
+ State createState() => _ProviderHealthTileState();
+}
+
+class _ProviderHealthTileState extends State {
+ bool _expanded = false;
+
@override
Widget build(BuildContext context) {
+ final theme = Theme.of(context);
+ final isDark = theme.brightness == Brightness.dark;
+ final isRu = Localizations.maybeLocaleOf(context)?.languageCode == 'ru';
+ final config = widget.config;
+ final health = widget.health;
+ final diagnostics = widget.diagnostics;
+
final hint = ProviderQualityHint.from(
config: config,
health: health,
diagnostics: diagnostics,
+ isRu: isRu,
);
- return Card(
- child: ExpansionTile(
- leading: ProviderStatusBadge(health: health),
- title: Text(config.name),
- subtitle: Wrap(
- spacing: 8,
- runSpacing: 6,
- crossAxisAlignment: WrapCrossAlignment.center,
- children: [
- Chip(
- avatar: Icon(hint.icon, size: 16),
- label: Text(hint.label),
- visualDensity: VisualDensity.compact,
- backgroundColor: hint.color(context).withValues(alpha: 0.16),
- side:
- BorderSide(color: hint.color(context).withValues(alpha: 0.4)),
- ),
- Text('${config.apiType} | priority ${config.priority}'),
- ],
- ),
- trailing: IconButton(
- tooltip: 'Check',
- onPressed: onCheck,
- icon: const Icon(Icons.network_check_rounded),
- ),
- childrenPadding: const EdgeInsets.fromLTRB(16, 0, 16, 14),
- children: [
- Align(
- alignment: Alignment.centerLeft,
- child: Text(
- [
- '${config.enabled ? 'Enabled' : 'Disabled'} | ${config.baseUrl}',
- if (health != null) 'Ping ${health!.pingMs} ms',
- if (health?.apiVersion != null) 'API ${health!.apiVersion}',
- if (health?.lastCheckedAt != null)
- 'Checked ${health!.lastCheckedAt}',
- if (diagnostics != null)
- 'Last search: ${diagnostics!.lastResultCount} posts at ${diagnostics!.lastSearchAt}',
- if (diagnostics?.lastErrorMessage != null)
- diagnostics!.lastErrorMessage!,
- if (health?.errorMessage != null) health!.errorMessage!,
- ].join('\n'),
- ),
+
+ final status = health?.status ?? ProviderStatus.unknown;
+ final statusColor = switch (status) {
+ ProviderStatus.online => (health != null && health.pingMs > 2500)
+ ? const Color(0xFFF59E0B)
+ : const Color(0xFF10B981),
+ ProviderStatus.offline => const Color(0xFFEF4444),
+ ProviderStatus.unknown => const Color(0xFF94A3B8),
+ };
+
+ final hasError = diagnostics?.lastErrorMessage != null ||
+ health?.errorMessage != null;
+ final errorMessage = diagnostics?.lastErrorMessage ?? health?.errorMessage;
+
+ return Container(
+ margin: const EdgeInsets.only(bottom: 14),
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(22),
+ boxShadow: [
+ BoxShadow(
+ color: isDark
+ ? Colors.black.withValues(alpha: 0.30)
+ : statusColor.withValues(alpha: 0.08),
+ blurRadius: 18,
+ offset: const Offset(0, 6),
+ spreadRadius: -2,
+ ),
+ BoxShadow(
+ color: statusColor.withValues(alpha: isDark ? 0.06 : 0.03),
+ blurRadius: 10,
+ offset: const Offset(0, 2),
),
],
),
+ child: ClipRRect(
+ borderRadius: BorderRadius.circular(22),
+ child: AnimatedContainer(
+ duration: const Duration(milliseconds: 250),
+ curve: Curves.easeOutCubic,
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(22),
+ gradient: LinearGradient(
+ begin: Alignment.topLeft,
+ end: Alignment.bottomRight,
+ colors: isDark
+ ? [
+ theme.colorScheme.surfaceContainerHigh
+ .withValues(alpha: 0.65),
+ theme.colorScheme.surfaceContainerLow
+ .withValues(alpha: 0.40),
+ ]
+ : [
+ Colors.white.withValues(alpha: 0.92),
+ Colors.white.withValues(alpha: 0.78),
+ ],
+ ),
+ border: Border.all(
+ color: isDark
+ ? statusColor.withValues(alpha: 0.30)
+ : Colors.white.withValues(alpha: 0.90),
+ width: 1.2,
+ ),
+ ),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ // Header Tile
+ InkWell(
+ onTap: () {
+ HapticFeedback.selectionClick();
+ setState(() => _expanded = !_expanded);
+ },
+ borderRadius: BorderRadius.circular(22),
+ child: Padding(
+ padding: const EdgeInsets.all(16),
+ child: Row(
+ children: [
+ // Status Badge with glowing LED
+ ProviderStatusBadge(health: health),
+ const SizedBox(width: 12),
+
+ // Name & Badges
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ config.name,
+ style: theme.textTheme.titleSmall?.copyWith(
+ fontWeight: FontWeight.w700,
+ letterSpacing: -0.2,
+ ),
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ ),
+ const SizedBox(height: 5),
+ Wrap(
+ spacing: 6,
+ runSpacing: 4,
+ crossAxisAlignment: WrapCrossAlignment.center,
+ children: [
+ // Ping chip if available
+ if (health != null)
+ Container(
+ padding: const EdgeInsets.symmetric(
+ horizontal: 7, vertical: 2.5),
+ decoration: BoxDecoration(
+ color: statusColor.withValues(
+ alpha: isDark ? 0.16 : 0.10),
+ borderRadius: BorderRadius.circular(8),
+ border: Border.all(
+ color: statusColor.withValues(
+ alpha: isDark ? 0.38 : 0.25),
+ width: 1,
+ ),
+ ),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Icon(
+ Icons.bolt_rounded,
+ size: 12,
+ color: statusColor,
+ ),
+ const SizedBox(width: 3),
+ Text(
+ '${health.pingMs} ms',
+ style: TextStyle(
+ fontSize: 11,
+ fontWeight: FontWeight.w700,
+ color: statusColor,
+ fontFamily: 'monospace',
+ ),
+ ),
+ ],
+ ),
+ ),
+
+ // Quality Hint Chip
+ Container(
+ padding: const EdgeInsets.symmetric(
+ horizontal: 7, vertical: 2.5),
+ decoration: BoxDecoration(
+ color: hint.color(context).withValues(
+ alpha: isDark ? 0.14 : 0.08),
+ borderRadius: BorderRadius.circular(8),
+ border: Border.all(
+ color: hint.color(context).withValues(
+ alpha: isDark ? 0.32 : 0.20),
+ width: 1,
+ ),
+ ),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Icon(
+ hint.icon,
+ size: 12,
+ color: hint.color(context),
+ ),
+ const SizedBox(width: 4),
+ Text(
+ hint.label,
+ style: TextStyle(
+ fontSize: 11,
+ fontWeight: FontWeight.w600,
+ color: hint.color(context),
+ letterSpacing: -0.1,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ ],
+ ),
+ ),
+
+ // Check Button
+ IconButton(
+ tooltip: isRu ? 'Проверить' : 'Check',
+ onPressed: () {
+ HapticFeedback.lightImpact();
+ widget.onCheck();
+ },
+ icon: Container(
+ width: 34,
+ height: 34,
+ decoration: BoxDecoration(
+ color: theme.colorScheme.primary
+ .withValues(alpha: isDark ? 0.18 : 0.10),
+ shape: BoxShape.circle,
+ border: Border.all(
+ color: theme.colorScheme.primary
+ .withValues(alpha: isDark ? 0.40 : 0.25),
+ ),
+ ),
+ child: Center(
+ child: Icon(
+ Icons.refresh_rounded,
+ size: 18,
+ color: theme.colorScheme.primary,
+ ),
+ ),
+ ),
+ ),
+
+ // Expand Chevron
+ Icon(
+ _expanded
+ ? Icons.keyboard_arrow_up_rounded
+ : Icons.keyboard_arrow_down_rounded,
+ size: 20,
+ color: theme.colorScheme.onSurfaceVariant,
+ ),
+ ],
+ ),
+ ),
+ ),
+
+ // Expanded Diagnostics Section
+ if (_expanded) ...[
+ Divider(
+ height: 1,
+ thickness: 1,
+ color: isDark
+ ? Colors.white.withValues(alpha: 0.08)
+ : Colors.black.withValues(alpha: 0.05),
+ ),
+ Padding(
+ padding: const EdgeInsets.fromLTRB(16, 14, 16, 16),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ // Diagnostic row 1: URL & Enabled
+ _DiagItem(
+ icon: Icons.link_rounded,
+ label: isRu ? 'Сервер' : 'Endpoint',
+ value: '${config.baseUrl} (${config.enabled ? (isRu ? 'Активен' : 'Enabled') : (isRu ? 'Выключен' : 'Disabled')})',
+ isMonospace: true,
+ ),
+ const SizedBox(height: 8),
+
+ // Diagnostic row 2: API Type & Priority
+ _DiagItem(
+ icon: Icons.hub_outlined,
+ label: isRu ? 'Движок и приоритет' : 'Engine & Priority',
+ value: '${config.apiType} · ${isRu ? 'приоритет' : 'priority'} ${config.priority}',
+ ),
+ const SizedBox(height: 8),
+
+ // Diagnostic row 3: API Version
+ if (health?.apiVersion != null) ...[
+ _DiagItem(
+ icon: Icons.info_outline_rounded,
+ label: isRu ? 'Версия API' : 'API Version',
+ value: health!.apiVersion!,
+ ),
+ const SizedBox(height: 8),
+ ],
+
+ // Diagnostic row 4: Last Checked Time
+ if (health?.lastCheckedAt != null) ...[
+ _DiagItem(
+ icon: Icons.schedule_rounded,
+ label: isRu ? 'Проверено' : 'Last Checked',
+ value: health!.lastCheckedAt.toString().split('.').first,
+ isMonospace: true,
+ ),
+ const SizedBox(height: 8),
+ ],
+
+ // Diagnostic row 5: Last Search
+ if (diagnostics != null) ...[
+ _DiagItem(
+ icon: Icons.search_rounded,
+ label: isRu ? 'Последний поиск' : 'Last Search',
+ value: isRu
+ ? '${diagnostics.lastResultCount} постов получено в ${diagnostics.lastSearchAt.toString().split('.').first}'
+ : '${diagnostics.lastResultCount} posts retrieved at ${diagnostics.lastSearchAt.toString().split('.').first}',
+ ),
+ const SizedBox(height: 8),
+ ],
+
+ // Error Box (if any)
+ if (hasError && errorMessage != null) ...[
+ const SizedBox(height: 4),
+ Container(
+ padding: const EdgeInsets.all(12),
+ decoration: BoxDecoration(
+ color: const Color(0xFFEF4444)
+ .withValues(alpha: isDark ? 0.16 : 0.08),
+ borderRadius: BorderRadius.circular(14),
+ border: Border.all(
+ color: const Color(0xFFEF4444)
+ .withValues(alpha: isDark ? 0.38 : 0.25),
+ ),
+ ),
+ child: Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ const Icon(
+ Icons.error_outline_rounded,
+ size: 16,
+ color: Color(0xFFEF4444),
+ ),
+ const SizedBox(width: 8),
+ Expanded(
+ child: Text(
+ errorMessage,
+ style: const TextStyle(
+ fontSize: 12,
+ fontFamily: 'monospace',
+ color: Color(0xFFEF4444),
+ height: 1.35,
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ],
+ ],
+ ),
+ ),
+ ],
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+}
+
+class _DiagItem extends StatelessWidget {
+ const _DiagItem({
+ required this.icon,
+ required this.label,
+ required this.value,
+ this.isMonospace = false,
+ });
+
+ final IconData icon;
+ final String label;
+ final String value;
+ final bool isMonospace;
+
+ @override
+ Widget build(BuildContext context) {
+ final theme = Theme.of(context);
+
+ return Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Icon(
+ icon,
+ size: 15,
+ color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
+ ),
+ const SizedBox(width: 8),
+ Text(
+ '$label: ',
+ style: TextStyle(
+ fontSize: 12,
+ fontWeight: FontWeight.w600,
+ color: theme.colorScheme.onSurfaceVariant,
+ ),
+ ),
+ Expanded(
+ child: Text(
+ value,
+ style: TextStyle(
+ fontSize: 12,
+ fontFamily: isMonospace ? 'monospace' : null,
+ color: theme.colorScheme.onSurface,
+ ),
+ ),
+ ),
+ ],
);
}
}
@@ -87,10 +437,11 @@ class ProviderQualityHint {
required ContentProviderConfig config,
required ProviderHealth? health,
required ProviderDiagnostics? diagnostics,
+ required bool isRu,
}) {
if (!config.enabled) {
return ProviderQualityHint._(
- 'Disabled',
+ isRu ? 'Выключен' : 'Disabled',
Icons.pause_circle_outline_rounded,
(scheme) => scheme.outline,
);
@@ -98,34 +449,34 @@ class ProviderQualityHint {
if (diagnostics?.lastErrorMessage != null ||
health?.status == ProviderStatus.offline) {
return ProviderQualityHint._(
- 'Search failing',
+ isRu ? 'Ошибка поиска' : 'Search failing',
Icons.error_outline_rounded,
(scheme) => scheme.error,
);
}
if (diagnostics != null && diagnostics.lastResultCount == 0) {
return ProviderQualityHint._(
- 'No recent results',
+ isRu ? 'Нет результатов' : 'No recent results',
Icons.search_off_rounded,
(scheme) => scheme.tertiary,
);
}
if (health != null && health.pingMs > 2500) {
return ProviderQualityHint._(
- 'Slow',
+ isRu ? 'Медленно' : 'Slow',
Icons.speed_rounded,
(scheme) => scheme.secondary,
);
}
if (health != null || diagnostics != null) {
return ProviderQualityHint._(
- 'Healthy',
+ isRu ? 'Стабилен' : 'Healthy',
Icons.check_circle_outline_rounded,
(scheme) => scheme.primary,
);
}
return ProviderQualityHint._(
- 'Not checked',
+ isRu ? 'Не проверен' : 'Not checked',
Icons.help_outline_rounded,
(scheme) => scheme.outline,
);
diff --git a/lib/shared/widgets/app_shell.dart b/lib/shared/widgets/app_shell.dart
index 1cb2f6d..86acd16 100644
--- a/lib/shared/widgets/app_shell.dart
+++ b/lib/shared/widgets/app_shell.dart
@@ -10,7 +10,7 @@ import '../../app/responsive.dart';
import '../../backend/backend.dart';
import '../../core/utils/result.dart';
-class AppShell extends ConsumerWidget {
+class AppShell extends ConsumerStatefulWidget {
const AppShell({required this.child, super.key});
final Widget child;
@@ -33,69 +33,6 @@ class AppShell extends ConsumerWidget {
_Destination('settings', 'Settings', Icons.settings_rounded, '/settings'),
];
- @override
- Widget build(BuildContext context, WidgetRef ref) {
- final settings =
- ref.watch(appSettingsProvider).value ?? AppSettings.defaults;
- final artistConfigs =
- ref.watch(_enabledArtistConfigsProvider).value ?? const [];
- final destinations =
- _visibleDestinations(settings, artistConfigs.isNotEmpty);
- final ru = settings.languageCode == 'ru';
- return Shortcuts(
- shortcuts: {
- LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyF):
- const _NavigateIntent('/search'),
- LogicalKeySet(LogicalKeyboardKey.escape): const _BackIntent(),
- LogicalKeySet(LogicalKeyboardKey.digit1): const _NavigateIntent('/'),
- LogicalKeySet(LogicalKeyboardKey.digit2):
- const _NavigateIntent('/search'),
- LogicalKeySet(LogicalKeyboardKey.digit3):
- const _NavigateIntent('/favorites'),
- LogicalKeySet(LogicalKeyboardKey.digit4):
- const _NavigateIntent('/viewed'),
- LogicalKeySet(LogicalKeyboardKey.digit5):
- const _NavigateIntent('/collections'),
- LogicalKeySet(LogicalKeyboardKey.digit6):
- const _NavigateIntent('/artists'),
- LogicalKeySet(LogicalKeyboardKey.digit7):
- const _NavigateIntent('/providers'),
- LogicalKeySet(LogicalKeyboardKey.digit8):
- const _NavigateIntent('/settings'),
- },
- child: Actions(
- actions: {
- _NavigateIntent: CallbackAction<_NavigateIntent>(
- onInvoke: (intent) {
- context.go(intent.location);
- return null;
- },
- ),
- _BackIntent: CallbackAction<_BackIntent>(
- onInvoke: (_) {
- if (Navigator.of(context).canPop()) Navigator.of(context).pop();
- return null;
- },
- ),
- },
- child: Focus(
- autofocus: true,
- child: Responsive.isDesktop(context)
- ? _DesktopShell(
- destinations: destinations,
- ru: ru,
- child: child,
- )
- : _MobileShell(
- destinations: destinations,
- ru: ru,
- child: child,
- ),
- ),
- ),
- );
- }
-
static List<_Destination> _visibleDestinations(
AppSettings settings,
bool hasArtists,
@@ -106,6 +43,139 @@ class AppShell extends ConsumerWidget {
return true;
}).toList(growable: false);
}
+
+ @override
+ ConsumerState createState() => _AppShellState();
+}
+
+class _AppShellState extends ConsumerState {
+ final List _tabHistory = [];
+ String? _currentPath;
+ bool _isBackNavigating = false;
+
+ @override
+ void didChangeDependencies() {
+ super.didChangeDependencies();
+ final path = GoRouterState.of(context).uri.path;
+ if (_isBackNavigating) {
+ _isBackNavigating = false;
+ _currentPath = path;
+ return;
+ }
+ if (_currentPath != null && _currentPath != path) {
+ if (path == '/') {
+ _tabHistory.clear();
+ } else {
+ _tabHistory.remove(_currentPath);
+ _tabHistory.add(_currentPath!);
+ if (_tabHistory.length > 25) {
+ _tabHistory.removeAt(0);
+ }
+ }
+ }
+ _currentPath = path;
+ }
+
+ bool _handleBack() {
+ if (Navigator.of(context).canPop()) {
+ Navigator.of(context).pop();
+ return true;
+ }
+
+ final currentPath = GoRouterState.of(context).uri.path;
+ while (_tabHistory.isNotEmpty && _tabHistory.last == currentPath) {
+ _tabHistory.removeLast();
+ }
+
+ if (_tabHistory.isNotEmpty) {
+ final previous = _tabHistory.removeLast();
+ _isBackNavigating = true;
+ context.go(previous);
+ return true;
+ }
+
+ if (currentPath != '/') {
+ _isBackNavigating = true;
+ context.go('/');
+ return true;
+ }
+
+ return false;
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final settings =
+ ref.watch(appSettingsProvider).value ?? AppSettings.defaults;
+ final artistConfigs =
+ ref.watch(_enabledArtistConfigsProvider).value ?? const [];
+ final destinations =
+ AppShell._visibleDestinations(settings, artistConfigs.isNotEmpty);
+ final ru = settings.languageCode == 'ru';
+
+ return PopScope(
+ canPop: false,
+ onPopInvokedWithResult: (didPop, _) {
+ if (didPop) return;
+ final handled = _handleBack();
+ if (!handled) {
+ SystemNavigator.pop();
+ }
+ },
+ child: Shortcuts(
+ shortcuts: {
+ LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyF):
+ const _NavigateIntent('/search'),
+ LogicalKeySet(LogicalKeyboardKey.escape): const _BackIntent(),
+ LogicalKeySet(LogicalKeyboardKey.digit1): const _NavigateIntent('/'),
+ LogicalKeySet(LogicalKeyboardKey.digit2):
+ const _NavigateIntent('/search'),
+ LogicalKeySet(LogicalKeyboardKey.digit3):
+ const _NavigateIntent('/favorites'),
+ LogicalKeySet(LogicalKeyboardKey.digit4):
+ const _NavigateIntent('/viewed'),
+ LogicalKeySet(LogicalKeyboardKey.digit5):
+ const _NavigateIntent('/collections'),
+ LogicalKeySet(LogicalKeyboardKey.digit6):
+ const _NavigateIntent('/artists'),
+ LogicalKeySet(LogicalKeyboardKey.digit7):
+ const _NavigateIntent('/providers'),
+ LogicalKeySet(LogicalKeyboardKey.digit8):
+ const _NavigateIntent('/settings'),
+ },
+ child: Actions(
+ actions: {
+ _NavigateIntent: CallbackAction<_NavigateIntent>(
+ onInvoke: (intent) {
+ context.go(intent.location);
+ return null;
+ },
+ ),
+ _BackIntent: CallbackAction<_BackIntent>(
+ onInvoke: (_) {
+ _handleBack();
+ return null;
+ },
+ ),
+ },
+ child: Focus(
+ autofocus: true,
+ child: Responsive.isDesktop(context)
+ ? _DesktopShell(
+ destinations: destinations,
+ ru: ru,
+ child: widget.child,
+ )
+ : _MobileShell(
+ destinations: destinations,
+ ru: ru,
+ child: widget.child,
+ ),
+ ),
+ ),
+ ),
+ );
+ }
}
class _DesktopShell extends StatefulWidget {
diff --git a/lib/shared/widgets/provider_status_badge.dart b/lib/shared/widgets/provider_status_badge.dart
index ea16cdf..19b75ba 100644
--- a/lib/shared/widgets/provider_status_badge.dart
+++ b/lib/shared/widgets/provider_status_badge.dart
@@ -3,22 +3,91 @@ import 'package:flutter/material.dart';
import '../../backend/backend.dart';
class ProviderStatusBadge extends StatelessWidget {
- const ProviderStatusBadge({required this.health, super.key});
+ const ProviderStatusBadge({
+ required this.health,
+ this.compact = false,
+ super.key,
+ });
final ProviderHealth? health;
+ final bool compact;
@override
Widget build(BuildContext context) {
+ final theme = Theme.of(context);
+ final isDark = theme.brightness == Brightness.dark;
final status = health?.status ?? ProviderStatus.unknown;
- final color = switch (status) {
- ProviderStatus.online => Colors.green,
- ProviderStatus.offline => Colors.red,
- ProviderStatus.unknown => Colors.grey,
+
+ final (color, labelRu, labelEn) = switch (status) {
+ ProviderStatus.online => (
+ const Color(0xFF10B981), // Emerald
+ 'Онлайн',
+ 'Online',
+ ),
+ ProviderStatus.offline => (
+ const Color(0xFFEF4444), // Crimson Red
+ 'Офлайн',
+ 'Offline',
+ ),
+ ProviderStatus.unknown => (
+ const Color(0xFF94A3B8), // Slate
+ 'Не проверен',
+ 'Unknown',
+ ),
};
- return Chip(
- avatar: Icon(Icons.circle, color: color, size: 10),
- label: Text(status.name),
- visualDensity: VisualDensity.compact,
+
+ final isRu = Localizations.maybeLocaleOf(context)?.languageCode == 'ru';
+ final label = isRu ? labelRu : labelEn;
+
+ return Container(
+ padding: EdgeInsets.symmetric(
+ horizontal: compact ? 8 : 10,
+ vertical: compact ? 3 : 5,
+ ),
+ decoration: BoxDecoration(
+ color: color.withValues(alpha: isDark ? 0.16 : 0.10),
+ borderRadius: BorderRadius.circular(14),
+ border: Border.all(
+ color: color.withValues(alpha: isDark ? 0.45 : 0.32),
+ width: 1.1,
+ ),
+ boxShadow: [
+ BoxShadow(
+ color: color.withValues(alpha: isDark ? 0.18 : 0.08),
+ blurRadius: 6,
+ offset: const Offset(0, 1.5),
+ ),
+ ],
+ ),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Container(
+ width: 7,
+ height: 7,
+ decoration: BoxDecoration(
+ color: color,
+ shape: BoxShape.circle,
+ boxShadow: [
+ BoxShadow(
+ color: color.withValues(alpha: 0.8),
+ blurRadius: 5,
+ spreadRadius: 0.5,
+ ),
+ ],
+ ),
+ ),
+ const SizedBox(width: 6),
+ Text(
+ label,
+ style: theme.textTheme.labelSmall?.copyWith(
+ color: color,
+ fontWeight: FontWeight.w700,
+ letterSpacing: -0.2,
+ ),
+ ),
+ ],
+ ),
);
}
}
diff --git a/pubspec.yaml b/pubspec.yaml
index 614149d..514b584 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -1,7 +1,7 @@
name: gel_rule_app
description: Local backend/core layer for a Flutter booru browser app.
publish_to: "none"
-version: 3.1.2+63
+version: 3.1.3+64
environment:
sdk: ">=3.3.0 <4.0.0"
diff --git a/test/features/providers_redesign_test.dart b/test/features/providers_redesign_test.dart
new file mode 100644
index 0000000..5869ca9
--- /dev/null
+++ b/test/features/providers_redesign_test.dart
@@ -0,0 +1,155 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:gel_rule_app/backend/backend.dart';
+import 'package:gel_rule_app/features/providers/presentation/provider_check_screen.dart';
+import 'package:gel_rule_app/features/providers/presentation/provider_form_screen.dart';
+import 'package:gel_rule_app/features/providers/presentation/providers_controller.dart';
+import 'package:gel_rule_app/features/providers/presentation/providers_screen.dart';
+
+ContentProviderConfig _sampleConfig({
+ required String id,
+ required String name,
+ String apiType = 'gelbooru',
+ String baseUrl = 'https://gelbooru.com',
+ bool enabled = true,
+ int priority = 10,
+}) {
+ return ContentProviderConfig(
+ id: id,
+ name: name,
+ baseUrl: baseUrl,
+ apiType: apiType,
+ enabled: enabled,
+ priority: priority,
+ timeoutSeconds: 20,
+ customHeaders: const {},
+ createdAt: DateTime(2025, 1, 1),
+ updatedAt: DateTime(2025, 1, 1),
+ );
+}
+
+class _FakeProvidersController extends ProvidersController {
+ _FakeProvidersController(this._items);
+ final List _items;
+
+ @override
+ Future> build() async => _items;
+}
+
+void main() {
+ setUp(() {
+ TestWidgetsFlutterBinding.ensureInitialized();
+ });
+
+ testWidgets('ProvidersScreen renders overview card, provider cards, and actions',
+ (tester) async {
+ tester.view.physicalSize = const Size(390, 844);
+ tester.view.devicePixelRatio = 1.0;
+ addTearDown(() {
+ tester.view.resetPhysicalSize();
+ tester.view.resetDevicePixelRatio();
+ });
+
+ final sampleProviders = [
+ _sampleConfig(id: 'gelbooru', name: 'Gelbooru', apiType: 'gelbooru'),
+ _sampleConfig(
+ id: 'rule34',
+ name: 'Rule34',
+ apiType: 'rule34',
+ baseUrl: 'https://api.rule34.xxx'),
+ ];
+
+ await tester.pumpWidget(
+ ProviderScope(
+ overrides: [
+ providersControllerProvider.overrideWith(
+ () => _FakeProvidersController(sampleProviders),
+ ),
+ ],
+ child: const MaterialApp(
+ home: ProvidersScreen(),
+ ),
+ ),
+ );
+
+ await tester.pumpAndSettle();
+
+ expect(find.text('Gelbooru'), findsWidgets);
+ expect(find.text('Rule34'), findsWidgets);
+ expect(find.byType(Switch), findsNWidgets(2));
+ expect(find.byIcon(Icons.network_check_rounded), findsWidgets);
+ expect(find.byIcon(Icons.add_rounded), findsOneWidget);
+ });
+
+ testWidgets('ProviderFormScreen renders presets, sections, and input fields',
+ (tester) async {
+ tester.view.physicalSize = const Size(400, 1600);
+ tester.view.devicePixelRatio = 1.0;
+ addTearDown(() {
+ tester.view.resetPhysicalSize();
+ tester.view.resetDevicePixelRatio();
+ });
+
+ await tester.pumpWidget(
+ const ProviderScope(
+ child: MaterialApp(
+ home: ProviderFormScreen(),
+ ),
+ ),
+ );
+
+ await tester.pumpAndSettle();
+
+ // Check presets are visible
+ expect(find.text('Danbooru'), findsOneWidget);
+ expect(find.text('e621'), findsOneWidget);
+ expect(find.byIcon(Icons.check_rounded), findsOneWidget);
+ });
+
+ testWidgets('ProviderCheckScreen renders metrics and health tiles',
+ (tester) async {
+ tester.view.physicalSize = const Size(390, 844);
+ tester.view.devicePixelRatio = 1.0;
+ addTearDown(() {
+ tester.view.resetPhysicalSize();
+ tester.view.resetDevicePixelRatio();
+ });
+
+ final sampleProviders = [
+ _sampleConfig(id: 'gelbooru', name: 'Gelbooru', apiType: 'gelbooru'),
+ ];
+
+ await tester.pumpWidget(
+ ProviderScope(
+ overrides: [
+ providersControllerProvider.overrideWith(
+ () => _FakeProvidersController(sampleProviders),
+ ),
+ providerDiagnosticsProvider.overrideWith(
+ (ref) async => const {},
+ ),
+ providerHealthProvider.overrideWith(
+ (ref) => {
+ 'gelbooru': ProviderHealth(
+ providerId: 'gelbooru',
+ status: ProviderStatus.online,
+ pingMs: 85,
+ apiVersion: '0.2.5',
+ lastCheckedAt: DateTime(2026, 1, 1),
+ ),
+ },
+ ),
+ ],
+ child: const MaterialApp(
+ home: ProviderCheckScreen(),
+ ),
+ ),
+ );
+
+ await tester.pumpAndSettle();
+
+ expect(find.text('Gelbooru'), findsOneWidget);
+ expect(find.text('85 ms'), findsOneWidget);
+ });
+}