Compare commits

...
8 Commits
Author SHA1 Message Date
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
30 changed files with 3922 additions and 576 deletions
+39 -38
View File
@@ -4,32 +4,33 @@
<application <application
android:label="Prisma" android:label="Prisma"
android:name="${applicationName}" android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"> android:icon="@mipmap/ic_launcher"
<activity android:largeHeap="true">
android:name=".MainActivity" <activity
android:exported="true" android:name=".MainActivity"
android:launchMode="singleTop" android:exported="true"
android:taskAffinity="" android:launchMode="singleTop"
android:theme="@style/LaunchTheme" android:taskAffinity=""
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" android:theme="@style/LaunchTheme"
android:hardwareAccelerated="true" android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:windowSoftInputMode="adjustResize"> android:hardwareAccelerated="true"
<!-- Specifies an Android theme to apply to this Activity as soon as android:windowSoftInputMode="adjustResize">
the Android process has started. This theme is visible to the user <!-- Specifies an Android theme to apply to this Activity as soon as
while the Flutter UI initializes. After that, this theme continues the Android process has started. This theme is visible to the user
to determine the Window background behind the Flutter UI. --> while the Flutter UI initializes. After that, this theme continues
<meta-data to determine the Window background behind the Flutter UI. -->
android:name="io.flutter.embedding.android.NormalTheme" <meta-data
android:resource="@style/NormalTheme" android:name="io.flutter.embedding.android.NormalTheme"
/> android:resource="@style/NormalTheme"
<intent-filter> />
<action android:name="android.intent.action.MAIN"/> <intent-filter>
<category android:name="android.intent.category.LAUNCHER"/> <action android:name="android.intent.action.MAIN"/>
</intent-filter> <category android:name="android.intent.category.LAUNCHER"/>
</activity> </intent-filter>
<!-- Don't delete the meta-data below. </activity>
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java --> <!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data <meta-data
android:name="flutterEmbedding" android:name="flutterEmbedding"
android:value="2" /> android:value="2" />
@@ -43,15 +44,15 @@
android:resource="@xml/file_paths" /> android:resource="@xml/file_paths" />
</provider> </provider>
</application> </application>
<!-- Required to query activities that can process text, see: <!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT. 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. --> In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries> <queries>
<intent> <intent>
<action android:name="android.intent.action.PROCESS_TEXT"/> <action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/> <data android:mimeType="text/plain"/>
</intent> </intent>
</queries> </queries>
</manifest> </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}"
+69
View File
@@ -1,4 +1,73 @@
const prismaChangelog = [ const prismaChangelog = [
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( PrismaChange(
version: '3.5.5', version: '3.5.5',
title: 'Сохранение последней вкладки и состояния навигации между разделами', title: 'Сохранение последней вкладки и состояния навигации между разделами',
+32
View File
@@ -36,6 +36,38 @@ class E621Mapper {
for (final entry in tagsMap.entries) for (final entry in tagsMap.entries)
entry.key: List<String>.from((entry.value as List?) ?? const []), entry.key: List<String>.from((entry.value as List?) ?? const []),
}..removeWhere((_, value) => value.isEmpty); }..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 tags = tagGroups.values.expand((items) => items).toSet().toList();
final fileUrl = _string(file['url']); final fileUrl = _string(file['url']);
return Post( 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 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 { String? get title {
final list = tagGroups['copyright']; final list = tagGroups['copyright'];
if (list != null && list.isNotEmpty) { if (list != null && list.isNotEmpty) {
+80
View File
@@ -2,6 +2,7 @@ import 'package:dio/dio.dart';
import '../../core/http/dio_client.dart'; import '../../core/http/dio_client.dart';
import '../mappers/e621_mapper.dart'; import '../mappers/e621_mapper.dart';
import '../models/e621_pool.dart';
import '../models/post.dart'; import '../models/post.dart';
import '../models/post_comment.dart'; import '../models/post_comment.dart';
import '../models/provider_health.dart'; import '../models/provider_health.dart';
@@ -33,6 +34,24 @@ class E621Provider
final Dio _dio; final Dio _dio;
final Map<String, String> _queryParameters; 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') ||
_dio.options.headers.containsKey('Authorization');
String? get login => _queryParameters['login'];
@override @override
String postPageUrl(Post post) => '$baseUrl/posts/${post.id}'; String postPageUrl(Post post) => '$baseUrl/posts/${post.id}';
@@ -44,6 +63,7 @@ class E621Provider
String? rating, String? rating,
TopPeriodFilter topPeriod = TopPeriodFilter.none, TopPeriodFilter topPeriod = TopPeriodFilter.none,
}) async { }) async {
await _throttle();
final response = await _dio.get<dynamic>( final response = await _dio.get<dynamic>(
'/posts.json', '/posts.json',
queryParameters: { queryParameters: {
@@ -66,6 +86,7 @@ class E621Provider
@override @override
Future<Post?> getPost(String id) async { Future<Post?> getPost(String id) async {
await _throttle();
final response = await _dio.get<dynamic>( final response = await _dio.get<dynamic>(
'/posts/$id.json', '/posts/$id.json',
queryParameters: _queryParameters, queryParameters: _queryParameters,
@@ -78,6 +99,63 @@ class E621Provider
return posts.isEmpty ? null : posts.first; 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<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 @override
Future<ProviderHealth> checkHealth() async { Future<ProviderHealth> checkHealth() async {
final startedAt = DateTime.now(); final startedAt = DateTime.now();
@@ -109,6 +187,7 @@ class E621Provider
Future<List<TagSuggestion>> suggestTags(String query, Future<List<TagSuggestion>> suggestTags(String query,
{int limit = 20}) async { {int limit = 20}) async {
if (query.trim().isEmpty) return const []; if (query.trim().isEmpty) return const [];
await _throttle();
final response = await _dio.get<dynamic>( final response = await _dio.get<dynamic>(
'/tags.json', '/tags.json',
queryParameters: { queryParameters: {
@@ -136,6 +215,7 @@ class E621Provider
@override @override
Future<List<PostComment>> getComments(String postId) async { Future<List<PostComment>> getComments(String postId) async {
await _throttle();
final response = await _dio.get<dynamic>( final response = await _dio.get<dynamic>(
'/comments.json', '/comments.json',
queryParameters: { queryParameters: {
@@ -1,3 +1,5 @@
import 'dart:convert';
import '../../core/http/dio_client.dart'; import '../../core/http/dio_client.dart';
import '../models/content_provider_config.dart'; import '../models/content_provider_config.dart';
import 'content_provider.dart'; import 'content_provider.dart';
@@ -20,6 +22,17 @@ class ProviderFactory {
if (entry.key.startsWith('query.') && entry.value.trim().isNotEmpty) if (entry.key.startsWith('query.') && entry.value.trim().isNotEmpty)
entry.key.substring('query.'.length): entry.value.trim(), 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( final client = DioClient(
baseUrl: config.baseUrl, baseUrl: config.baseUrl,
timeout: Duration(seconds: config.timeoutSeconds), 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 { Future<Result<List<ProviderHealth>>> checkAll({int concurrency = 3}) async {
final providersResult = await activeProviders(); final providersResult = await activeProviders();
if (providersResult is Error<List<ContentProvider>>) { if (providersResult is Error<List<ContentProvider>>) {
@@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:ui'; import 'dart:ui';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
@@ -1328,7 +1329,7 @@ class _FavoriteArtistsModalState extends State<_FavoriteArtistsModal> {
} }
} }
class _FavoriteArtistsSection extends ConsumerWidget { class _FavoriteArtistsSection extends StatefulWidget {
const _FavoriteArtistsSection({ const _FavoriteArtistsSection({
required this.favorites, required this.favorites,
required this.selectedKey, required this.selectedKey,
@@ -1344,8 +1345,23 @@ class _FavoriteArtistsSection extends ConsumerWidget {
final ValueChanged<FavoriteArtistItem> onRemoveFavorite; final ValueChanged<FavoriteArtistItem> onRemoveFavorite;
@override @override
Widget build(BuildContext context, WidgetRef ref) { State<_FavoriteArtistsSection> createState() =>
if (favorites.isEmpty) { _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( return Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 8), padding: const EdgeInsets.fromLTRB(16, 4, 16, 8),
child: _ArtistsLiquidCard( child: _ArtistsLiquidCard(
@@ -1368,9 +1384,9 @@ class _FavoriteArtistsSection extends ConsumerWidget {
); );
} }
final selected = favorites.firstWhere( final selected = widget.favorites.firstWhere(
(f) => f.key == selectedKey, (f) => f.key == widget.selectedKey,
orElse: () => favorites.first, orElse: () => widget.favorites.first,
); );
return Padding( return Padding(
@@ -1385,14 +1401,14 @@ class _FavoriteArtistsSection extends ConsumerWidget {
const Icon(Icons.star_rounded, color: Colors.amber, size: 20), const Icon(Icons.star_rounded, color: Colors.amber, size: 20),
const SizedBox(width: 6), const SizedBox(width: 6),
Text( Text(
'Любимые авторы (${favorites.length})', 'Любимые авторы (${widget.favorites.length})',
style: Theme.of(context).textTheme.titleSmall?.copyWith( style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
), ),
const Spacer(), const Spacer(),
TextButton( TextButton(
onPressed: () => onOpenArtist(selected), onPressed: () => widget.onOpenArtist(selected),
style: TextButton.styleFrom( style: TextButton.styleFrom(
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
padding: const EdgeInsets.symmetric(horizontal: 8), padding: const EdgeInsets.symmetric(horizontal: 8),
@@ -1409,111 +1425,136 @@ class _FavoriteArtistsSection extends ConsumerWidget {
], ],
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
SizedBox( MouseRegion(
height: 88, onEnter: (_) => _isHovered = true,
child: ListView.separated( onExit: (_) => _isHovered = false,
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4), child: Listener(
scrollDirection: Axis.horizontal, onPointerSignal: (event) {
itemCount: favorites.length, if (!_isHovered ||
separatorBuilder: (_, __) => const SizedBox(width: 10), event is! PointerScrollEvent ||
itemBuilder: (context, index) { !_scrollController.hasClients) {
final fav = favorites[index]; return;
final isSelected = fav.key == selected.key; }
final sColor = _serviceColor(fav.service); final delta = event.scrollDelta.dy != 0
? event.scrollDelta.dy
return InkWell( : event.scrollDelta.dx;
borderRadius: BorderRadius.circular(16), if (delta == 0) return;
onTap: () => onSelect(fav), final target = (_scrollController.offset + delta * 1.5).clamp(
child: AnimatedContainer( 0.0,
duration: const Duration(milliseconds: 200), _scrollController.position.maxScrollExtent,
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,
),
),
),
],
),
),
); );
_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), 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}); const _FavoriteArtistMediaStrip({required this.artist});
final FavoriteArtistItem artist; final FavoriteArtistItem artist;
@override @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( final query = ArtistWorkQuery(
providerId: artist.providerId, providerId: widget.artist.providerId,
service: artist.service, service: widget.artist.service,
artistId: artist.id, artistId: widget.artist.id,
artistName: artist.name, artistName: widget.artist.name,
); );
final asyncPosts = ref.watch(artistPostsProvider(query)); final asyncPosts = ref.watch(artistPostsProvider(query));
@@ -1564,76 +1621,101 @@ class _FavoriteArtistMediaStrip extends ConsumerWidget {
), ),
); );
} }
return SizedBox( return MouseRegion(
height: 96, onEnter: (_) => _isHovered = true,
child: ListView.separated( onExit: (_) => _isHovered = false,
scrollDirection: Axis.horizontal, child: Listener(
itemCount: posts.length.clamp(0, 15), onPointerSignal: (event) {
separatorBuilder: (_, __) => const SizedBox(width: 8), if (!_isHovered ||
itemBuilder: (context, index) { event is! PointerScrollEvent ||
final post = posts[index]; !_scrollController.hasClients) {
final isVideo = MediaUrlSelector.isVideo(post); return;
return InkWell( }
borderRadius: BorderRadius.circular(14), final delta = event.scrollDelta.dy != 0
onTap: () => AppNavigator.openPost( ? event.scrollDelta.dy
context, : event.scrollDelta.dx;
post: post, if (delta == 0) return;
postsList: posts, final target = (_scrollController.offset + delta * 1.5).clamp(
), 0.0,
child: Container( _scrollController.position.maxScrollExtent,
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),
),
),
],
),
),
),
); );
_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 ? fallback
: CachedNetworkImage( : CachedNetworkImage(
imageUrl: artist.avatarUrl!, imageUrl: artist.avatarUrl!,
memCacheWidth: 140,
memCacheHeight: 140,
fit: BoxFit.cover, fit: BoxFit.cover,
placeholder: (_, __) => fallback, placeholder: (_, __) => fallback,
errorWidget: (_, __, ___) => fallback, errorWidget: (_, __, ___) => fallback,
@@ -314,6 +314,8 @@ class _CollectionCard extends ConsumerWidget {
if (urls.length == 1) { if (urls.length == 1) {
return CachedNetworkImage( return CachedNetworkImage(
imageUrl: urls[0], imageUrl: urls[0],
memCacheWidth: 260,
memCacheHeight: 260,
fit: BoxFit.cover, fit: BoxFit.cover,
placeholder: (_, __) => Container(color: scheme.surfaceContainerHighest), placeholder: (_, __) => Container(color: scheme.surfaceContainerHighest),
errorWidget: (_, __, ___) => Container( errorWidget: (_, __, ___) => Container(
@@ -333,6 +335,8 @@ class _CollectionCard extends ConsumerWidget {
if (i < urls.length) if (i < urls.length)
CachedNetworkImage( CachedNetworkImage(
imageUrl: urls[i], imageUrl: urls[i],
memCacheWidth: 200,
memCacheHeight: 200,
fit: BoxFit.cover, fit: BoxFit.cover,
placeholder: (_, __) => placeholder: (_, __) =>
Container(color: scheme.surfaceContainerHighest), Container(color: scheme.surfaceContainerHighest),
@@ -1225,6 +1225,8 @@ class _ArtistCard extends StatelessWidget {
), ),
child: CachedNetworkImage( child: CachedNetworkImage(
imageUrl: url, imageUrl: url,
memCacheWidth: 128,
memCacheHeight: 128,
fit: BoxFit.cover, fit: BoxFit.cover,
placeholder: (_, __) => Container( placeholder: (_, __) => Container(
color: scheme.surfaceContainerHighest, color: scheme.surfaceContainerHighest,
@@ -144,7 +144,11 @@ class _FeedScreenState extends ConsumerState<FeedScreen>
return Shortcuts( return Shortcuts(
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.escape): const _ClearSelectionIntent(),
LogicalKeySet(LogicalKeyboardKey.delete): const _ClearSelectionIntent(), LogicalKeySet(LogicalKeyboardKey.delete): const _ClearSelectionIntent(),
LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyA): LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyA):
@@ -158,6 +162,24 @@ class _FeedScreenState extends ConsumerState<FeedScreen>
return null; 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>( _ClearSelectionIntent: CallbackAction<_ClearSelectionIntent>(
onInvoke: (_) { onInvoke: (_) {
_clearSelection(); _clearSelection();
@@ -682,6 +704,14 @@ class _SelectVisibleIntent extends Intent {
const _SelectVisibleIntent(); const _SelectVisibleIntent();
} }
class _RandomPostIntent extends Intent {
const _RandomPostIntent();
}
class _ToggleSelectionIntent extends Intent {
const _ToggleSelectionIntent();
}
class _BatchActionBar extends StatelessWidget { class _BatchActionBar extends StatelessWidget {
const _BatchActionBar({ const _BatchActionBar({
required this.count, required this.count,
@@ -110,7 +110,7 @@ class _FeedToolbarState extends State<FeedToolbar> {
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
IconButton.filledTonal( IconButton.filledTonal(
tooltip: widget.selectionMode ? 'Выйти из выбора' : 'Выбрать посты', tooltip: widget.selectionMode ? 'Выйти из выбора (Esc)' : 'Выбрать посты (V)',
onPressed: widget.onToggleSelectionMode, onPressed: widget.onToggleSelectionMode,
icon: Icon(widget.selectionMode icon: Icon(widget.selectionMode
? Icons.check_box_rounded ? Icons.check_box_rounded
@@ -139,13 +139,13 @@ class _FeedToolbarState extends State<FeedToolbar> {
), ),
const SizedBox(width: 4), const SizedBox(width: 4),
IconButton( IconButton(
tooltip: 'Обновить', tooltip: 'Обновить (Ctrl+R / F5)',
onPressed: widget.onRefresh, onPressed: widget.onRefresh,
icon: const Icon(Icons.refresh_rounded), icon: const Icon(Icons.refresh_rounded),
), ),
const SizedBox(width: 4), const SizedBox(width: 4),
IconButton.filledTonal( IconButton.filledTonal(
tooltip: 'Случайный пост', tooltip: 'Случайный пост (R)',
onPressed: widget.onRandom, onPressed: widget.onRandom,
icon: const Icon(Icons.casino_rounded), icon: const Icon(Icons.casino_rounded),
), ),
@@ -163,41 +163,36 @@ class _FeedToolbarState extends State<FeedToolbar> {
const SizedBox(height: 8), const SizedBox(height: 8),
], ],
_HorizontalWheelScroller( _HorizontalWheelScroller(
height: 36, height: 38,
builder: (controller) => ListView( builder: (controller) => ListView(
controller: controller, controller: controller,
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
primary: false, primary: false,
children: [ children: [
_LiquidPeriodTabs(
selectedPeriod: widget.topPeriodFilter,
onChanged: widget.onTopPeriodChanged,
),
const SizedBox(width: 8),
const _ToolbarDivider(),
const SizedBox(width: 8),
if (widget.providers.isNotEmpty) ...[ if (widget.providers.isNotEmpty) ...[
FilterChip( _LiquidProviderPill(
selected: widget.selectedProviderIds.isEmpty, label: 'Все источники',
label: const Text('Все источники'), isSelected: widget.selectedProviderIds.isEmpty,
onSelected: (_) => widget.onQuickProviderToggle('__all__'), icon: Icons.all_inclusive_rounded,
onTap: () => widget.onQuickProviderToggle('__all__'),
), ),
const SizedBox(width: 8), const SizedBox(width: 6),
for (final provider in widget.providers) ...[ for (final provider in widget.providers) ...[
FilterChip( _LiquidProviderPill(
selected: label: provider.name,
widget.selectedProviderIds.contains(provider.id), isSelected: widget.selectedProviderIds.contains(provider.id),
avatar: const Icon(Icons.hub_rounded, size: 15), icon: Icons.hub_rounded,
label: Text(provider.name), onTap: () => widget.onQuickProviderToggle(provider.id),
onSelected: (_) =>
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, primary: false,
children: [ children: [
if (hasActiveFilters || widget.selectedTags.isNotEmpty) ...[ if (hasActiveFilters || widget.selectedTags.isNotEmpty) ...[
ActionChip( _LiquidProviderPill(
visualDensity: VisualDensity.compact, label: 'Сбросить',
avatar: const Icon(Icons.filter_alt_off_rounded, size: 15), isSelected: false,
label: const Text('Сбросить'), icon: Icons.filter_alt_off_rounded,
onPressed: widget.onClearFilters, onTap: widget.onClearFilters,
), ),
const SizedBox(width: 6), const SizedBox(width: 6),
], ],
ActionChip( _LiquidProviderPill(
visualDensity: VisualDensity.compact, label: 'Обновить',
avatar: const Icon(Icons.refresh_rounded, size: 15), isSelected: false,
label: const Text('Обновить'), icon: Icons.refresh_rounded,
onPressed: widget.onRefresh, onTap: widget.onRefresh,
), ),
const SizedBox(width: 6), const SizedBox(width: 6),
const _ToolbarDivider(), const _ToolbarDivider(),
const SizedBox(width: 6), const SizedBox(width: 6),
FilterChip( _LiquidProviderPill(
visualDensity: VisualDensity.compact, label: widget.rating ?? 'Рейтинг',
selected: widget.rating != null, isSelected: widget.rating != null,
avatar: const Icon(Icons.shield_outlined, size: 15), icon: Icons.shield_outlined,
label: Text(widget.rating ?? 'Рейтинг'), onTap: widget.onRatingFilter,
onSelected: (_) => widget.onRatingFilter(),
), ),
const SizedBox(width: 6), const SizedBox(width: 6),
const _ToolbarDivider(), const _ToolbarDivider(),
const SizedBox(width: 6), const SizedBox(width: 6),
_LiquidPeriodTabs(
selectedPeriod: widget.topPeriodFilter,
onChanged: widget.onTopPeriodChanged,
),
if (widget.providers.isNotEmpty) ...[ if (widget.providers.isNotEmpty) ...[
FilterChip( const SizedBox(width: 6),
visualDensity: VisualDensity.compact, const _ToolbarDivider(),
selected: widget.selectedProviderIds.isEmpty, const SizedBox(width: 6),
label: const Text('Все'), _LiquidProviderPill(
onSelected: (_) => widget.onQuickProviderToggle('__all__'), label: 'Все',
isSelected: widget.selectedProviderIds.isEmpty,
icon: Icons.all_inclusive_rounded,
onTap: () => widget.onQuickProviderToggle('__all__'),
), ),
const SizedBox(width: 6), const SizedBox(width: 6),
for (final provider in widget.providers) ...[ for (final provider in widget.providers) ...[
FilterChip( _LiquidProviderPill(
visualDensity: VisualDensity.compact, label: provider.name,
selected: isSelected: widget.selectedProviderIds.contains(provider.id),
widget.selectedProviderIds.contains(provider.id), icon: Icons.hub_rounded,
label: Text(provider.name), onTap: () => widget.onQuickProviderToggle(provider.id),
onSelected: (_) =>
widget.onQuickProviderToggle(provider.id),
), ),
const SizedBox(width: 6), 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 { class _HorizontalWheelScroller extends StatefulWidget {
const _HorizontalWheelScroller({ const _HorizontalWheelScroller({
required this.builder, required this.builder,
@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:ui'; import 'dart:ui';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
@@ -28,7 +29,10 @@ import 'post_details_controller.dart';
import 'widgets/cloud_mirrors_card.dart'; import 'widgets/cloud_mirrors_card.dart';
import 'widgets/post_action_bar.dart'; import 'widgets/post_action_bar.dart';
import 'widgets/post_media_viewer.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 'widgets/post_tags_panel.dart';
import '../../../backend/providers/e621_provider.dart';
final postCommentsProvider = final postCommentsProvider =
FutureProvider.family<List<PostComment>, PostDetailsArgs>( FutureProvider.family<List<PostComment>, PostDetailsArgs>(
@@ -39,6 +43,11 @@ final postCommentsProvider =
return result is Success<List<PostComment>> ? result.data : const []; 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 { class PostDetailsScreen extends ConsumerWidget {
const PostDetailsScreen({ const PostDetailsScreen({
required this.providerId, required this.providerId,
@@ -123,6 +132,11 @@ class PostDetailsScreen extends ConsumerWidget {
(post.commentary != null && (post.commentary != null &&
post.commentary!.trim().isNotEmpty)); post.commentary!.trim().isNotEmpty));
final providerInstance =
ref.watch(postProviderInstanceProvider(post.providerId)).value;
final e621Provider =
providerInstance is E621Provider ? providerInstance : null;
if (Responsive.isMobile(context)) { if (Responsive.isMobile(context)) {
if (currentIndex >= 0 && feedPosts.length > 1) { if (currentIndex >= 0 && feedPosts.length > 1) {
return _MobilePostPager( return _MobilePostPager(
@@ -172,6 +186,8 @@ class PostDetailsScreen extends ConsumerWidget {
LogicalKeySet(LogicalKeyboardKey.keyC): LogicalKeySet(LogicalKeyboardKey.keyC):
const _AddCollectionIntent(), const _AddCollectionIntent(),
LogicalKeySet(LogicalKeyboardKey.keyS): const _DownloadIntent(), LogicalKeySet(LogicalKeyboardKey.keyS): const _DownloadIntent(),
LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyS):
const _DownloadIntent(),
LogicalKeySet(LogicalKeyboardKey.escape): const _CloseIntent(), LogicalKeySet(LogicalKeyboardKey.escape): const _CloseIntent(),
}, },
child: Actions( child: Actions(
@@ -359,7 +375,25 @@ class PostDetailsScreen extends ConsumerWidget {
strings: strings, strings: strings,
localMedia: localMedia, localMedia: localMedia,
fileSizeBytes: fileSizeBytes, 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,
onOpenPostId: (targetId) =>
_openPostById(context, post.providerId, targetId),
),
],
if (shouldShowCloudCard) ...[ if (shouldShowCloudCard) ...[
const SizedBox(height: 16), const SizedBox(height: 16),
CloudMirrorsCard( CloudMirrorsCard(
@@ -468,6 +502,11 @@ class PostDetailsScreen extends ConsumerWidget {
(post.commentary != null && (post.commentary != null &&
post.commentary!.trim().isNotEmpty)); post.commentary!.trim().isNotEmpty));
final providerInstance =
ref.watch(postProviderInstanceProvider(post.providerId)).value;
final e621Provider =
providerInstance is E621Provider ? providerInstance : null;
return GestureDetector( return GestureDetector(
behavior: HitTestBehavior.translucent, behavior: HitTestBehavior.translucent,
onVerticalDragEnd: (details) { onVerticalDragEnd: (details) {
@@ -594,7 +633,25 @@ class PostDetailsScreen extends ConsumerWidget {
strings: strings, strings: strings,
localMedia: localMedia, localMedia: localMedia,
fileSizeBytes: fileSizeBytes, 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,
onOpenPostId: (targetId) =>
_openPostById(context, post.providerId, targetId),
),
],
if (shouldShowCloudCard) ...[ if (shouldShowCloudCard) ...[
const SizedBox(height: 12), const SizedBox(height: 12),
CloudMirrorsCard( CloudMirrorsCard(
@@ -803,18 +860,43 @@ class PostDetailsScreen extends ConsumerWidget {
Post post, Post post,
Set<String> favoriteKeys, Set<String> favoriteKeys,
) async { ) async {
if (favoriteKeys.contains(post.cacheKey)) { final isFav = favoriteKeys.contains(post.cacheKey);
if (isFav) {
await ref await ref
.read(favoriteServiceProvider) .read(favoriteServiceProvider)
.removeFavorite(post.id, post.providerId); .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 { } else {
await ref.read(favoriteServiceProvider).addFavorite(post); await ref.read(favoriteServiceProvider).addFavorite(post);
await _maybeAutoDownloadFavorite(ref, 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(favoriteKeysProvider);
ref.invalidate(favoritesControllerProvider); ref.invalidate(favoritesControllerProvider);
} }
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 { Future<void> _maybeAutoDownloadFavorite(WidgetRef ref, Post post) async {
final settings = final settings =
ref.read(appSettingsProvider).value ?? AppSettings.defaults; ref.read(appSettingsProvider).value ?? AppSettings.defaults;
@@ -1153,6 +1235,8 @@ class _NeighborStrip extends StatelessWidget {
imageUrl: post.previewUrl.isNotEmpty imageUrl: post.previewUrl.isNotEmpty
? post.previewUrl ? post.previewUrl
: post.sampleUrl, : post.sampleUrl,
memCacheWidth: 160,
memCacheHeight: 160,
fit: BoxFit.cover, fit: BoxFit.cover,
errorWidget: (_, __, ___) => errorWidget: (_, __, ___) =>
const Center(child: Icon(Icons.broken_image_rounded)), const Center(child: Icon(Icons.broken_image_rounded)),
@@ -1336,12 +1420,14 @@ class _PostInfoCard extends StatefulWidget {
required this.strings, required this.strings,
this.localMedia, this.localMedia,
this.fileSizeBytes, this.fileSizeBytes,
this.e621Provider,
}); });
final Post post; final Post post;
final AppStrings strings; final AppStrings strings;
final DownloadedMedia? localMedia; final DownloadedMedia? localMedia;
final int? fileSizeBytes; final int? fileSizeBytes;
final E621Provider? e621Provider;
@override @override
State<_PostInfoCard> createState() => _PostInfoCardState(); State<_PostInfoCard> createState() => _PostInfoCardState();
@@ -1352,6 +1438,8 @@ class _PostInfoCardState extends State<_PostInfoCard> {
int? _resolvedWidth; int? _resolvedWidth;
int? _resolvedHeight; int? _resolvedHeight;
bool _resolvingDimensions = false; bool _resolvingDimensions = false;
int? _votedScore;
bool _isVoting = false;
static final _dimensionCache = <String, (int, int)>{}; static final _dimensionCache = <String, (int, int)>{};
@@ -1360,6 +1448,43 @@ class _PostInfoCardState extends State<_PostInfoCard> {
return imageTypes.contains(fileType.toLowerCase()); 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 @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -1587,11 +1712,59 @@ class _PostInfoCardState extends State<_PostInfoCard> {
label: post.fileType.toUpperCase(), label: post.fileType.toUpperCase(),
), ),
RatingBadge(rating: post.rating), 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( _SpecBadge(
icon: Icons.star_rounded, icon: Icons.favorite_rounded,
label: '${post.score}', label: '${post.favCount}',
iconColor: Colors.amber, iconColor: const Color(0xFFEF4444),
), ),
if (post.source != null && post.source!.isNotEmpty) if (post.source != null && post.source!.isNotEmpty)
InkWell( InkWell(
@@ -0,0 +1,472 @@
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: 18),
// 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; String? _videoError;
bool _retriedFormatError = false; bool _retriedFormatError = false;
bool _softwareDecodingFallback = false; bool _softwareDecodingFallback = false;
late bool _useSoftwareDecoding =
Platform.isLinux || Platform.isWindows || Platform.isMacOS;
Timer? _hideTimer; Timer? _hideTimer;
StreamSubscription<String>? _errorSubscription; StreamSubscription<String>? _errorSubscription;
StreamSubscription<Duration>? _positionSubscription; StreamSubscription<Duration>? _positionSubscription;
@@ -109,6 +111,8 @@ class _PostMediaViewerState extends State<PostMediaViewer>
_videoError = null; _videoError = null;
_retriedFormatError = false; _retriedFormatError = false;
_softwareDecodingFallback = false; _softwareDecodingFallback = false;
_useSoftwareDecoding =
Platform.isLinux || Platform.isWindows || Platform.isMacOS;
_imageUrls = _buildImageUrls(widget.post); _imageUrls = _buildImageUrls(widget.post);
_videoUrls = _buildVideoUrls(widget.post); _videoUrls = _buildVideoUrls(widget.post);
if (_isPlayableMedia(widget.post) && _videoUrls.isNotEmpty) { if (_isPlayableMedia(widget.post) && _videoUrls.isNotEmpty) {
@@ -226,6 +230,8 @@ class _PostMediaViewerState extends State<PostMediaViewer>
aspectRatio: widget.post.width > 0 && widget.post.height > 0 aspectRatio: widget.post.width > 0 && widget.post.height > 0
? widget.post.width / widget.post.height ? widget.post.width / widget.post.height
: 16 / 9, : 16 / 9,
isSoftwareDecoding: _useSoftwareDecoding,
onToggleDecoder: _toggleDecoderMode,
controlsVisible: _controlsVisible, controlsVisible: _controlsVisible,
coverVideo: _coverVideo, coverVideo: _coverVideo,
muted: _muted, muted: _muted,
@@ -317,10 +323,16 @@ class _PostMediaViewerState extends State<PostMediaViewer>
), ),
); );
} else { } 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( image = CachedNetworkImage(
key: ValueKey(url), key: ValueKey(url),
imageUrl: url, imageUrl: url,
httpHeaders: headers, httpHeaders: headers,
memCacheWidth: maxCacheWidth,
fit: BoxFit.contain, fit: BoxFit.contain,
placeholder: (context, url) => placeholder: (context, url) =>
const Center(child: CircularProgressIndicator()), 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( return LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
if (constraints.hasBoundedWidth && constraints.hasBoundedHeight) { if (constraints.hasBoundedWidth && constraints.hasBoundedHeight) {
return SizedBox( return SizedBox(
width: constraints.maxWidth, width: constraints.maxWidth,
height: constraints.maxHeight, height: constraints.maxHeight,
child: child, child: interactiveChild,
); );
} }
return child; return interactiveChild;
}, },
); );
} }
@@ -400,11 +450,12 @@ class _PostMediaViewerState extends State<PostMediaViewer>
void _initializeVideo() { void _initializeVideo() {
MediaKit.ensureInitialized(); MediaKit.ensureInitialized();
_player = Player(); _player = Player();
final bool enableHw = !_useSoftwareDecoding && !_softwareDecodingFallback;
_controller = VideoController( _controller = VideoController(
_player!, _player!,
configuration: VideoControllerConfiguration( configuration: VideoControllerConfiguration(
enableHardwareAcceleration: !_softwareDecodingFallback, enableHardwareAcceleration: enableHw,
hwdec: _softwareDecodingFallback ? 'no' : 'auto-safe', hwdec: enableHw ? (Platform.isAndroid ? 'auto-safe' : 'auto') : 'no',
), ),
); );
_applyVolume(); _applyVolume();
@@ -479,6 +530,7 @@ class _PostMediaViewerState extends State<PostMediaViewer>
lower.contains('failed to recognize file format'))) { lower.contains('failed to recognize file format'))) {
_retriedFormatError = true; _retriedFormatError = true;
_softwareDecodingFallback = true; _softwareDecodingFallback = true;
_useSoftwareDecoding = true;
_disposeVideo(); _disposeVideo();
await Future<void>.delayed(const Duration(milliseconds: 250)); await Future<void>.delayed(const Duration(milliseconds: 250));
if (mounted) { if (mounted) {
@@ -496,6 +548,7 @@ class _PostMediaViewerState extends State<PostMediaViewer>
_videoIndex = 0; _videoIndex = 0;
_retriedFormatError = false; _retriedFormatError = false;
_softwareDecodingFallback = true; _softwareDecodingFallback = true;
_useSoftwareDecoding = true;
_disposeVideo(); _disposeVideo();
await Future<void>.delayed(const Duration(milliseconds: 150)); await Future<void>.delayed(const Duration(milliseconds: 150));
if (mounted) { 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() { void _disposeVideo() {
_hideTimer?.cancel(); _hideTimer?.cancel();
_errorSubscription?.cancel(); _errorSubscription?.cancel();
@@ -644,6 +717,8 @@ class _PostMediaViewerState extends State<PostMediaViewer>
halfVolume: _halfVolume, halfVolume: _halfVolume,
coverVideo: _coverVideo, coverVideo: _coverVideo,
initialVolume: _currentVolume, initialVolume: _currentVolume,
isSoftwareDecoding: _useSoftwareDecoding,
onToggleDecoder: _toggleDecoderMode,
errorMessage: _videoError, errorMessage: _videoError,
onRetry: _retryVideo, onRetry: _retryVideo,
onChanged: (snapshot) { onChanged: (snapshot) {
@@ -739,6 +814,48 @@ class _TogglePlayIntent extends Intent {
const _TogglePlayIntent(); 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 { class _UnsupportedSwfPanel extends StatelessWidget {
const _UnsupportedSwfPanel(); const _UnsupportedSwfPanel();
@@ -798,6 +915,8 @@ class _FullscreenVideoPage extends StatefulWidget {
required this.halfVolume, required this.halfVolume,
required this.coverVideo, required this.coverVideo,
this.initialVolume = 100.0, this.initialVolume = 100.0,
required this.isSoftwareDecoding,
required this.onToggleDecoder,
required this.errorMessage, required this.errorMessage,
required this.onRetry, required this.onRetry,
required this.onChanged, required this.onChanged,
@@ -812,6 +931,8 @@ class _FullscreenVideoPage extends StatefulWidget {
final bool halfVolume; final bool halfVolume;
final bool coverVideo; final bool coverVideo;
final double initialVolume; final double initialVolume;
final bool isSoftwareDecoding;
final VoidCallback onToggleDecoder;
final String? errorMessage; final String? errorMessage;
final VoidCallback onRetry; final VoidCallback onRetry;
final ValueChanged<VideoPlaybackSnapshot> onChanged; final ValueChanged<VideoPlaybackSnapshot> onChanged;
@@ -911,6 +1032,8 @@ class _FullscreenVideoPageState extends State<_FullscreenVideoPage> {
shortcuts: { shortcuts: {
LogicalKeySet(LogicalKeyboardKey.escape): const _CloseVideoIntent(), LogicalKeySet(LogicalKeyboardKey.escape): const _CloseVideoIntent(),
LogicalKeySet(LogicalKeyboardKey.space): const _TogglePlayIntent(), LogicalKeySet(LogicalKeyboardKey.space): const _TogglePlayIntent(),
LogicalKeySet(LogicalKeyboardKey.keyK): const _TogglePlayIntent(),
LogicalKeySet(LogicalKeyboardKey.keyF): const _CloseVideoIntent(),
}, },
child: Actions( child: Actions(
actions: { actions: {
@@ -934,6 +1057,8 @@ class _FullscreenVideoPageState extends State<_FullscreenVideoPage> {
player: widget.player, player: widget.player,
controller: widget.controller, controller: widget.controller,
aspectRatio: widget.aspectRatio, aspectRatio: widget.aspectRatio,
isSoftwareDecoding: widget.isSoftwareDecoding,
onToggleDecoder: widget.onToggleDecoder,
controlsVisible: _controlsVisible, controlsVisible: _controlsVisible,
coverVideo: _coverVideo, coverVideo: _coverVideo,
muted: _muted, muted: _muted,
@@ -1485,6 +1610,8 @@ class _VideoSurface extends StatefulWidget {
required this.player, required this.player,
required this.controller, required this.controller,
required this.aspectRatio, required this.aspectRatio,
required this.isSoftwareDecoding,
required this.onToggleDecoder,
required this.controlsVisible, required this.controlsVisible,
required this.coverVideo, required this.coverVideo,
required this.muted, required this.muted,
@@ -1509,6 +1636,8 @@ class _VideoSurface extends StatefulWidget {
final Player player; final Player player;
final VideoController controller; final VideoController controller;
final double aspectRatio; final double aspectRatio;
final bool isSoftwareDecoding;
final VoidCallback onToggleDecoder;
final bool controlsVisible; final bool controlsVisible;
final bool coverVideo; final bool coverVideo;
final bool muted; final bool muted;
@@ -1754,6 +1883,8 @@ class _VideoSurfaceState extends State<_VideoSurface> {
fullscreen: widget.fullscreen, fullscreen: widget.fullscreen,
isLocked: _isLocked, isLocked: _isLocked,
isLandscape: widget.isLandscape, isLandscape: widget.isLandscape,
isSoftwareDecoding: widget.isSoftwareDecoding,
onToggleDecoder: widget.onToggleDecoder,
onToggleOrientation: widget.onToggleOrientation, onToggleOrientation: widget.onToggleOrientation,
onToggleLock: () => setState(() => _isLocked = !_isLocked), onToggleLock: () => setState(() => _isLocked = !_isLocked),
onToggleFit: widget.onToggleFit, onToggleFit: widget.onToggleFit,
@@ -1771,16 +1902,87 @@ class _VideoSurfaceState extends State<_VideoSurface> {
}, },
); );
return MouseRegion( return Shortcuts(
onHover: (_) => widget.onInteract(), shortcuts: {
child: ClipRRect( LogicalKeySet(LogicalKeyboardKey.space): const _TogglePlayIntent(),
borderRadius: BorderRadius.circular(widget.fullscreen ? 0 : 10), LogicalKeySet(LogicalKeyboardKey.keyK): const _TogglePlayIntent(),
child: widget.fullscreen LogicalKeySet(LogicalKeyboardKey.arrowLeft): const _SeekVideoIntent(-5),
? SizedBox.expand(child: child) LogicalKeySet(LogicalKeyboardKey.keyJ): const _SeekVideoIntent(-5),
: AspectRatio( LogicalKeySet(LogicalKeyboardKey.arrowRight): const _SeekVideoIntent(5),
aspectRatio: widget.aspectRatio.clamp(0.35, 2.4), LogicalKeySet(LogicalKeyboardKey.keyL): const _SeekVideoIntent(5),
child: child, 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(); 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return LayoutBuilder( return Shortcuts(
builder: (context, constraints) { shortcuts: {
final hasFrame = LogicalKeySet(LogicalKeyboardKey.equal): const _ZoomInIntent(),
constraints.hasBoundedWidth && constraints.hasBoundedHeight; LogicalKeySet(LogicalKeyboardKey.add): const _ZoomInIntent(),
final child = hasFrame LogicalKeySet(LogicalKeyboardKey.numpadAdd): const _ZoomInIntent(),
? SizedBox( LogicalKeySet(LogicalKeyboardKey.minus): const _ZoomOutIntent(),
width: constraints.maxWidth, LogicalKeySet(LogicalKeyboardKey.numpadSubtract): const _ZoomOutIntent(),
height: constraints.maxHeight, LogicalKeySet(LogicalKeyboardKey.digit0): const _ResetZoomIntent(),
child: widget.child, LogicalKeySet(LogicalKeyboardKey.numpad0): const _ResetZoomIntent(),
) LogicalKeySet(LogicalKeyboardKey.escape): const _ResetZoomIntent(),
: 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,
),
),
);
}, },
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.fullscreen,
required this.isLocked, required this.isLocked,
this.isLandscape, this.isLandscape,
required this.isSoftwareDecoding,
required this.onToggleDecoder,
this.onToggleOrientation, this.onToggleOrientation,
required this.onToggleLock, required this.onToggleLock,
required this.onToggleFit, required this.onToggleFit,
@@ -2460,6 +2717,8 @@ class _VideoControls extends StatefulWidget {
final bool fullscreen; final bool fullscreen;
final bool isLocked; final bool isLocked;
final bool? isLandscape; final bool? isLandscape;
final bool isSoftwareDecoding;
final VoidCallback onToggleDecoder;
final VoidCallback? onToggleOrientation; final VoidCallback? onToggleOrientation;
final VoidCallback onToggleLock; final VoidCallback onToggleLock;
final VoidCallback onToggleFit; final VoidCallback onToggleFit;
@@ -2559,6 +2818,17 @@ class _VideoControlsState extends State<_VideoControls> {
onPressed: widget.onToggleLock, onPressed: widget.onToggleLock,
), ),
const Spacer(), 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( _SpeedBadgeButton(
currentRate: currentRate, currentRate: currentRate,
onSelected: (newRate) => widget.player.setRate(newRate), onSelected: (newRate) => widget.player.setRate(newRate),
@@ -0,0 +1,149 @@
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.onOpenPostId,
super.key,
});
final Post post;
final E621Provider provider;
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)
Material(
color: Colors.transparent,
child: InkWell(
onTap: () {
HapticFeedback.lightImpact();
E621PoolSheet.show(
context,
currentPost: post,
poolId: poolId,
provider: provider,
onSelectPostId: onOpenPostId,
);
},
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 8),
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: 15, color: Colors.white),
const SizedBox(width: 6),
Text(
isRu
? 'Открыть комикс (Пул #$poolId)'
: 'Open Pool #$poolId',
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
const SizedBox(width: 4),
const Icon(Icons.arrow_forward_ios_rounded,
size: 10, color: Colors.white),
],
),
),
),
),
],
),
],
),
);
}
}
@@ -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', '/providers/new',
extra: item, extra: item,
), ),
onSaveConfig: (updated) => ref
.read(providersControllerProvider.notifier)
.save(updated),
onDelete: () async { onDelete: () async {
final ok = await showConfirmDialog( final ok = await showConfirmDialog(
context, 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 'package:flutter/services.dart';
import '../../../../backend/backend.dart'; import '../../../../backend/backend.dart';
import 'e621_auth_dialog.dart';
class ProviderCard extends StatelessWidget { class ProviderCard extends StatelessWidget {
const ProviderCard({ const ProviderCard({
@@ -9,6 +10,7 @@ class ProviderCard extends StatelessWidget {
required this.onToggle, required this.onToggle,
required this.onEdit, required this.onEdit,
required this.onDelete, required this.onDelete,
this.onSaveConfig,
super.key, super.key,
}); });
@@ -16,6 +18,7 @@ class ProviderCard extends StatelessWidget {
final ValueChanged<bool> onToggle; final ValueChanged<bool> onToggle;
final VoidCallback onEdit; final VoidCallback onEdit;
final VoidCallback onDelete; final VoidCallback onDelete;
final ValueChanged<ContentProviderConfig>? onSaveConfig;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -164,6 +167,16 @@ class ProviderCard extends StatelessWidget {
color: theme.colorScheme.primary, color: theme.colorScheme.primary,
isDark: isDark, 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), const SizedBox(height: 12),
// Row 3: Action Buttons (Edit & Delete) // Row 3: Action Buttons (Edit & Delete & e621 Auth)
Row( Row(
mainAxisAlignment: MainAxisAlignment.end, mainAxisAlignment: MainAxisAlignment.end,
children: [ 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 // Edit Action Button
_GlassActionButton( _GlassActionButton(
icon: Icons.edit_rounded, icon: Icons.edit_rounded,
@@ -1,6 +1,7 @@
import 'dart:io'; import 'dart:io';
import 'dart:ui'; import 'dart:ui';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart' hide appBuildNumber; import 'package:flutter/services.dart' hide appBuildNumber;
import 'package:flutter_riverpod/flutter_riverpod.dart'; 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({ const _CategoryQuickNav({
required this.categories, required this.categories,
required this.onSelect, required this.onSelect,
@@ -1449,103 +1450,143 @@ class _CategoryQuickNav extends StatelessWidget {
categories; categories;
final ValueChanged<GlobalKey> onSelect; 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark; final isDark = theme.brightness == Brightness.dark;
return SizedBox( return MouseRegion(
height: 44, onEnter: (_) => _isHovered = true,
child: ListView.separated( onExit: (_) => _isHovered = false,
padding: const EdgeInsets.symmetric(horizontal: 16), child: Listener(
scrollDirection: Axis.horizontal, onPointerSignal: (event) {
itemCount: categories.length, if (!_isHovered ||
separatorBuilder: (_, __) => const SizedBox(width: 8), event is! PointerScrollEvent ||
itemBuilder: (context, index) { !_scrollController.hasClients) {
final cat = categories[index]; return;
return Container( }
decoration: BoxDecoration( final delta = event.scrollDelta.dy != 0
borderRadius: BorderRadius.circular(22), ? event.scrollDelta.dy
boxShadow: [ : event.scrollDelta.dx;
BoxShadow( if (delta == 0) return;
color: isDark final target = (_scrollController.offset + delta * 1.5).clamp(
? Colors.black.withValues(alpha: 0.25) 0.0,
: cat.color.withValues(alpha: 0.12), _scrollController.position.maxScrollExtent,
blurRadius: 8, );
offset: const Offset(0, 2.5), _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: ClipRRect( child: BackdropFilter(
borderRadius: BorderRadius.circular(22), filter: ImageFilter.blur(sigmaX: 16, sigmaY: 16),
child: BackdropFilter( child: Material(
filter: ImageFilter.blur(sigmaX: 16, sigmaY: 16), color: isDark
child: Material( ? theme.colorScheme.surfaceContainerHigh
color: isDark .withValues(alpha: 0.60)
? theme.colorScheme.surfaceContainerHigh.withValues(alpha: 0.60) : Colors.white.withValues(alpha: 0.82),
: Colors.white.withValues(alpha: 0.82), child: InkWell(
child: InkWell(
borderRadius: BorderRadius.circular(22),
onTap: () {
HapticFeedback.selectionClick();
onSelect(cat.key);
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(22), borderRadius: BorderRadius.circular(22),
border: Border.all( onTap: () {
color: isDark HapticFeedback.selectionClick();
? Colors.white.withValues(alpha: 0.18) widget.onSelect(cat.key);
: Colors.white.withValues(alpha: 0.85), },
width: 1.1, child: Container(
), padding: const EdgeInsets.symmetric(
), horizontal: 12, vertical: 7),
child: Row( decoration: BoxDecoration(
mainAxisSize: MainAxisSize.min, borderRadius: BorderRadius.circular(22),
children: [ border: Border.all(
Container( color: isDark
width: 24, ? Colors.white.withValues(alpha: 0.18)
height: 24, : Colors.white.withValues(alpha: 0.85),
decoration: BoxDecoration( width: 1.1,
gradient: LinearGradient( ),
begin: Alignment.topLeft, ),
end: Alignment.bottomRight, child: Row(
colors: [ mainAxisSize: MainAxisSize.min,
cat.color, children: [
cat.color.withValues(alpha: 0.82), Container(
], width: 24,
), height: 24,
borderRadius: BorderRadius.circular(7), decoration: BoxDecoration(
boxShadow: [ gradient: LinearGradient(
BoxShadow( begin: Alignment.topLeft,
color: cat.color.withValues(alpha: 0.35), end: Alignment.bottomRight,
blurRadius: 5, colors: [
offset: const Offset(0, 1.5), 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,
child: Center( size: 14, color: Colors.white),
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( : CachedNetworkImage(
imageUrl: image, imageUrl: image,
memCacheWidth: 320,
memCacheHeight: 320,
fit: BoxFit.cover, fit: BoxFit.cover,
placeholder: (_, __) => Container( placeholder: (_, __) => Container(
color: scheme.surfaceContainerHighest, color: scheme.surfaceContainerHighest,
+5
View File
@@ -1,9 +1,14 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:media_kit/media_kit.dart';
import 'app/app.dart'; import 'app/app.dart';
void main() { void main() {
WidgetsFlutterBinding.ensureInitialized(); 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())); 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 'package:go_router/go_router.dart';
import '../../app/app.dart'; import '../../app/app.dart';
import '../../app/motion.dart';
import '../../app/responsive.dart'; import '../../app/responsive.dart';
import '../../app/router.dart'; import '../../app/router.dart';
import '../../backend/backend.dart'; import '../../backend/backend.dart';
import '../../core/utils/result.dart'; import '../../core/utils/result.dart';
import '../../features/feed/presentation/feed_controller.dart';
import 'desktop_shortcuts_dialog.dart';
class AppShell extends ConsumerStatefulWidget { class AppShell extends ConsumerStatefulWidget {
const AppShell({ const AppShell({
@@ -249,21 +250,54 @@ class _AppShellState extends ConsumerState<AppShell> {
LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyF): LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyF):
const _NavigateIntent('/search'), const _NavigateIntent('/search'),
LogicalKeySet(LogicalKeyboardKey.escape): const _BackIntent(), LogicalKeySet(LogicalKeyboardKey.escape): const _BackIntent(),
LogicalKeySet(LogicalKeyboardKey.digit1): const _NavigateIntent('/'), // Ctrl + 1..8
LogicalKeySet(LogicalKeyboardKey.digit2): LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.digit1):
const _NavigateIntent('/'),
LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.digit2):
const _NavigateIntent('/search'), const _NavigateIntent('/search'),
LogicalKeySet(LogicalKeyboardKey.digit3): LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.digit3):
const _NavigateIntent('/favorites'), const _NavigateIntent('/favorites'),
LogicalKeySet(LogicalKeyboardKey.digit4): LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.digit4):
const _NavigateIntent('/viewed'), const _NavigateIntent('/viewed'),
LogicalKeySet(LogicalKeyboardKey.digit5): LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.digit5):
const _NavigateIntent('/collections'), const _NavigateIntent('/collections'),
LogicalKeySet(LogicalKeyboardKey.digit6): LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.digit6):
const _NavigateIntent('/artists'), const _NavigateIntent('/artists'),
LogicalKeySet(LogicalKeyboardKey.digit7): LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.digit7):
const _NavigateIntent('/providers'), const _NavigateIntent('/providers'),
LogicalKeySet(LogicalKeyboardKey.digit8): LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.digit8):
const _NavigateIntent('/settings'), 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( child: Actions(
actions: { actions: {
@@ -279,6 +313,33 @@ class _AppShellState extends ConsumerState<AppShell> {
return null; 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( child: Focus(
autofocus: true, autofocus: true,
@@ -312,6 +373,7 @@ class _DesktopShell extends StatefulWidget {
required this.onNavigate, required this.onNavigate,
this.currentBranchIndex, this.currentBranchIndex,
}); });
final Widget child; final Widget child;
final List<_Destination> destinations; final List<_Destination> destinations;
final bool ru; final bool ru;
@@ -322,54 +384,416 @@ class _DesktopShell extends StatefulWidget {
State<_DesktopShell> createState() => _DesktopShellState(); 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 _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 @override
Widget build(BuildContext context) { 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 location = GoRouterState.of(context).uri.path;
final activeBranch = widget.currentBranchIndex ?? final activeBranch = widget.currentBranchIndex ??
AppShell.branchIndexForLocation(location); AppShell.branchIndexForLocation(location);
final selected = widget.destinations.indexWhere( final selected = widget.destinations.indexWhere(
(item) => AppShell.branchIndexForLocation(item.location) == activeBranch, (item) => AppShell.branchIndexForLocation(item.location) == activeBranch,
); );
return Scaffold( return Scaffold(
body: Row( body: Row(
children: [ children: [
MouseRegion( MouseRegion(
onEnter: (_) => setState(() => _hovered = true), onEnter: (_) => _handleHover(true),
onExit: (_) => setState(() => _hovered = false), onExit: (_) => _handleHover(false),
child: AnimatedContainer( child: AnimatedBuilder(
duration: AppMotion.duration(context, 180), animation: _animation,
curve: Curves.easeOutCubic, builder: (context, _) {
width: _hovered ? 256 : 72, final progress = _animation.value;
color: Theme.of(context).navigationRailTheme.backgroundColor, final width = lerpDouble(74.0, 260.0, progress)!;
child: SafeArea( final contentOpacity =
child: Padding( (progress - 0.25).clamp(0.0, 0.75) / 0.75;
padding: final isFullyExpanded = progress > 0.85;
const EdgeInsets.symmetric(vertical: 12, horizontal: 8),
child: Column( return Container(
children: [ width: width,
for (var index = 0; decoration: BoxDecoration(
index < widget.destinations.length; color: isDark
index++) ? scheme.surfaceContainerHigh
_RailButton( : scheme.surface,
destination: widget.destinations[index], border: Border(
ru: widget.ru, right: BorderSide(
selected: (selected < 0 ? 0 : selected) == index, color: isDark
expanded: _hovered, ? Colors.white.withValues(alpha: 0.1)
onTap: () { : scheme.outlineVariant.withValues(alpha: 0.35),
widget.onNavigate(widget.destinations[index].location); width: 1,
}, ),
), ),
const Spacer(), 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.0',
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), Expanded(child: widget.child),
], ],
), ),
@@ -381,75 +805,148 @@ class _RailButton extends StatelessWidget {
const _RailButton({ const _RailButton({
required this.destination, required this.destination,
required this.selected, required this.selected,
required this.expanded, required this.contentOpacity,
required this.isFullyExpanded,
required this.ru, required this.ru,
required this.shortcutKey,
required this.onTap, required this.onTap,
}); });
final _Destination destination; final _Destination destination;
final bool selected; final bool selected;
final bool expanded; final double contentOpacity;
final bool isFullyExpanded;
final bool ru; final bool ru;
final String shortcutKey;
final VoidCallback onTap; final VoidCallback onTap;
@override @override
Widget build(BuildContext context) { 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( return Padding(
padding: const EdgeInsets.only(bottom: 8), padding: const EdgeInsets.symmetric(vertical: 2),
child: Tooltip( child: Tooltip(
message: expanded ? '' : destination.labelFor(ru), message: isFullyExpanded ? '' : '$label ($shortcutKey)',
waitDuration: const Duration(milliseconds: 450), waitDuration: const Duration(milliseconds: 350),
child: Material( child: Material(
color: selected ? colors.primaryContainer : Colors.transparent, color: selected
borderRadius: BorderRadius.circular(28), ? scheme.primary.withValues(alpha: isDark ? 0.22 : 0.12)
: Colors.transparent,
borderRadius: BorderRadius.circular(14),
clipBehavior: Clip.antiAlias, clipBehavior: Clip.antiAlias,
child: InkWell( child: InkWell(
onTap: onTap, onTap: onTap,
child: SizedBox( child: SizedBox(
height: 48, width: 244,
child: Row( height: 46,
children: [ child: DecoratedBox(
SizedBox( decoration: BoxDecoration(
width: 56, borderRadius: BorderRadius.circular(14),
child: Icon( border: selected
destination.icon, ? Border.all(
color: selected color: scheme.primary
? colors.onPrimaryContainer .withValues(alpha: isDark ? 0.45 : 0.28),
: colors.onSurfaceVariant, 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,
),
), ),
), SizedBox(
if (expanded) width: 54.5,
Expanded( child: Center(
child: ClipRect( child: Icon(
child: AnimatedOpacity( destination.icon,
opacity: 1, size: 22,
duration: AppMotion.duration(context, 120), color: selected
curve: Curves.easeOut, ? scheme.primary
child: Align( : (isDark
alignment: Alignment.centerLeft, ? scheme.onSurfaceVariant
child: Text( .withValues(alpha: 0.85)
destination.labelFor(ru), : scheme.onSurfaceVariant),
maxLines: 1,
overflow: TextOverflow.clip,
softWrap: false,
style: TextStyle(
fontWeight: selected
? FontWeight.w700
: FontWeight.w600,
color: selected
? colors.onPrimaryContainer
: colors.onSurface,
),
),
),
), ),
), ),
) ),
else Expanded(
const SizedBox.shrink(), child: Opacity(
if (expanded) const SizedBox(width: 12), 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 { class _BackIntent extends Intent {
const _BackIntent(); 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:async';
import 'dart:collection'; import 'dart:collection';
import 'dart:io';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@@ -232,7 +233,7 @@ class _PostCardState extends ConsumerState<PostCard>
} }
final dpr = MediaQuery.devicePixelRatioOf(context); final dpr = MediaQuery.devicePixelRatioOf(context);
final cacheWidth = final cacheWidth =
(constraints.maxWidth * dpr).round().clamp(320, 1800); (constraints.maxWidth * dpr).round().clamp(280, 960);
final headers = _headersFor(post); final headers = _headersFor(post);
final videoUrls = MediaUrlSelector.video(post); final videoUrls = MediaUrlSelector.video(post);
if (!mobile && if (!mobile &&
@@ -590,7 +591,15 @@ class _FeedVideoPreviewState extends State<_FeedVideoPreview> {
super.initState(); super.initState();
MediaKit.ensureInitialized(); MediaKit.ensureInitialized();
_player = Player(); _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((_) { _player.stream.error.listen((_) {
if (mounted) setState(() => _failed = true); if (mounted) setState(() => _failed = true);
}); });
+20 -18
View File
@@ -68,24 +68,26 @@ class PostMasonryGrid extends StatelessWidget {
final spacing = mobile ? 8.0 : 12.0; final spacing = mobile ? 8.0 : 12.0;
Widget buildCard(Post post) { Widget buildCard(Post post) {
return PostCard( return RepaintBoundary(
post: post, child: PostCard(
blurExplicit: blurExplicit && !nsfwEnabled, post: post,
showBadges: showBadges, blurExplicit: blurExplicit && !nsfwEnabled,
isFavorite: favoriteKeys.contains(post.cacheKey), showBadges: showBadges,
isViewed: viewedKeys.contains(post.cacheKey), isFavorite: favoriteKeys.contains(post.cacheKey),
isDownloaded: downloadedKeys.contains(post.cacheKey), isViewed: viewedKeys.contains(post.cacheKey),
mediaQualityMode: mediaQualityMode, isDownloaded: downloadedKeys.contains(post.cacheKey),
onOpen: () => onOpen(post), mediaQualityMode: mediaQualityMode,
onFavorite: () => onFavorite(post), onOpen: () => onOpen(post),
onPreview: onPreview == null ? null : () => onPreview!(post), onFavorite: () => onFavorite(post),
onHide: onHide == null ? null : () => onHide!(post), onPreview: onPreview == null ? null : () => onPreview!(post),
selectionMode: selectionMode, onHide: onHide == null ? null : () => onHide!(post),
selected: selectedKeys.contains(post.cacheKey), selectionMode: selectionMode,
onToggleSelected: selected: selectedKeys.contains(post.cacheKey),
onToggleSelected == null ? null : () => onToggleSelected!(post), onToggleSelected:
onAddToCollection: onToggleSelected == null ? null : () => onToggleSelected!(post),
onAddToCollection == null ? null : () => onAddToCollection!(post), onAddToCollection:
onAddToCollection == null ? null : () => onAddToCollection!(post),
),
); );
} }
+1 -1
View File
@@ -1,7 +1,7 @@
name: gel_rule_app name: gel_rule_app
description: Local backend/core layer for a Flutter booru browser app. description: Local backend/core layer for a Flutter booru browser app.
publish_to: "none" publish_to: "none"
version: 3.5.5+81 version: 3.7.0+88
environment: environment:
sdk: ">=3.3.0 <4.0.0" sdk: ">=3.3.0 <4.0.0"