Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c91af8790 | ||
|
|
5434c5dc7b | ||
|
|
cd1a9ccc0c |
@@ -1,4 +1,21 @@
|
|||||||
const prismaChangelog = [
|
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(
|
PrismaChange(
|
||||||
version: '3.7.0',
|
version: '3.7.0',
|
||||||
title: 'Большое обновление e621: авторизация по API-ключу, комиксы/пулы, вариации постов и синхронизация',
|
title: 'Большое обновление e621: авторизация по API-ключу, комиксы/пулы, вариации постов и синхронизация',
|
||||||
|
|||||||
@@ -6,8 +6,19 @@ class E621Mapper {
|
|||||||
required String providerId,
|
required String providerId,
|
||||||
required String providerName,
|
required String providerName,
|
||||||
}) {
|
}) {
|
||||||
final posts = data is Map ? data['posts'] : data;
|
if (data is Map && data['post'] is Map) {
|
||||||
final items = posts is List ? posts : const [];
|
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
|
return items
|
||||||
.whereType<Map>()
|
.whereType<Map>()
|
||||||
.map((item) => postFromJson(
|
.map((item) => postFromJson(
|
||||||
|
|||||||
@@ -48,9 +48,19 @@ class E621Provider
|
|||||||
|
|
||||||
bool get isAuthorized =>
|
bool get isAuthorized =>
|
||||||
_queryParameters.containsKey('login') ||
|
_queryParameters.containsKey('login') ||
|
||||||
|
_queryParameters.containsKey('query.login') ||
|
||||||
_dio.options.headers.containsKey('Authorization');
|
_dio.options.headers.containsKey('Authorization');
|
||||||
|
|
||||||
String? get login => _queryParameters['login'];
|
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
|
@override
|
||||||
String postPageUrl(Post post) => '$baseUrl/posts/${post.id}';
|
String postPageUrl(Post post) => '$baseUrl/posts/${post.id}';
|
||||||
@@ -113,6 +123,52 @@ class E621Provider
|
|||||||
return null;
|
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 {
|
Future<bool> votePost(String postId, int score) async {
|
||||||
await _throttle();
|
await _throttle();
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import '../../../shared/widgets/empty_view.dart';
|
|||||||
import '../../../shared/widgets/error_view.dart';
|
import '../../../shared/widgets/error_view.dart';
|
||||||
import '../../../shared/widgets/post_masonry_grid.dart';
|
import '../../../shared/widgets/post_masonry_grid.dart';
|
||||||
import 'favorites_controller.dart';
|
import 'favorites_controller.dart';
|
||||||
|
import 'widgets/e621_favorites_view.dart';
|
||||||
|
|
||||||
enum FavoriteMediaType { all, video, image }
|
enum FavoriteMediaType { all, video, image }
|
||||||
enum OfflineSortOption { dateAddedDesc, sizeDesc, artist }
|
enum OfflineSortOption { dateAddedDesc, sizeDesc, artist }
|
||||||
@@ -315,6 +316,17 @@ class _FavoritesScreenState extends ConsumerState<FavoritesScreen> {
|
|||||||
overflow: TextOverflow.ellipsis,
|
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},
|
selected: {_tabIndex},
|
||||||
onSelectionChanged: (value) {
|
onSelectionChanged: (value) {
|
||||||
@@ -333,7 +345,7 @@ class _FavoritesScreenState extends ConsumerState<FavoritesScreen> {
|
|||||||
),
|
),
|
||||||
|
|
||||||
// Media Type Filter Chips (for Tab 0 and Tab 1)
|
// Media Type Filter Chips (for Tab 0 and Tab 1)
|
||||||
if (_tabIndex != 2)
|
if (_tabIndex != 2 && _tabIndex != 3)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -399,6 +411,10 @@ class _FavoritesScreenState extends ConsumerState<FavoritesScreen> {
|
|||||||
downloaded.keys.toSet(),
|
downloaded.keys.toSet(),
|
||||||
isRu,
|
isRu,
|
||||||
),
|
),
|
||||||
|
3 => E621FavoritesView(
|
||||||
|
settings: settings,
|
||||||
|
isRu: isRu,
|
||||||
|
),
|
||||||
_ => _buildAllView(
|
_ => _buildAllView(
|
||||||
allPosts,
|
allPosts,
|
||||||
settings,
|
settings,
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,6 +48,18 @@ final postProviderInstanceProvider =
|
|||||||
return ref.watch(providerManagerProvider).getProviderInstance(providerId);
|
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 {
|
class PostDetailsScreen extends ConsumerWidget {
|
||||||
const PostDetailsScreen({
|
const PostDetailsScreen({
|
||||||
required this.providerId,
|
required this.providerId,
|
||||||
@@ -377,6 +389,7 @@ class PostDetailsScreen extends ConsumerWidget {
|
|||||||
fileSizeBytes: fileSizeBytes,
|
fileSizeBytes: fileSizeBytes,
|
||||||
e621Provider: e621Provider,
|
e621Provider: e621Provider,
|
||||||
),
|
),
|
||||||
|
_ArtistPostsCard(post: post),
|
||||||
if (post.hasRelations) ...[
|
if (post.hasRelations) ...[
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
PostRelationsCard(
|
PostRelationsCard(
|
||||||
@@ -390,6 +403,9 @@ class PostDetailsScreen extends ConsumerWidget {
|
|||||||
PostPoolsCard(
|
PostPoolsCard(
|
||||||
post: post,
|
post: post,
|
||||||
provider: e621Provider,
|
provider: e621Provider,
|
||||||
|
onOpenComic: (poolId, currentPost, [targetId]) =>
|
||||||
|
_openComic(context, e621Provider, poolId,
|
||||||
|
currentPost, targetId),
|
||||||
onOpenPostId: (targetId) =>
|
onOpenPostId: (targetId) =>
|
||||||
_openPostById(context, post.providerId, targetId),
|
_openPostById(context, post.providerId, targetId),
|
||||||
),
|
),
|
||||||
@@ -635,6 +651,7 @@ class PostDetailsScreen extends ConsumerWidget {
|
|||||||
fileSizeBytes: fileSizeBytes,
|
fileSizeBytes: fileSizeBytes,
|
||||||
e621Provider: e621Provider,
|
e621Provider: e621Provider,
|
||||||
),
|
),
|
||||||
|
_ArtistPostsCard(post: post),
|
||||||
if (post.hasRelations) ...[
|
if (post.hasRelations) ...[
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
PostRelationsCard(
|
PostRelationsCard(
|
||||||
@@ -648,6 +665,8 @@ class PostDetailsScreen extends ConsumerWidget {
|
|||||||
PostPoolsCard(
|
PostPoolsCard(
|
||||||
post: post,
|
post: post,
|
||||||
provider: e621Provider,
|
provider: e621Provider,
|
||||||
|
onOpenComic: (poolId, currentPost, [targetId]) =>
|
||||||
|
_openComic(context, e621Provider, poolId, currentPost, targetId),
|
||||||
onOpenPostId: (targetId) =>
|
onOpenPostId: (targetId) =>
|
||||||
_openPostById(context, post.providerId, targetId),
|
_openPostById(context, post.providerId, targetId),
|
||||||
),
|
),
|
||||||
@@ -885,6 +904,64 @@ class PostDetailsScreen extends ConsumerWidget {
|
|||||||
ref.invalidate(favoritesControllerProvider);
|
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(
|
void _openPostById(
|
||||||
BuildContext context, String providerId, String targetId) {
|
BuildContext context, String providerId, String targetId) {
|
||||||
Navigator.of(context).push(
|
Navigator.of(context).push(
|
||||||
@@ -1053,10 +1130,12 @@ class _MobilePostPager extends StatefulWidget {
|
|||||||
class _MobilePostPagerState extends State<_MobilePostPager> {
|
class _MobilePostPagerState extends State<_MobilePostPager> {
|
||||||
late final PageController _controller;
|
late final PageController _controller;
|
||||||
bool _mediaGestureLocked = false;
|
bool _mediaGestureLocked = false;
|
||||||
|
late int _currentPage = widget.initialIndex;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_currentPage = widget.initialIndex;
|
||||||
_controller = PageController(initialPage: widget.initialIndex);
|
_controller = PageController(initialPage: widget.initialIndex);
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
_prefetchAround(widget.initialIndex);
|
_prefetchAround(widget.initialIndex);
|
||||||
@@ -1066,10 +1145,10 @@ class _MobilePostPagerState extends State<_MobilePostPager> {
|
|||||||
@override
|
@override
|
||||||
void didUpdateWidget(covariant _MobilePostPager oldWidget) {
|
void didUpdateWidget(covariant _MobilePostPager oldWidget) {
|
||||||
super.didUpdateWidget(oldWidget);
|
super.didUpdateWidget(oldWidget);
|
||||||
// Исправленная проверка: сравниваем старый и новый initialIndex, а не текущий свайп
|
|
||||||
if (oldWidget.initialIndex != widget.initialIndex &&
|
if (oldWidget.initialIndex != widget.initialIndex &&
|
||||||
widget.initialIndex >= 0 &&
|
widget.initialIndex >= 0 &&
|
||||||
widget.initialIndex < widget.posts.length) {
|
widget.initialIndex < widget.posts.length) {
|
||||||
|
_currentPage = widget.initialIndex;
|
||||||
if (_controller.hasClients) {
|
if (_controller.hasClients) {
|
||||||
_controller.jumpToPage(widget.initialIndex);
|
_controller.jumpToPage(widget.initialIndex);
|
||||||
}
|
}
|
||||||
@@ -1087,7 +1166,7 @@ class _MobilePostPagerState extends State<_MobilePostPager> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return ScrollConfiguration(
|
final pager = ScrollConfiguration(
|
||||||
behavior: ScrollConfiguration.of(context).copyWith(
|
behavior: ScrollConfiguration.of(context).copyWith(
|
||||||
dragDevices: {
|
dragDevices: {
|
||||||
PointerDeviceKind.touch,
|
PointerDeviceKind.touch,
|
||||||
@@ -1103,6 +1182,7 @@ class _MobilePostPagerState extends State<_MobilePostPager> {
|
|||||||
: const PageScrollPhysics(),
|
: const PageScrollPhysics(),
|
||||||
itemCount: widget.posts.length,
|
itemCount: widget.posts.length,
|
||||||
onPageChanged: (index) {
|
onPageChanged: (index) {
|
||||||
|
setState(() => _currentPage = index);
|
||||||
_prefetchAround(index);
|
_prefetchAround(index);
|
||||||
},
|
},
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
@@ -1135,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) {
|
void _setMediaGestureLocked(bool locked) {
|
||||||
if (_mediaGestureLocked == locked) return;
|
if (_mediaGestureLocked == locked) return;
|
||||||
@@ -1273,6 +1409,163 @@ class _DownloadIntent extends Intent {
|
|||||||
const _DownloadIntent();
|
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 {
|
class _CommentsSection extends ConsumerStatefulWidget {
|
||||||
const _CommentsSection({required this.post});
|
const _CommentsSection({required this.post});
|
||||||
|
|
||||||
|
|||||||
@@ -280,7 +280,36 @@ class _E621PoolSheetState extends State<E621PoolSheet> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
||||||
const SizedBox(height: 18),
|
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
|
// Quick Page Navigator Banner
|
||||||
Container(
|
Container(
|
||||||
|
|||||||
@@ -9,12 +9,15 @@ class PostPoolsCard extends StatelessWidget {
|
|||||||
const PostPoolsCard({
|
const PostPoolsCard({
|
||||||
required this.post,
|
required this.post,
|
||||||
required this.provider,
|
required this.provider,
|
||||||
|
required this.onOpenComic,
|
||||||
required this.onOpenPostId,
|
required this.onOpenPostId,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
final Post post;
|
final Post post;
|
||||||
final E621Provider provider;
|
final E621Provider provider;
|
||||||
|
final void Function(String poolId, Post currentPost, [String? targetId])
|
||||||
|
onOpenComic;
|
||||||
final ValueChanged<String> onOpenPostId;
|
final ValueChanged<String> onOpenPostId;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -83,62 +86,83 @@ class PostPoolsCard extends StatelessWidget {
|
|||||||
runSpacing: 8,
|
runSpacing: 8,
|
||||||
children: [
|
children: [
|
||||||
for (final poolId in pools)
|
for (final poolId in pools)
|
||||||
Material(
|
Row(
|
||||||
color: Colors.transparent,
|
mainAxisSize: MainAxisSize.min,
|
||||||
child: InkWell(
|
children: [
|
||||||
onTap: () {
|
Material(
|
||||||
HapticFeedback.lightImpact();
|
color: Colors.transparent,
|
||||||
E621PoolSheet.show(
|
child: InkWell(
|
||||||
context,
|
onTap: () {
|
||||||
currentPost: post,
|
HapticFeedback.lightImpact();
|
||||||
poolId: poolId,
|
onOpenComic(poolId, post);
|
||||||
provider: provider,
|
},
|
||||||
onSelectPostId: onOpenPostId,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 12, vertical: 8),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
gradient: const LinearGradient(
|
|
||||||
colors: [Color(0xFF0055AA), Color(0xFF0077EE)],
|
|
||||||
begin: Alignment.topLeft,
|
|
||||||
end: Alignment.bottomRight,
|
|
||||||
),
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
boxShadow: [
|
child: Container(
|
||||||
BoxShadow(
|
padding: const EdgeInsets.symmetric(
|
||||||
color:
|
horizontal: 14, vertical: 9),
|
||||||
const Color(0xFF0055AA).withValues(alpha: 0.25),
|
decoration: BoxDecoration(
|
||||||
blurRadius: 8,
|
gradient: const LinearGradient(
|
||||||
offset: const Offset(0, 3),
|
colors: [Color(0xFF0055AA), Color(0xFF0077EE)],
|
||||||
),
|
begin: Alignment.topLeft,
|
||||||
],
|
end: Alignment.bottomRight,
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
const Icon(Icons.auto_stories_rounded,
|
|
||||||
size: 15, color: Colors.white),
|
|
||||||
const SizedBox(width: 6),
|
|
||||||
Text(
|
|
||||||
isRu
|
|
||||||
? 'Открыть комикс (Пул #$poolId)'
|
|
||||||
: 'Open Pool #$poolId',
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: Colors.white,
|
|
||||||
),
|
),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: const Color(0xFF0055AA)
|
||||||
|
.withValues(alpha: 0.25),
|
||||||
|
blurRadius: 8,
|
||||||
|
offset: const Offset(0, 3),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(width: 4),
|
child: Row(
|
||||||
const Icon(Icons.arrow_forward_ios_rounded,
|
mainAxisSize: MainAxisSize.min,
|
||||||
size: 10, color: Colors.white),
|
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),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -580,7 +580,7 @@ class _DesktopShellState extends State<_DesktopShell>
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
'v3.7.0',
|
'v3.7.2',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
fontWeight:
|
fontWeight:
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
name: gel_rule_app
|
name: gel_rule_app
|
||||||
description: Local backend/core layer for a Flutter booru browser app.
|
description: Local backend/core layer for a Flutter booru browser app.
|
||||||
publish_to: "none"
|
publish_to: "none"
|
||||||
version: 3.7.0+88
|
version: 3.7.3+91
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ">=3.3.0 <4.0.0"
|
sdk: ">=3.3.0 <4.0.0"
|
||||||
|
|||||||
Reference in New Issue
Block a user