From d4e52ff429f9aea93d6dc6fc7a59b87c56003350 Mon Sep 17 00:00:00 2001 From: RarDog Date: Fri, 4 Sep 2026 14:17:09 +0300 Subject: [PATCH] feat: Pawchive account support and favorites synchronization (v2.3.6) --- lib/app/changelog.dart | 11 + lib/backend/backend.dart | 3 + lib/backend/models/favorite_artist_item.dart | 68 ++ lib/backend/models/pawchive_account.dart | 81 ++ .../services/pawchive_sync_service.dart | 369 +++++++++ lib/backend/services/settings_service.dart | 29 + .../presentation/artist_posts_screen.dart | 72 ++ .../artists/presentation/artists_screen.dart | 186 +++-- .../widgets/pawchive_accounts_sheet.dart | 746 ++++++++++++++++++ .../presentation/settings_screen.dart | 44 ++ pubspec.yaml | 2 +- test/backend/pawchive_sync_test.dart | 269 +++++++ 12 files changed, 1819 insertions(+), 61 deletions(-) create mode 100644 lib/backend/models/favorite_artist_item.dart create mode 100644 lib/backend/models/pawchive_account.dart create mode 100644 lib/backend/services/pawchive_sync_service.dart create mode 100644 lib/features/artists/presentation/widgets/pawchive_accounts_sheet.dart create mode 100644 test/backend/pawchive_sync_test.dart diff --git a/lib/app/changelog.dart b/lib/app/changelog.dart index 9157147..3715085 100644 --- a/lib/app/changelog.dart +++ b/lib/app/changelog.dart @@ -1,4 +1,15 @@ const lunarisChangelog = [ + LunarisChange( + version: '2.3.6', + title: 'Pawchive Accounts Integration & Favorites Synchronization', + bullets: [ + 'Pawchive Account Support: users can now log into Pawchive with their username and password or paste a direct session cookie.', + 'Multi-Account Synchronization: add and manage multiple Pawchive accounts; synchronize favorite artists from all connected accounts into a consolidated local favorites list with 1 tap.', + 'Real-Time Remote Favorites Sync: favoriting or unfavoriting any Pawchive artist in the app automatically pushes the change to the active Pawchive account in real time.', + 'Artist Posts Direct Favorite: added a star toggle directly in the artist posts screen top bar to add or remove creators from favorites without leaving their gallery.', + 'Dedicated UI & Quick Access: manage connected accounts, view sync stats, and trigger synchronization from the Artists screen, Favorites modal, and Settings screen.', + ], + ), LunarisChange( version: '2.3.5', title: 'Fullscreen Video Auto-Rotation & Orientation Controls', diff --git a/lib/backend/backend.dart b/lib/backend/backend.dart index 2bbc0cf..62365a9 100644 --- a/lib/backend/backend.dart +++ b/lib/backend/backend.dart @@ -11,6 +11,8 @@ export 'models/cloud_media_link.dart'; export 'models/download_task.dart'; export 'models/downloaded_media.dart'; export 'models/favorite.dart'; +export 'models/favorite_artist_item.dart'; +export 'models/pawchive_account.dart'; export 'models/post.dart'; export 'models/post_comment.dart'; export 'models/provider_diagnostics.dart'; @@ -27,6 +29,7 @@ export 'services/download_manager_service.dart'; export 'services/downloaded_media_service.dart'; export 'services/favorite_service.dart'; export 'services/feed_service.dart'; +export 'services/pawchive_sync_service.dart'; export 'services/provider_check_service.dart'; export 'services/search_service.dart'; export 'services/tag_cache_service.dart'; diff --git a/lib/backend/models/favorite_artist_item.dart b/lib/backend/models/favorite_artist_item.dart new file mode 100644 index 0000000..08c13e3 --- /dev/null +++ b/lib/backend/models/favorite_artist_item.dart @@ -0,0 +1,68 @@ +class FavoriteArtistItem { + const FavoriteArtistItem({ + required this.id, + required this.service, + required this.providerId, + required this.name, + this.avatarUrl, + }); + + const FavoriteArtistItem.empty() + : id = '', + service = '', + providerId = '', + name = '', + avatarUrl = null; + + final String id; + final String service; + final String providerId; + final String name; + final String? avatarUrl; + + bool get isEmpty => id.isEmpty; + String get key => '$providerId:$service:$id'; + + Map toJson() => { + 'id': id, + 'service': service, + 'providerId': providerId, + 'name': name, + 'avatarUrl': avatarUrl, + }; + + factory FavoriteArtistItem.fromJson(Map json) => + FavoriteArtistItem( + id: (json['id'] ?? '').toString(), + service: (json['service'] ?? '').toString(), + providerId: (json['providerId'] ?? '').toString(), + name: (json['name'] ?? '').toString(), + avatarUrl: json['avatarUrl'] as String?, + ); + + FavoriteArtistItem copyWith({ + String? id, + String? service, + String? providerId, + String? name, + String? avatarUrl, + }) { + return FavoriteArtistItem( + id: id ?? this.id, + service: service ?? this.service, + providerId: providerId ?? this.providerId, + name: name ?? this.name, + avatarUrl: avatarUrl ?? this.avatarUrl, + ); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is FavoriteArtistItem && + runtimeType == other.runtimeType && + key == other.key; + + @override + int get hashCode => key.hashCode; +} diff --git a/lib/backend/models/pawchive_account.dart b/lib/backend/models/pawchive_account.dart new file mode 100644 index 0000000..b259118 --- /dev/null +++ b/lib/backend/models/pawchive_account.dart @@ -0,0 +1,81 @@ +class PawchiveAccount { + const PawchiveAccount({ + required this.id, + required this.username, + required this.sessionCookie, + this.baseUrl = 'https://pawchive.pw', + required this.createdAt, + this.lastSyncedAt, + this.isActive = true, + this.syncedArtistsCount = 0, + }); + + final String id; + final String username; + final String sessionCookie; + final String baseUrl; + final DateTime createdAt; + final DateTime? lastSyncedAt; + final bool isActive; + final int syncedArtistsCount; + + PawchiveAccount copyWith({ + String? id, + String? username, + String? sessionCookie, + String? baseUrl, + DateTime? createdAt, + DateTime? lastSyncedAt, + bool? isActive, + int? syncedArtistsCount, + }) { + return PawchiveAccount( + id: id ?? this.id, + username: username ?? this.username, + sessionCookie: sessionCookie ?? this.sessionCookie, + baseUrl: baseUrl ?? this.baseUrl, + createdAt: createdAt ?? this.createdAt, + lastSyncedAt: lastSyncedAt ?? this.lastSyncedAt, + isActive: isActive ?? this.isActive, + syncedArtistsCount: syncedArtistsCount ?? this.syncedArtistsCount, + ); + } + + Map toJson() => { + 'id': id, + 'username': username, + 'sessionCookie': sessionCookie, + 'baseUrl': baseUrl, + 'createdAt': createdAt.toIso8601String(), + 'lastSyncedAt': lastSyncedAt?.toIso8601String(), + 'isActive': isActive, + 'syncedArtistsCount': syncedArtistsCount, + }; + + factory PawchiveAccount.fromJson(Map json) { + return PawchiveAccount( + id: (json['id'] ?? '').toString(), + username: (json['username'] ?? '').toString(), + sessionCookie: (json['sessionCookie'] ?? '').toString(), + baseUrl: (json['baseUrl'] ?? 'https://pawchive.pw').toString(), + createdAt: json['createdAt'] != null + ? DateTime.tryParse(json['createdAt'].toString()) ?? DateTime.now() + : DateTime.now(), + lastSyncedAt: json['lastSyncedAt'] != null + ? DateTime.tryParse(json['lastSyncedAt'].toString()) + : null, + isActive: json['isActive'] as bool? ?? true, + syncedArtistsCount: (json['syncedArtistsCount'] as num?)?.toInt() ?? 0, + ); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is PawchiveAccount && + runtimeType == other.runtimeType && + id == other.id; + + @override + int get hashCode => id.hashCode; +} diff --git a/lib/backend/services/pawchive_sync_service.dart b/lib/backend/services/pawchive_sync_service.dart new file mode 100644 index 0000000..a011cae --- /dev/null +++ b/lib/backend/services/pawchive_sync_service.dart @@ -0,0 +1,369 @@ +import 'dart:convert'; +import 'package:dio/dio.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../core/http/dio_client.dart'; +import '../di/backend_providers.dart'; +import '../models/favorite_artist_item.dart'; +import '../models/pawchive_account.dart'; +import 'settings_service.dart'; + +final pawchiveSyncServiceProvider = Provider((ref) { + return PawchiveSyncService(dio: DioClient().dio); +}); + +class PawchiveSyncResult { + const PawchiveSyncResult({ + required this.updatedSettings, + required this.newlyAddedToLocal, + required this.totalSyncedCount, + this.errorMessage, + }); + + final AppSettings updatedSettings; + final int newlyAddedToLocal; + final int totalSyncedCount; + final String? errorMessage; + + bool get isSuccess => errorMessage == null; +} + +class PawchiveSyncService { + PawchiveSyncService({Dio? dio}) : _dio = dio ?? Dio(); + + final Dio _dio; + + static String _normalizeCookie(String rawCookie) { + var c = rawCookie.trim(); + if (c.startsWith('session=')) { + c = c.substring('session='.length); + } + // strip semicolons if trailing + final semiIdx = c.indexOf(';'); + if (semiIdx != -1) { + c = c.substring(0, semiIdx); + } + return c.trim(); + } + + /// Logs into Pawchive with username & password, returns new PawchiveAccount on success. + Future loginWithCredentials({ + required String username, + required String password, + String baseUrl = 'https://pawchive.pw', + }) async { + final cleanUser = username.trim(); + final cleanPass = password.trim(); + if (cleanUser.isEmpty || cleanPass.isEmpty) { + throw ArgumentError('Логин и пароль не могут быть пустыми'); + } + + final normalizedBase = baseUrl.endsWith('/') + ? baseUrl.substring(0, baseUrl.length - 1) + : baseUrl; + + try { + final response = await _dio.post( + '$normalizedBase/account/login', + data: { + 'username': cleanUser, + 'password': cleanPass, + 'location': '/artists', + }, + options: Options( + contentType: Headers.formUrlEncodedContentType, + followRedirects: false, + validateStatus: (status) => status != null && status < 500, + headers: const { + 'User-Agent': 'Lunaris/2.0.1 Flutter local booru browser', + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + }, + ), + ); + + final redirectLocation = response.headers.value('location') ?? ''; + final setCookies = response.headers['set-cookie'] ?? const []; + + String? sessionValue; + for (final cookie in setCookies) { + final match = RegExp(r'session=([^;]+)').firstMatch(cookie); + if (match != null) { + sessionValue = match.group(1); + break; + } + } + + if (sessionValue == null || sessionValue.isEmpty) { + throw StateError('Не удалось получить сессию. Проверьте данные для входа.'); + } + + // Check if redirect points back to login with flash error + if (redirectLocation.contains('/account/login')) { + throw StateError('Неверный логин или пароль'); + } + + // Verify session and fetch initial artists + final remoteArtists = await fetchRemoteFavoriteArtists( + baseUrl: normalizedBase, + sessionCookie: sessionValue, + ); + + return PawchiveAccount( + id: cleanUser.toLowerCase(), + username: cleanUser, + sessionCookie: sessionValue, + baseUrl: normalizedBase, + createdAt: DateTime.now(), + lastSyncedAt: DateTime.now(), + isActive: true, + syncedArtistsCount: remoteArtists.length, + ); + } catch (e) { + if (e is StateError || e is ArgumentError) rethrow; + throw StateError('Ошибка входа в Pawchive: $e'); + } + } + + /// Validates session cookie directly and creates PawchiveAccount. + Future loginWithSession({ + required String sessionCookie, + required String username, + String baseUrl = 'https://pawchive.pw', + }) async { + final cleanUser = username.trim().isEmpty ? 'Pawchive User' : username.trim(); + final cleanCookie = _normalizeCookie(sessionCookie); + if (cleanCookie.isEmpty) { + throw ArgumentError('Сессионная кука не может быть пустой'); + } + + final normalizedBase = baseUrl.endsWith('/') + ? baseUrl.substring(0, baseUrl.length - 1) + : baseUrl; + + try { + final remoteArtists = await fetchRemoteFavoriteArtists( + baseUrl: normalizedBase, + sessionCookie: cleanCookie, + ); + + return PawchiveAccount( + id: cleanUser.toLowerCase(), + username: cleanUser, + sessionCookie: cleanCookie, + baseUrl: normalizedBase, + createdAt: DateTime.now(), + lastSyncedAt: DateTime.now(), + isActive: true, + syncedArtistsCount: remoteArtists.length, + ); + } catch (e) { + throw StateError('Сессия недействительна или истекла: $e'); + } + } + + /// Fetches favorite creators from Pawchive account. + Future> fetchRemoteFavoriteArtists({ + required String baseUrl, + required String sessionCookie, + }) async { + final cleanCookie = _normalizeCookie(sessionCookie); + final normalizedBase = baseUrl.endsWith('/') + ? baseUrl.substring(0, baseUrl.length - 1) + : baseUrl; + + final response = await _dio.get( + '$normalizedBase/account/favorites', + queryParameters: {'type': 'artist'}, + options: Options( + headers: { + 'Cookie': 'session=$cleanCookie', + 'Accept': 'application/json', + 'User-Agent': 'Lunaris/2.0.1 Flutter local booru browser', + }, + ), + ); + + final data = response.data; + if (data is! List) return const []; + + final list = []; + for (final raw in data) { + if (raw is! Map) continue; + final item = Map.from(raw); + final id = (item['id'] ?? '').toString(); + final service = (item['service'] ?? '').toString(); + final name = (item['name'] ?? id).toString(); + if (id.isEmpty || service.isEmpty) continue; + + final avatarUrl = '$normalizedBase/icons/$service/$id'; + list.add(FavoriteArtistItem( + id: id, + service: service, + providerId: 'pawchive', + name: name, + avatarUrl: avatarUrl, + )); + } + return list; + } + + /// Synchronizes a specific Pawchive account with current local AppSettings. + Future syncAccountFavorites({ + required PawchiveAccount account, + required AppSettings settings, + bool pushLocalPawchiveArtists = false, + }) async { + try { + final remoteArtists = await fetchRemoteFavoriteArtists( + baseUrl: account.baseUrl, + sessionCookie: account.sessionCookie, + ); + + final currentFavorites = List.from(settings.favoriteArtists); + final existingKeys = {}; + for (final raw in currentFavorites) { + try { + final parsed = FavoriteArtistItem.fromJson( + jsonDecode(raw) as Map, + ); + existingKeys.add(parsed.key); + } catch (_) {} + } + + var addedCount = 0; + for (final artist in remoteArtists) { + if (!existingKeys.contains(artist.key)) { + currentFavorites.insert(0, jsonEncode(artist.toJson())); + existingKeys.add(artist.key); + addedCount++; + } + } + + // Optionally push local Pawchive artists to remote + if (pushLocalPawchiveArtists) { + final remoteKeys = remoteArtists.map((a) => a.key).toSet(); + for (final raw in currentFavorites) { + try { + final parsed = FavoriteArtistItem.fromJson( + jsonDecode(raw) as Map, + ); + if (parsed.providerId == 'pawchive' && + !remoteKeys.contains(parsed.key)) { + await toggleRemoteFavorite( + account: account, + service: parsed.service, + artistId: parsed.id, + isFavorite: true, + ); + } + } catch (_) {} + } + } + + // Update account sync stats in settings + final updatedAccount = account.copyWith( + lastSyncedAt: DateTime.now(), + syncedArtistsCount: remoteArtists.length, + ); + + final updatedAccountsList = settings.parsedPawchiveAccounts.map((a) { + return a.id == updatedAccount.id ? updatedAccount : a; + }).toList(); + + final newSettings = settings.copyWith( + favoriteArtists: currentFavorites, + pawchiveAccounts: + updatedAccountsList.map((a) => jsonEncode(a.toJson())).toList(), + ); + + return PawchiveSyncResult( + updatedSettings: newSettings, + newlyAddedToLocal: addedCount, + totalSyncedCount: remoteArtists.length, + ); + } catch (e) { + return PawchiveSyncResult( + updatedSettings: settings, + newlyAddedToLocal: 0, + totalSyncedCount: 0, + errorMessage: e.toString(), + ); + } + } + + /// Synchronizes ALL connected Pawchive accounts with local AppSettings. + Future syncAllAccounts({ + required AppSettings settings, + }) async { + final accounts = settings.parsedPawchiveAccounts; + if (accounts.isEmpty) { + return PawchiveSyncResult( + updatedSettings: settings, + newlyAddedToLocal: 0, + totalSyncedCount: 0, + errorMessage: 'Нет добавленных аккаунтов Pawchive', + ); + } + + var runningSettings = settings; + var totalAdded = 0; + var totalSynced = 0; + String? firstError; + + for (final account in accounts) { + final res = await syncAccountFavorites( + account: account, + settings: runningSettings, + ); + if (res.isSuccess) { + runningSettings = res.updatedSettings; + totalAdded += res.newlyAddedToLocal; + totalSynced += res.totalSyncedCount; + } else { + firstError ??= res.errorMessage; + } + } + + return PawchiveSyncResult( + updatedSettings: runningSettings, + newlyAddedToLocal: totalAdded, + totalSyncedCount: totalSynced, + errorMessage: firstError, + ); + } + + /// Toggles favorite creator on Pawchive server (POST or DELETE). + Future toggleRemoteFavorite({ + required PawchiveAccount account, + required String service, + required String artistId, + required bool isFavorite, + }) async { + final cleanCookie = _normalizeCookie(account.sessionCookie); + final normalizedBase = account.baseUrl.endsWith('/') + ? account.baseUrl.substring(0, account.baseUrl.length - 1) + : account.baseUrl; + + final path = '$normalizedBase/favorites/creator/$service/$artistId'; + final options = Options( + headers: { + 'Cookie': 'session=$cleanCookie', + 'User-Agent': 'Lunaris/2.0.1 Flutter local booru browser', + }, + validateStatus: (status) => + status != null && (status == 200 || status == 201 || status == 204), + ); + + try { + if (isFavorite) { + final response = await _dio.post(path, options: options); + return response.statusCode != null && response.statusCode! < 300; + } else { + final response = await _dio.delete(path, options: options); + return response.statusCode != null && response.statusCode! < 300; + } + } catch (_) { + return false; + } + } +} diff --git a/lib/backend/services/settings_service.dart b/lib/backend/services/settings_service.dart index d3f31ee..0a2e2e8 100644 --- a/lib/backend/services/settings_service.dart +++ b/lib/backend/services/settings_service.dart @@ -7,6 +7,7 @@ import '../../core/database/database_service.dart'; import '../../core/errors/failure.dart'; import '../../core/utils/result.dart'; import '../models/content_provider_config.dart'; +import '../models/pawchive_account.dart'; import '../models/provider_diagnostics.dart'; import '../repositories/provider_repository.dart'; @@ -49,6 +50,7 @@ class AppSettings { required this.lastFeedTopPeriod, required this.lastFeedScrollOffset, this.favoriteArtists = const [], + this.pawchiveAccounts = const [], this.skippedUpdateVersion, this.lastUpdateCheckAt, this.defaultRatingFilter, @@ -63,6 +65,7 @@ class AppSettings { }); final List favoriteArtists; + final List pawchiveAccounts; final List enabledProviderIds; final bool nsfwEnabled; final int cacheTtlHours; @@ -118,6 +121,7 @@ class AppSettings { cacheMaxItems: 2000, providerPriority: {'gelbooru': 0, 'rule34': 1, 'realbooru': 2}, favoriteArtists: [], + pawchiveAccounts: [], themeMode: 'dark', languageCode: 'ru', appSeedColor: 0xFFE84D8A, @@ -200,6 +204,7 @@ class AppSettings { String? lastFeedRating, bool clearLastFeedRating = false, List? favoriteArtists, + List? pawchiveAccounts, bool? amoledMode, bool? useDynamicColor, String? gridMode, @@ -210,6 +215,7 @@ class AppSettings { }) { return AppSettings( favoriteArtists: favoriteArtists ?? this.favoriteArtists, + pawchiveAccounts: pawchiveAccounts ?? this.pawchiveAccounts, enabledProviderIds: enabledProviderIds ?? this.enabledProviderIds, nsfwEnabled: nsfwEnabled ?? this.nsfwEnabled, cacheTtlHours: cacheTtlHours ?? this.cacheTtlHours, @@ -269,6 +275,7 @@ class AppSettings { Map toJson() => { 'favoriteArtists': favoriteArtists, + 'pawchiveAccounts': pawchiveAccounts, 'enabledProviderIds': enabledProviderIds, 'nsfwEnabled': nsfwEnabled, 'cacheTtlHours': cacheTtlHours, @@ -411,6 +418,9 @@ class AppSettings { favoriteArtists: List.from( (json['favoriteArtists'] as List?) ?? defaults.favoriteArtists, ), + pawchiveAccounts: List.from( + (json['pawchiveAccounts'] as List?) ?? defaults.pawchiveAccounts, + ), skippedUpdateVersion: json['skippedUpdateVersion'] as String?, lastUpdateCheckAt: json['lastUpdateCheckAt'] as String?, defaultRatingFilter: json['defaultRatingFilter'] as String?, @@ -428,6 +438,25 @@ class AppSettings { tagCacheLimit: (json['tagCacheLimit'] as num?)?.toInt() ?? defaults.tagCacheLimit, ); + + List get parsedPawchiveAccounts { + final list = []; + for (final raw in pawchiveAccounts) { + try { + final decoded = jsonDecode(raw); + if (decoded is Map) { + list.add(PawchiveAccount.fromJson(decoded)); + } + } catch (_) {} + } + return list; + } + + PawchiveAccount? get activePawchiveAccount { + final accounts = parsedPawchiveAccounts; + if (accounts.isEmpty) return null; + return accounts.firstWhere((a) => a.isActive, orElse: () => accounts.first); + } } class SettingsService { diff --git a/lib/features/artists/presentation/artist_posts_screen.dart b/lib/features/artists/presentation/artist_posts_screen.dart index 590e725..9b1b160 100644 --- a/lib/features/artists/presentation/artist_posts_screen.dart +++ b/lib/features/artists/presentation/artist_posts_screen.dart @@ -1,3 +1,5 @@ +import 'dart:async'; +import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; @@ -12,6 +14,7 @@ import '../../../shared/widgets/empty_view.dart'; import '../../../shared/widgets/error_view.dart'; import '../../../shared/widgets/post_masonry_grid.dart'; import '../../favorites/presentation/favorites_controller.dart'; +import '../../settings/presentation/settings_controller.dart'; final artistPostsProvider = FutureProvider.family, ArtistWorkQuery>((ref, query) async { @@ -401,6 +404,54 @@ class _ArtistPostsScreenState extends ConsumerState { ); } + Future _toggleArtistFavorite( + AppSettings settings, + bool isCurrentlyFav, + ) async { + final current = List.from(settings.favoriteArtists); + final artistKey = + '${widget.providerId}:${widget.service}:${widget.artistId}'; + if (isCurrentlyFav) { + current.removeWhere((e) { + try { + return FavoriteArtistItem.fromJson( + jsonDecode(e) as Map) + .key == + artistKey; + } catch (_) { + return false; + } + }); + } else { + final avatarUrl = widget.providerId == 'pawchive' + ? 'https://pawchive.pw/icons/${widget.service}/${widget.artistId}' + : null; + final item = FavoriteArtistItem( + id: widget.artistId, + service: widget.service, + providerId: widget.providerId, + name: widget.artistName, + avatarUrl: avatarUrl, + ); + current.insert(0, jsonEncode(item.toJson())); + } + await ref.read(settingsControllerProvider.notifier).saveSettings( + settings.copyWith(favoriteArtists: current), + ); + + if (widget.providerId == 'pawchive') { + final activeAcc = settings.activePawchiveAccount; + if (activeAcc != null) { + unawaited(ref.read(pawchiveSyncServiceProvider).toggleRemoteFavorite( + account: activeAcc, + service: widget.service, + artistId: widget.artistId, + isFavorite: !isCurrentlyFav, + )); + } + } + } + @override Widget build(BuildContext context) { final settings = @@ -408,12 +459,33 @@ class _ArtistPostsScreenState extends ConsumerState { final favoriteKeys = ref.watch(favoriteKeysProvider).value ?? {}; final viewedKeys = ref.watch(viewedKeysProvider).value ?? {}; + final artistKey = + '${widget.providerId}:${widget.service}:${widget.artistId}'; + final isArtistFavorite = settings.favoriteArtists.any((e) { + try { + return FavoriteArtistItem.fromJson( + jsonDecode(e) as Map) + .key == + artistKey; + } catch (_) { + return false; + } + }); + final displayedPosts = _posts.where(_matchesTypeFilter).toList(growable: false); return AdaptiveScaffold( title: widget.artistName, actions: [ + IconButton( + tooltip: isArtistFavorite ? 'В избранном' : 'В избранное', + icon: Icon( + isArtistFavorite ? Icons.star_rounded : Icons.star_outline_rounded, + color: isArtistFavorite ? Colors.amber : null, + ), + onPressed: () => _toggleArtistFavorite(settings, isArtistFavorite), + ), if (_announcements.isNotEmpty) IconButton( tooltip: 'Анонсы автора', diff --git a/lib/features/artists/presentation/artists_screen.dart b/lib/features/artists/presentation/artists_screen.dart index 323694f..1848c37 100644 --- a/lib/features/artists/presentation/artists_screen.dart +++ b/lib/features/artists/presentation/artists_screen.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; @@ -13,6 +14,7 @@ import '../../../shared/widgets/empty_view.dart'; import '../../../shared/widgets/error_view.dart'; import '../../settings/presentation/settings_controller.dart'; import 'artist_posts_screen.dart'; +import 'widgets/pawchive_accounts_sheet.dart'; final artistProviderConfigsProvider = FutureProvider>((ref) async { @@ -49,7 +51,7 @@ class _ArtistsScreenState extends ConsumerState { ArtistProfile artist, ) async { final current = List.from(settings.favoriteArtists); - final item = _FavoriteArtistItem( + final item = FavoriteArtistItem( id: artist.id, service: artist.service, providerId: artist.providerId, @@ -60,13 +62,14 @@ class _ArtistsScreenState extends ConsumerState { final index = current.indexWhere((e) { try { final parsed = - _FavoriteArtistItem.fromJson(jsonDecode(e) as Map); + FavoriteArtistItem.fromJson(jsonDecode(e) as Map); return parsed.key == key; } catch (_) { return false; } }); - if (index >= 0) { + final isAdding = index < 0; + if (!isAdding) { current.removeAt(index); } else { current.insert(0, jsonEncode(item.toJson())); @@ -74,6 +77,19 @@ class _ArtistsScreenState extends ConsumerState { await ref.read(settingsControllerProvider.notifier).saveSettings( settings.copyWith(favoriteArtists: current), ); + + // Sync with active Pawchive account if artist belongs to Pawchive + if (artist.providerId == 'pawchive') { + final activeAcc = settings.activePawchiveAccount; + if (activeAcc != null) { + unawaited(ref.read(pawchiveSyncServiceProvider).toggleRemoteFavorite( + account: activeAcc, + service: artist.service, + artistId: artist.id, + isFavorite: isAdding, + )); + } + } } @override @@ -99,19 +115,29 @@ class _ArtistsScreenState extends ConsumerState { final favoriteItems = settings.favoriteArtists .map((e) { try { - return _FavoriteArtistItem.fromJson( + return FavoriteArtistItem.fromJson( jsonDecode(e) as Map); } catch (_) { return null; } }) - .whereType<_FavoriteArtistItem>() + .whereType() .toList(); final favoriteKeys = favoriteItems.map((e) => e.key).toSet(); return AdaptiveScaffold( title: 'Artists', actions: [ + IconButton( + tooltip: 'Синхронизация Pawchive', + icon: Badge( + isLabelVisible: settings.parsedPawchiveAccounts.isNotEmpty, + backgroundColor: Colors.green, + smallSize: 8, + child: const Icon(Icons.cloud_sync_rounded), + ), + onPressed: () => PawchiveAccountsSheet.show(context), + ), IconButton( tooltip: 'Любимые авторы', icon: Badge( @@ -318,7 +344,7 @@ class _ArtistsScreenState extends ConsumerState { void _showFavoriteArtistsModal( BuildContext context, AppSettings settings, - List<_FavoriteArtistItem> favorites, + List favorites, ) { showModalBottomSheet( context: context, @@ -341,7 +367,7 @@ class _ArtistsScreenState extends ConsumerState { final updated = List.from(settings.favoriteArtists) ..removeWhere((e) { try { - return _FavoriteArtistItem.fromJson( + return FavoriteArtistItem.fromJson( jsonDecode(e) as Map) .key == fav.key; @@ -352,6 +378,18 @@ class _ArtistsScreenState extends ConsumerState { await ref .read(settingsControllerProvider.notifier) .saveSettings(settings.copyWith(favoriteArtists: updated)); + + if (fav.providerId == 'pawchive') { + final activeAcc = settings.activePawchiveAccount; + if (activeAcc != null) { + unawaited(ref.read(pawchiveSyncServiceProvider).toggleRemoteFavorite( + account: activeAcc, + service: fav.service, + artistId: fav.id, + isFavorite: false, + )); + } + } setState(() {}); }, ); @@ -570,49 +608,6 @@ class _ArtistCard extends StatelessWidget { } } -class _FavoriteArtistItem { - const _FavoriteArtistItem({ - required this.id, - required this.service, - required this.providerId, - required this.name, - this.avatarUrl, - }); - - const _FavoriteArtistItem.empty() - : id = '', - service = '', - providerId = '', - name = '', - avatarUrl = null; - - final String id; - final String service; - final String providerId; - final String name; - final String? avatarUrl; - - bool get isEmpty => id.isEmpty; - String get key => '$providerId:$service:$id'; - - Map toJson() => { - 'id': id, - 'service': service, - 'providerId': providerId, - 'name': name, - 'avatarUrl': avatarUrl, - }; - - factory _FavoriteArtistItem.fromJson(Map json) => - _FavoriteArtistItem( - id: (json['id'] ?? '').toString(), - service: (json['service'] ?? '').toString(), - providerId: (json['providerId'] ?? '').toString(), - name: (json['name'] ?? '').toString(), - avatarUrl: json['avatarUrl'] as String?, - ); -} - class _FavoriteArtistsModal extends StatefulWidget { const _FavoriteArtistsModal({ required this.favorites, @@ -622,11 +617,11 @@ class _FavoriteArtistsModal extends StatefulWidget { required this.onRemoveFavorite, }); - final List<_FavoriteArtistItem> favorites; + final List favorites; final String? initialSelectedKey; - final ValueChanged<_FavoriteArtistItem> onSelect; - final ValueChanged<_FavoriteArtistItem> onOpenArtist; - final ValueChanged<_FavoriteArtistItem> onRemoveFavorite; + final ValueChanged onSelect; + final ValueChanged onOpenArtist; + final ValueChanged onRemoveFavorite; @override State<_FavoriteArtistsModal> createState() => _FavoriteArtistsModalState(); @@ -648,7 +643,7 @@ class _FavoriteArtistsModalState extends State<_FavoriteArtistsModal> { (e) => e.key == _selectedKey, orElse: () => widget.favorites.isNotEmpty ? widget.favorites.first - : const _FavoriteArtistItem.empty(), + : const FavoriteArtistItem.empty(), ); return DraggableScrollableSheet( @@ -716,6 +711,77 @@ class _FavoriteArtistsModalState extends State<_FavoriteArtistsModal> { ), ), const Divider(height: 1), + Consumer( + builder: (context, ref, _) { + final settings = + ref.watch(appSettingsProvider).value ?? AppSettings.defaults; + final activeAcc = settings.activePawchiveAccount; + final accounts = settings.parsedPawchiveAccounts; + + return Container( + margin: const EdgeInsets.fromLTRB(16, 8, 16, 6), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: scheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: activeAcc != null + ? scheme.primary.withValues(alpha: 0.35) + : scheme.outlineVariant.withValues(alpha: 0.25), + ), + ), + child: Row( + children: [ + Icon( + Icons.cloud_sync_rounded, + size: 20, + color: activeAcc != null + ? scheme.primary + : scheme.onSurfaceVariant, + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + activeAcc != null + ? 'Pawchive: @${activeAcc.username}' + : 'Pawchive не подключён', + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 12, + ), + ), + Text( + activeAcc != null + ? '${activeAcc.syncedArtistsCount} авторов в облаке' + : 'Синхронизируйте избранное с сервером', + style: TextStyle( + color: scheme.onSurfaceVariant, + fontSize: 11, + ), + ), + ], + ), + ), + FilledButton.tonal( + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 4, + ), + visualDensity: VisualDensity.compact, + ), + onPressed: () => PawchiveAccountsSheet.show(context), + child: Text(accounts.isEmpty ? 'Войти' : 'Аккаунты'), + ), + ], + ), + ); + }, + ), if (widget.favorites.isEmpty) Padding( padding: const EdgeInsets.fromLTRB(24, 36, 24, 40), @@ -792,11 +858,11 @@ class _FavoriteArtistsSection extends ConsumerWidget { required this.onRemoveFavorite, }); - final List<_FavoriteArtistItem> favorites; + final List favorites; final String? selectedKey; - final ValueChanged<_FavoriteArtistItem> onSelect; - final ValueChanged<_FavoriteArtistItem> onOpenArtist; - final ValueChanged<_FavoriteArtistItem> onRemoveFavorite; + final ValueChanged onSelect; + final ValueChanged onOpenArtist; + final ValueChanged onRemoveFavorite; @override Widget build(BuildContext context, WidgetRef ref) { @@ -986,7 +1052,7 @@ class _FavoriteArtistsSection extends ConsumerWidget { class _FavoriteArtistMediaStrip extends ConsumerWidget { const _FavoriteArtistMediaStrip({required this.artist}); - final _FavoriteArtistItem artist; + final FavoriteArtistItem artist; @override Widget build(BuildContext context, WidgetRef ref) { diff --git a/lib/features/artists/presentation/widgets/pawchive_accounts_sheet.dart b/lib/features/artists/presentation/widgets/pawchive_accounts_sheet.dart new file mode 100644 index 0000000..2520f8b --- /dev/null +++ b/lib/features/artists/presentation/widgets/pawchive_accounts_sheet.dart @@ -0,0 +1,746 @@ +import 'dart:convert'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../../app/app.dart'; +import '../../../../backend/backend.dart'; +import '../../../settings/presentation/settings_controller.dart'; + +class PawchiveAccountsSheet extends ConsumerStatefulWidget { + const PawchiveAccountsSheet({super.key}); + + static Future show(BuildContext context) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (_) => const PawchiveAccountsSheet(), + ); + } + + @override + ConsumerState createState() => + _PawchiveAccountsSheetState(); +} + +class _PawchiveAccountsSheetState extends ConsumerState + with SingleTickerProviderStateMixin { + late final TabController _addTabController; + final _usernameController = TextEditingController(); + final _passwordController = TextEditingController(); + final _cookieController = TextEditingController(); + + bool _isAdding = false; + bool _obscurePassword = true; + String? _syncingAccountId; + bool _syncingAll = false; + String? _errorMessage; + + @override + void initState() { + super.initState(); + _addTabController = TabController(length: 2, vsync: this); + } + + @override + void dispose() { + _addTabController.dispose(); + _usernameController.dispose(); + _passwordController.dispose(); + _cookieController.dispose(); + super.dispose(); + } + + Future _handleLoginCredentials() async { + final username = _usernameController.text.trim(); + final password = _passwordController.text.trim(); + if (username.isEmpty || password.isEmpty) { + setState(() => _errorMessage = 'Введите логин и пароль'); + return; + } + + setState(() { + _isAdding = true; + _errorMessage = null; + }); + + try { + final syncService = ref.read(pawchiveSyncServiceProvider); + final account = await syncService.loginWithCredentials( + username: username, + password: password, + ); + + final settings = + ref.read(appSettingsProvider).value ?? AppSettings.defaults; + final existingAccounts = settings.parsedPawchiveAccounts.where((a) => a.id != account.id).toList(); + // Set as active + final updatedList = [ + account.copyWith(isActive: true), + ...existingAccounts.map((a) => a.copyWith(isActive: false)), + ]; + + var newSettings = settings.copyWith( + pawchiveAccounts: + updatedList.map((a) => jsonEncode(a.toJson())).toList(), + ); + + // Perform initial sync + final syncResult = await syncService.syncAccountFavorites( + account: account, + settings: newSettings, + ); + if (syncResult.isSuccess) { + newSettings = syncResult.updatedSettings; + } + + await ref + .read(settingsControllerProvider.notifier) + .saveSettings(newSettings); + + _usernameController.clear(); + _passwordController.clear(); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Аккаунт @${account.username} добавлен! Синхронизировано ${account.syncedArtistsCount} авторов.', + ), + backgroundColor: Colors.green.shade700, + ), + ); + } + } catch (e) { + if (mounted) { + setState(() => _errorMessage = e.toString().replaceFirst('Exception: ', '').replaceFirst('StateError: ', '')); + } + } finally { + if (mounted) { + setState(() => _isAdding = false); + } + } + } + + Future _handleLoginCookie() async { + final cookie = _cookieController.text.trim(); + final username = _usernameController.text.trim(); + if (cookie.isEmpty) { + setState(() => _errorMessage = 'Введите сессионную куку'); + return; + } + + setState(() { + _isAdding = true; + _errorMessage = null; + }); + + try { + final syncService = ref.read(pawchiveSyncServiceProvider); + final account = await syncService.loginWithSession( + sessionCookie: cookie, + username: username.isEmpty ? 'Pawchive User' : username, + ); + + final settings = + ref.read(appSettingsProvider).value ?? AppSettings.defaults; + final existingAccounts = settings.parsedPawchiveAccounts.where((a) => a.id != account.id).toList(); + final updatedList = [ + account.copyWith(isActive: true), + ...existingAccounts.map((a) => a.copyWith(isActive: false)), + ]; + + var newSettings = settings.copyWith( + pawchiveAccounts: + updatedList.map((a) => jsonEncode(a.toJson())).toList(), + ); + + final syncResult = await syncService.syncAccountFavorites( + account: account, + settings: newSettings, + ); + if (syncResult.isSuccess) { + newSettings = syncResult.updatedSettings; + } + + await ref + .read(settingsControllerProvider.notifier) + .saveSettings(newSettings); + + _cookieController.clear(); + _usernameController.clear(); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Кука сохранена! Синхронизировано ${account.syncedArtistsCount} авторов.', + ), + backgroundColor: Colors.green.shade700, + ), + ); + } + } catch (e) { + if (mounted) { + setState(() => _errorMessage = e.toString().replaceFirst('Exception: ', '').replaceFirst('StateError: ', '')); + } + } finally { + if (mounted) { + setState(() => _isAdding = false); + } + } + } + + Future _syncSingleAccount(PawchiveAccount account) async { + setState(() => _syncingAccountId = account.id); + try { + final syncService = ref.read(pawchiveSyncServiceProvider); + final settings = + ref.read(appSettingsProvider).value ?? AppSettings.defaults; + final result = await syncService.syncAccountFavorites( + account: account, + settings: settings, + ); + + if (result.isSuccess) { + await ref + .read(settingsControllerProvider.notifier) + .saveSettings(result.updatedSettings); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Синхронизировано: ${result.totalSyncedCount} авторов (+${result.newlyAddedToLocal} новых)', + ), + ), + ); + } + } else { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Ошибка синхронизации: ${result.errorMessage}'), + backgroundColor: Colors.red.shade700, + ), + ); + } + } + } finally { + if (mounted) { + setState(() => _syncingAccountId = null); + } + } + } + + Future _syncAllAccounts() async { + setState(() => _syncingAll = true); + try { + final syncService = ref.read(pawchiveSyncServiceProvider); + final settings = + ref.read(appSettingsProvider).value ?? AppSettings.defaults; + final result = await syncService.syncAllAccounts(settings: settings); + + if (result.isSuccess) { + await ref + .read(settingsControllerProvider.notifier) + .saveSettings(result.updatedSettings); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Все аккаунты синхронизированы! Всего: ${result.totalSyncedCount} (+${result.newlyAddedToLocal} новых)', + ), + backgroundColor: Colors.green.shade700, + ), + ); + } + } else { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Ошибка синхронизации: ${result.errorMessage}'), + backgroundColor: Colors.red.shade700, + ), + ); + } + } + } finally { + if (mounted) { + setState(() => _syncingAll = false); + } + } + } + + Future _setActiveAccount(PawchiveAccount target) async { + final settings = + ref.read(appSettingsProvider).value ?? AppSettings.defaults; + final updatedList = settings.parsedPawchiveAccounts.map((a) { + return a.copyWith(isActive: a.id == target.id); + }).toList(); + + await ref.read(settingsControllerProvider.notifier).saveSettings( + settings.copyWith( + pawchiveAccounts: + updatedList.map((a) => jsonEncode(a.toJson())).toList(), + ), + ); + } + + Future _removeAccount(PawchiveAccount target) async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Удалить аккаунт Pawchive?'), + content: Text( + 'Вы уверены, что хотите удалить аккаунт @${target.username}? Локальные авторы останутся в приложении.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Отмена'), + ), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + style: FilledButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.error, + ), + child: const Text('Удалить'), + ), + ], + ), + ); + + if (confirmed != true) return; + + final settings = + ref.read(appSettingsProvider).value ?? AppSettings.defaults; + final filtered = + settings.parsedPawchiveAccounts.where((a) => a.id != target.id).toList(); + + // If active was removed, make first remaining active + if (filtered.isNotEmpty && !filtered.any((a) => a.isActive)) { + filtered[0] = filtered[0].copyWith(isActive: true); + } + + await ref.read(settingsControllerProvider.notifier).saveSettings( + settings.copyWith( + pawchiveAccounts: + filtered.map((a) => jsonEncode(a.toJson())).toList(), + ), + ); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + final settings = + ref.watch(appSettingsProvider).value ?? AppSettings.defaults; + final accounts = settings.parsedPawchiveAccounts; + + final keyboardPadding = MediaQuery.of(context).viewInsets.bottom; + + return DraggableScrollableSheet( + initialChildSize: accounts.isEmpty ? 0.8 : 0.75, + minChildSize: 0.4, + maxChildSize: 0.95, + builder: (context, scrollController) { + return Container( + decoration: BoxDecoration( + color: scheme.surface, + borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.35), + blurRadius: 18, + offset: const Offset(0, -2), + ), + ], + ), + child: Column( + children: [ + // Drag handle + Center( + child: Container( + margin: const EdgeInsets.only(top: 10, bottom: 6), + width: 44, + height: 5, + decoration: BoxDecoration( + color: scheme.onSurfaceVariant.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(10), + ), + ), + ), + // Header + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: scheme.primaryContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + Icons.cloud_sync_rounded, + color: scheme.onPrimaryContainer, + size: 22, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Аккаунты Pawchive', + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + Text( + 'Синхронизация авторов и избранного', + style: theme.textTheme.bodySmall?.copyWith( + color: scheme.onSurfaceVariant, + ), + ), + ], + ), + ), + if (accounts.isNotEmpty) + FilledButton.tonalIcon( + onPressed: _syncingAll ? null : _syncAllAccounts, + icon: _syncingAll + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.sync_rounded, size: 18), + label: const Text('Синхронизировать'), + ), + ], + ), + ), + const Divider(height: 1), + + // Body + Expanded( + child: ListView( + controller: scrollController, + padding: EdgeInsets.fromLTRB(16, 12, 16, 16 + keyboardPadding), + children: [ + // Connected accounts section + if (accounts.isNotEmpty) ...[ + Padding( + padding: const EdgeInsets.only(bottom: 8, left: 4), + child: Text( + 'ПОДКЛЮЧЁННЫЕ АККАУНТЫ (${accounts.length})', + style: theme.textTheme.labelSmall?.copyWith( + color: scheme.primary, + fontWeight: FontWeight.bold, + letterSpacing: 0.8, + ), + ), + ), + ...accounts.map((acc) => _buildAccountCard(acc, scheme, theme)), + const SizedBox(height: 16), + ], + + // Add account section + Padding( + padding: const EdgeInsets.only(bottom: 8, left: 4), + child: Text( + accounts.isEmpty ? 'ДОБАВИТЬ АККАУНТ' : 'ДОБАВИТЬ ЕЩЁ АККАУНТ', + style: theme.textTheme.labelSmall?.copyWith( + color: scheme.primary, + fontWeight: FontWeight.bold, + letterSpacing: 0.8, + ), + ), + ), + Card( + elevation: 0, + color: scheme.surfaceContainerHigh, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: BorderSide( + color: scheme.outlineVariant.withValues(alpha: 0.4), + ), + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TabBar( + controller: _addTabController, + tabs: const [ + Tab(text: 'Логин и пароль'), + Tab(text: 'Session Cookie'), + ], + ), + const SizedBox(height: 16), + if (_errorMessage != null) ...[ + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: scheme.errorContainer, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + Icon( + Icons.error_outline_rounded, + size: 18, + color: scheme.onErrorContainer, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + _errorMessage!, + style: TextStyle( + color: scheme.onErrorContainer, + fontSize: 13, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 12), + ], + SizedBox( + height: 190, + child: TabBarView( + controller: _addTabController, + children: [ + // Tab 1: Username & Password + Column( + children: [ + TextField( + controller: _usernameController, + decoration: const InputDecoration( + labelText: 'Имя пользователя', + prefixIcon: Icon(Icons.person_outline_rounded), + isDense: true, + ), + ), + const SizedBox(height: 12), + TextField( + controller: _passwordController, + obscureText: _obscurePassword, + decoration: InputDecoration( + labelText: 'Пароль', + prefixIcon: const Icon(Icons.lock_outline_rounded), + isDense: true, + suffixIcon: IconButton( + icon: Icon( + _obscurePassword + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + ), + onPressed: () => setState( + () => _obscurePassword = !_obscurePassword, + ), + ), + ), + ), + const Spacer(), + FilledButton.icon( + onPressed: _isAdding ? null : _handleLoginCredentials, + icon: _isAdding + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Icon(Icons.login_rounded), + label: const Text('Войти и синхронизировать'), + ), + ], + ), + // Tab 2: Session Cookie + Column( + children: [ + TextField( + controller: _usernameController, + decoration: const InputDecoration( + labelText: 'Никнейм (необязательно)', + prefixIcon: Icon(Icons.badge_outlined), + isDense: true, + ), + ), + const SizedBox(height: 12), + TextField( + controller: _cookieController, + maxLines: 2, + decoration: const InputDecoration( + labelText: 'Сессионная кука', + hintText: 'session=ey... или значение куки', + prefixIcon: Icon(Icons.cookie_outlined), + isDense: true, + ), + ), + const Spacer(), + FilledButton.icon( + onPressed: _isAdding ? null : _handleLoginCookie, + icon: _isAdding + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Icon(Icons.check_circle_outline_rounded), + label: const Text('Сохранить куку'), + ), + ], + ), + ], + ), + ), + ], + ), + ), + ), + ], + ), + ), + ], + ), + ); + }, + ); + } + + Widget _buildAccountCard(PawchiveAccount acc, ColorScheme scheme, ThemeData theme) { + final isSyncing = _syncingAccountId == acc.id; + + return Card( + margin: const EdgeInsets.only(bottom: 8), + elevation: 0, + color: acc.isActive + ? scheme.primaryContainer.withValues(alpha: 0.35) + : scheme.surfaceContainerHigh, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: BorderSide( + color: acc.isActive + ? scheme.primary.withValues(alpha: 0.6) + : scheme.outlineVariant.withValues(alpha: 0.3), + width: acc.isActive ? 1.5 : 1, + ), + ), + child: ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + leading: CircleAvatar( + backgroundColor: acc.isActive ? scheme.primary : scheme.surfaceContainerHighest, + foregroundColor: acc.isActive ? scheme.onPrimary : scheme.onSurfaceVariant, + child: Text( + acc.username.isNotEmpty ? acc.username[0].toUpperCase() : 'P', + style: const TextStyle(fontWeight: FontWeight.bold), + ), + ), + title: Row( + children: [ + Expanded( + child: Text( + acc.username, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + ), + if (acc.isActive) + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: scheme.primary, + borderRadius: BorderRadius.circular(10), + ), + child: Text( + 'Активен', + style: TextStyle( + color: scheme.onPrimary, + fontSize: 11, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 2), + Text( + 'Авторов: ${acc.syncedArtistsCount} • ${acc.lastSyncedAt != null ? _formatDate(acc.lastSyncedAt!) : "не синхр."}', + style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12), + ), + ], + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: isSyncing + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.sync_rounded), + tooltip: 'Синхронизировать этот аккаунт', + onPressed: isSyncing ? null : () => _syncSingleAccount(acc), + ), + PopupMenuButton( + icon: const Icon(Icons.more_vert_rounded), + onSelected: (val) { + if (val == 'active') { + _setActiveAccount(acc); + } else if (val == 'delete') { + _removeAccount(acc); + } + }, + itemBuilder: (ctx) => [ + if (!acc.isActive) + const PopupMenuItem( + value: 'active', + child: Row( + children: [ + Icon(Icons.check_circle_outline_rounded, size: 20), + SizedBox(width: 8), + Text('Сделать активным'), + ], + ), + ), + const PopupMenuItem( + value: 'delete', + child: Row( + children: [ + Icon(Icons.delete_outline_rounded, color: Colors.red, size: 20), + SizedBox(width: 8), + Text('Удалить аккаунт', style: TextStyle(color: Colors.red)), + ], + ), + ), + ], + ), + ], + ), + ), + ); + } + + String _formatDate(DateTime dt) { + final now = DateTime.now(); + if (now.difference(dt).inMinutes < 1) return 'только что'; + if (now.difference(dt).inHours < 1) return '${now.difference(dt).inMinutes} мин назад'; + return '${dt.day.toString().padLeft(2, '0')}.${dt.month.toString().padLeft(2, '0')} ${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}'; + } +} diff --git a/lib/features/settings/presentation/settings_screen.dart b/lib/features/settings/presentation/settings_screen.dart index b9273f2..8e92175 100644 --- a/lib/features/settings/presentation/settings_screen.dart +++ b/lib/features/settings/presentation/settings_screen.dart @@ -15,6 +15,7 @@ import '../../../backend/backend.dart'; import '../../../core/utils/result.dart'; import '../../../shared/widgets/adaptive_scaffold.dart'; import '../../../shared/widgets/error_view.dart'; +import '../../artists/presentation/widgets/pawchive_accounts_sheet.dart'; import 'settings_controller.dart'; class SettingsScreen extends ConsumerWidget { @@ -54,6 +55,7 @@ class _SettingsContentState extends ConsumerState<_SettingsContent> { final _feedKey = GlobalKey(); final _filtersKey = GlobalKey(); final _storageKey = GlobalKey(); + final _accountsKey = GlobalKey(); final _diagnosticsKey = GlobalKey(); final _aboutKey = GlobalKey(); @@ -126,6 +128,12 @@ class _SettingsContentState extends ConsumerState<_SettingsContent> { icon: Icons.storage_rounded, color: const Color(0xFF06B6D4), ), + ( + key: _accountsKey, + label: isRu ? 'Аккаунты' : 'Accounts', + icon: Icons.cloud_sync_rounded, + color: const Color(0xFF0EA5E9), + ), ( key: _diagnosticsKey, label: isRu ? 'Диагностика' : 'Diagnostics', @@ -751,6 +759,42 @@ class _SettingsContentState extends ConsumerState<_SettingsContent> { ], ), + // Accounts & Sync Section + _SettingsCardGroup( + sectionKey: _accountsKey, + title: isRu ? 'Аккаунты и синхронизация' : 'Accounts & Sync', + icon: Icons.cloud_sync_rounded, + accentColor: const Color(0xFF0EA5E9), + children: [ + ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + leading: const CircleAvatar( + backgroundColor: Color(0xFF0EA5E9), + foregroundColor: Colors.white, + child: Icon(Icons.cloud_sync_rounded), + ), + title: Text( + isRu ? 'Аккаунты Pawchive' : 'Pawchive Accounts', + style: const TextStyle(fontWeight: FontWeight.bold), + ), + subtitle: Text( + settings.parsedPawchiveAccounts.isEmpty + ? (isRu + ? 'Вход для синхронизации избранных авторов' + : 'Sign in to sync favorite creators') + : '${settings.parsedPawchiveAccounts.length} ${isRu ? "аккаунт(ов)" : "account(s)"}' + '${settings.activePawchiveAccount != null ? " • @${settings.activePawchiveAccount!.username}" : ""}', + ), + trailing: FilledButton.tonal( + onPressed: () => PawchiveAccountsSheet.show(context), + child: Text(settings.parsedPawchiveAccounts.isEmpty + ? (isRu ? 'Войти' : 'Login') + : (isRu ? 'Управление' : 'Manage')), + ), + ), + ], + ), + // 6. Providers & Diagnostics Section _SettingsCardGroup( sectionKey: _diagnosticsKey, diff --git a/pubspec.yaml b/pubspec.yaml index 7aad424..b6e0970 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: gel_rule_app description: Local backend/core layer for a Flutter booru browser app. publish_to: "none" -version: 2.3.5+45 +version: 2.3.6+46 environment: sdk: ">=3.3.0 <4.0.0" diff --git a/test/backend/pawchive_sync_test.dart b/test/backend/pawchive_sync_test.dart new file mode 100644 index 0000000..78501f8 --- /dev/null +++ b/test/backend/pawchive_sync_test.dart @@ -0,0 +1,269 @@ +import 'dart:convert'; +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gel_rule_app/backend/models/favorite_artist_item.dart'; +import 'package:gel_rule_app/backend/models/pawchive_account.dart'; +import 'package:gel_rule_app/backend/services/pawchive_sync_service.dart'; +import 'package:gel_rule_app/backend/services/settings_service.dart'; + +class _FakeAdapter implements HttpClientAdapter { + _FakeAdapter(this.handler); + final Future Function(RequestOptions options) handler; + + @override + Future fetch( + RequestOptions options, + Stream? requestStream, + Future? cancelFuture, + ) { + return handler(options); + } + + @override + void close({bool force = false}) {} +} + +void main() { + group('FavoriteArtistItem Tests', () { + test('roundtrip serialization and key generation', () { + const item = FavoriteArtistItem( + id: '12345', + service: 'fanbox', + providerId: 'pawchive', + name: 'CoolArtist', + avatarUrl: 'https://pawchive.pw/icons/fanbox/12345', + ); + + expect(item.key, equals('pawchive:fanbox:12345')); + expect(item.isEmpty, isFalse); + + final json = item.toJson(); + final restored = FavoriteArtistItem.fromJson(json); + expect(restored.key, equals(item.key)); + expect(restored.name, equals('CoolArtist')); + expect(restored.avatarUrl, equals('https://pawchive.pw/icons/fanbox/12345')); + expect(restored == item, isTrue); + }); + + test('empty factory behaves correctly', () { + const empty = FavoriteArtistItem.empty(); + expect(empty.isEmpty, isTrue); + expect(empty.key, equals('::')); + }); + }); + + group('PawchiveAccount Tests', () { + test('roundtrip serialization and copyWith', () { + final now = DateTime(2026, 9, 4, 12, 0, 0); + final account = PawchiveAccount( + id: 'tester', + username: 'Tester', + sessionCookie: 'eySessionToken123', + baseUrl: 'https://pawchive.pw', + createdAt: now, + lastSyncedAt: now, + isActive: true, + syncedArtistsCount: 15, + ); + + final json = account.toJson(); + final restored = PawchiveAccount.fromJson(json); + + expect(restored.id, equals('tester')); + expect(restored.username, equals('Tester')); + expect(restored.sessionCookie, equals('eySessionToken123')); + expect(restored.isActive, isTrue); + expect(restored.syncedArtistsCount, equals(15)); + expect(restored == account, isTrue); + + final updated = account.copyWith(isActive: false, syncedArtistsCount: 20); + expect(updated.isActive, isFalse); + expect(updated.syncedArtistsCount, equals(20)); + expect(updated.id, equals('tester')); + }); + }); + + group('AppSettings Pawchive integration tests', () { + test('serializes and parses pawchive accounts correctly', () { + final acc1 = PawchiveAccount( + id: 'user1', + username: 'User1', + sessionCookie: 'token1', + createdAt: DateTime(2026, 9, 1), + isActive: true, + syncedArtistsCount: 5, + ); + final acc2 = PawchiveAccount( + id: 'user2', + username: 'User2', + sessionCookie: 'token2', + createdAt: DateTime(2026, 9, 2), + isActive: false, + syncedArtistsCount: 10, + ); + + final settings = AppSettings.defaults.copyWith( + pawchiveAccounts: [ + jsonEncode(acc1.toJson()), + jsonEncode(acc2.toJson()), + ], + ); + + expect(settings.parsedPawchiveAccounts.length, equals(2)); + expect(settings.activePawchiveAccount?.id, equals('user1')); + + final json = settings.toJson(); + final restored = AppSettings.fromJson(json); + expect(restored.parsedPawchiveAccounts.length, equals(2)); + expect(restored.activePawchiveAccount?.username, equals('User1')); + }); + }); + + group('PawchiveSyncService Tests', () { + test('fetchRemoteFavoriteArtists parses server response correctly', () async { + final dio = Dio(); + dio.httpClientAdapter = _FakeAdapter((options) async { + if (options.path.endsWith('/account/favorites') && + options.queryParameters['type'] == 'artist') { + final cookie = options.headers['Cookie'] as String?; + expect(cookie, contains('session=test_cookie_123')); + + final payload = jsonEncode([ + { + 'id': '98765', + 'service': 'fanbox', + 'name': 'AlphaCreator', + 'updated': '2026-09-04T10:00:00.000Z', + }, + { + 'id': '43210', + 'service': 'patreon', + 'name': 'BetaCreator', + 'updated': '2026-09-03T15:00:00.000Z', + }, + ]); + + return ResponseBody.fromString( + payload, + 200, + headers: { + Headers.contentTypeHeader: [Headers.jsonContentType], + }, + ); + } + return ResponseBody.fromString('Not found', 404); + }); + + final service = PawchiveSyncService(dio: dio); + final items = await service.fetchRemoteFavoriteArtists( + baseUrl: 'https://pawchive.pw', + sessionCookie: 'session=test_cookie_123;', + ); + + expect(items.length, equals(2)); + expect(items[0].id, equals('98765')); + expect(items[0].service, equals('fanbox')); + expect(items[0].name, equals('AlphaCreator')); + expect(items[0].avatarUrl, equals('https://pawchive.pw/icons/fanbox/98765')); + + expect(items[1].id, equals('43210')); + expect(items[1].service, equals('patreon')); + expect(items[1].name, equals('BetaCreator')); + }); + + test('syncAccountFavorites merges remote artists into AppSettings', () async { + final dio = Dio(); + dio.httpClientAdapter = _FakeAdapter((options) async { + final payload = jsonEncode([ + { + 'id': 'remote_1', + 'service': 'fanbox', + 'name': 'RemoteArtistOne', + }, + { + 'id': 'existing_local', + 'service': 'patreon', + 'name': 'AlreadyPresent', + }, + ]); + return ResponseBody.fromString( + payload, + 200, + headers: { + Headers.contentTypeHeader: [Headers.jsonContentType], + }, + ); + }); + + final service = PawchiveSyncService(dio: dio); + + const existingItem = FavoriteArtistItem( + id: 'existing_local', + service: 'patreon', + providerId: 'pawchive', + name: 'AlreadyPresent', + ); + + final initialSettings = AppSettings.defaults.copyWith( + favoriteArtists: [jsonEncode(existingItem.toJson())], + ); + + final account = PawchiveAccount( + id: 'myuser', + username: 'myuser', + sessionCookie: 'cookie123', + createdAt: DateTime.now(), + ); + + final result = await service.syncAccountFavorites( + account: account, + settings: initialSettings, + ); + + expect(result.isSuccess, isTrue); + expect(result.totalSyncedCount, equals(2)); + expect(result.newlyAddedToLocal, equals(1)); // remote_1 added, existing_local skipped + expect(result.updatedSettings.favoriteArtists.length, equals(2)); + }); + + test('toggleRemoteFavorite sends POST and DELETE successfully', () async { + var lastMethod = ''; + var lastPath = ''; + + final dio = Dio(); + dio.httpClientAdapter = _FakeAdapter((options) async { + lastMethod = options.method; + lastPath = options.path; + return ResponseBody.fromString('ok', 200); + }); + + final service = PawchiveSyncService(dio: dio); + final account = PawchiveAccount( + id: 'user1', + username: 'user1', + sessionCookie: 'session=abc', + createdAt: DateTime.now(), + ); + + final addResult = await service.toggleRemoteFavorite( + account: account, + service: 'fanbox', + artistId: '12345', + isFavorite: true, + ); + expect(addResult, isTrue); + expect(lastMethod, equals('POST')); + expect(lastPath, equals('https://pawchive.pw/favorites/creator/fanbox/12345')); + + final removeResult = await service.toggleRemoteFavorite( + account: account, + service: 'fanbox', + artistId: '12345', + isFavorite: false, + ); + expect(removeResult, isTrue); + expect(lastMethod, equals('DELETE')); + expect(lastPath, equals('https://pawchive.pw/favorites/creator/fanbox/12345')); + }); + }); +}