Compare commits

...
10 Commits
Author SHA1 Message Date
RarDog 5434c5dc7b feat: open full e621 comic sequence in post pager with page indicator (v3.7.2)
Build and Release / Build Windows x64 (push) Has been cancelled
Build and Release / Build macOS (push) Has been cancelled
2026-09-05 20:31:59 +03:00
RarDog cd1a9ccc0c feat: add dedicated e621 tab in Favorites and fix comic post-not-found parsing (v3.7.1)
Build and Release / Build Windows x64 (push) Has been cancelled
Build and Release / Build macOS (push) Has been cancelled
2026-09-05 20:19:27 +03:00
RarDog cc6ad2d7bf feat: complete e621 integration with API key auth, comics/pools, post variations, and server sync (v3.7.0)
Build and Release / Build Windows x64 (push) Has been cancelled
Build and Release / Build macOS (push) Has been cancelled
2026-09-05 19:48:52 +03:00
RarDog 54b77726cb fix: remove preview image overlay from video player and restore clean black background (v3.6.5)
Build and Release / Build Windows x64 (push) Has been cancelled
Build and Release / Build macOS (push) Has been cancelled
2026-09-05 18:56:49 +03:00
RarDog 8ad36e2f6b fix: eliminate video player blue screen on desktop with software rendering default and decoder toggle (v3.6.4)
Build and Release / Build Windows x64 (push) Has been cancelled
Build and Release / Build macOS (push) Has been cancelled
2026-09-05 18:48:33 +03:00
RarDog 4aedde9b3f feat: desktop horizontal mouse-wheel scroll & fix PC video player blue screen (v3.6.3)
Build and Release / Build Windows x64 (push) Has been cancelled
Build and Release / Build macOS (push) Has been cancelled
2026-09-05 18:32:45 +03:00
RarDog 5b2c8eb53e fix(perf): memory optimization, prevent OOM on mobile, and completely disable mouse wheel zoom
Build and Release / Build Windows x64 (push) Has been cancelled
Build and Release / Build macOS (push) Has been cancelled
2026-09-05 18:09:58 +03:00
RarDog d7d9cad423 fix(desktop): smooth sidebar hover animation without text jitter and remove mouse wheel zoom
Build and Release / Build Windows x64 (push) Has been cancelled
Build and Release / Build macOS (push) Has been cancelled
2026-09-05 17:58:49 +03:00
RarDog 7d3c4f964a feat: desktop UI redesign and comprehensive PC hotkeys (v3.6.0)
Build and Release / Build Windows x64 (push) Has been cancelled
Build and Release / Build macOS (push) Has been cancelled
2026-09-05 17:44:53 +03:00
RarDog 877a044ff3 feat(linux): add build_linux_appimage.sh script for AppImage packaging 2026-09-05 17:15:21 +03:00
31 changed files with 4668 additions and 582 deletions
+39 -38
View File
@@ -4,32 +4,33 @@
<application
android:label="Prisma"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:largeHeap="true">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
@@ -43,15 +44,15 @@
android:resource="@xml/file_paths" />
</provider>
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>
+92
View File
@@ -0,0 +1,92 @@
#!/usr/bin/env bash
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
cd "${ROOT_DIR}"
VERSION=$(grep 'version:' pubspec.yaml | head -n 1 | awk '{print $2}' | cut -d'+' -f1)
if [ -z "$VERSION" ]; then
VERSION="3.5.5"
fi
echo "Building AppImage for Prisma v${VERSION}..."
BUNDLE_DIR="${ROOT_DIR}/build/linux/x64/release/bundle"
if [ ! -f "${BUNDLE_DIR}/gel_rule_app" ]; then
echo "Release bundle not found. Running 'flutter build linux --release'..."
flutter build linux --release
fi
APP_DIR="${ROOT_DIR}/build/AppDir"
OUTPUT_DIR="${ROOT_DIR}/build"
APPIMAGE_NAME="Prisma-v${VERSION}-linux-x86_64.AppImage"
OUTPUT_APPIMAGE="${OUTPUT_DIR}/${APPIMAGE_NAME}"
# Ensure appimagetool is available
APPIMAGETOOL=""
if command -v appimagetool >/dev/null 2>&1; then
APPIMAGETOOL="appimagetool"
elif [ -f "${ROOT_DIR}/build/tools/appimagetool" ]; then
APPIMAGETOOL="${ROOT_DIR}/build/tools/appimagetool"
else
echo "Downloading appimagetool..."
mkdir -p "${ROOT_DIR}/build/tools"
curl -fsSL https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage -o "${ROOT_DIR}/build/tools/appimagetool"
chmod +x "${ROOT_DIR}/build/tools/appimagetool"
APPIMAGETOOL="${ROOT_DIR}/build/tools/appimagetool"
fi
echo "Preparing AppDir at ${APP_DIR}..."
rm -rf "${APP_DIR}"
mkdir -p "${APP_DIR}"
# Copy bundle contents
cp -r "${BUNDLE_DIR}"/* "${APP_DIR}/"
# AppRun script
cat << 'EOF' > "${APP_DIR}/AppRun"
#!/bin/sh
HERE="$(dirname "$(readlink -f "${0}")")"
export PATH="${HERE}/usr/bin:${PATH}"
export LD_LIBRARY_PATH="${HERE}/lib:${HERE}/usr/lib:${LD_LIBRARY_PATH}"
exec "${HERE}/gel_rule_app" "$@"
EOF
chmod +x "${APP_DIR}/AppRun"
# Desktop file
cat << 'EOF' > "${APP_DIR}/prisma.desktop"
[Desktop Entry]
Name=Prisma
GenericName=Booru Client
Comment=Prisma - Modern Booru & Art Client
Exec=gel_rule_app %u
Icon=prisma
Terminal=false
Type=Application
Categories=Network;Graphics;
StartupWMClass=com.example.gel_rule_app
EOF
mkdir -p "${APP_DIR}/usr/share/applications"
cp "${APP_DIR}/prisma.desktop" "${APP_DIR}/usr/share/applications/prisma.desktop"
# Icons
ICON_SRC="${ROOT_DIR}/macos/Runner/Assets.xcassets/AppIcon.appiconset"
if [ -d "${ICON_SRC}" ]; then
for size in 16 32 64 128 256 512 1024; do
if [ -f "${ICON_SRC}/app_icon_${size}.png" ]; then
mkdir -p "${APP_DIR}/usr/share/icons/hicolor/${size}x${size}/apps"
cp "${ICON_SRC}/app_icon_${size}.png" "${APP_DIR}/usr/share/icons/hicolor/${size}x${size}/apps/prisma.png"
fi
done
cp "${ICON_SRC}/app_icon_512.png" "${APP_DIR}/prisma.png"
fi
# Build AppImage
echo "Generating AppImage with ${APPIMAGETOOL}..."
ARCH=x86_64 "${APPIMAGETOOL}" -n "${APP_DIR}" "${OUTPUT_APPIMAGE}"
echo "AppImage created successfully: ${OUTPUT_APPIMAGE}"
ls -lh "${OUTPUT_APPIMAGE}"
+86
View File
@@ -1,4 +1,90 @@
const prismaChangelog = [
PrismaChange(
version: '3.7.2',
title: 'Режим непрерывного чтения комиксов и серий e621',
bullets: [
'Мгновенное открытие комикса целиком: по нажатию «Читать весь комикс» загружаются все страницы серии в оригинальном порядке и открываются в полноэкранном ридере со свайпом (влево-вправо на мобильных, стрелками и кнопками на ПК).',
'Плавающий бейдж страниц: во время чтения комикса отображается аккуратный стеклянный индикатор номера страницы («Комикс • Стр. X из Y»).',
'Быстрое оглавление: отдельная кнопка сетки страниц для перехода на любую конкретную главу или страницу без потери последовательности чтения.',
],
),
PrismaChange(
version: '3.7.1',
title: 'Вкладка «e621» в Избранном и исправление листания комиксов',
bullets: [
'Отдельная вкладка «e621» в разделе «Избранное»: прямое отображение серверного избранного привязанного аккаунта e621 (своего или друга), плавная подгрузка при скролле и кнопка импорта в локальные закладки.',
'Исправление ошибки «Post not found» в комиксах: исправлен парсер ответов e621 для одиночных постов (/posts/<id>.json), теперь перелистывание страниц серий и переход по вариациям работают стабильно.',
],
),
PrismaChange(
version: '3.7.0',
title: 'Большое обновление e621: авторизация по API-ключу, комиксы/пулы, вариации постов и синхронизация',
bullets: [
'Окно авторизации e621 (Username + API Key): красивое модальное окно прямо в карточке источника e621 с быстрой проверкой ключа, снятием жесткого лимита в 2 тега при поиске и персональным User-Agent.',
'Комиксы и Пулы (Pools): удобная модальная панель для серий и комиксов с индикатором страниц, навигацией «Предыдущая/Следующая» и сеткой всех страниц серии для быстрого перехода.',
'Связанные посты и вариации (Parent/Child relations): карточка версий с быстрым переходом к родительскому арту и альтернативным вариациям (позы, одежда, скетчи).',
'Серверное голосование и синхронизация Избранного: возможность ставить лайки/дизлайки с отправкой на e621 и автоматическая синхронизация избранного при наличии API-ключа.',
'Защита Rate Limiting: интеллектуальное ограничение частоты запросов (до 2 rps), предотвращающее ошибки 503 и блокировки по IP.',
],
),
PrismaChange(
version: '3.6.5',
title: 'Исправление наложения превью на видеоплеер',
bullets: [
'Устранение наложения превью-картинки на воспроизводимое видео: убран фоновый слой постера под видеоплеером и восстановлен классический черный бэкграунд для правильного отображения границ и леттербоксинга видео любого формата.',
'Чистый рендеринг видео в карточках ленты: предпросмотр видео в ленте также изолирован от просвечивания подложки.',
],
),
PrismaChange(
version: '3.6.4',
title: 'Полное исправление воспроизведения видео на ПК (устранение синего экрана)',
bullets: [
'Программный рендеринг (S/W) по умолчанию для ПК: на десктопных платформах отключен проблемный OpenGL FBO мост аппаратного ускорения, вызывавший синий экран из-за отсутствия или конфликтов драйверов VAAPI/VDPAU в Linux/AppImage.',
'Переключатель декодера H/W / S/W в плеере: на верхней панели управления видеоплеером добавлена кнопка быстрого переключения между аппаратным (H/W) и программным (S/W) декодированием на лету.',
'Отображение постера-превью: под видеоплеером теперь отображается превью-постер на время буферизации, исключая появление пустых областей.',
'Безопасное превью в ленте: предпросмотр видео в карточках постов на ПК также переведен на надежный программный рендерер.',
],
),
PrismaChange(
version: '3.6.3',
title: 'Горизонтальный скролл колесиком мыши на ПК и исправление синего экрана в видеоплеере',
bullets: [
'Горизонтальная прокрутка колесиком мыши в настройках: верхняя панель категорий настроек теперь плавно прокручивается колесиком мыши строго при наведении курсора.',
'Горизонтальная прокрутка колесиком мыши в авторах: полоса любимых авторов и лента их медиа теперь поддерживают быструю прокрутку колесиком мыши при наведении курсора.',
'Устранение «синего экрана» в видеоплеере на ПК: исправлена нативная инициализация MediaKit перед запуском Flutter-движка и настроен оптимальный режим аппаратного ускорения для десктопных платформ.',
],
),
PrismaChange(
version: '3.6.2',
title: 'Комплексная оптимизация памяти на телефоне и ПК, полное отключение зума колесиком',
bullets: [
'Полное отключение масштабирования колесиком мыши: перехвачен внутренний обработчик PointerScrollEvent в интерактивном вьюере, что навсегда исключило нежелательный зум при прокрутке.',
'Предотвращение вылетов на смартфонах (OOM): включен флаг android:largeHeap="true", расширяющий доступный лимит оперативной памяти процесса до 512–1024 МБ.',
'Ограничение аппетита кэша изображений: глобальный ImageCache в Flutter ограничен безопасным лимитом в 180 МБ и 120 изображений (вместо неограниченного роста до 1 ГБ+), неактивные растры своевременно выгружаются сборщиком мусора.',
'Оптимизация декодирования тяжелых артов: полноразмерные изображения с экстремальным разрешением (до 10K+) теперь безопасно декодируются с ограничением memCacheWidth под экран устройства, снижая расход RAM на 80–90%.',
'Оптимизация сетки и миниатюр: карточки ленты изолированы в RepaintBoundary для гладкого 120 FPS скролла без паразитных перерисовок, а всем каруселям, аватаркам и мозаикам заданы строгие границы кэширования.',
],
),
PrismaChange(
version: '3.6.1',
title: 'Плавная боковая панель без лагов и отключение зума колесиком',
bullets: [
'Устранение лагов боковой панели: полностью переработана анимация наведения на боковую панель на ПК. Убран тяжелый шейдерный блюр при изменении ширины и зафиксирована верстка элементов — текст больше не сжимается, не дергается и раскрывается на 120 FPS максимально плавно.',
'Отключение зума фото по колесику мыши: исключено случайное и резкое масштабирование изображений при прокрутке колесиком мыши. Масштабирование фото доступно через горячие клавиши (+, -, 0, F) или двойной клик.',
],
),
PrismaChange(
version: '3.6.0',
title: 'Редизайн интерфейса для ПК и система горячих клавиш (хоткеи)',
bullets: [
'Горячие клавиши для всех вкладок: переключение разделов по Ctrl+1..8 (и Alt+1..8), циклический переход Ctrl+Tab / Ctrl+Shift+Tab, поиск Ctrl+F, обновление Ctrl+R / F5.',
'Хоткеи видеоплеера: пауза/воспроизведение (Space, K), плавная перемотка на 5с (←/→, J/L) и 15с (Shift+←/→) с визуальными индикаторами, регулировка громкости ↑/↓, выключение звука M, полный экран F.',
'Хоткеи просмотра фото и галереи: масштабирование клавишами + / - / 0 (сброс в 100%), переключение страниц и фото в постах стрелками ← / →, быстрое сохранение Ctrl+S.',
'Окно-шпаргалка горячих клавиш: интерактивное модальное окно со всеми клавиатурными комбинациями по нажатию ? или F1, а также из меню панели.',
'Редизайн боковой панели ПК: боковое меню в стиле Liquid Glass с эффектом матового стекла, логотипом Lunaris, бейджами хоткеев, закреплением панели и быстрыми действиями.',
'Редизайн вкладок ленты: стильный сегментированный бар выбора периодов (Все время, 24ч, Неделя, Месяц, Год) с анимированными иконками и неоновые чипы источников.',
],
),
PrismaChange(
version: '3.5.5',
title: 'Сохранение последней вкладки и состояния навигации между разделами',
+45 -2
View File
@@ -6,8 +6,19 @@ class E621Mapper {
required String providerId,
required String providerName,
}) {
final posts = data is Map ? data['posts'] : data;
final items = posts is List ? posts : const [];
if (data is Map && data['post'] is Map) {
return [
postFromJson(
Map<String, dynamic>.from(data['post'] as Map),
providerId: providerId,
providerName: providerName,
),
];
}
final posts = data is Map ? (data['posts'] ?? data['post']) : data;
final items = posts is List
? posts
: (posts is Map ? [posts] : const []);
return items
.whereType<Map>()
.map((item) => postFromJson(
@@ -36,6 +47,38 @@ class E621Mapper {
for (final entry in tagsMap.entries)
entry.key: List<String>.from((entry.value as List?) ?? const []),
}..removeWhere((_, value) => value.isEmpty);
final relationships = Map<String, dynamic>.from((json['relationships'] as Map?) ?? const {});
final parentId = relationships['parent_id']?.toString();
if (parentId != null && parentId.isNotEmpty && parentId != 'null') {
tagGroups['parent_id'] = [parentId];
}
final children = (relationships['children'] as List?)
?.map((e) => e?.toString() ?? '')
.where((s) => s.isNotEmpty && s != 'null')
.toList();
if (children != null && children.isNotEmpty) {
tagGroups['children'] = children;
}
final rawPools = (json['pools'] as List?)
?.map((e) => e?.toString() ?? '')
.where((s) => s.isNotEmpty)
.toList();
if (rawPools != null && rawPools.isNotEmpty) {
tagGroups['pools'] = rawPools;
}
final favCount = json['fav_count'];
if (favCount != null) {
tagGroups['fav_count'] = [favCount.toString()];
}
final desc = _string(json['description']);
if (desc.isNotEmpty) {
tagGroups['description'] = [desc];
}
final tags = tagGroups.values.expand((items) => items).toSet().toList();
final fileUrl = _string(file['url']);
return Post(
+58
View File
@@ -0,0 +1,58 @@
class E621Pool {
const E621Pool({
required this.id,
required this.name,
required this.description,
required this.postIds,
required this.postCount,
required this.category,
required this.isActive,
this.creatorName,
});
final int id;
final String name;
final String description;
final List<String> postIds;
final int postCount;
final String category; // 'series' or 'collection'
final bool isActive;
final String? creatorName;
String get cleanTitle => name.replaceAll('_', ' ');
int pageOf(String postId) {
final idx = postIds.indexOf(postId);
return idx >= 0 ? idx + 1 : 0;
}
String? nextPostId(String postId) {
final idx = postIds.indexOf(postId);
if (idx >= 0 && idx < postIds.length - 1) {
return postIds[idx + 1];
}
return null;
}
String? previousPostId(String postId) {
final idx = postIds.indexOf(postId);
if (idx > 0) {
return postIds[idx - 1];
}
return null;
}
factory E621Pool.fromJson(Map<String, dynamic> json) {
final rawPostIds = (json['post_ids'] as List?) ?? const [];
return E621Pool(
id: (json['id'] as num?)?.toInt() ?? 0,
name: (json['name'] ?? '').toString(),
description: (json['description'] ?? '').toString(),
postIds: rawPostIds.map((e) => e.toString()).toList(),
postCount: (json['post_count'] as num?)?.toInt() ?? rawPostIds.length,
category: (json['category'] ?? 'series').toString(),
isActive: (json['is_active'] as bool?) ?? true,
creatorName: json['creator_name']?.toString(),
);
}
}
+21
View File
@@ -62,6 +62,27 @@ class Post {
String? get description => commentary;
String? get parentId {
final list = tagGroups['parent_id'];
return (list != null && list.isNotEmpty && list.first.trim().isNotEmpty)
? list.first.trim()
: null;
}
List<String> get childrenIds => tagGroups['children'] ?? const [];
List<String> get poolIds => tagGroups['pools'] ?? const [];
bool get hasRelations =>
(parentId != null && parentId!.isNotEmpty) || childrenIds.isNotEmpty;
bool get hasPools => poolIds.isNotEmpty;
int? get favCount {
final raw = tagGroups['fav_count']?.firstOrNull;
return raw != null ? int.tryParse(raw) : null;
}
String? get title {
final list = tagGroups['copyright'];
if (list != null && list.isNotEmpty) {
+136
View File
@@ -2,6 +2,7 @@ import 'package:dio/dio.dart';
import '../../core/http/dio_client.dart';
import '../mappers/e621_mapper.dart';
import '../models/e621_pool.dart';
import '../models/post.dart';
import '../models/post_comment.dart';
import '../models/provider_health.dart';
@@ -33,6 +34,34 @@ class E621Provider
final Dio _dio;
final Map<String, String> _queryParameters;
static DateTime _lastRequestTime = DateTime.fromMillisecondsSinceEpoch(0);
static Future<void> _throttle() async {
final now = DateTime.now();
final diff = now.difference(_lastRequestTime);
if (diff.inMilliseconds < 500) {
final waitMs = 500 - diff.inMilliseconds;
await Future<void>.delayed(Duration(milliseconds: waitMs));
}
_lastRequestTime = DateTime.now();
}
bool get isAuthorized =>
_queryParameters.containsKey('login') ||
_queryParameters.containsKey('query.login') ||
_dio.options.headers.containsKey('Authorization');
String? get login =>
_queryParameters['login'] ?? _queryParameters['query.login'];
Future<List<Post>> getFavorites({int page = 1, int limit = 50}) async {
final currentLogin = login?.trim();
final targetTag = (currentLogin != null && currentLogin.isNotEmpty)
? 'fav:$currentLogin'
: 'fav:me';
return searchPosts(tags: [targetTag], page: page - 1, limit: limit);
}
@override
String postPageUrl(Post post) => '$baseUrl/posts/${post.id}';
@@ -44,6 +73,7 @@ class E621Provider
String? rating,
TopPeriodFilter topPeriod = TopPeriodFilter.none,
}) async {
await _throttle();
final response = await _dio.get<dynamic>(
'/posts.json',
queryParameters: {
@@ -66,6 +96,7 @@ class E621Provider
@override
Future<Post?> getPost(String id) async {
await _throttle();
final response = await _dio.get<dynamic>(
'/posts/$id.json',
queryParameters: _queryParameters,
@@ -78,6 +109,109 @@ class E621Provider
return posts.isEmpty ? null : posts.first;
}
Future<E621Pool?> getPool(String poolId) async {
await _throttle();
try {
final response = await _dio.get<dynamic>(
'/pools/$poolId.json',
queryParameters: _queryParameters,
);
if (response.data is Map) {
return E621Pool.fromJson(Map<String, dynamic>.from(response.data as Map));
}
} catch (_) {}
return null;
}
Future<List<Post>> getPoolPosts(String poolId) async {
final pool = await getPool(poolId);
if (pool == null || pool.postIds.isEmpty) return const [];
final fetchedMap = <String, Post>{};
int page = 1;
while (true) {
await _throttle();
try {
final response = await _dio.get<dynamic>(
'/posts.json',
queryParameters: {
'page': page,
'limit': isAuthorized ? 320 : 75,
'tags': 'pool:$poolId',
..._queryParameters,
},
);
final items = E621Mapper.postsFromResponse(
response.data,
providerId: id,
providerName: name,
);
if (items.isEmpty) break;
for (final item in items) {
fetchedMap[item.id] = item;
}
if (items.length < (isAuthorized ? 320 : 75) ||
fetchedMap.length >= pool.postIds.length) {
break;
}
page++;
} catch (_) {
break;
}
}
final ordered = <Post>[];
for (final id in pool.postIds) {
if (fetchedMap.containsKey(id)) {
ordered.add(fetchedMap[id]!);
}
}
return ordered.isNotEmpty ? ordered : fetchedMap.values.toList();
}
Future<bool> votePost(String postId, int score) async {
await _throttle();
try {
final response = await _dio.post<dynamic>(
'/posts/$postId/votes.json',
data: {'score': score, 'no_unvote': 'true'},
queryParameters: _queryParameters,
options: Options(contentType: Headers.formUrlEncodedContentType),
);
return response.statusCode == 200 || response.statusCode == 201;
} catch (_) {
return false;
}
}
Future<bool> addFavorite(String postId) async {
await _throttle();
try {
final response = await _dio.post<dynamic>(
'/favorites.json',
data: {'post_id': postId},
queryParameters: _queryParameters,
options: Options(contentType: Headers.formUrlEncodedContentType),
);
return response.statusCode == 200 || response.statusCode == 201;
} catch (_) {
return false;
}
}
Future<bool> removeFavorite(String postId) async {
await _throttle();
try {
final response = await _dio.delete<dynamic>(
'/favorites/$postId.json',
queryParameters: _queryParameters,
);
return response.statusCode == 200 || response.statusCode == 204;
} catch (_) {
return false;
}
}
@override
Future<ProviderHealth> checkHealth() async {
final startedAt = DateTime.now();
@@ -109,6 +243,7 @@ class E621Provider
Future<List<TagSuggestion>> suggestTags(String query,
{int limit = 20}) async {
if (query.trim().isEmpty) return const [];
await _throttle();
final response = await _dio.get<dynamic>(
'/tags.json',
queryParameters: {
@@ -136,6 +271,7 @@ class E621Provider
@override
Future<List<PostComment>> getComments(String postId) async {
await _throttle();
final response = await _dio.get<dynamic>(
'/comments.json',
queryParameters: {
@@ -1,3 +1,5 @@
import 'dart:convert';
import '../../core/http/dio_client.dart';
import '../models/content_provider_config.dart';
import 'content_provider.dart';
@@ -20,6 +22,17 @@ class ProviderFactory {
if (entry.key.startsWith('query.') && entry.value.trim().isNotEmpty)
entry.key.substring('query.'.length): entry.value.trim(),
};
if (config.apiType.toLowerCase() == 'e621') {
final login = queryParameters['login'] ?? headers['login'];
final apiKey = queryParameters['api_key'] ?? headers['api_key'];
if (login != null && apiKey != null && login.isNotEmpty && apiKey.isNotEmpty) {
final basicAuth = base64Encode(utf8.encode('$login:$apiKey'));
headers['Authorization'] = 'Basic $basicAuth';
headers['User-Agent'] = 'Prisma/3.6.6 (by $login on e621)';
}
}
final client = DioClient(
baseUrl: config.baseUrl,
timeout: Duration(seconds: config.timeoutSeconds),
@@ -144,6 +144,14 @@ class ProviderManager {
});
}
Future<ContentProvider?> getProviderInstance(String id) async {
final result = await _repository.getProvider(id);
if (result is Success<ContentProviderConfig?> && result.data != null) {
return _factory.create(result.data!);
}
return null;
}
Future<Result<List<ProviderHealth>>> checkAll({int concurrency = 3}) async {
final providersResult = await activeProviders();
if (providersResult is Error<List<ContentProvider>>) {
@@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:convert';
import 'dart:ui';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
@@ -1328,7 +1329,7 @@ class _FavoriteArtistsModalState extends State<_FavoriteArtistsModal> {
}
}
class _FavoriteArtistsSection extends ConsumerWidget {
class _FavoriteArtistsSection extends StatefulWidget {
const _FavoriteArtistsSection({
required this.favorites,
required this.selectedKey,
@@ -1344,8 +1345,23 @@ class _FavoriteArtistsSection extends ConsumerWidget {
final ValueChanged<FavoriteArtistItem> onRemoveFavorite;
@override
Widget build(BuildContext context, WidgetRef ref) {
if (favorites.isEmpty) {
State<_FavoriteArtistsSection> createState() =>
_FavoriteArtistsSectionState();
}
class _FavoriteArtistsSectionState extends State<_FavoriteArtistsSection> {
final ScrollController _scrollController = ScrollController();
bool _isHovered = false;
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (widget.favorites.isEmpty) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 8),
child: _ArtistsLiquidCard(
@@ -1368,9 +1384,9 @@ class _FavoriteArtistsSection extends ConsumerWidget {
);
}
final selected = favorites.firstWhere(
(f) => f.key == selectedKey,
orElse: () => favorites.first,
final selected = widget.favorites.firstWhere(
(f) => f.key == widget.selectedKey,
orElse: () => widget.favorites.first,
);
return Padding(
@@ -1385,14 +1401,14 @@ class _FavoriteArtistsSection extends ConsumerWidget {
const Icon(Icons.star_rounded, color: Colors.amber, size: 20),
const SizedBox(width: 6),
Text(
'Любимые авторы (${favorites.length})',
'Любимые авторы (${widget.favorites.length})',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
const Spacer(),
TextButton(
onPressed: () => onOpenArtist(selected),
onPressed: () => widget.onOpenArtist(selected),
style: TextButton.styleFrom(
visualDensity: VisualDensity.compact,
padding: const EdgeInsets.symmetric(horizontal: 8),
@@ -1409,111 +1425,136 @@ class _FavoriteArtistsSection extends ConsumerWidget {
],
),
const SizedBox(height: 8),
SizedBox(
height: 88,
child: ListView.separated(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4),
scrollDirection: Axis.horizontal,
itemCount: favorites.length,
separatorBuilder: (_, __) => const SizedBox(width: 10),
itemBuilder: (context, index) {
final fav = favorites[index];
final isSelected = fav.key == selected.key;
final sColor = _serviceColor(fav.service);
return InkWell(
borderRadius: BorderRadius.circular(16),
onTap: () => onSelect(fav),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
decoration: BoxDecoration(
color: isSelected
? Theme.of(context)
.colorScheme
.primaryContainer
.withValues(alpha: 0.45)
: Colors.transparent,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: isSelected
? Theme.of(context).colorScheme.primary
: Colors.transparent,
width: 1.5,
),
boxShadow: isSelected
? [
BoxShadow(
color: Theme.of(context)
.colorScheme
.primary
.withValues(alpha: 0.25),
blurRadius: 8,
),
]
: null,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: isSelected
? Theme.of(context).colorScheme.primary
: sColor.withValues(alpha: 0.4),
width: 1.2,
),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(13),
child: fav.avatarUrl != null &&
fav.avatarUrl!.isNotEmpty
? CachedNetworkImage(
imageUrl: fav.avatarUrl!,
fit: BoxFit.cover,
placeholder: (_, __) => const Icon(
Icons.person_rounded,
size: 20),
errorWidget: (_, __, ___) => const Icon(
Icons.person_rounded,
size: 20),
)
: Center(
child: Text(
fav.name.isNotEmpty
? fav.name[0].toUpperCase()
: '?',
style: const TextStyle(
fontWeight: FontWeight.bold),
),
),
),
),
const SizedBox(height: 4),
SizedBox(
width: 68,
child: Text(
fav.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 11,
fontWeight: isSelected
? FontWeight.bold
: FontWeight.normal,
),
),
),
],
),
),
MouseRegion(
onEnter: (_) => _isHovered = true,
onExit: (_) => _isHovered = false,
child: Listener(
onPointerSignal: (event) {
if (!_isHovered ||
event is! PointerScrollEvent ||
!_scrollController.hasClients) {
return;
}
final delta = event.scrollDelta.dy != 0
? event.scrollDelta.dy
: event.scrollDelta.dx;
if (delta == 0) return;
final target = (_scrollController.offset + delta * 1.5).clamp(
0.0,
_scrollController.position.maxScrollExtent,
);
_scrollController.jumpTo(target);
},
child: SizedBox(
height: 88,
child: ListView.separated(
controller: _scrollController,
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4),
scrollDirection: Axis.horizontal,
itemCount: widget.favorites.length,
separatorBuilder: (_, __) => const SizedBox(width: 10),
itemBuilder: (context, index) {
final fav = widget.favorites[index];
final isSelected = fav.key == selected.key;
final sColor = _serviceColor(fav.service);
return InkWell(
borderRadius: BorderRadius.circular(16),
onTap: () => widget.onSelect(fav),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
decoration: BoxDecoration(
color: isSelected
? Theme.of(context)
.colorScheme
.primaryContainer
.withValues(alpha: 0.45)
: Colors.transparent,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: isSelected
? Theme.of(context).colorScheme.primary
: Colors.transparent,
width: 1.5,
),
boxShadow: isSelected
? [
BoxShadow(
color: Theme.of(context)
.colorScheme
.primary
.withValues(alpha: 0.25),
blurRadius: 8,
),
]
: null,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: isSelected
? Theme.of(context).colorScheme.primary
: sColor.withValues(alpha: 0.4),
width: 1.2,
),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(13),
child: fav.avatarUrl != null &&
fav.avatarUrl!.isNotEmpty
? CachedNetworkImage(
imageUrl: fav.avatarUrl!,
memCacheWidth: 120,
memCacheHeight: 120,
fit: BoxFit.cover,
placeholder: (_, __) => const Icon(
Icons.person_rounded,
size: 20),
errorWidget: (_, __, ___) => const Icon(
Icons.person_rounded,
size: 20),
)
: Center(
child: Text(
fav.name.isNotEmpty
? fav.name[0].toUpperCase()
: '?',
style: const TextStyle(
fontWeight: FontWeight.bold),
),
),
),
),
const SizedBox(height: 4),
SizedBox(
width: 68,
child: Text(
fav.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 11,
fontWeight: isSelected
? FontWeight.bold
: FontWeight.normal,
),
),
),
],
),
),
);
},
),
),
),
),
const SizedBox(height: 6),
@@ -1525,18 +1566,34 @@ class _FavoriteArtistsSection extends ConsumerWidget {
}
}
class _FavoriteArtistMediaStrip extends ConsumerWidget {
class _FavoriteArtistMediaStrip extends ConsumerStatefulWidget {
const _FavoriteArtistMediaStrip({required this.artist});
final FavoriteArtistItem artist;
@override
Widget build(BuildContext context, WidgetRef ref) {
ConsumerState<_FavoriteArtistMediaStrip> createState() =>
_FavoriteArtistMediaStripState();
}
class _FavoriteArtistMediaStripState
extends ConsumerState<_FavoriteArtistMediaStrip> {
final ScrollController _scrollController = ScrollController();
bool _isHovered = false;
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final query = ArtistWorkQuery(
providerId: artist.providerId,
service: artist.service,
artistId: artist.id,
artistName: artist.name,
providerId: widget.artist.providerId,
service: widget.artist.service,
artistId: widget.artist.id,
artistName: widget.artist.name,
);
final asyncPosts = ref.watch(artistPostsProvider(query));
@@ -1564,76 +1621,101 @@ class _FavoriteArtistMediaStrip extends ConsumerWidget {
),
);
}
return SizedBox(
height: 96,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: posts.length.clamp(0, 15),
separatorBuilder: (_, __) => const SizedBox(width: 8),
itemBuilder: (context, index) {
final post = posts[index];
final isVideo = MediaUrlSelector.isVideo(post);
return InkWell(
borderRadius: BorderRadius.circular(14),
onTap: () => AppNavigator.openPost(
context,
post: post,
postsList: posts,
),
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: Colors.white.withValues(alpha: 0.15),
width: 1.0,
),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(13),
child: Stack(
children: [
SizedBox(
width: 96,
height: 96,
child: CachedNetworkImage(
imageUrl: post.previewUrl.isNotEmpty
? post.previewUrl
: post.sampleUrl,
fit: BoxFit.cover,
placeholder: (_, __) => ColoredBox(
color: Theme.of(context)
.colorScheme
.surfaceContainerHighest,
),
errorWidget: (_, __, ___) => ColoredBox(
color: Theme.of(context)
.colorScheme
.surfaceContainerHighest,
child: const Icon(Icons.broken_image_rounded,
size: 24),
),
),
),
if (isVideo)
Positioned(
bottom: 6,
right: 6,
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.7),
shape: BoxShape.circle,
),
child: const Icon(Icons.play_arrow_rounded,
color: Colors.white, size: 14),
),
),
],
),
),
),
return MouseRegion(
onEnter: (_) => _isHovered = true,
onExit: (_) => _isHovered = false,
child: Listener(
onPointerSignal: (event) {
if (!_isHovered ||
event is! PointerScrollEvent ||
!_scrollController.hasClients) {
return;
}
final delta = event.scrollDelta.dy != 0
? event.scrollDelta.dy
: event.scrollDelta.dx;
if (delta == 0) return;
final target = (_scrollController.offset + delta * 1.5).clamp(
0.0,
_scrollController.position.maxScrollExtent,
);
_scrollController.jumpTo(target);
},
child: SizedBox(
height: 96,
child: ListView.separated(
controller: _scrollController,
scrollDirection: Axis.horizontal,
itemCount: posts.length.clamp(0, 15),
separatorBuilder: (_, __) => const SizedBox(width: 8),
itemBuilder: (context, index) {
final post = posts[index];
final isVideo = MediaUrlSelector.isVideo(post);
return InkWell(
borderRadius: BorderRadius.circular(14),
onTap: () => AppNavigator.openPost(
context,
post: post,
postsList: posts,
),
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: Colors.white.withValues(alpha: 0.15),
width: 1.0,
),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(13),
child: Stack(
children: [
SizedBox(
width: 96,
height: 96,
child: CachedNetworkImage(
imageUrl: post.previewUrl.isNotEmpty
? post.previewUrl
: post.sampleUrl,
memCacheWidth: 200,
memCacheHeight: 200,
fit: BoxFit.cover,
placeholder: (_, __) => ColoredBox(
color: Theme.of(context)
.colorScheme
.surfaceContainerHighest,
),
errorWidget: (_, __, ___) => ColoredBox(
color: Theme.of(context)
.colorScheme
.surfaceContainerHighest,
child: const Icon(Icons.broken_image_rounded,
size: 24),
),
),
),
if (isVideo)
Positioned(
bottom: 6,
right: 6,
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.7),
shape: BoxShape.circle,
),
child: const Icon(Icons.play_arrow_rounded,
color: Colors.white, size: 14),
),
),
],
),
),
),
);
},
),
),
),
);
},
@@ -1706,6 +1788,8 @@ class _ArtistAvatar extends StatelessWidget {
? fallback
: CachedNetworkImage(
imageUrl: artist.avatarUrl!,
memCacheWidth: 140,
memCacheHeight: 140,
fit: BoxFit.cover,
placeholder: (_, __) => fallback,
errorWidget: (_, __, ___) => fallback,
@@ -314,6 +314,8 @@ class _CollectionCard extends ConsumerWidget {
if (urls.length == 1) {
return CachedNetworkImage(
imageUrl: urls[0],
memCacheWidth: 260,
memCacheHeight: 260,
fit: BoxFit.cover,
placeholder: (_, __) => Container(color: scheme.surfaceContainerHighest),
errorWidget: (_, __, ___) => Container(
@@ -333,6 +335,8 @@ class _CollectionCard extends ConsumerWidget {
if (i < urls.length)
CachedNetworkImage(
imageUrl: urls[i],
memCacheWidth: 200,
memCacheHeight: 200,
fit: BoxFit.cover,
placeholder: (_, __) =>
Container(color: scheme.surfaceContainerHighest),
@@ -15,6 +15,7 @@ import '../../../shared/widgets/empty_view.dart';
import '../../../shared/widgets/error_view.dart';
import '../../../shared/widgets/post_masonry_grid.dart';
import 'favorites_controller.dart';
import 'widgets/e621_favorites_view.dart';
enum FavoriteMediaType { all, video, image }
enum OfflineSortOption { dateAddedDesc, sizeDesc, artist }
@@ -315,6 +316,17 @@ class _FavoritesScreenState extends ConsumerState<FavoritesScreen> {
overflow: TextOverflow.ellipsis,
),
),
const ButtonSegment(
value: 3,
icon: Icon(Icons.hub_rounded,
size: 18, color: Color(0xFF0077EE)),
label: Text(
'e621',
style: TextStyle(
fontSize: 12, fontWeight: FontWeight.w600),
overflow: TextOverflow.ellipsis,
),
),
],
selected: {_tabIndex},
onSelectionChanged: (value) {
@@ -333,7 +345,7 @@ class _FavoritesScreenState extends ConsumerState<FavoritesScreen> {
),
// Media Type Filter Chips (for Tab 0 and Tab 1)
if (_tabIndex != 2)
if (_tabIndex != 2 && _tabIndex != 3)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: Row(
@@ -399,6 +411,10 @@ class _FavoritesScreenState extends ConsumerState<FavoritesScreen> {
downloaded.keys.toSet(),
isRu,
),
3 => E621FavoritesView(
settings: settings,
isRu: isRu,
),
_ => _buildAllView(
allPosts,
settings,
@@ -1225,6 +1241,8 @@ class _ArtistCard extends StatelessWidget {
),
child: CachedNetworkImage(
imageUrl: url,
memCacheWidth: 128,
memCacheHeight: 128,
fit: BoxFit.cover,
placeholder: (_, __) => Container(
color: scheme.surfaceContainerHighest,
@@ -0,0 +1,464 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../app/app_navigator.dart';
import '../../../../app/responsive.dart';
import '../../../../backend/backend.dart';
import '../../../../backend/providers/e621_provider.dart';
import '../../../../shared/widgets/empty_view.dart';
import '../../../../shared/widgets/post_masonry_grid.dart';
import '../../../providers/presentation/providers_controller.dart';
import '../../../providers/presentation/widgets/e621_auth_dialog.dart';
import '../favorites_controller.dart';
class E621FavoritesView extends ConsumerStatefulWidget {
const E621FavoritesView({
required this.settings,
required this.isRu,
super.key,
});
final AppSettings settings;
final bool isRu;
@override
ConsumerState<E621FavoritesView> createState() => _E621FavoritesViewState();
}
class _E621FavoritesViewState extends ConsumerState<E621FavoritesView> {
final List<Post> _posts = [];
final ScrollController _scrollController = ScrollController();
int _page = 1;
bool _isLoading = false;
bool _hasMore = true;
String? _error;
String? _currentLogin;
bool _isImporting = false;
@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
_loadInitial();
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
void _onScroll() {
if (_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent - 400 &&
!_isLoading &&
_hasMore) {
_loadNextPage();
}
}
Future<E621Provider?> _getE621Provider() async {
final instance = await ref
.read(providerManagerProvider)
.getProviderInstance('e621');
return instance is E621Provider ? instance : null;
}
Future<void> _loadInitial() async {
setState(() {
_isLoading = true;
_error = null;
_posts.clear();
_page = 1;
_hasMore = true;
});
try {
final provider = await _getE621Provider();
if (provider == null) {
setState(() {
_isLoading = false;
_error = widget.isRu
? 'Источник e621 не найден'
: 'e621 provider not found';
});
return;
}
final login = provider.login?.trim();
setState(() => _currentLogin = login);
if (login == null || login.isEmpty) {
setState(() => _isLoading = false);
return;
}
final items = await provider.getFavorites(page: 1, limit: 50);
if (mounted) {
setState(() {
_posts.addAll(items);
_isLoading = false;
_hasMore = items.length >= 25;
_page = 2;
});
}
} catch (e) {
if (mounted) {
setState(() {
_error = widget.isRu ? 'Ошибка загрузки: $e' : 'Loading error: $e';
_isLoading = false;
});
}
}
}
Future<void> _loadNextPage() async {
if (_isLoading || !_hasMore) return;
setState(() => _isLoading = true);
try {
final provider = await _getE621Provider();
if (provider == null) {
setState(() => _isLoading = false);
return;
}
final items = await provider.getFavorites(page: _page, limit: 50);
if (mounted) {
setState(() {
final existingIds = _posts.map((p) => p.id).toSet();
final newItems =
items.where((p) => !existingIds.contains(p.id)).toList();
_posts.addAll(newItems);
_isLoading = false;
_hasMore = items.length >= 25;
_page++;
});
}
} catch (_) {
if (mounted) {
setState(() => _isLoading = false);
}
}
}
Future<void> _importAllToLocal() async {
if (_posts.isEmpty || _isImporting) return;
setState(() => _isImporting = true);
HapticFeedback.mediumImpact();
final favService = ref.read(favoriteServiceProvider);
int imported = 0;
for (final post in _posts) {
await favService.addFavorite(post);
imported++;
}
ref.invalidate(favoriteKeysProvider);
ref.invalidate(favoritesControllerProvider);
if (mounted) {
setState(() => _isImporting = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
widget.isRu
? 'Импортировано $imported постов в локальное избранное!'
: 'Imported $imported posts to local favorites!',
),
duration: const Duration(seconds: 3),
),
);
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
final downloaded = ref.watch(
downloadedMediaByKeysProvider(
_posts.map((post) => post.cacheKey).toList(growable: false),
),
).value ??
const <String, DownloadedMedia>{};
final favoriteKeys = ref.watch(favoriteKeysProvider).value ?? <String>{};
// If no login is set
if (_currentLogin == null || _currentLogin!.isEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.all(28),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 68,
height: 68,
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFF0055AA), Color(0xFF0088FF)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(22),
boxShadow: [
BoxShadow(
color: const Color(0xFF0055AA).withValues(alpha: 0.35),
blurRadius: 16,
offset: const Offset(0, 6),
),
],
),
child: const Icon(
Icons.hub_rounded,
size: 34,
color: Colors.white,
),
),
const SizedBox(height: 18),
Text(
widget.isRu ? 'Аккаунт e621 не указан' : 'e621 account not set',
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 380),
child: Text(
widget.isRu
? 'Чтобы смотреть избранное (своё или друга) прямо здесь, укажите логин и API-ключ в настройках источника e621.'
: 'To browse favorites directly here, provide your e621 login and API key.',
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
const SizedBox(height: 22),
FilledButton.icon(
onPressed: () async {
final configs =
ref.read(providersControllerProvider).value ?? [];
final e621Config = configs.firstWhere(
(p) => p.apiType.toLowerCase() == 'e621',
orElse: () => configs.first,
);
await E621AuthDialog.show(
context,
config: e621Config,
onSaved: (updated) {
ref
.read(providersControllerProvider.notifier)
.save(updated);
_loadInitial();
},
);
},
icon: const Icon(Icons.vpn_key_rounded, size: 18),
label: Text(
widget.isRu
? 'Войти / Настроить API-ключ e621'
: 'Set e621 API Key',
),
style: FilledButton.styleFrom(
backgroundColor: const Color(0xFF0055AA),
padding: const EdgeInsets.symmetric(
horizontal: 20, vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
),
),
],
),
),
);
}
return RefreshIndicator(
onRefresh: _loadInitial,
child: Column(
children: [
// Header Banner
Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 8),
child: ClipRRect(
borderRadius: BorderRadius.circular(18),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 14, sigmaY: 14),
child: Container(
padding:
const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: isDark
? const Color(0xFF131A26).withValues(alpha: 0.85)
: Colors.white.withValues(alpha: 0.85),
borderRadius: BorderRadius.circular(18),
border: Border.all(
color: isDark
? const Color(0xFF0055AA).withValues(alpha: 0.35)
: const Color(0xFF0055AA).withValues(alpha: 0.20),
),
),
child: Row(
children: [
Container(
width: 38,
height: 38,
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFF0055AA), Color(0xFF0077EE)],
),
borderRadius: BorderRadius.circular(12),
),
child: const Icon(
Icons.hub_rounded,
color: Colors.white,
size: 20,
),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Избранное: @$_currentLogin',
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 13,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
Text(
widget.isRu
? '${_posts.length} постов на сервере e621'
: '${_posts.length} posts on e621',
style: TextStyle(
fontSize: 11,
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
if (_posts.isNotEmpty)
IconButton.filledTonal(
tooltip: widget.isRu
? 'Импортировать в локальное избранное'
: 'Import to local favorites',
onPressed: _isImporting ? null : _importAllToLocal,
icon: _isImporting
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2),
)
: const Icon(Icons.download_rounded, size: 18),
),
const SizedBox(width: 4),
IconButton.filledTonal(
tooltip: widget.isRu ? 'Обновить' : 'Refresh',
onPressed: _isLoading ? null : _loadInitial,
icon: const Icon(Icons.refresh_rounded, size: 18),
),
],
),
),
),
),
),
// Content
Expanded(
child: _isLoading && _posts.isEmpty
? const Center(child: CircularProgressIndicator())
: _error != null && _posts.isEmpty
? Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.error_outline_rounded,
size: 40, color: Color(0xFFEF4444)),
const SizedBox(height: 12),
Text(_error!),
const SizedBox(height: 12),
FilledButton.tonal(
onPressed: _loadInitial,
child: Text(widget.isRu ? 'Повторить' : 'Retry'),
),
],
),
)
: _posts.isEmpty
? Center(
child: EmptyView(
title: widget.isRu
? 'У пользователя нет избранных постов'
: 'No favorite posts for this user',
),
)
: ListView(
controller: _scrollController,
padding: const EdgeInsets.symmetric(horizontal: 8),
children: [
PostMasonryGrid(
posts: _posts,
columns: Responsive.columnsFor(
context,
mobileColumns: widget.settings.mobileColumns,
desktopColumns:
widget.settings.desktopColumns,
),
blurExplicit:
widget.settings.blurExplicitContent,
showBadges: widget.settings.showPostBadges,
nsfwEnabled: widget.settings.nsfwEnabled,
mediaQualityMode: MediaQualityMode.fromName(
widget.settings.mediaQualityMode,
),
favoriteKeys: favoriteKeys,
downloadedKeys: downloaded.keys.toSet(),
onOpen: (post) => AppNavigator.openPost(
context,
post: post,
postsList: _posts,
),
onFavorite: (post) async {
final isFav =
favoriteKeys.contains(post.cacheKey);
final favService =
ref.read(favoriteServiceProvider);
if (isFav) {
await favService.removeFavorite(
post.id, post.providerId);
} else {
await favService.addFavorite(post);
}
ref.invalidate(favoriteKeysProvider);
ref.invalidate(favoritesControllerProvider);
},
),
if (_isLoading)
const Padding(
padding: EdgeInsets.all(20),
child: Center(
child: CircularProgressIndicator(),
),
),
],
),
),
],
),
);
}
}
@@ -144,7 +144,11 @@ class _FeedScreenState extends ConsumerState<FeedScreen>
return Shortcuts(
shortcuts: {
LogicalKeySet(LogicalKeyboardKey.keyR): const _RefreshIntent(),
LogicalKeySet(LogicalKeyboardKey.keyR): const _RandomPostIntent(),
LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyR):
const _RefreshIntent(),
LogicalKeySet(LogicalKeyboardKey.f5): const _RefreshIntent(),
LogicalKeySet(LogicalKeyboardKey.keyV): const _ToggleSelectionIntent(),
LogicalKeySet(LogicalKeyboardKey.escape): const _ClearSelectionIntent(),
LogicalKeySet(LogicalKeyboardKey.delete): const _ClearSelectionIntent(),
LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyA):
@@ -158,6 +162,24 @@ class _FeedScreenState extends ConsumerState<FeedScreen>
return null;
},
),
_RandomPostIntent: CallbackAction<_RandomPostIntent>(
onInvoke: (_) {
final posts = feed.value?.posts;
if (posts != null && posts.isNotEmpty) {
_openRandom(posts);
}
return null;
},
),
_ToggleSelectionIntent: CallbackAction<_ToggleSelectionIntent>(
onInvoke: (_) {
setState(() {
_selectionMode = !_selectionMode;
if (!_selectionMode) _selectedKeys.clear();
});
return null;
},
),
_ClearSelectionIntent: CallbackAction<_ClearSelectionIntent>(
onInvoke: (_) {
_clearSelection();
@@ -682,6 +704,14 @@ class _SelectVisibleIntent extends Intent {
const _SelectVisibleIntent();
}
class _RandomPostIntent extends Intent {
const _RandomPostIntent();
}
class _ToggleSelectionIntent extends Intent {
const _ToggleSelectionIntent();
}
class _BatchActionBar extends StatelessWidget {
const _BatchActionBar({
required this.count,
@@ -110,7 +110,7 @@ class _FeedToolbarState extends State<FeedToolbar> {
),
const SizedBox(width: 8),
IconButton.filledTonal(
tooltip: widget.selectionMode ? 'Выйти из выбора' : 'Выбрать посты',
tooltip: widget.selectionMode ? 'Выйти из выбора (Esc)' : 'Выбрать посты (V)',
onPressed: widget.onToggleSelectionMode,
icon: Icon(widget.selectionMode
? Icons.check_box_rounded
@@ -139,13 +139,13 @@ class _FeedToolbarState extends State<FeedToolbar> {
),
const SizedBox(width: 4),
IconButton(
tooltip: 'Обновить',
tooltip: 'Обновить (Ctrl+R / F5)',
onPressed: widget.onRefresh,
icon: const Icon(Icons.refresh_rounded),
),
const SizedBox(width: 4),
IconButton.filledTonal(
tooltip: 'Случайный пост',
tooltip: 'Случайный пост (R)',
onPressed: widget.onRandom,
icon: const Icon(Icons.casino_rounded),
),
@@ -163,41 +163,36 @@ class _FeedToolbarState extends State<FeedToolbar> {
const SizedBox(height: 8),
],
_HorizontalWheelScroller(
height: 36,
height: 38,
builder: (controller) => ListView(
controller: controller,
scrollDirection: Axis.horizontal,
primary: false,
children: [
_LiquidPeriodTabs(
selectedPeriod: widget.topPeriodFilter,
onChanged: widget.onTopPeriodChanged,
),
const SizedBox(width: 8),
const _ToolbarDivider(),
const SizedBox(width: 8),
if (widget.providers.isNotEmpty) ...[
FilterChip(
selected: widget.selectedProviderIds.isEmpty,
label: const Text('Все источники'),
onSelected: (_) => widget.onQuickProviderToggle('__all__'),
_LiquidProviderPill(
label: 'Все источники',
isSelected: widget.selectedProviderIds.isEmpty,
icon: Icons.all_inclusive_rounded,
onTap: () => widget.onQuickProviderToggle('__all__'),
),
const SizedBox(width: 8),
const SizedBox(width: 6),
for (final provider in widget.providers) ...[
FilterChip(
selected:
widget.selectedProviderIds.contains(provider.id),
avatar: const Icon(Icons.hub_rounded, size: 15),
label: Text(provider.name),
onSelected: (_) =>
widget.onQuickProviderToggle(provider.id),
_LiquidProviderPill(
label: provider.name,
isSelected: widget.selectedProviderIds.contains(provider.id),
icon: Icons.hub_rounded,
onTap: () => widget.onQuickProviderToggle(provider.id),
),
const SizedBox(width: 8),
const SizedBox(width: 6),
],
const SizedBox(width: 4),
const _ToolbarDivider(),
const SizedBox(width: 10),
],
for (final period in TopPeriodFilter.values) ...[
ChoiceChip(
selected: widget.topPeriodFilter == period,
label: Text(period.label),
onSelected: (_) => widget.onTopPeriodChanged(period),
),
const SizedBox(width: 8),
],
],
),
@@ -289,64 +284,56 @@ class _FeedToolbarState extends State<FeedToolbar> {
primary: false,
children: [
if (hasActiveFilters || widget.selectedTags.isNotEmpty) ...[
ActionChip(
visualDensity: VisualDensity.compact,
avatar: const Icon(Icons.filter_alt_off_rounded, size: 15),
label: const Text('Сбросить'),
onPressed: widget.onClearFilters,
_LiquidProviderPill(
label: 'Сбросить',
isSelected: false,
icon: Icons.filter_alt_off_rounded,
onTap: widget.onClearFilters,
),
const SizedBox(width: 6),
],
ActionChip(
visualDensity: VisualDensity.compact,
avatar: const Icon(Icons.refresh_rounded, size: 15),
label: const Text('Обновить'),
onPressed: widget.onRefresh,
_LiquidProviderPill(
label: 'Обновить',
isSelected: false,
icon: Icons.refresh_rounded,
onTap: widget.onRefresh,
),
const SizedBox(width: 6),
const _ToolbarDivider(),
const SizedBox(width: 6),
FilterChip(
visualDensity: VisualDensity.compact,
selected: widget.rating != null,
avatar: const Icon(Icons.shield_outlined, size: 15),
label: Text(widget.rating ?? 'Рейтинг'),
onSelected: (_) => widget.onRatingFilter(),
_LiquidProviderPill(
label: widget.rating ?? 'Рейтинг',
isSelected: widget.rating != null,
icon: Icons.shield_outlined,
onTap: widget.onRatingFilter,
),
const SizedBox(width: 6),
const _ToolbarDivider(),
const SizedBox(width: 6),
_LiquidPeriodTabs(
selectedPeriod: widget.topPeriodFilter,
onChanged: widget.onTopPeriodChanged,
),
if (widget.providers.isNotEmpty) ...[
FilterChip(
visualDensity: VisualDensity.compact,
selected: widget.selectedProviderIds.isEmpty,
label: const Text('Все'),
onSelected: (_) => widget.onQuickProviderToggle('__all__'),
const SizedBox(width: 6),
const _ToolbarDivider(),
const SizedBox(width: 6),
_LiquidProviderPill(
label: 'Все',
isSelected: widget.selectedProviderIds.isEmpty,
icon: Icons.all_inclusive_rounded,
onTap: () => widget.onQuickProviderToggle('__all__'),
),
const SizedBox(width: 6),
for (final provider in widget.providers) ...[
FilterChip(
visualDensity: VisualDensity.compact,
selected:
widget.selectedProviderIds.contains(provider.id),
label: Text(provider.name),
onSelected: (_) =>
widget.onQuickProviderToggle(provider.id),
_LiquidProviderPill(
label: provider.name,
isSelected: widget.selectedProviderIds.contains(provider.id),
icon: Icons.hub_rounded,
onTap: () => widget.onQuickProviderToggle(provider.id),
),
const SizedBox(width: 6),
],
const SizedBox(width: 2),
const _ToolbarDivider(),
const SizedBox(width: 6),
],
for (final period in TopPeriodFilter.values) ...[
ChoiceChip(
visualDensity: VisualDensity.compact,
selected: widget.topPeriodFilter == period,
label: Text(period.label),
onSelected: (_) => widget.onTopPeriodChanged(period),
),
const SizedBox(width: 6),
],
],
),
@@ -371,6 +358,227 @@ class _ToolbarDivider extends StatelessWidget {
}
}
class _LiquidPeriodTabs extends StatelessWidget {
const _LiquidPeriodTabs({
required this.selectedPeriod,
required this.onChanged,
});
final TopPeriodFilter selectedPeriod;
final ValueChanged<TopPeriodFilter> onChanged;
IconData _iconFor(TopPeriodFilter period) {
return switch (period) {
TopPeriodFilter.none => Icons.auto_awesome_rounded,
TopPeriodFilter.month => Icons.calendar_month_rounded,
TopPeriodFilter.year => Icons.calendar_today_rounded,
TopPeriodFilter.allTime => Icons.workspace_premium_rounded,
};
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final isDark = theme.brightness == Brightness.dark;
return Container(
padding: const EdgeInsets.all(2.5),
decoration: BoxDecoration(
color: isDark
? scheme.surfaceContainerHigh.withValues(alpha: 0.55)
: scheme.surfaceContainerHighest.withValues(alpha: 0.45),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: isDark
? Colors.white.withValues(alpha: 0.1)
: Colors.black.withValues(alpha: 0.06),
width: 1,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
for (final period in TopPeriodFilter.values) ...[
_LiquidPeriodTabItem(
period: period,
icon: _iconFor(period),
isSelected: selectedPeriod == period,
onTap: () => onChanged(period),
),
if (period != TopPeriodFilter.values.last) const SizedBox(width: 3),
],
],
),
);
}
}
class _LiquidPeriodTabItem extends StatelessWidget {
const _LiquidPeriodTabItem({
required this.period,
required this.icon,
required this.isSelected,
required this.onTap,
});
final TopPeriodFilter period;
final IconData icon;
final bool isSelected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final isDark = theme.brightness == Brightness.dark;
return Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(13),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
curve: Curves.easeOutCubic,
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: isSelected
? (isDark
? scheme.primary.withValues(alpha: 0.28)
: scheme.primaryContainer.withValues(alpha: 0.85))
: Colors.transparent,
borderRadius: BorderRadius.circular(13),
border: isSelected
? Border.all(
color: scheme.primary.withValues(alpha: isDark ? 0.45 : 0.3),
width: 1,
)
: null,
boxShadow: isSelected
? [
BoxShadow(
color: scheme.primary.withValues(alpha: isDark ? 0.25 : 0.12),
blurRadius: 8,
offset: const Offset(0, 2),
),
]
: null,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
size: 14,
color: isSelected
? (isDark ? scheme.primary : scheme.onPrimaryContainer)
: scheme.onSurfaceVariant.withValues(alpha: 0.85),
),
const SizedBox(width: 6),
Text(
period.label,
style: TextStyle(
fontSize: 12,
fontWeight: isSelected ? FontWeight.w700 : FontWeight.w500,
color: isSelected
? (isDark ? scheme.primary : scheme.onPrimaryContainer)
: scheme.onSurfaceVariant,
),
),
],
),
),
),
);
}
}
class _LiquidProviderPill extends StatelessWidget {
const _LiquidProviderPill({
required this.label,
required this.isSelected,
required this.onTap,
this.icon,
});
final String label;
final bool isSelected;
final VoidCallback onTap;
final IconData? icon;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final isDark = theme.brightness == Brightness.dark;
return Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(14),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
curve: Curves.easeOutCubic,
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: isSelected
? (isDark
? scheme.secondaryContainer.withValues(alpha: 0.35)
: scheme.secondaryContainer.withValues(alpha: 0.7))
: (isDark
? scheme.surfaceContainerHigh.withValues(alpha: 0.4)
: scheme.surfaceContainerHighest.withValues(alpha: 0.35)),
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: isSelected
? scheme.secondary.withValues(alpha: isDark ? 0.6 : 0.4)
: (isDark
? Colors.white.withValues(alpha: 0.08)
: Colors.black.withValues(alpha: 0.05)),
width: 1,
),
boxShadow: isSelected
? [
BoxShadow(
color: scheme.secondary.withValues(alpha: isDark ? 0.2 : 0.08),
blurRadius: 6,
),
]
: null,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null) ...[
Icon(
icon,
size: 14,
color: isSelected
? (isDark ? scheme.secondary : scheme.onSecondaryContainer)
: scheme.onSurfaceVariant.withValues(alpha: 0.8),
),
const SizedBox(width: 5),
],
Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: isSelected ? FontWeight.w700 : FontWeight.w500,
color: isSelected
? (isDark ? scheme.secondary : scheme.onSecondaryContainer)
: scheme.onSurfaceVariant,
),
),
],
),
),
),
);
}
}
class _HorizontalWheelScroller extends StatefulWidget {
const _HorizontalWheelScroller({
required this.builder,
@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
@@ -28,7 +29,10 @@ import 'post_details_controller.dart';
import 'widgets/cloud_mirrors_card.dart';
import 'widgets/post_action_bar.dart';
import 'widgets/post_media_viewer.dart';
import 'widgets/post_pools_card.dart';
import 'widgets/post_relations_card.dart';
import 'widgets/post_tags_panel.dart';
import '../../../backend/providers/e621_provider.dart';
final postCommentsProvider =
FutureProvider.family<List<PostComment>, PostDetailsArgs>(
@@ -39,6 +43,11 @@ final postCommentsProvider =
return result is Success<List<PostComment>> ? result.data : const [];
});
final postProviderInstanceProvider =
FutureProvider.family<ContentProvider?, String>((ref, providerId) async {
return ref.watch(providerManagerProvider).getProviderInstance(providerId);
});
class PostDetailsScreen extends ConsumerWidget {
const PostDetailsScreen({
required this.providerId,
@@ -123,6 +132,11 @@ class PostDetailsScreen extends ConsumerWidget {
(post.commentary != null &&
post.commentary!.trim().isNotEmpty));
final providerInstance =
ref.watch(postProviderInstanceProvider(post.providerId)).value;
final e621Provider =
providerInstance is E621Provider ? providerInstance : null;
if (Responsive.isMobile(context)) {
if (currentIndex >= 0 && feedPosts.length > 1) {
return _MobilePostPager(
@@ -172,6 +186,8 @@ class PostDetailsScreen extends ConsumerWidget {
LogicalKeySet(LogicalKeyboardKey.keyC):
const _AddCollectionIntent(),
LogicalKeySet(LogicalKeyboardKey.keyS): const _DownloadIntent(),
LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyS):
const _DownloadIntent(),
LogicalKeySet(LogicalKeyboardKey.escape): const _CloseIntent(),
},
child: Actions(
@@ -359,7 +375,28 @@ class PostDetailsScreen extends ConsumerWidget {
strings: strings,
localMedia: localMedia,
fileSizeBytes: fileSizeBytes,
e621Provider: e621Provider,
),
if (post.hasRelations) ...[
const SizedBox(height: 16),
PostRelationsCard(
post: post,
onOpenPostId: (targetId) =>
_openPostById(context, post.providerId, targetId),
),
],
if (post.hasPools && e621Provider != null) ...[
const SizedBox(height: 16),
PostPoolsCard(
post: post,
provider: e621Provider,
onOpenComic: (poolId, currentPost, [targetId]) =>
_openComic(context, e621Provider, poolId,
currentPost, targetId),
onOpenPostId: (targetId) =>
_openPostById(context, post.providerId, targetId),
),
],
if (shouldShowCloudCard) ...[
const SizedBox(height: 16),
CloudMirrorsCard(
@@ -468,6 +505,11 @@ class PostDetailsScreen extends ConsumerWidget {
(post.commentary != null &&
post.commentary!.trim().isNotEmpty));
final providerInstance =
ref.watch(postProviderInstanceProvider(post.providerId)).value;
final e621Provider =
providerInstance is E621Provider ? providerInstance : null;
return GestureDetector(
behavior: HitTestBehavior.translucent,
onVerticalDragEnd: (details) {
@@ -594,7 +636,27 @@ class PostDetailsScreen extends ConsumerWidget {
strings: strings,
localMedia: localMedia,
fileSizeBytes: fileSizeBytes,
e621Provider: e621Provider,
),
if (post.hasRelations) ...[
const SizedBox(height: 12),
PostRelationsCard(
post: post,
onOpenPostId: (targetId) =>
_openPostById(context, post.providerId, targetId),
),
],
if (post.hasPools && e621Provider != null) ...[
const SizedBox(height: 12),
PostPoolsCard(
post: post,
provider: e621Provider,
onOpenComic: (poolId, currentPost, [targetId]) =>
_openComic(context, e621Provider, poolId, currentPost, targetId),
onOpenPostId: (targetId) =>
_openPostById(context, post.providerId, targetId),
),
],
if (shouldShowCloudCard) ...[
const SizedBox(height: 12),
CloudMirrorsCard(
@@ -803,18 +865,101 @@ class PostDetailsScreen extends ConsumerWidget {
Post post,
Set<String> favoriteKeys,
) async {
if (favoriteKeys.contains(post.cacheKey)) {
final isFav = favoriteKeys.contains(post.cacheKey);
if (isFav) {
await ref
.read(favoriteServiceProvider)
.removeFavorite(post.id, post.providerId);
final provider = await ref
.read(providerManagerProvider)
.getProviderInstance(post.providerId);
if (provider is E621Provider && provider.isAuthorized) {
unawaited(provider.removeFavorite(post.id));
}
} else {
await ref.read(favoriteServiceProvider).addFavorite(post);
await _maybeAutoDownloadFavorite(ref, post);
final provider = await ref
.read(providerManagerProvider)
.getProviderInstance(post.providerId);
if (provider is E621Provider && provider.isAuthorized) {
unawaited(provider.addFavorite(post.id));
}
}
ref.invalidate(favoriteKeysProvider);
ref.invalidate(favoritesControllerProvider);
}
Future<void> _openComic(
BuildContext context,
E621Provider provider,
String poolId,
Post currentPost, [
String? targetId,
]) async {
final isRu = Localizations.maybeLocaleOf(context)?.languageCode == 'ru';
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2, color: Colors.white),
),
const SizedBox(width: 12),
Text(isRu ? 'Загрузка комикса...' : 'Loading comic...'),
],
),
duration: const Duration(seconds: 4),
),
);
final poolPosts = await provider.getPoolPosts(poolId);
if (!context.mounted) return;
ScaffoldMessenger.of(context).hideCurrentSnackBar();
if (poolPosts.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(isRu
? 'Не удалось загрузить страницы комикса'
: 'Could not load comic pages'),
),
);
return;
}
final idToFind = targetId ?? currentPost.id;
final targetIndex = poolPosts.indexWhere((p) => p.id == idToFind);
final startPost =
targetIndex >= 0 ? poolPosts[targetIndex] : poolPosts.first;
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (ctx) => PostDetailsScreen(
providerId: provider.id,
postId: startPost.id,
initialPost: startPost,
postsList: poolPosts,
),
),
);
}
void _openPostById(
BuildContext context, String providerId, String targetId) {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (ctx) => PostDetailsScreen(
providerId: providerId,
postId: targetId,
),
),
);
}
Future<void> _maybeAutoDownloadFavorite(WidgetRef ref, Post post) async {
final settings =
ref.read(appSettingsProvider).value ?? AppSettings.defaults;
@@ -971,10 +1116,12 @@ class _MobilePostPager extends StatefulWidget {
class _MobilePostPagerState extends State<_MobilePostPager> {
late final PageController _controller;
bool _mediaGestureLocked = false;
late int _currentPage = widget.initialIndex;
@override
void initState() {
super.initState();
_currentPage = widget.initialIndex;
_controller = PageController(initialPage: widget.initialIndex);
WidgetsBinding.instance.addPostFrameCallback((_) {
_prefetchAround(widget.initialIndex);
@@ -984,10 +1131,10 @@ class _MobilePostPagerState extends State<_MobilePostPager> {
@override
void didUpdateWidget(covariant _MobilePostPager oldWidget) {
super.didUpdateWidget(oldWidget);
// Исправленная проверка: сравниваем старый и новый initialIndex, а не текущий свайп
if (oldWidget.initialIndex != widget.initialIndex &&
widget.initialIndex >= 0 &&
widget.initialIndex < widget.posts.length) {
_currentPage = widget.initialIndex;
if (_controller.hasClients) {
_controller.jumpToPage(widget.initialIndex);
}
@@ -1005,7 +1152,7 @@ class _MobilePostPagerState extends State<_MobilePostPager> {
@override
Widget build(BuildContext context) {
return ScrollConfiguration(
final pager = ScrollConfiguration(
behavior: ScrollConfiguration.of(context).copyWith(
dragDevices: {
PointerDeviceKind.touch,
@@ -1021,6 +1168,7 @@ class _MobilePostPagerState extends State<_MobilePostPager> {
: const PageScrollPhysics(),
itemCount: widget.posts.length,
onPageChanged: (index) {
setState(() => _currentPage = index);
_prefetchAround(index);
},
itemBuilder: (context, index) {
@@ -1053,7 +1201,64 @@ class _MobilePostPagerState extends State<_MobilePostPager> {
},
),
);
}
if (widget.posts.length <= 1) return pager;
final isComic = widget.posts.first.hasPools;
return Stack(
children: [
pager,
Positioned(
top: MediaQuery.paddingOf(context).top + 10,
left: 0,
right: 0,
child: Center(
child: IgnorePointer(
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 12, sigmaY: 12),
child: Container(
padding:
const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.60),
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: Colors.white.withValues(alpha: 0.18),
width: 1,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (isComic) ...[
const Icon(Icons.auto_stories_rounded,
size: 14, color: Colors.white),
const SizedBox(width: 6),
],
Text(
isComic
? 'Комикс • Стр. ${_currentPage + 1} из ${widget.posts.length}'
: '${_currentPage + 1} / ${widget.posts.length}',
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.bold,
letterSpacing: -0.2,
),
),
],
),
),
),
),
),
),
),
],
);
}
void _setMediaGestureLocked(bool locked) {
if (_mediaGestureLocked == locked) return;
@@ -1153,6 +1358,8 @@ class _NeighborStrip extends StatelessWidget {
imageUrl: post.previewUrl.isNotEmpty
? post.previewUrl
: post.sampleUrl,
memCacheWidth: 160,
memCacheHeight: 160,
fit: BoxFit.cover,
errorWidget: (_, __, ___) =>
const Center(child: Icon(Icons.broken_image_rounded)),
@@ -1336,12 +1543,14 @@ class _PostInfoCard extends StatefulWidget {
required this.strings,
this.localMedia,
this.fileSizeBytes,
this.e621Provider,
});
final Post post;
final AppStrings strings;
final DownloadedMedia? localMedia;
final int? fileSizeBytes;
final E621Provider? e621Provider;
@override
State<_PostInfoCard> createState() => _PostInfoCardState();
@@ -1352,6 +1561,8 @@ class _PostInfoCardState extends State<_PostInfoCard> {
int? _resolvedWidth;
int? _resolvedHeight;
bool _resolvingDimensions = false;
int? _votedScore;
bool _isVoting = false;
static final _dimensionCache = <String, (int, int)>{};
@@ -1360,6 +1571,43 @@ class _PostInfoCardState extends State<_PostInfoCard> {
return imageTypes.contains(fileType.toLowerCase());
}
Future<void> _handleVote(int score) async {
final provider = widget.e621Provider;
if (provider == null) return;
if (!provider.isAuthorized) {
ScaffoldMessenger.maybeOf(context)?.showSnackBar(
const SnackBar(
content: Text('Для голосования на e621 настройте API-ключ в Источниках'),
duration: Duration(seconds: 3),
),
);
return;
}
if (_isVoting) return;
setState(() => _isVoting = true);
final success = await provider.votePost(widget.post.id, score);
if (mounted) {
setState(() {
_isVoting = false;
if (success) {
_votedScore = (_votedScore == score) ? 0 : score;
}
});
ScaffoldMessenger.maybeOf(context)?.showSnackBar(
SnackBar(
content: Text(
success
? (score > 0
? 'Голос ЗА (+1) отправлен на e621!'
: 'Голос ПРОТИВ (-1) отправлен на e621!')
: 'Не удалось отправить голос',
),
duration: const Duration(seconds: 2),
),
);
}
}
@override
void initState() {
super.initState();
@@ -1587,11 +1835,59 @@ class _PostInfoCardState extends State<_PostInfoCard> {
label: post.fileType.toUpperCase(),
),
RatingBadge(rating: post.rating),
if (post.score != 0)
if (post.score != 0 || widget.e621Provider != null)
Row(
mainAxisSize: MainAxisSize.min,
children: [
_SpecBadge(
icon: Icons.star_rounded,
label: '${post.score + (_votedScore ?? 0)}',
iconColor: Colors.amber,
),
if (widget.e621Provider != null) ...[
const SizedBox(width: 4),
IconButton(
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints:
const BoxConstraints(minWidth: 28, minHeight: 28),
icon: Icon(
_votedScore == 1
? Icons.thumb_up_rounded
: Icons.thumb_up_alt_outlined,
size: 15,
color: _votedScore == 1
? const Color(0xFF10B981)
: scheme.onSurfaceVariant,
),
onPressed: _isVoting ? null : () => _handleVote(1),
tooltip: 'Голос ЗА (+1)',
),
IconButton(
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints:
const BoxConstraints(minWidth: 28, minHeight: 28),
icon: Icon(
_votedScore == -1
? Icons.thumb_down_rounded
: Icons.thumb_down_alt_outlined,
size: 15,
color: _votedScore == -1
? const Color(0xFFEF4444)
: scheme.onSurfaceVariant,
),
onPressed: _isVoting ? null : () => _handleVote(-1),
tooltip: 'Голос ПРОТИВ (-1)',
),
],
],
),
if (post.favCount != null && post.favCount! > 0)
_SpecBadge(
icon: Icons.star_rounded,
label: '${post.score}',
iconColor: Colors.amber,
icon: Icons.favorite_rounded,
label: '${post.favCount}',
iconColor: const Color(0xFFEF4444),
),
if (post.source != null && post.source!.isNotEmpty)
InkWell(
@@ -0,0 +1,501 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../../../backend/models/e621_pool.dart';
import '../../../../backend/models/post.dart';
import '../../../../backend/providers/e621_provider.dart';
class E621PoolSheet extends StatefulWidget {
const E621PoolSheet({
required this.currentPost,
required this.poolId,
required this.provider,
required this.onSelectPostId,
super.key,
});
final Post currentPost;
final String poolId;
final E621Provider provider;
final ValueChanged<String> onSelectPostId;
static Future<void> show(
BuildContext context, {
required Post currentPost,
required String poolId,
required E621Provider provider,
required ValueChanged<String> onSelectPostId,
}) {
return showModalBottomSheet(
context: context,
backgroundColor: Colors.transparent,
isScrollControlled: true,
builder: (context) => E621PoolSheet(
currentPost: currentPost,
poolId: poolId,
provider: provider,
onSelectPostId: onSelectPostId,
),
);
}
@override
State<E621PoolSheet> createState() => _E621PoolSheetState();
}
class _E621PoolSheetState extends State<E621PoolSheet> {
E621Pool? _pool;
bool _isLoading = true;
String? _error;
@override
void initState() {
super.initState();
_loadPool();
}
Future<void> _loadPool() async {
setState(() {
_isLoading = true;
_error = null;
});
try {
final pool = await widget.provider.getPool(widget.poolId);
if (mounted) {
setState(() {
_pool = pool;
_isLoading = false;
if (pool == null) {
_error = 'Не удалось загрузить информацию о серии';
}
});
}
} catch (e) {
if (mounted) {
setState(() {
_error = 'Ошибка: $e';
_isLoading = false;
});
}
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
final isRu = Localizations.maybeLocaleOf(context)?.languageCode == 'ru';
final pool = _pool;
final currentPage = pool?.pageOf(widget.currentPost.id) ?? 0;
final prevId = pool?.previousPostId(widget.currentPost.id);
final nextId = pool?.nextPostId(widget.currentPost.id);
return DraggableScrollableSheet(
initialChildSize: 0.55,
minChildSize: 0.35,
maxChildSize: 0.88,
builder: (context, scrollController) {
return ClipRRect(
borderRadius: const BorderRadius.vertical(top: Radius.circular(28)),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
child: Container(
decoration: BoxDecoration(
color: isDark
? const Color(0xFF161B22).withValues(alpha: 0.92)
: Colors.white.withValues(alpha: 0.94),
borderRadius:
const BorderRadius.vertical(top: Radius.circular(28)),
border: Border.all(
color: isDark
? Colors.white.withValues(alpha: 0.12)
: Colors.black.withValues(alpha: 0.08),
width: 1.1,
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.35),
blurRadius: 28,
offset: const Offset(0, -6),
),
],
),
child: ListView(
controller: scrollController,
padding: const EdgeInsets.fromLTRB(20, 12, 20, 32),
children: [
// Drag handle
Center(
child: Container(
width: 44,
height: 5,
decoration: BoxDecoration(
color: isDark
? Colors.white.withValues(alpha: 0.25)
: Colors.black.withValues(alpha: 0.20),
borderRadius: BorderRadius.circular(3),
),
),
),
const SizedBox(height: 16),
if (_isLoading)
const Center(
child: Padding(
padding: EdgeInsets.all(40),
child: CircularProgressIndicator(),
),
)
else if (_error != null)
Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
children: [
const Icon(Icons.error_outline_rounded,
size: 40, color: Color(0xFFEF4444)),
const SizedBox(height: 12),
Text(_error!),
const SizedBox(height: 12),
FilledButton.tonal(
onPressed: _loadPool,
child: const Text('Повторить'),
),
],
),
),
)
else if (pool != null) ...[
// Pool header
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFF0055AA), Color(0xFF0088FF)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: const Color(0xFF0055AA)
.withValues(alpha: 0.35),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: const Icon(
Icons.menu_book_rounded,
color: Colors.white,
size: 24,
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
pool.cleanTitle,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
letterSpacing: -0.2,
),
),
const SizedBox(height: 4),
Wrap(
spacing: 6,
runSpacing: 4,
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: const Color(0xFF0055AA)
.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: const Color(0xFF0055AA)
.withValues(alpha: 0.30),
width: 0.8,
),
),
child: Text(
pool.category == 'series'
? (isRu ? 'Комикс / Серия' : 'Series')
: (isRu
? 'Коллекция'
: 'Collection'),
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: Color(0xFF0077EE),
),
),
),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: isDark
? Colors.white.withValues(alpha: 0.08)
: Colors.black.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(8),
),
child: Text(
'${pool.postCount} ${isRu ? 'страниц' : 'pages'}',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
color: theme
.colorScheme.onSurfaceVariant,
),
),
),
],
),
],
),
),
],
),
if (pool.description.trim().isNotEmpty) ...[
const SizedBox(height: 12),
Text(
pool.description.trim(),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
maxLines: 4,
overflow: TextOverflow.ellipsis,
),
],
const SizedBox(height: 16),
// Big "Read Full Comic" Button
FilledButton.icon(
onPressed: () {
HapticFeedback.mediumImpact();
Navigator.of(context).pop();
final startId = pool.postIds.contains(widget.currentPost.id)
? widget.currentPost.id
: pool.postIds.first;
widget.onSelectPostId(startId);
},
icon: const Icon(Icons.auto_stories_rounded, size: 18),
label: Text(
isRu
? 'Читать весь комикс (${pool.postCount} стр.)'
: 'Read Full Comic (${pool.postCount} pages)',
style: const TextStyle(fontWeight: FontWeight.bold),
),
style: FilledButton.styleFrom(
backgroundColor: const Color(0xFF0055AA),
foregroundColor: Colors.white,
minimumSize: const Size(double.infinity, 46),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
),
),
const SizedBox(height: 16),
// Quick Page Navigator Banner
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: isDark
? Colors.black.withValues(alpha: 0.25)
: theme.colorScheme.surfaceContainerHighest
.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: isDark
? Colors.white.withValues(alpha: 0.08)
: Colors.black.withValues(alpha: 0.06),
),
),
child: Row(
children: [
IconButton.filledTonal(
tooltip: isRu ? 'Предыдущая' : 'Previous',
onPressed: prevId == null
? null
: () {
HapticFeedback.lightImpact();
Navigator.of(context).pop();
widget.onSelectPostId(prevId);
},
icon: const Icon(Icons.arrow_back_rounded, size: 18),
),
Expanded(
child: Column(
children: [
Text(
currentPage > 0
? (isRu
? 'Страница $currentPage из ${pool.postCount}'
: 'Page $currentPage of ${pool.postCount}')
: (isRu
? 'Страниц в пуле: ${pool.postCount}'
: '${pool.postCount} pages in pool'),
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 13,
),
),
if (currentPage > 0)
Padding(
padding: const EdgeInsets.only(top: 6),
child: LinearProgressIndicator(
value: (currentPage / pool.postCount)
.clamp(0.0, 1.0),
borderRadius: BorderRadius.circular(4),
minHeight: 4,
backgroundColor: isDark
? Colors.white.withValues(alpha: 0.1)
: Colors.black.withValues(alpha: 0.08),
),
),
],
),
),
IconButton.filledTonal(
tooltip: isRu ? 'Следующая' : 'Next',
onPressed: nextId == null
? null
: () {
HapticFeedback.lightImpact();
Navigator.of(context).pop();
widget.onSelectPostId(nextId);
},
icon: const Icon(Icons.arrow_forward_rounded,
size: 18),
),
],
),
),
const SizedBox(height: 18),
Text(
isRu ? 'Все страницы серии:' : 'All pages in pool:',
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 10),
// Grid of page buttons
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (int i = 0; i < pool.postIds.length; i++) ...[
_PageButton(
pageNumber: i + 1,
postId: pool.postIds[i],
isCurrent: pool.postIds[i] == widget.currentPost.id,
onTap: () {
if (pool.postIds[i] == widget.currentPost.id) {
return;
}
HapticFeedback.lightImpact();
Navigator.of(context).pop();
widget.onSelectPostId(pool.postIds[i]);
},
),
],
],
),
],
],
),
),
),
);
},
);
}
}
class _PageButton extends StatelessWidget {
const _PageButton({
required this.pageNumber,
required this.postId,
required this.isCurrent,
required this.onTap,
});
final int pageNumber;
final String postId;
final bool isCurrent;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
return Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
constraints: const BoxConstraints(minWidth: 42, minHeight: 40),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
decoration: BoxDecoration(
color: isCurrent
? const Color(0xFF0055AA)
: (isDark
? Colors.white.withValues(alpha: 0.08)
: Colors.black.withValues(alpha: 0.05)),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isCurrent
? const Color(0xFF0077EE)
: (isDark
? Colors.white.withValues(alpha: 0.12)
: Colors.black.withValues(alpha: 0.08)),
width: isCurrent ? 1.4 : 0.8,
),
boxShadow: isCurrent
? [
BoxShadow(
color: const Color(0xFF0055AA).withValues(alpha: 0.40),
blurRadius: 8,
offset: const Offset(0, 2),
),
]
: null,
),
child: Center(
child: Text(
'$pageNumber',
style: TextStyle(
fontSize: 13,
fontWeight: isCurrent ? FontWeight.bold : FontWeight.w600,
color: isCurrent
? Colors.white
: theme.colorScheme.onSurface,
),
),
),
),
),
);
}
}
@@ -78,6 +78,8 @@ class _PostMediaViewerState extends State<PostMediaViewer>
String? _videoError;
bool _retriedFormatError = false;
bool _softwareDecodingFallback = false;
late bool _useSoftwareDecoding =
Platform.isLinux || Platform.isWindows || Platform.isMacOS;
Timer? _hideTimer;
StreamSubscription<String>? _errorSubscription;
StreamSubscription<Duration>? _positionSubscription;
@@ -109,6 +111,8 @@ class _PostMediaViewerState extends State<PostMediaViewer>
_videoError = null;
_retriedFormatError = false;
_softwareDecodingFallback = false;
_useSoftwareDecoding =
Platform.isLinux || Platform.isWindows || Platform.isMacOS;
_imageUrls = _buildImageUrls(widget.post);
_videoUrls = _buildVideoUrls(widget.post);
if (_isPlayableMedia(widget.post) && _videoUrls.isNotEmpty) {
@@ -226,6 +230,8 @@ class _PostMediaViewerState extends State<PostMediaViewer>
aspectRatio: widget.post.width > 0 && widget.post.height > 0
? widget.post.width / widget.post.height
: 16 / 9,
isSoftwareDecoding: _useSoftwareDecoding,
onToggleDecoder: _toggleDecoderMode,
controlsVisible: _controlsVisible,
coverVideo: _coverVideo,
muted: _muted,
@@ -317,10 +323,16 @@ class _PostMediaViewerState extends State<PostMediaViewer>
),
);
} else {
final mq = MediaQuery.maybeOf(context);
final dpr = mq?.devicePixelRatio ?? 1.5;
final screenWidth = mq?.size.width ?? 1280;
final maxCacheWidth =
(screenWidth * dpr * 1.5).round().clamp(1080, 2560);
image = CachedNetworkImage(
key: ValueKey(url),
imageUrl: url,
httpHeaders: headers,
memCacheWidth: maxCacheWidth,
fit: BoxFit.contain,
placeholder: (context, url) =>
const Center(child: CircularProgressIndicator()),
@@ -383,16 +395,54 @@ class _PostMediaViewerState extends State<PostMediaViewer>
),
],
);
final interactiveChild = _imageUrls.length > 1
? Shortcuts(
shortcuts: {
LogicalKeySet(LogicalKeyboardKey.arrowLeft):
const _PrevImageIntent(),
LogicalKeySet(LogicalKeyboardKey.keyA): const _PrevImageIntent(),
LogicalKeySet(LogicalKeyboardKey.arrowRight):
const _NextImageIntent(),
LogicalKeySet(LogicalKeyboardKey.keyD): const _NextImageIntent(),
},
child: Actions(
actions: {
_PrevImageIntent: CallbackAction<_PrevImageIntent>(
onInvoke: (_) {
if (_imageIndex > 0) {
setState(() => _imageIndex--);
}
return null;
},
),
_NextImageIntent: CallbackAction<_NextImageIntent>(
onInvoke: (_) {
if (_imageIndex < _imageUrls.length - 1) {
setState(() => _imageIndex++);
}
return null;
},
),
},
child: Focus(
autofocus: true,
child: child,
),
),
)
: child;
return LayoutBuilder(
builder: (context, constraints) {
if (constraints.hasBoundedWidth && constraints.hasBoundedHeight) {
return SizedBox(
width: constraints.maxWidth,
height: constraints.maxHeight,
child: child,
child: interactiveChild,
);
}
return child;
return interactiveChild;
},
);
}
@@ -400,11 +450,12 @@ class _PostMediaViewerState extends State<PostMediaViewer>
void _initializeVideo() {
MediaKit.ensureInitialized();
_player = Player();
final bool enableHw = !_useSoftwareDecoding && !_softwareDecodingFallback;
_controller = VideoController(
_player!,
configuration: VideoControllerConfiguration(
enableHardwareAcceleration: !_softwareDecodingFallback,
hwdec: _softwareDecodingFallback ? 'no' : 'auto-safe',
enableHardwareAcceleration: enableHw,
hwdec: enableHw ? (Platform.isAndroid ? 'auto-safe' : 'auto') : 'no',
),
);
_applyVolume();
@@ -479,6 +530,7 @@ class _PostMediaViewerState extends State<PostMediaViewer>
lower.contains('failed to recognize file format'))) {
_retriedFormatError = true;
_softwareDecodingFallback = true;
_useSoftwareDecoding = true;
_disposeVideo();
await Future<void>.delayed(const Duration(milliseconds: 250));
if (mounted) {
@@ -496,6 +548,7 @@ class _PostMediaViewerState extends State<PostMediaViewer>
_videoIndex = 0;
_retriedFormatError = false;
_softwareDecodingFallback = true;
_useSoftwareDecoding = true;
_disposeVideo();
await Future<void>.delayed(const Duration(milliseconds: 150));
if (mounted) {
@@ -503,6 +556,26 @@ class _PostMediaViewerState extends State<PostMediaViewer>
}
}
void _toggleDecoderMode() {
setState(() {
_useSoftwareDecoding = !_useSoftwareDecoding;
_softwareDecodingFallback = false;
_retriedFormatError = false;
});
_disposeVideo();
_initializeVideo();
ScaffoldMessenger.maybeOf(context)?.showSnackBar(
SnackBar(
content: Text(
_useSoftwareDecoding
? 'Включено программное декодирование (S/W)'
: 'Включено аппаратное декодирование (H/W)',
),
duration: const Duration(seconds: 2),
),
);
}
void _disposeVideo() {
_hideTimer?.cancel();
_errorSubscription?.cancel();
@@ -644,6 +717,8 @@ class _PostMediaViewerState extends State<PostMediaViewer>
halfVolume: _halfVolume,
coverVideo: _coverVideo,
initialVolume: _currentVolume,
isSoftwareDecoding: _useSoftwareDecoding,
onToggleDecoder: _toggleDecoderMode,
errorMessage: _videoError,
onRetry: _retryVideo,
onChanged: (snapshot) {
@@ -739,6 +814,48 @@ class _TogglePlayIntent extends Intent {
const _TogglePlayIntent();
}
class _SeekVideoIntent extends Intent {
const _SeekVideoIntent(this.seconds);
final int seconds;
}
class _VolumeVideoIntent extends Intent {
const _VolumeVideoIntent(this.deltaY);
final double deltaY;
}
class _MuteVideoIntent extends Intent {
const _MuteVideoIntent();
}
class _FullscreenVideoIntent extends Intent {
const _FullscreenVideoIntent();
}
class _LoopVideoIntent extends Intent {
const _LoopVideoIntent();
}
class _ZoomInIntent extends Intent {
const _ZoomInIntent();
}
class _ZoomOutIntent extends Intent {
const _ZoomOutIntent();
}
class _ResetZoomIntent extends Intent {
const _ResetZoomIntent();
}
class _PrevImageIntent extends Intent {
const _PrevImageIntent();
}
class _NextImageIntent extends Intent {
const _NextImageIntent();
}
class _UnsupportedSwfPanel extends StatelessWidget {
const _UnsupportedSwfPanel();
@@ -798,6 +915,8 @@ class _FullscreenVideoPage extends StatefulWidget {
required this.halfVolume,
required this.coverVideo,
this.initialVolume = 100.0,
required this.isSoftwareDecoding,
required this.onToggleDecoder,
required this.errorMessage,
required this.onRetry,
required this.onChanged,
@@ -812,6 +931,8 @@ class _FullscreenVideoPage extends StatefulWidget {
final bool halfVolume;
final bool coverVideo;
final double initialVolume;
final bool isSoftwareDecoding;
final VoidCallback onToggleDecoder;
final String? errorMessage;
final VoidCallback onRetry;
final ValueChanged<VideoPlaybackSnapshot> onChanged;
@@ -911,6 +1032,8 @@ class _FullscreenVideoPageState extends State<_FullscreenVideoPage> {
shortcuts: {
LogicalKeySet(LogicalKeyboardKey.escape): const _CloseVideoIntent(),
LogicalKeySet(LogicalKeyboardKey.space): const _TogglePlayIntent(),
LogicalKeySet(LogicalKeyboardKey.keyK): const _TogglePlayIntent(),
LogicalKeySet(LogicalKeyboardKey.keyF): const _CloseVideoIntent(),
},
child: Actions(
actions: {
@@ -934,6 +1057,8 @@ class _FullscreenVideoPageState extends State<_FullscreenVideoPage> {
player: widget.player,
controller: widget.controller,
aspectRatio: widget.aspectRatio,
isSoftwareDecoding: widget.isSoftwareDecoding,
onToggleDecoder: widget.onToggleDecoder,
controlsVisible: _controlsVisible,
coverVideo: _coverVideo,
muted: _muted,
@@ -1485,6 +1610,8 @@ class _VideoSurface extends StatefulWidget {
required this.player,
required this.controller,
required this.aspectRatio,
required this.isSoftwareDecoding,
required this.onToggleDecoder,
required this.controlsVisible,
required this.coverVideo,
required this.muted,
@@ -1509,6 +1636,8 @@ class _VideoSurface extends StatefulWidget {
final Player player;
final VideoController controller;
final double aspectRatio;
final bool isSoftwareDecoding;
final VoidCallback onToggleDecoder;
final bool controlsVisible;
final bool coverVideo;
final bool muted;
@@ -1754,6 +1883,8 @@ class _VideoSurfaceState extends State<_VideoSurface> {
fullscreen: widget.fullscreen,
isLocked: _isLocked,
isLandscape: widget.isLandscape,
isSoftwareDecoding: widget.isSoftwareDecoding,
onToggleDecoder: widget.onToggleDecoder,
onToggleOrientation: widget.onToggleOrientation,
onToggleLock: () => setState(() => _isLocked = !_isLocked),
onToggleFit: widget.onToggleFit,
@@ -1771,16 +1902,87 @@ class _VideoSurfaceState extends State<_VideoSurface> {
},
);
return MouseRegion(
onHover: (_) => widget.onInteract(),
child: ClipRRect(
borderRadius: BorderRadius.circular(widget.fullscreen ? 0 : 10),
child: widget.fullscreen
? SizedBox.expand(child: child)
: AspectRatio(
aspectRatio: widget.aspectRatio.clamp(0.35, 2.4),
child: child,
),
return Shortcuts(
shortcuts: {
LogicalKeySet(LogicalKeyboardKey.space): const _TogglePlayIntent(),
LogicalKeySet(LogicalKeyboardKey.keyK): const _TogglePlayIntent(),
LogicalKeySet(LogicalKeyboardKey.arrowLeft): const _SeekVideoIntent(-5),
LogicalKeySet(LogicalKeyboardKey.keyJ): const _SeekVideoIntent(-5),
LogicalKeySet(LogicalKeyboardKey.arrowRight): const _SeekVideoIntent(5),
LogicalKeySet(LogicalKeyboardKey.keyL): const _SeekVideoIntent(5),
LogicalKeySet(LogicalKeyboardKey.shift, LogicalKeyboardKey.arrowLeft):
const _SeekVideoIntent(-15),
LogicalKeySet(LogicalKeyboardKey.shift, LogicalKeyboardKey.arrowRight):
const _SeekVideoIntent(15),
LogicalKeySet(LogicalKeyboardKey.arrowUp): const _VolumeVideoIntent(-10),
LogicalKeySet(LogicalKeyboardKey.arrowDown): const _VolumeVideoIntent(10),
LogicalKeySet(LogicalKeyboardKey.keyM): const _MuteVideoIntent(),
LogicalKeySet(LogicalKeyboardKey.keyF): const _FullscreenVideoIntent(),
LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyL):
const _LoopVideoIntent(),
},
child: Actions(
actions: {
_TogglePlayIntent: CallbackAction<_TogglePlayIntent>(
onInvoke: (_) {
if (widget.player.state.playing) {
widget.player.pause();
} else {
if (widget.player.state.completed) {
widget.player.seek(Duration.zero);
}
widget.player.play();
}
widget.onInteract();
return null;
},
),
_SeekVideoIntent: CallbackAction<_SeekVideoIntent>(
onInvoke: (intent) {
_seekBy(Duration(seconds: intent.seconds));
return null;
},
),
_VolumeVideoIntent: CallbackAction<_VolumeVideoIntent>(
onInvoke: (intent) {
_adjustVolume(intent.deltaY);
return null;
},
),
_MuteVideoIntent: CallbackAction<_MuteVideoIntent>(
onInvoke: (_) {
widget.onToggleMute();
return null;
},
),
_FullscreenVideoIntent: CallbackAction<_FullscreenVideoIntent>(
onInvoke: (_) {
widget.onFullscreen();
return null;
},
),
_LoopVideoIntent: CallbackAction<_LoopVideoIntent>(
onInvoke: (_) {
widget.onToggleLoop();
return null;
},
),
},
child: Focus(
autofocus: true,
child: MouseRegion(
onHover: (_) => widget.onInteract(),
child: ClipRRect(
borderRadius: BorderRadius.circular(widget.fullscreen ? 0 : 10),
child: widget.fullscreen
? SizedBox.expand(child: child)
: AspectRatio(
aspectRatio: widget.aspectRatio.clamp(0.35, 2.4),
child: child,
),
),
),
),
),
);
}
@@ -1959,80 +2161,133 @@ class _ZoomableImageState extends State<_ZoomableImage> {
super.dispose();
}
void _zoomIn() {
final currentScale = _controller.value.getMaxScaleOnAxis();
final newScale = (currentScale * 1.35).clamp(1.0, 6.0);
_controller.value = Matrix4.identity()..scaleByDouble(newScale, newScale, 1, 1);
if (mounted) setState(() => _zoomed = newScale > 1.02);
_setLocked(_zoomed);
}
void _zoomOut() {
final currentScale = _controller.value.getMaxScaleOnAxis();
final newScale = (currentScale / 1.35).clamp(1.0, 6.0);
if (newScale <= 1.02) {
_controller.value = Matrix4.identity();
if (mounted) setState(() => _zoomed = false);
_setLocked(false);
} else {
_controller.value = Matrix4.identity()..scaleByDouble(newScale, newScale, 1, 1);
if (mounted) setState(() => _zoomed = true);
_setLocked(true);
}
}
void _resetZoom() {
_controller.value = Matrix4.identity();
if (mounted) setState(() => _zoomed = false);
_setLocked(false);
}
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final hasFrame =
constraints.hasBoundedWidth && constraints.hasBoundedHeight;
final child = hasFrame
? SizedBox(
width: constraints.maxWidth,
height: constraints.maxHeight,
child: widget.child,
)
: widget.child;
return Listener(
onPointerSignal: (signal) {
if (signal is PointerScrollEvent) {
final scaleDelta = signal.scrollDelta.dy < 0 ? 1.15 : 0.85;
final currentScale = _controller.value.getMaxScaleOnAxis();
final newScale = (currentScale * scaleDelta).clamp(1.0, 6.0);
if (newScale <= 1.02) {
_controller.value = Matrix4.identity();
if (mounted) setState(() => _zoomed = false);
_setLocked(false);
} else {
final focalPoint = signal.localPosition;
_controller.value = Matrix4.identity()
..translateByDouble(
-focalPoint.dx * (newScale - 1),
-focalPoint.dy * (newScale - 1),
0,
1)
..scaleByDouble(newScale, newScale, 1, 1);
if (mounted) setState(() => _zoomed = true);
_setLocked(true);
}
}
},
onPointerDown: (_) {
_pointerCount++;
if (_pointerCount >= 2 || _zoomed) {
_setLocked(true);
}
},
onPointerUp: (_) => _releasePointer(),
onPointerCancel: (_) => _releasePointer(),
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onDoubleTapDown: (details) => _doubleTapDetails = details,
onDoubleTap: _toggleZoom,
child: InteractiveViewer(
transformationController: _controller,
minScale: 1,
maxScale: 6,
boundaryMargin: const EdgeInsets.all(160),
panEnabled: _zoomed,
scaleEnabled: true,
clipBehavior: Clip.none,
onInteractionStart: (_) {
if (_pointerCount >= 2 || _zoomed) _setLocked(true);
},
onInteractionEnd: (_) {
final scale = _controller.value.getMaxScaleOnAxis();
final nextZoomed = scale > 1.03;
if (!nextZoomed) {
_controller.value = Matrix4.identity();
}
if (mounted) setState(() => _zoomed = nextZoomed);
_setLocked(nextZoomed || _pointerCount >= 2);
},
child: child,
),
),
);
return Shortcuts(
shortcuts: {
LogicalKeySet(LogicalKeyboardKey.equal): const _ZoomInIntent(),
LogicalKeySet(LogicalKeyboardKey.add): const _ZoomInIntent(),
LogicalKeySet(LogicalKeyboardKey.numpadAdd): const _ZoomInIntent(),
LogicalKeySet(LogicalKeyboardKey.minus): const _ZoomOutIntent(),
LogicalKeySet(LogicalKeyboardKey.numpadSubtract): const _ZoomOutIntent(),
LogicalKeySet(LogicalKeyboardKey.digit0): const _ResetZoomIntent(),
LogicalKeySet(LogicalKeyboardKey.numpad0): const _ResetZoomIntent(),
LogicalKeySet(LogicalKeyboardKey.escape): const _ResetZoomIntent(),
},
child: Actions(
actions: {
_ZoomInIntent: CallbackAction<_ZoomInIntent>(
onInvoke: (_) {
_zoomIn();
return null;
},
),
_ZoomOutIntent: CallbackAction<_ZoomOutIntent>(
onInvoke: (_) {
_zoomOut();
return null;
},
),
_ResetZoomIntent: CallbackAction<_ResetZoomIntent>(
onInvoke: (_) {
if (_zoomed) {
_resetZoom();
}
return null;
},
),
},
child: Focus(
autofocus: true,
child: LayoutBuilder(
builder: (context, constraints) {
final hasFrame =
constraints.hasBoundedWidth && constraints.hasBoundedHeight;
final child = hasFrame
? SizedBox(
width: constraints.maxWidth,
height: constraints.maxHeight,
child: widget.child,
)
: widget.child;
return Listener(
onPointerDown: (_) {
_pointerCount++;
if (_pointerCount >= 2 || _zoomed) {
_setLocked(true);
}
},
onPointerUp: (_) => _releasePointer(),
onPointerCancel: (_) => _releasePointer(),
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onDoubleTapDown: (details) => _doubleTapDetails = details,
onDoubleTap: _toggleZoom,
child: InteractiveViewer(
transformationController: _controller,
minScale: 1,
maxScale: 6,
scaleFactor: 1000000.0,
boundaryMargin: const EdgeInsets.all(160),
panEnabled: _zoomed,
scaleEnabled: true,
clipBehavior: Clip.none,
onInteractionStart: (_) {
if (_pointerCount >= 2 || _zoomed) _setLocked(true);
},
onInteractionEnd: (_) {
final scale = _controller.value.getMaxScaleOnAxis();
final nextZoomed = scale > 1.03;
if (!nextZoomed) {
_controller.value = Matrix4.identity();
}
if (mounted) setState(() => _zoomed = nextZoomed);
_setLocked(nextZoomed || _pointerCount >= 2);
},
child: Listener(
onPointerSignal: (event) {
if (event is PointerScrollEvent) {
GestureBinding.instance.pointerSignalResolver
.register(event, (_) {});
}
},
child: child,
),
),
),
);
},
),
),
),
);
}
@@ -2441,6 +2696,8 @@ class _VideoControls extends StatefulWidget {
required this.fullscreen,
required this.isLocked,
this.isLandscape,
required this.isSoftwareDecoding,
required this.onToggleDecoder,
this.onToggleOrientation,
required this.onToggleLock,
required this.onToggleFit,
@@ -2460,6 +2717,8 @@ class _VideoControls extends StatefulWidget {
final bool fullscreen;
final bool isLocked;
final bool? isLandscape;
final bool isSoftwareDecoding;
final VoidCallback onToggleDecoder;
final VoidCallback? onToggleOrientation;
final VoidCallback onToggleLock;
final VoidCallback onToggleFit;
@@ -2559,6 +2818,17 @@ class _VideoControlsState extends State<_VideoControls> {
onPressed: widget.onToggleLock,
),
const Spacer(),
_RoundControlButton(
tooltip: widget.isSoftwareDecoding
? 'Декодер: S/W (программный). Нажмите для переключения на H/W'
: 'Декодер: H/W (аппаратный). Нажмите для переключения на S/W',
selected: !widget.isSoftwareDecoding,
icon: widget.isSoftwareDecoding
? Icons.memory_rounded
: Icons.speed_rounded,
onPressed: widget.onToggleDecoder,
),
const SizedBox(width: 8),
_SpeedBadgeButton(
currentRate: currentRate,
onSelected: (newRate) => widget.player.setRate(newRate),
@@ -0,0 +1,173 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../../../backend/models/post.dart';
import '../../../../backend/providers/e621_provider.dart';
import 'e621_pool_sheet.dart';
class PostPoolsCard extends StatelessWidget {
const PostPoolsCard({
required this.post,
required this.provider,
required this.onOpenComic,
required this.onOpenPostId,
super.key,
});
final Post post;
final E621Provider provider;
final void Function(String poolId, Post currentPost, [String? targetId])
onOpenComic;
final ValueChanged<String> onOpenPostId;
@override
Widget build(BuildContext context) {
if (!post.hasPools) return const SizedBox.shrink();
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
final isRu = Localizations.maybeLocaleOf(context)?.languageCode == 'ru';
final pools = post.poolIds;
return Container(
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: isDark
? theme.colorScheme.surfaceContainerHigh.withValues(alpha: 0.70)
: theme.colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: isDark
? Colors.white.withValues(alpha: 0.12)
: theme.colorScheme.outlineVariant.withValues(alpha: 0.40),
width: 1.1,
),
),
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(
Icons.menu_book_rounded,
size: 20,
color: Color(0xFF0077EE),
),
const SizedBox(width: 8),
Text(
isRu ? 'Входит в комикс / серию' : 'Part of Series / Pool',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const Spacer(),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: const Color(0xFF0055AA).withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(8),
),
child: Text(
'${pools.length}',
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
color: Color(0xFF0077EE),
),
),
),
],
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final poolId in pools)
Row(
mainAxisSize: MainAxisSize.min,
children: [
Material(
color: Colors.transparent,
child: InkWell(
onTap: () {
HapticFeedback.lightImpact();
onOpenComic(poolId, post);
},
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 14, vertical: 9),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFF0055AA), Color(0xFF0077EE)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: const Color(0xFF0055AA)
.withValues(alpha: 0.25),
blurRadius: 8,
offset: const Offset(0, 3),
),
],
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.auto_stories_rounded,
size: 16, color: Colors.white),
const SizedBox(width: 7),
Text(
isRu
? 'Читать весь комикс (#$poolId)'
: 'Read Full Comic (#$poolId)',
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
const SizedBox(width: 5),
const Icon(Icons.arrow_forward_rounded,
size: 13, color: Colors.white),
],
),
),
),
),
const SizedBox(width: 4),
IconButton.filledTonal(
tooltip: isRu ? 'Все страницы' : 'All pages',
style: IconButton.styleFrom(
visualDensity: VisualDensity.compact,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
),
onPressed: () {
HapticFeedback.lightImpact();
E621PoolSheet.show(
context,
currentPost: post,
poolId: poolId,
provider: provider,
onSelectPostId: (targetId) =>
onOpenComic(poolId, post, targetId),
);
},
icon: const Icon(Icons.grid_view_rounded, size: 16),
),
],
),
],
),
],
),
);
}
}
@@ -0,0 +1,164 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../../../backend/models/post.dart';
class PostRelationsCard extends StatelessWidget {
const PostRelationsCard({
required this.post,
required this.onOpenPostId,
super.key,
});
final Post post;
final ValueChanged<String> onOpenPostId;
@override
Widget build(BuildContext context) {
if (!post.hasRelations) return const SizedBox.shrink();
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
final isRu = Localizations.maybeLocaleOf(context)?.languageCode == 'ru';
final parentId = post.parentId;
final children = post.childrenIds;
return Container(
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: isDark
? theme.colorScheme.surfaceContainerHigh.withValues(alpha: 0.70)
: theme.colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: isDark
? Colors.white.withValues(alpha: 0.12)
: theme.colorScheme.outlineVariant.withValues(alpha: 0.40),
width: 1.1,
),
),
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.copy_all_rounded,
size: 20,
color: theme.colorScheme.primary,
),
const SizedBox(width: 8),
Text(
isRu ? 'Связанные посты и версии' : 'Variations & Relations',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const Spacer(),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: theme.colorScheme.primary.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(8),
),
child: Text(
'${(parentId != null ? 1 : 0) + children.length}',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
color: theme.colorScheme.primary,
),
),
),
],
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
if (parentId != null)
_RelationPill(
icon: Icons.north_west_rounded,
label: isRu ? 'Родительский арт #$parentId' : 'Parent #$parentId',
color: const Color(0xFF0077EE),
onTap: () {
HapticFeedback.lightImpact();
onOpenPostId(parentId);
},
),
for (final childId in children)
_RelationPill(
icon: Icons.style_rounded,
label: isRu ? 'Версия #$childId' : 'Variant #$childId',
color: const Color(0xFF10B981),
onTap: () {
HapticFeedback.lightImpact();
onOpenPostId(childId);
},
),
],
),
],
),
);
}
}
class _RelationPill extends StatelessWidget {
const _RelationPill({
required this.icon,
required this.label,
required this.color,
required this.onTap,
});
final IconData icon;
final String label;
final Color color;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7),
decoration: BoxDecoration(
color: color.withValues(alpha: isDark ? 0.15 : 0.10),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: color.withValues(alpha: isDark ? 0.35 : 0.25),
width: 0.9,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 14, color: color),
const SizedBox(width: 6),
Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: color,
),
),
const SizedBox(width: 4),
Icon(Icons.arrow_forward_ios_rounded, size: 10, color: color),
],
),
),
),
);
}
}
@@ -358,6 +358,9 @@ class ProvidersScreen extends ConsumerWidget {
'/providers/new',
extra: item,
),
onSaveConfig: (updated) => ref
.read(providersControllerProvider.notifier)
.save(updated),
onDelete: () async {
final ok = await showConfirmDialog(
context,
@@ -0,0 +1,490 @@
import 'dart:convert';
import 'dart:ui';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../../../backend/models/content_provider_config.dart';
class E621AuthDialog extends StatefulWidget {
const E621AuthDialog({
required this.config,
required this.onSaved,
super.key,
});
final ContentProviderConfig config;
final ValueChanged<ContentProviderConfig> onSaved;
static Future<void> show(
BuildContext context, {
required ContentProviderConfig config,
required ValueChanged<ContentProviderConfig> onSaved,
}) {
return showDialog(
context: context,
barrierColor: Colors.black54,
builder: (context) => E621AuthDialog(
config: config,
onSaved: onSaved,
),
);
}
@override
State<E621AuthDialog> createState() => _E621AuthDialogState();
}
class _E621AuthDialogState extends State<E621AuthDialog> {
late final TextEditingController _loginController;
late final TextEditingController _apiKeyController;
bool _obscureKey = true;
bool _isLoading = false;
String? _statusMessage;
bool _isSuccess = false;
@override
void initState() {
super.initState();
final login = widget.config.customHeaders['query.login'] ?? '';
final apiKey = widget.config.customHeaders['query.api_key'] ?? '';
_loginController = TextEditingController(text: login);
_apiKeyController = TextEditingController(text: apiKey);
}
@override
void dispose() {
_loginController.dispose();
_apiKeyController.dispose();
super.dispose();
}
Future<void> _testAndSave() async {
final login = _loginController.text.trim();
final apiKey = _apiKeyController.text.trim();
if (login.isEmpty || apiKey.isEmpty) {
setState(() {
_statusMessage = 'Укажите логин и API ключ';
_isSuccess = false;
});
return;
}
setState(() {
_isLoading = true;
_statusMessage = null;
});
try {
final dio = Dio(
BaseOptions(
baseUrl: widget.config.baseUrl,
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 10),
headers: {
'Authorization':
'Basic ${base64Encode(utf8.encode('$login:$apiKey'))}',
'User-Agent': 'Prisma/3.6.6 (by $login on e621)',
'Accept': 'application/json',
},
),
);
final response = await dio.get<dynamic>('/posts.json', queryParameters: {
'limit': 1,
});
if (response.statusCode == 200) {
final updatedHeaders =
Map<String, String>.from(widget.config.customHeaders)
..['query.login'] = login
..['query.api_key'] = apiKey;
final updatedConfig = widget.config.copyWith(
customHeaders: updatedHeaders,
updatedAt: DateTime.now(),
);
widget.onSaved(updatedConfig);
setState(() {
_isLoading = false;
_isSuccess = true;
_statusMessage = 'Успешно авторизован! Лимит тегов снят.';
});
await Future<void>.delayed(const Duration(milliseconds: 650));
if (mounted) Navigator.of(context).pop();
} else {
setState(() {
_isLoading = false;
_isSuccess = false;
_statusMessage =
'Ошибка проверки (код ${response.statusCode}). Проверьте логин и ключ.';
});
}
} on DioException catch (e) {
setState(() {
_isLoading = false;
_isSuccess = false;
if (e.response?.statusCode == 401) {
_statusMessage = 'Неверный логин или API-ключ (401 Unauthorized)';
} else if (e.response?.statusCode == 403) {
_statusMessage = 'Доступ запрещен (403 Forbidden). Проверьте API access в профиле e621.';
} else {
_statusMessage = 'Ошибка подключения: ${e.message ?? e.toString()}';
}
});
} catch (e) {
setState(() {
_isLoading = false;
_isSuccess = false;
_statusMessage = 'Ошибка: $e';
});
}
}
void _clearAuth() {
final updatedHeaders =
Map<String, String>.from(widget.config.customHeaders)
..remove('query.login')
..remove('query.api_key');
final updatedConfig = widget.config.copyWith(
customHeaders: updatedHeaders,
updatedAt: DateTime.now(),
);
widget.onSaved(updatedConfig);
Navigator.of(context).pop();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
final currentLogin = widget.config.customHeaders['query.login'];
final isAlreadyAuthorized =
currentLogin != null && currentLogin.trim().isNotEmpty;
return Dialog(
backgroundColor: Colors.transparent,
insetPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 480),
child: ClipRRect(
borderRadius: BorderRadius.circular(28),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
child: Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: isDark
? const Color(0xFF1E2430).withValues(alpha: 0.90)
: Colors.white.withValues(alpha: 0.94),
borderRadius: BorderRadius.circular(28),
border: Border.all(
color: isDark
? const Color(0xFF0055AA).withValues(alpha: 0.40)
: const Color(0xFF0055AA).withValues(alpha: 0.20),
width: 1.2,
),
boxShadow: [
BoxShadow(
color: const Color(0xFF0055AA).withValues(alpha: 0.15),
blurRadius: 30,
offset: const Offset(0, 10),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Title row
Row(
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFF0055AA), Color(0xFF0077EE)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(14),
boxShadow: [
BoxShadow(
color: const Color(0xFF0055AA).withValues(alpha: 0.35),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: const Icon(
Icons.vpn_key_rounded,
color: Colors.white,
size: 22,
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Авторизация e621',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
letterSpacing: -0.2,
),
),
Text(
isAlreadyAuthorized
? 'Вход выполнен как $currentLogin'
: 'Снятие ограничений поиска и функций',
style: theme.textTheme.bodySmall?.copyWith(
color: isAlreadyAuthorized
? const Color(0xFF10B981)
: theme.colorScheme.onSurfaceVariant,
fontWeight: isAlreadyAuthorized
? FontWeight.w600
: FontWeight.normal,
),
),
],
),
),
IconButton(
icon: const Icon(Icons.close_rounded),
onPressed: () => Navigator.of(context).pop(),
visualDensity: VisualDensity.compact,
),
],
),
const SizedBox(height: 18),
// Benefits badge
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0xFF0055AA).withValues(alpha: isDark ? 0.15 : 0.08),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: const Color(0xFF0055AA).withValues(alpha: 0.25),
),
),
child: const Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_BenefitRow(
icon: Icons.check_circle_outline_rounded,
text: 'Снятие лимита в 2 тега (поиск по 4–6+ тегам)',
),
SizedBox(height: 6),
_BenefitRow(
icon: Icons.favorite_border_rounded,
text: 'Серверная синхронизация Избранного (Favorites)',
),
SizedBox(height: 6),
_BenefitRow(
icon: Icons.thumb_up_alt_outlined,
text: 'Голосование за посты (Upvote / Downvote)',
),
],
),
),
const SizedBox(height: 16),
// Username field
TextField(
controller: _loginController,
decoration: InputDecoration(
labelText: 'Имя пользователя (Username на e621)',
prefixIcon: const Icon(Icons.person_outline_rounded),
filled: true,
fillColor: isDark
? Colors.black.withValues(alpha: 0.25)
: Colors.grey.shade100,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide.none,
),
),
),
const SizedBox(height: 12),
// API Key field
TextField(
controller: _apiKeyController,
obscureText: _obscureKey,
decoration: InputDecoration(
labelText: 'API Ключ (API Key)',
prefixIcon: const Icon(Icons.key_rounded),
suffixIcon: IconButton(
icon: Icon(
_obscureKey
? Icons.visibility_off_rounded
: Icons.visibility_rounded,
),
onPressed: () =>
setState(() => _obscureKey = !_obscureKey),
),
filled: true,
fillColor: isDark
? Colors.black.withValues(alpha: 0.25)
: Colors.grey.shade100,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide.none,
),
),
),
const SizedBox(height: 10),
// How to get API key link
Align(
alignment: Alignment.centerLeft,
child: TextButton.icon(
onPressed: () {
launchUrl(
Uri.parse('https://e621.net/users/home'),
mode: LaunchMode.externalApplication,
);
},
icon: const Icon(Icons.open_in_new_rounded, size: 14),
label: const Text(
'Как получить ключ (e621 -> Manage API Access)',
style: TextStyle(fontSize: 12),
),
style: TextButton.styleFrom(
padding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
),
),
),
if (_statusMessage != null) ...[
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: _isSuccess
? const Color(0xFF10B981).withValues(alpha: 0.15)
: const Color(0xFFEF4444).withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: _isSuccess
? const Color(0xFF10B981).withValues(alpha: 0.40)
: const Color(0xFFEF4444).withValues(alpha: 0.40),
),
),
child: Row(
children: [
Icon(
_isSuccess
? Icons.check_circle_rounded
: Icons.error_outline_rounded,
size: 16,
color: _isSuccess
? const Color(0xFF10B981)
: const Color(0xFFEF4444),
),
const SizedBox(width: 8),
Expanded(
child: Text(
_statusMessage!,
style: TextStyle(
fontSize: 12,
color: _isSuccess
? const Color(0xFF10B981)
: const Color(0xFFEF4444),
fontWeight: FontWeight.w600,
),
),
),
],
),
),
],
const SizedBox(height: 20),
// Action buttons
Row(
children: [
if (isAlreadyAuthorized) ...[
TextButton.icon(
onPressed: _isLoading ? null : _clearAuth,
icon: const Icon(Icons.logout_rounded,
size: 16, color: Color(0xFFEF4444)),
label: const Text('Выйти',
style: TextStyle(color: Color(0xFFEF4444))),
),
const Spacer(),
] else
const Spacer(),
FilledButton.icon(
onPressed: _isLoading ? null : _testAndSave,
icon: _isLoading
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Icon(Icons.check_rounded, size: 18),
label: Text(_isLoading ? 'Проверка...' : 'Сохранить'),
style: FilledButton.styleFrom(
backgroundColor: const Color(0xFF0055AA),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
padding: const EdgeInsets.symmetric(
horizontal: 20, vertical: 12),
),
),
],
),
],
),
),
),
),
),
);
}
}
class _BenefitRow extends StatelessWidget {
const _BenefitRow({required this.icon, required this.text});
final IconData icon;
final String text;
@override
Widget build(BuildContext context) {
return Row(
children: [
Icon(icon, size: 14, color: const Color(0xFF0077EE)),
const SizedBox(width: 8),
Expanded(
child: Text(
text,
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w500),
),
),
],
);
}
}
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../../../backend/backend.dart';
import 'e621_auth_dialog.dart';
class ProviderCard extends StatelessWidget {
const ProviderCard({
@@ -9,6 +10,7 @@ class ProviderCard extends StatelessWidget {
required this.onToggle,
required this.onEdit,
required this.onDelete,
this.onSaveConfig,
super.key,
});
@@ -16,6 +18,7 @@ class ProviderCard extends StatelessWidget {
final ValueChanged<bool> onToggle;
final VoidCallback onEdit;
final VoidCallback onDelete;
final ValueChanged<ContentProviderConfig>? onSaveConfig;
@override
Widget build(BuildContext context) {
@@ -164,6 +167,16 @@ class ProviderCard extends StatelessWidget {
color: theme.colorScheme.primary,
isDark: isDark,
),
if (config.apiType.toLowerCase() == 'e621')
_GlassPill(
label: (config.customHeaders['query.login']?.isNotEmpty ?? false)
? 'e621: ${config.customHeaders['query.login']}'
: (isRu ? 'e621: Гость (макс 2 тега)' : 'e621: Guest (max 2 tags)'),
color: (config.customHeaders['query.login']?.isNotEmpty ?? false)
? const Color(0xFF10B981)
: Colors.amber,
isDark: isDark,
),
],
),
],
@@ -231,10 +244,30 @@ class ProviderCard extends StatelessWidget {
const SizedBox(height: 12),
// Row 3: Action Buttons (Edit & Delete)
// Row 3: Action Buttons (Edit & Delete & e621 Auth)
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
if (config.apiType.toLowerCase() == 'e621' && onSaveConfig != null) ...[
_GlassActionButton(
icon: Icons.vpn_key_rounded,
label: (config.customHeaders['query.login']?.isNotEmpty ?? false)
? (isRu ? 'Аккаунт' : 'Account')
: (isRu ? 'API Ключ' : 'API Key'),
color: const Color(0xFF0077EE),
isDark: isDark,
onTap: () {
HapticFeedback.lightImpact();
E621AuthDialog.show(
context,
config: config,
onSaved: onSaveConfig!,
);
},
),
const SizedBox(width: 8),
],
// Edit Action Button
_GlassActionButton(
icon: Icons.edit_rounded,
@@ -1,6 +1,7 @@
import 'dart:io';
import 'dart:ui';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart' hide appBuildNumber;
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -1439,7 +1440,7 @@ class _SettingsIconBadge extends StatelessWidget {
}
}
class _CategoryQuickNav extends StatelessWidget {
class _CategoryQuickNav extends StatefulWidget {
const _CategoryQuickNav({
required this.categories,
required this.onSelect,
@@ -1449,103 +1450,143 @@ class _CategoryQuickNav extends StatelessWidget {
categories;
final ValueChanged<GlobalKey> onSelect;
@override
State<_CategoryQuickNav> createState() => _CategoryQuickNavState();
}
class _CategoryQuickNavState extends State<_CategoryQuickNav> {
final ScrollController _scrollController = ScrollController();
bool _isHovered = false;
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
@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),
return MouseRegion(
onEnter: (_) => _isHovered = true,
onExit: (_) => _isHovered = false,
child: Listener(
onPointerSignal: (event) {
if (!_isHovered ||
event is! PointerScrollEvent ||
!_scrollController.hasClients) {
return;
}
final delta = event.scrollDelta.dy != 0
? event.scrollDelta.dy
: event.scrollDelta.dx;
if (delta == 0) return;
final target = (_scrollController.offset + delta * 1.5).clamp(
0.0,
_scrollController.position.maxScrollExtent,
);
_scrollController.jumpTo(target);
},
child: SizedBox(
height: 44,
child: ListView.separated(
controller: _scrollController,
padding: const EdgeInsets.symmetric(horizontal: 16),
scrollDirection: Axis.horizontal,
itemCount: widget.categories.length,
separatorBuilder: (_, __) => const SizedBox(width: 8),
itemBuilder: (context, index) {
final cat = widget.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(
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),
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),
onTap: () {
HapticFeedback.selectionClick();
widget.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),
),
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,
),
),
],
),
const SizedBox(width: 8),
Text(
cat.label,
style: theme.textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w600,
letterSpacing: -0.2,
),
),
],
),
),
),
),
),
),
),
);
},
);
},
),
),
),
);
}
@@ -427,6 +427,8 @@ class _ViewedPostTile extends StatelessWidget {
)
: CachedNetworkImage(
imageUrl: image,
memCacheWidth: 320,
memCacheHeight: 320,
fit: BoxFit.cover,
placeholder: (_, __) => Container(
color: scheme.surfaceContainerHighest,
+5
View File
@@ -1,9 +1,14 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:media_kit/media_kit.dart';
import 'app/app.dart';
void main() {
WidgetsFlutterBinding.ensureInitialized();
MediaKit.ensureInitialized();
// Optimize image cache to prevent memory bloat and OOM crashes
PaintingBinding.instance.imageCache.maximumSizeBytes = 180 * 1024 * 1024; // 180 MB
PaintingBinding.instance.imageCache.maximumSize = 120; // 120 images
runApp(const ProviderScope(child: GelRuleApp()));
}
+597 -87
View File
@@ -5,11 +5,12 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../app/app.dart';
import '../../app/motion.dart';
import '../../app/responsive.dart';
import '../../app/router.dart';
import '../../backend/backend.dart';
import '../../core/utils/result.dart';
import '../../features/feed/presentation/feed_controller.dart';
import 'desktop_shortcuts_dialog.dart';
class AppShell extends ConsumerStatefulWidget {
const AppShell({
@@ -249,21 +250,54 @@ class _AppShellState extends ConsumerState<AppShell> {
LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyF):
const _NavigateIntent('/search'),
LogicalKeySet(LogicalKeyboardKey.escape): const _BackIntent(),
LogicalKeySet(LogicalKeyboardKey.digit1): const _NavigateIntent('/'),
LogicalKeySet(LogicalKeyboardKey.digit2):
// Ctrl + 1..8
LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.digit1):
const _NavigateIntent('/'),
LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.digit2):
const _NavigateIntent('/search'),
LogicalKeySet(LogicalKeyboardKey.digit3):
LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.digit3):
const _NavigateIntent('/favorites'),
LogicalKeySet(LogicalKeyboardKey.digit4):
LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.digit4):
const _NavigateIntent('/viewed'),
LogicalKeySet(LogicalKeyboardKey.digit5):
LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.digit5):
const _NavigateIntent('/collections'),
LogicalKeySet(LogicalKeyboardKey.digit6):
LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.digit6):
const _NavigateIntent('/artists'),
LogicalKeySet(LogicalKeyboardKey.digit7):
LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.digit7):
const _NavigateIntent('/providers'),
LogicalKeySet(LogicalKeyboardKey.digit8):
LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.digit8):
const _NavigateIntent('/settings'),
// Alt + 1..8
LogicalKeySet(LogicalKeyboardKey.alt, LogicalKeyboardKey.digit1):
const _NavigateIntent('/'),
LogicalKeySet(LogicalKeyboardKey.alt, LogicalKeyboardKey.digit2):
const _NavigateIntent('/search'),
LogicalKeySet(LogicalKeyboardKey.alt, LogicalKeyboardKey.digit3):
const _NavigateIntent('/favorites'),
LogicalKeySet(LogicalKeyboardKey.alt, LogicalKeyboardKey.digit4):
const _NavigateIntent('/viewed'),
LogicalKeySet(LogicalKeyboardKey.alt, LogicalKeyboardKey.digit5):
const _NavigateIntent('/collections'),
LogicalKeySet(LogicalKeyboardKey.alt, LogicalKeyboardKey.digit6):
const _NavigateIntent('/artists'),
LogicalKeySet(LogicalKeyboardKey.alt, LogicalKeyboardKey.digit7):
const _NavigateIntent('/providers'),
LogicalKeySet(LogicalKeyboardKey.alt, LogicalKeyboardKey.digit8):
const _NavigateIntent('/settings'),
// Tab cycling
LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.tab):
const _CycleTabIntent(forward: true),
LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.shift, LogicalKeyboardKey.tab):
const _CycleTabIntent(forward: false),
// Refresh
LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyR):
const _RefreshIntent(),
LogicalKeySet(LogicalKeyboardKey.f5): const _RefreshIntent(),
// Shortcuts help cheat sheet
LogicalKeySet(LogicalKeyboardKey.question): const _HelpIntent(),
LogicalKeySet(LogicalKeyboardKey.f1): const _HelpIntent(),
LogicalKeySet(LogicalKeyboardKey.shift, LogicalKeyboardKey.slash):
const _HelpIntent(),
},
child: Actions(
actions: {
@@ -279,6 +313,33 @@ class _AppShellState extends ConsumerState<AppShell> {
return null;
},
),
_CycleTabIntent: CallbackAction<_CycleTabIntent>(
onInvoke: (intent) {
if (destinations.isEmpty) return null;
final currentBranch = widget.navigationShell?.currentIndex ?? 0;
final currentIdx = destinations.indexWhere(
(d) => AppShell.branchIndexForLocation(d.location) == currentBranch,
);
final safeIdx = currentIdx < 0 ? 0 : currentIdx;
final nextIdx = intent.forward
? ((safeIdx + 1) % destinations.length)
: ((safeIdx - 1 + destinations.length) % destinations.length);
_onNavigate(destinations[nextIdx].location);
return null;
},
),
_RefreshIntent: CallbackAction<_RefreshIntent>(
onInvoke: (_) {
ref.read(feedControllerProvider.notifier).refresh();
return null;
},
),
_HelpIntent: CallbackAction<_HelpIntent>(
onInvoke: (_) {
DesktopShortcutsDialog.show(context);
return null;
},
),
},
child: Focus(
autofocus: true,
@@ -312,6 +373,7 @@ class _DesktopShell extends StatefulWidget {
required this.onNavigate,
this.currentBranchIndex,
});
final Widget child;
final List<_Destination> destinations;
final bool ru;
@@ -322,54 +384,416 @@ class _DesktopShell extends StatefulWidget {
State<_DesktopShell> createState() => _DesktopShellState();
}
class _DesktopShellState extends State<_DesktopShell> {
class _DesktopShellState extends State<_DesktopShell>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
late final Animation<double> _animation;
bool _hovered = false;
bool _pinned = false;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 220),
);
_animation = CurvedAnimation(
parent: _controller,
curve: Curves.easeOutCubic,
reverseCurve: Curves.easeInCubic,
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _handleHover(bool hovered) {
if (_hovered == hovered) return;
setState(() => _hovered = hovered);
if (_pinned) return;
if (hovered) {
_controller.forward();
} else {
_controller.reverse();
}
}
void _togglePinned() {
setState(() {
_pinned = !_pinned;
if (_pinned) {
_controller.forward();
} else if (!_hovered) {
_controller.reverse();
}
});
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final isDark = theme.brightness == Brightness.dark;
final location = GoRouterState.of(context).uri.path;
final activeBranch = widget.currentBranchIndex ??
AppShell.branchIndexForLocation(location);
final selected = widget.destinations.indexWhere(
(item) => AppShell.branchIndexForLocation(item.location) == activeBranch,
);
return Scaffold(
body: Row(
children: [
MouseRegion(
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: AnimatedContainer(
duration: AppMotion.duration(context, 180),
curve: Curves.easeOutCubic,
width: _hovered ? 256 : 72,
color: Theme.of(context).navigationRailTheme.backgroundColor,
child: SafeArea(
child: Padding(
padding:
const EdgeInsets.symmetric(vertical: 12, horizontal: 8),
child: Column(
children: [
for (var index = 0;
index < widget.destinations.length;
index++)
_RailButton(
destination: widget.destinations[index],
ru: widget.ru,
selected: (selected < 0 ? 0 : selected) == index,
expanded: _hovered,
onTap: () {
widget.onNavigate(widget.destinations[index].location);
},
),
const Spacer(),
onEnter: (_) => _handleHover(true),
onExit: (_) => _handleHover(false),
child: AnimatedBuilder(
animation: _animation,
builder: (context, _) {
final progress = _animation.value;
final width = lerpDouble(74.0, 260.0, progress)!;
final contentOpacity =
(progress - 0.25).clamp(0.0, 0.75) / 0.75;
final isFullyExpanded = progress > 0.85;
return Container(
width: width,
decoration: BoxDecoration(
color: isDark
? scheme.surfaceContainerHigh
: scheme.surface,
border: Border(
right: BorderSide(
color: isDark
? Colors.white.withValues(alpha: 0.1)
: scheme.outlineVariant.withValues(alpha: 0.35),
width: 1,
),
),
boxShadow: [
BoxShadow(
color:
Colors.black.withValues(alpha: isDark ? 0.3 : 0.05),
blurRadius: 16,
offset: const Offset(3, 0),
),
],
),
),
),
child: ClipRect(
child: OverflowBox(
minWidth: 260,
maxWidth: 260,
alignment: Alignment.topLeft,
child: SizedBox(
width: 260,
child: SafeArea(
child: Column(
children: [
// App Brand Header
Padding(
padding:
const EdgeInsets.fromLTRB(15, 16, 12, 14),
child: SizedBox(
height: 44,
child: Row(
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
scheme.primary,
scheme.tertiary,
],
),
borderRadius:
BorderRadius.circular(14),
boxShadow: [
BoxShadow(
color: scheme.primary
.withValues(alpha: 0.35),
blurRadius: 12,
offset: const Offset(0, 3),
),
],
),
child: const Center(
child: Icon(
Icons.auto_awesome_rounded,
color: Colors.white,
size: 24,
),
),
),
const SizedBox(width: 12),
Expanded(
child: Opacity(
opacity: contentOpacity,
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
mainAxisAlignment:
MainAxisAlignment.center,
children: [
Text(
'Lunaris',
maxLines: 1,
softWrap: false,
style: theme
.textTheme
.titleMedium
?.copyWith(
fontWeight:
FontWeight.w800,
letterSpacing: 0.2,
),
),
const SizedBox(height: 2),
Container(
padding: const EdgeInsets
.symmetric(
horizontal: 6,
vertical: 1.5,
),
decoration: BoxDecoration(
color: scheme.primary
.withValues(
alpha: 0.15),
borderRadius:
BorderRadius
.circular(6),
border: Border.all(
color: scheme.primary
.withValues(
alpha: 0.3),
width: 0.8,
),
),
child: Text(
'v3.7.2',
style: TextStyle(
fontSize: 10,
fontWeight:
FontWeight.bold,
color: scheme.primary,
letterSpacing: 0.4,
),
),
),
],
),
),
IconButton(
visualDensity:
VisualDensity.compact,
tooltip: _pinned
? (widget.ru
? 'Открепить панель'
: 'Unpin panel')
: (widget.ru
? 'Закрепить панель'
: 'Pin panel'),
onPressed: _togglePinned,
icon: Icon(
_pinned
? Icons.push_pin_rounded
: Icons.push_pin_outlined,
size: 19,
color: _pinned
? scheme.primary
: scheme
.onSurfaceVariant,
),
),
],
),
),
),
],
),
),
),
const Divider(height: 1),
const SizedBox(height: 8),
// Destinations List
Expanded(
child: ListView(
padding:
const EdgeInsets.symmetric(horizontal: 8),
children: [
for (var index = 0;
index < widget.destinations.length;
index++)
_RailButton(
destination: widget.destinations[index],
ru: widget.ru,
selected: (selected < 0 ? 0 : selected) ==
index,
contentOpacity: contentOpacity,
isFullyExpanded: isFullyExpanded,
shortcutKey: 'Ctrl+${index + 1}',
onTap: () {
widget.onNavigate(
widget.destinations[index]
.location);
},
),
],
),
),
// Bottom Actions
const Divider(height: 1),
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 8),
child: SizedBox(
width: 244,
child: Row(
children: [
Expanded(
child: Tooltip(
message: isFullyExpanded
? ''
: (widget.ru
? 'Горячие клавиши (F1 / ?)'
: 'Keyboard Shortcuts (F1 / ?)'),
waitDuration: const Duration(
milliseconds: 350),
child: Material(
color: Colors.transparent,
borderRadius:
BorderRadius.circular(14),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: () =>
DesktopShortcutsDialog.show(
context),
child: SizedBox(
height: 44,
child: Row(
children: [
SizedBox(
width: 58,
child: Icon(
Icons.keyboard_rounded,
size: 22,
color: scheme
.onSurfaceVariant,
),
),
Expanded(
child: Opacity(
opacity: contentOpacity,
child: Row(
children: [
Expanded(
child: Text(
widget.ru
? 'Горячие клавиши'
: 'Shortcuts',
maxLines: 1,
softWrap: false,
overflow:
TextOverflow
.ellipsis,
style:
TextStyle(
fontWeight:
FontWeight
.w600,
fontSize: 13,
color: scheme
.onSurface,
),
),
),
Container(
padding:
const EdgeInsets
.symmetric(
horizontal: 6,
vertical: 2,
),
decoration:
BoxDecoration(
color: isDark
? Colors
.white
.withValues(
alpha:
0.1)
: Colors
.black
.withValues(
alpha:
0.06),
borderRadius:
BorderRadius
.circular(
6),
),
child: const Text(
'?',
style:
TextStyle(
fontSize: 11,
fontWeight:
FontWeight
.bold,
fontFamily:
'monospace',
),
),
),
const SizedBox(
width: 8),
],
),
),
),
],
),
),
),
),
),
),
if (!_pinned && progress < 0.4)
IconButton(
tooltip: widget.ru
? 'Закрепить панель'
: 'Pin sidebar',
onPressed: _togglePinned,
icon: const Icon(
Icons.chevron_right_rounded,
size: 20),
),
],
),
),
),
],
),
),
),
),
),
);
},
),
),
const VerticalDivider(width: 1),
Expanded(child: widget.child),
],
),
@@ -381,75 +805,148 @@ class _RailButton extends StatelessWidget {
const _RailButton({
required this.destination,
required this.selected,
required this.expanded,
required this.contentOpacity,
required this.isFullyExpanded,
required this.ru,
required this.shortcutKey,
required this.onTap,
});
final _Destination destination;
final bool selected;
final bool expanded;
final double contentOpacity;
final bool isFullyExpanded;
final bool ru;
final String shortcutKey;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final isDark = theme.brightness == Brightness.dark;
final label = destination.labelFor(ru);
return Padding(
padding: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.symmetric(vertical: 2),
child: Tooltip(
message: expanded ? '' : destination.labelFor(ru),
waitDuration: const Duration(milliseconds: 450),
message: isFullyExpanded ? '' : '$label ($shortcutKey)',
waitDuration: const Duration(milliseconds: 350),
child: Material(
color: selected ? colors.primaryContainer : Colors.transparent,
borderRadius: BorderRadius.circular(28),
color: selected
? scheme.primary.withValues(alpha: isDark ? 0.22 : 0.12)
: Colors.transparent,
borderRadius: BorderRadius.circular(14),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: SizedBox(
height: 48,
child: Row(
children: [
SizedBox(
width: 56,
child: Icon(
destination.icon,
color: selected
? colors.onPrimaryContainer
: colors.onSurfaceVariant,
width: 244,
height: 46,
child: DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(14),
border: selected
? Border.all(
color: scheme.primary
.withValues(alpha: isDark ? 0.45 : 0.28),
width: 1.1,
)
: null,
),
child: Row(
children: [
// Active left indicator bar
Container(
width: 3.5,
height: 22,
decoration: BoxDecoration(
color: selected ? scheme.primary : Colors.transparent,
borderRadius: BorderRadius.circular(2),
boxShadow: selected
? [
BoxShadow(
color: scheme.primary.withValues(alpha: 0.6),
blurRadius: 6,
),
]
: null,
),
),
),
if (expanded)
Expanded(
child: ClipRect(
child: AnimatedOpacity(
opacity: 1,
duration: AppMotion.duration(context, 120),
curve: Curves.easeOut,
child: Align(
alignment: Alignment.centerLeft,
child: Text(
destination.labelFor(ru),
maxLines: 1,
overflow: TextOverflow.clip,
softWrap: false,
style: TextStyle(
fontWeight: selected
? FontWeight.w700
: FontWeight.w600,
color: selected
? colors.onPrimaryContainer
: colors.onSurface,
),
),
),
SizedBox(
width: 54.5,
child: Center(
child: Icon(
destination.icon,
size: 22,
color: selected
? scheme.primary
: (isDark
? scheme.onSurfaceVariant
.withValues(alpha: 0.85)
: scheme.onSurfaceVariant),
),
),
)
else
const SizedBox.shrink(),
if (expanded) const SizedBox(width: 12),
],
),
Expanded(
child: Opacity(
opacity: contentOpacity,
child: Row(
children: [
Expanded(
child: Text(
label,
maxLines: 1,
softWrap: false,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 13.5,
fontWeight: selected
? FontWeight.w700
: FontWeight.w500,
color: selected
? scheme.primary
: scheme.onSurface,
),
),
),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 5.5,
vertical: 2,
),
decoration: BoxDecoration(
color: isDark
? Colors.white.withValues(alpha: 0.08)
: Colors.black.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(6),
border: Border.all(
color: isDark
? Colors.white.withValues(alpha: 0.12)
: Colors.black.withValues(alpha: 0.08),
width: 0.8,
),
),
child: Text(
shortcutKey,
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
fontFamily: 'monospace',
color: selected
? scheme.primary
: scheme.onSurfaceVariant
.withValues(alpha: 0.8),
),
),
),
const SizedBox(width: 8),
],
),
),
),
],
),
),
),
),
@@ -945,3 +1442,16 @@ class _NavigateIntent extends Intent {
class _BackIntent extends Intent {
const _BackIntent();
}
class _CycleTabIntent extends Intent {
const _CycleTabIntent({required this.forward});
final bool forward;
}
class _RefreshIntent extends Intent {
const _RefreshIntent();
}
class _HelpIntent extends Intent {
const _HelpIntent();
}
@@ -0,0 +1,321 @@
import 'dart:ui';
import 'package:flutter/material.dart';
class DesktopShortcutsDialog extends StatelessWidget {
const DesktopShortcutsDialog({super.key});
static Future<void> show(BuildContext context) {
return showDialog(
context: context,
barrierColor: Colors.black54,
builder: (_) => const DesktopShortcutsDialog(),
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final isDark = theme.brightness == Brightness.dark;
return Dialog(
backgroundColor: Colors.transparent,
insetPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 720, maxHeight: 680),
child: ClipRRect(
borderRadius: BorderRadius.circular(24),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
child: Container(
decoration: BoxDecoration(
color: isDark
? scheme.surfaceContainerHigh.withValues(alpha: 0.88)
: Colors.white.withValues(alpha: 0.94),
borderRadius: BorderRadius.circular(24),
border: Border.all(
color: isDark
? Colors.white.withValues(alpha: 0.16)
: scheme.outlineVariant.withValues(alpha: 0.4),
width: 1.2,
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: isDark ? 0.45 : 0.12),
blurRadius: 32,
offset: const Offset(0, 12),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Header
Padding(
padding: const EdgeInsets.fromLTRB(24, 20, 16, 16),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: scheme.primaryContainer.withValues(alpha: 0.7),
borderRadius: BorderRadius.circular(14),
),
child: Icon(
Icons.keyboard_rounded,
color: scheme.primary,
size: 24,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Горячие клавиши',
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 2),
Text(
'Быстрое управление с клавиатуры для ПК',
style: theme.textTheme.bodyMedium?.copyWith(
color: scheme.onSurfaceVariant,
),
),
],
),
),
IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.close_rounded),
tooltip: 'Закрыть (Esc)',
),
],
),
),
const Divider(height: 1),
// Shortcuts list
Flexible(
child: ListView(
padding: const EdgeInsets.fromLTRB(24, 16, 24, 24),
children: const [
_ShortcutSection(
icon: Icons.navigation_rounded,
title: 'Общие и навигация',
items: [
_ShortcutRow(['Ctrl', '1…8'], 'Переключение между вкладками'),
_ShortcutRow(['Ctrl', 'Tab'], 'Следующая вкладка'),
_ShortcutRow(['Ctrl', 'Shift', 'Tab'], 'Предыдущая вкладка'),
_ShortcutRow(['Ctrl', 'F'], 'Перейти к поиску / строка поиска'),
_ShortcutRow(['Ctrl', 'R'], 'Обновить ленту / страницу'),
_ShortcutRow(['F5'], 'Обновить страницу'),
_ShortcutRow(['Esc'], 'Закрыть окно / назад / сбросить выбор'),
_ShortcutRow(['?'], 'Открыть это окно горячих клавиш'),
],
),
SizedBox(height: 20),
_ShortcutSection(
icon: Icons.smart_display_rounded,
title: 'Видеоплеер',
items: [
_ShortcutRow(['Пробел'], 'Воспроизведение / Пауза'),
_ShortcutRow(['←', '→'], 'Перемотка на 5 секунд назад / вперед'),
_ShortcutRow(['Shift', '← / →'], 'Перемотка на 15 секунд'),
_ShortcutRow(['↑', '↓'], 'Регулировка громкости (±5%)'),
_ShortcutRow(['M'], 'Включить / выключить звук'),
_ShortcutRow(['F'], 'Полноэкранный режим (Full Screen)'),
_ShortcutRow(['L'], 'Повтор видео (Loop on/off)'),
_ShortcutRow(['Esc'], 'Выйти из полноэкранного режима'),
],
),
SizedBox(height: 20),
_ShortcutSection(
icon: Icons.photo_library_rounded,
title: 'Фото и просмотр постов',
items: [
_ShortcutRow(['←', '→'], 'Предыдущее / следующее фото или пост'),
_ShortcutRow(['+'], 'Увеличить масштаб (Zoom In)'),
_ShortcutRow(['-'], 'Уменьшить масштаб (Zoom Out)'),
_ShortcutRow(['0'], 'Сбросить масштаб (100%)'),
_ShortcutRow(['F'], 'Подгонка по экрану / на весь экран'),
_ShortcutRow(['D'], 'Скачать текущее изображение'),
_ShortcutRow(['Ctrl', 'S'], 'Сохранить / Скачать файл'),
_ShortcutRow(['Esc'], 'Сброс зума / назад'),
],
),
SizedBox(height: 20),
_ShortcutSection(
icon: Icons.grid_view_rounded,
title: 'Лента и посты',
items: [
_ShortcutRow(['R'], 'Открыть случайный пост'),
_ShortcutRow(['V'], 'Включить/выключить режим выбора постов'),
],
),
],
),
),
],
),
),
),
),
),
);
}
}
class _ShortcutSection extends StatelessWidget {
const _ShortcutSection({
required this.icon,
required this.title,
required this.items,
});
final IconData icon;
final String title;
final List<_ShortcutRow> items;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final isDark = theme.brightness == Brightness.dark;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(icon, size: 18, color: scheme.primary),
const SizedBox(width: 8),
Text(
title,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
color: scheme.primary,
letterSpacing: 0.3,
),
),
],
),
const SizedBox(height: 8),
Container(
decoration: BoxDecoration(
color: isDark
? scheme.surface.withValues(alpha: 0.6)
: scheme.surfaceContainerHighest.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: isDark
? Colors.white.withValues(alpha: 0.08)
: Colors.black.withValues(alpha: 0.04),
),
),
child: Column(
children: [
for (var i = 0; i < items.length; i++) ...[
if (i > 0)
Divider(
height: 1,
thickness: 1,
color: isDark
? Colors.white.withValues(alpha: 0.05)
: Colors.black.withValues(alpha: 0.04),
),
items[i],
],
],
),
),
],
);
}
}
class _ShortcutRow extends StatelessWidget {
const _ShortcutRow(this.keys, this.description);
final List<String> keys;
final String description;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
child: Row(
children: [
Expanded(
child: Text(
description,
style: theme.textTheme.bodyMedium?.copyWith(
color: scheme.onSurface,
fontWeight: FontWeight.w500,
),
),
),
const SizedBox(width: 12),
Wrap(
spacing: 4,
runSpacing: 4,
children: keys.map((key) => _KeyCap(key)).toList(growable: false),
),
],
),
);
}
}
class _KeyCap extends StatelessWidget {
const _KeyCap(this.text);
final String text;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
final scheme = theme.colorScheme;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: isDark
? scheme.surfaceContainerHighest.withValues(alpha: 0.9)
: Colors.white,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: isDark
? Colors.white.withValues(alpha: 0.2)
: Colors.black.withValues(alpha: 0.15),
width: 1,
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: isDark ? 0.4 : 0.08),
offset: const Offset(0, 1.5),
blurRadius: 1,
),
],
),
child: Text(
text,
style: TextStyle(
fontFamily: 'monospace',
fontSize: 12,
fontWeight: FontWeight.bold,
color: scheme.onSurface,
letterSpacing: 0.2,
),
),
);
}
}
+11 -2
View File
@@ -1,5 +1,6 @@
import 'dart:async';
import 'dart:collection';
import 'dart:io';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
@@ -232,7 +233,7 @@ class _PostCardState extends ConsumerState<PostCard>
}
final dpr = MediaQuery.devicePixelRatioOf(context);
final cacheWidth =
(constraints.maxWidth * dpr).round().clamp(320, 1800);
(constraints.maxWidth * dpr).round().clamp(280, 960);
final headers = _headersFor(post);
final videoUrls = MediaUrlSelector.video(post);
if (!mobile &&
@@ -590,7 +591,15 @@ class _FeedVideoPreviewState extends State<_FeedVideoPreview> {
super.initState();
MediaKit.ensureInitialized();
_player = Player();
_controller = VideoController(_player);
final isDesktop =
Platform.isLinux || Platform.isWindows || Platform.isMacOS;
_controller = VideoController(
_player,
configuration: VideoControllerConfiguration(
enableHardwareAcceleration: !isDesktop,
hwdec: isDesktop ? 'no' : 'auto-safe',
),
);
_player.stream.error.listen((_) {
if (mounted) setState(() => _failed = true);
});
+20 -18
View File
@@ -68,24 +68,26 @@ class PostMasonryGrid extends StatelessWidget {
final spacing = mobile ? 8.0 : 12.0;
Widget buildCard(Post post) {
return PostCard(
post: post,
blurExplicit: blurExplicit && !nsfwEnabled,
showBadges: showBadges,
isFavorite: favoriteKeys.contains(post.cacheKey),
isViewed: viewedKeys.contains(post.cacheKey),
isDownloaded: downloadedKeys.contains(post.cacheKey),
mediaQualityMode: mediaQualityMode,
onOpen: () => onOpen(post),
onFavorite: () => onFavorite(post),
onPreview: onPreview == null ? null : () => onPreview!(post),
onHide: onHide == null ? null : () => onHide!(post),
selectionMode: selectionMode,
selected: selectedKeys.contains(post.cacheKey),
onToggleSelected:
onToggleSelected == null ? null : () => onToggleSelected!(post),
onAddToCollection:
onAddToCollection == null ? null : () => onAddToCollection!(post),
return RepaintBoundary(
child: PostCard(
post: post,
blurExplicit: blurExplicit && !nsfwEnabled,
showBadges: showBadges,
isFavorite: favoriteKeys.contains(post.cacheKey),
isViewed: viewedKeys.contains(post.cacheKey),
isDownloaded: downloadedKeys.contains(post.cacheKey),
mediaQualityMode: mediaQualityMode,
onOpen: () => onOpen(post),
onFavorite: () => onFavorite(post),
onPreview: onPreview == null ? null : () => onPreview!(post),
onHide: onHide == null ? null : () => onHide!(post),
selectionMode: selectionMode,
selected: selectedKeys.contains(post.cacheKey),
onToggleSelected:
onToggleSelected == null ? null : () => onToggleSelected!(post),
onAddToCollection:
onAddToCollection == null ? null : () => onAddToCollection!(post),
),
);
}
+1 -1
View File
@@ -1,7 +1,7 @@
name: gel_rule_app
description: Local backend/core layer for a Flutter booru browser app.
publish_to: "none"
version: 3.5.5+81
version: 3.7.2+90
environment:
sdk: ">=3.3.0 <4.0.0"