import 'dart:io'; import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart' hide appBuildNumber; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:url_launcher/url_launcher.dart'; import '../../../app/app.dart'; import '../../../app/app_version.dart'; import '../../../app/changelog.dart'; import '../../../app/app_strings.dart'; import '../../../app/motion.dart'; import '../../../backend/backend.dart'; import '../../../core/utils/result.dart'; import '../../../shared/widgets/adaptive_scaffold.dart'; import '../../../shared/widgets/error_view.dart'; import '../../artists/presentation/widgets/pawchive_accounts_sheet.dart'; import 'settings_controller.dart'; class SettingsScreen extends ConsumerWidget { const SettingsScreen({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final settings = ref.watch(settingsControllerProvider); final strings = AppStrings( settings.value?.languageCode ?? AppSettings.defaults.languageCode, ); return AdaptiveScaffold( title: strings.settings, body: settings.when( loading: () => const Center(child: CircularProgressIndicator()), error: (error, _) => ErrorView(message: error.toString()), data: (settings) => _SettingsContent(settings: settings), ), ); } } class _SettingsContent extends ConsumerStatefulWidget { const _SettingsContent({required this.settings}); final AppSettings settings; @override ConsumerState<_SettingsContent> createState() => _SettingsContentState(); } class _SettingsContentState extends ConsumerState<_SettingsContent> { final _scrollController = ScrollController(); final _generalKey = GlobalKey(); final _appearanceKey = GlobalKey(); final _feedKey = GlobalKey(); final _filtersKey = GlobalKey(); final _storageKey = GlobalKey(); final _accountsKey = GlobalKey(); final _diagnosticsKey = GlobalKey(); final _aboutKey = GlobalKey(); @override void dispose() { _scrollController.dispose(); super.dispose(); } void _scrollTo(GlobalKey key) { final ctx = key.currentContext; if (ctx != null) { Scrollable.ensureVisible( ctx, duration: const Duration(milliseconds: 380), curve: Curves.easeInOutCubic, alignment: 0.0, ); } } AppSettings get settings => widget.settings; @override Widget build(BuildContext context) { final theme = Theme.of(context); final isAndroid = Platform.isAndroid; final strings = AppStrings(settings.languageCode); final isRu = strings.ru; final deviceInfo = isAndroid ? ref.watch(motionDeviceInfoProvider).value : null; final detectedHz = isAndroid ? View.maybeOf(context)?.display.refreshRate ?? 60.0 : 60.0; final motion = isAndroid ? resolveMotionSettings( settings: settings, detectedHz: detectedHz, device: deviceInfo, ) : null; final navCategories = [ ( key: _generalKey, label: strings.general, icon: Icons.tune_rounded, color: const Color(0xFF6366F1), ), ( key: _appearanceKey, label: strings.appearance, icon: Icons.palette_rounded, color: const Color(0xFF8B5CF6), ), ( key: _feedKey, label: strings.feedLayout, icon: Icons.dashboard_customize_rounded, color: const Color(0xFF10B981), ), ( key: _filtersKey, label: strings.filters, icon: Icons.filter_alt_rounded, color: const Color(0xFFF59E0B), ), ( key: _storageKey, label: strings.storage, icon: Icons.storage_rounded, color: const Color(0xFF06B6D4), ), ( key: _accountsKey, label: isRu ? 'Аккаунты' : 'Accounts', icon: Icons.cloud_sync_rounded, color: const Color(0xFF0EA5E9), ), ( key: _diagnosticsKey, label: isRu ? 'Диагностика' : 'Diagnostics', icon: Icons.hub_rounded, color: const Color(0xFF3B82F6), ), ( key: _aboutKey, label: strings.about, icon: Icons.info_rounded, color: const Color(0xFFE84D8A), ), ]; return Column( children: [ // Pinned Top Category Quick Navigation (Frosted Glass) ClipRect( child: BackdropFilter( filter: ImageFilter.blur(sigmaX: 18, sigmaY: 18), child: Container( padding: const EdgeInsets.symmetric(vertical: 8), decoration: BoxDecoration( color: theme.scaffoldBackgroundColor.withValues( alpha: theme.brightness == Brightness.dark ? 0.76 : 0.82, ), border: Border( bottom: BorderSide( color: theme.brightness == Brightness.dark ? Colors.white.withValues(alpha: 0.08) : Colors.black.withValues(alpha: 0.06), ), ), ), child: Align( alignment: Alignment.center, child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 860), child: _CategoryQuickNav( categories: navCategories, onSelect: _scrollTo, ), ), ), ), ), ), // Scrollable Settings Cards Expanded( child: SingleChildScrollView( controller: _scrollController, padding: EdgeInsets.fromLTRB( 16, 12, 16, 120 + MediaQuery.paddingOf(context).bottom, ), child: Align( alignment: Alignment.topCenter, child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 860), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // 1. General Section _SettingsCardGroup( sectionKey: _generalKey, title: strings.general, icon: Icons.tune_rounded, accentColor: const Color(0xFF6366F1), children: [ _SettingsSegmentedTile( icon: Icons.brightness_6_rounded, iconColor: const Color(0xFF6366F1), title: strings.theme, subtitle: isRu ? 'Оформление цветовой схемы приложения' : 'App theme brightness mode', segments: [ ButtonSegment( value: 'dark', icon: const Icon(Icons.dark_mode_rounded, size: 16), label: Text(isRu ? 'Темная' : 'Dark'), ), ButtonSegment( value: 'light', icon: const Icon(Icons.light_mode_rounded, size: 16), label: Text(isRu ? 'Светлая' : 'Light'), ), ButtonSegment( value: 'system', icon: const Icon(Icons.brightness_auto_rounded, size: 16), label: Text(isRu ? 'Авто' : 'Auto'), ), ], selected: {settings.themeMode}, onSelectionChanged: (set) => _update( ref, settings.copyWith(themeMode: set.first), ), ), const _SettingsDivider(), _SettingsSwitchTile( icon: Icons.explicit_rounded, iconColor: const Color(0xFFEF4444), title: strings.allowNsfw, subtitle: isRu ? 'Отображать контент с рейтингом Questionable и Explicit' : 'Show Questionable and Explicit rated media', value: settings.nsfwEnabled, onChanged: (val) => _update(ref, settings.copyWith(nsfwEnabled: val)), ), const _SettingsDivider(), _SettingsSwitchTile( icon: Icons.blur_on_rounded, iconColor: const Color(0xFFEC4899), title: strings.blurSensitive, subtitle: isRu ? 'Мягко размывать превью откровенных постов в ленте' : 'Blur sensitive thumbnails in feed grid', value: settings.blurExplicitContent, onChanged: (val) => _update(ref, settings.copyWith(blurExplicitContent: val)), ), const _SettingsDivider(), _SettingsSwitchTile( icon: Icons.label_important_outline_rounded, iconColor: const Color(0xFFF59E0B), title: strings.showPostBadges, subtitle: isRu ? 'Индикаторы видео, источников, рейтингов и статусов' : 'Show source, rating and media indicators on cards', value: settings.showPostBadges, onChanged: (val) => _update(ref, settings.copyWith(showPostBadges: val)), ), const _SettingsDivider(), _SettingsSwitchTile( icon: Icons.download_rounded, iconColor: const Color(0xFF10B981), title: strings.allowDownloads, subtitle: isRu ? 'Кнопки быстрого скачивания медиафайлов' : 'Enable direct download buttons on cards & viewer', value: settings.allowDownloads, onChanged: (val) => _update(ref, settings.copyWith(allowDownloads: val)), ), const _SettingsDivider(), _SettingsSwitchTile( icon: Icons.offline_pin_rounded, iconColor: const Color(0xFF06B6D4), title: isRu ? 'Offline избранное' : 'Offline favorites', subtitle: isRu ? 'Фоновая загрузка медиафайла при добавлении поста в избранное' : 'Automatically cache files in background when favorited', value: settings.autoDownloadFavorites, onChanged: (val) => _update( ref, settings.copyWith(autoDownloadFavorites: val), ), ), if (settings.allowDownloads) ...[ const _SettingsDivider(), _SettingsFolderStructureTile( currentTemplate: settings.downloadPathTemplate, isRu: isRu, onChanged: (template) => _update( ref, settings.copyWith(downloadPathTemplate: template), ), ), ], ], ), // 2. Appearance Section _SettingsCardGroup( sectionKey: _appearanceKey, title: strings.appearance, icon: Icons.palette_rounded, accentColor: const Color(0xFF8B5CF6), children: [ _SettingsSegmentedTile( icon: Icons.translate_rounded, iconColor: const Color(0xFF8B5CF6), title: strings.language, subtitle: isRu ? 'Язык интерфейса приложения' : 'App user interface language', segments: const [ ButtonSegment( value: 'ru', label: Text('🇷🇺 Русский'), ), ButtonSegment( value: 'en', label: Text('🇬🇧 English'), ), ], selected: {settings.languageCode}, onSelectionChanged: (set) => _update( ref, settings.copyWith(languageCode: set.first), ), ), const _SettingsDivider(), _SettingsSwitchTile( icon: Icons.contrast_rounded, iconColor: const Color(0xFF475569), title: isRu ? 'AMOLED Pure Black' : 'AMOLED Pure Black', subtitle: isRu ? 'Абсолютно черный фон (#000000) для OLED-дисплеев' : 'Deep black (#000000) surfaces for OLED screens', value: settings.amoledMode, onChanged: (val) => _update(ref, settings.copyWith(amoledMode: val)), ), if (isAndroid) ...[ const _SettingsDivider(), _SettingsSwitchTile( icon: Icons.auto_awesome_rounded, iconColor: const Color(0xFFEC4899), title: isRu ? 'Динамические цвета Material You' : 'Material You Dynamic Colors', subtitle: isRu ? 'Палитра интерфейса подстраивается под обои системы' : 'Color palette adapts automatically to device wallpaper', value: settings.useDynamicColor, onChanged: (val) => _update(ref, settings.copyWith(useDynamicColor: val)), ), ], const _SettingsDivider(), Padding( padding: const EdgeInsets.symmetric( horizontal: 16, vertical: 12, ), child: _ColorSwatches( selected: settings.appSeedColor, onChanged: (color) => _update(ref, settings.copyWith(appSeedColor: color)), ), ), const _SettingsDivider(), Padding( padding: const EdgeInsets.symmetric( horizontal: 16, vertical: 12, ), child: _TabVisibilityEditor( hiddenTabs: settings.hiddenTabs, onChanged: (hiddenTabs) => _update(ref, settings.copyWith(hiddenTabs: hiddenTabs)), ), ), const _SettingsDivider(), _SettingsSwitchTile( icon: Icons.science_rounded, iconColor: const Color(0xFFF97316), title: isRu ? 'Beta и экспериментальные обновления' : 'Receive beta & experimental updates', subtitle: isRu ? 'Проверка и уведомление о prerelease сборках Lunaris' : 'Include prerelease builds when checking for updates', value: settings.allowExperimentalUpdates, onChanged: (val) => _update( ref, settings.copyWith(allowExperimentalUpdates: val), ), ), ], ), // 3. Feed & Layout Section _SettingsCardGroup( sectionKey: _feedKey, title: strings.feedLayout, icon: Icons.dashboard_customize_rounded, accentColor: const Color(0xFF10B981), children: [ _SettingsSegmentedTile( icon: Icons.high_quality_rounded, iconColor: const Color(0xFF10B981), title: strings.mediaQuality, subtitle: isRu ? 'Разрешение загружаемых картинок в сетке' : 'Resolution profile for feed thumbnails', segments: [ for (final mode in MediaQualityMode.values) ButtonSegment( value: mode.name, label: Text( isRu ? switch (mode) { MediaQualityMode.auto => 'Авто', MediaQualityMode.dataSaver => 'Экономия', MediaQualityMode.highQuality => 'HQ', } : mode.label, ), ), ], selected: {settings.mediaQualityMode}, onSelectionChanged: (set) => _update( ref, settings.copyWith(mediaQualityMode: set.first), ), ), if (isAndroid) ...[ const _SettingsDivider(), Padding( padding: const EdgeInsets.symmetric( horizontal: 16, vertical: 10, ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Container( width: 38, height: 38, decoration: BoxDecoration( color: const Color(0xFF10B981) .withValues(alpha: 0.12), borderRadius: BorderRadius.circular(12), ), child: const Icon( Icons.speed_rounded, size: 20, color: Color(0xFF10B981), ), ), const SizedBox(width: 14), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( isRu ? 'Частота обновления экрана' : 'Animation refresh profile', style: theme.textTheme.bodyLarge ?.copyWith(fontWeight: FontWeight.w600), ), Text( isRu ? 'Оптимизация плавности скролла ленты' : 'Fluid 120-165 Hz scrolling optimization', style: theme.textTheme.bodyMedium ?.copyWith( color: theme.colorScheme .onSurfaceVariant, ), ), ], ), ), ], ), const SizedBox(height: 12), Wrap( spacing: 8, runSpacing: 8, children: [ for (final mode in MotionRefreshMode.values) ChoiceChip( label: Text(mode.label), selected: settings.motionRefreshMode == mode.name, onSelected: (selected) { if (selected) { _update( ref, settings.copyWith( motionRefreshMode: mode.name, ), ); } }, ), ], ), ], ), ), const _SettingsDivider(), _SettingsSwitchTile( icon: Icons.battery_saver_rounded, iconColor: const Color(0xFF22C55E), title: isRu ? 'Энергосбережение: 60 Гц при < 20% батареи' : 'Auto 60 Hz below 20% battery', subtitle: isRu ? 'Определено ${motion!.detectedHz.toStringAsFixed(0)} Гц' '${motion.batteryLevel == null ? '' : ', заряд ${motion.batteryLevel}%'}' '${motion.batterySaverActive ? ', режим энергосбережения' : ''}' : 'Detected ${motion!.detectedHz.toStringAsFixed(0)} Hz' '${motion.batteryLevel == null ? '' : ', battery ${motion.batteryLevel}%'}' '${motion.batterySaverActive ? ', saver active' : ''}', value: settings.autoBatterySaver60Hz, onChanged: (val) => _update( ref, settings.copyWith(autoBatterySaver60Hz: val), ), ), ], const _SettingsDivider(), _SettingsStepperTile( icon: Icons.desktop_windows_rounded, iconColor: const Color(0xFF3B82F6), title: strings.desktopColumns, subtitle: isRu ? 'Количество столбцов постов на ПК' : 'Columns on desktop layout', value: settings.desktopColumns, min: 3, max: 8, onChanged: (val) => _update(ref, settings.copyWith(desktopColumns: val)), ), const _SettingsDivider(), _SettingsStepperTile( icon: Icons.smartphone_rounded, iconColor: const Color(0xFF6366F1), title: strings.mobileColumns, subtitle: isRu ? 'Количество столбцов постов на телефоне' : 'Columns on phone layout', value: settings.mobileColumns, min: 1, max: 3, onChanged: (val) => _update(ref, settings.copyWith(mobileColumns: val)), ), ], ), // 4. Filters & Blacklist Section _SettingsCardGroup( sectionKey: _filtersKey, title: strings.filters, icon: Icons.filter_alt_rounded, accentColor: const Color(0xFFF59E0B), children: [ _SettingsSwitchTile( icon: Icons.visibility_off_rounded, iconColor: const Color(0xFFF59E0B), title: strings.hideViewed, subtitle: isRu ? 'Автоматически скрывать просмотренные посты из ленты' : 'Automatically hide already seen posts from feed', value: settings.hideViewedPosts, onChanged: (val) => _update(ref, settings.copyWith(hideViewedPosts: val)), ), const _SettingsDivider(), Padding( padding: const EdgeInsets.all(14), child: _TagListEditor( title: strings.smartBlacklist, icon: Icons.block_rounded, accentColor: const Color(0xFFEF4444), tags: settings.smartBlacklistRules, helper: isRu ? 'Примеры: tag, tag_a tag_b, provider:e621, rating:explicit, type:video, score:<10, artist:name' : 'Examples: tag, tag_a tag_b, provider:e621, rating:explicit, type:video, score:<10, artist:name', onChanged: (tags) => _update( ref, settings.copyWith(smartBlacklistRules: tags), ), ), ), const _SettingsDivider(), Padding( padding: const EdgeInsets.all(14), child: _TagListEditor( title: strings.whitelistedTags, icon: Icons.verified_rounded, accentColor: const Color(0xFF10B981), tags: settings.whitelistedTags, helper: isRu ? 'Теги, которые никогда не будут скрываться черным списком' : 'Tags that will bypass blacklist rules', onChanged: (tags) => _update( ref, settings.copyWith(whitelistedTags: tags), ), ), ), ], ), // 5. Storage Section _SettingsCardGroup( sectionKey: _storageKey, title: strings.storage, icon: Icons.storage_rounded, accentColor: const Color(0xFF06B6D4), children: [ _SettingsStepperTile( icon: Icons.photo_library_rounded, iconColor: const Color(0xFF06B6D4), title: strings.cacheMaxItems, subtitle: isRu ? 'Максимальное количество файлов в дисковом кэше' : 'Max items stored in image/video cache', value: settings.cacheMaxItems, min: 100, max: 10000, step: 100, onChanged: (val) => _update(ref, settings.copyWith(cacheMaxItems: val)), ), const _SettingsDivider(), _SettingsStepperTile( icon: Icons.history_rounded, iconColor: const Color(0xFF3B82F6), title: isRu ? 'Лимит истории поиска' : 'Search history limit', subtitle: isRu ? 'Хранение недавних поисковых запросов' : 'Max search query entries stored', value: settings.searchHistoryLimit, min: 50, max: 2000, step: 50, onChanged: (val) => _update(ref, settings.copyWith(searchHistoryLimit: val)), ), const _SettingsDivider(), _SettingsStepperTile( icon: Icons.tag_rounded, iconColor: const Color(0xFF8B5CF6), title: isRu ? 'Лимит кэша тегов (диск)' : 'Tag cache limit (disk)', subtitle: isRu ? 'Количество кэшируемых подсказок тегов на диске' : 'Max tag autocomplete entries persisted on disk', value: settings.tagCacheLimit, min: 500, max: 20000, step: 500, onChanged: (val) => _update(ref, settings.copyWith(tagCacheLimit: val)), ), const _SettingsDivider(), Padding( padding: const EdgeInsets.all(14), child: _ActionGrid( children: [ _ActionButton( icon: Icons.cleaning_services_rounded, label: strings.clearCache, onPressed: () => ref .read(settingsControllerProvider.notifier) .clearCache(), ), _ActionButton( icon: Icons.pie_chart_rounded, label: isRu ? 'Менеджер кэша' : 'Cache Manager', isPrimary: true, onPressed: () => context.go('/settings/cache'), ), _ActionButton( icon: Icons.label_off_rounded, label: isRu ? 'Очистить кэш тегов' : 'Clear tag cache', onPressed: () async { await ref .read(settingsControllerProvider.notifier) .clearTagCache(); if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( isRu ? 'Кэш подсказок тегов очищен' : 'Tag suggestions cache cleared', ), ), ); } }, ), _ActionButton( icon: Icons.history_toggle_off_rounded, label: isRu ? 'Очистить историю поиска' : 'Clear search history', onPressed: () async { await ref .read(settingsControllerProvider.notifier) .clearSearchHistory(); if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( isRu ? 'История поиска очищена' : 'Search history cleared', ), ), ); } }, ), _ActionButton( icon: Icons.visibility_off_rounded, label: strings.clearViewed, onPressed: () => ref .read(settingsControllerProvider.notifier) .clearViewedHistory(), ), _ActionButton( icon: Icons.restore_rounded, label: isRu ? 'Скрытые посты' : 'Hidden posts', onPressed: settings.hiddenPostKeys.isEmpty ? null : () => context.go('/settings/hidden'), ), _ActionButton( icon: Icons.delete_sweep_rounded, label: isRu ? 'Очистить скрытые (${settings.hiddenPostKeys.length})' : 'Clear hidden (${settings.hiddenPostKeys.length})', onPressed: () => ref .read(settingsControllerProvider.notifier) .clearHiddenPosts(), ), ], ), ), ], ), // Accounts & Sync Section _SettingsCardGroup( sectionKey: _accountsKey, title: isRu ? 'Аккаунты и синхронизация' : 'Accounts & Sync', icon: Icons.cloud_sync_rounded, accentColor: const Color(0xFF0EA5E9), children: [ ListTile( contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), leading: const CircleAvatar( backgroundColor: Color(0xFF0EA5E9), foregroundColor: Colors.white, child: Icon(Icons.cloud_sync_rounded), ), title: Text( isRu ? 'Аккаунты Pawchive' : 'Pawchive Accounts', style: const TextStyle(fontWeight: FontWeight.bold), ), subtitle: Text( settings.parsedPawchiveAccounts.isEmpty ? (isRu ? 'Вход для синхронизации избранных авторов' : 'Sign in to sync favorite creators') : '${settings.parsedPawchiveAccounts.length} ${isRu ? "аккаунт(ов)" : "account(s)"}' '${settings.activePawchiveAccount != null ? " • @${settings.activePawchiveAccount!.username}" : ""}', ), trailing: FilledButton.tonal( onPressed: () => PawchiveAccountsSheet.show(context), child: Text(settings.parsedPawchiveAccounts.isEmpty ? (isRu ? 'Войти' : 'Login') : (isRu ? 'Управление' : 'Manage')), ), ), ], ), // 6. Providers & Diagnostics Section _SettingsCardGroup( sectionKey: _diagnosticsKey, title: isRu ? 'Провайдеры и диагностика' : 'Providers & Diagnostics', icon: Icons.hub_rounded, accentColor: const Color(0xFF3B82F6), children: [ Padding( padding: const EdgeInsets.all(14), child: _ActionGrid( children: [ _ActionButton( icon: Icons.hub_rounded, label: isRu ? 'Провайдеры' : 'Providers', isPrimary: true, onPressed: () => context.go('/providers'), ), _ActionButton( icon: Icons.network_check_rounded, label: isRu ? 'Диагностика сети' : 'Diagnostics', isPrimary: true, onPressed: () => context.go('/providers/check'), ), _ActionButton( icon: Icons.bug_report_rounded, label: isRu ? 'Копировать отчет' : 'Copy report', onPressed: () => _copyDiagnostics(context, ref), ), _ActionButton( icon: Icons.receipt_long_rounded, label: isRu ? 'Копировать логи' : 'Copy logs', onPressed: () => _copyLogs(context, ref), ), _ActionButton( icon: Icons.delete_sweep_rounded, label: isRu ? 'Очистить логи' : 'Clear logs', onPressed: () => ref .read(settingsControllerProvider.notifier) .clearDiagnosticLogs(), ), ], ), ), ], ), // 7. About & Hero Section _HeroBrandBanner( sectionKey: _aboutKey, settings: settings, isRu: isRu, onCheckUpdates: () => _checkUpdates(context, ref), onShowChangelog: () => _showChangelog(context), onExportJson: () => _exportJson(context, ref), onImportJson: () => _importDialog(context, ref), onExportBackup: () => _exportBackupFile(context, ref), onImportBackup: () => _importBackupFile(context, ref), ), const SizedBox(height: 32), ], ), ), ), ), ), ], ); } Future _update(WidgetRef ref, AppSettings settings) { return ref.read(settingsControllerProvider.notifier).saveSettings(settings); } Future _copyDiagnostics(BuildContext context, WidgetRef ref) async { final report = await ref.read(settingsControllerProvider.notifier).diagnosticsReport(); await Clipboard.setData(ClipboardData(text: report)); if (!context.mounted) return; ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Diagnostics copied')), ); } Future _copyLogs(BuildContext context, WidgetRef ref) async { final logs = await ref.read(settingsControllerProvider.notifier).diagnosticLogs(); await Clipboard.setData(ClipboardData(text: logs)); if (!context.mounted) return; ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Diagnostic logs copied')), ); } Future _exportJson(BuildContext context, WidgetRef ref) async { final json = await ref.read(settingsControllerProvider.notifier).exportJson(); await Clipboard.setData(ClipboardData(text: json)); if (!context.mounted) return; ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Settings JSON copied')), ); } Future _exportBackupFile(BuildContext context, WidgetRef ref) async { final success = await ref.read(backupServiceProvider).exportBackup(); if (!context.mounted) return; if (success) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Бэкап сохранен / отправлен')), ); } } Future _importBackupFile(BuildContext context, WidgetRef ref) async { final result = await ref.read(backupServiceProvider).importBackup(); if (!context.mounted) return; if (result == null) return; if (result is Success) { ref.invalidate(appSettingsProvider); ref.invalidate(settingsControllerProvider); ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Настройки успешно импортированы!')), ); } else if (result is Error) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Ошибка импорта: ${result.failure.message}')), ); } } Future _importDialog(BuildContext context, WidgetRef ref) async { final controller = TextEditingController(); await showDialog( context: context, builder: (context) => AlertDialog( title: const Text('Import settings'), content: TextField( controller: controller, minLines: 6, maxLines: 12, decoration: const InputDecoration(labelText: 'JSON'), ), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: const Text('Cancel'), ), FilledButton( onPressed: () async { await ref .read(settingsControllerProvider.notifier) .importJson(controller.text); if (context.mounted) Navigator.pop(context); }, child: const Text('Import'), ), ], ), ); controller.dispose(); } Future _checkUpdates(BuildContext context, WidgetRef ref) async { final isRu = settings.languageCode == 'ru'; final theme = Theme.of(context); final isDark = theme.brightness == Brightness.dark; final selectedSource = await showModalBottomSheet( context: context, useRootNavigator: true, isScrollControlled: true, backgroundColor: Colors.transparent, builder: (sheetContext) { return ClipRRect( borderRadius: const BorderRadius.vertical(top: Radius.circular(28)), child: BackdropFilter( filter: ImageFilter.blur(sigmaX: 24, sigmaY: 24), child: Container( decoration: BoxDecoration( color: isDark ? theme.colorScheme.surface.withValues(alpha: 0.88) : Colors.white.withValues(alpha: 0.94), borderRadius: const BorderRadius.vertical(top: Radius.circular(28)), border: Border( top: BorderSide( color: isDark ? Colors.white.withValues(alpha: 0.18) : Colors.white.withValues(alpha: 0.85), width: 1.2, ), ), ), child: SafeArea( top: false, child: Padding( padding: EdgeInsets.fromLTRB( 20, 14, 20, 24 + MediaQuery.paddingOf(sheetContext).bottom, ), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Center( child: Container( width: 38, height: 4.5, margin: const EdgeInsets.only(bottom: 16), decoration: BoxDecoration( color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.35), borderRadius: BorderRadius.circular(3), ), ), ), Text( isRu ? 'Источник обновлений' : 'Update Source', style: theme.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w800, letterSpacing: -0.3, ), textAlign: TextAlign.center, ), const SizedBox(height: 6), Text( isRu ? 'Выберите сервер для быстрой загрузки новой версии' : 'Choose a server to download updates from', style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), textAlign: TextAlign.center, ), const SizedBox(height: 20), // Option 1: GitHub (Recommended for speed) Material( color: isDark ? theme.colorScheme.primaryContainer.withValues(alpha: 0.35) : theme.colorScheme.primary.withValues(alpha: 0.08), borderRadius: BorderRadius.circular(18), child: InkWell( borderRadius: BorderRadius.circular(18), onTap: () { HapticFeedback.selectionClick(); Navigator.pop(sheetContext, UpdateSource.github); }, child: Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( borderRadius: BorderRadius.circular(18), border: Border.all( color: isDark ? Colors.white.withValues(alpha: 0.15) : theme.colorScheme.primary.withValues(alpha: 0.35), width: 1.2, ), ), child: Row( children: [ Container( width: 44, height: 44, decoration: BoxDecoration( gradient: const LinearGradient( colors: [Color(0xFF6366F1), Color(0xFF4F46E5)], begin: Alignment.topLeft, end: Alignment.bottomRight, ), borderRadius: BorderRadius.circular(13), boxShadow: [ BoxShadow( color: const Color(0xFF6366F1).withValues(alpha: 0.40), blurRadius: 10, offset: const Offset(0, 3), ), ], ), child: const Center( child: Icon( Icons.bolt_rounded, color: Colors.white, size: 24, ), ), ), const SizedBox(width: 14), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Text( 'GitHub', style: theme.textTheme.titleSmall?.copyWith( fontWeight: FontWeight.w700, ), ), const SizedBox(width: 8), Container( padding: const EdgeInsets.symmetric( horizontal: 7, vertical: 2.5, ), decoration: BoxDecoration( color: Colors.green.withValues(alpha: 0.20), borderRadius: BorderRadius.circular(6), ), child: Text( isRu ? 'Рекомендуется' : 'Recommended', style: const TextStyle( fontSize: 10.5, fontWeight: FontWeight.w700, color: Colors.green, ), ), ), ], ), const SizedBox(height: 3), Text( isRu ? 'Прямая быстрая загрузка релизов без задержек' : 'Direct fast download without limits', style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ], ), ), Icon( Icons.arrow_forward_ios_rounded, size: 15, color: theme.colorScheme.onSurfaceVariant, ), ], ), ), ), ), const SizedBox(height: 12), // Option 2: Gitea Material( color: isDark ? theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.35) : theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.40), borderRadius: BorderRadius.circular(18), child: InkWell( borderRadius: BorderRadius.circular(18), onTap: () { HapticFeedback.selectionClick(); Navigator.pop(sheetContext, UpdateSource.gitea); }, child: Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( borderRadius: BorderRadius.circular(18), border: Border.all( color: isDark ? Colors.white.withValues(alpha: 0.12) : theme.colorScheme.outlineVariant.withValues(alpha: 0.35), width: 1.2, ), ), child: Row( children: [ Container( width: 44, height: 44, decoration: BoxDecoration( gradient: const LinearGradient( colors: [Color(0xFF0EA5E9), Color(0xFF0284C7)], begin: Alignment.topLeft, end: Alignment.bottomRight, ), borderRadius: BorderRadius.circular(13), boxShadow: [ BoxShadow( color: const Color(0xFF0EA5E9).withValues(alpha: 0.35), blurRadius: 10, offset: const Offset(0, 3), ), ], ), child: const Center( child: Icon( Icons.dns_rounded, color: Colors.white, size: 22, ), ), ), const SizedBox(width: 14), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Gitea (Synapse)', style: theme.textTheme.titleSmall?.copyWith( fontWeight: FontWeight.w700, ), ), const SizedBox(height: 3), Text( isRu ? 'Альтернативный независимый сервер релизов' : 'Alternative independent release mirror', style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ], ), ), Icon( Icons.arrow_forward_ios_rounded, size: 15, color: theme.colorScheme.onSurfaceVariant, ), ], ), ), ), ), ], ), ), ), ), ), ); }, ); if (selectedSource == null) return; if (!context.mounted) return; final messenger = ScaffoldMessenger.of(context); messenger.showSnackBar( SnackBar( content: Text( selectedSource == UpdateSource.gitea ? (isRu ? 'Проверка обновлений на Gitea...' : 'Checking updates on Gitea...') : (isRu ? 'Проверка обновлений на GitHub...' : 'Checking updates on GitHub...'), ), ), ); final result = await ref.read(updateServiceProvider).checkForUpdates( force: true, source: selectedSource, ); if (!context.mounted) return; messenger.hideCurrentSnackBar(); if (result is Error) { messenger.showSnackBar(SnackBar(content: Text(result.failure.message))); return; } final update = (result as Success).data; if (update == null) { messenger.showSnackBar( SnackBar( content: Text( isRu ? 'У вас установлена последняя версия Lunaris' : 'Lunaris is up to date', ), ), ); return; } await _updateDialog(context, ref, update); } Future _updateDialog( BuildContext context, WidgetRef ref, AppUpdateInfo info, ) async { await showDialog( context: context, builder: (context) => AlertDialog( title: Text('Lunaris ${info.version} is available'), content: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 520), child: SingleChildScrollView( child: Text( info.body.trim().isEmpty ? info.name : info.body.trim(), maxLines: 14, overflow: TextOverflow.ellipsis, ), ), ), actions: [ TextButton( onPressed: () async { await ref.read(updateServiceProvider).skipVersion(info); if (context.mounted) Navigator.pop(context); }, child: const Text('Skip this version'), ), TextButton( onPressed: () async { await ref.read(updateServiceProvider).remindLater(); if (context.mounted) Navigator.pop(context); }, child: const Text('Later'), ), FilledButton.icon( onPressed: () async { await _downloadUpdate(ref, info); if (context.mounted) Navigator.pop(context); }, icon: const Icon(Icons.download_rounded), label: const Text('Download & open'), ), ], ), ); ref.invalidate(settingsControllerProvider); } Future _downloadUpdate(WidgetRef ref, AppUpdateInfo info) async { final updateService = ref.read(updateServiceProvider); final assetUrl = await updateService.assetUrlForCurrentPlatform(info); if (assetUrl == null) { final url = Uri.tryParse(info.htmlUrl); if (url != null) { await launchUrl(url, mode: LaunchMode.externalApplication); } return; } await ref.read(downloadManagerServiceProvider).startUrl( url: assetUrl, fileName: updateService.assetFileName(info, assetUrl), openAfterDownload: true, ); } Future _showChangelog(BuildContext context) { return showDialog( context: context, builder: (context) => AlertDialog( title: const Text('Lunaris changelog'), content: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 560), child: SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ for (final change in lunarisChangelog) ...[ Text( '${change.version} - ${change.title}', style: Theme.of(context).textTheme.titleMedium, ), const SizedBox(height: 6), for (final bullet in change.bullets) Padding( padding: const EdgeInsets.only(left: 8, bottom: 4), child: Text('- $bullet'), ), const SizedBox(height: 12), ], ], ), ), ), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: const Text('Close'), ), ], ), ); } } // ----------------------------------------------------------------------------- // Settings 2.0 Modern Components // ----------------------------------------------------------------------------- class _SettingsIconBadge extends StatelessWidget { const _SettingsIconBadge({ required this.icon, required this.color, this.size = 34, this.iconSize = 18, }); final IconData icon; final Color color; final double size; final double iconSize; @override Widget build(BuildContext context) { return Container( width: size, height: size, decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [ color, color.withValues(alpha: 0.86), ], ), borderRadius: BorderRadius.circular(size * 0.28), boxShadow: [ BoxShadow( color: color.withValues(alpha: 0.35), blurRadius: 7, offset: const Offset(0, 2.5), ), ], ), child: Center( child: Icon(icon, size: iconSize, color: Colors.white), ), ); } } class _CategoryQuickNav extends StatelessWidget { const _CategoryQuickNav({ required this.categories, required this.onSelect, }); final List<({GlobalKey key, String label, IconData icon, Color color})> categories; final ValueChanged onSelect; @override Widget build(BuildContext context) { final theme = Theme.of(context); final isDark = theme.brightness == Brightness.dark; return SizedBox( height: 44, child: ListView.separated( padding: const EdgeInsets.symmetric(horizontal: 16), scrollDirection: Axis.horizontal, itemCount: categories.length, separatorBuilder: (_, __) => const SizedBox(width: 8), itemBuilder: (context, index) { final cat = categories[index]; return Container( decoration: BoxDecoration( borderRadius: BorderRadius.circular(22), boxShadow: [ BoxShadow( color: isDark ? Colors.black.withValues(alpha: 0.25) : cat.color.withValues(alpha: 0.12), blurRadius: 8, offset: const Offset(0, 2.5), ), ], ), child: ClipRRect( borderRadius: BorderRadius.circular(22), child: BackdropFilter( filter: ImageFilter.blur(sigmaX: 16, sigmaY: 16), child: Material( color: isDark ? theme.colorScheme.surfaceContainerHigh.withValues(alpha: 0.60) : Colors.white.withValues(alpha: 0.82), child: InkWell( borderRadius: BorderRadius.circular(22), onTap: () { HapticFeedback.selectionClick(); onSelect(cat.key); }, child: Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7), decoration: BoxDecoration( borderRadius: BorderRadius.circular(22), border: Border.all( color: isDark ? Colors.white.withValues(alpha: 0.18) : Colors.white.withValues(alpha: 0.85), width: 1.1, ), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Container( width: 24, height: 24, decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [ cat.color, cat.color.withValues(alpha: 0.82), ], ), borderRadius: BorderRadius.circular(7), boxShadow: [ BoxShadow( color: cat.color.withValues(alpha: 0.35), blurRadius: 5, offset: const Offset(0, 1.5), ), ], ), child: Center( child: Icon(cat.icon, size: 14, color: Colors.white), ), ), const SizedBox(width: 8), Text( cat.label, style: theme.textTheme.labelLarge?.copyWith( fontWeight: FontWeight.w600, letterSpacing: -0.2, ), ), ], ), ), ), ), ), ), ); }, ), ); } } class _SettingsCardGroup extends StatelessWidget { const _SettingsCardGroup({ required this.sectionKey, required this.title, required this.icon, required this.accentColor, required this.children, }); final Key sectionKey; final String title; final IconData icon; final Color accentColor; final List children; @override Widget build(BuildContext context) { final theme = Theme.of(context); final isDark = theme.brightness == Brightness.dark; return Container( key: sectionKey, margin: const EdgeInsets.only(bottom: 18), decoration: BoxDecoration( borderRadius: BorderRadius.circular(24), boxShadow: [ BoxShadow( color: isDark ? Colors.black.withValues(alpha: 0.40) : accentColor.withValues(alpha: 0.08), blurRadius: 20, offset: const Offset(0, 6), spreadRadius: -2, ), BoxShadow( color: accentColor.withValues(alpha: isDark ? 0.08 : 0.04), blurRadius: 10, offset: const Offset(0, 2), ), ], ), child: ClipRRect( borderRadius: BorderRadius.circular(24), child: BackdropFilter( filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), child: Container( decoration: BoxDecoration( borderRadius: BorderRadius.circular(24), gradient: LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: isDark ? [ theme.colorScheme.surfaceContainerHigh.withValues(alpha: 0.60), theme.colorScheme.surfaceContainerLow.withValues(alpha: 0.40), ] : [ Colors.white.withValues(alpha: 0.88), Colors.white.withValues(alpha: 0.72), ], ), border: Border.all( color: isDark ? Colors.white.withValues(alpha: 0.15) : Colors.white.withValues(alpha: 0.85), width: 1.2, ), ), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // iOS Liquid Glass Card Header Padding( padding: const EdgeInsets.fromLTRB(16, 16, 16, 12), child: Row( children: [ _SettingsIconBadge( icon: icon, color: accentColor, size: 34, iconSize: 19, ), const SizedBox(width: 14), Expanded( child: Text( title, style: theme.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w700, letterSpacing: -0.3, ), ), ), ], ), ), Container( height: 1, margin: const EdgeInsets.symmetric(horizontal: 16), color: isDark ? Colors.white.withValues(alpha: 0.08) : theme.colorScheme.outlineVariant.withValues(alpha: 0.25), ), ...children, ], ), ), ), ), ); } } class _SettingsDivider extends StatelessWidget { const _SettingsDivider(); @override Widget build(BuildContext context) { final isDark = Theme.of(context).brightness == Brightness.dark; return Divider( height: 1, thickness: 0.7, indent: 64, endIndent: 16, color: isDark ? Colors.white.withValues(alpha: 0.07) : Theme.of(context).colorScheme.outlineVariant.withValues(alpha: 0.25), ); } } class _SettingsTile extends StatelessWidget { const _SettingsTile({ required this.icon, required this.iconColor, required this.title, this.subtitle, this.trailing, this.onTap, }); final IconData icon; final Color iconColor; final String title; final String? subtitle; final Widget? trailing; final VoidCallback? onTap; @override Widget build(BuildContext context) { final theme = Theme.of(context); return InkWell( onTap: onTap, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: Row( children: [ _SettingsIconBadge( icon: icon, color: iconColor, size: 34, iconSize: 18, ), const SizedBox(width: 14), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( title, style: theme.textTheme.bodyLarge?.copyWith( fontWeight: FontWeight.w600, ), ), if (subtitle != null) ...[ const SizedBox(height: 2), Text( subtitle!, style: theme.textTheme.bodyMedium?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ], ], ), ), if (trailing != null) ...[ const SizedBox(width: 12), trailing!, ], ], ), ), ); } } /// Full-width segmented tile so titles never get squeezed vertically on narrow screens. class _SettingsSegmentedTile extends StatelessWidget { const _SettingsSegmentedTile({ required this.icon, required this.iconColor, required this.title, required this.segments, required this.selected, required this.onSelectionChanged, this.subtitle, }); final IconData icon; final Color iconColor; final String title; final String? subtitle; final List> segments; final Set selected; final ValueChanged> onSelectionChanged; @override Widget build(BuildContext context) { final theme = Theme.of(context); return Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Row( children: [ _SettingsIconBadge( icon: icon, color: iconColor, size: 34, iconSize: 18, ), const SizedBox(width: 14), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( title, style: theme.textTheme.bodyLarge?.copyWith( fontWeight: FontWeight.w600, ), ), if (subtitle != null) ...[ const SizedBox(height: 2), Text( subtitle!, style: theme.textTheme.bodyMedium?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ], ], ), ), ], ), const SizedBox(height: 12), SizedBox( width: double.infinity, child: SegmentedButton( showSelectedIcon: false, style: const ButtonStyle( visualDensity: VisualDensity.compact, tapTargetSize: MaterialTapTargetSize.shrinkWrap, ), segments: segments, selected: selected, onSelectionChanged: onSelectionChanged, ), ), ], ), ); } } class _SettingsSwitchTile extends StatelessWidget { const _SettingsSwitchTile({ required this.icon, required this.iconColor, required this.title, required this.value, required this.onChanged, this.subtitle, }); final IconData icon; final Color iconColor; final String title; final String? subtitle; final bool value; final ValueChanged onChanged; @override Widget build(BuildContext context) { return _SettingsTile( icon: icon, iconColor: iconColor, title: title, subtitle: subtitle, onTap: () => onChanged(!value), trailing: Switch.adaptive( value: value, onChanged: onChanged, ), ); } } class _SettingsStepperTile extends StatelessWidget { const _SettingsStepperTile({ required this.icon, required this.iconColor, required this.title, required this.value, required this.min, required this.max, required this.onChanged, this.subtitle, this.step = 1, }); final IconData icon; final Color iconColor; final String title; final String? subtitle; final int value; final int min; final int max; final int step; final ValueChanged onChanged; @override Widget build(BuildContext context) { final theme = Theme.of(context); return Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), child: Row( children: [ _SettingsIconBadge( icon: icon, color: iconColor, size: 34, iconSize: 18, ), const SizedBox(width: 14), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( title, style: theme.textTheme.bodyLarge?.copyWith( fontWeight: FontWeight.w600, ), ), if (subtitle != null) ...[ const SizedBox(height: 2), Text( subtitle!, style: theme.textTheme.bodyMedium?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ], ], ), ), const SizedBox(width: 8), Row( mainAxisSize: MainAxisSize.min, children: [ IconButton.filledTonal( visualDensity: VisualDensity.compact, onPressed: value <= min ? null : () => onChanged(value - step), icon: const Icon(Icons.remove_rounded, size: 18), ), Container( margin: const EdgeInsets.symmetric(horizontal: 6), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), decoration: BoxDecoration( color: theme.colorScheme.secondaryContainer, borderRadius: BorderRadius.circular(8), ), child: Text( '$value', style: theme.textTheme.titleSmall?.copyWith( fontWeight: FontWeight.bold, color: theme.colorScheme.onSecondaryContainer, ), ), ), IconButton.filledTonal( visualDensity: VisualDensity.compact, onPressed: value >= max ? null : () => onChanged(value + step), icon: const Icon(Icons.add_rounded, size: 18), ), ], ), ], ), ); } } class _SettingsFolderStructureTile extends StatelessWidget { const _SettingsFolderStructureTile({ required this.currentTemplate, required this.isRu, required this.onChanged, }); final String currentTemplate; final bool isRu; final ValueChanged onChanged; static const _presets = [ '{Artist}/{ID}', '{Artist}', '{Provider}/{Artist}', '{Service}/{ID}', '{Date}/{Artist}', ]; @override Widget build(BuildContext context) { final theme = Theme.of(context); return InkWell( onTap: () => _showPicker(context), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ const _SettingsIconBadge( icon: Icons.folder_copy_rounded, color: Color(0xFF0EA5E9), size: 34, iconSize: 18, ), const SizedBox(width: 14), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( isRu ? 'Папки скачивания' : 'Download folder structure', style: theme.textTheme.bodyLarge?.copyWith( fontWeight: FontWeight.w600, ), ), const SizedBox(height: 2), Text( isRu ? 'Шаблон организации сохраняемых файлов' : 'Path template for organized downloads', style: theme.textTheme.bodyMedium?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ], ), ), const SizedBox(width: 8), Icon( Icons.chevron_right_rounded, color: theme.colorScheme.onSurfaceVariant, ), ], ), const SizedBox(height: 10), Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration( color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), borderRadius: BorderRadius.circular(10), border: Border.all( color: theme.colorScheme.outlineVariant.withValues(alpha: 0.35), ), ), child: Row( children: [ const Icon(Icons.folder_open_rounded, size: 16), const SizedBox(width: 8), Expanded( child: Text( currentTemplate, style: TextStyle( fontFamily: 'monospace', fontSize: 13, fontWeight: FontWeight.w600, color: theme.colorScheme.primary, ), ), ), Text( isRu ? 'Выбрать' : 'Select', style: theme.textTheme.labelMedium?.copyWith( color: theme.colorScheme.primary, fontWeight: FontWeight.bold, ), ), ], ), ), ], ), ), ); } Future _showPicker(BuildContext context) async { final picked = await showModalBottomSheet( context: context, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(24)), ), builder: (ctx) { return SafeArea( child: Padding( padding: const EdgeInsets.fromLTRB(16, 20, 16, 16), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( isRu ? 'Структура папок скачивания' : 'Download path template', style: Theme.of(ctx).textTheme.titleLarge?.copyWith( fontWeight: FontWeight.bold, ), ), const SizedBox(height: 6), Text( isRu ? 'Переменные: {Artist}, {Provider}, {Service}, {ID}, {Date}' : 'Tags: {Artist}, {Provider}, {Service}, {ID}, {Date}', style: Theme.of(ctx).textTheme.bodySmall, ), const SizedBox(height: 14), for (final preset in _presets) ListTile( shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), leading: const Icon(Icons.folder_outlined), title: Text(preset, style: const TextStyle(fontFamily: 'monospace')), trailing: currentTemplate == preset ? const Icon(Icons.check_rounded, color: Colors.green) : null, onTap: () => Navigator.pop(ctx, preset), ), ListTile( shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), leading: const Icon(Icons.edit_note_rounded), title: Text( isRu ? 'Пользовательский шаблон...' : 'Custom template...', ), onTap: () => Navigator.pop(ctx, '__custom__'), ), ], ), ), ); }, ); if (picked == '__custom__' && context.mounted) { final ctrl = TextEditingController(text: currentTemplate); final custom = await showDialog( context: context, builder: (ctx) => AlertDialog( title: Text(isRu ? 'Свой шаблон папок' : 'Custom path template'), content: TextField( controller: ctrl, decoration: InputDecoration( labelText: isRu ? 'Шаблон' : 'Template', helperText: '{Artist}, {Provider}, {Service}, {ID}, {Date}', ), ), actions: [ TextButton( onPressed: () => Navigator.pop(ctx), child: Text(isRu ? 'Отмена' : 'Cancel'), ), FilledButton( onPressed: () => Navigator.pop(ctx, ctrl.text.trim()), child: Text(isRu ? 'Сохранить' : 'Save'), ), ], ), ); if (custom != null && custom.isNotEmpty) { onChanged(custom); } } else if (picked != null && picked != '__custom__') { onChanged(picked); } } } class _ActionButton extends StatelessWidget { const _ActionButton({ required this.icon, required this.label, required this.onPressed, this.isPrimary = false, }); final IconData icon; final String label; final VoidCallback? onPressed; final bool isPrimary; @override Widget build(BuildContext context) { if (isPrimary) { return FilledButton.tonalIcon( onPressed: onPressed, icon: Icon(icon, size: 18), label: Text(label), ); } return OutlinedButton.icon( onPressed: onPressed, icon: Icon(icon, size: 18), label: Text(label), ); } } class _ActionGrid extends StatelessWidget { const _ActionGrid({required this.children}); final List children; @override Widget build(BuildContext context) { return Wrap( spacing: 10, runSpacing: 10, children: [ for (final child in children) ConstrainedBox( constraints: const BoxConstraints(minWidth: 170), child: child, ), ], ); } } class _TagListEditor extends StatefulWidget { const _TagListEditor({ required this.title, required this.icon, required this.accentColor, required this.tags, required this.onChanged, this.helper, }); final String title; final IconData icon; final Color accentColor; final List tags; final ValueChanged> onChanged; final String? helper; @override State<_TagListEditor> createState() => _TagListEditorState(); } class _TagListEditorState extends State<_TagListEditor> { final _controller = TextEditingController(); @override void dispose() { _controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final theme = Theme.of(context); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Icon(widget.icon, size: 20, color: widget.accentColor), const SizedBox(width: 8), Expanded( child: Text( widget.title, style: theme.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w600, ), ), ), const SizedBox(width: 8), Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), decoration: BoxDecoration( color: widget.accentColor.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(10), ), child: Text( '${widget.tags.length}', style: theme.textTheme.labelSmall?.copyWith( fontWeight: FontWeight.bold, color: widget.accentColor, ), ), ), ], ), if (widget.helper != null) ...[ const SizedBox(height: 6), Text( widget.helper!, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ], const SizedBox(height: 10), Wrap( spacing: 8, runSpacing: 8, children: [ for (final tag in widget.tags) InputChip( label: Text(tag), visualDensity: VisualDensity.compact, onDeleted: () => widget.onChanged( widget.tags.where((item) => item != tag).toList(), ), ), SizedBox( width: 240, child: TextField( controller: _controller, decoration: const InputDecoration( isDense: true, hintText: 'Add tag...', prefixIcon: Icon(Icons.add_rounded, size: 18), contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8), ), textInputAction: TextInputAction.done, onSubmitted: (_) => _submit(), ), ), IconButton.filledTonal( visualDensity: VisualDensity.compact, tooltip: 'Add', onPressed: _submit, icon: const Icon(Icons.check_rounded, size: 18), ), ], ), ], ); } void _submit() { final incoming = _controller.text .split(RegExp(r'\s+')) .map((tag) => tag.trim().toLowerCase()) .where((tag) => tag.isNotEmpty); final merged = {...widget.tags, ...incoming}.toList()..sort(); _controller.clear(); widget.onChanged(merged); } } class _ColorSwatches extends StatelessWidget { const _ColorSwatches({ required this.selected, required this.onChanged, }); final int selected; final ValueChanged onChanged; static const colors = <(int, String)>[ (0xFFE84D8A, 'Lunaris pink'), (0xFF8B5CF6, 'Violet'), (0xFF3B82F6, 'Azure'), (0xFF0EA5E9, 'Sky'), (0xFF14B8A6, 'Teal'), (0xFF22C55E, 'Green'), (0xFFEAB308, 'Amber'), (0xFFF97316, 'Orange'), (0xFFEF4444, 'Red'), (0xFFEC4899, 'Hot pink'), (0xFF6366F1, 'Indigo'), (0xFF64748B, 'Slate'), ]; @override Widget build(BuildContext context) { final theme = Theme.of(context); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ const Icon(Icons.palette_outlined, size: 20, color: Color(0xFF8B5CF6)), const SizedBox(width: 8), Expanded( child: Text( 'Акцентный цвет интерфейса', style: theme.textTheme.titleSmall?.copyWith( fontWeight: FontWeight.w600, ), ), ), ], ), const SizedBox(height: 10), Wrap( spacing: 10, runSpacing: 10, children: [ for (final entry in colors) Tooltip( message: '${entry.$2} #${entry.$1.toRadixString(16).substring(2).toUpperCase()}', child: InkWell( borderRadius: BorderRadius.circular(999), onTap: () => onChanged(entry.$1), child: AnimatedContainer( duration: const Duration(milliseconds: 200), decoration: BoxDecoration( color: Color(entry.$1), shape: BoxShape.circle, border: Border.all( color: selected == entry.$1 ? theme.colorScheme.onSurface : theme.colorScheme.outlineVariant.withValues(alpha: 0.5), width: selected == entry.$1 ? 3 : 1.5, ), boxShadow: selected == entry.$1 ? [ BoxShadow( color: Color(entry.$1).withValues(alpha: 0.4), blurRadius: 8, spreadRadius: 1, ), ] : null, ), child: SizedBox( width: 38, height: 38, child: selected == entry.$1 ? const Icon(Icons.check_rounded, color: Colors.white) : null, ), ), ), ), ], ), ], ); } } class _TabVisibilityEditor extends StatelessWidget { const _TabVisibilityEditor({ required this.hiddenTabs, required this.onChanged, }); final List hiddenTabs; final ValueChanged> onChanged; static const tabs = { 'feed': ('Feed', Icons.dynamic_feed_rounded), 'search': ('Search', Icons.search_rounded), 'favorites': ('Favorites', Icons.favorite_rounded), 'viewed': ('Viewed', Icons.history_rounded), 'collections': ('Collections', Icons.collections_bookmark_rounded), 'artists': ('Artists', Icons.person_rounded), }; @override Widget build(BuildContext context) { final theme = Theme.of(context); final hidden = hiddenTabs.toSet(); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ const Icon(Icons.tab_rounded, size: 20, color: Color(0xFF8B5CF6)), const SizedBox(width: 8), Expanded( child: Text( 'Отображение вкладок навигации', style: theme.textTheme.titleSmall?.copyWith( fontWeight: FontWeight.w600, ), ), ), ], ), const SizedBox(height: 10), Wrap( spacing: 8, runSpacing: 8, children: [ for (final entry in tabs.entries) FilterChip( selected: !hidden.contains(entry.key), avatar: Icon(entry.value.$2, size: 16), label: Text(entry.value.$1), onSelected: (visible) { final next = {...hidden}; if (visible) { next.remove(entry.key); } else if (entry.key != 'feed') { next.add(entry.key); } onChanged(next.toList()..sort()); }, ), ], ), ], ); } } class _HeroBrandBanner extends StatelessWidget { const _HeroBrandBanner({ required this.sectionKey, required this.settings, required this.isRu, required this.onCheckUpdates, required this.onShowChangelog, required this.onExportJson, required this.onImportJson, required this.onExportBackup, required this.onImportBackup, }); final Key sectionKey; final AppSettings settings; final bool isRu; final VoidCallback onCheckUpdates; final VoidCallback onShowChangelog; final VoidCallback onExportJson; final VoidCallback onImportJson; final VoidCallback onExportBackup; final VoidCallback onImportBackup; @override Widget build(BuildContext context) { final theme = Theme.of(context); return Container( key: sectionKey, margin: const EdgeInsets.only(top: 8), padding: const EdgeInsets.all(20), decoration: BoxDecoration( color: theme.colorScheme.surfaceContainerLow, borderRadius: BorderRadius.circular(24), border: Border.all( color: theme.colorScheme.outlineVariant.withValues(alpha: 0.35), ), gradient: LinearGradient( colors: [ theme.colorScheme.surfaceContainerLow, theme.colorScheme.surfaceContainer, ], begin: Alignment.topLeft, end: Alignment.bottomRight, ), ), child: Column( children: [ // Logo & Version Badge Container( width: 68, height: 68, decoration: BoxDecoration( gradient: const LinearGradient( colors: [Color(0xFFE84D8A), Color(0xFF8B5CF6)], begin: Alignment.topLeft, end: Alignment.bottomRight, ), borderRadius: BorderRadius.circular(20), boxShadow: [ BoxShadow( color: const Color(0xFFE84D8A).withValues(alpha: 0.35), blurRadius: 18, offset: const Offset(0, 6), ), ], ), child: const Icon( Icons.brightness_3_rounded, color: Colors.white, size: 38, ), ), const SizedBox(height: 14), Text( 'Lunaris', style: theme.textTheme.headlineSmall?.copyWith( fontWeight: FontWeight.w800, letterSpacing: -0.5, ), ), const SizedBox(height: 4), Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), decoration: BoxDecoration( color: theme.colorScheme.secondaryContainer, borderRadius: BorderRadius.circular(12), ), child: Text( 'v$appDisplayVersion (build $appBuildNumber)', style: theme.textTheme.labelMedium?.copyWith( fontWeight: FontWeight.w600, color: theme.colorScheme.onSecondaryContainer, ), ), ), const SizedBox(height: 6), Text( isRu ? 'Быстрый и красивый Booru & Anime клиент' : 'Modern, fast & customizable booru client', style: theme.textTheme.bodyMedium?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), textAlign: TextAlign.center, ), const SizedBox(height: 20), // Action Buttons Wrap( spacing: 12, runSpacing: 10, alignment: WrapAlignment.center, children: [ FilledButton.icon( onPressed: onCheckUpdates, icon: const Icon(Icons.system_update_alt_rounded, size: 18), label: Text( isRu ? 'Проверить обновления' : 'Check for updates', ), ), FilledButton.tonalIcon( onPressed: onShowChangelog, icon: const Icon(Icons.new_releases_rounded, size: 18), label: Text(isRu ? 'Что нового' : 'Changelog'), ), OutlinedButton.icon( onPressed: () async { final url = Uri.parse( 'https://github.com/RarDog/Lunaris', ); await launchUrl(url, mode: LaunchMode.externalApplication); }, icon: const Icon(Icons.code_rounded, size: 18), label: const Text('GitHub'), ), ], ), const SizedBox(height: 16), const Divider(), const SizedBox(height: 10), // Backup & JSON controls Text( isRu ? 'Резервное копирование и экспорт' : 'Backup & JSON Export', style: theme.textTheme.titleSmall?.copyWith( fontWeight: FontWeight.w700, letterSpacing: -0.2, ), ), const SizedBox(height: 12), Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Row( children: [ Expanded( child: OutlinedButton.icon( onPressed: onExportBackup, icon: const Icon(Icons.file_upload_rounded, size: 18), label: Text( isRu ? 'Экспорт бэкапа' : 'Export backup', overflow: TextOverflow.ellipsis, ), ), ), const SizedBox(width: 10), Expanded( child: OutlinedButton.icon( onPressed: onImportBackup, icon: const Icon(Icons.file_download_rounded, size: 18), label: Text( isRu ? 'Импорт бэкапа' : 'Import backup', overflow: TextOverflow.ellipsis, ), ), ), ], ), const SizedBox(height: 10), Row( children: [ Expanded( child: FilledButton.tonalIcon( onPressed: onExportJson, icon: const Icon(Icons.data_object_rounded, size: 18), label: Text( isRu ? 'JSON Экспорт' : 'JSON Export', overflow: TextOverflow.ellipsis, ), ), ), const SizedBox(width: 10), Expanded( child: FilledButton.tonalIcon( onPressed: onImportJson, icon: const Icon(Icons.download_for_offline_rounded, size: 18), label: Text( isRu ? 'JSON Импорт' : 'JSON Import', overflow: TextOverflow.ellipsis, ), ), ), ], ), ], ), ], ), ); } }