feat: Liquid Glass redesign for providers and network check, and tab back navigation (v3.1.3)
This commit is contained in:
@@ -10,7 +10,7 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://github.com/RarDog/Lunaris/releases"><img src="https://img.shields.io/badge/Version-3.1.2-8A2BE2?style=for-the-badge&logo=semver&logoColor=white" alt="Version 3.1.2" /></a>
|
<a href="https://github.com/RarDog/Lunaris/releases"><img src="https://img.shields.io/badge/Version-3.1.3-8A2BE2?style=for-the-badge&logo=semver&logoColor=white" alt="Version 3.1.3" /></a>
|
||||||
<img src="https://img.shields.io/badge/Flutter-3.47+-02569B?style=for-the-badge&logo=flutter&logoColor=white" alt="Flutter" />
|
<img src="https://img.shields.io/badge/Flutter-3.47+-02569B?style=for-the-badge&logo=flutter&logoColor=white" alt="Flutter" />
|
||||||
<img src="https://img.shields.io/badge/Platform-Android%20%7C%20Linux%20%7C%20Windows-00C853?style=for-the-badge" alt="Platforms" />
|
<img src="https://img.shields.io/badge/Platform-Android%20%7C%20Linux%20%7C%20Windows-00C853?style=for-the-badge" alt="Platforms" />
|
||||||
<a href="https://github.com/RarDog/Lunaris/wiki"><img src="https://img.shields.io/badge/Wiki-Documentation-FF6F00?style=for-the-badge&logo=gitbook&logoColor=white" alt="Wiki" /></a>
|
<a href="https://github.com/RarDog/Lunaris/wiki"><img src="https://img.shields.io/badge/Wiki-Documentation-FF6F00?style=for-the-badge&logo=gitbook&logoColor=white" alt="Wiki" /></a>
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
const appDisplayVersion = '3.1.2';
|
const appDisplayVersion = '3.1.3';
|
||||||
const appBuildNumber = 63;
|
const appBuildNumber = 64;
|
||||||
const appReleaseChannel = 'Stable';
|
const appReleaseChannel = 'Stable';
|
||||||
|
|||||||
@@ -1,4 +1,14 @@
|
|||||||
const lunarisChangelog = [
|
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(
|
LunarisChange(
|
||||||
version: '3.1.2',
|
version: '3.1.2',
|
||||||
title: 'Оптимизация производительности: устранение лагов при скролле к тегам',
|
title: 'Оптимизация производительности: устранение лагов при скролле к тегам',
|
||||||
|
|||||||
@@ -1,56 +1,334 @@
|
|||||||
|
import 'dart:ui';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
import '../../../backend/backend.dart';
|
import '../../../backend/backend.dart';
|
||||||
import '../../../core/utils/result.dart';
|
import '../../../core/utils/result.dart';
|
||||||
import '../../../shared/widgets/adaptive_scaffold.dart';
|
|
||||||
import '../../../shared/widgets/error_view.dart';
|
import '../../../shared/widgets/error_view.dart';
|
||||||
import 'providers_controller.dart';
|
import 'providers_controller.dart';
|
||||||
import 'widgets/provider_health_tile.dart';
|
import 'widgets/provider_health_tile.dart';
|
||||||
|
|
||||||
class ProviderCheckScreen extends ConsumerWidget {
|
class ProviderCheckScreen extends ConsumerStatefulWidget {
|
||||||
const ProviderCheckScreen({super.key});
|
const ProviderCheckScreen({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
ConsumerState<ProviderCheckScreen> createState() =>
|
||||||
|
_ProviderCheckScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ProviderCheckScreenState extends ConsumerState<ProviderCheckScreen> {
|
||||||
|
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 providers = ref.watch(providersControllerProvider);
|
||||||
final health = ref.watch(providerHealthProvider);
|
final health = ref.watch(providerHealthProvider);
|
||||||
final diagnostics = ref.watch(providerDiagnosticsProvider).value ?? {};
|
final diagnostics = ref.watch(providerDiagnosticsProvider).value ?? {};
|
||||||
return AdaptiveScaffold(
|
final bottomInset = MediaQuery.paddingOf(context).bottom;
|
||||||
title: 'Provider Check',
|
|
||||||
actions: [
|
return Scaffold(
|
||||||
IconButton(
|
extendBodyBehindAppBar: true,
|
||||||
tooltip: 'Check all',
|
appBar: PreferredSize(
|
||||||
onPressed: () => _checkAll(ref),
|
preferredSize: const Size.fromHeight(60),
|
||||||
icon: const Icon(Icons.playlist_add_check_rounded),
|
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(
|
body: providers.when(
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
error: (error, _) => ErrorView(message: error.toString()),
|
error: (error, _) => ErrorView(message: error.toString()),
|
||||||
data: (items) => ListView.builder(
|
data: (items) {
|
||||||
padding: const EdgeInsets.all(16),
|
int onlineCount = 0;
|
||||||
itemCount: items.length,
|
int offlineCount = 0;
|
||||||
itemBuilder: (context, index) {
|
int pingSum = 0;
|
||||||
final item = items[index];
|
int pingCount = 0;
|
||||||
return ProviderHealthTile(
|
|
||||||
config: item,
|
for (final item in items) {
|
||||||
health: health[item.id],
|
final h = health[item.id];
|
||||||
diagnostics: diagnostics[item.id],
|
if (h != null) {
|
||||||
onCheck: () => _checkOne(ref, item.id),
|
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<void> _checkAll(WidgetRef ref) async {
|
Future<void> _checkAll(WidgetRef ref) async {
|
||||||
final result = await ref.read(providerCheckServiceProvider).checkAll();
|
setState(() => _isCheckingAll = true);
|
||||||
if (result is Success<List<ProviderHealth>>) {
|
HapticFeedback.mediumImpact();
|
||||||
ref.read(providerHealthProvider.notifier).state = {
|
try {
|
||||||
for (final item in result.data) item.providerId: item,
|
final result =
|
||||||
};
|
await ref.read(providerCheckServiceProvider).checkAll();
|
||||||
|
if (result is Success<List<ProviderHealth>>) {
|
||||||
|
ref.read(providerHealthProvider.notifier).state = {
|
||||||
|
for (final item in result.data) item.providerId: item,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} 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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,9 @@
|
|||||||
|
import 'dart:ui';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
import '../../../shared/widgets/adaptive_scaffold.dart';
|
|
||||||
import '../../../shared/widgets/confirm_dialog.dart';
|
import '../../../shared/widgets/confirm_dialog.dart';
|
||||||
import '../../../shared/widgets/error_view.dart';
|
import '../../../shared/widgets/error_view.dart';
|
||||||
import 'providers_controller.dart';
|
import 'providers_controller.dart';
|
||||||
@@ -13,50 +14,371 @@ class ProvidersScreen extends ConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
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);
|
final providers = ref.watch(providersControllerProvider);
|
||||||
return AdaptiveScaffold(
|
final bottomInset = MediaQuery.paddingOf(context).bottom;
|
||||||
title: 'Providers',
|
|
||||||
actions: [
|
return Scaffold(
|
||||||
IconButton(
|
extendBodyBehindAppBar: true,
|
||||||
tooltip: 'Check providers',
|
appBar: PreferredSize(
|
||||||
onPressed: () => context.go('/providers/check'),
|
preferredSize: const Size.fromHeight(60),
|
||||||
icon: const Icon(Icons.network_check_rounded),
|
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(
|
body: providers.when(
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
error: (error, _) => ErrorView(message: error.toString()),
|
error: (error, _) => ErrorView(message: error.toString()),
|
||||||
data: (items) => ListView.builder(
|
data: (items) {
|
||||||
padding: const EdgeInsets.all(16),
|
final activeCount = items.where((p) => p.enabled).length;
|
||||||
itemCount: items.length,
|
|
||||||
itemBuilder: (context, index) {
|
return Align(
|
||||||
final item = items[index];
|
alignment: Alignment.topCenter,
|
||||||
return ProviderCard(
|
child: ConstrainedBox(
|
||||||
config: item,
|
constraints: const BoxConstraints(maxWidth: 860),
|
||||||
onToggle: (enabled) => ref
|
child: ListView(
|
||||||
.read(providersControllerProvider.notifier)
|
padding: EdgeInsets.fromLTRB(
|
||||||
.toggle(item.id, enabled),
|
16,
|
||||||
onEdit: () => context.go('/providers/new', extra: item),
|
MediaQuery.paddingOf(context).top + 68,
|
||||||
onDelete: () async {
|
16,
|
||||||
final ok = await showConfirmDialog(
|
140 + bottomInset,
|
||||||
context,
|
),
|
||||||
title: 'Delete provider',
|
children: [
|
||||||
message: 'Remove ${item.name}?',
|
// Overview Glass Card
|
||||||
);
|
Container(
|
||||||
if (ok) {
|
margin: const EdgeInsets.only(bottom: 16),
|
||||||
await ref
|
padding: const EdgeInsets.all(16),
|
||||||
.read(providersControllerProvider.notifier)
|
decoration: BoxDecoration(
|
||||||
.delete(item.id);
|
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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
import '../../../../backend/backend.dart';
|
import '../../../../backend/backend.dart';
|
||||||
|
|
||||||
@@ -18,27 +19,396 @@ class ProviderCard extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Card(
|
final theme = Theme.of(context);
|
||||||
child: ListTile(
|
final isDark = theme.brightness == Brightness.dark;
|
||||||
leading: Switch(value: config.enabled, onChanged: onToggle),
|
final isRu = Localizations.maybeLocaleOf(context)?.languageCode == 'ru';
|
||||||
title: Text(config.name),
|
final (engineColor, engineIcon, engineTitle) = _engineInfo(config.apiType);
|
||||||
subtitle: Text(
|
|
||||||
'${config.apiType} · ${config.baseUrl}\nPriority ${config.priority}'),
|
return Container(
|
||||||
isThreeLine: true,
|
margin: const EdgeInsets.only(bottom: 14),
|
||||||
trailing: Wrap(
|
decoration: BoxDecoration(
|
||||||
spacing: 4,
|
borderRadius: BorderRadius.circular(24),
|
||||||
children: [
|
boxShadow: [
|
||||||
IconButton(
|
BoxShadow(
|
||||||
tooltip: 'Edit',
|
color: isDark
|
||||||
onPressed: onEdit,
|
? Colors.black.withValues(alpha: 0.30)
|
||||||
icon: const Icon(Icons.edit_rounded),
|
: 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(
|
border: Border.all(
|
||||||
tooltip: 'Delete',
|
color: isDark
|
||||||
onPressed: onDelete,
|
? (config.enabled
|
||||||
icon: const Icon(Icons.delete_rounded),
|
? 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
import '../../../../backend/backend.dart';
|
import '../../../../backend/backend.dart';
|
||||||
import '../../../../shared/widgets/provider_status_badge.dart';
|
import '../../../../shared/widgets/provider_status_badge.dart';
|
||||||
|
|
||||||
class ProviderHealthTile extends StatelessWidget {
|
class ProviderHealthTile extends StatefulWidget {
|
||||||
const ProviderHealthTile({
|
const ProviderHealthTile({
|
||||||
required this.config,
|
required this.config,
|
||||||
required this.health,
|
required this.health,
|
||||||
@@ -17,59 +18,408 @@ class ProviderHealthTile extends StatelessWidget {
|
|||||||
final ProviderDiagnostics? diagnostics;
|
final ProviderDiagnostics? diagnostics;
|
||||||
final VoidCallback onCheck;
|
final VoidCallback onCheck;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ProviderHealthTile> createState() => _ProviderHealthTileState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ProviderHealthTileState extends State<ProviderHealthTile> {
|
||||||
|
bool _expanded = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
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(
|
final hint = ProviderQualityHint.from(
|
||||||
config: config,
|
config: config,
|
||||||
health: health,
|
health: health,
|
||||||
diagnostics: diagnostics,
|
diagnostics: diagnostics,
|
||||||
|
isRu: isRu,
|
||||||
);
|
);
|
||||||
return Card(
|
|
||||||
child: ExpansionTile(
|
final status = health?.status ?? ProviderStatus.unknown;
|
||||||
leading: ProviderStatusBadge(health: health),
|
final statusColor = switch (status) {
|
||||||
title: Text(config.name),
|
ProviderStatus.online => (health != null && health.pingMs > 2500)
|
||||||
subtitle: Wrap(
|
? const Color(0xFFF59E0B)
|
||||||
spacing: 8,
|
: const Color(0xFF10B981),
|
||||||
runSpacing: 6,
|
ProviderStatus.offline => const Color(0xFFEF4444),
|
||||||
crossAxisAlignment: WrapCrossAlignment.center,
|
ProviderStatus.unknown => const Color(0xFF94A3B8),
|
||||||
children: [
|
};
|
||||||
Chip(
|
|
||||||
avatar: Icon(hint.icon, size: 16),
|
final hasError = diagnostics?.lastErrorMessage != null ||
|
||||||
label: Text(hint.label),
|
health?.errorMessage != null;
|
||||||
visualDensity: VisualDensity.compact,
|
final errorMessage = diagnostics?.lastErrorMessage ?? health?.errorMessage;
|
||||||
backgroundColor: hint.color(context).withValues(alpha: 0.16),
|
|
||||||
side:
|
return Container(
|
||||||
BorderSide(color: hint.color(context).withValues(alpha: 0.4)),
|
margin: const EdgeInsets.only(bottom: 14),
|
||||||
),
|
decoration: BoxDecoration(
|
||||||
Text('${config.apiType} | priority ${config.priority}'),
|
borderRadius: BorderRadius.circular(22),
|
||||||
],
|
boxShadow: [
|
||||||
),
|
BoxShadow(
|
||||||
trailing: IconButton(
|
color: isDark
|
||||||
tooltip: 'Check',
|
? Colors.black.withValues(alpha: 0.30)
|
||||||
onPressed: onCheck,
|
: statusColor.withValues(alpha: 0.08),
|
||||||
icon: const Icon(Icons.network_check_rounded),
|
blurRadius: 18,
|
||||||
),
|
offset: const Offset(0, 6),
|
||||||
childrenPadding: const EdgeInsets.fromLTRB(16, 0, 16, 14),
|
spreadRadius: -2,
|
||||||
children: [
|
),
|
||||||
Align(
|
BoxShadow(
|
||||||
alignment: Alignment.centerLeft,
|
color: statusColor.withValues(alpha: isDark ? 0.06 : 0.03),
|
||||||
child: Text(
|
blurRadius: 10,
|
||||||
[
|
offset: const Offset(0, 2),
|
||||||
'${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'),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
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 ContentProviderConfig config,
|
||||||
required ProviderHealth? health,
|
required ProviderHealth? health,
|
||||||
required ProviderDiagnostics? diagnostics,
|
required ProviderDiagnostics? diagnostics,
|
||||||
|
required bool isRu,
|
||||||
}) {
|
}) {
|
||||||
if (!config.enabled) {
|
if (!config.enabled) {
|
||||||
return ProviderQualityHint._(
|
return ProviderQualityHint._(
|
||||||
'Disabled',
|
isRu ? 'Выключен' : 'Disabled',
|
||||||
Icons.pause_circle_outline_rounded,
|
Icons.pause_circle_outline_rounded,
|
||||||
(scheme) => scheme.outline,
|
(scheme) => scheme.outline,
|
||||||
);
|
);
|
||||||
@@ -98,34 +449,34 @@ class ProviderQualityHint {
|
|||||||
if (diagnostics?.lastErrorMessage != null ||
|
if (diagnostics?.lastErrorMessage != null ||
|
||||||
health?.status == ProviderStatus.offline) {
|
health?.status == ProviderStatus.offline) {
|
||||||
return ProviderQualityHint._(
|
return ProviderQualityHint._(
|
||||||
'Search failing',
|
isRu ? 'Ошибка поиска' : 'Search failing',
|
||||||
Icons.error_outline_rounded,
|
Icons.error_outline_rounded,
|
||||||
(scheme) => scheme.error,
|
(scheme) => scheme.error,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (diagnostics != null && diagnostics.lastResultCount == 0) {
|
if (diagnostics != null && diagnostics.lastResultCount == 0) {
|
||||||
return ProviderQualityHint._(
|
return ProviderQualityHint._(
|
||||||
'No recent results',
|
isRu ? 'Нет результатов' : 'No recent results',
|
||||||
Icons.search_off_rounded,
|
Icons.search_off_rounded,
|
||||||
(scheme) => scheme.tertiary,
|
(scheme) => scheme.tertiary,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (health != null && health.pingMs > 2500) {
|
if (health != null && health.pingMs > 2500) {
|
||||||
return ProviderQualityHint._(
|
return ProviderQualityHint._(
|
||||||
'Slow',
|
isRu ? 'Медленно' : 'Slow',
|
||||||
Icons.speed_rounded,
|
Icons.speed_rounded,
|
||||||
(scheme) => scheme.secondary,
|
(scheme) => scheme.secondary,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (health != null || diagnostics != null) {
|
if (health != null || diagnostics != null) {
|
||||||
return ProviderQualityHint._(
|
return ProviderQualityHint._(
|
||||||
'Healthy',
|
isRu ? 'Стабилен' : 'Healthy',
|
||||||
Icons.check_circle_outline_rounded,
|
Icons.check_circle_outline_rounded,
|
||||||
(scheme) => scheme.primary,
|
(scheme) => scheme.primary,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return ProviderQualityHint._(
|
return ProviderQualityHint._(
|
||||||
'Not checked',
|
isRu ? 'Не проверен' : 'Not checked',
|
||||||
Icons.help_outline_rounded,
|
Icons.help_outline_rounded,
|
||||||
(scheme) => scheme.outline,
|
(scheme) => scheme.outline,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import '../../app/responsive.dart';
|
|||||||
import '../../backend/backend.dart';
|
import '../../backend/backend.dart';
|
||||||
import '../../core/utils/result.dart';
|
import '../../core/utils/result.dart';
|
||||||
|
|
||||||
class AppShell extends ConsumerWidget {
|
class AppShell extends ConsumerStatefulWidget {
|
||||||
const AppShell({required this.child, super.key});
|
const AppShell({required this.child, super.key});
|
||||||
|
|
||||||
final Widget child;
|
final Widget child;
|
||||||
@@ -33,69 +33,6 @@ class AppShell extends ConsumerWidget {
|
|||||||
_Destination('settings', 'Settings', Icons.settings_rounded, '/settings'),
|
_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(
|
static List<_Destination> _visibleDestinations(
|
||||||
AppSettings settings,
|
AppSettings settings,
|
||||||
bool hasArtists,
|
bool hasArtists,
|
||||||
@@ -106,6 +43,139 @@ class AppShell extends ConsumerWidget {
|
|||||||
return true;
|
return true;
|
||||||
}).toList(growable: false);
|
}).toList(growable: false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<AppShell> createState() => _AppShellState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AppShellState extends ConsumerState<AppShell> {
|
||||||
|
final List<String> _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 {
|
class _DesktopShell extends StatefulWidget {
|
||||||
|
|||||||
@@ -3,22 +3,91 @@ import 'package:flutter/material.dart';
|
|||||||
import '../../backend/backend.dart';
|
import '../../backend/backend.dart';
|
||||||
|
|
||||||
class ProviderStatusBadge extends StatelessWidget {
|
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 ProviderHealth? health;
|
||||||
|
final bool compact;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final isDark = theme.brightness == Brightness.dark;
|
||||||
final status = health?.status ?? ProviderStatus.unknown;
|
final status = health?.status ?? ProviderStatus.unknown;
|
||||||
final color = switch (status) {
|
|
||||||
ProviderStatus.online => Colors.green,
|
final (color, labelRu, labelEn) = switch (status) {
|
||||||
ProviderStatus.offline => Colors.red,
|
ProviderStatus.online => (
|
||||||
ProviderStatus.unknown => Colors.grey,
|
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),
|
final isRu = Localizations.maybeLocaleOf(context)?.languageCode == 'ru';
|
||||||
label: Text(status.name),
|
final label = isRu ? labelRu : labelEn;
|
||||||
visualDensity: VisualDensity.compact,
|
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
name: gel_rule_app
|
name: gel_rule_app
|
||||||
description: Local backend/core layer for a Flutter booru browser app.
|
description: Local backend/core layer for a Flutter booru browser app.
|
||||||
publish_to: "none"
|
publish_to: "none"
|
||||||
version: 3.1.2+63
|
version: 3.1.3+64
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ">=3.3.0 <4.0.0"
|
sdk: ">=3.3.0 <4.0.0"
|
||||||
|
|||||||
@@ -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<ContentProviderConfig> _items;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<ContentProviderConfig>> 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user