Compare commits

..
9 Commits
Author SHA1 Message Date
RarDog 3c91af8790 Release v3.7.3: Add artist posts row and fix comic page indicator
Build and Release / Build Windows x64 (push) Has been cancelled
Build and Release / Build macOS (push) Has been cancelled
2026-09-05 21:27:29 +03:00
RarDog 5434c5dc7b feat: open full e621 comic sequence in post pager with page indicator (v3.7.2)
Build and Release / Build Windows x64 (push) Has been cancelled
Build and Release / Build macOS (push) Has been cancelled
2026-09-05 20:31:59 +03:00
RarDog cd1a9ccc0c feat: add dedicated e621 tab in Favorites and fix comic post-not-found parsing (v3.7.1)
Build and Release / Build Windows x64 (push) Has been cancelled
Build and Release / Build macOS (push) Has been cancelled
2026-09-05 20:19:27 +03:00
RarDog cc6ad2d7bf feat: complete e621 integration with API key auth, comics/pools, post variations, and server sync (v3.7.0)
Build and Release / Build Windows x64 (push) Has been cancelled
Build and Release / Build macOS (push) Has been cancelled
2026-09-05 19:48:52 +03:00
RarDog 54b77726cb fix: remove preview image overlay from video player and restore clean black background (v3.6.5)
Build and Release / Build Windows x64 (push) Has been cancelled
Build and Release / Build macOS (push) Has been cancelled
2026-09-05 18:56:49 +03:00
RarDog 8ad36e2f6b fix: eliminate video player blue screen on desktop with software rendering default and decoder toggle (v3.6.4)
Build and Release / Build Windows x64 (push) Has been cancelled
Build and Release / Build macOS (push) Has been cancelled
2026-09-05 18:48:33 +03:00
RarDog 4aedde9b3f feat: desktop horizontal mouse-wheel scroll & fix PC video player blue screen (v3.6.3)
Build and Release / Build Windows x64 (push) Has been cancelled
Build and Release / Build macOS (push) Has been cancelled
2026-09-05 18:32:45 +03:00
RarDog 5b2c8eb53e fix(perf): memory optimization, prevent OOM on mobile, and completely disable mouse wheel zoom
Build and Release / Build Windows x64 (push) Has been cancelled
Build and Release / Build macOS (push) Has been cancelled
2026-09-05 18:09:58 +03:00
RarDog d7d9cad423 fix(desktop): smooth sidebar hover animation without text jitter and remove mouse wheel zoom
Build and Release / Build Windows x64 (push) Has been cancelled
Build and Release / Build macOS (push) Has been cancelled
2026-09-05 17:58:49 +03:00
27 changed files with 3701 additions and 678 deletions
+39 -38
View File
@@ -4,32 +4,33 @@
<application
android:label="Prisma"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:largeHeap="true">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
@@ -43,15 +44,15 @@
android:resource="@xml/file_paths" />
</provider>
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>
+74
View File
@@ -1,4 +1,78 @@
const prismaChangelog = [
PrismaChange(
version: '3.7.2',
title: 'Режим непрерывного чтения комиксов и серий e621',
bullets: [
'Мгновенное открытие комикса целиком: по нажатию «Читать весь комикс» загружаются все страницы серии в оригинальном порядке и открываются в полноэкранном ридере со свайпом (влево-вправо на мобильных, стрелками и кнопками на ПК).',
'Плавающий бейдж страниц: во время чтения комикса отображается аккуратный стеклянный индикатор номера страницы («Комикс • Стр. X из Y»).',
'Быстрое оглавление: отдельная кнопка сетки страниц для перехода на любую конкретную главу или страницу без потери последовательности чтения.',
],
),
PrismaChange(
version: '3.7.1',
title: 'Вкладка «e621» в Избранном и исправление листания комиксов',
bullets: [
'Отдельная вкладка «e621» в разделе «Избранное»: прямое отображение серверного избранного привязанного аккаунта e621 (своего или друга), плавная подгрузка при скролле и кнопка импорта в локальные закладки.',
'Исправление ошибки «Post not found» в комиксах: исправлен парсер ответов e621 для одиночных постов (/posts/<id>.json), теперь перелистывание страниц серий и переход по вариациям работают стабильно.',
],
),
PrismaChange(
version: '3.7.0',
title: 'Большое обновление e621: авторизация по API-ключу, комиксы/пулы, вариации постов и синхронизация',
bullets: [
'Окно авторизации e621 (Username + API Key): красивое модальное окно прямо в карточке источника e621 с быстрой проверкой ключа, снятием жесткого лимита в 2 тега при поиске и персональным User-Agent.',
'Комиксы и Пулы (Pools): удобная модальная панель для серий и комиксов с индикатором страниц, навигацией «Предыдущая/Следующая» и сеткой всех страниц серии для быстрого перехода.',
'Связанные посты и вариации (Parent/Child relations): карточка версий с быстрым переходом к родительскому арту и альтернативным вариациям (позы, одежда, скетчи).',
'Серверное голосование и синхронизация Избранного: возможность ставить лайки/дизлайки с отправкой на e621 и автоматическая синхронизация избранного при наличии API-ключа.',
'Защита Rate Limiting: интеллектуальное ограничение частоты запросов (до 2 rps), предотвращающее ошибки 503 и блокировки по IP.',
],
),
PrismaChange(
version: '3.6.5',
title: 'Исправление наложения превью на видеоплеер',
bullets: [
'Устранение наложения превью-картинки на воспроизводимое видео: убран фоновый слой постера под видеоплеером и восстановлен классический черный бэкграунд для правильного отображения границ и леттербоксинга видео любого формата.',
'Чистый рендеринг видео в карточках ленты: предпросмотр видео в ленте также изолирован от просвечивания подложки.',
],
),
PrismaChange(
version: '3.6.4',
title: 'Полное исправление воспроизведения видео на ПК (устранение синего экрана)',
bullets: [
'Программный рендеринг (S/W) по умолчанию для ПК: на десктопных платформах отключен проблемный OpenGL FBO мост аппаратного ускорения, вызывавший синий экран из-за отсутствия или конфликтов драйверов VAAPI/VDPAU в Linux/AppImage.',
'Переключатель декодера H/W / S/W в плеере: на верхней панели управления видеоплеером добавлена кнопка быстрого переключения между аппаратным (H/W) и программным (S/W) декодированием на лету.',
'Отображение постера-превью: под видеоплеером теперь отображается превью-постер на время буферизации, исключая появление пустых областей.',
'Безопасное превью в ленте: предпросмотр видео в карточках постов на ПК также переведен на надежный программный рендерер.',
],
),
PrismaChange(
version: '3.6.3',
title: 'Горизонтальный скролл колесиком мыши на ПК и исправление синего экрана в видеоплеере',
bullets: [
'Горизонтальная прокрутка колесиком мыши в настройках: верхняя панель категорий настроек теперь плавно прокручивается колесиком мыши строго при наведении курсора.',
'Горизонтальная прокрутка колесиком мыши в авторах: полоса любимых авторов и лента их медиа теперь поддерживают быструю прокрутку колесиком мыши при наведении курсора.',
'Устранение «синего экрана» в видеоплеере на ПК: исправлена нативная инициализация MediaKit перед запуском Flutter-движка и настроен оптимальный режим аппаратного ускорения для десктопных платформ.',
],
),
PrismaChange(
version: '3.6.2',
title: 'Комплексная оптимизация памяти на телефоне и ПК, полное отключение зума колесиком',
bullets: [
'Полное отключение масштабирования колесиком мыши: перехвачен внутренний обработчик PointerScrollEvent в интерактивном вьюере, что навсегда исключило нежелательный зум при прокрутке.',
'Предотвращение вылетов на смартфонах (OOM): включен флаг android:largeHeap="true", расширяющий доступный лимит оперативной памяти процесса до 512–1024 МБ.',
'Ограничение аппетита кэша изображений: глобальный ImageCache в Flutter ограничен безопасным лимитом в 180 МБ и 120 изображений (вместо неограниченного роста до 1 ГБ+), неактивные растры своевременно выгружаются сборщиком мусора.',
'Оптимизация декодирования тяжелых артов: полноразмерные изображения с экстремальным разрешением (до 10K+) теперь безопасно декодируются с ограничением memCacheWidth под экран устройства, снижая расход RAM на 80–90%.',
'Оптимизация сетки и миниатюр: карточки ленты изолированы в RepaintBoundary для гладкого 120 FPS скролла без паразитных перерисовок, а всем каруселям, аватаркам и мозаикам заданы строгие границы кэширования.',
],
),
PrismaChange(
version: '3.6.1',
title: 'Плавная боковая панель без лагов и отключение зума колесиком',
bullets: [
'Устранение лагов боковой панели: полностью переработана анимация наведения на боковую панель на ПК. Убран тяжелый шейдерный блюр при изменении ширины и зафиксирована верстка элементов — текст больше не сжимается, не дергается и раскрывается на 120 FPS максимально плавно.',
'Отключение зума фото по колесику мыши: исключено случайное и резкое масштабирование изображений при прокрутке колесиком мыши. Масштабирование фото доступно через горячие клавиши (+, -, 0, F) или двойной клик.',
],
),
PrismaChange(
version: '3.6.0',
title: 'Редизайн интерфейса для ПК и система горячих клавиш (хоткеи)',
+45 -2
View File
@@ -6,8 +6,19 @@ class E621Mapper {
required String providerId,
required String providerName,
}) {
final posts = data is Map ? data['posts'] : data;
final items = posts is List ? posts : const [];
if (data is Map && data['post'] is Map) {
return [
postFromJson(
Map<String, dynamic>.from(data['post'] as Map),
providerId: providerId,
providerName: providerName,
),
];
}
final posts = data is Map ? (data['posts'] ?? data['post']) : data;
final items = posts is List
? posts
: (posts is Map ? [posts] : const []);
return items
.whereType<Map>()
.map((item) => postFromJson(
@@ -36,6 +47,38 @@ class E621Mapper {
for (final entry in tagsMap.entries)
entry.key: List<String>.from((entry.value as List?) ?? const []),
}..removeWhere((_, value) => value.isEmpty);
final relationships = Map<String, dynamic>.from((json['relationships'] as Map?) ?? const {});
final parentId = relationships['parent_id']?.toString();
if (parentId != null && parentId.isNotEmpty && parentId != 'null') {
tagGroups['parent_id'] = [parentId];
}
final children = (relationships['children'] as List?)
?.map((e) => e?.toString() ?? '')
.where((s) => s.isNotEmpty && s != 'null')
.toList();
if (children != null && children.isNotEmpty) {
tagGroups['children'] = children;
}
final rawPools = (json['pools'] as List?)
?.map((e) => e?.toString() ?? '')
.where((s) => s.isNotEmpty)
.toList();
if (rawPools != null && rawPools.isNotEmpty) {
tagGroups['pools'] = rawPools;
}
final favCount = json['fav_count'];
if (favCount != null) {
tagGroups['fav_count'] = [favCount.toString()];
}
final desc = _string(json['description']);
if (desc.isNotEmpty) {
tagGroups['description'] = [desc];
}
final tags = tagGroups.values.expand((items) => items).toSet().toList();
final fileUrl = _string(file['url']);
return Post(
+58
View File
@@ -0,0 +1,58 @@
class E621Pool {
const E621Pool({
required this.id,
required this.name,
required this.description,
required this.postIds,
required this.postCount,
required this.category,
required this.isActive,
this.creatorName,
});
final int id;
final String name;
final String description;
final List<String> postIds;
final int postCount;
final String category; // 'series' or 'collection'
final bool isActive;
final String? creatorName;
String get cleanTitle => name.replaceAll('_', ' ');
int pageOf(String postId) {
final idx = postIds.indexOf(postId);
return idx >= 0 ? idx + 1 : 0;
}
String? nextPostId(String postId) {
final idx = postIds.indexOf(postId);
if (idx >= 0 && idx < postIds.length - 1) {
return postIds[idx + 1];
}
return null;
}
String? previousPostId(String postId) {
final idx = postIds.indexOf(postId);
if (idx > 0) {
return postIds[idx - 1];
}
return null;
}
factory E621Pool.fromJson(Map<String, dynamic> json) {
final rawPostIds = (json['post_ids'] as List?) ?? const [];
return E621Pool(
id: (json['id'] as num?)?.toInt() ?? 0,
name: (json['name'] ?? '').toString(),
description: (json['description'] ?? '').toString(),
postIds: rawPostIds.map((e) => e.toString()).toList(),
postCount: (json['post_count'] as num?)?.toInt() ?? rawPostIds.length,
category: (json['category'] ?? 'series').toString(),
isActive: (json['is_active'] as bool?) ?? true,
creatorName: json['creator_name']?.toString(),
);
}
}
+21
View File
@@ -62,6 +62,27 @@ class Post {
String? get description => commentary;
String? get parentId {
final list = tagGroups['parent_id'];
return (list != null && list.isNotEmpty && list.first.trim().isNotEmpty)
? list.first.trim()
: null;
}
List<String> get childrenIds => tagGroups['children'] ?? const [];
List<String> get poolIds => tagGroups['pools'] ?? const [];
bool get hasRelations =>
(parentId != null && parentId!.isNotEmpty) || childrenIds.isNotEmpty;
bool get hasPools => poolIds.isNotEmpty;
int? get favCount {
final raw = tagGroups['fav_count']?.firstOrNull;
return raw != null ? int.tryParse(raw) : null;
}
String? get title {
final list = tagGroups['copyright'];
if (list != null && list.isNotEmpty) {
+136
View File
@@ -2,6 +2,7 @@ import 'package:dio/dio.dart';
import '../../core/http/dio_client.dart';
import '../mappers/e621_mapper.dart';
import '../models/e621_pool.dart';
import '../models/post.dart';
import '../models/post_comment.dart';
import '../models/provider_health.dart';
@@ -33,6 +34,34 @@ class E621Provider
final Dio _dio;
final Map<String, String> _queryParameters;
static DateTime _lastRequestTime = DateTime.fromMillisecondsSinceEpoch(0);
static Future<void> _throttle() async {
final now = DateTime.now();
final diff = now.difference(_lastRequestTime);
if (diff.inMilliseconds < 500) {
final waitMs = 500 - diff.inMilliseconds;
await Future<void>.delayed(Duration(milliseconds: waitMs));
}
_lastRequestTime = DateTime.now();
}
bool get isAuthorized =>
_queryParameters.containsKey('login') ||
_queryParameters.containsKey('query.login') ||
_dio.options.headers.containsKey('Authorization');
String? get login =>
_queryParameters['login'] ?? _queryParameters['query.login'];
Future<List<Post>> getFavorites({int page = 1, int limit = 50}) async {
final currentLogin = login?.trim();
final targetTag = (currentLogin != null && currentLogin.isNotEmpty)
? 'fav:$currentLogin'
: 'fav:me';
return searchPosts(tags: [targetTag], page: page - 1, limit: limit);
}
@override
String postPageUrl(Post post) => '$baseUrl/posts/${post.id}';
@@ -44,6 +73,7 @@ class E621Provider
String? rating,
TopPeriodFilter topPeriod = TopPeriodFilter.none,
}) async {
await _throttle();
final response = await _dio.get<dynamic>(
'/posts.json',
queryParameters: {
@@ -66,6 +96,7 @@ class E621Provider
@override
Future<Post?> getPost(String id) async {
await _throttle();
final response = await _dio.get<dynamic>(
'/posts/$id.json',
queryParameters: _queryParameters,
@@ -78,6 +109,109 @@ class E621Provider
return posts.isEmpty ? null : posts.first;
}
Future<E621Pool?> getPool(String poolId) async {
await _throttle();
try {
final response = await _dio.get<dynamic>(
'/pools/$poolId.json',
queryParameters: _queryParameters,
);
if (response.data is Map) {
return E621Pool.fromJson(Map<String, dynamic>.from(response.data as Map));
}
} catch (_) {}
return null;
}
Future<List<Post>> getPoolPosts(String poolId) async {
final pool = await getPool(poolId);
if (pool == null || pool.postIds.isEmpty) return const [];
final fetchedMap = <String, Post>{};
int page = 1;
while (true) {
await _throttle();
try {
final response = await _dio.get<dynamic>(
'/posts.json',
queryParameters: {
'page': page,
'limit': isAuthorized ? 320 : 75,
'tags': 'pool:$poolId',
..._queryParameters,
},
);
final items = E621Mapper.postsFromResponse(
response.data,
providerId: id,
providerName: name,
);
if (items.isEmpty) break;
for (final item in items) {
fetchedMap[item.id] = item;
}
if (items.length < (isAuthorized ? 320 : 75) ||
fetchedMap.length >= pool.postIds.length) {
break;
}
page++;
} catch (_) {
break;
}
}
final ordered = <Post>[];
for (final id in pool.postIds) {
if (fetchedMap.containsKey(id)) {
ordered.add(fetchedMap[id]!);
}
}
return ordered.isNotEmpty ? ordered : fetchedMap.values.toList();
}
Future<bool> votePost(String postId, int score) async {
await _throttle();
try {
final response = await _dio.post<dynamic>(
'/posts/$postId/votes.json',
data: {'score': score, 'no_unvote': 'true'},
queryParameters: _queryParameters,
options: Options(contentType: Headers.formUrlEncodedContentType),
);
return response.statusCode == 200 || response.statusCode == 201;
} catch (_) {
return false;
}
}
Future<bool> addFavorite(String postId) async {
await _throttle();
try {
final response = await _dio.post<dynamic>(
'/favorites.json',
data: {'post_id': postId},
queryParameters: _queryParameters,
options: Options(contentType: Headers.formUrlEncodedContentType),
);
return response.statusCode == 200 || response.statusCode == 201;
} catch (_) {
return false;
}
}
Future<bool> removeFavorite(String postId) async {
await _throttle();
try {
final response = await _dio.delete<dynamic>(
'/favorites/$postId.json',
queryParameters: _queryParameters,
);
return response.statusCode == 200 || response.statusCode == 204;
} catch (_) {
return false;
}
}
@override
Future<ProviderHealth> checkHealth() async {
final startedAt = DateTime.now();
@@ -109,6 +243,7 @@ class E621Provider
Future<List<TagSuggestion>> suggestTags(String query,
{int limit = 20}) async {
if (query.trim().isEmpty) return const [];
await _throttle();
final response = await _dio.get<dynamic>(
'/tags.json',
queryParameters: {
@@ -136,6 +271,7 @@ class E621Provider
@override
Future<List<PostComment>> getComments(String postId) async {
await _throttle();
final response = await _dio.get<dynamic>(
'/comments.json',
queryParameters: {
@@ -1,3 +1,5 @@
import 'dart:convert';
import '../../core/http/dio_client.dart';
import '../models/content_provider_config.dart';
import 'content_provider.dart';
@@ -20,6 +22,17 @@ class ProviderFactory {
if (entry.key.startsWith('query.') && entry.value.trim().isNotEmpty)
entry.key.substring('query.'.length): entry.value.trim(),
};
if (config.apiType.toLowerCase() == 'e621') {
final login = queryParameters['login'] ?? headers['login'];
final apiKey = queryParameters['api_key'] ?? headers['api_key'];
if (login != null && apiKey != null && login.isNotEmpty && apiKey.isNotEmpty) {
final basicAuth = base64Encode(utf8.encode('$login:$apiKey'));
headers['Authorization'] = 'Basic $basicAuth';
headers['User-Agent'] = 'Prisma/3.6.6 (by $login on e621)';
}
}
final client = DioClient(
baseUrl: config.baseUrl,
timeout: Duration(seconds: config.timeoutSeconds),
@@ -144,6 +144,14 @@ class ProviderManager {
});
}
Future<ContentProvider?> getProviderInstance(String id) async {
final result = await _repository.getProvider(id);
if (result is Success<ContentProviderConfig?> && result.data != null) {
return _factory.create(result.data!);
}
return null;
}
Future<Result<List<ProviderHealth>>> checkAll({int concurrency = 3}) async {
final providersResult = await activeProviders();
if (providersResult is Error<List<ContentProvider>>) {
@@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:convert';
import 'dart:ui';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
@@ -1328,7 +1329,7 @@ class _FavoriteArtistsModalState extends State<_FavoriteArtistsModal> {
}
}
class _FavoriteArtistsSection extends ConsumerWidget {
class _FavoriteArtistsSection extends StatefulWidget {
const _FavoriteArtistsSection({
required this.favorites,
required this.selectedKey,
@@ -1344,8 +1345,23 @@ class _FavoriteArtistsSection extends ConsumerWidget {
final ValueChanged<FavoriteArtistItem> onRemoveFavorite;
@override
Widget build(BuildContext context, WidgetRef ref) {
if (favorites.isEmpty) {
State<_FavoriteArtistsSection> createState() =>
_FavoriteArtistsSectionState();
}
class _FavoriteArtistsSectionState extends State<_FavoriteArtistsSection> {
final ScrollController _scrollController = ScrollController();
bool _isHovered = false;
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (widget.favorites.isEmpty) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 8),
child: _ArtistsLiquidCard(
@@ -1368,9 +1384,9 @@ class _FavoriteArtistsSection extends ConsumerWidget {
);
}
final selected = favorites.firstWhere(
(f) => f.key == selectedKey,
orElse: () => favorites.first,
final selected = widget.favorites.firstWhere(
(f) => f.key == widget.selectedKey,
orElse: () => widget.favorites.first,
);
return Padding(
@@ -1385,14 +1401,14 @@ class _FavoriteArtistsSection extends ConsumerWidget {
const Icon(Icons.star_rounded, color: Colors.amber, size: 20),
const SizedBox(width: 6),
Text(
'Любимые авторы (${favorites.length})',
'Любимые авторы (${widget.favorites.length})',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
const Spacer(),
TextButton(
onPressed: () => onOpenArtist(selected),
onPressed: () => widget.onOpenArtist(selected),
style: TextButton.styleFrom(
visualDensity: VisualDensity.compact,
padding: const EdgeInsets.symmetric(horizontal: 8),
@@ -1409,111 +1425,136 @@ class _FavoriteArtistsSection extends ConsumerWidget {
],
),
const SizedBox(height: 8),
SizedBox(
height: 88,
child: ListView.separated(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4),
scrollDirection: Axis.horizontal,
itemCount: favorites.length,
separatorBuilder: (_, __) => const SizedBox(width: 10),
itemBuilder: (context, index) {
final fav = favorites[index];
final isSelected = fav.key == selected.key;
final sColor = _serviceColor(fav.service);
return InkWell(
borderRadius: BorderRadius.circular(16),
onTap: () => onSelect(fav),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
decoration: BoxDecoration(
color: isSelected
? Theme.of(context)
.colorScheme
.primaryContainer
.withValues(alpha: 0.45)
: Colors.transparent,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: isSelected
? Theme.of(context).colorScheme.primary
: Colors.transparent,
width: 1.5,
),
boxShadow: isSelected
? [
BoxShadow(
color: Theme.of(context)
.colorScheme
.primary
.withValues(alpha: 0.25),
blurRadius: 8,
),
]
: null,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: isSelected
? Theme.of(context).colorScheme.primary
: sColor.withValues(alpha: 0.4),
width: 1.2,
),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(13),
child: fav.avatarUrl != null &&
fav.avatarUrl!.isNotEmpty
? CachedNetworkImage(
imageUrl: fav.avatarUrl!,
fit: BoxFit.cover,
placeholder: (_, __) => const Icon(
Icons.person_rounded,
size: 20),
errorWidget: (_, __, ___) => const Icon(
Icons.person_rounded,
size: 20),
)
: Center(
child: Text(
fav.name.isNotEmpty
? fav.name[0].toUpperCase()
: '?',
style: const TextStyle(
fontWeight: FontWeight.bold),
),
),
),
),
const SizedBox(height: 4),
SizedBox(
width: 68,
child: Text(
fav.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 11,
fontWeight: isSelected
? FontWeight.bold
: FontWeight.normal,
),
),
),
],
),
),
MouseRegion(
onEnter: (_) => _isHovered = true,
onExit: (_) => _isHovered = false,
child: Listener(
onPointerSignal: (event) {
if (!_isHovered ||
event is! PointerScrollEvent ||
!_scrollController.hasClients) {
return;
}
final delta = event.scrollDelta.dy != 0
? event.scrollDelta.dy
: event.scrollDelta.dx;
if (delta == 0) return;
final target = (_scrollController.offset + delta * 1.5).clamp(
0.0,
_scrollController.position.maxScrollExtent,
);
_scrollController.jumpTo(target);
},
child: SizedBox(
height: 88,
child: ListView.separated(
controller: _scrollController,
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4),
scrollDirection: Axis.horizontal,
itemCount: widget.favorites.length,
separatorBuilder: (_, __) => const SizedBox(width: 10),
itemBuilder: (context, index) {
final fav = widget.favorites[index];
final isSelected = fav.key == selected.key;
final sColor = _serviceColor(fav.service);
return InkWell(
borderRadius: BorderRadius.circular(16),
onTap: () => widget.onSelect(fav),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
decoration: BoxDecoration(
color: isSelected
? Theme.of(context)
.colorScheme
.primaryContainer
.withValues(alpha: 0.45)
: Colors.transparent,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: isSelected
? Theme.of(context).colorScheme.primary
: Colors.transparent,
width: 1.5,
),
boxShadow: isSelected
? [
BoxShadow(
color: Theme.of(context)
.colorScheme
.primary
.withValues(alpha: 0.25),
blurRadius: 8,
),
]
: null,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: isSelected
? Theme.of(context).colorScheme.primary
: sColor.withValues(alpha: 0.4),
width: 1.2,
),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(13),
child: fav.avatarUrl != null &&
fav.avatarUrl!.isNotEmpty
? CachedNetworkImage(
imageUrl: fav.avatarUrl!,
memCacheWidth: 120,
memCacheHeight: 120,
fit: BoxFit.cover,
placeholder: (_, __) => const Icon(
Icons.person_rounded,
size: 20),
errorWidget: (_, __, ___) => const Icon(
Icons.person_rounded,
size: 20),
)
: Center(
child: Text(
fav.name.isNotEmpty
? fav.name[0].toUpperCase()
: '?',
style: const TextStyle(
fontWeight: FontWeight.bold),
),
),
),
),
const SizedBox(height: 4),
SizedBox(
width: 68,
child: Text(
fav.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 11,
fontWeight: isSelected
? FontWeight.bold
: FontWeight.normal,
),
),
),
],
),
),
);
},
),
),
),
),
const SizedBox(height: 6),
@@ -1525,18 +1566,34 @@ class _FavoriteArtistsSection extends ConsumerWidget {
}
}
class _FavoriteArtistMediaStrip extends ConsumerWidget {
class _FavoriteArtistMediaStrip extends ConsumerStatefulWidget {
const _FavoriteArtistMediaStrip({required this.artist});
final FavoriteArtistItem artist;
@override
Widget build(BuildContext context, WidgetRef ref) {
ConsumerState<_FavoriteArtistMediaStrip> createState() =>
_FavoriteArtistMediaStripState();
}
class _FavoriteArtistMediaStripState
extends ConsumerState<_FavoriteArtistMediaStrip> {
final ScrollController _scrollController = ScrollController();
bool _isHovered = false;
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final query = ArtistWorkQuery(
providerId: artist.providerId,
service: artist.service,
artistId: artist.id,
artistName: artist.name,
providerId: widget.artist.providerId,
service: widget.artist.service,
artistId: widget.artist.id,
artistName: widget.artist.name,
);
final asyncPosts = ref.watch(artistPostsProvider(query));
@@ -1564,76 +1621,101 @@ class _FavoriteArtistMediaStrip extends ConsumerWidget {
),
);
}
return SizedBox(
height: 96,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: posts.length.clamp(0, 15),
separatorBuilder: (_, __) => const SizedBox(width: 8),
itemBuilder: (context, index) {
final post = posts[index];
final isVideo = MediaUrlSelector.isVideo(post);
return InkWell(
borderRadius: BorderRadius.circular(14),
onTap: () => AppNavigator.openPost(
context,
post: post,
postsList: posts,
),
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: Colors.white.withValues(alpha: 0.15),
width: 1.0,
),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(13),
child: Stack(
children: [
SizedBox(
width: 96,
height: 96,
child: CachedNetworkImage(
imageUrl: post.previewUrl.isNotEmpty
? post.previewUrl
: post.sampleUrl,
fit: BoxFit.cover,
placeholder: (_, __) => ColoredBox(
color: Theme.of(context)
.colorScheme
.surfaceContainerHighest,
),
errorWidget: (_, __, ___) => ColoredBox(
color: Theme.of(context)
.colorScheme
.surfaceContainerHighest,
child: const Icon(Icons.broken_image_rounded,
size: 24),
),
),
),
if (isVideo)
Positioned(
bottom: 6,
right: 6,
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.7),
shape: BoxShape.circle,
),
child: const Icon(Icons.play_arrow_rounded,
color: Colors.white, size: 14),
),
),
],
),
),
),
return MouseRegion(
onEnter: (_) => _isHovered = true,
onExit: (_) => _isHovered = false,
child: Listener(
onPointerSignal: (event) {
if (!_isHovered ||
event is! PointerScrollEvent ||
!_scrollController.hasClients) {
return;
}
final delta = event.scrollDelta.dy != 0
? event.scrollDelta.dy
: event.scrollDelta.dx;
if (delta == 0) return;
final target = (_scrollController.offset + delta * 1.5).clamp(
0.0,
_scrollController.position.maxScrollExtent,
);
_scrollController.jumpTo(target);
},
child: SizedBox(
height: 96,
child: ListView.separated(
controller: _scrollController,
scrollDirection: Axis.horizontal,
itemCount: posts.length.clamp(0, 15),
separatorBuilder: (_, __) => const SizedBox(width: 8),
itemBuilder: (context, index) {
final post = posts[index];
final isVideo = MediaUrlSelector.isVideo(post);
return InkWell(
borderRadius: BorderRadius.circular(14),
onTap: () => AppNavigator.openPost(
context,
post: post,
postsList: posts,
),
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: Colors.white.withValues(alpha: 0.15),
width: 1.0,
),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(13),
child: Stack(
children: [
SizedBox(
width: 96,
height: 96,
child: CachedNetworkImage(
imageUrl: post.previewUrl.isNotEmpty
? post.previewUrl
: post.sampleUrl,
memCacheWidth: 200,
memCacheHeight: 200,
fit: BoxFit.cover,
placeholder: (_, __) => ColoredBox(
color: Theme.of(context)
.colorScheme
.surfaceContainerHighest,
),
errorWidget: (_, __, ___) => ColoredBox(
color: Theme.of(context)
.colorScheme
.surfaceContainerHighest,
child: const Icon(Icons.broken_image_rounded,
size: 24),
),
),
),
if (isVideo)
Positioned(
bottom: 6,
right: 6,
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.7),
shape: BoxShape.circle,
),
child: const Icon(Icons.play_arrow_rounded,
color: Colors.white, size: 14),
),
),
],
),
),
),
);
},
),
),
),
);
},
@@ -1706,6 +1788,8 @@ class _ArtistAvatar extends StatelessWidget {
? fallback
: CachedNetworkImage(
imageUrl: artist.avatarUrl!,
memCacheWidth: 140,
memCacheHeight: 140,
fit: BoxFit.cover,
placeholder: (_, __) => fallback,
errorWidget: (_, __, ___) => fallback,
@@ -314,6 +314,8 @@ class _CollectionCard extends ConsumerWidget {
if (urls.length == 1) {
return CachedNetworkImage(
imageUrl: urls[0],
memCacheWidth: 260,
memCacheHeight: 260,
fit: BoxFit.cover,
placeholder: (_, __) => Container(color: scheme.surfaceContainerHighest),
errorWidget: (_, __, ___) => Container(
@@ -333,6 +335,8 @@ class _CollectionCard extends ConsumerWidget {
if (i < urls.length)
CachedNetworkImage(
imageUrl: urls[i],
memCacheWidth: 200,
memCacheHeight: 200,
fit: BoxFit.cover,
placeholder: (_, __) =>
Container(color: scheme.surfaceContainerHighest),
@@ -15,6 +15,7 @@ import '../../../shared/widgets/empty_view.dart';
import '../../../shared/widgets/error_view.dart';
import '../../../shared/widgets/post_masonry_grid.dart';
import 'favorites_controller.dart';
import 'widgets/e621_favorites_view.dart';
enum FavoriteMediaType { all, video, image }
enum OfflineSortOption { dateAddedDesc, sizeDesc, artist }
@@ -315,6 +316,17 @@ class _FavoritesScreenState extends ConsumerState<FavoritesScreen> {
overflow: TextOverflow.ellipsis,
),
),
const ButtonSegment(
value: 3,
icon: Icon(Icons.hub_rounded,
size: 18, color: Color(0xFF0077EE)),
label: Text(
'e621',
style: TextStyle(
fontSize: 12, fontWeight: FontWeight.w600),
overflow: TextOverflow.ellipsis,
),
),
],
selected: {_tabIndex},
onSelectionChanged: (value) {
@@ -333,7 +345,7 @@ class _FavoritesScreenState extends ConsumerState<FavoritesScreen> {
),
// Media Type Filter Chips (for Tab 0 and Tab 1)
if (_tabIndex != 2)
if (_tabIndex != 2 && _tabIndex != 3)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: Row(
@@ -399,6 +411,10 @@ class _FavoritesScreenState extends ConsumerState<FavoritesScreen> {
downloaded.keys.toSet(),
isRu,
),
3 => E621FavoritesView(
settings: settings,
isRu: isRu,
),
_ => _buildAllView(
allPosts,
settings,
@@ -1225,6 +1241,8 @@ class _ArtistCard extends StatelessWidget {
),
child: CachedNetworkImage(
imageUrl: url,
memCacheWidth: 128,
memCacheHeight: 128,
fit: BoxFit.cover,
placeholder: (_, __) => Container(
color: scheme.surfaceContainerHighest,
@@ -0,0 +1,464 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../app/app_navigator.dart';
import '../../../../app/responsive.dart';
import '../../../../backend/backend.dart';
import '../../../../backend/providers/e621_provider.dart';
import '../../../../shared/widgets/empty_view.dart';
import '../../../../shared/widgets/post_masonry_grid.dart';
import '../../../providers/presentation/providers_controller.dart';
import '../../../providers/presentation/widgets/e621_auth_dialog.dart';
import '../favorites_controller.dart';
class E621FavoritesView extends ConsumerStatefulWidget {
const E621FavoritesView({
required this.settings,
required this.isRu,
super.key,
});
final AppSettings settings;
final bool isRu;
@override
ConsumerState<E621FavoritesView> createState() => _E621FavoritesViewState();
}
class _E621FavoritesViewState extends ConsumerState<E621FavoritesView> {
final List<Post> _posts = [];
final ScrollController _scrollController = ScrollController();
int _page = 1;
bool _isLoading = false;
bool _hasMore = true;
String? _error;
String? _currentLogin;
bool _isImporting = false;
@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
_loadInitial();
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
void _onScroll() {
if (_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent - 400 &&
!_isLoading &&
_hasMore) {
_loadNextPage();
}
}
Future<E621Provider?> _getE621Provider() async {
final instance = await ref
.read(providerManagerProvider)
.getProviderInstance('e621');
return instance is E621Provider ? instance : null;
}
Future<void> _loadInitial() async {
setState(() {
_isLoading = true;
_error = null;
_posts.clear();
_page = 1;
_hasMore = true;
});
try {
final provider = await _getE621Provider();
if (provider == null) {
setState(() {
_isLoading = false;
_error = widget.isRu
? 'Источник e621 не найден'
: 'e621 provider not found';
});
return;
}
final login = provider.login?.trim();
setState(() => _currentLogin = login);
if (login == null || login.isEmpty) {
setState(() => _isLoading = false);
return;
}
final items = await provider.getFavorites(page: 1, limit: 50);
if (mounted) {
setState(() {
_posts.addAll(items);
_isLoading = false;
_hasMore = items.length >= 25;
_page = 2;
});
}
} catch (e) {
if (mounted) {
setState(() {
_error = widget.isRu ? 'Ошибка загрузки: $e' : 'Loading error: $e';
_isLoading = false;
});
}
}
}
Future<void> _loadNextPage() async {
if (_isLoading || !_hasMore) return;
setState(() => _isLoading = true);
try {
final provider = await _getE621Provider();
if (provider == null) {
setState(() => _isLoading = false);
return;
}
final items = await provider.getFavorites(page: _page, limit: 50);
if (mounted) {
setState(() {
final existingIds = _posts.map((p) => p.id).toSet();
final newItems =
items.where((p) => !existingIds.contains(p.id)).toList();
_posts.addAll(newItems);
_isLoading = false;
_hasMore = items.length >= 25;
_page++;
});
}
} catch (_) {
if (mounted) {
setState(() => _isLoading = false);
}
}
}
Future<void> _importAllToLocal() async {
if (_posts.isEmpty || _isImporting) return;
setState(() => _isImporting = true);
HapticFeedback.mediumImpact();
final favService = ref.read(favoriteServiceProvider);
int imported = 0;
for (final post in _posts) {
await favService.addFavorite(post);
imported++;
}
ref.invalidate(favoriteKeysProvider);
ref.invalidate(favoritesControllerProvider);
if (mounted) {
setState(() => _isImporting = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
widget.isRu
? 'Импортировано $imported постов в локальное избранное!'
: 'Imported $imported posts to local favorites!',
),
duration: const Duration(seconds: 3),
),
);
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
final downloaded = ref.watch(
downloadedMediaByKeysProvider(
_posts.map((post) => post.cacheKey).toList(growable: false),
),
).value ??
const <String, DownloadedMedia>{};
final favoriteKeys = ref.watch(favoriteKeysProvider).value ?? <String>{};
// If no login is set
if (_currentLogin == null || _currentLogin!.isEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.all(28),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 68,
height: 68,
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFF0055AA), Color(0xFF0088FF)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(22),
boxShadow: [
BoxShadow(
color: const Color(0xFF0055AA).withValues(alpha: 0.35),
blurRadius: 16,
offset: const Offset(0, 6),
),
],
),
child: const Icon(
Icons.hub_rounded,
size: 34,
color: Colors.white,
),
),
const SizedBox(height: 18),
Text(
widget.isRu ? 'Аккаунт e621 не указан' : 'e621 account not set',
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 380),
child: Text(
widget.isRu
? 'Чтобы смотреть избранное (своё или друга) прямо здесь, укажите логин и API-ключ в настройках источника e621.'
: 'To browse favorites directly here, provide your e621 login and API key.',
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
const SizedBox(height: 22),
FilledButton.icon(
onPressed: () async {
final configs =
ref.read(providersControllerProvider).value ?? [];
final e621Config = configs.firstWhere(
(p) => p.apiType.toLowerCase() == 'e621',
orElse: () => configs.first,
);
await E621AuthDialog.show(
context,
config: e621Config,
onSaved: (updated) {
ref
.read(providersControllerProvider.notifier)
.save(updated);
_loadInitial();
},
);
},
icon: const Icon(Icons.vpn_key_rounded, size: 18),
label: Text(
widget.isRu
? 'Войти / Настроить API-ключ e621'
: 'Set e621 API Key',
),
style: FilledButton.styleFrom(
backgroundColor: const Color(0xFF0055AA),
padding: const EdgeInsets.symmetric(
horizontal: 20, vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
),
),
],
),
),
);
}
return RefreshIndicator(
onRefresh: _loadInitial,
child: Column(
children: [
// Header Banner
Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 8),
child: ClipRRect(
borderRadius: BorderRadius.circular(18),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 14, sigmaY: 14),
child: Container(
padding:
const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: isDark
? const Color(0xFF131A26).withValues(alpha: 0.85)
: Colors.white.withValues(alpha: 0.85),
borderRadius: BorderRadius.circular(18),
border: Border.all(
color: isDark
? const Color(0xFF0055AA).withValues(alpha: 0.35)
: const Color(0xFF0055AA).withValues(alpha: 0.20),
),
),
child: Row(
children: [
Container(
width: 38,
height: 38,
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFF0055AA), Color(0xFF0077EE)],
),
borderRadius: BorderRadius.circular(12),
),
child: const Icon(
Icons.hub_rounded,
color: Colors.white,
size: 20,
),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Избранное: @$_currentLogin',
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 13,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
Text(
widget.isRu
? '${_posts.length} постов на сервере e621'
: '${_posts.length} posts on e621',
style: TextStyle(
fontSize: 11,
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
if (_posts.isNotEmpty)
IconButton.filledTonal(
tooltip: widget.isRu
? 'Импортировать в локальное избранное'
: 'Import to local favorites',
onPressed: _isImporting ? null : _importAllToLocal,
icon: _isImporting
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2),
)
: const Icon(Icons.download_rounded, size: 18),
),
const SizedBox(width: 4),
IconButton.filledTonal(
tooltip: widget.isRu ? 'Обновить' : 'Refresh',
onPressed: _isLoading ? null : _loadInitial,
icon: const Icon(Icons.refresh_rounded, size: 18),
),
],
),
),
),
),
),
// Content
Expanded(
child: _isLoading && _posts.isEmpty
? const Center(child: CircularProgressIndicator())
: _error != null && _posts.isEmpty
? Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.error_outline_rounded,
size: 40, color: Color(0xFFEF4444)),
const SizedBox(height: 12),
Text(_error!),
const SizedBox(height: 12),
FilledButton.tonal(
onPressed: _loadInitial,
child: Text(widget.isRu ? 'Повторить' : 'Retry'),
),
],
),
)
: _posts.isEmpty
? Center(
child: EmptyView(
title: widget.isRu
? 'У пользователя нет избранных постов'
: 'No favorite posts for this user',
),
)
: ListView(
controller: _scrollController,
padding: const EdgeInsets.symmetric(horizontal: 8),
children: [
PostMasonryGrid(
posts: _posts,
columns: Responsive.columnsFor(
context,
mobileColumns: widget.settings.mobileColumns,
desktopColumns:
widget.settings.desktopColumns,
),
blurExplicit:
widget.settings.blurExplicitContent,
showBadges: widget.settings.showPostBadges,
nsfwEnabled: widget.settings.nsfwEnabled,
mediaQualityMode: MediaQualityMode.fromName(
widget.settings.mediaQualityMode,
),
favoriteKeys: favoriteKeys,
downloadedKeys: downloaded.keys.toSet(),
onOpen: (post) => AppNavigator.openPost(
context,
post: post,
postsList: _posts,
),
onFavorite: (post) async {
final isFav =
favoriteKeys.contains(post.cacheKey);
final favService =
ref.read(favoriteServiceProvider);
if (isFav) {
await favService.removeFavorite(
post.id, post.providerId);
} else {
await favService.addFavorite(post);
}
ref.invalidate(favoriteKeysProvider);
ref.invalidate(favoritesControllerProvider);
},
),
if (_isLoading)
const Padding(
padding: EdgeInsets.all(20),
child: Center(
child: CircularProgressIndicator(),
),
),
],
),
),
],
),
);
}
}
@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
@@ -28,7 +29,10 @@ import 'post_details_controller.dart';
import 'widgets/cloud_mirrors_card.dart';
import 'widgets/post_action_bar.dart';
import 'widgets/post_media_viewer.dart';
import 'widgets/post_pools_card.dart';
import 'widgets/post_relations_card.dart';
import 'widgets/post_tags_panel.dart';
import '../../../backend/providers/e621_provider.dart';
final postCommentsProvider =
FutureProvider.family<List<PostComment>, PostDetailsArgs>(
@@ -39,6 +43,23 @@ final postCommentsProvider =
return result is Success<List<PostComment>> ? result.data : const [];
});
final postProviderInstanceProvider =
FutureProvider.family<ContentProvider?, String>((ref, providerId) async {
return ref.watch(providerManagerProvider).getProviderInstance(providerId);
});
/// Fetches up to 6 recent posts from the same artist.
final artistPostsProvider =
FutureProvider.family<List<Post>, String>((ref, artistTag) async {
if (artistTag.isEmpty) return const [];
final result = await ref.watch(feedServiceProvider).refresh(
tags: [artistTag],
limit: 7,
);
if (result is! Success<List<Post>>) return const [];
return (result as Success<List<Post>>).data.take(6).toList(growable: false);
});
class PostDetailsScreen extends ConsumerWidget {
const PostDetailsScreen({
required this.providerId,
@@ -123,6 +144,11 @@ class PostDetailsScreen extends ConsumerWidget {
(post.commentary != null &&
post.commentary!.trim().isNotEmpty));
final providerInstance =
ref.watch(postProviderInstanceProvider(post.providerId)).value;
final e621Provider =
providerInstance is E621Provider ? providerInstance : null;
if (Responsive.isMobile(context)) {
if (currentIndex >= 0 && feedPosts.length > 1) {
return _MobilePostPager(
@@ -361,7 +387,29 @@ class PostDetailsScreen extends ConsumerWidget {
strings: strings,
localMedia: localMedia,
fileSizeBytes: fileSizeBytes,
e621Provider: e621Provider,
),
_ArtistPostsCard(post: post),
if (post.hasRelations) ...[
const SizedBox(height: 16),
PostRelationsCard(
post: post,
onOpenPostId: (targetId) =>
_openPostById(context, post.providerId, targetId),
),
],
if (post.hasPools && e621Provider != null) ...[
const SizedBox(height: 16),
PostPoolsCard(
post: post,
provider: e621Provider,
onOpenComic: (poolId, currentPost, [targetId]) =>
_openComic(context, e621Provider, poolId,
currentPost, targetId),
onOpenPostId: (targetId) =>
_openPostById(context, post.providerId, targetId),
),
],
if (shouldShowCloudCard) ...[
const SizedBox(height: 16),
CloudMirrorsCard(
@@ -470,6 +518,11 @@ class PostDetailsScreen extends ConsumerWidget {
(post.commentary != null &&
post.commentary!.trim().isNotEmpty));
final providerInstance =
ref.watch(postProviderInstanceProvider(post.providerId)).value;
final e621Provider =
providerInstance is E621Provider ? providerInstance : null;
return GestureDetector(
behavior: HitTestBehavior.translucent,
onVerticalDragEnd: (details) {
@@ -596,7 +649,28 @@ class PostDetailsScreen extends ConsumerWidget {
strings: strings,
localMedia: localMedia,
fileSizeBytes: fileSizeBytes,
e621Provider: e621Provider,
),
_ArtistPostsCard(post: post),
if (post.hasRelations) ...[
const SizedBox(height: 12),
PostRelationsCard(
post: post,
onOpenPostId: (targetId) =>
_openPostById(context, post.providerId, targetId),
),
],
if (post.hasPools && e621Provider != null) ...[
const SizedBox(height: 12),
PostPoolsCard(
post: post,
provider: e621Provider,
onOpenComic: (poolId, currentPost, [targetId]) =>
_openComic(context, e621Provider, poolId, currentPost, targetId),
onOpenPostId: (targetId) =>
_openPostById(context, post.providerId, targetId),
),
],
if (shouldShowCloudCard) ...[
const SizedBox(height: 12),
CloudMirrorsCard(
@@ -805,18 +879,101 @@ class PostDetailsScreen extends ConsumerWidget {
Post post,
Set<String> favoriteKeys,
) async {
if (favoriteKeys.contains(post.cacheKey)) {
final isFav = favoriteKeys.contains(post.cacheKey);
if (isFav) {
await ref
.read(favoriteServiceProvider)
.removeFavorite(post.id, post.providerId);
final provider = await ref
.read(providerManagerProvider)
.getProviderInstance(post.providerId);
if (provider is E621Provider && provider.isAuthorized) {
unawaited(provider.removeFavorite(post.id));
}
} else {
await ref.read(favoriteServiceProvider).addFavorite(post);
await _maybeAutoDownloadFavorite(ref, post);
final provider = await ref
.read(providerManagerProvider)
.getProviderInstance(post.providerId);
if (provider is E621Provider && provider.isAuthorized) {
unawaited(provider.addFavorite(post.id));
}
}
ref.invalidate(favoriteKeysProvider);
ref.invalidate(favoritesControllerProvider);
}
Future<void> _openComic(
BuildContext context,
E621Provider provider,
String poolId,
Post currentPost, [
String? targetId,
]) async {
final isRu = Localizations.maybeLocaleOf(context)?.languageCode == 'ru';
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2, color: Colors.white),
),
const SizedBox(width: 12),
Text(isRu ? 'Загрузка комикса...' : 'Loading comic...'),
],
),
duration: const Duration(seconds: 4),
),
);
final poolPosts = await provider.getPoolPosts(poolId);
if (!context.mounted) return;
ScaffoldMessenger.of(context).hideCurrentSnackBar();
if (poolPosts.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(isRu
? 'Не удалось загрузить страницы комикса'
: 'Could not load comic pages'),
),
);
return;
}
final idToFind = targetId ?? currentPost.id;
final targetIndex = poolPosts.indexWhere((p) => p.id == idToFind);
final startPost =
targetIndex >= 0 ? poolPosts[targetIndex] : poolPosts.first;
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (ctx) => PostDetailsScreen(
providerId: provider.id,
postId: startPost.id,
initialPost: startPost,
postsList: poolPosts,
),
),
);
}
void _openPostById(
BuildContext context, String providerId, String targetId) {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (ctx) => PostDetailsScreen(
providerId: providerId,
postId: targetId,
),
),
);
}
Future<void> _maybeAutoDownloadFavorite(WidgetRef ref, Post post) async {
final settings =
ref.read(appSettingsProvider).value ?? AppSettings.defaults;
@@ -973,10 +1130,12 @@ class _MobilePostPager extends StatefulWidget {
class _MobilePostPagerState extends State<_MobilePostPager> {
late final PageController _controller;
bool _mediaGestureLocked = false;
late int _currentPage = widget.initialIndex;
@override
void initState() {
super.initState();
_currentPage = widget.initialIndex;
_controller = PageController(initialPage: widget.initialIndex);
WidgetsBinding.instance.addPostFrameCallback((_) {
_prefetchAround(widget.initialIndex);
@@ -986,10 +1145,10 @@ class _MobilePostPagerState extends State<_MobilePostPager> {
@override
void didUpdateWidget(covariant _MobilePostPager oldWidget) {
super.didUpdateWidget(oldWidget);
// Исправленная проверка: сравниваем старый и новый initialIndex, а не текущий свайп
if (oldWidget.initialIndex != widget.initialIndex &&
widget.initialIndex >= 0 &&
widget.initialIndex < widget.posts.length) {
_currentPage = widget.initialIndex;
if (_controller.hasClients) {
_controller.jumpToPage(widget.initialIndex);
}
@@ -1007,7 +1166,7 @@ class _MobilePostPagerState extends State<_MobilePostPager> {
@override
Widget build(BuildContext context) {
return ScrollConfiguration(
final pager = ScrollConfiguration(
behavior: ScrollConfiguration.of(context).copyWith(
dragDevices: {
PointerDeviceKind.touch,
@@ -1023,6 +1182,7 @@ class _MobilePostPagerState extends State<_MobilePostPager> {
: const PageScrollPhysics(),
itemCount: widget.posts.length,
onPageChanged: (index) {
setState(() => _currentPage = index);
_prefetchAround(index);
},
itemBuilder: (context, index) {
@@ -1055,7 +1215,63 @@ class _MobilePostPagerState extends State<_MobilePostPager> {
},
),
);
}
if (widget.posts.length <= 1) return pager;
// Show the page indicator overlay only for comic/pool lists.
final isComic = widget.posts.first.hasPools;
if (!isComic) return pager;
return Stack(
children: [
pager,
Positioned(
top: MediaQuery.paddingOf(context).top + 10,
left: 0,
right: 0,
child: Center(
child: IgnorePointer(
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 12, sigmaY: 12),
child: Container(
padding:
const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.60),
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: Colors.white.withValues(alpha: 0.18),
width: 1,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.auto_stories_rounded,
size: 14, color: Colors.white),
const SizedBox(width: 6),
Text(
'Комикс • Стр. ${_currentPage + 1} из ${widget.posts.length}',
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.bold,
letterSpacing: -0.2,
),
),
],
),
),
),
),
),
),
),
],
);
}
void _setMediaGestureLocked(bool locked) {
if (_mediaGestureLocked == locked) return;
@@ -1155,6 +1371,8 @@ class _NeighborStrip extends StatelessWidget {
imageUrl: post.previewUrl.isNotEmpty
? post.previewUrl
: post.sampleUrl,
memCacheWidth: 160,
memCacheHeight: 160,
fit: BoxFit.cover,
errorWidget: (_, __, ___) =>
const Center(child: Icon(Icons.broken_image_rounded)),
@@ -1191,6 +1409,163 @@ class _DownloadIntent extends Intent {
const _DownloadIntent();
}
// ---------------------------------------------------------------------------
// Artist posts card
// ---------------------------------------------------------------------------
class _ArtistPostsCard extends ConsumerWidget {
const _ArtistPostsCard({required this.post});
final Post post;
String _artistTag(Post post) {
final artists = post.tagGroups['artist'] ??
post.tags
.where((t) => t.startsWith('artist:'))
.map((t) => t.replaceFirst('artist:', ''))
.toList();
if (artists.isEmpty) return '';
return 'artist:${artists.first}';
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final tag = _artistTag(post);
if (tag.isEmpty) return const SizedBox.shrink();
final artistPosts = ref.watch(artistPostsProvider(tag));
final isRu = Localizations.maybeLocaleOf(context)?.languageCode == 'ru';
final scheme = Theme.of(context).colorScheme;
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
final artistName = tag.replaceFirst('artist:', '');
return artistPosts.when(
loading: () => const SizedBox.shrink(),
error: (_, __) => const SizedBox.shrink(),
data: (posts) {
if (posts.isEmpty) return const SizedBox.shrink();
// exclude the current post
final filtered =
posts.where((p) => p.cacheKey != post.cacheKey).take(6).toList();
if (filtered.isEmpty) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.only(top: 12),
child: Container(
decoration: BoxDecoration(
color: isDark
? scheme.surfaceContainerHigh.withValues(alpha: 0.70)
: scheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: isDark
? Colors.white.withValues(alpha: 0.12)
: scheme.outlineVariant.withValues(alpha: 0.40),
width: 1.1,
),
),
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.palette_rounded,
size: 18, color: scheme.primary),
const SizedBox(width: 8),
Expanded(
child: Text(
isRu
? 'Ещё от $artistName'
: 'More by $artistName',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
InkWell(
onTap: () => context.push(
'/search?q=${Uri.encodeComponent(tag)}',
),
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 4),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
isRu ? 'Все' : 'See all',
style: theme.textTheme.labelSmall?.copyWith(
color: scheme.primary,
fontWeight: FontWeight.bold,
),
),
const SizedBox(width: 3),
Icon(Icons.arrow_forward_ios_rounded,
size: 11, color: scheme.primary),
],
),
),
),
],
),
const SizedBox(height: 12),
SizedBox(
height: 110,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: filtered.length,
separatorBuilder: (_, __) => const SizedBox(width: 8),
itemBuilder: (context, index) {
final p = filtered[index];
return InkWell(
onTap: () => Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => PostDetailsScreen(
providerId: p.providerId,
postId: p.id,
initialPost: p,
),
),
),
borderRadius: BorderRadius.circular(12),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: CachedNetworkImage(
imageUrl: p.previewUrl.isNotEmpty
? p.previewUrl
: p.sampleUrl,
width: 110,
height: 110,
fit: BoxFit.cover,
memCacheWidth: 220,
memCacheHeight: 220,
errorWidget: (_, __, ___) => Container(
width: 110,
height: 110,
color: scheme.surfaceContainerHigh,
child: Icon(Icons.broken_image_rounded,
color: scheme.onSurfaceVariant),
),
),
),
);
},
),
),
],
),
),
);
},
);
}
}
class _CommentsSection extends ConsumerStatefulWidget {
const _CommentsSection({required this.post});
@@ -1338,12 +1713,14 @@ class _PostInfoCard extends StatefulWidget {
required this.strings,
this.localMedia,
this.fileSizeBytes,
this.e621Provider,
});
final Post post;
final AppStrings strings;
final DownloadedMedia? localMedia;
final int? fileSizeBytes;
final E621Provider? e621Provider;
@override
State<_PostInfoCard> createState() => _PostInfoCardState();
@@ -1354,6 +1731,8 @@ class _PostInfoCardState extends State<_PostInfoCard> {
int? _resolvedWidth;
int? _resolvedHeight;
bool _resolvingDimensions = false;
int? _votedScore;
bool _isVoting = false;
static final _dimensionCache = <String, (int, int)>{};
@@ -1362,6 +1741,43 @@ class _PostInfoCardState extends State<_PostInfoCard> {
return imageTypes.contains(fileType.toLowerCase());
}
Future<void> _handleVote(int score) async {
final provider = widget.e621Provider;
if (provider == null) return;
if (!provider.isAuthorized) {
ScaffoldMessenger.maybeOf(context)?.showSnackBar(
const SnackBar(
content: Text('Для голосования на e621 настройте API-ключ в Источниках'),
duration: Duration(seconds: 3),
),
);
return;
}
if (_isVoting) return;
setState(() => _isVoting = true);
final success = await provider.votePost(widget.post.id, score);
if (mounted) {
setState(() {
_isVoting = false;
if (success) {
_votedScore = (_votedScore == score) ? 0 : score;
}
});
ScaffoldMessenger.maybeOf(context)?.showSnackBar(
SnackBar(
content: Text(
success
? (score > 0
? 'Голос ЗА (+1) отправлен на e621!'
: 'Голос ПРОТИВ (-1) отправлен на e621!')
: 'Не удалось отправить голос',
),
duration: const Duration(seconds: 2),
),
);
}
}
@override
void initState() {
super.initState();
@@ -1589,11 +2005,59 @@ class _PostInfoCardState extends State<_PostInfoCard> {
label: post.fileType.toUpperCase(),
),
RatingBadge(rating: post.rating),
if (post.score != 0)
if (post.score != 0 || widget.e621Provider != null)
Row(
mainAxisSize: MainAxisSize.min,
children: [
_SpecBadge(
icon: Icons.star_rounded,
label: '${post.score + (_votedScore ?? 0)}',
iconColor: Colors.amber,
),
if (widget.e621Provider != null) ...[
const SizedBox(width: 4),
IconButton(
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints:
const BoxConstraints(minWidth: 28, minHeight: 28),
icon: Icon(
_votedScore == 1
? Icons.thumb_up_rounded
: Icons.thumb_up_alt_outlined,
size: 15,
color: _votedScore == 1
? const Color(0xFF10B981)
: scheme.onSurfaceVariant,
),
onPressed: _isVoting ? null : () => _handleVote(1),
tooltip: 'Голос ЗА (+1)',
),
IconButton(
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints:
const BoxConstraints(minWidth: 28, minHeight: 28),
icon: Icon(
_votedScore == -1
? Icons.thumb_down_rounded
: Icons.thumb_down_alt_outlined,
size: 15,
color: _votedScore == -1
? const Color(0xFFEF4444)
: scheme.onSurfaceVariant,
),
onPressed: _isVoting ? null : () => _handleVote(-1),
tooltip: 'Голос ПРОТИВ (-1)',
),
],
],
),
if (post.favCount != null && post.favCount! > 0)
_SpecBadge(
icon: Icons.star_rounded,
label: '${post.score}',
iconColor: Colors.amber,
icon: Icons.favorite_rounded,
label: '${post.favCount}',
iconColor: const Color(0xFFEF4444),
),
if (post.source != null && post.source!.isNotEmpty)
InkWell(
@@ -0,0 +1,501 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../../../backend/models/e621_pool.dart';
import '../../../../backend/models/post.dart';
import '../../../../backend/providers/e621_provider.dart';
class E621PoolSheet extends StatefulWidget {
const E621PoolSheet({
required this.currentPost,
required this.poolId,
required this.provider,
required this.onSelectPostId,
super.key,
});
final Post currentPost;
final String poolId;
final E621Provider provider;
final ValueChanged<String> onSelectPostId;
static Future<void> show(
BuildContext context, {
required Post currentPost,
required String poolId,
required E621Provider provider,
required ValueChanged<String> onSelectPostId,
}) {
return showModalBottomSheet(
context: context,
backgroundColor: Colors.transparent,
isScrollControlled: true,
builder: (context) => E621PoolSheet(
currentPost: currentPost,
poolId: poolId,
provider: provider,
onSelectPostId: onSelectPostId,
),
);
}
@override
State<E621PoolSheet> createState() => _E621PoolSheetState();
}
class _E621PoolSheetState extends State<E621PoolSheet> {
E621Pool? _pool;
bool _isLoading = true;
String? _error;
@override
void initState() {
super.initState();
_loadPool();
}
Future<void> _loadPool() async {
setState(() {
_isLoading = true;
_error = null;
});
try {
final pool = await widget.provider.getPool(widget.poolId);
if (mounted) {
setState(() {
_pool = pool;
_isLoading = false;
if (pool == null) {
_error = 'Не удалось загрузить информацию о серии';
}
});
}
} catch (e) {
if (mounted) {
setState(() {
_error = 'Ошибка: $e';
_isLoading = false;
});
}
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
final isRu = Localizations.maybeLocaleOf(context)?.languageCode == 'ru';
final pool = _pool;
final currentPage = pool?.pageOf(widget.currentPost.id) ?? 0;
final prevId = pool?.previousPostId(widget.currentPost.id);
final nextId = pool?.nextPostId(widget.currentPost.id);
return DraggableScrollableSheet(
initialChildSize: 0.55,
minChildSize: 0.35,
maxChildSize: 0.88,
builder: (context, scrollController) {
return ClipRRect(
borderRadius: const BorderRadius.vertical(top: Radius.circular(28)),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
child: Container(
decoration: BoxDecoration(
color: isDark
? const Color(0xFF161B22).withValues(alpha: 0.92)
: Colors.white.withValues(alpha: 0.94),
borderRadius:
const BorderRadius.vertical(top: Radius.circular(28)),
border: Border.all(
color: isDark
? Colors.white.withValues(alpha: 0.12)
: Colors.black.withValues(alpha: 0.08),
width: 1.1,
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.35),
blurRadius: 28,
offset: const Offset(0, -6),
),
],
),
child: ListView(
controller: scrollController,
padding: const EdgeInsets.fromLTRB(20, 12, 20, 32),
children: [
// Drag handle
Center(
child: Container(
width: 44,
height: 5,
decoration: BoxDecoration(
color: isDark
? Colors.white.withValues(alpha: 0.25)
: Colors.black.withValues(alpha: 0.20),
borderRadius: BorderRadius.circular(3),
),
),
),
const SizedBox(height: 16),
if (_isLoading)
const Center(
child: Padding(
padding: EdgeInsets.all(40),
child: CircularProgressIndicator(),
),
)
else if (_error != null)
Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
children: [
const Icon(Icons.error_outline_rounded,
size: 40, color: Color(0xFFEF4444)),
const SizedBox(height: 12),
Text(_error!),
const SizedBox(height: 12),
FilledButton.tonal(
onPressed: _loadPool,
child: const Text('Повторить'),
),
],
),
),
)
else if (pool != null) ...[
// Pool header
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFF0055AA), Color(0xFF0088FF)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: const Color(0xFF0055AA)
.withValues(alpha: 0.35),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: const Icon(
Icons.menu_book_rounded,
color: Colors.white,
size: 24,
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
pool.cleanTitle,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
letterSpacing: -0.2,
),
),
const SizedBox(height: 4),
Wrap(
spacing: 6,
runSpacing: 4,
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: const Color(0xFF0055AA)
.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: const Color(0xFF0055AA)
.withValues(alpha: 0.30),
width: 0.8,
),
),
child: Text(
pool.category == 'series'
? (isRu ? 'Комикс / Серия' : 'Series')
: (isRu
? 'Коллекция'
: 'Collection'),
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: Color(0xFF0077EE),
),
),
),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: isDark
? Colors.white.withValues(alpha: 0.08)
: Colors.black.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(8),
),
child: Text(
'${pool.postCount} ${isRu ? 'страниц' : 'pages'}',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
color: theme
.colorScheme.onSurfaceVariant,
),
),
),
],
),
],
),
),
],
),
if (pool.description.trim().isNotEmpty) ...[
const SizedBox(height: 12),
Text(
pool.description.trim(),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
maxLines: 4,
overflow: TextOverflow.ellipsis,
),
],
const SizedBox(height: 16),
// Big "Read Full Comic" Button
FilledButton.icon(
onPressed: () {
HapticFeedback.mediumImpact();
Navigator.of(context).pop();
final startId = pool.postIds.contains(widget.currentPost.id)
? widget.currentPost.id
: pool.postIds.first;
widget.onSelectPostId(startId);
},
icon: const Icon(Icons.auto_stories_rounded, size: 18),
label: Text(
isRu
? 'Читать весь комикс (${pool.postCount} стр.)'
: 'Read Full Comic (${pool.postCount} pages)',
style: const TextStyle(fontWeight: FontWeight.bold),
),
style: FilledButton.styleFrom(
backgroundColor: const Color(0xFF0055AA),
foregroundColor: Colors.white,
minimumSize: const Size(double.infinity, 46),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
),
),
const SizedBox(height: 16),
// Quick Page Navigator Banner
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: isDark
? Colors.black.withValues(alpha: 0.25)
: theme.colorScheme.surfaceContainerHighest
.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: isDark
? Colors.white.withValues(alpha: 0.08)
: Colors.black.withValues(alpha: 0.06),
),
),
child: Row(
children: [
IconButton.filledTonal(
tooltip: isRu ? 'Предыдущая' : 'Previous',
onPressed: prevId == null
? null
: () {
HapticFeedback.lightImpact();
Navigator.of(context).pop();
widget.onSelectPostId(prevId);
},
icon: const Icon(Icons.arrow_back_rounded, size: 18),
),
Expanded(
child: Column(
children: [
Text(
currentPage > 0
? (isRu
? 'Страница $currentPage из ${pool.postCount}'
: 'Page $currentPage of ${pool.postCount}')
: (isRu
? 'Страниц в пуле: ${pool.postCount}'
: '${pool.postCount} pages in pool'),
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 13,
),
),
if (currentPage > 0)
Padding(
padding: const EdgeInsets.only(top: 6),
child: LinearProgressIndicator(
value: (currentPage / pool.postCount)
.clamp(0.0, 1.0),
borderRadius: BorderRadius.circular(4),
minHeight: 4,
backgroundColor: isDark
? Colors.white.withValues(alpha: 0.1)
: Colors.black.withValues(alpha: 0.08),
),
),
],
),
),
IconButton.filledTonal(
tooltip: isRu ? 'Следующая' : 'Next',
onPressed: nextId == null
? null
: () {
HapticFeedback.lightImpact();
Navigator.of(context).pop();
widget.onSelectPostId(nextId);
},
icon: const Icon(Icons.arrow_forward_rounded,
size: 18),
),
],
),
),
const SizedBox(height: 18),
Text(
isRu ? 'Все страницы серии:' : 'All pages in pool:',
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 10),
// Grid of page buttons
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (int i = 0; i < pool.postIds.length; i++) ...[
_PageButton(
pageNumber: i + 1,
postId: pool.postIds[i],
isCurrent: pool.postIds[i] == widget.currentPost.id,
onTap: () {
if (pool.postIds[i] == widget.currentPost.id) {
return;
}
HapticFeedback.lightImpact();
Navigator.of(context).pop();
widget.onSelectPostId(pool.postIds[i]);
},
),
],
],
),
],
],
),
),
),
);
},
);
}
}
class _PageButton extends StatelessWidget {
const _PageButton({
required this.pageNumber,
required this.postId,
required this.isCurrent,
required this.onTap,
});
final int pageNumber;
final String postId;
final bool isCurrent;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
return Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
constraints: const BoxConstraints(minWidth: 42, minHeight: 40),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
decoration: BoxDecoration(
color: isCurrent
? const Color(0xFF0055AA)
: (isDark
? Colors.white.withValues(alpha: 0.08)
: Colors.black.withValues(alpha: 0.05)),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isCurrent
? const Color(0xFF0077EE)
: (isDark
? Colors.white.withValues(alpha: 0.12)
: Colors.black.withValues(alpha: 0.08)),
width: isCurrent ? 1.4 : 0.8,
),
boxShadow: isCurrent
? [
BoxShadow(
color: const Color(0xFF0055AA).withValues(alpha: 0.40),
blurRadius: 8,
offset: const Offset(0, 2),
),
]
: null,
),
child: Center(
child: Text(
'$pageNumber',
style: TextStyle(
fontSize: 13,
fontWeight: isCurrent ? FontWeight.bold : FontWeight.w600,
color: isCurrent
? Colors.white
: theme.colorScheme.onSurface,
),
),
),
),
),
);
}
}
@@ -78,6 +78,8 @@ class _PostMediaViewerState extends State<PostMediaViewer>
String? _videoError;
bool _retriedFormatError = false;
bool _softwareDecodingFallback = false;
late bool _useSoftwareDecoding =
Platform.isLinux || Platform.isWindows || Platform.isMacOS;
Timer? _hideTimer;
StreamSubscription<String>? _errorSubscription;
StreamSubscription<Duration>? _positionSubscription;
@@ -109,6 +111,8 @@ class _PostMediaViewerState extends State<PostMediaViewer>
_videoError = null;
_retriedFormatError = false;
_softwareDecodingFallback = false;
_useSoftwareDecoding =
Platform.isLinux || Platform.isWindows || Platform.isMacOS;
_imageUrls = _buildImageUrls(widget.post);
_videoUrls = _buildVideoUrls(widget.post);
if (_isPlayableMedia(widget.post) && _videoUrls.isNotEmpty) {
@@ -226,6 +230,8 @@ class _PostMediaViewerState extends State<PostMediaViewer>
aspectRatio: widget.post.width > 0 && widget.post.height > 0
? widget.post.width / widget.post.height
: 16 / 9,
isSoftwareDecoding: _useSoftwareDecoding,
onToggleDecoder: _toggleDecoderMode,
controlsVisible: _controlsVisible,
coverVideo: _coverVideo,
muted: _muted,
@@ -317,10 +323,16 @@ class _PostMediaViewerState extends State<PostMediaViewer>
),
);
} else {
final mq = MediaQuery.maybeOf(context);
final dpr = mq?.devicePixelRatio ?? 1.5;
final screenWidth = mq?.size.width ?? 1280;
final maxCacheWidth =
(screenWidth * dpr * 1.5).round().clamp(1080, 2560);
image = CachedNetworkImage(
key: ValueKey(url),
imageUrl: url,
httpHeaders: headers,
memCacheWidth: maxCacheWidth,
fit: BoxFit.contain,
placeholder: (context, url) =>
const Center(child: CircularProgressIndicator()),
@@ -438,11 +450,12 @@ class _PostMediaViewerState extends State<PostMediaViewer>
void _initializeVideo() {
MediaKit.ensureInitialized();
_player = Player();
final bool enableHw = !_useSoftwareDecoding && !_softwareDecodingFallback;
_controller = VideoController(
_player!,
configuration: VideoControllerConfiguration(
enableHardwareAcceleration: !_softwareDecodingFallback,
hwdec: _softwareDecodingFallback ? 'no' : 'auto-safe',
enableHardwareAcceleration: enableHw,
hwdec: enableHw ? (Platform.isAndroid ? 'auto-safe' : 'auto') : 'no',
),
);
_applyVolume();
@@ -517,6 +530,7 @@ class _PostMediaViewerState extends State<PostMediaViewer>
lower.contains('failed to recognize file format'))) {
_retriedFormatError = true;
_softwareDecodingFallback = true;
_useSoftwareDecoding = true;
_disposeVideo();
await Future<void>.delayed(const Duration(milliseconds: 250));
if (mounted) {
@@ -534,6 +548,7 @@ class _PostMediaViewerState extends State<PostMediaViewer>
_videoIndex = 0;
_retriedFormatError = false;
_softwareDecodingFallback = true;
_useSoftwareDecoding = true;
_disposeVideo();
await Future<void>.delayed(const Duration(milliseconds: 150));
if (mounted) {
@@ -541,6 +556,26 @@ class _PostMediaViewerState extends State<PostMediaViewer>
}
}
void _toggleDecoderMode() {
setState(() {
_useSoftwareDecoding = !_useSoftwareDecoding;
_softwareDecodingFallback = false;
_retriedFormatError = false;
});
_disposeVideo();
_initializeVideo();
ScaffoldMessenger.maybeOf(context)?.showSnackBar(
SnackBar(
content: Text(
_useSoftwareDecoding
? 'Включено программное декодирование (S/W)'
: 'Включено аппаратное декодирование (H/W)',
),
duration: const Duration(seconds: 2),
),
);
}
void _disposeVideo() {
_hideTimer?.cancel();
_errorSubscription?.cancel();
@@ -682,6 +717,8 @@ class _PostMediaViewerState extends State<PostMediaViewer>
halfVolume: _halfVolume,
coverVideo: _coverVideo,
initialVolume: _currentVolume,
isSoftwareDecoding: _useSoftwareDecoding,
onToggleDecoder: _toggleDecoderMode,
errorMessage: _videoError,
onRetry: _retryVideo,
onChanged: (snapshot) {
@@ -878,6 +915,8 @@ class _FullscreenVideoPage extends StatefulWidget {
required this.halfVolume,
required this.coverVideo,
this.initialVolume = 100.0,
required this.isSoftwareDecoding,
required this.onToggleDecoder,
required this.errorMessage,
required this.onRetry,
required this.onChanged,
@@ -892,6 +931,8 @@ class _FullscreenVideoPage extends StatefulWidget {
final bool halfVolume;
final bool coverVideo;
final double initialVolume;
final bool isSoftwareDecoding;
final VoidCallback onToggleDecoder;
final String? errorMessage;
final VoidCallback onRetry;
final ValueChanged<VideoPlaybackSnapshot> onChanged;
@@ -1016,6 +1057,8 @@ class _FullscreenVideoPageState extends State<_FullscreenVideoPage> {
player: widget.player,
controller: widget.controller,
aspectRatio: widget.aspectRatio,
isSoftwareDecoding: widget.isSoftwareDecoding,
onToggleDecoder: widget.onToggleDecoder,
controlsVisible: _controlsVisible,
coverVideo: _coverVideo,
muted: _muted,
@@ -1567,6 +1610,8 @@ class _VideoSurface extends StatefulWidget {
required this.player,
required this.controller,
required this.aspectRatio,
required this.isSoftwareDecoding,
required this.onToggleDecoder,
required this.controlsVisible,
required this.coverVideo,
required this.muted,
@@ -1591,6 +1636,8 @@ class _VideoSurface extends StatefulWidget {
final Player player;
final VideoController controller;
final double aspectRatio;
final bool isSoftwareDecoding;
final VoidCallback onToggleDecoder;
final bool controlsVisible;
final bool coverVideo;
final bool muted;
@@ -1836,6 +1883,8 @@ class _VideoSurfaceState extends State<_VideoSurface> {
fullscreen: widget.fullscreen,
isLocked: _isLocked,
isLandscape: widget.isLandscape,
isSoftwareDecoding: widget.isSoftwareDecoding,
onToggleDecoder: widget.onToggleDecoder,
onToggleOrientation: widget.onToggleOrientation,
onToggleLock: () => setState(() => _isLocked = !_isLocked),
onToggleFit: widget.onToggleFit,
@@ -2190,29 +2239,6 @@ class _ZoomableImageState extends State<_ZoomableImage> {
)
: 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) {
@@ -2229,6 +2255,7 @@ class _ZoomableImageState extends State<_ZoomableImage> {
transformationController: _controller,
minScale: 1,
maxScale: 6,
scaleFactor: 1000000.0,
boundaryMargin: const EdgeInsets.all(160),
panEnabled: _zoomed,
scaleEnabled: true,
@@ -2245,7 +2272,15 @@ class _ZoomableImageState extends State<_ZoomableImage> {
if (mounted) setState(() => _zoomed = nextZoomed);
_setLocked(nextZoomed || _pointerCount >= 2);
},
child: child,
child: Listener(
onPointerSignal: (event) {
if (event is PointerScrollEvent) {
GestureBinding.instance.pointerSignalResolver
.register(event, (_) {});
}
},
child: child,
),
),
),
);
@@ -2661,6 +2696,8 @@ class _VideoControls extends StatefulWidget {
required this.fullscreen,
required this.isLocked,
this.isLandscape,
required this.isSoftwareDecoding,
required this.onToggleDecoder,
this.onToggleOrientation,
required this.onToggleLock,
required this.onToggleFit,
@@ -2680,6 +2717,8 @@ class _VideoControls extends StatefulWidget {
final bool fullscreen;
final bool isLocked;
final bool? isLandscape;
final bool isSoftwareDecoding;
final VoidCallback onToggleDecoder;
final VoidCallback? onToggleOrientation;
final VoidCallback onToggleLock;
final VoidCallback onToggleFit;
@@ -2779,6 +2818,17 @@ class _VideoControlsState extends State<_VideoControls> {
onPressed: widget.onToggleLock,
),
const Spacer(),
_RoundControlButton(
tooltip: widget.isSoftwareDecoding
? 'Декодер: S/W (программный). Нажмите для переключения на H/W'
: 'Декодер: H/W (аппаратный). Нажмите для переключения на S/W',
selected: !widget.isSoftwareDecoding,
icon: widget.isSoftwareDecoding
? Icons.memory_rounded
: Icons.speed_rounded,
onPressed: widget.onToggleDecoder,
),
const SizedBox(width: 8),
_SpeedBadgeButton(
currentRate: currentRate,
onSelected: (newRate) => widget.player.setRate(newRate),
@@ -0,0 +1,173 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../../../backend/models/post.dart';
import '../../../../backend/providers/e621_provider.dart';
import 'e621_pool_sheet.dart';
class PostPoolsCard extends StatelessWidget {
const PostPoolsCard({
required this.post,
required this.provider,
required this.onOpenComic,
required this.onOpenPostId,
super.key,
});
final Post post;
final E621Provider provider;
final void Function(String poolId, Post currentPost, [String? targetId])
onOpenComic;
final ValueChanged<String> onOpenPostId;
@override
Widget build(BuildContext context) {
if (!post.hasPools) return const SizedBox.shrink();
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
final isRu = Localizations.maybeLocaleOf(context)?.languageCode == 'ru';
final pools = post.poolIds;
return Container(
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: isDark
? theme.colorScheme.surfaceContainerHigh.withValues(alpha: 0.70)
: theme.colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: isDark
? Colors.white.withValues(alpha: 0.12)
: theme.colorScheme.outlineVariant.withValues(alpha: 0.40),
width: 1.1,
),
),
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(
Icons.menu_book_rounded,
size: 20,
color: Color(0xFF0077EE),
),
const SizedBox(width: 8),
Text(
isRu ? 'Входит в комикс / серию' : 'Part of Series / Pool',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const Spacer(),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: const Color(0xFF0055AA).withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(8),
),
child: Text(
'${pools.length}',
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
color: Color(0xFF0077EE),
),
),
),
],
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final poolId in pools)
Row(
mainAxisSize: MainAxisSize.min,
children: [
Material(
color: Colors.transparent,
child: InkWell(
onTap: () {
HapticFeedback.lightImpact();
onOpenComic(poolId, post);
},
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 14, vertical: 9),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFF0055AA), Color(0xFF0077EE)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: const Color(0xFF0055AA)
.withValues(alpha: 0.25),
blurRadius: 8,
offset: const Offset(0, 3),
),
],
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.auto_stories_rounded,
size: 16, color: Colors.white),
const SizedBox(width: 7),
Text(
isRu
? 'Читать весь комикс (#$poolId)'
: 'Read Full Comic (#$poolId)',
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
const SizedBox(width: 5),
const Icon(Icons.arrow_forward_rounded,
size: 13, color: Colors.white),
],
),
),
),
),
const SizedBox(width: 4),
IconButton.filledTonal(
tooltip: isRu ? 'Все страницы' : 'All pages',
style: IconButton.styleFrom(
visualDensity: VisualDensity.compact,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
),
onPressed: () {
HapticFeedback.lightImpact();
E621PoolSheet.show(
context,
currentPost: post,
poolId: poolId,
provider: provider,
onSelectPostId: (targetId) =>
onOpenComic(poolId, post, targetId),
);
},
icon: const Icon(Icons.grid_view_rounded, size: 16),
),
],
),
],
),
],
),
);
}
}
@@ -0,0 +1,164 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../../../backend/models/post.dart';
class PostRelationsCard extends StatelessWidget {
const PostRelationsCard({
required this.post,
required this.onOpenPostId,
super.key,
});
final Post post;
final ValueChanged<String> onOpenPostId;
@override
Widget build(BuildContext context) {
if (!post.hasRelations) return const SizedBox.shrink();
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
final isRu = Localizations.maybeLocaleOf(context)?.languageCode == 'ru';
final parentId = post.parentId;
final children = post.childrenIds;
return Container(
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: isDark
? theme.colorScheme.surfaceContainerHigh.withValues(alpha: 0.70)
: theme.colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: isDark
? Colors.white.withValues(alpha: 0.12)
: theme.colorScheme.outlineVariant.withValues(alpha: 0.40),
width: 1.1,
),
),
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.copy_all_rounded,
size: 20,
color: theme.colorScheme.primary,
),
const SizedBox(width: 8),
Text(
isRu ? 'Связанные посты и версии' : 'Variations & Relations',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const Spacer(),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: theme.colorScheme.primary.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(8),
),
child: Text(
'${(parentId != null ? 1 : 0) + children.length}',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
color: theme.colorScheme.primary,
),
),
),
],
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
if (parentId != null)
_RelationPill(
icon: Icons.north_west_rounded,
label: isRu ? 'Родительский арт #$parentId' : 'Parent #$parentId',
color: const Color(0xFF0077EE),
onTap: () {
HapticFeedback.lightImpact();
onOpenPostId(parentId);
},
),
for (final childId in children)
_RelationPill(
icon: Icons.style_rounded,
label: isRu ? 'Версия #$childId' : 'Variant #$childId',
color: const Color(0xFF10B981),
onTap: () {
HapticFeedback.lightImpact();
onOpenPostId(childId);
},
),
],
),
],
),
);
}
}
class _RelationPill extends StatelessWidget {
const _RelationPill({
required this.icon,
required this.label,
required this.color,
required this.onTap,
});
final IconData icon;
final String label;
final Color color;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7),
decoration: BoxDecoration(
color: color.withValues(alpha: isDark ? 0.15 : 0.10),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: color.withValues(alpha: isDark ? 0.35 : 0.25),
width: 0.9,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 14, color: color),
const SizedBox(width: 6),
Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: color,
),
),
const SizedBox(width: 4),
Icon(Icons.arrow_forward_ios_rounded, size: 10, color: color),
],
),
),
),
);
}
}
@@ -358,6 +358,9 @@ class ProvidersScreen extends ConsumerWidget {
'/providers/new',
extra: item,
),
onSaveConfig: (updated) => ref
.read(providersControllerProvider.notifier)
.save(updated),
onDelete: () async {
final ok = await showConfirmDialog(
context,
@@ -0,0 +1,490 @@
import 'dart:convert';
import 'dart:ui';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../../../backend/models/content_provider_config.dart';
class E621AuthDialog extends StatefulWidget {
const E621AuthDialog({
required this.config,
required this.onSaved,
super.key,
});
final ContentProviderConfig config;
final ValueChanged<ContentProviderConfig> onSaved;
static Future<void> show(
BuildContext context, {
required ContentProviderConfig config,
required ValueChanged<ContentProviderConfig> onSaved,
}) {
return showDialog(
context: context,
barrierColor: Colors.black54,
builder: (context) => E621AuthDialog(
config: config,
onSaved: onSaved,
),
);
}
@override
State<E621AuthDialog> createState() => _E621AuthDialogState();
}
class _E621AuthDialogState extends State<E621AuthDialog> {
late final TextEditingController _loginController;
late final TextEditingController _apiKeyController;
bool _obscureKey = true;
bool _isLoading = false;
String? _statusMessage;
bool _isSuccess = false;
@override
void initState() {
super.initState();
final login = widget.config.customHeaders['query.login'] ?? '';
final apiKey = widget.config.customHeaders['query.api_key'] ?? '';
_loginController = TextEditingController(text: login);
_apiKeyController = TextEditingController(text: apiKey);
}
@override
void dispose() {
_loginController.dispose();
_apiKeyController.dispose();
super.dispose();
}
Future<void> _testAndSave() async {
final login = _loginController.text.trim();
final apiKey = _apiKeyController.text.trim();
if (login.isEmpty || apiKey.isEmpty) {
setState(() {
_statusMessage = 'Укажите логин и API ключ';
_isSuccess = false;
});
return;
}
setState(() {
_isLoading = true;
_statusMessage = null;
});
try {
final dio = Dio(
BaseOptions(
baseUrl: widget.config.baseUrl,
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 10),
headers: {
'Authorization':
'Basic ${base64Encode(utf8.encode('$login:$apiKey'))}',
'User-Agent': 'Prisma/3.6.6 (by $login on e621)',
'Accept': 'application/json',
},
),
);
final response = await dio.get<dynamic>('/posts.json', queryParameters: {
'limit': 1,
});
if (response.statusCode == 200) {
final updatedHeaders =
Map<String, String>.from(widget.config.customHeaders)
..['query.login'] = login
..['query.api_key'] = apiKey;
final updatedConfig = widget.config.copyWith(
customHeaders: updatedHeaders,
updatedAt: DateTime.now(),
);
widget.onSaved(updatedConfig);
setState(() {
_isLoading = false;
_isSuccess = true;
_statusMessage = 'Успешно авторизован! Лимит тегов снят.';
});
await Future<void>.delayed(const Duration(milliseconds: 650));
if (mounted) Navigator.of(context).pop();
} else {
setState(() {
_isLoading = false;
_isSuccess = false;
_statusMessage =
'Ошибка проверки (код ${response.statusCode}). Проверьте логин и ключ.';
});
}
} on DioException catch (e) {
setState(() {
_isLoading = false;
_isSuccess = false;
if (e.response?.statusCode == 401) {
_statusMessage = 'Неверный логин или API-ключ (401 Unauthorized)';
} else if (e.response?.statusCode == 403) {
_statusMessage = 'Доступ запрещен (403 Forbidden). Проверьте API access в профиле e621.';
} else {
_statusMessage = 'Ошибка подключения: ${e.message ?? e.toString()}';
}
});
} catch (e) {
setState(() {
_isLoading = false;
_isSuccess = false;
_statusMessage = 'Ошибка: $e';
});
}
}
void _clearAuth() {
final updatedHeaders =
Map<String, String>.from(widget.config.customHeaders)
..remove('query.login')
..remove('query.api_key');
final updatedConfig = widget.config.copyWith(
customHeaders: updatedHeaders,
updatedAt: DateTime.now(),
);
widget.onSaved(updatedConfig);
Navigator.of(context).pop();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
final currentLogin = widget.config.customHeaders['query.login'];
final isAlreadyAuthorized =
currentLogin != null && currentLogin.trim().isNotEmpty;
return Dialog(
backgroundColor: Colors.transparent,
insetPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 480),
child: ClipRRect(
borderRadius: BorderRadius.circular(28),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
child: Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: isDark
? const Color(0xFF1E2430).withValues(alpha: 0.90)
: Colors.white.withValues(alpha: 0.94),
borderRadius: BorderRadius.circular(28),
border: Border.all(
color: isDark
? const Color(0xFF0055AA).withValues(alpha: 0.40)
: const Color(0xFF0055AA).withValues(alpha: 0.20),
width: 1.2,
),
boxShadow: [
BoxShadow(
color: const Color(0xFF0055AA).withValues(alpha: 0.15),
blurRadius: 30,
offset: const Offset(0, 10),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Title row
Row(
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFF0055AA), Color(0xFF0077EE)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(14),
boxShadow: [
BoxShadow(
color: const Color(0xFF0055AA).withValues(alpha: 0.35),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: const Icon(
Icons.vpn_key_rounded,
color: Colors.white,
size: 22,
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Авторизация e621',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
letterSpacing: -0.2,
),
),
Text(
isAlreadyAuthorized
? 'Вход выполнен как $currentLogin'
: 'Снятие ограничений поиска и функций',
style: theme.textTheme.bodySmall?.copyWith(
color: isAlreadyAuthorized
? const Color(0xFF10B981)
: theme.colorScheme.onSurfaceVariant,
fontWeight: isAlreadyAuthorized
? FontWeight.w600
: FontWeight.normal,
),
),
],
),
),
IconButton(
icon: const Icon(Icons.close_rounded),
onPressed: () => Navigator.of(context).pop(),
visualDensity: VisualDensity.compact,
),
],
),
const SizedBox(height: 18),
// Benefits badge
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0xFF0055AA).withValues(alpha: isDark ? 0.15 : 0.08),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: const Color(0xFF0055AA).withValues(alpha: 0.25),
),
),
child: const Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_BenefitRow(
icon: Icons.check_circle_outline_rounded,
text: 'Снятие лимита в 2 тега (поиск по 4–6+ тегам)',
),
SizedBox(height: 6),
_BenefitRow(
icon: Icons.favorite_border_rounded,
text: 'Серверная синхронизация Избранного (Favorites)',
),
SizedBox(height: 6),
_BenefitRow(
icon: Icons.thumb_up_alt_outlined,
text: 'Голосование за посты (Upvote / Downvote)',
),
],
),
),
const SizedBox(height: 16),
// Username field
TextField(
controller: _loginController,
decoration: InputDecoration(
labelText: 'Имя пользователя (Username на e621)',
prefixIcon: const Icon(Icons.person_outline_rounded),
filled: true,
fillColor: isDark
? Colors.black.withValues(alpha: 0.25)
: Colors.grey.shade100,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide.none,
),
),
),
const SizedBox(height: 12),
// API Key field
TextField(
controller: _apiKeyController,
obscureText: _obscureKey,
decoration: InputDecoration(
labelText: 'API Ключ (API Key)',
prefixIcon: const Icon(Icons.key_rounded),
suffixIcon: IconButton(
icon: Icon(
_obscureKey
? Icons.visibility_off_rounded
: Icons.visibility_rounded,
),
onPressed: () =>
setState(() => _obscureKey = !_obscureKey),
),
filled: true,
fillColor: isDark
? Colors.black.withValues(alpha: 0.25)
: Colors.grey.shade100,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide.none,
),
),
),
const SizedBox(height: 10),
// How to get API key link
Align(
alignment: Alignment.centerLeft,
child: TextButton.icon(
onPressed: () {
launchUrl(
Uri.parse('https://e621.net/users/home'),
mode: LaunchMode.externalApplication,
);
},
icon: const Icon(Icons.open_in_new_rounded, size: 14),
label: const Text(
'Как получить ключ (e621 -> Manage API Access)',
style: TextStyle(fontSize: 12),
),
style: TextButton.styleFrom(
padding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
),
),
),
if (_statusMessage != null) ...[
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: _isSuccess
? const Color(0xFF10B981).withValues(alpha: 0.15)
: const Color(0xFFEF4444).withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: _isSuccess
? const Color(0xFF10B981).withValues(alpha: 0.40)
: const Color(0xFFEF4444).withValues(alpha: 0.40),
),
),
child: Row(
children: [
Icon(
_isSuccess
? Icons.check_circle_rounded
: Icons.error_outline_rounded,
size: 16,
color: _isSuccess
? const Color(0xFF10B981)
: const Color(0xFFEF4444),
),
const SizedBox(width: 8),
Expanded(
child: Text(
_statusMessage!,
style: TextStyle(
fontSize: 12,
color: _isSuccess
? const Color(0xFF10B981)
: const Color(0xFFEF4444),
fontWeight: FontWeight.w600,
),
),
),
],
),
),
],
const SizedBox(height: 20),
// Action buttons
Row(
children: [
if (isAlreadyAuthorized) ...[
TextButton.icon(
onPressed: _isLoading ? null : _clearAuth,
icon: const Icon(Icons.logout_rounded,
size: 16, color: Color(0xFFEF4444)),
label: const Text('Выйти',
style: TextStyle(color: Color(0xFFEF4444))),
),
const Spacer(),
] else
const Spacer(),
FilledButton.icon(
onPressed: _isLoading ? null : _testAndSave,
icon: _isLoading
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Icon(Icons.check_rounded, size: 18),
label: Text(_isLoading ? 'Проверка...' : 'Сохранить'),
style: FilledButton.styleFrom(
backgroundColor: const Color(0xFF0055AA),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
padding: const EdgeInsets.symmetric(
horizontal: 20, vertical: 12),
),
),
],
),
],
),
),
),
),
),
);
}
}
class _BenefitRow extends StatelessWidget {
const _BenefitRow({required this.icon, required this.text});
final IconData icon;
final String text;
@override
Widget build(BuildContext context) {
return Row(
children: [
Icon(icon, size: 14, color: const Color(0xFF0077EE)),
const SizedBox(width: 8),
Expanded(
child: Text(
text,
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w500),
),
),
],
);
}
}
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../../../backend/backend.dart';
import 'e621_auth_dialog.dart';
class ProviderCard extends StatelessWidget {
const ProviderCard({
@@ -9,6 +10,7 @@ class ProviderCard extends StatelessWidget {
required this.onToggle,
required this.onEdit,
required this.onDelete,
this.onSaveConfig,
super.key,
});
@@ -16,6 +18,7 @@ class ProviderCard extends StatelessWidget {
final ValueChanged<bool> onToggle;
final VoidCallback onEdit;
final VoidCallback onDelete;
final ValueChanged<ContentProviderConfig>? onSaveConfig;
@override
Widget build(BuildContext context) {
@@ -164,6 +167,16 @@ class ProviderCard extends StatelessWidget {
color: theme.colorScheme.primary,
isDark: isDark,
),
if (config.apiType.toLowerCase() == 'e621')
_GlassPill(
label: (config.customHeaders['query.login']?.isNotEmpty ?? false)
? 'e621: ${config.customHeaders['query.login']}'
: (isRu ? 'e621: Гость (макс 2 тега)' : 'e621: Guest (max 2 tags)'),
color: (config.customHeaders['query.login']?.isNotEmpty ?? false)
? const Color(0xFF10B981)
: Colors.amber,
isDark: isDark,
),
],
),
],
@@ -231,10 +244,30 @@ class ProviderCard extends StatelessWidget {
const SizedBox(height: 12),
// Row 3: Action Buttons (Edit & Delete)
// Row 3: Action Buttons (Edit & Delete & e621 Auth)
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
if (config.apiType.toLowerCase() == 'e621' && onSaveConfig != null) ...[
_GlassActionButton(
icon: Icons.vpn_key_rounded,
label: (config.customHeaders['query.login']?.isNotEmpty ?? false)
? (isRu ? 'Аккаунт' : 'Account')
: (isRu ? 'API Ключ' : 'API Key'),
color: const Color(0xFF0077EE),
isDark: isDark,
onTap: () {
HapticFeedback.lightImpact();
E621AuthDialog.show(
context,
config: config,
onSaved: onSaveConfig!,
);
},
),
const SizedBox(width: 8),
],
// Edit Action Button
_GlassActionButton(
icon: Icons.edit_rounded,
@@ -1,6 +1,7 @@
import 'dart:io';
import 'dart:ui';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart' hide appBuildNumber;
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -1439,7 +1440,7 @@ class _SettingsIconBadge extends StatelessWidget {
}
}
class _CategoryQuickNav extends StatelessWidget {
class _CategoryQuickNav extends StatefulWidget {
const _CategoryQuickNav({
required this.categories,
required this.onSelect,
@@ -1449,103 +1450,143 @@ class _CategoryQuickNav extends StatelessWidget {
categories;
final ValueChanged<GlobalKey> onSelect;
@override
State<_CategoryQuickNav> createState() => _CategoryQuickNavState();
}
class _CategoryQuickNavState extends State<_CategoryQuickNav> {
final ScrollController _scrollController = ScrollController();
bool _isHovered = false;
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
return SizedBox(
height: 44,
child: ListView.separated(
padding: const EdgeInsets.symmetric(horizontal: 16),
scrollDirection: Axis.horizontal,
itemCount: categories.length,
separatorBuilder: (_, __) => const SizedBox(width: 8),
itemBuilder: (context, index) {
final cat = categories[index];
return Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(22),
boxShadow: [
BoxShadow(
color: isDark
? Colors.black.withValues(alpha: 0.25)
: cat.color.withValues(alpha: 0.12),
blurRadius: 8,
offset: const Offset(0, 2.5),
return MouseRegion(
onEnter: (_) => _isHovered = true,
onExit: (_) => _isHovered = false,
child: Listener(
onPointerSignal: (event) {
if (!_isHovered ||
event is! PointerScrollEvent ||
!_scrollController.hasClients) {
return;
}
final delta = event.scrollDelta.dy != 0
? event.scrollDelta.dy
: event.scrollDelta.dx;
if (delta == 0) return;
final target = (_scrollController.offset + delta * 1.5).clamp(
0.0,
_scrollController.position.maxScrollExtent,
);
_scrollController.jumpTo(target);
},
child: SizedBox(
height: 44,
child: ListView.separated(
controller: _scrollController,
padding: const EdgeInsets.symmetric(horizontal: 16),
scrollDirection: Axis.horizontal,
itemCount: widget.categories.length,
separatorBuilder: (_, __) => const SizedBox(width: 8),
itemBuilder: (context, index) {
final cat = widget.categories[index];
return Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(22),
boxShadow: [
BoxShadow(
color: isDark
? Colors.black.withValues(alpha: 0.25)
: cat.color.withValues(alpha: 0.12),
blurRadius: 8,
offset: const Offset(0, 2.5),
),
],
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(22),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 16, sigmaY: 16),
child: Material(
color: isDark
? theme.colorScheme.surfaceContainerHigh.withValues(alpha: 0.60)
: Colors.white.withValues(alpha: 0.82),
child: InkWell(
borderRadius: BorderRadius.circular(22),
onTap: () {
HapticFeedback.selectionClick();
onSelect(cat.key);
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7),
decoration: BoxDecoration(
child: ClipRRect(
borderRadius: BorderRadius.circular(22),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 16, sigmaY: 16),
child: Material(
color: isDark
? theme.colorScheme.surfaceContainerHigh
.withValues(alpha: 0.60)
: Colors.white.withValues(alpha: 0.82),
child: InkWell(
borderRadius: BorderRadius.circular(22),
border: Border.all(
color: isDark
? Colors.white.withValues(alpha: 0.18)
: Colors.white.withValues(alpha: 0.85),
width: 1.1,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 24,
height: 24,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
cat.color,
cat.color.withValues(alpha: 0.82),
],
),
borderRadius: BorderRadius.circular(7),
boxShadow: [
BoxShadow(
color: cat.color.withValues(alpha: 0.35),
blurRadius: 5,
offset: const Offset(0, 1.5),
onTap: () {
HapticFeedback.selectionClick();
widget.onSelect(cat.key);
},
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 7),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(22),
border: Border.all(
color: isDark
? Colors.white.withValues(alpha: 0.18)
: Colors.white.withValues(alpha: 0.85),
width: 1.1,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 24,
height: 24,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
cat.color,
cat.color.withValues(alpha: 0.82),
],
),
borderRadius: BorderRadius.circular(7),
boxShadow: [
BoxShadow(
color: cat.color.withValues(alpha: 0.35),
blurRadius: 5,
offset: const Offset(0, 1.5),
),
],
),
],
),
child: Center(
child: Icon(cat.icon, size: 14, color: Colors.white),
),
child: Center(
child: Icon(cat.icon,
size: 14, color: Colors.white),
),
),
const SizedBox(width: 8),
Text(
cat.label,
style: theme.textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w600,
letterSpacing: -0.2,
),
),
],
),
const SizedBox(width: 8),
Text(
cat.label,
style: theme.textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w600,
letterSpacing: -0.2,
),
),
],
),
),
),
),
),
),
),
);
},
);
},
),
),
),
);
}
@@ -427,6 +427,8 @@ class _ViewedPostTile extends StatelessWidget {
)
: CachedNetworkImage(
imageUrl: image,
memCacheWidth: 320,
memCacheHeight: 320,
fit: BoxFit.cover,
placeholder: (_, __) => Container(
color: scheme.surfaceContainerHighest,
+5
View File
@@ -1,9 +1,14 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:media_kit/media_kit.dart';
import 'app/app.dart';
void main() {
WidgetsFlutterBinding.ensureInitialized();
MediaKit.ensureInitialized();
// Optimize image cache to prevent memory bloat and OOM crashes
PaintingBinding.instance.imageCache.maximumSizeBytes = 180 * 1024 * 1024; // 180 MB
PaintingBinding.instance.imageCache.maximumSize = 120; // 120 images
runApp(const ProviderScope(child: GelRuleApp()));
}
+473 -311
View File
@@ -5,7 +5,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../app/app.dart';
import '../../app/motion.dart';
import '../../app/responsive.dart';
import '../../app/router.dart';
import '../../backend/backend.dart';
@@ -385,10 +384,55 @@ class _DesktopShell extends StatefulWidget {
State<_DesktopShell> createState() => _DesktopShellState();
}
class _DesktopShellState extends State<_DesktopShell> {
class _DesktopShellState extends State<_DesktopShell>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
late final Animation<double> _animation;
bool _hovered = false;
bool _pinned = false;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 220),
);
_animation = CurvedAnimation(
parent: _controller,
curve: Curves.easeOutCubic,
reverseCurve: Curves.easeInCubic,
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _handleHover(bool hovered) {
if (_hovered == hovered) return;
setState(() => _hovered = hovered);
if (_pinned) return;
if (hovered) {
_controller.forward();
} else {
_controller.reverse();
}
}
void _togglePinned() {
setState(() {
_pinned = !_pinned;
if (_pinned) {
_controller.forward();
} else if (!_hovered) {
_controller.reverse();
}
});
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
@@ -400,256 +444,354 @@ class _DesktopShellState extends State<_DesktopShell> {
final selected = widget.destinations.indexWhere(
(item) => AppShell.branchIndexForLocation(item.location) == activeBranch,
);
final isExpanded = _pinned || _hovered;
return Scaffold(
body: Row(
children: [
MouseRegion(
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: AnimatedContainer(
duration: AppMotion.duration(context, 200),
curve: Curves.easeOutCubic,
width: isExpanded ? 260 : 74,
decoration: BoxDecoration(
color: isDark
? scheme.surfaceContainerHigh.withValues(alpha: 0.75)
: scheme.surface.withValues(alpha: 0.85),
border: Border(
right: BorderSide(
onEnter: (_) => _handleHover(true),
onExit: (_) => _handleHover(false),
child: AnimatedBuilder(
animation: _animation,
builder: (context, _) {
final progress = _animation.value;
final width = lerpDouble(74.0, 260.0, progress)!;
final contentOpacity =
(progress - 0.25).clamp(0.0, 0.75) / 0.75;
final isFullyExpanded = progress > 0.85;
return Container(
width: width,
decoration: BoxDecoration(
color: isDark
? Colors.white.withValues(alpha: 0.12)
: scheme.outlineVariant.withValues(alpha: 0.35),
width: 1,
? scheme.surfaceContainerHigh
: scheme.surface,
border: Border(
right: BorderSide(
color: isDark
? Colors.white.withValues(alpha: 0.1)
: scheme.outlineVariant.withValues(alpha: 0.35),
width: 1,
),
),
boxShadow: [
BoxShadow(
color:
Colors.black.withValues(alpha: isDark ? 0.3 : 0.05),
blurRadius: 16,
offset: const Offset(3, 0),
),
],
),
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: isDark ? 0.3 : 0.05),
blurRadius: 18,
offset: const Offset(4, 0),
),
],
),
child: ClipRect(
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
child: SafeArea(
child: Column(
children: [
// App Brand Header
Padding(
padding: const EdgeInsets.fromLTRB(14, 16, 12, 14),
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,
),
),
),
if (isExpanded) ...[
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Lunaris',
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.6.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: () {
setState(() => _pinned = !_pinned);
},
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,
expanded: isExpanded,
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: ClipRect(
child: OverflowBox(
minWidth: 260,
maxWidth: 260,
alignment: Alignment.topLeft,
child: SizedBox(
width: 260,
child: SafeArea(
child: Column(
children: [
// Shortcuts helper button
Tooltip(
message: isExpanded
? ''
: (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,
),
// 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,
],
),
if (isExpanded) ...[
Expanded(
child: Text(
widget.ru
? 'Горячие клавиши'
: 'Shortcuts',
style: TextStyle(
fontWeight: FontWeight.w600,
fontSize: 13,
color: scheme.onSurface,
),
overflow: TextOverflow.ellipsis,
),
borderRadius:
BorderRadius.circular(14),
boxShadow: [
BoxShadow(
color: scheme.primary
.withValues(alpha: 0.35),
blurRadius: 12,
offset: const Offset(0, 3),
),
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),
],
],
),
child: const Center(
child: Icon(
Icons.auto_awesome_rounded,
color: Colors.white,
size: 24,
),
),
),
),
const SizedBox(width: 12),
Expanded(
child: Opacity(
opacity: contentOpacity,
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
mainAxisAlignment:
MainAxisAlignment.center,
children: [
Text(
'Lunaris',
maxLines: 1,
softWrap: false,
style: theme
.textTheme
.titleMedium
?.copyWith(
fontWeight:
FontWeight.w800,
letterSpacing: 0.2,
),
),
const SizedBox(height: 2),
Container(
padding: const EdgeInsets
.symmetric(
horizontal: 6,
vertical: 1.5,
),
decoration: BoxDecoration(
color: scheme.primary
.withValues(
alpha: 0.15),
borderRadius:
BorderRadius
.circular(6),
border: Border.all(
color: scheme.primary
.withValues(
alpha: 0.3),
width: 0.8,
),
),
child: Text(
'v3.7.2',
style: TextStyle(
fontSize: 10,
fontWeight:
FontWeight.bold,
color: scheme.primary,
letterSpacing: 0.4,
),
),
),
],
),
),
IconButton(
visualDensity:
VisualDensity.compact,
tooltip: _pinned
? (widget.ru
? 'Открепить панель'
: 'Unpin panel')
: (widget.ru
? 'Закрепить панель'
: 'Pin panel'),
onPressed: _togglePinned,
icon: Icon(
_pinned
? Icons.push_pin_rounded
: Icons.push_pin_outlined,
size: 19,
color: _pinned
? scheme.primary
: scheme
.onSurfaceVariant,
),
),
],
),
),
),
],
),
),
),
const Divider(height: 1),
const SizedBox(height: 8),
// Quick pin toggle button when collapsed
if (!isExpanded)
IconButton(
tooltip: widget.ru ? 'Закрепить панель' : 'Pin sidebar',
onPressed: () => setState(() => _pinned = true),
icon: const Icon(Icons.chevron_right_rounded, size: 20),
// 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),
),
],
),
),
),
],
),
),
],
),
),
),
),
),
);
},
),
),
Expanded(child: widget.child),
@@ -663,7 +805,8 @@ class _RailButton extends StatelessWidget {
const _RailButton({
required this.destination,
required this.selected,
required this.expanded,
required this.contentOpacity,
required this.isFullyExpanded,
required this.ru,
required this.shortcutKey,
required this.onTap,
@@ -671,7 +814,8 @@ class _RailButton extends StatelessWidget {
final _Destination destination;
final bool selected;
final bool expanded;
final double contentOpacity;
final bool isFullyExpanded;
final bool ru;
final String shortcutKey;
final VoidCallback onTap;
@@ -686,7 +830,7 @@ class _RailButton extends StatelessWidget {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Tooltip(
message: expanded ? '' : '$label ($shortcutKey)',
message: isFullyExpanded ? '' : '$label ($shortcutKey)',
waitDuration: const Duration(milliseconds: 350),
child: Material(
color: selected
@@ -696,95 +840,113 @@ class _RailButton extends StatelessWidget {
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: Container(
child: SizedBox(
width: 244,
height: 46,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(14),
border: selected
? Border.all(
color: scheme.primary.withValues(alpha: isDark ? 0.45 : 0.28),
width: 1.1,
)
: null,
),
child: Row(
children: [
// Active left indicator bar
Container(
width: 3.5,
height: 22,
decoration: BoxDecoration(
color: selected ? scheme.primary : Colors.transparent,
borderRadius: BorderRadius.circular(2),
boxShadow: selected
? [
BoxShadow(
color: scheme.primary.withValues(alpha: 0.6),
blurRadius: 6,
),
]
: null,
),
),
SizedBox(
width: 54.5,
child: Center(
child: Icon(
destination.icon,
size: 22,
color: selected
? scheme.primary
: (isDark
? scheme.onSurfaceVariant.withValues(alpha: 0.85)
: scheme.onSurfaceVariant),
),
),
),
if (expanded) ...[
Expanded(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 13.5,
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
color: selected ? scheme.primary : scheme.onSurface,
),
),
),
child: DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(14),
border: selected
? Border.all(
color: scheme.primary
.withValues(alpha: isDark ? 0.45 : 0.28),
width: 1.1,
)
: null,
),
child: Row(
children: [
// Active left indicator bar
Container(
padding: const EdgeInsets.symmetric(
horizontal: 5.5,
vertical: 2,
),
width: 3.5,
height: 22,
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,
),
color: selected ? scheme.primary : Colors.transparent,
borderRadius: BorderRadius.circular(2),
boxShadow: selected
? [
BoxShadow(
color: scheme.primary.withValues(alpha: 0.6),
blurRadius: 6,
),
]
: null,
),
child: Text(
shortcutKey,
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
fontFamily: 'monospace',
),
SizedBox(
width: 54.5,
child: Center(
child: Icon(
destination.icon,
size: 22,
color: selected
? scheme.primary
: scheme.onSurfaceVariant.withValues(alpha: 0.8),
: (isDark
? scheme.onSurfaceVariant
.withValues(alpha: 0.85)
: scheme.onSurfaceVariant),
),
),
),
Expanded(
child: Opacity(
opacity: contentOpacity,
child: Row(
children: [
Expanded(
child: Text(
label,
maxLines: 1,
softWrap: false,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 13.5,
fontWeight: selected
? FontWeight.w700
: FontWeight.w500,
color: selected
? scheme.primary
: scheme.onSurface,
),
),
),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 5.5,
vertical: 2,
),
decoration: BoxDecoration(
color: isDark
? Colors.white.withValues(alpha: 0.08)
: Colors.black.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(6),
border: Border.all(
color: isDark
? Colors.white.withValues(alpha: 0.12)
: Colors.black.withValues(alpha: 0.08),
width: 0.8,
),
),
child: Text(
shortcutKey,
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
fontFamily: 'monospace',
color: selected
? scheme.primary
: scheme.onSurfaceVariant
.withValues(alpha: 0.8),
),
),
),
const SizedBox(width: 8),
],
),
),
),
const SizedBox(width: 10),
],
],
),
),
),
),
+11 -2
View File
@@ -1,5 +1,6 @@
import 'dart:async';
import 'dart:collection';
import 'dart:io';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
@@ -232,7 +233,7 @@ class _PostCardState extends ConsumerState<PostCard>
}
final dpr = MediaQuery.devicePixelRatioOf(context);
final cacheWidth =
(constraints.maxWidth * dpr).round().clamp(320, 1800);
(constraints.maxWidth * dpr).round().clamp(280, 960);
final headers = _headersFor(post);
final videoUrls = MediaUrlSelector.video(post);
if (!mobile &&
@@ -590,7 +591,15 @@ class _FeedVideoPreviewState extends State<_FeedVideoPreview> {
super.initState();
MediaKit.ensureInitialized();
_player = Player();
_controller = VideoController(_player);
final isDesktop =
Platform.isLinux || Platform.isWindows || Platform.isMacOS;
_controller = VideoController(
_player,
configuration: VideoControllerConfiguration(
enableHardwareAcceleration: !isDesktop,
hwdec: isDesktop ? 'no' : 'auto-safe',
),
);
_player.stream.error.listen((_) {
if (mounted) setState(() => _failed = true);
});
+20 -18
View File
@@ -68,24 +68,26 @@ class PostMasonryGrid extends StatelessWidget {
final spacing = mobile ? 8.0 : 12.0;
Widget buildCard(Post post) {
return PostCard(
post: post,
blurExplicit: blurExplicit && !nsfwEnabled,
showBadges: showBadges,
isFavorite: favoriteKeys.contains(post.cacheKey),
isViewed: viewedKeys.contains(post.cacheKey),
isDownloaded: downloadedKeys.contains(post.cacheKey),
mediaQualityMode: mediaQualityMode,
onOpen: () => onOpen(post),
onFavorite: () => onFavorite(post),
onPreview: onPreview == null ? null : () => onPreview!(post),
onHide: onHide == null ? null : () => onHide!(post),
selectionMode: selectionMode,
selected: selectedKeys.contains(post.cacheKey),
onToggleSelected:
onToggleSelected == null ? null : () => onToggleSelected!(post),
onAddToCollection:
onAddToCollection == null ? null : () => onAddToCollection!(post),
return RepaintBoundary(
child: PostCard(
post: post,
blurExplicit: blurExplicit && !nsfwEnabled,
showBadges: showBadges,
isFavorite: favoriteKeys.contains(post.cacheKey),
isViewed: viewedKeys.contains(post.cacheKey),
isDownloaded: downloadedKeys.contains(post.cacheKey),
mediaQualityMode: mediaQualityMode,
onOpen: () => onOpen(post),
onFavorite: () => onFavorite(post),
onPreview: onPreview == null ? null : () => onPreview!(post),
onHide: onHide == null ? null : () => onHide!(post),
selectionMode: selectionMode,
selected: selectedKeys.contains(post.cacheKey),
onToggleSelected:
onToggleSelected == null ? null : () => onToggleSelected!(post),
onAddToCollection:
onAddToCollection == null ? null : () => onAddToCollection!(post),
),
);
}
+1 -1
View File
@@ -1,7 +1,7 @@
name: gel_rule_app
description: Local backend/core layer for a Flutter booru browser app.
publish_to: "none"
version: 3.6.0+82
version: 3.7.3+91
environment:
sdk: ">=3.3.0 <4.0.0"