Compare commits

...
4 Commits
40 changed files with 2723 additions and 243 deletions
+26 -13
View File
@@ -10,7 +10,7 @@
</p>
<p align="center">
<img src="https://img.shields.io/badge/Version-2.0.2-blueviolet?style=for-the-badge" alt="Version 2.0.2" />
<img src="https://img.shields.io/badge/Version-2.0.6-blueviolet?style=for-the-badge" alt="Version 2.0.6" />
<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-brightgreen?style=for-the-badge" alt="Platforms" />
<img src="https://img.shields.io/badge/Gitea-RarDog%2FLunaris-orange?style=for-the-badge&logo=gitea&logoColor=white" alt="Gitea" />
@@ -20,16 +20,29 @@
## ✨ Основные возможности
- **📱 Плавная лента в стиле Pinterest**: Masonry-сетка с адаптивным количеством колонок для мобильных и ПК.
- **🌐 Общая лента Pawchive**: Недавние посты со всех платформ прямо в главной ленте (Feed) с поддержкой поиска.
- **🏷️ Интерактивные теги автора**: Быстрая фильтрация по темам с живыми счётчиками постов (`#tag (count)`).
- **📢 Анонсы и новости авторов**: Всплывающее окно анонсов автора с датами и текстом обращений к аудитории.
- **🔗 Связанные аккаунты**: Удобный переход между профилями автора на разных платформах (Patreon, Fanbox, Discord и др.).
- **🎬 Серверная фильтрация по типу медиа**: Поиск фото, видео и GIF по всей глубине архива автора через API.
- **📱 Плавная лента в стиле Pinterest**: Masonry-сетка с переключением режима (Masonry, 1:1 Сетка, Список) в шапке ленты.
- **⚡ Сверхбыстрые видео-превью**: Мгновенная загрузка превью без фризов и лишней нагрузки на процессор и память смартфона.
- **↔️ Листание постов влево и вправо**: Полноэкранный пейджер с плавными свайпами между фото и видео во всех разделах (Лента, Авторы, Избранное, Коллекции, История просмотров).
- **🖼️ Мульти-медиа в одном посте**: Удобная карусель с индикатором `1 / 4` для постов с несколькими файлами.
- **⭐ Любимые авторы сверху**: Верхняя панель со «сториз»-аватарами любимых создателей и мгновенным просмотром их свежих фото и видео.
- **🐾 Поддержка Pawchive**: Интеграция с `pawchive.pw` для просмотра профилей авторов, связанных аккаунтов, тегов и работ с Patreon, Fanbox, Fantia, Discord, Boosty и др.
- **🔍 Умный поиск и автодополнение**: Интерактивные чипсы тегов с подсказками от активных API. Быстрое исключение (`-tag`), добавление (`+tag`) или отправка в черный список по долгому тапу.
- **🛡️ Продвинутый Blacklist / Whitelist**: Фильтрация по тегам, категориям, рейтингу контента (safe/questionable/explicit), провайдерам и минимальному score.
- **🎬 Встроенный плеер**: Удобное воспроизведение WebM / MP4, запоминание настроек звука, fullscreen и повтор.
- **💾 Менеджер загрузок и коллекции**: Скачивание оригиналов в высоком качестве, тематические папки-коллекции и автозагрузка избранного.
- **↔️ Листание постов влево и вправо**: Полноэкранный пейджер с плавными свайпами между фото и видео во всех разделах.
- **❤️ Двойной тап для лайка и свайп вниз для закрытия**: Анимированное сердечко по двойному тапу и закрытие экрана просмотра свайпом вниз.
- **📤 Прямая отправка файла (Share)**: Мгновенный экспорт оригинальных медиафайлов через системное меню «Поделиться».
- **🔖 Сохраненные поисковые пресеты**: Быстрое сохранение комбинаций тегов и фильтров в закладки.
- **📁 Умная структура папок скачивания**: Настраиваемые шаблоны поддиректорий (`{Artist}`, `{Provider}`, `{Service}`, `{ID}`, `{Date}`).
- **🗑️ Менеджер кэша**: Детальная визуализация занятой памяти и выборочная очистка.
- **📦 Бэкап и синхронизация**: Экспорт и импорт полной конфигурации в JSON-файл для переноса на другие устройства.
- **🌑 AMOLED Pure Black тема**: Истинно черный фон (`#000000`) для экономии батареи на OLED дисплеях.
- **🎨 Динамические цвета Material You**: Поддержка системной палитры Android из обоев устройства.
- **⭐ Любимые авторы**: Компактная кнопка-звёздочка сверху справа для быстрого доступа ко всем любимым авторам.
- **🏷️ Мультивыбор платформ**: Выбор нескольких платформ (Patreon, Fanbox, Fantia, Boosty, Discord).
- **📜 Бесконечная подгрузка постов автора**: Умная пагинация при прокрутке вниз загружает всю историю работ.
- **🔍 Умный поиск и автодополнение**: Интерактивные чипсы тегов с подсказками от активных API.
- **🛡️ Продвинутый Blacklist / Whitelist**: Фильтрация по тегам, категориям, рейтингу контента и минимальному score.
- **🎬 Встроенный плеер**: Удобное воспроизведение WebM / MP4, запоминание звука, fullscreen и повтор.
- **💾 Менеджер загрузок и коллекции**: Скачивание оригиналов в высоком качестве и тематические папки-коллекции.
- **🔄 Встроенное автообновление**: Проверка и скачивание свежих релизов напрямую из личного репозитория Gitea.
---
@@ -52,9 +65,9 @@
🔗 **[Gitea Releases (RarDog/Lunaris)](https://gitea.rardogsynapse.online/RarDog/Lunaris/releases)**
- **Android**: `Lunaris-v2.0.2.apk`
- **Linux (x64)**: `Lunaris-v2.0.2-linux-x64.tar.gz`
- **Windows**: `LunarisSetup-v2.0.2.exe` / `LunarisPortable-v2.0.2.zip`
- **Android**: `Lunaris-v2.0.6.apk`
- **Linux (x64)**: `Lunaris-v2.0.6-linux-x64.tar.gz`
- **Windows**: `LunarisSetup-v2.0.6.exe` / `LunarisPortable-v2.0.6.zip`
---
@@ -26,7 +26,8 @@ class MainActivity : FlutterActivity() {
val path = call.argument<String>("path") ?: error("Missing path")
val fileName = call.argument<String>("fileName") ?: File(path).name
val mimeType = call.argument<String>("mimeType") ?: "application/octet-stream"
result.success(saveToDownloads(path, fileName, mimeType))
val subDir = call.argument<String>("subDir")
result.success(saveToDownloads(path, fileName, mimeType, subDir))
}
"openFile" -> {
val path = call.argument<String>("path") ?: error("Missing path")
@@ -61,13 +62,19 @@ class MainActivity : FlutterActivity() {
}
}
private fun saveToDownloads(path: String, fileName: String, mimeType: String): String {
private fun saveToDownloads(path: String, fileName: String, mimeType: String, subDir: String? = null): String {
val source = File(path)
val relPath = if (!subDir.isNullOrBlank()) {
val clean = subDir.trim().removePrefix("/").removeSuffix("/")
"${Environment.DIRECTORY_DOWNLOADS}/$clean"
} else {
Environment.DIRECTORY_DOWNLOADS
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val values = ContentValues().apply {
put(MediaStore.Downloads.DISPLAY_NAME, fileName)
put(MediaStore.Downloads.MIME_TYPE, mimeType)
put(MediaStore.Downloads.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS)
put(MediaStore.Downloads.RELATIVE_PATH, relPath)
put(MediaStore.Downloads.IS_PENDING, 1)
}
val resolver = applicationContext.contentResolver
@@ -83,9 +90,10 @@ class MainActivity : FlutterActivity() {
}
@Suppress("DEPRECATION")
val downloads = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
downloads.mkdirs()
val destination = File(downloads, fileName)
val baseDownloads = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
val targetDir = if (!subDir.isNullOrBlank()) File(baseDownloads, subDir.trim()) else baseDownloads
targetDir.mkdirs()
val destination = File(targetDir, fileName)
FileInputStream(source).use { input ->
FileOutputStream(destination).use { output -> input.copyTo(output) }
}
+24 -14
View File
@@ -1,5 +1,6 @@
import 'dart:io';
import 'package:dynamic_color/dynamic_color.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:url_launcher/url_launcher.dart';
@@ -34,20 +35,29 @@ class GelRuleApp extends ConsumerWidget {
),
data: (_) {
final appSettings = settings.value ?? AppSettings.defaults;
return MaterialApp.router(
title: 'Lunaris',
debugShowCheckedModeBanner: false,
theme: buildTheme(
Brightness.light,
seedColor: appSettings.appSeedColor,
),
darkTheme: buildTheme(
Brightness.dark,
seedColor: appSettings.appSeedColor,
),
themeMode: parseThemeMode(appSettings.themeMode),
routerConfig: router,
builder: (context, child) => _AppOverlay(child: child),
final amoled = appSettings.amoledMode;
final useDynamic = appSettings.useDynamicColor;
return DynamicColorBuilder(
builder: (lightDynamic, darkDynamic) {
return MaterialApp.router(
title: 'Lunaris',
debugShowCheckedModeBanner: false,
theme: buildTheme(
Brightness.light,
seedColor: appSettings.appSeedColor,
dynamicColorScheme: useDynamic ? lightDynamic : null,
),
darkTheme: buildTheme(
Brightness.dark,
seedColor: appSettings.appSeedColor,
amoled: amoled,
dynamicColorScheme: useDynamic ? darkDynamic : null,
),
themeMode: parseThemeMode(appSettings.themeMode),
routerConfig: router,
builder: (context, child) => _AppOverlay(child: child),
);
},
);
},
);
+2 -2
View File
@@ -1,3 +1,3 @@
const appDisplayVersion = '2.0.2';
const appBuildNumber = 24;
const appDisplayVersion = '2.0.6';
const appBuildNumber = 28;
const appReleaseChannel = 'Stable';
+45
View File
@@ -1,4 +1,49 @@
const lunarisChangelog = [
LunarisChange(
version: '2.0.6',
title: 'Pawchive Deep Integration: Global Posts Feed, Artist Tags, Announcements, Links & Server Media Filter',
bullets: [
'Connected Pawchive global recent posts (/api/v1/posts) to the main Feed screen with search.',
'Interactive artist tags bar (/tags) with live post counts (#tag (N)) and instant topic filtering.',
'Linked accounts bar (/links) with direct navigation to author profiles across platforms.',
'Artist announcements modal (/announcements) showing creator news, dates and status updates.',
'Server-side media filtering (Photos, Videos, GIFs) querying the entire author archive through the API.',
],
),
LunarisChange(
version: '2.0.5',
title: '10 QoL Super-Update: AMOLED, Dynamic Colors, Grid switch, Share, Presets, Cache & Backup',
bullets: [
'AMOLED Pure Black theme (#000000) for OLED battery saving.',
'Material You dynamic color support on Android matching system wallpaper.',
'Grid layout mode switcher in the feed toolbar: Masonry, 1:1 Square Grid, and List.',
'Swipe down to dismiss and double-tap with animated heart overlay to favorite.',
'Direct file sharing (Share sheet) from post details.',
'Saved search presets with instant filtering and bookmark creation.',
'Smart download folder template support ({Artist}, {Provider}, {Service}, {ID}, {Date}).',
'Interactive Cache Manager screen with detailed storage breakdown and selective cleanup.',
'Full JSON backup export and import for seamless configuration sync.',
],
),
LunarisChange(
version: '2.0.4',
title: 'Platform service selection & media type filtering in artist posts',
bullets: [
'Added multi-select platform filters (Patreon, Pixiv Fanbox, Fantia, Boosty, Discord) in the Artists tab.',
'Added multi-select media type filter chips (Photos, Videos, GIFs) in artist posts with counts.',
'Full-screen post pager respects active media type filters during swiping.',
],
),
LunarisChange(
version: '2.0.3',
title: 'Infinite artist pagination, favorite artists modal, and card layout polish',
bullets: [
'Implemented infinite scroll pagination in the artist post feed — now loads all historical works endlessly as you scroll.',
'Moved favorite artists to a compact star action button in the top-right header that opens an interactive modal.',
'Expanded artist card vertical height so updated dates and statistics are never cut off.',
'Removed mapping truncation in Pawchive provider to deliver all post attachments cleanly.',
],
),
LunarisChange(
version: '2.0.2',
title: 'Swipeable media pager, favorite artists, performance and Pawchive',
+5
View File
@@ -15,6 +15,7 @@ import '../features/providers/presentation/provider_check_screen.dart';
import '../features/providers/presentation/provider_form_screen.dart';
import '../features/providers/presentation/providers_screen.dart';
import '../features/search/presentation/search_screen.dart';
import '../features/settings/presentation/cache_manager_screen.dart';
import '../features/settings/presentation/hidden_posts_screen.dart';
import '../features/settings/presentation/settings_screen.dart';
import '../features/viewed/presentation/viewed_screen.dart';
@@ -126,6 +127,10 @@ final appRouterProvider = Provider<GoRouter>((ref) {
path: '/settings/hidden',
builder: (context, state) => const HiddenPostsScreen(),
),
GoRoute(
path: '/settings/cache',
builder: (context, state) => const CacheManagerScreen(),
),
],
),
],
+37 -10
View File
@@ -1,26 +1,53 @@
import 'package:flutter/material.dart';
ThemeData buildTheme(Brightness brightness, {int seedColor = 0xFFE84D8A}) {
ThemeData buildTheme(
Brightness brightness, {
int seedColor = 0xFFE84D8A,
bool amoled = false,
ColorScheme? dynamicColorScheme,
}) {
final dark = brightness == Brightness.dark;
final scheme = ColorScheme.fromSeed(
seedColor: Color(seedColor),
brightness: brightness,
);
final scheme = dynamicColorScheme ??
ColorScheme.fromSeed(
seedColor: Color(seedColor),
brightness: brightness,
);
final scaffoldBg = amoled && dark
? const Color(0xFF000000)
: dark
? const Color(0xFF101114)
: const Color(0xFFF8F8FA);
final cardBg = amoled && dark
? const Color(0xFF0A0A0A)
: dark
? const Color(0xFF191B20)
: Colors.white;
final navBg = amoled && dark
? const Color(0xFF000000)
: dark
? const Color(0xFF15161A)
: Colors.white;
final appBarBg = amoled && dark
? const Color(0xFF000000)
: dark
? const Color(0xFF101114)
: const Color(0xFFF8F8FA);
return ThemeData(
useMaterial3: true,
brightness: brightness,
colorScheme: scheme,
scaffoldBackgroundColor:
dark ? const Color(0xFF101114) : const Color(0xFFF8F8FA),
cardColor: dark ? const Color(0xFF191B20) : Colors.white,
scaffoldBackgroundColor: scaffoldBg,
cardColor: cardBg,
navigationRailTheme: NavigationRailThemeData(
backgroundColor: dark ? const Color(0xFF15161A) : Colors.white,
backgroundColor: navBg,
selectedIconTheme: IconThemeData(color: scheme.primary),
),
appBarTheme: AppBarTheme(
centerTitle: false,
elevation: 0,
backgroundColor: dark ? const Color(0xFF101114) : const Color(0xFFF8F8FA),
backgroundColor: appBarBg,
foregroundColor: scheme.onSurface,
),
cardTheme: CardThemeData(
+4
View File
@@ -4,6 +4,9 @@ export 'models/content_provider_config.dart';
export 'models/app_update_info.dart';
export 'models/artist_profile.dart';
export 'models/artist_work_query.dart';
export 'models/artist_tag.dart';
export 'models/artist_link.dart';
export 'models/artist_announcement.dart';
export 'models/download_task.dart';
export 'models/downloaded_media.dart';
export 'models/favorite.dart';
@@ -28,5 +31,6 @@ export 'services/search_service.dart';
export 'services/settings_service.dart';
export 'services/update_service.dart';
export 'services/viewed_history_service.dart';
export 'services/backup_service.dart';
export 'utils/smart_blacklist.dart';
export 'utils/media_quality.dart';
+6
View File
@@ -31,6 +31,7 @@ import '../services/search_service.dart';
import '../services/settings_service.dart';
import '../services/update_service.dart';
import '../services/viewed_history_service.dart';
import '../services/backup_service.dart';
final appDatabaseProvider = FutureProvider<AppDatabase>((ref) async {
final database = await AppDatabase.open();
@@ -169,6 +170,7 @@ final downloadManagerServiceProvider = Provider<DownloadManagerService>((ref) {
return DownloadManagerService(
ref.watch(downloadServiceProvider),
downloadedMediaService: ref.watch(downloadedMediaServiceProvider),
settingsService: ref.watch(settingsServiceProvider),
);
});
@@ -215,3 +217,7 @@ final updateServiceProvider = Provider<UpdateService>((ref) {
ref.watch(settingsServiceProvider),
);
});
final backupServiceProvider = Provider<BackupService>((ref) {
return BackupService(ref.watch(settingsServiceProvider));
});
@@ -0,0 +1,35 @@
class ArtistAnnouncement {
const ArtistAnnouncement({
required this.service,
required this.userId,
required this.content,
this.hash,
this.added,
});
final String service;
final String userId;
final String content;
final String? hash;
final DateTime? added;
factory ArtistAnnouncement.fromJson(Map<String, dynamic> json) {
return ArtistAnnouncement(
service: (json['service'] ?? '').toString(),
userId: (json['user_id'] ?? '').toString(),
content: (json['content'] ?? '').toString(),
hash: json['hash']?.toString(),
added: json['added'] != null
? DateTime.tryParse(json['added'].toString())
: null,
);
}
Map<String, dynamic> toJson() => {
'service': service,
'user_id': userId,
'content': content,
if (hash != null) 'hash': hash,
if (added != null) 'added': added?.toIso8601String(),
};
}
+39
View File
@@ -0,0 +1,39 @@
class ArtistLink {
const ArtistLink({
required this.id,
required this.service,
required this.name,
this.publicId,
this.indexed,
this.updated,
});
final String id;
final String service;
final String name;
final String? publicId;
final DateTime? indexed;
final DateTime? updated;
factory ArtistLink.fromJson(Map<String, dynamic> json) {
return ArtistLink(
id: (json['id'] ?? '').toString(),
service: (json['service'] ?? '').toString(),
name: (json['name'] ?? '').toString(),
publicId: json['public_id']?.toString(),
indexed: json['indexed'] != null
? DateTime.tryParse(json['indexed'].toString())
: null,
updated: json['updated'] != null
? DateTime.tryParse(json['updated'].toString())
: null,
);
}
Map<String, dynamic> toJson() => {
'id': id,
'service': service,
'name': name,
if (publicId != null) 'public_id': publicId,
};
}
+21
View File
@@ -0,0 +1,21 @@
class ArtistTag {
const ArtistTag({
required this.tag,
required this.postCount,
});
final String tag;
final int postCount;
factory ArtistTag.fromJson(Map<String, dynamic> json) {
return ArtistTag(
tag: (json['tag'] ?? '').toString(),
postCount: (json['post_count'] as num?)?.toInt() ?? 0,
);
}
Map<String, dynamic> toJson() => {
'tag': tag,
'post_count': postCount,
};
}
+33 -2
View File
@@ -4,12 +4,34 @@ class ArtistWorkQuery {
required this.service,
required this.artistId,
required this.artistName,
this.queryText,
this.tagFilter,
});
final String providerId;
final String service;
final String artistId;
final String artistName;
final String? queryText;
final String? tagFilter;
ArtistWorkQuery copyWith({
String? providerId,
String? service,
String? artistId,
String? artistName,
String? queryText,
String? tagFilter,
}) {
return ArtistWorkQuery(
providerId: providerId ?? this.providerId,
service: service ?? this.service,
artistId: artistId ?? this.artistId,
artistName: artistName ?? this.artistName,
queryText: queryText ?? this.queryText,
tagFilter: tagFilter ?? this.tagFilter,
);
}
@override
bool operator ==(Object other) {
@@ -17,9 +39,18 @@ class ArtistWorkQuery {
other.providerId == providerId &&
other.service == service &&
other.artistId == artistId &&
other.artistName == artistName;
other.artistName == artistName &&
other.queryText == queryText &&
other.tagFilter == tagFilter;
}
@override
int get hashCode => Object.hash(providerId, service, artistId, artistName);
int get hashCode => Object.hash(
providerId,
service,
artistId,
artistName,
queryText,
tagFilter,
);
}
+18 -2
View File
@@ -1,10 +1,13 @@
import '../models/artist_announcement.dart';
import '../models/artist_link.dart';
import '../models/artist_profile.dart';
import '../models/artist_tag.dart';
import '../models/artist_work_query.dart';
import '../models/post.dart';
import '../models/post_comment.dart';
import '../models/provider_health.dart';
import '../models/tag_suggestion.dart';
import '../models/top_period_filter.dart';
import '../models/artist_profile.dart';
import '../models/artist_work_query.dart';
abstract class ContentProvider {
String get id;
@@ -47,6 +50,7 @@ abstract class TagMetadataProvider {
abstract class ArtistProvider {
Future<List<ArtistProfile>> listArtists({
String? service,
List<String>? services,
String? query,
int page = 1,
int limit = 30,
@@ -55,6 +59,7 @@ abstract class ArtistProvider {
Future<List<ArtistProfile>> searchArtists(
String query, {
String? service,
List<String>? services,
int page = 1,
int limit = 30,
});
@@ -70,4 +75,15 @@ abstract class ArtistProvider {
String artistId,
String postId,
);
Future<List<ArtistTag>> getArtistTags(String service, String artistId) async =>
const [];
Future<List<ArtistLink>> getArtistLinks(
String service, String artistId) async =>
const [];
Future<List<ArtistAnnouncement>> getArtistAnnouncements(
String service, String artistId) async =>
const [];
}
+39 -31
View File
@@ -1,7 +1,10 @@
import 'package:dio/dio.dart';
import '../../core/http/dio_client.dart';
import '../models/artist_announcement.dart';
import '../models/artist_link.dart';
import '../models/artist_profile.dart';
import '../models/artist_tag.dart';
import '../models/artist_work_query.dart';
import '../models/post.dart';
import '../models/post_comment.dart';
@@ -91,66 +94,56 @@ class KemonoProvider
@override
Future<List<ArtistProfile>> listArtists({
String? service,
List<String>? services,
String? query,
int page = 1,
int limit = 30,
}) async {
final searchText = query?.trim() ?? '';
if (searchText.isNotEmpty) {
try {
final response = await _creatorDio.get<dynamic>(
_creatorPath(),
queryParameters: {
'keyword': searchText,
'page': page,
'itemsPerPage': limit.clamp(1, 100),
},
);
final artists = _rankArtists(
_artistsFromResponse(response.data)
.where((artist) =>
service == null ||
service.isEmpty ||
artist.service == service)
.toList(growable: false),
searchText,
);
if (artists.isNotEmpty) return artists;
} catch (_) {
// Fall through to the indexed creator endpoints.
}
}
final effectiveService =
services != null && services.length == 1 ? services.first : service;
List<ArtistProfile> list;
try {
final response = await _creatorDio.get<dynamic>(
service == null || service.isEmpty
effectiveService == null || effectiveService.isEmpty
? _creatorPath()
: '${_creatorPath()}/$service',
: '${_creatorPath()}/$effectiveService',
queryParameters: {
'page': page,
'itemsPerPage': limit.clamp(1, 100),
if (query != null && query.trim().isNotEmpty) 'keyword': query.trim(),
},
);
return _rankArtists(_artistsFromResponse(response.data), query ?? '');
list = _rankArtists(_artistsFromResponse(response.data), query ?? '');
} catch (_) {
return _listArtistsFromPublicApi(
service: service,
list = await _listArtistsFromPublicApi(
service: effectiveService,
query: query,
page: page,
limit: limit,
);
}
if (services != null && services.isNotEmpty) {
final sSet = services.map((s) => s.toLowerCase()).toSet();
list = list.where((a) => sSet.contains(a.service.toLowerCase())).toList();
}
return list;
}
@override
Future<List<ArtistProfile>> searchArtists(
String query, {
String? service,
List<String>? services,
int page = 1,
int limit = 30,
}) {
return listArtists(
service: service, query: query, page: page, limit: limit);
service: service,
services: services,
query: query,
page: page,
limit: limit,
);
}
@override
@@ -224,6 +217,21 @@ class KemonoProvider
return getArtistPostComments(parts[0], parts[1], parts[2]);
}
@override
Future<List<ArtistTag>> getArtistTags(
String service, String artistId) async =>
const [];
@override
Future<List<ArtistLink>> getArtistLinks(
String service, String artistId) async =>
const [];
@override
Future<List<ArtistAnnouncement>> getArtistAnnouncements(
String service, String artistId) async =>
const [];
String _creatorPath() => _isCoomer ? '/coomer' : '/kemono';
List<ArtistProfile> _artistsFromResponse(dynamic data) {
+117 -6
View File
@@ -3,7 +3,10 @@ import 'dart:async';
import 'package:dio/dio.dart';
import '../../core/http/dio_client.dart';
import '../models/artist_announcement.dart';
import '../models/artist_link.dart';
import '../models/artist_profile.dart';
import '../models/artist_tag.dart';
import '../models/artist_work_query.dart';
import '../models/post.dart';
import '../models/post_comment.dart';
@@ -42,7 +45,34 @@ class PawchiveProvider
String? rating,
TopPeriodFilter topPeriod = TopPeriodFilter.none,
}) async {
return const [];
final offset = (page - 1).clamp(0, 999999) * 50;
final queryText = tags.join(' ').trim();
try {
final response = await _dio.get<dynamic>(
'/api/v1/posts',
queryParameters: {
if (queryText.isNotEmpty) 'q': queryText,
'o': offset,
},
);
final posts = _postItems(response.data);
final mapped = <Post>[];
for (final post in posts.whereType<Map>()) {
final json = Map<String, dynamic>.from(post);
final service = (json['service'] ?? 'fanbox').toString();
final userId = (json['user'] ?? '').toString();
final dummyQuery = ArtistWorkQuery(
providerId: id,
service: service,
artistId: userId,
artistName: userId,
);
mapped.addAll(_postsFromArtistPost(json, query: dummyQuery));
}
return mapped;
} catch (_) {
return const [];
}
}
@override
@@ -122,16 +152,23 @@ class PawchiveProvider
@override
Future<List<ArtistProfile>> listArtists({
String? service,
List<String>? services,
String? query,
int page = 1,
int limit = 30,
}) async {
final allArtists = await _ensureArtistsLoaded();
final queryText = (query ?? '').trim().toLowerCase();
final activeServices = services != null && services.isNotEmpty
? services.map((s) => s.trim().toLowerCase()).toSet()
: null;
final filtered = allArtists.where((artist) {
final matchService = service == null ||
service.isEmpty ||
artist.service.toLowerCase() == service.toLowerCase();
final artistService = artist.service.toLowerCase();
final matchService = activeServices != null
? activeServices.contains(artistService)
: service == null ||
service.isEmpty ||
artistService == service.toLowerCase();
if (!matchService) return false;
if (queryText.isEmpty) return true;
return artist.displayName.toLowerCase().contains(queryText) ||
@@ -147,11 +184,13 @@ class PawchiveProvider
Future<List<ArtistProfile>> searchArtists(
String query, {
String? service,
List<String>? services,
int page = 1,
int limit = 30,
}) {
return listArtists(
service: service,
services: services,
query: query,
page: page,
limit: limit,
@@ -169,7 +208,13 @@ class PawchiveProvider
try {
final response = await _dio.get<dynamic>(
path,
queryParameters: {'o': offset},
queryParameters: {
'o': offset,
if (query.tagFilter != null && query.tagFilter!.isNotEmpty)
'tag': query.tagFilter,
if (query.queryText != null && query.queryText!.isNotEmpty)
'q': query.queryText,
},
);
final posts = _postItems(response.data);
final mapped = <Post>[];
@@ -180,7 +225,7 @@ class PawchiveProvider
));
}
mapped.sort((a, b) => b.createdAt.compareTo(a.createdAt));
return mapped.take(limit).toList(growable: false);
return mapped.toList(growable: false);
} catch (error) {
throw StateError(
'$name artist posts are unavailable right now. ${_shortError(error)}',
@@ -207,6 +252,72 @@ class PawchiveProvider
}
}
@override
Future<List<ArtistTag>> getArtistTags(String service, String artistId) async {
try {
final response = await _dio.get<dynamic>(
'/api/v1/$service/user/$artistId/tags',
options: Options(
receiveTimeout: const Duration(seconds: 15),
sendTimeout: const Duration(seconds: 10),
),
);
final items = response.data;
if (items is List) {
return items
.whereType<Map>()
.map((item) => ArtistTag.fromJson(Map<String, dynamic>.from(item)))
.toList(growable: false);
}
} catch (_) {}
return const [];
}
@override
Future<List<ArtistLink>> getArtistLinks(
String service, String artistId) async {
try {
final response = await _dio.get<dynamic>(
'/api/v1/$service/user/$artistId/links',
options: Options(
receiveTimeout: const Duration(seconds: 15),
sendTimeout: const Duration(seconds: 10),
),
);
final items = response.data;
if (items is List) {
return items
.whereType<Map>()
.map((item) => ArtistLink.fromJson(Map<String, dynamic>.from(item)))
.toList(growable: false);
}
} catch (_) {}
return const [];
}
@override
Future<List<ArtistAnnouncement>> getArtistAnnouncements(
String service, String artistId) async {
try {
final response = await _dio.get<dynamic>(
'/api/v1/$service/user/$artistId/announcements',
options: Options(
receiveTimeout: const Duration(seconds: 15),
sendTimeout: const Duration(seconds: 10),
),
);
final items = response.data;
if (items is List) {
return items
.whereType<Map>()
.map((item) =>
ArtistAnnouncement.fromJson(Map<String, dynamic>.from(item)))
.toList(growable: false);
}
} catch (_) {}
return const [];
}
@override
Future<List<PostComment>> getComments(String postId) async {
final parts = postId.split(':');
@@ -83,5 +83,11 @@ class DownloadedMediaRepository {
});
}
Future<Result<int>> count() {
return _databaseService.safeRead((isar) async {
return isar.downloadedMediaEntitys.count();
});
}
String fileNameForPath(String path) => p.basename(path);
}
+87
View File
@@ -0,0 +1,87 @@
import 'dart:convert';
import 'dart:io';
import 'package:file_selector/file_selector.dart';
import 'package:path_provider/path_provider.dart';
import 'package:share_plus/share_plus.dart';
import '../../core/errors/failure.dart';
import '../../core/utils/result.dart';
import 'settings_service.dart';
class BackupService {
BackupService(this._settingsService);
final SettingsService _settingsService;
Future<String> createBackupJson() async {
final result = await _settingsService.getSettings();
final settings =
result is Success<AppSettings> ? result.data : AppSettings.defaults;
const encoder = JsonEncoder.withIndent(' ');
return encoder.convert({
'version': 1,
'createdAt': DateTime.now().toIso8601String(),
'settings': settings.toJson(),
});
}
Future<Result<AppSettings>> restoreFromJson(String jsonContent) async {
try {
final decoded = jsonDecode(jsonContent);
if (decoded is! Map<String, dynamic>) {
return const Error(Failure(
code: 'invalid_json',
message: 'Неверный формат JSON файла',
));
}
final rawSettings = decoded['settings'] is Map<String, dynamic>
? decoded['settings'] as Map<String, dynamic>
: decoded;
final settings = AppSettings.fromJson(rawSettings);
await _settingsService.updateSettings(settings);
return Success(settings);
} catch (e) {
return Error(Failure(
code: 'import_error',
message: 'Ошибка импорта настроек: $e',
));
}
}
Future<bool> exportBackup() async {
final json = await createBackupJson();
final fileName =
'lunaris_backup_${DateTime.now().millisecondsSinceEpoch}.json';
if (Platform.isAndroid) {
final tempDir = await getTemporaryDirectory();
final file = File('${tempDir.path}/$fileName');
await file.writeAsString(json);
await Share.shareXFiles([XFile(file.path)], text: 'Lunaris Backup');
return true;
}
final location = await getSaveLocation(
suggestedName: fileName,
acceptedTypeGroups: [
const XTypeGroup(label: 'JSON files', extensions: ['json']),
],
);
if (location == null) return false;
final file = File(location.path);
await file.writeAsString(json);
return true;
}
Future<Result<AppSettings>?> importBackup() async {
const typeGroup = XTypeGroup(
label: 'JSON files',
extensions: ['json'],
);
final file = await openFile(acceptedTypeGroups: [typeGroup]);
if (file == null) return null;
final content = await file.readAsString();
return restoreFromJson(content);
}
}
@@ -1,19 +1,24 @@
import 'dart:async';
import '../../core/utils/result.dart';
import '../models/download_task.dart';
import '../models/post.dart';
import '../utils/media_quality.dart';
import 'download_service.dart';
import 'downloaded_media_service.dart';
import 'settings_service.dart';
class DownloadManagerService {
DownloadManagerService(
this._downloadService, {
DownloadedMediaService? downloadedMediaService,
}) : _downloadedMediaService = downloadedMediaService;
SettingsService? settingsService,
}) : _downloadedMediaService = downloadedMediaService,
_settingsService = settingsService;
final DownloadService _downloadService;
final DownloadedMediaService? _downloadedMediaService;
final SettingsService? _settingsService;
final _controller = StreamController<List<DownloadTask>>.broadcast();
final Map<String, DownloadTask> _tasks = {};
final Set<String> _runningTaskIds = {};
@@ -100,9 +105,17 @@ class DownloadManagerService {
if (!_runningTaskIds.add(task.id)) return;
_set(task.copyWith(status: DownloadTaskStatus.running));
try {
String? folderTemplate;
if (_settingsService != null) {
final settingsRes = await _settingsService.getSettings();
if (settingsRes is Success<AppSettings>) {
folderTemplate = settingsRes.data.downloadPathTemplate;
}
}
final saved = task.sourceUrl == null
? await _downloadService.downloadPost(
task.post!,
folderTemplate: folderTemplate,
onProgress: (received, total) {
_updateProgress(task.id, received, total);
},
+92 -6
View File
@@ -1,7 +1,6 @@
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:file_selector/file_selector.dart';
import 'package:flutter/services.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
@@ -16,7 +15,54 @@ class DownloadService {
final Dio _dio;
Future<String?> downloadPost(
String resolveSubDir(Post post, String template) {
if (template.isEmpty) return '';
String artist = 'Unknown';
final artistGroup =
post.tagGroups['artist'] ?? post.tagGroups['creator'];
if (artistGroup != null && artistGroup.isNotEmpty) {
artist = artistGroup.first;
} else {
for (final tag in post.tags) {
final lower = tag.toLowerCase();
if (lower.startsWith('artist:') || lower.startsWith('creator:')) {
final parts = tag.split(':');
if (parts.length > 1 && parts[1].isNotEmpty) {
artist = parts.sublist(1).join(':');
break;
}
}
}
}
artist = artist.replaceAll(RegExp(r'[\\/:*?"<>|]'), '_');
final now = DateTime.now();
final dateStr =
'${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}';
var cleanTemplate = template.trim();
if (cleanTemplate.toLowerCase().endsWith('/{id}')) {
cleanTemplate =
cleanTemplate.substring(0, cleanTemplate.length - 5).trim();
}
final resolved = cleanTemplate
.replaceAll(
'{Artist}', artist)
.replaceAll(
'{Provider}', post.providerName.replaceAll(RegExp(r'[\\/:*?"<>|]'), '_'))
.replaceAll(
'{Service}', post.providerId.replaceAll(RegExp(r'[\\/:*?"<>|]'), '_'))
.replaceAll(
'{Rating}', post.rating.replaceAll(RegExp(r'[\\/:*?"<>|]'), '_'))
.replaceAll(
'{ID}', post.id.replaceAll(RegExp(r'[\\/:*?"<>|]'), '_'))
.replaceAll('{Date}', dateStr);
return resolved;
}
Future<String> prepareFileForShare(
Post post, {
void Function(int received, int total)? onProgress,
}) async {
@@ -25,6 +71,38 @@ class DownloadService {
throw StateError('No downloadable URL for this post.');
}
final fileName = _fileName(post, url);
final tempDir = await getTemporaryDirectory();
final shareDir = Directory(p.join(tempDir.path, 'shares'));
if (!shareDir.existsSync()) {
shareDir.createSync(recursive: true);
}
final filePath = p.join(shareDir.path, fileName);
if (File(filePath).existsSync() && File(filePath).lengthSync() > 0) {
return filePath;
}
await _dio.download(
url,
filePath,
onReceiveProgress: onProgress,
options: Options(headers: _headersFor(post)),
);
return filePath;
}
Future<String?> downloadPost(
Post post, {
String? folderTemplate,
void Function(int received, int total)? onProgress,
}) async {
final url = _downloadUrl(post);
if (url == null) {
throw StateError('No downloadable URL for this post.');
}
final fileName = _fileName(post, url);
final subDir = folderTemplate != null && folderTemplate.isNotEmpty
? resolveSubDir(post, folderTemplate)
: null;
if (Platform.isAndroid) {
final tempDir = await getTemporaryDirectory();
final tempPath = p.join(tempDir.path, fileName);
@@ -38,19 +116,27 @@ class DownloadService {
'path': tempPath,
'fileName': fileName,
'mimeType': _mimeType(fileName, post.fileType),
if (subDir != null && subDir.isNotEmpty) 'subDir': subDir,
});
return saved ?? fileName;
}
final location = await getSaveLocation(suggestedName: fileName);
if (location == null) return null;
final downloads =
await getDownloadsDirectory() ?? await getTemporaryDirectory();
final targetDir = subDir != null && subDir.isNotEmpty
? Directory(p.join(downloads.path, subDir))
: downloads;
if (!targetDir.existsSync()) {
targetDir.createSync(recursive: true);
}
final savePath = p.join(targetDir.path, fileName);
await _dio.download(
url,
location.path,
savePath,
onReceiveProgress: onProgress,
options: Options(headers: _headersFor(post)),
);
return location.path;
return savePath;
}
Future<String?> downloadUrl(
@@ -46,4 +46,6 @@ class DownloadedMediaService {
}
Future<Result<void>> clearMissingFiles() => _repository.clearMissingFiles();
Future<Result<int>> count() => _repository.count();
}
@@ -53,6 +53,11 @@ class AppSettings {
this.lastUpdateCheckAt,
this.defaultRatingFilter,
this.lastFeedRating,
this.amoledMode = false,
this.useDynamicColor = false,
this.gridMode = 'masonry',
this.searchPresets = const [],
this.downloadPathTemplate = '{Artist}/{ID}',
});
final List<String> favoriteArtists;
@@ -96,6 +101,11 @@ class AppSettings {
final String? lastUpdateCheckAt;
final String? defaultRatingFilter;
final String? lastFeedRating;
final bool amoledMode;
final bool useDynamicColor;
final String gridMode;
final List<String> searchPresets;
final String downloadPathTemplate;
static const defaults = AppSettings(
enabledProviderIds: ['gelbooru', 'rule34', 'realbooru'],
@@ -184,6 +194,11 @@ class AppSettings {
String? lastFeedRating,
bool clearLastFeedRating = false,
List<String>? favoriteArtists,
bool? amoledMode,
bool? useDynamicColor,
String? gridMode,
List<String>? searchPresets,
String? downloadPathTemplate,
}) {
return AppSettings(
favoriteArtists: favoriteArtists ?? this.favoriteArtists,
@@ -234,6 +249,11 @@ class AppSettings {
defaultRatingFilter: defaultRatingFilter ?? this.defaultRatingFilter,
lastFeedRating:
clearLastFeedRating ? null : lastFeedRating ?? this.lastFeedRating,
amoledMode: amoledMode ?? this.amoledMode,
useDynamicColor: useDynamicColor ?? this.useDynamicColor,
gridMode: gridMode ?? this.gridMode,
searchPresets: searchPresets ?? this.searchPresets,
downloadPathTemplate: downloadPathTemplate ?? this.downloadPathTemplate,
);
}
@@ -279,6 +299,11 @@ class AppSettings {
'lastUpdateCheckAt': lastUpdateCheckAt,
'defaultRatingFilter': defaultRatingFilter,
'lastFeedRating': lastFeedRating,
'amoledMode': amoledMode,
'useDynamicColor': useDynamicColor,
'gridMode': gridMode,
'searchPresets': searchPresets,
'downloadPathTemplate': downloadPathTemplate,
};
factory AppSettings.fromJson(Map<String, dynamic> json) => AppSettings(
@@ -378,6 +403,14 @@ class AppSettings {
lastUpdateCheckAt: json['lastUpdateCheckAt'] as String?,
defaultRatingFilter: json['defaultRatingFilter'] as String?,
lastFeedRating: json['lastFeedRating'] as String?,
amoledMode: (json['amoledMode'] as bool?) ?? false,
useDynamicColor: (json['useDynamicColor'] as bool?) ?? false,
gridMode: (json['gridMode'] as String?) ?? 'masonry',
searchPresets: List<String>.from(
(json['searchPresets'] as List?) ?? const [],
),
downloadPathTemplate:
(json['downloadPathTemplate'] as String?) ?? '{Artist}/{ID}',
);
}
+6
View File
@@ -62,6 +62,12 @@ class CacheService {
});
}
Future<Result<int>> getCachedPostsCount() {
return _databaseService.safeRead((isar) async {
return isar.cachedPostEntitys.count();
});
}
Future<void> _evictOverflow(Isar isar, int maxItems) async {
final entities = await isar.cachedPostEntitys.where().findAll();
if (entities.length <= maxItems) return;
@@ -20,12 +20,12 @@ final artistPostsProvider =
final provider = providers.data.firstWhere(
(item) => (item as ContentProvider).id == query.providerId,
);
final posts = await provider.getArtistPosts(query: query, limit: 50);
final posts = await provider.getArtistPosts(query: query, page: 0, limit: 50);
await ref.read(cacheServiceProvider).cachePosts(posts);
return posts;
});
class ArtistPostsScreen extends ConsumerWidget {
class ArtistPostsScreen extends ConsumerStatefulWidget {
const ArtistPostsScreen({
required this.providerId,
required this.service,
@@ -40,77 +40,536 @@ class ArtistPostsScreen extends ConsumerWidget {
final String artistName;
@override
Widget build(BuildContext context, WidgetRef ref) {
ConsumerState<ArtistPostsScreen> createState() => _ArtistPostsScreenState();
}
class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
final _scrollController = ScrollController();
final List<Post> _posts = [];
final Set<String> _selectedTypes = <String>{};
List<ArtistTag> _tags = [];
List<ArtistLink> _links = [];
List<ArtistAnnouncement> _announcements = [];
String? _selectedTag;
int _page = 0;
bool _loading = false;
bool _hasMore = true;
Object? _error;
@override
void initState() {
super.initState();
_scrollController.addListener(() {
if (_scrollController.position.extentAfter < 600) {
_loadMore();
}
});
WidgetsBinding.instance.addPostFrameCallback((_) {
_loadMore();
_loadMetadata();
});
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
Future<void> _loadMetadata() async {
try {
final providers =
await ref.read(providerManagerProvider).activeArtistProviders();
if (providers is! Success<List<ArtistProvider>>) return;
final provider = providers.data.firstWhere(
(item) => (item as ContentProvider).id == widget.providerId,
);
final tagsRes = await provider.getArtistTags(widget.service, widget.artistId);
final linksRes = await provider.getArtistLinks(widget.service, widget.artistId);
final annRes =
await provider.getArtistAnnouncements(widget.service, widget.artistId);
if (!mounted) return;
setState(() {
_tags = tagsRes;
_links = linksRes;
_announcements = annRes;
});
} catch (_) {}
}
Future<void> _refresh() async {
setState(() {
_posts.clear();
_page = 0;
_hasMore = true;
_error = null;
});
await Future.wait([_loadMore(), _loadMetadata()]);
}
Future<void> _loadMore() async {
if (_loading || !_hasMore) return;
setState(() {
_loading = true;
_error = null;
});
String? queryText;
if (_selectedTypes.length == 1) {
if (_selectedTypes.contains('video')) queryText = 'mp4';
if (_selectedTypes.contains('gif')) queryText = 'gif';
}
final query = ArtistWorkQuery(
providerId: providerId,
service: service,
artistId: artistId,
artistName: artistName,
providerId: widget.providerId,
service: widget.service,
artistId: widget.artistId,
artistName: widget.artistName,
queryText: queryText,
tagFilter: _selectedTag,
);
final posts = ref.watch(artistPostsProvider(query));
try {
final providers =
await ref.read(providerManagerProvider).activeArtistProviders();
if (providers is! Success<List<ArtistProvider>>) {
if (!mounted) return;
setState(() {
_loading = false;
_error = 'Could not load artist providers';
});
return;
}
final provider = providers.data.firstWhere(
(item) => (item as ContentProvider).id == query.providerId,
);
final newPosts = await provider.getArtistPosts(
query: query,
page: _page,
limit: 50,
);
await ref.read(cacheServiceProvider).cachePosts(newPosts);
if (!mounted) return;
setState(() {
_loading = false;
if (newPosts.isEmpty) {
_hasMore = false;
} else {
final existingKeys = _posts.map((p) => p.cacheKey).toSet();
final uniqueNew =
newPosts.where((p) => !existingKeys.contains(p.cacheKey)).toList();
_posts.addAll(uniqueNew);
_page++;
if (newPosts.length < 10) {
_hasMore = false;
}
}
});
} catch (error) {
if (!mounted) return;
setState(() {
_loading = false;
_error = error;
});
}
}
void _onTagSelected(String? tag) {
if (_selectedTag == tag) return;
setState(() {
_selectedTag = tag;
_posts.clear();
_page = 0;
_hasMore = true;
_error = null;
});
_loadMore();
}
void _onMediaTypeToggled(String type, bool selected) {
setState(() {
if (selected) {
_selectedTypes.add(type);
} else {
_selectedTypes.remove(type);
}
_posts.clear();
_page = 0;
_hasMore = true;
_error = null;
});
_loadMore();
}
bool _matchesTypeFilter(Post post) {
if (_selectedTypes.isEmpty) return true;
final isVid = MediaUrlSelector.isVideo(post);
final isGif = MediaUrlSelector.isGif(post);
final isPhoto = !isVid && !isGif;
if (_selectedTypes.contains('video') && isVid) return true;
if (_selectedTypes.contains('gif') && isGif) return true;
if (_selectedTypes.contains('photo') && isPhoto) return true;
return false;
}
void _showAnnouncements() {
final theme = Theme.of(context);
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (ctx) => DraggableScrollableSheet(
expand: false,
initialChildSize: 0.6,
maxChildSize: 0.9,
builder: (_, scroll) => Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
child: Row(
children: [
Icon(Icons.campaign_rounded, color: theme.colorScheme.primary),
const SizedBox(width: 10),
Text(
'Анонсы автора (${_announcements.length})',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
],
),
),
const Divider(),
Expanded(
child: ListView.separated(
controller: scroll,
padding: const EdgeInsets.all(16),
itemCount: _announcements.length,
separatorBuilder: (_, __) => const Divider(height: 24),
itemBuilder: (context, index) {
final a = _announcements[index];
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (a.added != null)
Text(
'${a.added!.year}-${a.added!.month.toString().padLeft(2, '0')}-${a.added!.day.toString().padLeft(2, '0')}',
style: TextStyle(
fontSize: 12,
color: theme.colorScheme.primary,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 6),
SelectableText(
a.content,
style: theme.textTheme.bodyMedium,
),
],
);
},
),
),
],
),
),
);
}
Widget _buildMediaTypeFilters() {
final scheme = Theme.of(context).colorScheme;
final photoCount = _posts
.where((p) => !MediaUrlSelector.isVideo(p) && !MediaUrlSelector.isGif(p))
.length;
final videoCount = _posts.where(MediaUrlSelector.isVideo).length;
final gifCount = _posts.where(MediaUrlSelector.isGif).length;
return Padding(
padding: const EdgeInsets.fromLTRB(16, 6, 16, 4),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
FilterChip(
selected: _selectedTypes.isEmpty,
label: Text('Все (${_posts.length})'),
onSelected: (_) {
if (_selectedTypes.isNotEmpty) {
setState(() {
_selectedTypes.clear();
_posts.clear();
_page = 0;
_hasMore = true;
_error = null;
});
_loadMore();
}
},
),
const SizedBox(width: 8),
FilterChip(
selected: _selectedTypes.contains('photo'),
avatar: Icon(Icons.photo_outlined,
size: 16,
color: _selectedTypes.contains('photo')
? scheme.onPrimaryContainer
: scheme.onSurfaceVariant),
label: Text('Фото ($photoCount)'),
onSelected: (selected) => _onMediaTypeToggled('photo', selected),
),
const SizedBox(width: 8),
FilterChip(
selected: _selectedTypes.contains('video'),
avatar: Icon(Icons.videocam_outlined,
size: 16,
color: _selectedTypes.contains('video')
? scheme.onPrimaryContainer
: scheme.onSurfaceVariant),
label: Text('Видео ($videoCount)'),
onSelected: (selected) => _onMediaTypeToggled('video', selected),
),
const SizedBox(width: 8),
FilterChip(
selected: _selectedTypes.contains('gif'),
avatar: Icon(Icons.gif_rounded,
size: 20,
color: _selectedTypes.contains('gif')
? scheme.onPrimaryContainer
: scheme.onSurfaceVariant),
label: Text('GIF ($gifCount)'),
onSelected: (selected) => _onMediaTypeToggled('gif', selected),
),
],
),
),
);
}
Widget _buildTagsBar() {
if (_tags.isEmpty) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.fromLTRB(16, 2, 16, 6),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
if (_selectedTag != null) ...[
InputChip(
selected: true,
avatar: const Icon(Icons.close_rounded, size: 16),
label: Text('#$_selectedTag'),
onSelected: (_) => _onTagSelected(null),
),
const SizedBox(width: 8),
],
for (final tag in _tags)
if (tag.tag != _selectedTag) ...[
ActionChip(
label: Text('#${tag.tag} (${tag.postCount})'),
onPressed: () => _onTagSelected(tag.tag),
),
const SizedBox(width: 8),
],
],
),
),
);
}
Widget _buildLinksBar() {
if (_links.isEmpty) return const SizedBox.shrink();
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 6),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
for (final link in _links) ...[
ActionChip(
avatar: Icon(
_serviceIcon(link.service),
size: 16,
color: theme.colorScheme.primary,
),
label: Text('${_serviceLabel(link.service)}: ${link.name}'),
onPressed: () {
context.push(
'/artists/${widget.providerId}/${link.service}/${link.id}?name=${Uri.encodeComponent(link.name)}',
);
},
),
const SizedBox(width: 8),
],
],
),
),
);
}
IconData _serviceIcon(String service) {
return switch (service.toLowerCase()) {
'patreon' => Icons.loyalty_rounded,
'fanbox' => Icons.favorite_border_rounded,
'discord' => Icons.chat_bubble_outline_rounded,
'boosty' => Icons.bolt_rounded,
_ => Icons.link_rounded,
};
}
String _serviceLabel(String service) {
return switch (service.toLowerCase()) {
'patreon' => 'Patreon',
'fanbox' => 'Pixiv Fanbox',
'discord' => 'Discord',
'boosty' => 'Boosty',
'fantia' => 'Fantia',
_ => service,
};
}
@override
Widget build(BuildContext context) {
final settings =
ref.watch(appSettingsProvider).value ?? AppSettings.defaults;
final favoriteKeys = ref.watch(favoriteKeysProvider).value ?? <String>{};
final viewedKeys = ref.watch(viewedKeysProvider).value ?? <String>{};
final displayedPosts =
_posts.where(_matchesTypeFilter).toList(growable: false);
return AdaptiveScaffold(
title: artistName,
title: widget.artistName,
actions: [
if (_announcements.isNotEmpty)
IconButton(
tooltip: 'Анонсы автора',
onPressed: _showAnnouncements,
icon: Badge.count(
count: _announcements.length,
child: const Icon(Icons.campaign_rounded),
),
),
IconButton(
tooltip: 'Refresh',
onPressed: () => ref.invalidate(artistPostsProvider(query)),
tooltip: 'Обновить',
onPressed: _refresh,
icon: const Icon(Icons.refresh_rounded),
),
],
body: posts.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) => ErrorView(
message: _friendlyArtistPostError(error),
onRetry: () => ref.invalidate(artistPostsProvider(query)),
),
data: (items) => items.isEmpty
? const EmptyView(
title: 'No works',
message: 'This artist has no visible media yet.',
)
: PostMasonryGrid(
posts: items,
columns: Responsive.columnsFor(
context,
mobileColumns: settings.mobileColumns,
desktopColumns: settings.desktopColumns,
),
blurExplicit: settings.blurExplicitContent,
showBadges: settings.showPostBadges,
nsfwEnabled: settings.nsfwEnabled,
mediaQualityMode:
MediaQualityMode.fromName(settings.mediaQualityMode),
favoriteKeys: favoriteKeys,
viewedKeys: viewedKeys,
onOpen: (post) => context.push(
'/post/${post.providerId}/${post.id}',
extra: PostNavigationContext(
currentPost: post,
posts: items,
),
),
onFavorite: (post) async {
if (favoriteKeys.contains(post.cacheKey)) {
await ref
.read(favoriteServiceProvider)
.removeFavorite(post.id, post.providerId);
} else {
await ref.read(favoriteServiceProvider).addFavorite(
post,
settings: settings,
downloadManager:
ref.read(downloadManagerServiceProvider),
);
}
ref.invalidate(favoriteKeysProvider);
ref.invalidate(favoritesControllerProvider);
},
),
),
body: _posts.isEmpty && _loading
? const Center(child: CircularProgressIndicator())
: _posts.isEmpty && _error != null
? ErrorView(
message: _friendlyArtistPostError(_error!),
onRetry: _refresh,
)
: _posts.isEmpty
? Column(
children: [
_buildLinksBar(),
_buildTagsBar(),
const Expanded(
child: EmptyView(
title: 'Нет работ',
message: 'У этого автора пока нет видимых постов.',
),
),
],
)
: Column(
children: [
_buildMediaTypeFilters(),
_buildTagsBar(),
_buildLinksBar(),
Expanded(
child: displayedPosts.isEmpty
? Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.filter_alt_off_rounded,
size: 48),
const SizedBox(height: 12),
const Text(
'Нет медиа по выбранным фильтрам',
style: TextStyle(
fontWeight: FontWeight.w600),
),
const SizedBox(height: 8),
FilledButton.tonal(
onPressed: () {
setState(() {
_selectedTypes.clear();
_selectedTag = null;
_posts.clear();
_page = 0;
_hasMore = true;
});
_loadMore();
},
child: const Text('Сбросить фильтры'),
),
],
),
),
)
: PostMasonryGrid(
posts: displayedPosts,
controller: _scrollController,
loading: _loading,
gridMode: settings.gridMode,
columns: Responsive.columnsFor(
context,
mobileColumns: settings.mobileColumns,
desktopColumns: settings.desktopColumns,
),
blurExplicit: settings.blurExplicitContent,
showBadges: settings.showPostBadges,
nsfwEnabled: settings.nsfwEnabled,
mediaQualityMode: MediaQualityMode.fromName(
settings.mediaQualityMode),
favoriteKeys: favoriteKeys,
viewedKeys: viewedKeys,
onOpen: (post) => context.push(
'/post/${post.providerId}/${post.id}',
extra: PostNavigationContext(
currentPost: post,
posts: displayedPosts,
),
),
onFavorite: (post) async {
if (favoriteKeys.contains(post.cacheKey)) {
await ref
.read(favoriteServiceProvider)
.removeFavorite(
post.id, post.providerId);
} else {
await ref
.read(favoriteServiceProvider)
.addFavorite(
post,
settings: settings,
downloadManager: ref.read(
downloadManagerServiceProvider),
);
}
ref.invalidate(favoriteKeysProvider);
ref.invalidate(
favoritesControllerProvider);
},
),
),
],
),
);
}
@@ -118,7 +577,7 @@ class ArtistPostsScreen extends ConsumerWidget {
final message = error.toString();
if (message.contains('HandshakeException') ||
message.contains('artist works are unavailable')) {
return 'Artist works are unavailable from this provider right now. Try again later or choose another artist/provider.';
return 'Работы автора временно недоступны. Попробуйте обновить позже.';
}
return message;
}
@@ -34,6 +34,7 @@ class ArtistsScreen extends ConsumerStatefulWidget {
class _ArtistsScreenState extends ConsumerState<ArtistsScreen> {
final _search = TextEditingController();
final _scroll = ScrollController();
final Set<String> _selectedServices = <String>{};
String? _providerId;
var _page = 1;
var _loading = false;
@@ -107,16 +108,23 @@ class _ArtistsScreenState extends ConsumerState<ArtistsScreen> {
.whereType<_FavoriteArtistItem>()
.toList();
final favoriteKeys = favoriteItems.map((e) => e.key).toSet();
final selectedFav = favoriteItems.firstWhere(
(e) => e.key == _selectedFavoriteArtistKey,
orElse: () => favoriteItems.isNotEmpty
? favoriteItems.first
: const _FavoriteArtistItem.empty(),
);
return AdaptiveScaffold(
title: 'Artists',
actions: [
IconButton(
tooltip: 'Любимые авторы',
icon: Badge(
isLabelVisible: favoriteItems.isNotEmpty,
label: Text('${favoriteItems.length}'),
child: const Icon(Icons.star_rounded, color: Colors.amber),
),
onPressed: () => _showFavoriteArtistsModal(
context,
settings,
favoriteItems,
),
),
IconButton(
tooltip: 'Refresh',
onPressed: () => _refresh(configs.valueOrNull ?? const []),
@@ -138,43 +146,39 @@ class _ArtistsScreenState extends ConsumerState<ArtistsScreen> {
WidgetsBinding.instance
.addPostFrameCallback((_) => _refresh(items));
}
final displayedArtists = _selectedServices.isEmpty
? _artists
: _artists
.where((a) =>
_selectedServices.contains(a.service.toLowerCase()))
.toList(growable: false);
return Column(
children: [
_ArtistsHeader(
searchController: _search,
providers: items,
selectedProviderId: _providerId,
selectedServices: _selectedServices,
onProviderChanged: (value) {
setState(() => _providerId = value);
_refresh(items);
},
onSearch: () => _refresh(items),
),
_FavoriteArtistsSection(
favorites: favoriteItems,
selectedKey: selectedFav.isEmpty ? null : selectedFav.key,
onSelect: (fav) =>
setState(() => _selectedFavoriteArtistKey = fav.key),
onOpenArtist: (fav) => context.push(
'/artists/${fav.providerId}/${fav.service}/${fav.id}?name=${Uri.encodeComponent(fav.name)}',
),
onRemoveFavorite: (fav) async {
final updated = List<String>.from(settings.favoriteArtists)
..removeWhere((e) {
try {
return _FavoriteArtistItem.fromJson(
jsonDecode(e) as Map<String, dynamic>)
.key ==
fav.key;
} catch (_) {
return false;
}
});
await ref
.read(settingsControllerProvider.notifier)
.saveSettings(
settings.copyWith(favoriteArtists: updated));
onToggleService: (service) {
setState(() {
if (_selectedServices.contains(service)) {
_selectedServices.remove(service);
} else {
_selectedServices.add(service);
}
});
_refresh(items);
},
onClearServices: () {
setState(() => _selectedServices.clear());
_refresh(items);
},
onSearch: () => _refresh(items),
),
Expanded(
child: _error != null
@@ -182,10 +186,33 @@ class _ArtistsScreenState extends ConsumerState<ArtistsScreen> {
message: _friendlyArtistError(_error),
onRetry: () => _refresh(items),
)
: _artists.isEmpty && !_loading
? const EmptyView(
title: 'No artists',
message: 'Try another name or provider.',
: displayedArtists.isEmpty && !_loading
? Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.person_search_outlined,
size: 48),
const SizedBox(height: 12),
const Text(
'Нет авторов по выбранным фильтрам',
style:
TextStyle(fontWeight: FontWeight.w600),
),
const SizedBox(height: 8),
FilledButton.tonal(
onPressed: () {
setState(
() => _selectedServices.clear());
_refresh(items);
},
child: const Text('Показать все сервисы'),
),
],
),
),
)
: LayoutBuilder(
builder: (context, constraints) {
@@ -206,14 +233,15 @@ class _ArtistsScreenState extends ConsumerState<ArtistsScreen> {
crossAxisSpacing: 12,
mainAxisSpacing: 12,
mainAxisExtent:
Responsive.isMobile(context) ? 88 : 104,
Responsive.isMobile(context) ? 104 : 116,
),
itemCount: _artists.length + (_loading ? 1 : 0),
itemCount:
displayedArtists.length + (_loading ? 1 : 0),
itemBuilder: (context, index) {
if (index >= _artists.length) {
if (index >= displayedArtists.length) {
return const _ArtistSkeletonCard();
}
final artist = _artists[index];
final artist = displayedArtists[index];
final isFav = favoriteKeys.contains(
'${artist.providerId}:${artist.service}:${artist.id}');
return _ArtistCard(
@@ -269,6 +297,7 @@ class _ArtistsScreenState extends ConsumerState<ArtistsScreen> {
);
final next = await provider.searchArtists(
_search.text.trim(),
services: _selectedServices.isEmpty ? null : _selectedServices.toList(),
page: _page,
limit: 30,
);
@@ -285,6 +314,50 @@ class _ArtistsScreenState extends ConsumerState<ArtistsScreen> {
if (mounted) setState(() => _loading = false);
}
}
void _showFavoriteArtistsModal(
BuildContext context,
AppSettings settings,
List<_FavoriteArtistItem> favorites,
) {
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
useSafeArea: true,
backgroundColor: Theme.of(context).colorScheme.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (ctx) {
return _FavoriteArtistsModal(
favorites: favorites,
initialSelectedKey: _selectedFavoriteArtistKey,
onSelect: (fav) =>
setState(() => _selectedFavoriteArtistKey = fav.key),
onOpenArtist: (fav) => context.push(
'/artists/${fav.providerId}/${fav.service}/${fav.id}?name=${Uri.encodeComponent(fav.name)}',
),
onRemoveFavorite: (fav) async {
final updated = List<String>.from(settings.favoriteArtists)
..removeWhere((e) {
try {
return _FavoriteArtistItem.fromJson(
jsonDecode(e) as Map<String, dynamic>)
.key ==
fav.key;
} catch (_) {
return false;
}
});
await ref
.read(settingsControllerProvider.notifier)
.saveSettings(settings.copyWith(favoriteArtists: updated));
setState(() {});
},
);
},
);
}
}
class _ArtistsHeader extends StatelessWidget {
@@ -292,16 +365,41 @@ class _ArtistsHeader extends StatelessWidget {
required this.searchController,
required this.providers,
required this.selectedProviderId,
required this.selectedServices,
required this.onProviderChanged,
required this.onToggleService,
required this.onClearServices,
required this.onSearch,
});
final TextEditingController searchController;
final List<ContentProviderConfig> providers;
final String? selectedProviderId;
final Set<String> selectedServices;
final ValueChanged<String?> onProviderChanged;
final ValueChanged<String> onToggleService;
final VoidCallback onClearServices;
final VoidCallback onSearch;
static const _availableServices = ['patreon', 'fanbox', 'fantia', 'boosty', 'discord'];
String _formatServiceName(String s) {
switch (s.toLowerCase()) {
case 'patreon':
return 'Patreon';
case 'fanbox':
return 'Pixiv Fanbox';
case 'fantia':
return 'Fantia';
case 'boosty':
return 'Boosty';
case 'discord':
return 'Discord';
default:
return s.isEmpty ? s : s[0].toUpperCase() + s.substring(1);
}
}
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
@@ -357,6 +455,29 @@ class _ArtistsHeader extends StatelessWidget {
},
),
),
const SizedBox(height: 8),
SizedBox(
height: 36,
child: ListView(
scrollDirection: Axis.horizontal,
children: [
FilterChip(
selected: selectedServices.isEmpty,
label: const Text('Все платформы'),
onSelected: (_) => onClearServices(),
),
const SizedBox(width: 8),
for (final service in _availableServices) ...[
FilterChip(
selected: selectedServices.contains(service),
label: Text(_formatServiceName(service)),
onSelected: (_) => onToggleService(service),
),
const SizedBox(width: 8),
],
],
),
),
],
),
),
@@ -388,7 +509,7 @@ class _ArtistCard extends StatelessWidget {
child: InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.all(12),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
child: Row(
children: [
_ArtistAvatar(artist: artist),
@@ -492,6 +613,176 @@ class _FavoriteArtistItem {
);
}
class _FavoriteArtistsModal extends StatefulWidget {
const _FavoriteArtistsModal({
required this.favorites,
required this.initialSelectedKey,
required this.onSelect,
required this.onOpenArtist,
required this.onRemoveFavorite,
});
final List<_FavoriteArtistItem> favorites;
final String? initialSelectedKey;
final ValueChanged<_FavoriteArtistItem> onSelect;
final ValueChanged<_FavoriteArtistItem> onOpenArtist;
final ValueChanged<_FavoriteArtistItem> onRemoveFavorite;
@override
State<_FavoriteArtistsModal> createState() => _FavoriteArtistsModalState();
}
class _FavoriteArtistsModalState extends State<_FavoriteArtistsModal> {
late String? _selectedKey;
@override
void initState() {
super.initState();
_selectedKey = widget.initialSelectedKey;
}
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final selectedFav = widget.favorites.firstWhere(
(e) => e.key == _selectedKey,
orElse: () => widget.favorites.isNotEmpty
? widget.favorites.first
: const _FavoriteArtistItem.empty(),
);
return DraggableScrollableSheet(
expand: false,
initialChildSize: widget.favorites.isEmpty ? 0.35 : 0.65,
minChildSize: 0.25,
maxChildSize: 0.92,
builder: (context, scroll) {
return SingleChildScrollView(
controller: scroll,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Center(
child: Container(
width: 40,
height: 4,
margin: const EdgeInsets.symmetric(vertical: 12),
decoration: BoxDecoration(
color: scheme.onSurfaceVariant.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(2),
),
),
),
Padding(
padding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: Row(
children: [
const Icon(Icons.star_rounded,
color: Colors.amber, size: 26),
const SizedBox(width: 8),
Text(
'Любимые авторы',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
if (widget.favorites.isNotEmpty) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: Colors.amber.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(12),
),
child: Text(
'${widget.favorites.length}',
style:
Theme.of(context).textTheme.labelSmall?.copyWith(
color: Colors.amber.shade900,
fontWeight: FontWeight.w700,
),
),
),
],
const Spacer(),
IconButton(
icon: const Icon(Icons.close_rounded),
onPressed: () => Navigator.of(context).pop(),
),
],
),
),
const Divider(height: 1),
if (widget.favorites.isEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(24, 36, 24, 40),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.star_border_rounded,
size: 52,
color: scheme.onSurfaceVariant.withValues(alpha: 0.4),
),
const SizedBox(height: 16),
Text(
'Список пуст',
style:
Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
Text(
'Нажмите на иконку звёздочки ★ на карточке любого автора, чтобы добавить его в избранное и быстро смотреть свежие работы.',
textAlign: TextAlign.center,
style:
Theme.of(context).textTheme.bodyMedium?.copyWith(
color: scheme.onSurfaceVariant,
),
),
],
),
),
)
else ...[
_FavoriteArtistsSection(
favorites: widget.favorites,
selectedKey: selectedFav.isEmpty ? null : selectedFav.key,
onSelect: (fav) {
setState(() => _selectedKey = fav.key);
widget.onSelect(fav);
},
onOpenArtist: (fav) {
Navigator.of(context).pop();
widget.onOpenArtist(fav);
},
onRemoveFavorite: (fav) {
widget.onRemoveFavorite(fav);
if (_selectedKey == fav.key) {
setState(() {
_selectedKey = widget.favorites
.where((e) => e.key != fav.key)
.firstOrNull
?.key;
});
}
},
),
const SizedBox(height: 16),
],
],
),
);
},
);
}
}
class _FavoriteArtistsSection extends ConsumerWidget {
const _FavoriteArtistsSection({
required this.favorites,
@@ -1,3 +1,4 @@
import 'dart:convert';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
@@ -17,6 +18,7 @@ import '../../../shared/widgets/post_card.dart';
import '../../../shared/widgets/post_masonry_grid.dart';
import '../../collections/presentation/collection_form_dialog.dart';
import '../../favorites/presentation/favorites_controller.dart';
import '../../settings/presentation/settings_controller.dart';
import 'feed_controller.dart';
import 'widgets/feed_toolbar.dart';
@@ -179,6 +181,20 @@ class _FeedScreenState extends ConsumerState<FeedScreen>
child: AdaptiveScaffold(
title: 'Feed',
titleWidget: const _FeedTitle(),
actions: [
if (feed.value != null && feed.value!.selectedTags.isNotEmpty)
IconButton(
icon: const Icon(Icons.bookmark_add_rounded),
tooltip: 'Сохранить пресет',
onPressed: () => _saveCurrentPreset(
context, feed.value!.selectedTags, settings),
),
IconButton(
icon: Icon(_gridModeIcon(settings.gridMode)),
tooltip: 'Вид сетки',
onPressed: () => _cycleGridMode(settings),
),
],
floatingActionButton: _showScrollToTop
? FloatingActionButton.small(
tooltip: 'Наверх',
@@ -201,6 +217,26 @@ class _FeedScreenState extends ConsumerState<FeedScreen>
),
data: (state) => Column(
children: [
// --- Search Presets Bar ---
if (settings.searchPresets.isNotEmpty)
_SearchPresetsBar(
presets: settings.searchPresets,
currentTags: state.selectedTags,
onApply: (preset) {
final decoded =
_decodePreset(preset);
_submitSearch(decoded['tags'] ?? '');
},
onDelete: (preset) async {
final updated = settings.searchPresets
.where((p) => p != preset)
.toList();
await ref
.read(settingsControllerProvider.notifier)
.saveSettings(
settings.copyWith(searchPresets: updated));
},
),
FeedToolbar(
selectedTags: state.selectedTags,
selectedProviderIds: state.selectedProviderIds,
@@ -294,6 +330,7 @@ class _FeedScreenState extends ConsumerState<FeedScreen>
viewedKeys: viewedKeys,
selectionMode: _selectionMode,
selectedKeys: _selectedKeys,
gridMode: settings.gridMode,
onOpen: (post) => context.push(
'/post/${post.providerId}/${post.id}',
extra: PostNavigationContext(
@@ -527,6 +564,75 @@ class _FeedScreenState extends ConsumerState<FeedScreen>
if (!settings.autoDownloadFavorites || !settings.allowDownloads) return;
await ref.read(downloadManagerServiceProvider).start(post);
}
// ── Grid mode helpers ────────────────────────────────────────────
IconData _gridModeIcon(String mode) {
return switch (mode) {
'grid' => Icons.grid_4x4_rounded,
'list' => Icons.view_list_rounded,
_ => Icons.dashboard_rounded,
};
}
Future<void> _cycleGridMode(AppSettings settings) async {
const modes = ['masonry', 'grid', 'list'];
final idx = modes.indexOf(settings.gridMode);
final next = modes[(idx + 1) % modes.length];
await ref
.read(settingsControllerProvider.notifier)
.saveSettings(settings.copyWith(gridMode: next));
}
// ── Search preset helpers ─────────────────────────────────────────
Map<String, String> _decodePreset(String raw) {
try {
final map = jsonDecode(raw) as Map<String, dynamic>;
return {'name': map['name'] as String, 'tags': map['tags'] as String};
} catch (_) {
return {'name': raw, 'tags': raw};
}
}
Future<String?> _showSavePresetDialog(
BuildContext context, String tags) async {
final ctrl = TextEditingController(text: tags);
return showDialog<String>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Сохранить пресет'),
content: TextField(
controller: ctrl,
decoration: const InputDecoration(
labelText: 'Название пресета',
),
autofocus: true,
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Отмена'),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, ctrl.text.trim()),
child: const Text('Сохранить'),
),
],
),
);
}
Future<void> _saveCurrentPreset(
BuildContext context, List<String> tags, AppSettings settings) async {
if (tags.isEmpty) return;
final tagsStr = tags.join(' ');
final name = await _showSavePresetDialog(context, tagsStr);
if (name == null || name.isEmpty || !context.mounted) return;
final preset = jsonEncode({'name': name, 'tags': tagsStr});
final updated = [...settings.searchPresets, preset];
await ref
.read(settingsControllerProvider.notifier)
.saveSettings(settings.copyWith(searchPresets: updated));
}
}
class _RefreshIntent extends Intent {
@@ -631,3 +737,86 @@ class _BatchActionBar extends StatelessWidget {
);
}
}
// ── Search Presets Bar ─────────────────────────────────────────────────────
class _SearchPresetsBar extends StatelessWidget {
const _SearchPresetsBar({
required this.presets,
required this.currentTags,
required this.onApply,
required this.onDelete,
});
final List<String> presets;
final List<String> currentTags;
final ValueChanged<String> onApply;
final ValueChanged<String> onDelete;
Map<String, String> _decode(String raw) {
try {
final map = jsonDecode(raw) as Map<String, dynamic>;
return {'name': map['name'] as String, 'tags': map['tags'] as String};
} catch (_) {
return {'name': raw, 'tags': raw};
}
}
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return SizedBox(
height: 40,
child: ListView.separated(
padding: const EdgeInsets.symmetric(horizontal: 12),
scrollDirection: Axis.horizontal,
itemCount: presets.length,
separatorBuilder: (_, __) => const SizedBox(width: 8),
itemBuilder: (context, i) {
final raw = presets[i];
final decoded = _decode(raw);
final name = decoded['name'] ?? raw;
final tags = decoded['tags'] ?? raw;
final active = currentTags.join(' ') == tags;
return GestureDetector(
onLongPress: () {
showDialog<void>(
context: context,
builder: (ctx) => AlertDialog(
title: Text('«$name»'),
content: Text('Теги: $tags'),
actions: [
TextButton(
onPressed: () {
Navigator.pop(ctx);
onDelete(raw);
},
child: const Text('Удалить',
style: TextStyle(color: Colors.red)),
),
FilledButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Закрыть'),
),
],
),
);
},
child: FilterChip(
label: Text(name),
selected: active,
onSelected: (_) => onApply(raw),
selectedColor: scheme.primaryContainer,
checkmarkColor: scheme.onPrimaryContainer,
labelStyle: TextStyle(
color: active ? scheme.onPrimaryContainer : scheme.onSurface,
fontWeight:
active ? FontWeight.w600 : FontWeight.normal,
),
),
);
},
),
);
}
}
@@ -4,6 +4,7 @@ import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:share_plus/share_plus.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../../app/app.dart';
@@ -269,6 +270,7 @@ class PostDetailsScreen extends ConsumerWidget {
: null,
onDeleteLocalFile: () =>
_deleteLocalFile(context, ref, post),
onShare: () => _sharePost(context, ref, post),
),
const SizedBox(height: 16),
Wrap(
@@ -377,6 +379,7 @@ class PostDetailsScreen extends ConsumerWidget {
? () => _download(context, ref, post)
: null,
onDeleteLocalFile: () => _deleteLocalFile(context, ref, post),
onShare: () => _sharePost(context, ref, post),
),
const SizedBox(height: 12),
Wrap(
@@ -448,6 +451,30 @@ class PostDetailsScreen extends ConsumerWidget {
});
}
Future<void> _sharePost(BuildContext context, WidgetRef ref, Post post) async {
final strings = ref.read(appStringsProvider);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
strings.ru
? 'Подготовка файла к отправке...'
: 'Preparing file for sharing...',
),
duration: const Duration(seconds: 1),
),
);
try {
final path =
await ref.read(downloadServiceProvider).prepareFileForShare(post);
await Share.shareXFiles([XFile(path)], text: post.source ?? post.fileUrl);
} catch (e) {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Ошибка отправки: $e')),
);
}
}
Future<void> _showMobileQuickActions(
BuildContext context,
WidgetRef ref,
@@ -558,6 +585,7 @@ class PostDetailsScreen extends ConsumerWidget {
download: strings.download,
deleteLocalFile: strings.deleteLocalFile,
hidePost: strings.hidePost,
share: strings.ru ? 'Поделиться' : 'Share',
);
}
@@ -10,6 +10,7 @@ class PostActionBar extends StatelessWidget {
this.onOpenSource,
this.onDownload,
this.onDeleteLocalFile,
this.onShare,
this.downloaded = false,
this.labels,
this.onSimilar,
@@ -25,6 +26,7 @@ class PostActionBar extends StatelessWidget {
final bool isFavorite;
final VoidCallback? onDownload;
final VoidCallback? onDeleteLocalFile;
final VoidCallback? onShare;
final VoidCallback? onSimilar;
final VoidCallback? onHide;
final bool downloaded;
@@ -91,6 +93,12 @@ class PostActionBar extends StatelessWidget {
onPressed: onHide,
icon: const Icon(Icons.visibility_off_rounded),
),
if (onShare != null)
IconButton.filledTonal(
tooltip: text.share,
onPressed: onShare,
icon: const Icon(Icons.share_rounded),
),
],
);
}
@@ -108,6 +116,7 @@ class PostActionLabels {
this.download = 'Download',
this.deleteLocalFile = 'Delete local file',
this.hidePost = 'Hide post locally',
this.share = 'Share',
});
final String favorite;
@@ -120,4 +129,5 @@ class PostActionLabels {
final String download;
final String deleteLocalFile;
final String hidePost;
final String share;
}
@@ -0,0 +1,362 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path_provider/path_provider.dart';
import '../../../app/app.dart';
import '../../../backend/backend.dart';
import '../../../core/utils/result.dart';
import '../../../shared/widgets/adaptive_scaffold.dart';
class CacheStats {
const CacheStats({
required this.cachedPostsCount,
required this.imageMemoryBytes,
required this.imageMemoryCount,
required this.tempDirectoryBytes,
required this.downloadedCount,
});
final int cachedPostsCount;
final int imageMemoryBytes;
final int imageMemoryCount;
final int tempDirectoryBytes;
final int downloadedCount;
int get totalEstimatedBytes =>
imageMemoryBytes + tempDirectoryBytes + (cachedPostsCount * 2048);
static const empty = CacheStats(
cachedPostsCount: 0,
imageMemoryBytes: 0,
imageMemoryCount: 0,
tempDirectoryBytes: 0,
downloadedCount: 0,
);
}
final cacheStatsProvider = FutureProvider.autoDispose<CacheStats>((ref) async {
final cacheService = ref.watch(cacheServiceProvider);
final downloadedService = ref.watch(downloadedMediaServiceProvider);
final postsRes = await cacheService.getCachedPostsCount();
final postsCount = postsRes is Success<int> ? postsRes.data : 0;
final downloadedRes = await downloadedService.count();
final downloadedCount =
downloadedRes is Success<int> ? downloadedRes.data : 0;
final imageCache = PaintingBinding.instance.imageCache;
final memBytes = imageCache.currentSizeBytes;
final memCount = imageCache.currentSize;
var tempBytes = 0;
try {
final tempDir = await getTemporaryDirectory();
if (tempDir.existsSync()) {
await for (final file
in tempDir.list(recursive: true, followLinks: false)) {
if (file is File) {
tempBytes += await file.length();
}
}
}
} catch (_) {}
return CacheStats(
cachedPostsCount: postsCount,
imageMemoryBytes: memBytes,
imageMemoryCount: memCount,
tempDirectoryBytes: tempBytes,
downloadedCount: downloadedCount,
);
});
class CacheManagerScreen extends ConsumerStatefulWidget {
const CacheManagerScreen({super.key});
@override
ConsumerState<CacheManagerScreen> createState() => _CacheManagerScreenState();
}
class _CacheManagerScreenState extends ConsumerState<CacheManagerScreen> {
bool _isClearing = false;
String _formatBytes(int bytes) {
if (bytes <= 0) return '0 B';
if (bytes < 1024) return '$bytes B';
if (bytes < 1024 * 1024) {
return '${(bytes / 1024).toStringAsFixed(1)} KB';
}
if (bytes < 1024 * 1024 * 1024) {
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
}
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(2)} GB';
}
Future<void> _clearPostsCache() async {
setState(() => _isClearing = true);
await ref.read(cacheServiceProvider).clear();
ref.invalidate(cacheStatsProvider);
if (mounted) setState(() => _isClearing = false);
}
Future<void> _clearImagesCache() async {
setState(() => _isClearing = true);
PaintingBinding.instance.imageCache.clear();
PaintingBinding.instance.imageCache.clearLiveImages();
ref.invalidate(cacheStatsProvider);
if (mounted) setState(() => _isClearing = false);
}
Future<void> _clearTempFiles() async {
setState(() => _isClearing = true);
try {
final tempDir = await getTemporaryDirectory();
if (tempDir.existsSync()) {
final entities = tempDir.listSync(recursive: false);
for (final entity in entities) {
try {
entity.deleteSync(recursive: true);
} catch (_) {}
}
}
} catch (_) {}
ref.invalidate(cacheStatsProvider);
if (mounted) setState(() => _isClearing = false);
}
Future<void> _clearAllCache() async {
setState(() => _isClearing = true);
await ref.read(cacheServiceProvider).clear();
PaintingBinding.instance.imageCache.clear();
PaintingBinding.instance.imageCache.clearLiveImages();
try {
final tempDir = await getTemporaryDirectory();
if (tempDir.existsSync()) {
final entities = tempDir.listSync(recursive: false);
for (final entity in entities) {
try {
entity.deleteSync(recursive: true);
} catch (_) {}
}
}
} catch (_) {}
ref.invalidate(cacheStatsProvider);
if (mounted) setState(() => _isClearing = false);
}
@override
Widget build(BuildContext context) {
final statsAsync = ref.watch(cacheStatsProvider);
final theme = Theme.of(context);
final strings = ref.watch(appStringsProvider);
return AdaptiveScaffold(
title: strings.ru ? 'Менеджер кэша' : 'Cache Manager',
actions: [
IconButton(
tooltip: strings.ru ? 'Обновить' : 'Refresh',
onPressed: () => ref.invalidate(cacheStatsProvider),
icon: const Icon(Icons.refresh_rounded),
),
],
body: statsAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('Ошибка: $e')),
data: (stats) {
return ListView(
padding: const EdgeInsets.all(16),
children: [
// Summary card
Card(
color: theme.colorScheme.primaryContainer.withValues(alpha: 0.35),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(
color: theme.colorScheme.primary.withValues(alpha: 0.2),
),
),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
children: [
Icon(
Icons.pie_chart_rounded,
size: 48,
color: theme.colorScheme.primary,
),
const SizedBox(height: 12),
Text(
_formatBytes(stats.totalEstimatedBytes),
style: theme.textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.primary,
),
),
const SizedBox(height: 4),
Text(
strings.ru
? 'Общий предполагаемый объем кэша'
: 'Total estimated cache size',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
),
const SizedBox(height: 16),
// Detail cards
_CacheCategoryTile(
icon: Icons.article_rounded,
title: strings.ru ? 'Кэш постов (база данных)' : 'Posts cache (DB)',
subtitle: strings.ru
? '${stats.cachedPostsCount} сохраненных постов'
: '${stats.cachedPostsCount} cached posts',
sizeText: _formatBytes(stats.cachedPostsCount * 2048),
onClear: _isClearing ? null : _clearPostsCache,
),
const SizedBox(height: 8),
_CacheCategoryTile(
icon: Icons.image_rounded,
title: strings.ru ? 'Кэш картинок в памяти' : 'Image memory cache',
subtitle: strings.ru
? '${stats.imageMemoryCount} картинок в ОЗУ'
: '${stats.imageMemoryCount} images in RAM',
sizeText: _formatBytes(stats.imageMemoryBytes),
onClear: _isClearing ? null : _clearImagesCache,
),
const SizedBox(height: 8),
_CacheCategoryTile(
icon: Icons.folder_open_rounded,
title: strings.ru ? 'Временные файлы и медиа' : 'Temp files & media',
subtitle: strings.ru
? 'Файлы для отправки и временные буферы'
: 'Share exports & temporary buffers',
sizeText: _formatBytes(stats.tempDirectoryBytes),
onClear: _isClearing ? null : _clearTempFiles,
),
const SizedBox(height: 8),
_CacheCategoryTile(
icon: Icons.download_done_rounded,
title: strings.ru ? 'Скачанные медиа' : 'Downloaded media',
subtitle: strings.ru
? '${stats.downloadedCount} файлов в истории скачиваний'
: '${stats.downloadedCount} files in download history',
sizeText: '',
showClear: false,
),
const SizedBox(height: 24),
// Global action button
FilledButton.tonalIcon(
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
onPressed: _isClearing ? null : _clearAllCache,
icon: _isClearing
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.delete_sweep_rounded),
label: Text(
strings.ru ? 'Очистить весь кэш' : 'Clear all cache',
style: const TextStyle(fontWeight: FontWeight.w600),
),
),
],
);
},
),
);
}
}
class _CacheCategoryTile extends StatelessWidget {
const _CacheCategoryTile({
required this.icon,
required this.title,
required this.subtitle,
required this.sizeText,
this.onClear,
this.showClear = true,
});
final IconData icon;
final String title;
final String subtitle;
final String sizeText;
final VoidCallback? onClear;
final bool showClear;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Card(
elevation: 0,
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
CircleAvatar(
backgroundColor: theme.colorScheme.primaryContainer,
foregroundColor: theme.colorScheme.onPrimaryContainer,
child: Icon(icon, size: 22),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 2),
Text(
subtitle,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
if (sizeText.isNotEmpty) ...[
Text(
sizeText,
style: theme.textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.primary,
),
),
const SizedBox(width: 8),
],
if (showClear && onClear != null)
IconButton(
tooltip: 'Очистить',
onPressed: onClear,
icon: const Icon(Icons.delete_outline_rounded, size: 20),
),
],
),
),
);
}
}
@@ -6,6 +6,7 @@ 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_version.dart';
import '../../../app/changelog.dart';
import '../../../app/app_strings.dart';
@@ -112,6 +113,99 @@ class _SettingsContent extends ConsumerWidget {
settings.copyWith(autoDownloadFavorites: value),
),
),
if (settings.allowDownloads) ...[
const SizedBox(height: 8),
DropdownButtonFormField<String>(
initialValue: const [
'{Artist}/{ID}',
'{Artist}',
'{Provider}/{Artist}',
'{Service}/{ID}',
'{Date}/{Artist}',
].contains(settings.downloadPathTemplate)
? settings.downloadPathTemplate
: 'custom',
decoration: InputDecoration(
labelText: settings.languageCode == 'ru'
? 'Структура папок скачивания'
: 'Download folder structure',
helperText: '{Artist}, {Provider}, {Service}, {ID}, {Date}',
),
items: [
const DropdownMenuItem(
value: '{Artist}/{ID}',
child: Text('{Artist}/{ID}'),
),
const DropdownMenuItem(
value: '{Artist}',
child: Text('{Artist}'),
),
const DropdownMenuItem(
value: '{Provider}/{Artist}',
child: Text('{Provider}/{Artist}'),
),
const DropdownMenuItem(
value: '{Service}/{ID}',
child: Text('{Service}/{ID}'),
),
const DropdownMenuItem(
value: '{Date}/{Artist}',
child: Text('{Date}/{Artist}'),
),
if (![
'{Artist}/{ID}',
'{Artist}',
'{Provider}/{Artist}',
'{Service}/{ID}',
'{Date}/{Artist}',
].contains(settings.downloadPathTemplate))
DropdownMenuItem(
value: settings.downloadPathTemplate,
child: Text(settings.downloadPathTemplate),
),
const DropdownMenuItem(
value: 'custom',
child: Text('Пользовательский шаблон...'),
),
],
onChanged: (value) async {
if (value == 'custom') {
final ctrl = TextEditingController(
text: settings.downloadPathTemplate);
final custom = await showDialog<String>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Шаблон пути скачивания'),
content: TextField(
controller: ctrl,
decoration: const InputDecoration(
labelText: 'Шаблон',
helperText:
'Доступно: {Artist}, {Provider}, {Service}, {ID}, {Date}',
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Отмена')),
FilledButton(
onPressed: () =>
Navigator.pop(ctx, ctrl.text.trim()),
child: const Text('Сохранить')),
],
),
);
if (custom != null && custom.isNotEmpty) {
_update(ref,
settings.copyWith(downloadPathTemplate: custom));
}
} else if (value != null) {
_update(ref,
settings.copyWith(downloadPathTemplate: value));
}
},
),
],
],
),
_SettingsSection(
@@ -130,6 +224,33 @@ class _SettingsContent extends ConsumerWidget {
settings.copyWith(languageCode: value ?? 'ru'),
),
),
SwitchListTile(
value: settings.amoledMode,
title: Text(settings.languageCode == 'ru'
? 'AMOLED Pure Black тема'
: 'AMOLED Pure Black theme'),
subtitle: Text(settings.languageCode == 'ru'
? 'Абсолютно черный фон (#000000) для OLED дисплеев.'
: 'Pitch-black background (#000000) for OLED displays.'),
onChanged: (value) => _update(
ref,
settings.copyWith(amoledMode: value),
),
),
if (isAndroid)
SwitchListTile(
value: settings.useDynamicColor,
title: Text(settings.languageCode == 'ru'
? 'Динамические цвета Material You'
: 'Material You Dynamic Colors'),
subtitle: Text(settings.languageCode == 'ru'
? 'Палитра интерфейса подстраивается под обои системы.'
: 'Palette adapts to device wallpaper colors.'),
onChanged: (value) => _update(
ref,
settings.copyWith(useDynamicColor: value),
),
),
const SizedBox(height: 12),
_ColorSwatches(
selected: settings.appSeedColor,
@@ -280,6 +401,13 @@ class _SettingsContent extends ConsumerWidget {
icon: const Icon(Icons.cleaning_services_rounded),
label: Text(strings.clearCache),
),
FilledButton.tonalIcon(
onPressed: () => context.go('/settings/cache'),
icon: const Icon(Icons.pie_chart_rounded),
label: Text(settings.languageCode == 'ru'
? 'Менеджер кэша'
: 'Cache Manager'),
),
FilledButton.tonalIcon(
onPressed: () => ref
.read(settingsControllerProvider.notifier)
@@ -369,6 +497,20 @@ class _SettingsContent extends ConsumerWidget {
icon: const Icon(Icons.download_for_offline_rounded),
label: Text(strings.importJson),
),
FilledButton.tonalIcon(
onPressed: () => _exportBackupFile(context, ref),
icon: const Icon(Icons.file_upload_rounded),
label: Text(settings.languageCode == 'ru'
? 'Экспорт бэкапа'
: 'Export backup'),
),
FilledButton.tonalIcon(
onPressed: () => _importBackupFile(context, ref),
icon: const Icon(Icons.file_download_rounded),
label: Text(settings.languageCode == 'ru'
? 'Импорт бэкапа'
: 'Import backup'),
),
],
),
const SizedBox(height: 8),
@@ -413,6 +555,33 @@ class _SettingsContent extends ConsumerWidget {
);
}
Future<void> _exportBackupFile(BuildContext context, WidgetRef ref) async {
final success = await ref.read(backupServiceProvider).exportBackup();
if (!context.mounted) return;
if (success) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Бэкап сохранен / отправлен')),
);
}
}
Future<void> _importBackupFile(BuildContext context, WidgetRef ref) async {
final result = await ref.read(backupServiceProvider).importBackup();
if (!context.mounted) return;
if (result == null) return;
if (result is Success<AppSettings>) {
ref.invalidate(appSettingsProvider);
ref.invalidate(settingsControllerProvider);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Настройки успешно импортированы!')),
);
} else if (result is Error<AppSettings>) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Ошибка импорта: ${result.failure.message}')),
);
}
}
Future<void> _importDialog(BuildContext context, WidgetRef ref) async {
final controller = TextEditingController();
await showDialog<void>(
+71 -7
View File
@@ -54,26 +54,63 @@ class PostCard extends ConsumerStatefulWidget {
ConsumerState<PostCard> createState() => _PostCardState();
}
class _PostCardState extends ConsumerState<PostCard> {
class _PostCardState extends ConsumerState<PostCard>
with SingleTickerProviderStateMixin {
static final Map<String, Post> _resolvedRealbooruPosts = {};
bool _hovered = false;
Post? _resolvedPost;
bool _showHeart = false;
late final AnimationController _heartController;
late final Animation<double> _heartScale;
late final Animation<double> _heartOpacity;
@override
void initState() {
super.initState();
_resolvedPost = _resolvedRealbooruPosts[widget.post.cacheKey];
_maybeResolveRealbooruPost();
_heartController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 600),
);
_heartScale = TweenSequence<double>([
TweenSequenceItem(
tween: Tween(begin: 0.4, end: 1.3)
.chain(CurveTween(curve: Curves.easeOut)),
weight: 40),
TweenSequenceItem(
tween: Tween(begin: 1.3, end: 1.0)
.chain(CurveTween(curve: Curves.easeIn)),
weight: 30),
TweenSequenceItem(
tween: Tween(begin: 1.0, end: 1.0), weight: 30),
]).animate(_heartController);
_heartOpacity = TweenSequence<double>([
TweenSequenceItem(tween: ConstantTween(1.0), weight: 60),
TweenSequenceItem(
tween: Tween(begin: 1.0, end: 0.0)
.chain(CurveTween(curve: Curves.easeIn)),
weight: 40),
]).animate(_heartController);
_heartController.addStatusListener((status) {
if (status == AnimationStatus.completed && mounted) {
setState(() => _showHeart = false);
_heartController.reset();
}
});
}
@override
void didUpdateWidget(covariant PostCard oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.post.cacheKey != widget.post.cacheKey) {
_resolvedPost = _resolvedRealbooruPosts[widget.post.cacheKey];
_maybeResolveRealbooruPost();
}
void dispose() {
_heartController.dispose();
super.dispose();
}
void _triggerDoubleTapFavorite() {
widget.onFavorite();
setState(() => _showHeart = true);
_heartController.forward();
}
@override
@@ -97,6 +134,7 @@ class _PostCardState extends ConsumerState<PostCard> {
}
widget.onOpen();
},
onDoubleTap: _triggerDoubleTapFavorite,
onLongPress: () {
if (widget.selectionMode && widget.onToggleSelected != null) {
widget.onToggleSelected!();
@@ -247,6 +285,32 @@ class _PostCardState extends ConsumerState<PostCard> {
onPressed: widget.onFavorite,
),
),
// Double-tap heart animation overlay
if (_showHeart)
Positioned.fill(
child: Center(
child: AnimatedBuilder(
animation: _heartController,
builder: (context, _) => Opacity(
opacity: _heartOpacity.value,
child: Transform.scale(
scale: _heartScale.value,
child: const Icon(
Icons.favorite_rounded,
color: Colors.white,
size: 72,
shadows: [
Shadow(
color: Colors.black54,
blurRadius: 12,
),
],
),
),
),
),
),
),
Positioned.fill(
child: IgnorePointer(
ignoring: !_hovered || mobile,
+74 -23
View File
@@ -27,6 +27,7 @@ class PostMasonryGrid extends StatelessWidget {
this.downloadedKeys = const {},
this.loading = false,
this.controller,
this.gridMode = 'masonry',
super.key,
});
@@ -49,39 +50,89 @@ class PostMasonryGrid extends StatelessWidget {
final Set<String> downloadedKeys;
final bool loading;
final ScrollController? controller;
final String gridMode;
@override
Widget build(BuildContext context) {
final mobile = Responsive.isMobile(context);
final pad = EdgeInsets.all(mobile ? 8 : 16);
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),
);
}
if (gridMode == 'grid') {
// Uniform square grid
return GridView.builder(
controller: controller,
padding: pad,
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: columns,
mainAxisSpacing: spacing,
crossAxisSpacing: spacing,
childAspectRatio: 1.0,
),
itemCount: posts.length + (loading ? columns : 0),
itemBuilder: (context, index) {
if (index >= posts.length) return const LoadingSkeleton();
return ClipRRect(
borderRadius: BorderRadius.circular(8),
child: SizedBox.expand(
child: FittedBox(
fit: BoxFit.cover,
clipBehavior: Clip.hardEdge,
child: buildCard(posts[index]),
),
),
);
},
);
}
if (gridMode == 'list') {
// Vertical list with larger cards
return ListView.separated(
controller: controller,
padding: pad,
separatorBuilder: (_, __) => SizedBox(height: spacing),
itemCount: posts.length + (loading ? 3 : 0),
itemBuilder: (context, index) {
if (index >= posts.length) return const LoadingSkeleton();
return buildCard(posts[index]);
},
);
}
// Default: masonry
return MasonryGridView.count(
controller: controller,
padding: EdgeInsets.all(mobile ? 8 : 16),
padding: pad,
crossAxisCount: columns,
mainAxisSpacing: mobile ? 8 : 12,
crossAxisSpacing: mobile ? 8 : 12,
mainAxisSpacing: spacing,
crossAxisSpacing: spacing,
itemCount: posts.length + (loading ? columns : 0),
itemBuilder: (context, index) {
if (index >= posts.length) return const LoadingSkeleton();
final post = posts[index];
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 buildCard(posts[index]);
},
);
}
@@ -6,6 +6,7 @@
#include "generated_plugin_registrant.h"
#include <dynamic_color/dynamic_color_plugin.h>
#include <file_selector_linux/file_selector_plugin.h>
#include <isar_flutter_libs/isar_flutter_libs_plugin.h>
#include <media_kit_libs_linux/media_kit_libs_linux_plugin.h>
@@ -14,6 +15,9 @@
#include <volume_controller/volume_controller_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) dynamic_color_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "DynamicColorPlugin");
dynamic_color_plugin_register_with_registrar(dynamic_color_registrar);
g_autoptr(FlPluginRegistrar) file_selector_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin");
file_selector_plugin_register_with_registrar(file_selector_linux_registrar);
+1
View File
@@ -3,6 +3,7 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
dynamic_color
file_selector_linux
isar_flutter_libs
media_kit_libs_linux
+32 -16
View File
@@ -273,6 +273,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.2"
dynamic_color:
dependency: "direct main"
description:
name: dynamic_color
sha256: "4aeb76de323e8eb660bab5557d826ad7ebbf1434a598f0c6aa8a11a9696a6757"
url: "https://pub.dev"
source: hosted
version: "1.9.0"
fake_async:
dependency: transitive
description:
@@ -289,14 +297,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.2.0"
ffi_leak_tracker:
dependency: transitive
description:
name: ffi_leak_tracker
sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97"
url: "https://pub.dev"
source: hosted
version: "0.1.2"
file:
dependency: transitive
description:
@@ -739,18 +739,18 @@ packages:
dependency: transitive
description:
name: package_info_plus
sha256: "4bf625947f6c7713ee242296a682e23e44823c09cf9d79e4f1238923c92db852"
sha256: "468c26b4254ab01979fa5e4a98cb343ea3631b9acee6f21028997419a80e1a20"
url: "https://pub.dev"
source: hosted
version: "10.1.0"
version: "9.0.1"
package_info_plus_platform_interface:
dependency: transitive
description:
name: package_info_plus_platform_interface
sha256: db762cb2f4f25ee60fb6359773861b0f199e00b90d237bd85a76a1e806b46ef4
sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086"
url: "https://pub.dev"
source: hosted
version: "4.1.0"
version: "3.2.1"
path:
dependency: "direct main"
description:
@@ -911,6 +911,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.1"
share_plus:
dependency: "direct main"
description:
name: share_plus
sha256: fce43200aa03ea87b91ce4c3ac79f0cecd52e2a7a56c7a4185023c271fbfa6da
url: "https://pub.dev"
source: hosted
version: "10.1.4"
share_plus_platform_interface:
dependency: transitive
description:
name: share_plus_platform_interface
sha256: cc012a23fc2d479854e6c80150696c4a5f5bb62cb89af4de1c505cf78d0a5d0b
url: "https://pub.dev"
source: hosted
version: "5.0.2"
shelf:
dependency: transitive
description:
@@ -1208,10 +1224,10 @@ packages:
dependency: transitive
description:
name: wakelock_plus
sha256: "824c5bba0f800e86d32e57d3d1843c531f090005cc89d9a837933e6601093d53"
sha256: ddf3db70eaa10c37558ff817519b85d527dbd21034fd5d8e1c2e85f31588f1c1
url: "https://pub.dev"
source: hosted
version: "1.6.1"
version: "1.5.2"
wakelock_plus_platform_interface:
dependency: transitive
description:
@@ -1256,10 +1272,10 @@ packages:
dependency: transitive
description:
name: win32
sha256: ba6f4bba816c8d7e3c1580e170f3786d216951cc6b94babc3b814c08d2cb2738
sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e
url: "https://pub.dev"
source: hosted
version: "6.3.0"
version: "5.15.0"
xdg_directories:
dependency: transitive
description:
+3 -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: 2.0.2+24
version: 2.0.6+28
environment:
sdk: ">=3.3.0 <4.0.0"
@@ -31,6 +31,8 @@ dependencies:
file_selector: ^1.0.3
path: ^1.9.0
media_kit_libs_android_video: ^1.3.8
share_plus: ^10.0.0
dynamic_color: ^1.7.0
dev_dependencies:
flutter_test:
+76
View File
@@ -0,0 +1,76 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:gel_rule_app/backend/models/artist_announcement.dart';
import 'package:gel_rule_app/backend/models/artist_link.dart';
import 'package:gel_rule_app/backend/models/artist_tag.dart';
import 'package:gel_rule_app/backend/models/artist_work_query.dart';
void main() {
group('Pawchive API Features Tests', () {
test('ArtistTag model fromJson and toJson roundtrip', () {
final json = {
'tag': 'genshin_impact',
'post_count': 42,
};
final tag = ArtistTag.fromJson(json);
expect(tag.tag, equals('genshin_impact'));
expect(tag.postCount, equals(42));
expect(tag.toJson(), equals(json));
});
test('ArtistLink model parses all fields correctly', () {
final json = {
'id': '123456',
'service': 'fanbox',
'name': 'CoolArtist',
'public_id': 'cool_pub_id',
'indexed': '2026-09-03T12:00:00.000Z',
'updated': '2026-09-03T14:00:00.000Z',
};
final link = ArtistLink.fromJson(json);
expect(link.id, equals('123456'));
expect(link.service, equals('fanbox'));
expect(link.name, equals('CoolArtist'));
expect(link.publicId, equals('cool_pub_id'));
expect(link.indexed, isNotNull);
expect(link.updated, isNotNull);
});
test('ArtistAnnouncement model parses content and date', () {
final json = {
'service': 'patreon',
'user_id': '778899',
'content': 'Taking a vacation next week!',
'added': '2026-08-01T10:00:00.000Z',
'hash': 'abc123hash',
};
final announcement = ArtistAnnouncement.fromJson(json);
expect(announcement.service, equals('patreon'));
expect(announcement.userId, equals('778899'));
expect(announcement.content, contains('vacation'));
expect(announcement.added, isNotNull);
expect(announcement.hash, equals('abc123hash'));
});
test('ArtistWorkQuery supports queryText and tagFilter', () {
const query = ArtistWorkQuery(
providerId: 'pawchive',
service: 'fanbox',
artistId: '112233',
artistName: 'TestArtist',
queryText: 'mp4',
tagFilter: 'cyberpunk',
);
expect(query.queryText, equals('mp4'));
expect(query.tagFilter, equals('cyberpunk'));
final modified = query.copyWith(queryText: 'gif');
expect(modified.queryText, equals('gif'));
expect(modified.tagFilter, equals('cyberpunk'));
expect(modified == query, isFalse);
});
});
}
+138
View File
@@ -0,0 +1,138 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:gel_rule_app/backend/models/post.dart';
import 'package:gel_rule_app/backend/services/backup_service.dart';
import 'package:gel_rule_app/backend/services/download_service.dart';
import 'package:gel_rule_app/backend/services/settings_service.dart';
import 'package:gel_rule_app/core/utils/result.dart';
void main() {
group('QoL Features Unit Tests', () {
test('AppSettings JSON roundtrip retains new QoL fields', () {
const initial = AppSettings(
enabledProviderIds: ['gelbooru'],
nsfwEnabled: true,
cacheTtlHours: 12,
cacheMaxItems: 500,
providerPriority: {'gelbooru': 0},
themeMode: 'dark',
languageCode: 'ru',
appSeedColor: 0xFF112233,
hiddenTabs: [],
allowExperimentalUpdates: false,
desktopColumns: 4,
mobileColumns: 2,
blurExplicitContent: false,
allowDownloads: true,
autoDownloadFavorites: true,
selectedFeedProviderIds: [],
showPostBadges: true,
defaultTopPeriodFilter: 'none',
blacklistedTags: [],
whitelistedTags: [],
smartBlacklistRules: [],
hideViewedPosts: false,
mediaQualityMode: 'auto',
motionRefreshMode: 'auto',
autoBatterySaver60Hz: false,
videoPlayerMuted: true,
videoPlayerHalfVolume: false,
videoPlayerLoop: false,
videoPlayerCover: false,
videoPlaybackPositions: {},
hiddenPostKeys: [],
diagnosticLogLines: [],
lastFeedTags: [],
lastFeedProviderIds: [],
lastFeedTopPeriod: 'none',
lastFeedScrollOffset: 0,
amoledMode: true,
useDynamicColor: true,
gridMode: 'grid',
searchPresets: ['{"name":"Favs","tags":"cat"}'],
downloadPathTemplate: '{Provider}/{Artist}',
);
final json = initial.toJson();
final restored = AppSettings.fromJson(json);
expect(restored.amoledMode, isTrue);
expect(restored.useDynamicColor, isTrue);
expect(restored.gridMode, equals('grid'));
expect(restored.searchPresets, equals(['{"name":"Favs","tags":"cat"}']));
expect(restored.downloadPathTemplate, equals('{Provider}/{Artist}'));
});
test('DownloadService resolveSubDir replaces template variables correctly', () {
final downloadService = DownloadService();
final post = Post(
id: '12345',
providerId: 'pawchive',
providerName: 'Pawchive',
previewUrl: 'https://example.com/prev.jpg',
sampleUrl: 'https://example.com/sample.jpg',
fileUrl: 'https://example.com/file.jpg',
tags: ['artist:CoolArtist', 'cat', 'safe'],
rating: 'safe',
width: 800,
height: 600,
createdAt: DateTime.now(),
fileType: 'jpg',
score: 42,
tagGroups: {
'artist': ['CoolArtist'],
},
);
final resolved = downloadService.resolveSubDir(post, '{Provider}/{Artist}');
expect(resolved, equals('Pawchive/CoolArtist'));
final singleDir = downloadService.resolveSubDir(post, '{Artist}');
expect(singleDir, equals('CoolArtist'));
final withId = downloadService.resolveSubDir(post, '{Artist}/{ID}');
expect(withId, equals('CoolArtist'));
});
test('BackupService exports and restores settings accurately', () async {
final fakeSettings = _FakeSettingsService();
final backupService = BackupService(fakeSettings);
fakeSettings.current = AppSettings.defaults.copyWith(
amoledMode: true,
useDynamicColor: true,
gridMode: 'list',
downloadPathTemplate: '{Provider}/{ID}',
);
final exportedJson = await backupService.createBackupJson();
expect(exportedJson, contains('version'));
expect(exportedJson, contains('settings'));
// Change settings to something else
fakeSettings.current = AppSettings.defaults.copyWith(
amoledMode: false,
gridMode: 'masonry',
);
// Restore from backup
final restoreResult = await backupService.restoreFromJson(exportedJson);
expect(restoreResult.isSuccess, isTrue);
expect(fakeSettings.current.amoledMode, isTrue);
expect(fakeSettings.current.gridMode, equals('list'));
expect(fakeSettings.current.downloadPathTemplate, equals('{Provider}/{ID}'));
});
});
}
class _FakeSettingsService extends Fake implements SettingsService {
AppSettings current = AppSettings.defaults;
@override
Future<Result<AppSettings>> getSettings() async => Success(current);
@override
Future<Result<void>> updateSettings(AppSettings settings) async {
current = settings;
return const Success(null);
}
}
@@ -7,16 +7,20 @@
#include "generated_plugin_registrant.h"
#include <connectivity_plus/connectivity_plus_windows_plugin.h>
#include <dynamic_color/dynamic_color_plugin_c_api.h>
#include <file_selector_windows/file_selector_windows.h>
#include <isar_flutter_libs/isar_flutter_libs_plugin.h>
#include <media_kit_libs_windows_video/media_kit_libs_windows_video_plugin_c_api.h>
#include <media_kit_video/media_kit_video_plugin_c_api.h>
#include <share_plus/share_plus_windows_plugin_c_api.h>
#include <url_launcher_windows/url_launcher_windows.h>
#include <volume_controller/volume_controller_plugin_c_api.h>
void RegisterPlugins(flutter::PluginRegistry* registry) {
ConnectivityPlusWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin"));
DynamicColorPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("DynamicColorPluginCApi"));
FileSelectorWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FileSelectorWindows"));
IsarFlutterLibsPluginRegisterWithRegistrar(
@@ -25,6 +29,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
registry->GetRegistrarForPlugin("MediaKitLibsWindowsVideoPluginCApi"));
MediaKitVideoPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("MediaKitVideoPluginCApi"));
SharePlusWindowsPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi"));
UrlLauncherWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
VolumeControllerPluginCApiRegisterWithRegistrar(
+2
View File
@@ -4,10 +4,12 @@
list(APPEND FLUTTER_PLUGIN_LIST
connectivity_plus
dynamic_color
file_selector_windows
isar_flutter_libs
media_kit_libs_windows_video
media_kit_video
share_plus
url_launcher_windows
volume_controller
)