Compare commits

...
5 Commits
Author SHA1 Message Date
RarDog 5b2c8eb53e fix(perf): memory optimization, prevent OOM on mobile, and completely disable mouse wheel zoom
Build and Release / Build Windows x64 (push) Has been cancelled
Build and Release / Build macOS (push) Has been cancelled
2026-09-05 18:09:58 +03:00
RarDog d7d9cad423 fix(desktop): smooth sidebar hover animation without text jitter and remove mouse wheel zoom
Build and Release / Build Windows x64 (push) Has been cancelled
Build and Release / Build macOS (push) Has been cancelled
2026-09-05 17:58:49 +03:00
RarDog 7d3c4f964a feat: desktop UI redesign and comprehensive PC hotkeys (v3.6.0)
Build and Release / Build Windows x64 (push) Has been cancelled
Build and Release / Build macOS (push) Has been cancelled
2026-09-05 17:44:53 +03:00
RarDog 877a044ff3 feat(linux): add build_linux_appimage.sh script for AppImage packaging 2026-09-05 17:15:21 +03:00
RarDog 28a63483da feat: persist last active tab and preserve per-tab navigation state
Build and Release / Build Windows x64 (push) Has been cancelled
Build and Release / Build macOS (push) Has been cancelled
2026-09-05 15:59:02 +03:00
28 changed files with 2199 additions and 468 deletions
+1 -1
View File
@@ -14,7 +14,7 @@
</p>
<p align="center">
<a href="https://github.com/RarDog/Prisma/releases"><img src="https://img.shields.io/badge/Version-3.5.4-8A2BE2?style=for-the-badge&logo=semver&logoColor=white" alt="Version 3.5.4" /></a>
<a href="https://github.com/RarDog/Prisma/releases"><img src="https://img.shields.io/badge/Version-3.5.5-8A2BE2?style=for-the-badge&logo=semver&logoColor=white" alt="Version 3.5.5" /></a>
<img src="https://img.shields.io/badge/Flutter-3.47+-02569B?style=for-the-badge&logo=flutter&logoColor=white" alt="Flutter" />
<img src="https://img.shields.io/badge/Platform-Android%20%7C%20Linux%20%7C%20Windows%20%7C%20macOS-00C853?style=for-the-badge" alt="Platforms" />
<a href="https://github.com/RarDog/Prisma/wiki"><img src="https://img.shields.io/badge/Wiki-Documentation-FF6F00?style=for-the-badge&logo=gitbook&logoColor=white" alt="Wiki" /></a>
+1 -1
View File
@@ -14,7 +14,7 @@
</p>
<p align="center">
<a href="https://github.com/RarDog/Prisma/releases"><img src="https://img.shields.io/badge/Version-3.5.4-8A2BE2?style=for-the-badge&logo=semver&logoColor=white" alt="Version 3.5.4" /></a>
<a href="https://github.com/RarDog/Prisma/releases"><img src="https://img.shields.io/badge/Version-3.5.5-8A2BE2?style=for-the-badge&logo=semver&logoColor=white" alt="Version 3.5.5" /></a>
<img src="https://img.shields.io/badge/Flutter-3.47+-02569B?style=for-the-badge&logo=flutter&logoColor=white" alt="Flutter" />
<img src="https://img.shields.io/badge/Platform-Android%20%7C%20Linux%20%7C%20Windows%20%7C%20macOS-00C853?style=for-the-badge" alt="Platforms" />
<a href="https://github.com/RarDog/Prisma/wiki"><img src="https://img.shields.io/badge/Wiki-Documentation-FF6F00?style=for-the-badge&logo=gitbook&logoColor=white" alt="Wiki" /></a>
+39 -38
View File
@@ -4,32 +4,33 @@
<application
android:label="Prisma"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:largeHeap="true">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
@@ -43,15 +44,15 @@
android:resource="@xml/file_paths" />
</provider>
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>
+92
View File
@@ -0,0 +1,92 @@
#!/usr/bin/env bash
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
cd "${ROOT_DIR}"
VERSION=$(grep 'version:' pubspec.yaml | head -n 1 | awk '{print $2}' | cut -d'+' -f1)
if [ -z "$VERSION" ]; then
VERSION="3.5.5"
fi
echo "Building AppImage for Prisma v${VERSION}..."
BUNDLE_DIR="${ROOT_DIR}/build/linux/x64/release/bundle"
if [ ! -f "${BUNDLE_DIR}/gel_rule_app" ]; then
echo "Release bundle not found. Running 'flutter build linux --release'..."
flutter build linux --release
fi
APP_DIR="${ROOT_DIR}/build/AppDir"
OUTPUT_DIR="${ROOT_DIR}/build"
APPIMAGE_NAME="Prisma-v${VERSION}-linux-x86_64.AppImage"
OUTPUT_APPIMAGE="${OUTPUT_DIR}/${APPIMAGE_NAME}"
# Ensure appimagetool is available
APPIMAGETOOL=""
if command -v appimagetool >/dev/null 2>&1; then
APPIMAGETOOL="appimagetool"
elif [ -f "${ROOT_DIR}/build/tools/appimagetool" ]; then
APPIMAGETOOL="${ROOT_DIR}/build/tools/appimagetool"
else
echo "Downloading appimagetool..."
mkdir -p "${ROOT_DIR}/build/tools"
curl -fsSL https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage -o "${ROOT_DIR}/build/tools/appimagetool"
chmod +x "${ROOT_DIR}/build/tools/appimagetool"
APPIMAGETOOL="${ROOT_DIR}/build/tools/appimagetool"
fi
echo "Preparing AppDir at ${APP_DIR}..."
rm -rf "${APP_DIR}"
mkdir -p "${APP_DIR}"
# Copy bundle contents
cp -r "${BUNDLE_DIR}"/* "${APP_DIR}/"
# AppRun script
cat << 'EOF' > "${APP_DIR}/AppRun"
#!/bin/sh
HERE="$(dirname "$(readlink -f "${0}")")"
export PATH="${HERE}/usr/bin:${PATH}"
export LD_LIBRARY_PATH="${HERE}/lib:${HERE}/usr/lib:${LD_LIBRARY_PATH}"
exec "${HERE}/gel_rule_app" "$@"
EOF
chmod +x "${APP_DIR}/AppRun"
# Desktop file
cat << 'EOF' > "${APP_DIR}/prisma.desktop"
[Desktop Entry]
Name=Prisma
GenericName=Booru Client
Comment=Prisma - Modern Booru & Art Client
Exec=gel_rule_app %u
Icon=prisma
Terminal=false
Type=Application
Categories=Network;Graphics;
StartupWMClass=com.example.gel_rule_app
EOF
mkdir -p "${APP_DIR}/usr/share/applications"
cp "${APP_DIR}/prisma.desktop" "${APP_DIR}/usr/share/applications/prisma.desktop"
# Icons
ICON_SRC="${ROOT_DIR}/macos/Runner/Assets.xcassets/AppIcon.appiconset"
if [ -d "${ICON_SRC}" ]; then
for size in 16 32 64 128 256 512 1024; do
if [ -f "${ICON_SRC}/app_icon_${size}.png" ]; then
mkdir -p "${APP_DIR}/usr/share/icons/hicolor/${size}x${size}/apps"
cp "${ICON_SRC}/app_icon_${size}.png" "${APP_DIR}/usr/share/icons/hicolor/${size}x${size}/apps/prisma.png"
fi
done
cp "${ICON_SRC}/app_icon_512.png" "${APP_DIR}/prisma.png"
fi
# Build AppImage
echo "Generating AppImage with ${APPIMAGETOOL}..."
ARCH=x86_64 "${APPIMAGETOOL}" -n "${APP_DIR}" "${OUTPUT_APPIMAGE}"
echo "AppImage created successfully: ${OUTPUT_APPIMAGE}"
ls -lh "${OUTPUT_APPIMAGE}"
+41
View File
@@ -0,0 +1,41 @@
import 'package:flutter/material.dart';
import '../backend/models/post.dart';
import '../features/post/presentation/post_details_screen.dart';
import '../features/post/presentation/similar_posts_screen.dart';
class AppNavigator {
const AppNavigator._();
static void openPost(
BuildContext context, {
required Post post,
List<Post>? postsList,
}) {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (ctx) => PostDetailsScreen(
providerId: post.providerId,
postId: post.id,
initialPost: post,
postsList: postsList,
),
),
);
}
static void openSimilarPosts(
BuildContext context, {
required Post post,
}) {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (ctx) => SimilarPostsScreen(
providerId: post.providerId,
postId: post.id,
initialPost: post,
),
),
);
}
}
+2 -2
View File
@@ -1,3 +1,3 @@
const appDisplayVersion = '3.5.4';
const appBuildNumber = 80;
const appDisplayVersion = '3.5.5';
const appBuildNumber = 81;
const appReleaseChannel = 'Stable';
+40
View File
@@ -1,4 +1,44 @@
const prismaChangelog = [
PrismaChange(
version: '3.6.2',
title: 'Комплексная оптимизация памяти на телефоне и ПК, полное отключение зума колесиком',
bullets: [
'Полное отключение масштабирования колесиком мыши: перехвачен внутренний обработчик PointerScrollEvent в интерактивном вьюере, что навсегда исключило нежелательный зум при прокрутке.',
'Предотвращение вылетов на смартфонах (OOM): включен флаг android:largeHeap="true", расширяющий доступный лимит оперативной памяти процесса до 512–1024 МБ.',
'Ограничение аппетита кэша изображений: глобальный ImageCache в Flutter ограничен безопасным лимитом в 180 МБ и 120 изображений (вместо неограниченного роста до 1 ГБ+), неактивные растры своевременно выгружаются сборщиком мусора.',
'Оптимизация декодирования тяжелых артов: полноразмерные изображения с экстремальным разрешением (до 10K+) теперь безопасно декодируются с ограничением memCacheWidth под экран устройства, снижая расход RAM на 80–90%.',
'Оптимизация сетки и миниатюр: карточки ленты изолированы в RepaintBoundary для гладкого 120 FPS скролла без паразитных перерисовок, а всем каруселям, аватаркам и мозаикам заданы строгие границы кэширования.',
],
),
PrismaChange(
version: '3.6.1',
title: 'Плавная боковая панель без лагов и отключение зума колесиком',
bullets: [
'Устранение лагов боковой панели: полностью переработана анимация наведения на боковую панель на ПК. Убран тяжелый шейдерный блюр при изменении ширины и зафиксирована верстка элементов — текст больше не сжимается, не дергается и раскрывается на 120 FPS максимально плавно.',
'Отключение зума фото по колесику мыши: исключено случайное и резкое масштабирование изображений при прокрутке колесиком мыши. Масштабирование фото доступно через горячие клавиши (+, -, 0, F) или двойной клик.',
],
),
PrismaChange(
version: '3.6.0',
title: 'Редизайн интерфейса для ПК и система горячих клавиш (хоткеи)',
bullets: [
'Горячие клавиши для всех вкладок: переключение разделов по Ctrl+1..8 (и Alt+1..8), циклический переход Ctrl+Tab / Ctrl+Shift+Tab, поиск Ctrl+F, обновление Ctrl+R / F5.',
'Хоткеи видеоплеера: пауза/воспроизведение (Space, K), плавная перемотка на 5с (←/→, J/L) и 15с (Shift+←/→) с визуальными индикаторами, регулировка громкости ↑/↓, выключение звука M, полный экран F.',
'Хоткеи просмотра фото и галереи: масштабирование клавишами + / - / 0 (сброс в 100%), переключение страниц и фото в постах стрелками ← / →, быстрое сохранение Ctrl+S.',
'Окно-шпаргалка горячих клавиш: интерактивное модальное окно со всеми клавиатурными комбинациями по нажатию ? или F1, а также из меню панели.',
'Редизайн боковой панели ПК: боковое меню в стиле Liquid Glass с эффектом матового стекла, логотипом Lunaris, бейджами хоткеев, закреплением панели и быстрыми действиями.',
'Редизайн вкладок ленты: стильный сегментированный бар выбора периодов (Все время, 24ч, Неделя, Месяц, Год) с анимированными иконками и неоновые чипы источников.',
],
),
PrismaChange(
version: '3.5.5',
title: 'Сохранение последней вкладки и состояния навигации между разделами',
bullets: [
'Сохранение последней вкладки: приложение запоминает активный раздел при выходе или закрытии и автоматически открывает его при следующем запуске.',
'Независимая история и сохранение места: при переключении между вкладками состояние каждого раздела полностью сохраняется. Например, если открыть публикацию или карточку у артиста и переключиться в поиск или ленту, при возврате обратно вы останетесь ровно на том же посте.',
'Бесшовные переходы между ветками навигации: улучшена обработка системной кнопки «Назад», переход по разделам стал еще быстрее и плавнее.',
],
),
PrismaChange(
version: '3.5.4',
title: 'Исправление распознавания MP4-видео и отключение устаревших сервисов Kemono/Coomer',
+153 -98
View File
@@ -21,116 +21,171 @@ import '../features/settings/presentation/settings_screen.dart';
import '../features/viewed/presentation/viewed_screen.dart';
import '../shared/widgets/app_shell.dart';
final branchNavKeys = List.generate(8, (_) => GlobalKey<NavigatorState>());
final appRouterProvider = Provider<GoRouter>((ref) {
return GoRouter(
initialLocation: '/',
routes: [
ShellRoute(
builder: (context, state, child) => AppShell(child: child),
StatefulShellRoute.indexedStack(
builder: (context, state, navigationShell) => AppShell(
navigationShell: navigationShell,
child: navigationShell,
),
branches: [
StatefulShellBranch(
navigatorKey: branchNavKeys[0],
routes: [
GoRoute(
path: '/',
pageBuilder: (context, state) => _transitionPage(
state,
child: FeedScreen(initialQuery: state.uri.queryParameters['q']),
),
),
],
),
StatefulShellBranch(
navigatorKey: branchNavKeys[1],
routes: [
GoRoute(
path: '/search',
builder: (context, state) => const SearchScreen(),
),
],
),
StatefulShellBranch(
navigatorKey: branchNavKeys[2],
routes: [
GoRoute(
path: '/favorites',
builder: (context, state) => const FavoritesScreen(),
),
],
),
StatefulShellBranch(
navigatorKey: branchNavKeys[3],
routes: [
GoRoute(
path: '/viewed',
builder: (context, state) => const ViewedScreen(),
),
],
),
StatefulShellBranch(
navigatorKey: branchNavKeys[4],
routes: [
GoRoute(
path: '/collections',
builder: (context, state) => const CollectionsScreen(),
routes: [
GoRoute(
path: ':collectionId',
builder: (context, state) => CollectionDetailsScreen(
collectionId: state.pathParameters['collectionId']!,
),
),
],
),
],
),
StatefulShellBranch(
navigatorKey: branchNavKeys[5],
routes: [
GoRoute(
path: '/artists',
builder: (context, state) => const ArtistsScreen(),
routes: [
GoRoute(
path: ':providerId/:service/:artistId',
builder: (context, state) => ArtistPostsScreen(
providerId: state.pathParameters['providerId']!,
service: state.pathParameters['service']!,
artistId: state.pathParameters['artistId']!,
artistName: state.uri.queryParameters['name'] ??
state.pathParameters['artistId']!,
),
),
],
),
],
),
StatefulShellBranch(
navigatorKey: branchNavKeys[6],
routes: [
GoRoute(
path: '/providers',
builder: (context, state) => const ProvidersScreen(),
routes: [
GoRoute(
path: 'new',
builder: (context, state) => ProviderFormScreen(
initialConfig: state.extra is ContentProviderConfig
? state.extra! as ContentProviderConfig
: null,
),
),
GoRoute(
path: 'check',
builder: (context, state) => const ProviderCheckScreen(),
),
],
),
],
),
StatefulShellBranch(
navigatorKey: branchNavKeys[7],
routes: [
GoRoute(
path: '/settings',
builder: (context, state) => const SettingsScreen(),
routes: [
GoRoute(
path: 'hidden',
builder: (context, state) => const HiddenPostsScreen(),
),
GoRoute(
path: 'cache',
builder: (context, state) => const CacheManagerScreen(),
),
],
),
],
),
],
),
GoRoute(
path: '/post/:providerId/:postId',
builder: (context, state) {
final extra = state.extra;
final Post? initialPost;
final List<Post>? postsList;
if (extra is PostNavigationContext) {
initialPost = extra.currentPost;
postsList = extra.posts;
} else if (extra is Post) {
initialPost = extra;
postsList = null;
} else {
initialPost = null;
postsList = null;
}
return PostDetailsScreen(
providerId: state.pathParameters['providerId']!,
postId: state.pathParameters['postId']!,
initialPost: initialPost,
postsList: postsList,
);
},
routes: [
GoRoute(
path: '/',
pageBuilder: (context, state) => _transitionPage(
state,
child: FeedScreen(initialQuery: state.uri.queryParameters['q']),
),
),
GoRoute(
path: '/search',
builder: (context, state) => const SearchScreen(),
),
GoRoute(
path: '/post/:providerId/:postId',
builder: (context, state) {
final extra = state.extra;
final Post? initialPost;
final List<Post>? postsList;
if (extra is PostNavigationContext) {
initialPost = extra.currentPost;
postsList = extra.posts;
} else if (extra is Post) {
initialPost = extra;
postsList = null;
} else {
initialPost = null;
postsList = null;
}
return PostDetailsScreen(
providerId: state.pathParameters['providerId']!,
postId: state.pathParameters['postId']!,
initialPost: initialPost,
postsList: postsList,
);
},
),
GoRoute(
path: '/post/:providerId/:postId/similar',
path: 'similar',
builder: (context, state) => SimilarPostsScreen(
providerId: state.pathParameters['providerId']!,
postId: state.pathParameters['postId']!,
initialPost: state.extra is Post ? state.extra! as Post : null,
),
),
GoRoute(
path: '/favorites',
builder: (context, state) => const FavoritesScreen(),
),
GoRoute(
path: '/viewed',
builder: (context, state) => const ViewedScreen(),
),
GoRoute(
path: '/collections',
builder: (context, state) => const CollectionsScreen(),
),
GoRoute(
path: '/collections/:collectionId',
builder: (context, state) => CollectionDetailsScreen(
collectionId: state.pathParameters['collectionId']!,
),
),
GoRoute(
path: '/artists',
builder: (context, state) => const ArtistsScreen(),
),
GoRoute(
path: '/artists/:providerId/:service/:artistId',
builder: (context, state) => ArtistPostsScreen(
providerId: state.pathParameters['providerId']!,
service: state.pathParameters['service']!,
artistId: state.pathParameters['artistId']!,
artistName: state.uri.queryParameters['name'] ??
state.pathParameters['artistId']!,
),
),
GoRoute(
path: '/providers',
builder: (context, state) => const ProvidersScreen(),
),
GoRoute(
path: '/providers/new',
builder: (context, state) => ProviderFormScreen(
initialConfig: state.extra is ContentProviderConfig
? state.extra! as ContentProviderConfig
: null,
),
),
GoRoute(
path: '/providers/check',
builder: (context, state) => const ProviderCheckScreen(),
),
GoRoute(
path: '/settings',
builder: (context, state) => const SettingsScreen(),
),
GoRoute(
path: '/settings/hidden',
builder: (context, state) => const HiddenPostsScreen(),
),
GoRoute(
path: '/settings/cache',
builder: (context, state) => const CacheManagerScreen(),
),
],
),
],
@@ -64,8 +64,10 @@ class AppSettings {
this.downloadPathTemplate = '{Artist}/{ID}',
this.searchHistoryLimit = 500,
this.tagCacheLimit = 5000,
this.lastActiveLocation = '/',
});
final String lastActiveLocation;
final List<String> favoriteArtists;
final List<String> pawchiveAccounts;
final bool pawchiveBidirectionalSync;
@@ -165,6 +167,7 @@ class AppSettings {
lastFeedRating: null,
searchHistoryLimit: 500,
tagCacheLimit: 5000,
lastActiveLocation: '/',
);
AppSettings copyWith({
@@ -220,8 +223,10 @@ class AppSettings {
String? downloadPathTemplate,
int? searchHistoryLimit,
int? tagCacheLimit,
String? lastActiveLocation,
}) {
return AppSettings(
lastActiveLocation: lastActiveLocation ?? this.lastActiveLocation,
favoriteArtists: favoriteArtists ?? this.favoriteArtists,
pawchiveAccounts: pawchiveAccounts ?? this.pawchiveAccounts,
pawchiveBidirectionalSync:
@@ -336,6 +341,7 @@ class AppSettings {
'downloadPathTemplate': downloadPathTemplate,
'searchHistoryLimit': searchHistoryLimit,
'tagCacheLimit': tagCacheLimit,
'lastActiveLocation': lastActiveLocation,
};
factory AppSettings.fromJson(Map<String, dynamic> json) => AppSettings(
@@ -455,6 +461,8 @@ class AppSettings {
defaults.searchHistoryLimit,
tagCacheLimit: (json['tagCacheLimit'] as num?)?.toInt() ??
defaults.tagCacheLimit,
lastActiveLocation: (json['lastActiveLocation'] as String?) ??
defaults.lastActiveLocation,
);
List<PawchiveAccount> get parsedPawchiveAccounts {
@@ -545,6 +553,14 @@ class SettingsService {
return updateSettings(settings.copyWith(nsfwEnabled: enabled));
}
Future<Result<void>> saveLastActiveLocation(String location) async {
final result = await getSettings();
if (result is Error<AppSettings>) return Error(result.failure);
final settings = (result as Success<AppSettings>).data;
if (settings.lastActiveLocation == location) return const Success(null);
return updateSettings(settings.copyWith(lastActiveLocation: location));
}
Future<Result<void>> saveCacheSettings({
required int ttlHours,
required int maxItems,
@@ -3,10 +3,10 @@ import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../../app/app.dart';
import '../../../app/app_navigator.dart';
import '../../../app/responsive.dart';
import '../../../backend/backend.dart';
import '../../../core/utils/result.dart';
@@ -791,12 +791,10 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
settings.mediaQualityMode),
favoriteKeys: favoriteKeys,
viewedKeys: viewedKeys,
onOpen: (post) => context.push(
'/post/${post.providerId}/${post.id}',
extra: PostNavigationContext(
currentPost: post,
posts: displayedPosts,
),
onOpen: (post) => AppNavigator.openPost(
context,
post: post,
postsList: displayedPosts,
),
onFavorite: (post) async {
if (favoriteKeys.contains(post.cacheKey)) {
@@ -7,6 +7,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../app/app.dart';
import '../../../app/app_navigator.dart';
import '../../../app/responsive.dart';
import '../../../backend/backend.dart';
import '../../../core/utils/result.dart';
@@ -1473,6 +1474,8 @@ class _FavoriteArtistsSection extends ConsumerWidget {
fav.avatarUrl!.isNotEmpty
? CachedNetworkImage(
imageUrl: fav.avatarUrl!,
memCacheWidth: 120,
memCacheHeight: 120,
fit: BoxFit.cover,
placeholder: (_, __) => const Icon(
Icons.person_rounded,
@@ -1574,12 +1577,10 @@ class _FavoriteArtistMediaStrip extends ConsumerWidget {
final isVideo = MediaUrlSelector.isVideo(post);
return InkWell(
borderRadius: BorderRadius.circular(14),
onTap: () => context.push(
'/post/${post.providerId}/${post.id}',
extra: PostNavigationContext(
currentPost: post,
posts: posts,
),
onTap: () => AppNavigator.openPost(
context,
post: post,
postsList: posts,
),
child: Container(
decoration: BoxDecoration(
@@ -1600,6 +1601,8 @@ class _FavoriteArtistMediaStrip extends ConsumerWidget {
imageUrl: post.previewUrl.isNotEmpty
? post.previewUrl
: post.sampleUrl,
memCacheWidth: 200,
memCacheHeight: 200,
fit: BoxFit.cover,
placeholder: (_, __) => ColoredBox(
color: Theme.of(context)
@@ -1707,6 +1710,8 @@ class _ArtistAvatar extends StatelessWidget {
? fallback
: CachedNetworkImage(
imageUrl: artist.avatarUrl!,
memCacheWidth: 140,
memCacheHeight: 140,
fit: BoxFit.cover,
placeholder: (_, __) => fallback,
errorWidget: (_, __, ___) => fallback,
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../app/app.dart';
import '../../../app/app_navigator.dart';
import '../../../app/responsive.dart';
import '../../../backend/backend.dart';
import '../../../shared/widgets/adaptive_scaffold.dart';
@@ -167,12 +168,10 @@ class CollectionDetailsScreen extends ConsumerWidget {
mediaQualityMode:
MediaQualityMode.fromName(settings.mediaQualityMode),
favoriteKeys: favoriteKeys,
onOpen: (post) => context.push(
'/post/${post.providerId}/${post.id}',
extra: PostNavigationContext(
currentPost: post,
posts: items,
),
onOpen: (post) => AppNavigator.openPost(
context,
post: post,
postsList: items,
),
onFavorite: (post) async {
if (favoriteKeys.contains(post.cacheKey)) {
@@ -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),
@@ -7,6 +7,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../app/app.dart';
import '../../../app/app_navigator.dart';
import '../../../app/responsive.dart';
import '../../../backend/backend.dart';
import '../../../shared/widgets/adaptive_scaffold.dart';
@@ -970,12 +971,10 @@ class _FavoritesScreenState extends ConsumerState<FavoritesScreen> {
mediaQualityMode: MediaQualityMode.fromName(settings.mediaQualityMode),
favoriteKeys: posts.map((post) => post.cacheKey).toSet(),
downloadedKeys: downloadedKeys,
onOpen: (post) => context.push(
'/post/${post.providerId}/${post.id}',
extra: PostNavigationContext(
currentPost: post,
posts: posts,
),
onOpen: (post) => AppNavigator.openPost(
context,
post: post,
postsList: posts,
),
onFavorite: (post) =>
ref.read(favoritesControllerProvider.notifier).remove(post),
@@ -1226,6 +1225,8 @@ class _ArtistCard extends StatelessWidget {
),
child: CachedNetworkImage(
imageUrl: url,
memCacheWidth: 128,
memCacheHeight: 128,
fit: BoxFit.cover,
placeholder: (_, __) => Container(
color: scheme.surfaceContainerHighest,
@@ -8,6 +8,7 @@ import 'dart:async';
import 'package:cached_network_image/cached_network_image.dart';
import '../../../app/app.dart';
import '../../../app/app_navigator.dart';
import '../../../app/responsive.dart';
import '../../../backend/backend.dart';
import '../../../core/utils/result.dart';
@@ -143,7 +144,11 @@ class _FeedScreenState extends ConsumerState<FeedScreen>
return Shortcuts(
shortcuts: {
LogicalKeySet(LogicalKeyboardKey.keyR): const _RefreshIntent(),
LogicalKeySet(LogicalKeyboardKey.keyR): const _RandomPostIntent(),
LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyR):
const _RefreshIntent(),
LogicalKeySet(LogicalKeyboardKey.f5): const _RefreshIntent(),
LogicalKeySet(LogicalKeyboardKey.keyV): const _ToggleSelectionIntent(),
LogicalKeySet(LogicalKeyboardKey.escape): const _ClearSelectionIntent(),
LogicalKeySet(LogicalKeyboardKey.delete): const _ClearSelectionIntent(),
LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyA):
@@ -157,6 +162,24 @@ class _FeedScreenState extends ConsumerState<FeedScreen>
return null;
},
),
_RandomPostIntent: CallbackAction<_RandomPostIntent>(
onInvoke: (_) {
final posts = feed.value?.posts;
if (posts != null && posts.isNotEmpty) {
_openRandom(posts);
}
return null;
},
),
_ToggleSelectionIntent: CallbackAction<_ToggleSelectionIntent>(
onInvoke: (_) {
setState(() {
_selectionMode = !_selectionMode;
if (!_selectionMode) _selectedKeys.clear();
});
return null;
},
),
_ClearSelectionIntent: CallbackAction<_ClearSelectionIntent>(
onInvoke: (_) {
_clearSelection();
@@ -331,12 +354,10 @@ class _FeedScreenState extends ConsumerState<FeedScreen>
selectionMode: _selectionMode,
selectedKeys: _selectedKeys,
gridMode: settings.gridMode,
onOpen: (post) => context.push(
'/post/${post.providerId}/${post.id}',
extra: PostNavigationContext(
currentPost: post,
posts: state.posts,
),
onOpen: (post) => AppNavigator.openPost(
context,
post: post,
postsList: state.posts,
),
onPreview: (post) => _showPreview(context, post),
onToggleSelected: (post) => _toggleSelected(post),
@@ -392,7 +413,7 @@ class _FeedScreenState extends ConsumerState<FeedScreen>
void _openRandom(List<Post> posts) {
if (posts.isEmpty) return;
final post = posts[Random().nextInt(posts.length)];
context.push('/post/${post.providerId}/${post.id}', extra: post);
AppNavigator.openPost(context, post: post, postsList: posts);
}
Future<void> _applySearchQuery(String query) async {
@@ -683,6 +704,14 @@ class _SelectVisibleIntent extends Intent {
const _SelectVisibleIntent();
}
class _RandomPostIntent extends Intent {
const _RandomPostIntent();
}
class _ToggleSelectionIntent extends Intent {
const _ToggleSelectionIntent();
}
class _BatchActionBar extends StatelessWidget {
const _BatchActionBar({
required this.count,
@@ -110,7 +110,7 @@ class _FeedToolbarState extends State<FeedToolbar> {
),
const SizedBox(width: 8),
IconButton.filledTonal(
tooltip: widget.selectionMode ? 'Выйти из выбора' : 'Выбрать посты',
tooltip: widget.selectionMode ? 'Выйти из выбора (Esc)' : 'Выбрать посты (V)',
onPressed: widget.onToggleSelectionMode,
icon: Icon(widget.selectionMode
? Icons.check_box_rounded
@@ -139,13 +139,13 @@ class _FeedToolbarState extends State<FeedToolbar> {
),
const SizedBox(width: 4),
IconButton(
tooltip: 'Обновить',
tooltip: 'Обновить (Ctrl+R / F5)',
onPressed: widget.onRefresh,
icon: const Icon(Icons.refresh_rounded),
),
const SizedBox(width: 4),
IconButton.filledTonal(
tooltip: 'Случайный пост',
tooltip: 'Случайный пост (R)',
onPressed: widget.onRandom,
icon: const Icon(Icons.casino_rounded),
),
@@ -163,41 +163,36 @@ class _FeedToolbarState extends State<FeedToolbar> {
const SizedBox(height: 8),
],
_HorizontalWheelScroller(
height: 36,
height: 38,
builder: (controller) => ListView(
controller: controller,
scrollDirection: Axis.horizontal,
primary: false,
children: [
_LiquidPeriodTabs(
selectedPeriod: widget.topPeriodFilter,
onChanged: widget.onTopPeriodChanged,
),
const SizedBox(width: 8),
const _ToolbarDivider(),
const SizedBox(width: 8),
if (widget.providers.isNotEmpty) ...[
FilterChip(
selected: widget.selectedProviderIds.isEmpty,
label: const Text('Все источники'),
onSelected: (_) => widget.onQuickProviderToggle('__all__'),
_LiquidProviderPill(
label: 'Все источники',
isSelected: widget.selectedProviderIds.isEmpty,
icon: Icons.all_inclusive_rounded,
onTap: () => widget.onQuickProviderToggle('__all__'),
),
const SizedBox(width: 8),
const SizedBox(width: 6),
for (final provider in widget.providers) ...[
FilterChip(
selected:
widget.selectedProviderIds.contains(provider.id),
avatar: const Icon(Icons.hub_rounded, size: 15),
label: Text(provider.name),
onSelected: (_) =>
widget.onQuickProviderToggle(provider.id),
_LiquidProviderPill(
label: provider.name,
isSelected: widget.selectedProviderIds.contains(provider.id),
icon: Icons.hub_rounded,
onTap: () => widget.onQuickProviderToggle(provider.id),
),
const SizedBox(width: 8),
const SizedBox(width: 6),
],
const SizedBox(width: 4),
const _ToolbarDivider(),
const SizedBox(width: 10),
],
for (final period in TopPeriodFilter.values) ...[
ChoiceChip(
selected: widget.topPeriodFilter == period,
label: Text(period.label),
onSelected: (_) => widget.onTopPeriodChanged(period),
),
const SizedBox(width: 8),
],
],
),
@@ -289,64 +284,56 @@ class _FeedToolbarState extends State<FeedToolbar> {
primary: false,
children: [
if (hasActiveFilters || widget.selectedTags.isNotEmpty) ...[
ActionChip(
visualDensity: VisualDensity.compact,
avatar: const Icon(Icons.filter_alt_off_rounded, size: 15),
label: const Text('Сбросить'),
onPressed: widget.onClearFilters,
_LiquidProviderPill(
label: 'Сбросить',
isSelected: false,
icon: Icons.filter_alt_off_rounded,
onTap: widget.onClearFilters,
),
const SizedBox(width: 6),
],
ActionChip(
visualDensity: VisualDensity.compact,
avatar: const Icon(Icons.refresh_rounded, size: 15),
label: const Text('Обновить'),
onPressed: widget.onRefresh,
_LiquidProviderPill(
label: 'Обновить',
isSelected: false,
icon: Icons.refresh_rounded,
onTap: widget.onRefresh,
),
const SizedBox(width: 6),
const _ToolbarDivider(),
const SizedBox(width: 6),
FilterChip(
visualDensity: VisualDensity.compact,
selected: widget.rating != null,
avatar: const Icon(Icons.shield_outlined, size: 15),
label: Text(widget.rating ?? 'Рейтинг'),
onSelected: (_) => widget.onRatingFilter(),
_LiquidProviderPill(
label: widget.rating ?? 'Рейтинг',
isSelected: widget.rating != null,
icon: Icons.shield_outlined,
onTap: widget.onRatingFilter,
),
const SizedBox(width: 6),
const _ToolbarDivider(),
const SizedBox(width: 6),
_LiquidPeriodTabs(
selectedPeriod: widget.topPeriodFilter,
onChanged: widget.onTopPeriodChanged,
),
if (widget.providers.isNotEmpty) ...[
FilterChip(
visualDensity: VisualDensity.compact,
selected: widget.selectedProviderIds.isEmpty,
label: const Text('Все'),
onSelected: (_) => widget.onQuickProviderToggle('__all__'),
const SizedBox(width: 6),
const _ToolbarDivider(),
const SizedBox(width: 6),
_LiquidProviderPill(
label: 'Все',
isSelected: widget.selectedProviderIds.isEmpty,
icon: Icons.all_inclusive_rounded,
onTap: () => widget.onQuickProviderToggle('__all__'),
),
const SizedBox(width: 6),
for (final provider in widget.providers) ...[
FilterChip(
visualDensity: VisualDensity.compact,
selected:
widget.selectedProviderIds.contains(provider.id),
label: Text(provider.name),
onSelected: (_) =>
widget.onQuickProviderToggle(provider.id),
_LiquidProviderPill(
label: provider.name,
isSelected: widget.selectedProviderIds.contains(provider.id),
icon: Icons.hub_rounded,
onTap: () => widget.onQuickProviderToggle(provider.id),
),
const SizedBox(width: 6),
],
const SizedBox(width: 2),
const _ToolbarDivider(),
const SizedBox(width: 6),
],
for (final period in TopPeriodFilter.values) ...[
ChoiceChip(
visualDensity: VisualDensity.compact,
selected: widget.topPeriodFilter == period,
label: Text(period.label),
onSelected: (_) => widget.onTopPeriodChanged(period),
),
const SizedBox(width: 6),
],
],
),
@@ -371,6 +358,227 @@ class _ToolbarDivider extends StatelessWidget {
}
}
class _LiquidPeriodTabs extends StatelessWidget {
const _LiquidPeriodTabs({
required this.selectedPeriod,
required this.onChanged,
});
final TopPeriodFilter selectedPeriod;
final ValueChanged<TopPeriodFilter> onChanged;
IconData _iconFor(TopPeriodFilter period) {
return switch (period) {
TopPeriodFilter.none => Icons.auto_awesome_rounded,
TopPeriodFilter.month => Icons.calendar_month_rounded,
TopPeriodFilter.year => Icons.calendar_today_rounded,
TopPeriodFilter.allTime => Icons.workspace_premium_rounded,
};
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final isDark = theme.brightness == Brightness.dark;
return Container(
padding: const EdgeInsets.all(2.5),
decoration: BoxDecoration(
color: isDark
? scheme.surfaceContainerHigh.withValues(alpha: 0.55)
: scheme.surfaceContainerHighest.withValues(alpha: 0.45),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: isDark
? Colors.white.withValues(alpha: 0.1)
: Colors.black.withValues(alpha: 0.06),
width: 1,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
for (final period in TopPeriodFilter.values) ...[
_LiquidPeriodTabItem(
period: period,
icon: _iconFor(period),
isSelected: selectedPeriod == period,
onTap: () => onChanged(period),
),
if (period != TopPeriodFilter.values.last) const SizedBox(width: 3),
],
],
),
);
}
}
class _LiquidPeriodTabItem extends StatelessWidget {
const _LiquidPeriodTabItem({
required this.period,
required this.icon,
required this.isSelected,
required this.onTap,
});
final TopPeriodFilter period;
final IconData icon;
final bool isSelected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final isDark = theme.brightness == Brightness.dark;
return Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(13),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
curve: Curves.easeOutCubic,
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: isSelected
? (isDark
? scheme.primary.withValues(alpha: 0.28)
: scheme.primaryContainer.withValues(alpha: 0.85))
: Colors.transparent,
borderRadius: BorderRadius.circular(13),
border: isSelected
? Border.all(
color: scheme.primary.withValues(alpha: isDark ? 0.45 : 0.3),
width: 1,
)
: null,
boxShadow: isSelected
? [
BoxShadow(
color: scheme.primary.withValues(alpha: isDark ? 0.25 : 0.12),
blurRadius: 8,
offset: const Offset(0, 2),
),
]
: null,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
size: 14,
color: isSelected
? (isDark ? scheme.primary : scheme.onPrimaryContainer)
: scheme.onSurfaceVariant.withValues(alpha: 0.85),
),
const SizedBox(width: 6),
Text(
period.label,
style: TextStyle(
fontSize: 12,
fontWeight: isSelected ? FontWeight.w700 : FontWeight.w500,
color: isSelected
? (isDark ? scheme.primary : scheme.onPrimaryContainer)
: scheme.onSurfaceVariant,
),
),
],
),
),
),
);
}
}
class _LiquidProviderPill extends StatelessWidget {
const _LiquidProviderPill({
required this.label,
required this.isSelected,
required this.onTap,
this.icon,
});
final String label;
final bool isSelected;
final VoidCallback onTap;
final IconData? icon;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final isDark = theme.brightness == Brightness.dark;
return Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(14),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
curve: Curves.easeOutCubic,
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: isSelected
? (isDark
? scheme.secondaryContainer.withValues(alpha: 0.35)
: scheme.secondaryContainer.withValues(alpha: 0.7))
: (isDark
? scheme.surfaceContainerHigh.withValues(alpha: 0.4)
: scheme.surfaceContainerHighest.withValues(alpha: 0.35)),
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: isSelected
? scheme.secondary.withValues(alpha: isDark ? 0.6 : 0.4)
: (isDark
? Colors.white.withValues(alpha: 0.08)
: Colors.black.withValues(alpha: 0.05)),
width: 1,
),
boxShadow: isSelected
? [
BoxShadow(
color: scheme.secondary.withValues(alpha: isDark ? 0.2 : 0.08),
blurRadius: 6,
),
]
: null,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null) ...[
Icon(
icon,
size: 14,
color: isSelected
? (isDark ? scheme.secondary : scheme.onSecondaryContainer)
: scheme.onSurfaceVariant.withValues(alpha: 0.8),
),
const SizedBox(width: 5),
],
Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: isSelected ? FontWeight.w700 : FontWeight.w500,
color: isSelected
? (isDark ? scheme.secondary : scheme.onSecondaryContainer)
: scheme.onSurfaceVariant,
),
),
],
),
),
),
);
}
}
class _HorizontalWheelScroller extends StatefulWidget {
const _HorizontalWheelScroller({
required this.builder,
@@ -8,6 +8,7 @@ import 'package:share_plus/share_plus.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../../app/app.dart';
import '../../../app/app_navigator.dart';
import '../../../app/app_strings.dart';
import '../../../app/motion.dart';
import '../../../app/responsive.dart';
@@ -171,6 +172,8 @@ class PostDetailsScreen extends ConsumerWidget {
LogicalKeySet(LogicalKeyboardKey.keyC):
const _AddCollectionIntent(),
LogicalKeySet(LogicalKeyboardKey.keyS): const _DownloadIntent(),
LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyS):
const _DownloadIntent(),
LogicalKeySet(LogicalKeyboardKey.escape): const _CloseIntent(),
},
child: Actions(
@@ -872,7 +875,7 @@ class PostDetailsScreen extends ConsumerWidget {
}
void _openSimilar(BuildContext context, WidgetRef ref, Post post) {
context.push('/post/${post.providerId}/${post.id}/similar', extra: post);
AppNavigator.openSimilarPosts(context, post: post);
}
Future<void> _openSourcePage(WidgetRef ref, Post post) async {
@@ -888,7 +891,16 @@ class PostDetailsScreen extends ConsumerWidget {
}
void _replacePost(BuildContext context, Post post) {
context.replace('/post/${post.providerId}/${post.id}', extra: post);
Navigator.of(context).pushReplacement(
MaterialPageRoute<void>(
builder: (ctx) => PostDetailsScreen(
providerId: post.providerId,
postId: post.id,
initialPost: post,
postsList: postsList,
),
),
);
}
void _close(BuildContext context) {
@@ -1143,6 +1155,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)),
@@ -1,8 +1,8 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../app/app.dart';
import '../../../app/app_navigator.dart';
import '../../../app/responsive.dart';
import '../../../backend/backend.dart';
import '../../../core/utils/result.dart';
@@ -98,9 +98,9 @@ class SimilarPostsScreen extends ConsumerWidget {
MediaQualityMode.fromName(settings.mediaQualityMode),
favoriteKeys: favorites,
viewedKeys: viewed,
onOpen: (post) => context.push(
'/post/${post.providerId}/${post.id}',
extra: post,
onOpen: (post) => AppNavigator.openPost(
context,
post: post,
),
onFavorite: (post) =>
_toggleFavorite(ref, post, favorites),
@@ -317,10 +317,16 @@ class _PostMediaViewerState extends State<PostMediaViewer>
),
);
} else {
final mq = MediaQuery.maybeOf(context);
final dpr = mq?.devicePixelRatio ?? 1.5;
final screenWidth = mq?.size.width ?? 1280;
final maxCacheWidth =
(screenWidth * dpr * 1.5).round().clamp(1080, 2560);
image = CachedNetworkImage(
key: ValueKey(url),
imageUrl: url,
httpHeaders: headers,
memCacheWidth: maxCacheWidth,
fit: BoxFit.contain,
placeholder: (context, url) =>
const Center(child: CircularProgressIndicator()),
@@ -383,16 +389,54 @@ class _PostMediaViewerState extends State<PostMediaViewer>
),
],
);
final interactiveChild = _imageUrls.length > 1
? Shortcuts(
shortcuts: {
LogicalKeySet(LogicalKeyboardKey.arrowLeft):
const _PrevImageIntent(),
LogicalKeySet(LogicalKeyboardKey.keyA): const _PrevImageIntent(),
LogicalKeySet(LogicalKeyboardKey.arrowRight):
const _NextImageIntent(),
LogicalKeySet(LogicalKeyboardKey.keyD): const _NextImageIntent(),
},
child: Actions(
actions: {
_PrevImageIntent: CallbackAction<_PrevImageIntent>(
onInvoke: (_) {
if (_imageIndex > 0) {
setState(() => _imageIndex--);
}
return null;
},
),
_NextImageIntent: CallbackAction<_NextImageIntent>(
onInvoke: (_) {
if (_imageIndex < _imageUrls.length - 1) {
setState(() => _imageIndex++);
}
return null;
},
),
},
child: Focus(
autofocus: true,
child: child,
),
),
)
: child;
return LayoutBuilder(
builder: (context, constraints) {
if (constraints.hasBoundedWidth && constraints.hasBoundedHeight) {
return SizedBox(
width: constraints.maxWidth,
height: constraints.maxHeight,
child: child,
child: interactiveChild,
);
}
return child;
return interactiveChild;
},
);
}
@@ -739,6 +783,48 @@ class _TogglePlayIntent extends Intent {
const _TogglePlayIntent();
}
class _SeekVideoIntent extends Intent {
const _SeekVideoIntent(this.seconds);
final int seconds;
}
class _VolumeVideoIntent extends Intent {
const _VolumeVideoIntent(this.deltaY);
final double deltaY;
}
class _MuteVideoIntent extends Intent {
const _MuteVideoIntent();
}
class _FullscreenVideoIntent extends Intent {
const _FullscreenVideoIntent();
}
class _LoopVideoIntent extends Intent {
const _LoopVideoIntent();
}
class _ZoomInIntent extends Intent {
const _ZoomInIntent();
}
class _ZoomOutIntent extends Intent {
const _ZoomOutIntent();
}
class _ResetZoomIntent extends Intent {
const _ResetZoomIntent();
}
class _PrevImageIntent extends Intent {
const _PrevImageIntent();
}
class _NextImageIntent extends Intent {
const _NextImageIntent();
}
class _UnsupportedSwfPanel extends StatelessWidget {
const _UnsupportedSwfPanel();
@@ -911,6 +997,8 @@ class _FullscreenVideoPageState extends State<_FullscreenVideoPage> {
shortcuts: {
LogicalKeySet(LogicalKeyboardKey.escape): const _CloseVideoIntent(),
LogicalKeySet(LogicalKeyboardKey.space): const _TogglePlayIntent(),
LogicalKeySet(LogicalKeyboardKey.keyK): const _TogglePlayIntent(),
LogicalKeySet(LogicalKeyboardKey.keyF): const _CloseVideoIntent(),
},
child: Actions(
actions: {
@@ -1771,16 +1859,87 @@ class _VideoSurfaceState extends State<_VideoSurface> {
},
);
return MouseRegion(
onHover: (_) => widget.onInteract(),
child: ClipRRect(
borderRadius: BorderRadius.circular(widget.fullscreen ? 0 : 10),
child: widget.fullscreen
? SizedBox.expand(child: child)
: AspectRatio(
aspectRatio: widget.aspectRatio.clamp(0.35, 2.4),
child: child,
),
return Shortcuts(
shortcuts: {
LogicalKeySet(LogicalKeyboardKey.space): const _TogglePlayIntent(),
LogicalKeySet(LogicalKeyboardKey.keyK): const _TogglePlayIntent(),
LogicalKeySet(LogicalKeyboardKey.arrowLeft): const _SeekVideoIntent(-5),
LogicalKeySet(LogicalKeyboardKey.keyJ): const _SeekVideoIntent(-5),
LogicalKeySet(LogicalKeyboardKey.arrowRight): const _SeekVideoIntent(5),
LogicalKeySet(LogicalKeyboardKey.keyL): const _SeekVideoIntent(5),
LogicalKeySet(LogicalKeyboardKey.shift, LogicalKeyboardKey.arrowLeft):
const _SeekVideoIntent(-15),
LogicalKeySet(LogicalKeyboardKey.shift, LogicalKeyboardKey.arrowRight):
const _SeekVideoIntent(15),
LogicalKeySet(LogicalKeyboardKey.arrowUp): const _VolumeVideoIntent(-10),
LogicalKeySet(LogicalKeyboardKey.arrowDown): const _VolumeVideoIntent(10),
LogicalKeySet(LogicalKeyboardKey.keyM): const _MuteVideoIntent(),
LogicalKeySet(LogicalKeyboardKey.keyF): const _FullscreenVideoIntent(),
LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyL):
const _LoopVideoIntent(),
},
child: Actions(
actions: {
_TogglePlayIntent: CallbackAction<_TogglePlayIntent>(
onInvoke: (_) {
if (widget.player.state.playing) {
widget.player.pause();
} else {
if (widget.player.state.completed) {
widget.player.seek(Duration.zero);
}
widget.player.play();
}
widget.onInteract();
return null;
},
),
_SeekVideoIntent: CallbackAction<_SeekVideoIntent>(
onInvoke: (intent) {
_seekBy(Duration(seconds: intent.seconds));
return null;
},
),
_VolumeVideoIntent: CallbackAction<_VolumeVideoIntent>(
onInvoke: (intent) {
_adjustVolume(intent.deltaY);
return null;
},
),
_MuteVideoIntent: CallbackAction<_MuteVideoIntent>(
onInvoke: (_) {
widget.onToggleMute();
return null;
},
),
_FullscreenVideoIntent: CallbackAction<_FullscreenVideoIntent>(
onInvoke: (_) {
widget.onFullscreen();
return null;
},
),
_LoopVideoIntent: CallbackAction<_LoopVideoIntent>(
onInvoke: (_) {
widget.onToggleLoop();
return null;
},
),
},
child: Focus(
autofocus: true,
child: MouseRegion(
onHover: (_) => widget.onInteract(),
child: ClipRRect(
borderRadius: BorderRadius.circular(widget.fullscreen ? 0 : 10),
child: widget.fullscreen
? SizedBox.expand(child: child)
: AspectRatio(
aspectRatio: widget.aspectRatio.clamp(0.35, 2.4),
child: child,
),
),
),
),
),
);
}
@@ -1959,80 +2118,133 @@ class _ZoomableImageState extends State<_ZoomableImage> {
super.dispose();
}
void _zoomIn() {
final currentScale = _controller.value.getMaxScaleOnAxis();
final newScale = (currentScale * 1.35).clamp(1.0, 6.0);
_controller.value = Matrix4.identity()..scaleByDouble(newScale, newScale, 1, 1);
if (mounted) setState(() => _zoomed = newScale > 1.02);
_setLocked(_zoomed);
}
void _zoomOut() {
final currentScale = _controller.value.getMaxScaleOnAxis();
final newScale = (currentScale / 1.35).clamp(1.0, 6.0);
if (newScale <= 1.02) {
_controller.value = Matrix4.identity();
if (mounted) setState(() => _zoomed = false);
_setLocked(false);
} else {
_controller.value = Matrix4.identity()..scaleByDouble(newScale, newScale, 1, 1);
if (mounted) setState(() => _zoomed = true);
_setLocked(true);
}
}
void _resetZoom() {
_controller.value = Matrix4.identity();
if (mounted) setState(() => _zoomed = false);
_setLocked(false);
}
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final hasFrame =
constraints.hasBoundedWidth && constraints.hasBoundedHeight;
final child = hasFrame
? SizedBox(
width: constraints.maxWidth,
height: constraints.maxHeight,
child: widget.child,
)
: widget.child;
return Listener(
onPointerSignal: (signal) {
if (signal is PointerScrollEvent) {
final scaleDelta = signal.scrollDelta.dy < 0 ? 1.15 : 0.85;
final currentScale = _controller.value.getMaxScaleOnAxis();
final newScale = (currentScale * scaleDelta).clamp(1.0, 6.0);
if (newScale <= 1.02) {
_controller.value = Matrix4.identity();
if (mounted) setState(() => _zoomed = false);
_setLocked(false);
} else {
final focalPoint = signal.localPosition;
_controller.value = Matrix4.identity()
..translateByDouble(
-focalPoint.dx * (newScale - 1),
-focalPoint.dy * (newScale - 1),
0,
1)
..scaleByDouble(newScale, newScale, 1, 1);
if (mounted) setState(() => _zoomed = true);
_setLocked(true);
}
}
},
onPointerDown: (_) {
_pointerCount++;
if (_pointerCount >= 2 || _zoomed) {
_setLocked(true);
}
},
onPointerUp: (_) => _releasePointer(),
onPointerCancel: (_) => _releasePointer(),
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onDoubleTapDown: (details) => _doubleTapDetails = details,
onDoubleTap: _toggleZoom,
child: InteractiveViewer(
transformationController: _controller,
minScale: 1,
maxScale: 6,
boundaryMargin: const EdgeInsets.all(160),
panEnabled: _zoomed,
scaleEnabled: true,
clipBehavior: Clip.none,
onInteractionStart: (_) {
if (_pointerCount >= 2 || _zoomed) _setLocked(true);
},
onInteractionEnd: (_) {
final scale = _controller.value.getMaxScaleOnAxis();
final nextZoomed = scale > 1.03;
if (!nextZoomed) {
_controller.value = Matrix4.identity();
}
if (mounted) setState(() => _zoomed = nextZoomed);
_setLocked(nextZoomed || _pointerCount >= 2);
},
child: child,
),
),
);
return Shortcuts(
shortcuts: {
LogicalKeySet(LogicalKeyboardKey.equal): const _ZoomInIntent(),
LogicalKeySet(LogicalKeyboardKey.add): const _ZoomInIntent(),
LogicalKeySet(LogicalKeyboardKey.numpadAdd): const _ZoomInIntent(),
LogicalKeySet(LogicalKeyboardKey.minus): const _ZoomOutIntent(),
LogicalKeySet(LogicalKeyboardKey.numpadSubtract): const _ZoomOutIntent(),
LogicalKeySet(LogicalKeyboardKey.digit0): const _ResetZoomIntent(),
LogicalKeySet(LogicalKeyboardKey.numpad0): const _ResetZoomIntent(),
LogicalKeySet(LogicalKeyboardKey.escape): const _ResetZoomIntent(),
},
child: Actions(
actions: {
_ZoomInIntent: CallbackAction<_ZoomInIntent>(
onInvoke: (_) {
_zoomIn();
return null;
},
),
_ZoomOutIntent: CallbackAction<_ZoomOutIntent>(
onInvoke: (_) {
_zoomOut();
return null;
},
),
_ResetZoomIntent: CallbackAction<_ResetZoomIntent>(
onInvoke: (_) {
if (_zoomed) {
_resetZoom();
}
return null;
},
),
},
child: Focus(
autofocus: true,
child: LayoutBuilder(
builder: (context, constraints) {
final hasFrame =
constraints.hasBoundedWidth && constraints.hasBoundedHeight;
final child = hasFrame
? SizedBox(
width: constraints.maxWidth,
height: constraints.maxHeight,
child: widget.child,
)
: widget.child;
return Listener(
onPointerDown: (_) {
_pointerCount++;
if (_pointerCount >= 2 || _zoomed) {
_setLocked(true);
}
},
onPointerUp: (_) => _releasePointer(),
onPointerCancel: (_) => _releasePointer(),
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onDoubleTapDown: (details) => _doubleTapDetails = details,
onDoubleTap: _toggleZoom,
child: InteractiveViewer(
transformationController: _controller,
minScale: 1,
maxScale: 6,
scaleFactor: 1000000.0,
boundaryMargin: const EdgeInsets.all(160),
panEnabled: _zoomed,
scaleEnabled: true,
clipBehavior: Clip.none,
onInteractionStart: (_) {
if (_pointerCount >= 2 || _zoomed) _setLocked(true);
},
onInteractionEnd: (_) {
final scale = _controller.value.getMaxScaleOnAxis();
final nextZoomed = scale > 1.03;
if (!nextZoomed) {
_controller.value = Matrix4.identity();
}
if (mounted) setState(() => _zoomed = nextZoomed);
_setLocked(nextZoomed || _pointerCount >= 2);
},
child: Listener(
onPointerSignal: (event) {
if (event is PointerScrollEvent) {
GestureBinding.instance.pointerSignalResolver
.register(event, (_) {});
}
},
child: child,
),
),
),
);
},
),
),
),
);
}
@@ -1,9 +1,9 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../app/app.dart';
import '../../../app/app_navigator.dart';
import '../../../backend/backend.dart';
import '../../../core/utils/result.dart';
import '../../../shared/widgets/adaptive_scaffold.dart';
@@ -62,9 +62,9 @@ class HiddenPostsScreen extends ConsumerWidget {
item: item,
onOpen: item.post == null
? null
: () => context.push(
'/post/${item.post!.providerId}/${item.post!.id}',
extra: item.post,
: () => AppNavigator.openPost(
context,
post: item.post!,
),
onRestore: () async {
await ref
@@ -7,6 +7,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../app/app.dart';
import '../../../app/app_navigator.dart';
import '../../../app/responsive.dart';
import '../../../backend/backend.dart';
import '../../../shared/widgets/adaptive_scaffold.dart';
@@ -142,12 +143,10 @@ class _ViewedScreenState extends ConsumerState<ViewedScreen> {
mediaQualityMode:
MediaQualityMode.fromName(settings.mediaQualityMode),
favoriteKeys: favoriteKeys,
onOpen: (post) => context.push(
'/post/${post.providerId}/${post.id}',
extra: PostNavigationContext(
currentPost: post,
posts: allPosts,
),
onOpen: (post) => AppNavigator.openPost(
context,
post: post,
postsList: allPosts,
),
onFavorite: (post) => _toggleFavorite(
ref,
@@ -260,12 +259,10 @@ class _ViewedScreenState extends ConsumerState<ViewedScreen> {
isFavorite: favoriteKeys.contains(item.post.cacheKey),
isRu: isRu,
onOpen: () {
context.push(
'/post/${item.post.providerId}/${item.post.id}',
extra: PostNavigationContext(
currentPost: item.post,
posts: allPosts,
),
AppNavigator.openPost(
context,
post: item.post,
postsList: allPosts,
);
},
onFavorite: () => _toggleFavorite(
@@ -430,6 +427,8 @@ class _ViewedPostTile extends StatelessWidget {
)
: CachedNetworkImage(
imageUrl: image,
memCacheWidth: 320,
memCacheHeight: 320,
fit: BoxFit.cover,
placeholder: (_, __) => Container(
color: scheme.surfaceContainerHighest,
+3
View File
@@ -5,5 +5,8 @@ import 'app/app.dart';
void main() {
WidgetsFlutterBinding.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()));
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,321 @@
import 'dart:ui';
import 'package:flutter/material.dart';
class DesktopShortcutsDialog extends StatelessWidget {
const DesktopShortcutsDialog({super.key});
static Future<void> show(BuildContext context) {
return showDialog(
context: context,
barrierColor: Colors.black54,
builder: (_) => const DesktopShortcutsDialog(),
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final isDark = theme.brightness == Brightness.dark;
return Dialog(
backgroundColor: Colors.transparent,
insetPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 720, maxHeight: 680),
child: ClipRRect(
borderRadius: BorderRadius.circular(24),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
child: Container(
decoration: BoxDecoration(
color: isDark
? scheme.surfaceContainerHigh.withValues(alpha: 0.88)
: Colors.white.withValues(alpha: 0.94),
borderRadius: BorderRadius.circular(24),
border: Border.all(
color: isDark
? Colors.white.withValues(alpha: 0.16)
: scheme.outlineVariant.withValues(alpha: 0.4),
width: 1.2,
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: isDark ? 0.45 : 0.12),
blurRadius: 32,
offset: const Offset(0, 12),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Header
Padding(
padding: const EdgeInsets.fromLTRB(24, 20, 16, 16),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: scheme.primaryContainer.withValues(alpha: 0.7),
borderRadius: BorderRadius.circular(14),
),
child: Icon(
Icons.keyboard_rounded,
color: scheme.primary,
size: 24,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Горячие клавиши',
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 2),
Text(
'Быстрое управление с клавиатуры для ПК',
style: theme.textTheme.bodyMedium?.copyWith(
color: scheme.onSurfaceVariant,
),
),
],
),
),
IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.close_rounded),
tooltip: 'Закрыть (Esc)',
),
],
),
),
const Divider(height: 1),
// Shortcuts list
Flexible(
child: ListView(
padding: const EdgeInsets.fromLTRB(24, 16, 24, 24),
children: const [
_ShortcutSection(
icon: Icons.navigation_rounded,
title: 'Общие и навигация',
items: [
_ShortcutRow(['Ctrl', '1…8'], 'Переключение между вкладками'),
_ShortcutRow(['Ctrl', 'Tab'], 'Следующая вкладка'),
_ShortcutRow(['Ctrl', 'Shift', 'Tab'], 'Предыдущая вкладка'),
_ShortcutRow(['Ctrl', 'F'], 'Перейти к поиску / строка поиска'),
_ShortcutRow(['Ctrl', 'R'], 'Обновить ленту / страницу'),
_ShortcutRow(['F5'], 'Обновить страницу'),
_ShortcutRow(['Esc'], 'Закрыть окно / назад / сбросить выбор'),
_ShortcutRow(['?'], 'Открыть это окно горячих клавиш'),
],
),
SizedBox(height: 20),
_ShortcutSection(
icon: Icons.smart_display_rounded,
title: 'Видеоплеер',
items: [
_ShortcutRow(['Пробел'], 'Воспроизведение / Пауза'),
_ShortcutRow(['←', '→'], 'Перемотка на 5 секунд назад / вперед'),
_ShortcutRow(['Shift', '← / →'], 'Перемотка на 15 секунд'),
_ShortcutRow(['↑', '↓'], 'Регулировка громкости (±5%)'),
_ShortcutRow(['M'], 'Включить / выключить звук'),
_ShortcutRow(['F'], 'Полноэкранный режим (Full Screen)'),
_ShortcutRow(['L'], 'Повтор видео (Loop on/off)'),
_ShortcutRow(['Esc'], 'Выйти из полноэкранного режима'),
],
),
SizedBox(height: 20),
_ShortcutSection(
icon: Icons.photo_library_rounded,
title: 'Фото и просмотр постов',
items: [
_ShortcutRow(['←', '→'], 'Предыдущее / следующее фото или пост'),
_ShortcutRow(['+'], 'Увеличить масштаб (Zoom In)'),
_ShortcutRow(['-'], 'Уменьшить масштаб (Zoom Out)'),
_ShortcutRow(['0'], 'Сбросить масштаб (100%)'),
_ShortcutRow(['F'], 'Подгонка по экрану / на весь экран'),
_ShortcutRow(['D'], 'Скачать текущее изображение'),
_ShortcutRow(['Ctrl', 'S'], 'Сохранить / Скачать файл'),
_ShortcutRow(['Esc'], 'Сброс зума / назад'),
],
),
SizedBox(height: 20),
_ShortcutSection(
icon: Icons.grid_view_rounded,
title: 'Лента и посты',
items: [
_ShortcutRow(['R'], 'Открыть случайный пост'),
_ShortcutRow(['V'], 'Включить/выключить режим выбора постов'),
],
),
],
),
),
],
),
),
),
),
),
);
}
}
class _ShortcutSection extends StatelessWidget {
const _ShortcutSection({
required this.icon,
required this.title,
required this.items,
});
final IconData icon;
final String title;
final List<_ShortcutRow> items;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final isDark = theme.brightness == Brightness.dark;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(icon, size: 18, color: scheme.primary),
const SizedBox(width: 8),
Text(
title,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
color: scheme.primary,
letterSpacing: 0.3,
),
),
],
),
const SizedBox(height: 8),
Container(
decoration: BoxDecoration(
color: isDark
? scheme.surface.withValues(alpha: 0.6)
: scheme.surfaceContainerHighest.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: isDark
? Colors.white.withValues(alpha: 0.08)
: Colors.black.withValues(alpha: 0.04),
),
),
child: Column(
children: [
for (var i = 0; i < items.length; i++) ...[
if (i > 0)
Divider(
height: 1,
thickness: 1,
color: isDark
? Colors.white.withValues(alpha: 0.05)
: Colors.black.withValues(alpha: 0.04),
),
items[i],
],
],
),
),
],
);
}
}
class _ShortcutRow extends StatelessWidget {
const _ShortcutRow(this.keys, this.description);
final List<String> keys;
final String description;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
child: Row(
children: [
Expanded(
child: Text(
description,
style: theme.textTheme.bodyMedium?.copyWith(
color: scheme.onSurface,
fontWeight: FontWeight.w500,
),
),
),
const SizedBox(width: 12),
Wrap(
spacing: 4,
runSpacing: 4,
children: keys.map((key) => _KeyCap(key)).toList(growable: false),
),
],
),
);
}
}
class _KeyCap extends StatelessWidget {
const _KeyCap(this.text);
final String text;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
final scheme = theme.colorScheme;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: isDark
? scheme.surfaceContainerHighest.withValues(alpha: 0.9)
: Colors.white,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: isDark
? Colors.white.withValues(alpha: 0.2)
: Colors.black.withValues(alpha: 0.15),
width: 1,
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: isDark ? 0.4 : 0.08),
offset: const Offset(0, 1.5),
blurRadius: 1,
),
],
),
child: Text(
text,
style: TextStyle(
fontFamily: 'monospace',
fontSize: 12,
fontWeight: FontWeight.bold,
color: scheme.onSurface,
letterSpacing: 0.2,
),
),
);
}
}
+1 -1
View File
@@ -232,7 +232,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 &&
+20 -18
View File
@@ -68,24 +68,26 @@ class PostMasonryGrid extends StatelessWidget {
final spacing = mobile ? 8.0 : 12.0;
Widget buildCard(Post post) {
return PostCard(
post: post,
blurExplicit: blurExplicit && !nsfwEnabled,
showBadges: showBadges,
isFavorite: favoriteKeys.contains(post.cacheKey),
isViewed: viewedKeys.contains(post.cacheKey),
isDownloaded: downloadedKeys.contains(post.cacheKey),
mediaQualityMode: mediaQualityMode,
onOpen: () => onOpen(post),
onFavorite: () => onFavorite(post),
onPreview: onPreview == null ? null : () => onPreview!(post),
onHide: onHide == null ? null : () => onHide!(post),
selectionMode: selectionMode,
selected: selectedKeys.contains(post.cacheKey),
onToggleSelected:
onToggleSelected == null ? null : () => onToggleSelected!(post),
onAddToCollection:
onAddToCollection == null ? null : () => onAddToCollection!(post),
return RepaintBoundary(
child: PostCard(
post: post,
blurExplicit: blurExplicit && !nsfwEnabled,
showBadges: showBadges,
isFavorite: favoriteKeys.contains(post.cacheKey),
isViewed: viewedKeys.contains(post.cacheKey),
isDownloaded: downloadedKeys.contains(post.cacheKey),
mediaQualityMode: mediaQualityMode,
onOpen: () => onOpen(post),
onFavorite: () => onFavorite(post),
onPreview: onPreview == null ? null : () => onPreview!(post),
onHide: onHide == null ? null : () => onHide!(post),
selectionMode: selectionMode,
selected: selectedKeys.contains(post.cacheKey),
onToggleSelected:
onToggleSelected == null ? null : () => onToggleSelected!(post),
onAddToCollection:
onAddToCollection == null ? null : () => onAddToCollection!(post),
),
);
}
+1 -1
View File
@@ -1,7 +1,7 @@
name: gel_rule_app
description: Local backend/core layer for a Flutter booru browser app.
publish_to: "none"
version: 3.5.4+80
version: 3.6.2+84
environment:
sdk: ">=3.3.0 <4.0.0"
@@ -0,0 +1,47 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:gel_rule_app/backend/backend.dart';
import 'package:gel_rule_app/shared/widgets/app_shell.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('Tab Navigation & State Preservation Tests', () {
test('AppShell branchIndexForLocation and locationForBranchIndex map correctly', () {
expect(AppShell.branchIndexForLocation('/'), 0);
expect(AppShell.branchIndexForLocation('/?q=test'), 0);
expect(AppShell.branchIndexForLocation('/search'), 1);
expect(AppShell.branchIndexForLocation('/favorites'), 2);
expect(AppShell.branchIndexForLocation('/viewed'), 3);
expect(AppShell.branchIndexForLocation('/collections'), 4);
expect(AppShell.branchIndexForLocation('/collections/123'), 4);
expect(AppShell.branchIndexForLocation('/artists'), 5);
expect(AppShell.branchIndexForLocation('/artists/pawchive/patreon/99'), 5);
expect(AppShell.branchIndexForLocation('/providers'), 6);
expect(AppShell.branchIndexForLocation('/settings'), 7);
expect(AppShell.branchIndexForLocation('/settings/hidden'), 7);
expect(AppShell.locationForBranchIndex(0), '/');
expect(AppShell.locationForBranchIndex(1), '/search');
expect(AppShell.locationForBranchIndex(2), '/favorites');
expect(AppShell.locationForBranchIndex(3), '/viewed');
expect(AppShell.locationForBranchIndex(4), '/collections');
expect(AppShell.locationForBranchIndex(5), '/artists');
expect(AppShell.locationForBranchIndex(6), '/providers');
expect(AppShell.locationForBranchIndex(7), '/settings');
});
test('AppSettings preserves lastActiveLocation in JSON serialization', () {
const defaults = AppSettings.defaults;
expect(defaults.lastActiveLocation, '/');
final updated = defaults.copyWith(lastActiveLocation: '/artists/pawchive/patreon/12345');
expect(updated.lastActiveLocation, '/artists/pawchive/patreon/12345');
final json = updated.toJson();
expect(json['lastActiveLocation'], '/artists/pawchive/patreon/12345');
final restored = AppSettings.fromJson(json);
expect(restored.lastActiveLocation, '/artists/pawchive/patreon/12345');
});
});
}