diff --git a/README.md b/README.md
index e84da89..e758372 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,7 @@
-
+
@@ -20,6 +20,8 @@
## ✨ Основные возможности
+- **⚙️ Settings 2.0**: Полный редизайн настроек с современными squircle-карточками, горизонтальной быстрой навигацией по разделам, мгновенными Segmented-кнопками выбора темы/языка/качества и стильной карточкой о приложении.
+
- **⚡ Мгновенные подсказки тегов (0 мс) и постоянный файловый кэш**: Локальный JSON-кэш на телефоне и ПК обеспечивает мгновенный отклик при наборе, а Danbooru, Gelbooru, Moebooru и e621 динамически дополняют базу тегов.
- **🎨 Цветные бейджи категорий и подсветка совпадений**: Выделение введенных букв жирным цветом акцента и четкая цветовая палитра категорий (художник — красный, персонаж — зеленый, копирайт — фиолетовый, вид — голубой).
- **📚 Расширенная история поиска и гибкие настройки**: До 2000 записей истории и до 20000 закэшированных тегов с ручным управлением и кнопками очистки в Настройках.
diff --git a/lib/app/app_version.dart b/lib/app/app_version.dart
index ca113a7..7616acd 100644
--- a/lib/app/app_version.dart
+++ b/lib/app/app_version.dart
@@ -1,3 +1,3 @@
-const appDisplayVersion = '2.0.13';
-const appBuildNumber = 35;
+const appDisplayVersion = '2.0.14';
+const appBuildNumber = 36;
const appReleaseChannel = 'Stable';
diff --git a/lib/app/changelog.dart b/lib/app/changelog.dart
index 5c59019..eb2743f 100644
--- a/lib/app/changelog.dart
+++ b/lib/app/changelog.dart
@@ -1,4 +1,16 @@
const lunarisChangelog = [
+ LunarisChange(
+ version: '2.0.14',
+ title: 'Settings 2.0: Modern Grouped Design & Quick Ergonomics',
+ bullets: [
+ 'Settings 2.0 Layout: reimagined settings experience with modern squircle card groups, smooth borders, and curated accent badges.',
+ 'Category Quick Navigation: horizontal capsule strip at the top to smoothly jump between General, Appearance, Feed, Filters, Storage, Diagnostics, and About.',
+ 'Instant Segmented Controls: replaced tedious dropdowns with fast 1-tap SegmentedButtons for Theme (Dark/Light/Auto), Language (RU/EN), and Media Quality (Auto/Data saver/HQ).',
+ 'Folder Structure Bottom Sheet: intuitive download directory template picker with preset chips and custom pattern editor.',
+ 'Interactive Stepper & Limit Controls: modernized desktop & mobile column steppers and disk cache limits with accent pill badges.',
+ 'Hero Brand Banner: polished app presentation card in About section with glowing icon, version build badge, update checker, and Gitea repository link.',
+ ],
+ ),
LunarisChange(
version: '2.0.13',
title: 'Toolbar Section Dividers & Post Card Badge Stacking Fix',
diff --git a/lib/features/settings/presentation/settings_screen.dart b/lib/features/settings/presentation/settings_screen.dart
index cdb2cfc..2c08de7 100644
--- a/lib/features/settings/presentation/settings_screen.dart
+++ b/lib/features/settings/presentation/settings_screen.dart
@@ -24,7 +24,8 @@ class SettingsScreen extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final settings = ref.watch(settingsControllerProvider);
final strings = AppStrings(
- settings.value?.languageCode ?? AppSettings.defaults.languageCode);
+ settings.value?.languageCode ?? AppSettings.defaults.languageCode,
+ );
return AdaptiveScaffold(
title: strings.settings,
body: settings.when(
@@ -36,15 +37,51 @@ class SettingsScreen extends ConsumerWidget {
}
}
-class _SettingsContent extends ConsumerWidget {
+class _SettingsContent extends ConsumerStatefulWidget {
const _SettingsContent({required this.settings});
final AppSettings settings;
@override
- Widget build(BuildContext context, WidgetRef ref) {
+ 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 _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.easeOutCubic,
+ alignment: 0.04,
+ );
+ }
+ }
+
+ AppSettings get settings => widget.settings;
+
+ @override
+ Widget build(BuildContext context) {
final isAndroid = Platform.isAndroid;
final strings = AppStrings(settings.languageCode);
+ final isRu = strings.ru;
final deviceInfo =
isAndroid ? ref.watch(motionDeviceInfoProvider).value : null;
final detectedHz =
@@ -56,538 +93,729 @@ class _SettingsContent extends ConsumerWidget {
device: deviceInfo,
)
: null;
- return ListView(
- padding: const EdgeInsets.all(16),
- children: [
- _SettingsSection(
- title: strings.general,
- icon: Icons.tune_rounded,
+
+ 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: _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 Align(
+ alignment: Alignment.topCenter,
+ child: ConstrainedBox(
+ constraints: const BoxConstraints(maxWidth: 860),
+ child: ListView(
+ controller: _scrollController,
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
children: [
- DropdownButtonFormField(
- initialValue: settings.themeMode,
- decoration: InputDecoration(
- labelText: strings.theme,
- ),
- items: const [
- DropdownMenuItem(value: 'dark', child: Text('Dark')),
- DropdownMenuItem(value: 'light', child: Text('Light')),
- DropdownMenuItem(value: 'system', child: Text('System')),
- ],
- onChanged: (value) =>
- _update(ref, settings.copyWith(themeMode: value ?? 'dark')),
+ // Top Quick-Nav Bar
+ _CategoryQuickNav(
+ categories: navCategories,
+ onSelect: _scrollTo,
),
- SwitchListTile(
- value: settings.nsfwEnabled,
- title: Text(strings.allowNsfw),
- onChanged: (value) =>
- _update(ref, settings.copyWith(nsfwEnabled: value)),
- ),
- SwitchListTile(
- value: settings.blurExplicitContent,
- title: Text(strings.blurSensitive),
- onChanged: (value) =>
- _update(ref, settings.copyWith(blurExplicitContent: value)),
- ),
- SwitchListTile(
- value: settings.showPostBadges,
- title: Text(strings.showPostBadges),
- onChanged: (value) =>
- _update(ref, settings.copyWith(showPostBadges: value)),
- ),
- SwitchListTile(
- value: settings.allowDownloads,
- title: Text(strings.allowDownloads),
- onChanged: (value) =>
- _update(ref, settings.copyWith(allowDownloads: value)),
- ),
- SwitchListTile(
- value: settings.autoDownloadFavorites,
- title: Text(settings.languageCode == 'ru'
- ? 'Offline избранное'
- : 'Offline favorites'),
- subtitle: Text(settings.languageCode == 'ru'
- ? 'При добавлении фото или видео в избранное файл скачивается в фоне для просмотра офлайн.'
- : 'When a photo or video is added to Favorites, Lunaris downloads it in the background for offline viewing.'),
- onChanged: (value) => _update(
- ref,
- settings.copyWith(autoDownloadFavorites: value),
- ),
- ),
- if (settings.allowDownloads) ...[
- const SizedBox(height: 8),
- DropdownButtonFormField(
- initialValue: const [
- '{Artist}/{ID}',
- '{Artist}',
- '{Provider}/{Artist}',
- '{Service}/{ID}',
- '{Date}/{Artist}',
- ].contains(settings.downloadPathTemplate)
- ? settings.downloadPathTemplate
- : 'custom',
- decoration: InputDecoration(
- labelText: settings.languageCode == 'ru'
- ? 'Структура папок скачивания'
- : 'Download folder structure',
- helperText: '{Artist}, {Provider}, {Service}, {ID}, {Date}',
- ),
- items: [
- const DropdownMenuItem(
- value: '{Artist}/{ID}',
- child: Text('{Artist}/{ID}'),
- ),
- const DropdownMenuItem(
- value: '{Artist}',
- child: Text('{Artist}'),
- ),
- const DropdownMenuItem(
- value: '{Provider}/{Artist}',
- child: Text('{Provider}/{Artist}'),
- ),
- const DropdownMenuItem(
- value: '{Service}/{ID}',
- child: Text('{Service}/{ID}'),
- ),
- const DropdownMenuItem(
- value: '{Date}/{Artist}',
- child: Text('{Date}/{Artist}'),
- ),
- if (![
- '{Artist}/{ID}',
- '{Artist}',
- '{Provider}/{Artist}',
- '{Service}/{ID}',
- '{Date}/{Artist}',
- ].contains(settings.downloadPathTemplate))
- DropdownMenuItem(
- value: settings.downloadPathTemplate,
- child: Text(settings.downloadPathTemplate),
+ const SizedBox(height: 16),
+
+ // 1. General Section
+ _SettingsCardGroup(
+ sectionKey: _generalKey,
+ title: strings.general,
+ icon: Icons.tune_rounded,
+ accentColor: const Color(0xFF6366F1),
+ children: [
+ _SettingsTile(
+ icon: Icons.brightness_6_rounded,
+ iconColor: const Color(0xFF6366F1),
+ title: strings.theme,
+ subtitle: isRu
+ ? 'Оформление цветовой схемы приложения'
+ : 'App theme brightness mode',
+ trailing: SegmentedButton(
+ showSelectedIcon: false,
+ style: const ButtonStyle(
+ visualDensity: VisualDensity.compact,
+ tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
- const DropdownMenuItem(
- value: 'custom',
- child: Text('Пользовательский шаблон...'),
- ),
- ],
- onChanged: (value) async {
- if (value == 'custom') {
- final ctrl = TextEditingController(
- text: settings.downloadPathTemplate);
- final custom = await showDialog(
- context: context,
- builder: (ctx) => AlertDialog(
- title: const Text('Шаблон пути скачивания'),
- content: TextField(
- controller: ctrl,
- decoration: const InputDecoration(
- labelText: 'Шаблон',
- helperText:
- 'Доступно: {Artist}, {Provider}, {Service}, {ID}, {Date}',
- ),
- ),
- actions: [
- TextButton(
- onPressed: () => Navigator.pop(ctx),
- child: const Text('Отмена')),
- FilledButton(
- onPressed: () =>
- Navigator.pop(ctx, ctrl.text.trim()),
- child: const Text('Сохранить')),
- ],
+ segments: [
+ ButtonSegment(
+ value: 'dark',
+ icon: const Icon(Icons.dark_mode_rounded, size: 16),
+ label: Text(isRu ? 'Темная' : 'Dark'),
),
- );
- if (custom != null && custom.isNotEmpty) {
- _update(ref,
- settings.copyWith(downloadPathTemplate: custom));
- }
- } else if (value != null) {
- _update(ref,
- settings.copyWith(downloadPathTemplate: value));
- }
- },
- ),
- ],
- ],
- ),
- _SettingsSection(
- title: strings.appearance,
- icon: Icons.palette_rounded,
- children: [
- DropdownButtonFormField(
- initialValue: settings.languageCode,
- decoration: InputDecoration(labelText: strings.language),
- items: const [
- DropdownMenuItem(value: 'ru', child: Text('Русский')),
- DropdownMenuItem(value: 'en', child: Text('English')),
- ],
- onChanged: (value) => _update(
- ref,
- settings.copyWith(languageCode: value ?? 'ru'),
- ),
- ),
- SwitchListTile(
- value: settings.amoledMode,
- title: Text(settings.languageCode == 'ru'
- ? 'AMOLED Pure Black тема'
- : 'AMOLED Pure Black theme'),
- subtitle: Text(settings.languageCode == 'ru'
- ? 'Абсолютно черный фон (#000000) для OLED дисплеев.'
- : 'Pitch-black background (#000000) for OLED displays.'),
- onChanged: (value) => _update(
- ref,
- settings.copyWith(amoledMode: value),
- ),
- ),
- if (isAndroid)
- SwitchListTile(
- value: settings.useDynamicColor,
- title: Text(settings.languageCode == 'ru'
- ? 'Динамические цвета Material You'
- : 'Material You Dynamic Colors'),
- subtitle: Text(settings.languageCode == 'ru'
- ? 'Палитра интерфейса подстраивается под обои системы.'
- : 'Palette adapts to device wallpaper colors.'),
- onChanged: (value) => _update(
- ref,
- settings.copyWith(useDynamicColor: value),
+ 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 SizedBox(height: 12),
- _ColorSwatches(
- selected: settings.appSeedColor,
- onChanged: (color) =>
- _update(ref, settings.copyWith(appSeedColor: color)),
- ),
- const SizedBox(height: 12),
- _TabVisibilityEditor(
- hiddenTabs: settings.hiddenTabs,
- onChanged: (hiddenTabs) =>
- _update(ref, settings.copyWith(hiddenTabs: hiddenTabs)),
- ),
- SwitchListTile(
- value: settings.allowExperimentalUpdates,
- title: Text(settings.languageCode == 'ru'
- ? 'Получать beta / experimental обновления'
- : 'Receive beta / experimental updates'),
- subtitle: Text(settings.languageCode == 'ru'
- ? 'Обновлятор будет видеть prerelease-сборки Gitea.'
- : 'When enabled, the updater also sees prerelease Gitea builds.'),
- onChanged: (value) => _update(
- ref,
- settings.copyWith(allowExperimentalUpdates: value),
- ),
- ),
- ],
- ),
- _SettingsSection(
- title: strings.feedLayout,
- icon: Icons.dashboard_customize_rounded,
- children: [
- DropdownButtonFormField(
- initialValue: settings.mediaQualityMode,
- decoration: InputDecoration(labelText: strings.mediaQuality),
- items: [
- for (final mode in MediaQualityMode.values)
- DropdownMenuItem(value: mode.name, child: Text(mode.label)),
- ],
- onChanged: (value) => _update(
- ref,
- settings.copyWith(mediaQualityMode: value ?? 'auto'),
- ),
- ),
- if (isAndroid) ...[
- const SizedBox(height: 12),
- DropdownButtonFormField(
- initialValue: settings.motionRefreshMode,
- decoration: const InputDecoration(
- labelText: 'Animation refresh profile',
+ 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)),
),
- items: [
- for (final mode in MotionRefreshMode.values)
- DropdownMenuItem(value: mode.name, child: Text(mode.label)),
+ 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),
+ ),
+ ),
],
- onChanged: (value) => _update(
- ref,
- settings.copyWith(motionRefreshMode: value ?? 'auto'),
- ),
- ),
- SwitchListTile(
- value: settings.autoBatterySaver60Hz,
- title: const Text('Auto 60 Hz below 20% battery'),
- subtitle: Text(
- 'Detected ${motion!.detectedHz.toStringAsFixed(0)} Hz'
- '${motion.batteryLevel == null ? '' : ', battery ${motion.batteryLevel}%'}'
- '${motion.batterySaverActive ? ', saver active' : ''}',
- ),
- onChanged: (value) => _update(
- ref,
- settings.copyWith(autoBatterySaver60Hz: value),
- ),
- ),
- ],
- _StepperTile(
- title: strings.desktopColumns,
- value: settings.desktopColumns,
- min: 3,
- max: 8,
- onChanged: (value) => _update(
- ref,
- settings.copyWith(desktopColumns: value),
- ),
+ ],
),
- _StepperTile(
- title: strings.mobileColumns,
- value: settings.mobileColumns,
- min: 1,
- max: 3,
- onChanged: (value) => _update(
- ref,
- settings.copyWith(mobileColumns: value),
- ),
- ),
- ],
- ),
- _SettingsSection(
- title: strings.filters,
- icon: Icons.filter_alt_rounded,
- children: [
- SwitchListTile(
- value: settings.hideViewedPosts,
- title: Text(strings.hideViewed),
- onChanged: (value) =>
- _update(ref, settings.copyWith(hideViewedPosts: value)),
- ),
- _TagListEditor(
- title: strings.smartBlacklist,
- icon: Icons.visibility_off_rounded,
- tags: settings.smartBlacklistRules,
- helper:
- '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 SizedBox(height: 12),
- _TagListEditor(
- title: strings.whitelistedTags,
- icon: Icons.verified_rounded,
- tags: settings.whitelistedTags,
- onChanged: (tags) => _update(
- ref,
- settings.copyWith(whitelistedTags: tags),
- ),
- ),
- ],
- ),
- _SettingsSection(
- title: strings.storage,
- icon: Icons.storage_rounded,
- children: [
- _StepperTile(
- title: strings.cacheMaxItems,
- value: settings.cacheMaxItems,
- min: 100,
- max: 10000,
- step: 100,
- onChanged: (value) =>
- _update(ref, settings.copyWith(cacheMaxItems: value)),
- ),
- _StepperTile(
- title: settings.languageCode == 'ru'
- ? 'Лимит истории поиска'
- : 'Search history limit',
- value: settings.searchHistoryLimit,
- min: 50,
- max: 2000,
- step: 50,
- onChanged: (value) =>
- _update(ref, settings.copyWith(searchHistoryLimit: value)),
- ),
- _StepperTile(
- title: settings.languageCode == 'ru'
- ? 'Лимит кэша тегов (диск)'
- : 'Tag cache limit (disk)',
- value: settings.tagCacheLimit,
- min: 500,
- max: 20000,
- step: 500,
- onChanged: (value) =>
- _update(ref, settings.copyWith(tagCacheLimit: value)),
- ),
- _ActionGrid(
+
+ // 2. Appearance Section
+ _SettingsCardGroup(
+ sectionKey: _appearanceKey,
+ title: strings.appearance,
+ icon: Icons.palette_rounded,
+ accentColor: const Color(0xFF8B5CF6),
children: [
- FilledButton.tonalIcon(
- onPressed: () => ref
- .read(settingsControllerProvider.notifier)
- .clearCache(),
- icon: const Icon(Icons.cleaning_services_rounded),
- label: Text(strings.clearCache),
- ),
- FilledButton.tonalIcon(
- onPressed: () => context.go('/settings/cache'),
- icon: const Icon(Icons.pie_chart_rounded),
- label: Text(settings.languageCode == 'ru'
- ? 'Менеджер кэша'
- : 'Cache Manager'),
- ),
- FilledButton.tonalIcon(
- onPressed: () async {
- await ref
- .read(settingsControllerProvider.notifier)
- .clearTagCache();
- if (context.mounted) {
- ScaffoldMessenger.of(context).showSnackBar(
- SnackBar(
- content: Text(
- settings.languageCode == 'ru'
- ? 'Кэш подсказок тегов очищен'
- : 'Tag suggestions cache cleared',
- ),
- ),
- );
- }
- },
- icon: const Icon(Icons.label_off_rounded),
- label: Text(
- settings.languageCode == 'ru'
- ? 'Очистить кэш тегов'
- : 'Clear tag cache',
+ _SettingsTile(
+ icon: Icons.translate_rounded,
+ iconColor: const Color(0xFF8B5CF6),
+ title: strings.language,
+ subtitle: isRu
+ ? 'Язык интерфейса приложения'
+ : 'App user interface language',
+ trailing: SegmentedButton(
+ showSelectedIcon: false,
+ style: const ButtonStyle(
+ visualDensity: VisualDensity.compact,
+ tapTargetSize: MaterialTapTargetSize.shrinkWrap,
+ ),
+ segments: const [
+ ButtonSegment(
+ value: 'ru',
+ label: Text('🇷🇺 RU'),
+ ),
+ ButtonSegment(
+ value: 'en',
+ label: Text('🇬🇧 EN'),
+ ),
+ ],
+ selected: {settings.languageCode},
+ onSelectionChanged: (set) => _update(
+ ref,
+ settings.copyWith(languageCode: set.first),
+ ),
),
),
- FilledButton.tonalIcon(
- onPressed: () async {
- await ref
- .read(settingsControllerProvider.notifier)
- .clearSearchHistory();
- if (context.mounted) {
- ScaffoldMessenger.of(context).showSnackBar(
- SnackBar(
- content: Text(
- settings.languageCode == 'ru'
- ? 'История поиска очищена'
- : 'Search history cleared',
- ),
- ),
- );
- }
- },
- icon: const Icon(Icons.history_toggle_off_rounded),
- label: Text(
- settings.languageCode == 'ru'
- ? 'Очистить историю'
- : 'Clear search history',
+ 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)),
),
),
- FilledButton.tonalIcon(
- onPressed: () => ref
- .read(settingsControllerProvider.notifier)
- .clearViewedHistory(),
- icon: const Icon(Icons.visibility_off_rounded),
- label: Text(strings.clearViewed),
+ const _SettingsDivider(),
+ Padding(
+ padding: const EdgeInsets.symmetric(
+ horizontal: 16,
+ vertical: 12,
+ ),
+ child: _TabVisibilityEditor(
+ hiddenTabs: settings.hiddenTabs,
+ onChanged: (hiddenTabs) =>
+ _update(ref, settings.copyWith(hiddenTabs: hiddenTabs)),
+ ),
),
- FilledButton.tonalIcon(
- onPressed: settings.hiddenPostKeys.isEmpty
- ? null
- : () => context.go('/settings/hidden'),
- icon: const Icon(Icons.restore_rounded),
- label: const Text('Restore hidden'),
- ),
- FilledButton.tonalIcon(
- onPressed: () => ref
- .read(settingsControllerProvider.notifier)
- .clearHiddenPosts(),
- icon: const Icon(Icons.delete_sweep_rounded),
- label:
- Text('Clear hidden (${settings.hiddenPostKeys.length})'),
+ 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),
+ ),
),
],
),
- ],
- ),
- _SettingsSection(
- title: 'Providers & Diagnostics',
- icon: Icons.hub_rounded,
- children: [
- _ActionGrid(
+
+ // 3. Feed & Layout Section
+ _SettingsCardGroup(
+ sectionKey: _feedKey,
+ title: strings.feedLayout,
+ icon: Icons.dashboard_customize_rounded,
+ accentColor: const Color(0xFF10B981),
children: [
- FilledButton.tonalIcon(
- onPressed: () => context.go('/providers'),
- icon: const Icon(Icons.hub_rounded),
- label: const Text('Providers'),
+ _SettingsTile(
+ icon: Icons.high_quality_rounded,
+ iconColor: const Color(0xFF10B981),
+ title: strings.mediaQuality,
+ subtitle: isRu
+ ? 'Разрешение загружаемых картинок в сетке'
+ : 'Resolution profile for feed thumbnails',
+ trailing: SegmentedButton(
+ showSelectedIcon: false,
+ style: const ButtonStyle(
+ visualDensity: VisualDensity.compact,
+ tapTargetSize: MaterialTapTargetSize.shrinkWrap,
+ ),
+ 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),
+ ),
+ ),
),
- FilledButton.tonalIcon(
- onPressed: () => context.go('/providers/check'),
- icon: const Icon(Icons.network_check_rounded),
- label: const Text('Diagnostics'),
+ 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.of(context)
+ .textTheme
+ .bodyLarge
+ ?.copyWith(fontWeight: FontWeight.w600),
+ ),
+ Text(
+ isRu
+ ? 'Оптимизация плавности скролла ленты'
+ : 'Fluid 120-165 Hz scrolling optimization',
+ style: Theme.of(context)
+ .textTheme
+ .bodyMedium
+ ?.copyWith(
+ color: Theme.of(context)
+ .colorScheme
+ .onSurfaceVariant,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ const SizedBox(height: 10),
+ 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
+ ? 'Количество столбцов постов на ПК и широких экранах'
+ : 'Number of columns on desktop layout',
+ value: settings.desktopColumns,
+ min: 3,
+ max: 8,
+ onChanged: (val) =>
+ _update(ref, settings.copyWith(desktopColumns: val)),
),
- FilledButton.tonalIcon(
- onPressed: () => _copyDiagnostics(context, ref),
- icon: const Icon(Icons.bug_report_rounded),
- label: const Text('Copy report'),
- ),
- FilledButton.tonalIcon(
- onPressed: () => _copyLogs(context, ref),
- icon: const Icon(Icons.receipt_long_rounded),
- label: const Text('Copy logs'),
- ),
- FilledButton.tonalIcon(
- onPressed: () => ref
- .read(settingsControllerProvider.notifier)
- .clearDiagnosticLogs(),
- icon: const Icon(Icons.delete_sweep_rounded),
- label: const Text('Clear logs'),
+ const _SettingsDivider(),
+ _SettingsStepperTile(
+ icon: Icons.smartphone_rounded,
+ iconColor: const Color(0xFF6366F1),
+ title: strings.mobileColumns,
+ subtitle: isRu
+ ? 'Количество столбцов постов на телефонах'
+ : 'Number of columns on phone layout',
+ value: settings.mobileColumns,
+ min: 1,
+ max: 3,
+ onChanged: (val) =>
+ _update(ref, settings.copyWith(mobileColumns: val)),
),
],
),
- ],
- ),
- _SettingsSection(
- title: strings.about,
- icon: Icons.info_rounded,
- children: [
- _ActionGrid(
+
+ // 4. Filters & Blacklist Section
+ _SettingsCardGroup(
+ sectionKey: _filtersKey,
+ title: strings.filters,
+ icon: Icons.filter_alt_rounded,
+ accentColor: const Color(0xFFF59E0B),
children: [
- FilledButton.tonalIcon(
- onPressed: () => _checkUpdates(context, ref),
- icon: const Icon(Icons.system_update_alt_rounded),
- label: Text(strings.checkUpdates),
+ _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)),
),
- FilledButton.tonalIcon(
- onPressed: () => _showChangelog(context),
- icon: const Icon(Icons.new_releases_rounded),
- label: const Text('What changed'),
+ 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),
+ ),
+ ),
),
- FilledButton.tonalIcon(
- onPressed: () => _exportJson(context, ref),
- icon: const Icon(Icons.upload_file_rounded),
- label: Text(strings.exportJson),
- ),
- FilledButton.tonalIcon(
- onPressed: () => _importDialog(context, ref),
- icon: const Icon(Icons.download_for_offline_rounded),
- label: Text(strings.importJson),
- ),
- FilledButton.tonalIcon(
- onPressed: () => _exportBackupFile(context, ref),
- icon: const Icon(Icons.file_upload_rounded),
- label: Text(settings.languageCode == 'ru'
- ? 'Экспорт бэкапа'
- : 'Export backup'),
- ),
- FilledButton.tonalIcon(
- onPressed: () => _importBackupFile(context, ref),
- icon: const Icon(Icons.file_download_rounded),
- label: Text(settings.languageCode == 'ru'
- ? 'Импорт бэкапа'
- : 'Import backup'),
+ 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),
+ ),
+ ),
),
],
),
- const SizedBox(height: 8),
- const Text('Version $appDisplayVersion ($appBuildNumber)'),
+
+ // 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(),
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+
+ // 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),
],
),
- ],
+ ),
);
}
@@ -812,45 +1040,528 @@ class _SettingsContent extends ConsumerWidget {
}
}
-class _SettingsSection extends StatelessWidget {
- const _SettingsSection({
+// -----------------------------------------------------------------------------
+// Settings 2.0 Modern Components
+// -----------------------------------------------------------------------------
+
+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);
+ return SizedBox(
+ height: 44,
+ child: ListView.separated(
+ scrollDirection: Axis.horizontal,
+ itemCount: categories.length,
+ separatorBuilder: (_, __) => const SizedBox(width: 8),
+ itemBuilder: (context, index) {
+ final cat = categories[index];
+ return Material(
+ color: theme.colorScheme.surfaceContainer,
+ borderRadius: BorderRadius.circular(22),
+ child: InkWell(
+ borderRadius: BorderRadius.circular(22),
+ onTap: () => onSelect(cat.key),
+ child: Container(
+ padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(22),
+ border: Border.all(
+ color: theme.colorScheme.outlineVariant.withValues(alpha: 0.35),
+ ),
+ ),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Icon(cat.icon, size: 16, color: cat.color),
+ const SizedBox(width: 8),
+ Text(
+ cat.label,
+ style: theme.textTheme.labelLarge?.copyWith(
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+ },
+ ),
+ );
+ }
+}
+
+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) {
- return Padding(
- padding: const EdgeInsets.only(bottom: 14),
- child: DecoratedBox(
- decoration: BoxDecoration(
- border:
- Border.all(color: Theme.of(context).colorScheme.outlineVariant),
- borderRadius: BorderRadius.circular(12),
- ),
- child: Padding(
- padding: const EdgeInsets.all(14),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.stretch,
- children: [
- Row(
- children: [
- Icon(icon, size: 20),
- const SizedBox(width: 8),
- Text(title, style: Theme.of(context).textTheme.titleMedium),
- ],
- ),
- const SizedBox(height: 12),
- ...children,
- ],
- ),
+ final theme = Theme.of(context);
+ return Container(
+ key: sectionKey,
+ margin: const EdgeInsets.only(bottom: 16),
+ decoration: BoxDecoration(
+ color: theme.colorScheme.surfaceContainerLow,
+ borderRadius: BorderRadius.circular(20),
+ border: Border.all(
+ color: theme.colorScheme.outlineVariant.withValues(alpha: 0.3),
),
),
+ clipBehavior: Clip.antiAlias,
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ // Header
+ Padding(
+ padding: const EdgeInsets.fromLTRB(16, 16, 16, 10),
+ child: Row(
+ children: [
+ Container(
+ width: 32,
+ height: 32,
+ decoration: BoxDecoration(
+ color: accentColor.withValues(alpha: 0.14),
+ borderRadius: BorderRadius.circular(10),
+ ),
+ child: Icon(icon, size: 18, color: accentColor),
+ ),
+ const SizedBox(width: 12),
+ Text(
+ title,
+ style: theme.textTheme.titleMedium?.copyWith(
+ fontWeight: FontWeight.w700,
+ letterSpacing: -0.2,
+ ),
+ ),
+ ],
+ ),
+ ),
+ const Divider(height: 1, thickness: 1, indent: 0, endIndent: 0),
+ ...children,
+ ],
+ ),
+ );
+ }
+}
+
+class _SettingsDivider extends StatelessWidget {
+ const _SettingsDivider();
+
+ @override
+ Widget build(BuildContext context) {
+ return Divider(
+ height: 1,
+ thickness: 0.7,
+ indent: 68,
+ endIndent: 16,
+ color: 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: [
+ Container(
+ width: 38,
+ height: 38,
+ decoration: BoxDecoration(
+ color: iconColor.withValues(alpha: 0.12),
+ borderRadius: BorderRadius.circular(12),
+ ),
+ child: Icon(icon, size: 20, color: iconColor),
+ ),
+ 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!,
+ ],
+ ],
+ ),
+ ),
+ );
+ }
+}
+
+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: [
+ Container(
+ width: 38,
+ height: 38,
+ decoration: BoxDecoration(
+ color: iconColor.withValues(alpha: 0.12),
+ borderRadius: BorderRadius.circular(12),
+ ),
+ child: Icon(icon, size: 20, color: iconColor),
+ ),
+ 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 _SettingsTile(
+ icon: Icons.folder_copy_rounded,
+ iconColor: const Color(0xFF0EA5E9),
+ title: isRu ? 'Папки скачивания' : 'Download folder structure',
+ subtitle: isRu
+ ? 'Шаблон организации сохраняемых файлов'
+ : 'Path template for organized downloads',
+ trailing: Container(
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
+ decoration: BoxDecoration(
+ color: theme.colorScheme.surfaceContainerHighest,
+ borderRadius: BorderRadius.circular(10),
+ border: Border.all(
+ color: theme.colorScheme.outlineVariant.withValues(alpha: 0.4),
+ ),
+ ),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Text(
+ currentTemplate,
+ style: TextStyle(
+ fontFamily: 'monospace',
+ fontSize: 12,
+ fontWeight: FontWeight.w600,
+ color: theme.colorScheme.primary,
+ ),
+ ),
+ const SizedBox(width: 4),
+ Icon(
+ Icons.unfold_more_rounded,
+ size: 16,
+ color: theme.colorScheme.onSurfaceVariant,
+ ),
+ ],
+ ),
+ ),
+ onTap: () => _showPicker(context),
+ );
+ }
+
+ 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),
);
}
}
@@ -868,7 +1579,7 @@ class _ActionGrid extends StatelessWidget {
children: [
for (final child in children)
ConstrainedBox(
- constraints: const BoxConstraints(minWidth: 180),
+ constraints: const BoxConstraints(minWidth: 170),
child: child,
),
],
@@ -880,6 +1591,7 @@ class _TagListEditor extends StatefulWidget {
const _TagListEditor({
required this.title,
required this.icon,
+ required this.accentColor,
required this.tags,
required this.onChanged,
this.helper,
@@ -887,6 +1599,7 @@ class _TagListEditor extends StatefulWidget {
final String title;
final IconData icon;
+ final Color accentColor;
final List tags;
final ValueChanged> onChanged;
final String? helper;
@@ -906,68 +1619,82 @@ class _TagListEditorState extends State<_TagListEditor> {
@override
Widget build(BuildContext context) {
- return DecoratedBox(
- decoration: BoxDecoration(
- border: Border.all(color: Theme.of(context).colorScheme.outlineVariant),
- borderRadius: BorderRadius.circular(12),
- ),
- child: Padding(
- padding: const EdgeInsets.all(12),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
+ final theme = Theme.of(context);
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
children: [
- Row(
- children: [
- Icon(widget.icon, size: 20),
- const SizedBox(width: 8),
- Text(
- widget.title,
- style: Theme.of(context).textTheme.titleMedium,
- ),
- ],
- ),
- if (widget.helper != null) ...[
- const SizedBox(height: 6),
- Text(
- widget.helper!,
- style: Theme.of(context).textTheme.bodySmall,
+ Icon(widget.icon, size: 20, color: widget.accentColor),
+ const SizedBox(width: 8),
+ Text(
+ widget.title,
+ style: theme.textTheme.titleMedium?.copyWith(
+ fontWeight: FontWeight.w600,
),
- ],
- const SizedBox(height: 10),
- Wrap(
- spacing: 8,
- runSpacing: 8,
- children: [
- for (final tag in widget.tags)
- InputChip(
- label: Text(tag),
- onDeleted: () => widget.onChanged(
- widget.tags.where((item) => item != tag).toList(),
- ),
- ),
- SizedBox(
- width: 240,
- child: TextField(
- controller: _controller,
- decoration: const InputDecoration(
- isDense: true,
- hintText: 'Add tags',
- prefixIcon: Icon(Icons.tag_rounded),
- ),
- textInputAction: TextInputAction.done,
- onSubmitted: (_) => _submit(),
- ),
+ ),
+ 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,
),
- IconButton.filledTonal(
- tooltip: 'Add tags',
- onPressed: _submit,
- icon: const Icon(Icons.add_rounded),
- ),
- ],
+ ),
),
],
),
- ),
+ 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),
+ ),
+ ],
+ ),
+ ],
);
}
@@ -1008,11 +1735,23 @@ class _ColorSwatches extends StatelessWidget {
@override
Widget build(BuildContext context) {
+ final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
- Text('Accent color', style: Theme.of(context).textTheme.titleSmall),
- const SizedBox(height: 8),
+ Row(
+ children: [
+ const Icon(Icons.palette_outlined, size: 20, color: Color(0xFF8B5CF6)),
+ const SizedBox(width: 8),
+ Text(
+ 'Акцентный цвет интерфейса',
+ style: theme.textTheme.titleSmall?.copyWith(
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ ],
+ ),
+ const SizedBox(height: 10),
Wrap(
spacing: 10,
runSpacing: 10,
@@ -1024,16 +1763,26 @@ class _ColorSwatches extends StatelessWidget {
child: InkWell(
borderRadius: BorderRadius.circular(999),
onTap: () => onChanged(entry.$1),
- child: DecoratedBox(
+ child: AnimatedContainer(
+ duration: const Duration(milliseconds: 200),
decoration: BoxDecoration(
color: Color(entry.$1),
shape: BoxShape.circle,
border: Border.all(
color: selected == entry.$1
- ? Theme.of(context).colorScheme.onSurface
- : Theme.of(context).colorScheme.outlineVariant,
- width: selected == entry.$1 ? 3 : 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,
@@ -1061,23 +1810,35 @@ class _TabVisibilityEditor extends StatelessWidget {
final List hiddenTabs;
final ValueChanged> onChanged;
- static const tabs = {
- 'feed': 'Feed',
- 'search': 'Search',
- 'favorites': 'Favorites',
- 'viewed': 'Viewed',
- 'collections': 'Collections',
- 'artists': 'Artists',
+ 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: [
- Text('Visible tabs', style: Theme.of(context).textTheme.titleSmall),
- const SizedBox(height: 8),
+ Row(
+ children: [
+ const Icon(Icons.tab_rounded, size: 20, color: Color(0xFF8B5CF6)),
+ const SizedBox(width: 8),
+ Text(
+ 'Отображение вкладок навигации',
+ style: theme.textTheme.titleSmall?.copyWith(
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ ],
+ ),
+ const SizedBox(height: 10),
Wrap(
spacing: 8,
runSpacing: 8,
@@ -1085,7 +1846,8 @@ class _TabVisibilityEditor extends StatelessWidget {
for (final entry in tabs.entries)
FilterChip(
selected: !hidden.contains(entry.key),
- label: Text(entry.value),
+ avatar: Icon(entry.value.$2, size: 16),
+ label: Text(entry.value.$1),
onSelected: (visible) {
final next = {...hidden};
if (visible) {
@@ -1103,38 +1865,182 @@ class _TabVisibilityEditor extends StatelessWidget {
}
}
-class _StepperTile extends StatelessWidget {
- const _StepperTile({
- required this.title,
- required this.value,
- required this.min,
- required this.max,
- required this.onChanged,
- this.step = 1,
+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 String title;
- final int value;
- final int min;
- final int max;
- final int step;
- final ValueChanged onChanged;
+ 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) {
- return ListTile(
- title: Text(title),
- trailing: Row(
- mainAxisSize: MainAxisSize.min,
+ 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: [
- IconButton(
- onPressed: value <= min ? null : () => onChanged(value - step),
- icon: const Icon(Icons.remove_rounded),
+ // 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,
+ ),
),
- SizedBox(width: 52, child: Center(child: Text('$value'))),
- IconButton(
- onPressed: value >= max ? null : () => onChanged(value + step),
- icon: const Icon(Icons.add_rounded),
+ 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://gitea.rardogsynapse.online/RarDog/Lunaris',
+ );
+ await launchUrl(url, mode: LaunchMode.externalApplication);
+ },
+ icon: const Icon(Icons.code_rounded, size: 18),
+ label: const Text('Gitea'),
+ ),
+ ],
+ ),
+ 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.w600,
+ color: theme.colorScheme.onSurfaceVariant,
+ ),
+ ),
+ const SizedBox(height: 12),
+ Wrap(
+ spacing: 10,
+ runSpacing: 10,
+ alignment: WrapAlignment.center,
+ children: [
+ OutlinedButton.icon(
+ onPressed: onExportBackup,
+ icon: const Icon(Icons.file_upload_rounded, size: 18),
+ label: Text(isRu ? 'Экспорт бэкапа' : 'Export backup'),
+ ),
+ OutlinedButton.icon(
+ onPressed: onImportBackup,
+ icon: const Icon(Icons.file_download_rounded, size: 18),
+ label: Text(isRu ? 'Импорт бэкапа' : 'Import backup'),
+ ),
+ OutlinedButton.icon(
+ onPressed: onExportJson,
+ icon: const Icon(Icons.upload_file_rounded, size: 18),
+ label: const Text('JSON Export'),
+ ),
+ OutlinedButton.icon(
+ onPressed: onImportJson,
+ icon: const Icon(Icons.download_for_offline_rounded, size: 18),
+ label: const Text('JSON Import'),
+ ),
+ ],
),
],
),
diff --git a/pubspec.yaml b/pubspec.yaml
index 9f92f20..f3d875b 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -1,7 +1,7 @@
name: gel_rule_app
description: Local backend/core layer for a Flutter booru browser app.
publish_to: "none"
-version: 2.0.13+35
+version: 2.0.14+36
environment:
sdk: ">=3.3.0 <4.0.0"
diff --git a/test/features/settings_screen_test.dart b/test/features/settings_screen_test.dart
new file mode 100644
index 0000000..e8de3e7
--- /dev/null
+++ b/test/features/settings_screen_test.dart
@@ -0,0 +1,68 @@
+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/settings/presentation/settings_controller.dart';
+import 'package:gel_rule_app/features/settings/presentation/settings_screen.dart';
+
+void main() {
+ testWidgets('SettingsScreen renders category quick nav and sections', (tester) async {
+ tester.view.physicalSize = const Size(1200, 2400);
+ tester.view.devicePixelRatio = 1.0;
+ addTearDown(() {
+ tester.view.resetPhysicalSize();
+ tester.view.resetDevicePixelRatio();
+ });
+
+ final settings = AppSettings.defaults.copyWith(
+ languageCode: 'ru',
+ themeMode: 'dark',
+ );
+
+ await tester.pumpWidget(
+ ProviderScope(
+ overrides: [
+ settingsControllerProvider.overrideWith(
+ () => _FakeSettingsController(settings),
+ ),
+ ],
+ child: const MaterialApp(
+ home: SettingsScreen(),
+ ),
+ ),
+ );
+
+ await tester.pumpAndSettle();
+
+ // Verify Category Quick Nav renders
+ expect(find.text('Основное'), findsWidgets);
+ expect(find.text('Внешний вид'), findsWidgets);
+ expect(find.text('Лента и раскладка'), findsWidgets);
+ expect(find.text('Фильтры'), findsWidgets);
+ expect(find.text('Хранилище'), findsWidgets);
+
+ // Verify Theme mode segment buttons
+ expect(find.text('Темная'), findsOneWidget);
+ expect(find.text('Светлая'), findsOneWidget);
+ // Both ThemeMode and MediaQualityMode have 'Авто'
+ expect(find.text('Авто'), findsNWidgets(2));
+
+ // Verify Language segmented buttons
+ expect(find.text('🇷🇺 RU'), findsOneWidget);
+ expect(find.text('🇬🇧 EN'), findsOneWidget);
+ });
+}
+
+class _FakeSettingsController extends SettingsController {
+ _FakeSettingsController(this._initial);
+
+ final AppSettings _initial;
+
+ @override
+ Future build() async => _initial;
+
+ @override
+ Future saveSettings(AppSettings settings) async {
+ state = AsyncData(settings);
+ }
+}