From 254f0e3d5930249559088a0fbf4b6c36b1ed77e1 Mon Sep 17 00:00:00 2001 From: RarDog Date: Fri, 4 Sep 2026 21:29:25 +0300 Subject: [PATCH] feat: Liquid Glass redesign for providers and network check, and tab back navigation (v3.1.3) --- README.md | 2 +- lib/app/app_version.dart | 4 +- lib/app/changelog.dart | 10 + .../presentation/provider_check_screen.dart | 386 +++++- .../presentation/provider_form_screen.dart | 1034 +++++++++++++++-- .../presentation/providers_screen.dart | 402 ++++++- .../presentation/widgets/provider_card.dart | 408 ++++++- .../widgets/provider_health_tile.dart | 451 ++++++- lib/shared/widgets/app_shell.dart | 198 +++- lib/shared/widgets/provider_status_badge.dart | 87 +- pubspec.yaml | 2 +- test/features/providers_redesign_test.dart | 155 +++ 12 files changed, 2802 insertions(+), 337 deletions(-) create mode 100644 test/features/providers_redesign_test.dart diff --git a/README.md b/README.md index 80f06f7..34260fa 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@

- Version 3.1.2 + Version 3.1.3 Flutter Platforms Wiki 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); + }); +}