feat(pawchive): export local favorites to Pawchive and two-way sync (v2.3.10)

This commit is contained in:
2026-09-04 15:54:27 +03:00
parent 402a5436bc
commit 96affe60e7
8 changed files with 430 additions and 22 deletions
+10
View File
@@ -1,4 +1,14 @@
const lunarisChangelog = [ const lunarisChangelog = [
LunarisChange(
version: '2.3.10',
title: 'Выгрузка локального избранного в Pawchive и двусторонняя синхронизация',
bullets: [
'Экспорт локального избранного: добавлена возможность выгрузить всех ваших локальных авторов из приложения прямо в аккаунт Pawchive на сервере в 1 клик.',
'Двусторонняя синхронизация: переключатель двусторонней синхронизации объединяет коллекции — скачивает новых авторов с сервера и отправляет локальных на сервер Pawchive.',
'Кнопка выгрузки в карточке аккаунта: быстрая кнопка со стрелкой вверх и пункт контекстного меню «Выгрузить в Pawchive» для любого подключённого аккаунта.',
'Синхронизация в реальном времени с любых провайдеров: при добавлении автора в избранное в лентах Kemono, Coomer или Pawchive изменение мгновенно передаётся на сервер Pawchive.',
],
),
LunarisChange( LunarisChange(
version: '2.3.9', version: '2.3.9',
title: 'Исправление входа и синхронизации аккаунтов Pawchive', title: 'Исправление входа и синхронизации аккаунтов Pawchive',
+135 -15
View File
@@ -16,12 +16,32 @@ class PawchiveSyncResult {
required this.updatedSettings, required this.updatedSettings,
required this.newlyAddedToLocal, required this.newlyAddedToLocal,
required this.totalSyncedCount, required this.totalSyncedCount,
this.pushedToRemoteCount = 0,
this.errorMessage, this.errorMessage,
}); });
final AppSettings updatedSettings; final AppSettings updatedSettings;
final int newlyAddedToLocal; final int newlyAddedToLocal;
final int totalSyncedCount; final int totalSyncedCount;
final int pushedToRemoteCount;
final String? errorMessage;
bool get isSuccess => errorMessage == null;
}
class PawchivePushResult {
const PawchivePushResult({
required this.pushedCount,
required this.totalRemoteCount,
required this.totalLocalCandidates,
this.updatedSettings,
this.errorMessage,
});
final int pushedCount;
final int totalRemoteCount;
final int totalLocalCandidates;
final AppSettings? updatedSettings;
final String? errorMessage; final String? errorMessage;
bool get isSuccess => errorMessage == null; bool get isSuccess => errorMessage == null;
@@ -249,10 +269,12 @@ class PawchiveSyncService {
} }
/// Synchronizes a specific Pawchive account with current local AppSettings. /// Synchronizes a specific Pawchive account with current local AppSettings.
/// If [pushLocalPawchiveArtists] is true (or [settings.pawchiveBidirectionalSync] is enabled),
/// local favorites are also pushed to the Pawchive server.
Future<PawchiveSyncResult> syncAccountFavorites({ Future<PawchiveSyncResult> syncAccountFavorites({
required PawchiveAccount account, required PawchiveAccount account,
required AppSettings settings, required AppSettings settings,
bool pushLocalPawchiveArtists = false, bool? pushLocalPawchiveArtists,
}) async { }) async {
try { try {
final remoteArtists = await fetchRemoteFavoriteArtists( final remoteArtists = await fetchRemoteFavoriteArtists(
@@ -262,40 +284,57 @@ class PawchiveSyncService {
final currentFavorites = List<String>.from(settings.favoriteArtists); final currentFavorites = List<String>.from(settings.favoriteArtists);
final existingKeys = <String>{}; final existingKeys = <String>{};
final existingServiceId = <String>{};
for (final raw in currentFavorites) { for (final raw in currentFavorites) {
try { try {
final parsed = FavoriteArtistItem.fromJson( final parsed = FavoriteArtistItem.fromJson(
jsonDecode(raw) as Map<String, dynamic>, jsonDecode(raw) as Map<String, dynamic>,
); );
existingKeys.add(parsed.key); existingKeys.add(parsed.key);
if (parsed.service.isNotEmpty && parsed.id.isNotEmpty) {
existingServiceId.add('${parsed.service}:${parsed.id}'.toLowerCase());
}
} catch (_) {} } catch (_) {}
} }
var addedCount = 0; var addedCount = 0;
for (final artist in remoteArtists) { for (final artist in remoteArtists) {
if (!existingKeys.contains(artist.key)) { final pair = '${artist.service}:${artist.id}'.toLowerCase();
if (!existingKeys.contains(artist.key) && !existingServiceId.contains(pair)) {
currentFavorites.insert(0, jsonEncode(artist.toJson())); currentFavorites.insert(0, jsonEncode(artist.toJson()));
existingKeys.add(artist.key); existingKeys.add(artist.key);
existingServiceId.add(pair);
addedCount++; addedCount++;
} }
} }
// Optionally push local Pawchive artists to remote var pushedCount = 0;
if (pushLocalPawchiveArtists) { final shouldPush =
final remoteKeys = remoteArtists.map((a) => a.key).toSet(); pushLocalPawchiveArtists ?? settings.pawchiveBidirectionalSync;
if (shouldPush) {
final remotePairSet = remoteArtists
.map((a) => '${a.service}:${a.id}'.toLowerCase())
.toSet();
for (final raw in currentFavorites) { for (final raw in currentFavorites) {
try { try {
final parsed = FavoriteArtistItem.fromJson( final parsed = FavoriteArtistItem.fromJson(
jsonDecode(raw) as Map<String, dynamic>, jsonDecode(raw) as Map<String, dynamic>,
); );
if (parsed.providerId == 'pawchive' && if (parsed.service.isNotEmpty && parsed.id.isNotEmpty) {
!remoteKeys.contains(parsed.key)) { final pair = '${parsed.service}:${parsed.id}'.toLowerCase();
await toggleRemoteFavorite( if (!remotePairSet.contains(pair)) {
account: account, final ok = await toggleRemoteFavorite(
service: parsed.service, account: account,
artistId: parsed.id, service: parsed.service,
isFavorite: true, artistId: parsed.id,
); isFavorite: true,
);
if (ok) {
pushedCount++;
remotePairSet.add(pair);
}
}
} }
} catch (_) {} } catch (_) {}
} }
@@ -304,7 +343,7 @@ class PawchiveSyncService {
// Update account sync stats in settings // Update account sync stats in settings
final updatedAccount = account.copyWith( final updatedAccount = account.copyWith(
lastSyncedAt: DateTime.now(), lastSyncedAt: DateTime.now(),
syncedArtistsCount: remoteArtists.length, syncedArtistsCount: remoteArtists.length + pushedCount,
); );
final updatedAccountsList = settings.parsedPawchiveAccounts.map((a) { final updatedAccountsList = settings.parsedPawchiveAccounts.map((a) {
@@ -320,45 +359,125 @@ class PawchiveSyncService {
return PawchiveSyncResult( return PawchiveSyncResult(
updatedSettings: newSettings, updatedSettings: newSettings,
newlyAddedToLocal: addedCount, newlyAddedToLocal: addedCount,
totalSyncedCount: remoteArtists.length, pushedToRemoteCount: pushedCount,
totalSyncedCount: remoteArtists.length + pushedCount,
); );
} catch (e) { } catch (e) {
return PawchiveSyncResult( return PawchiveSyncResult(
updatedSettings: settings, updatedSettings: settings,
newlyAddedToLocal: 0, newlyAddedToLocal: 0,
pushedToRemoteCount: 0,
totalSyncedCount: 0, totalSyncedCount: 0,
errorMessage: e.toString(), errorMessage: e.toString(),
); );
} }
} }
/// Explicitly pushes all local favorite artists into a specified Pawchive account.
Future<PawchivePushResult> pushLocalFavoritesToAccount({
required PawchiveAccount account,
required AppSettings settings,
}) async {
try {
final remoteArtists = await fetchRemoteFavoriteArtists(
baseUrl: account.baseUrl,
sessionCookie: account.sessionCookie,
);
final remotePairSet = remoteArtists
.map((a) => '${a.service}:${a.id}'.toLowerCase())
.toSet();
var pushedCount = 0;
var totalLocalCandidates = 0;
for (final raw in settings.favoriteArtists) {
try {
final parsed = FavoriteArtistItem.fromJson(
jsonDecode(raw) as Map<String, dynamic>,
);
if (parsed.service.isNotEmpty && parsed.id.isNotEmpty) {
totalLocalCandidates++;
final pair = '${parsed.service}:${parsed.id}'.toLowerCase();
if (!remotePairSet.contains(pair)) {
final ok = await toggleRemoteFavorite(
account: account,
service: parsed.service,
artistId: parsed.id,
isFavorite: true,
);
if (ok) {
pushedCount++;
remotePairSet.add(pair);
}
}
}
} catch (_) {}
}
final updatedAccount = account.copyWith(
lastSyncedAt: DateTime.now(),
syncedArtistsCount: remoteArtists.length + pushedCount,
);
final updatedAccountsList = settings.parsedPawchiveAccounts.map((a) {
return a.id == updatedAccount.id ? updatedAccount : a;
}).toList();
final newSettings = settings.copyWith(
pawchiveAccounts:
updatedAccountsList.map((a) => jsonEncode(a.toJson())).toList(),
);
return PawchivePushResult(
pushedCount: pushedCount,
totalRemoteCount: remoteArtists.length + pushedCount,
totalLocalCandidates: totalLocalCandidates,
updatedSettings: newSettings,
);
} catch (e) {
return PawchivePushResult(
pushedCount: 0,
totalRemoteCount: 0,
totalLocalCandidates: 0,
errorMessage: e.toString(),
);
}
}
/// Synchronizes ALL connected Pawchive accounts with local AppSettings. /// Synchronizes ALL connected Pawchive accounts with local AppSettings.
Future<PawchiveSyncResult> syncAllAccounts({ Future<PawchiveSyncResult> syncAllAccounts({
required AppSettings settings, required AppSettings settings,
bool? pushLocalArtists,
}) async { }) async {
final accounts = settings.parsedPawchiveAccounts; final accounts = settings.parsedPawchiveAccounts;
if (accounts.isEmpty) { if (accounts.isEmpty) {
return PawchiveSyncResult( return PawchiveSyncResult(
updatedSettings: settings, updatedSettings: settings,
newlyAddedToLocal: 0, newlyAddedToLocal: 0,
pushedToRemoteCount: 0,
totalSyncedCount: 0, totalSyncedCount: 0,
errorMessage: 'Нет добавленных аккаунтов Pawchive', errorMessage: 'Нет добавленных аккаунтов Pawchive',
); );
} }
final doPush = pushLocalArtists ?? settings.pawchiveBidirectionalSync;
var runningSettings = settings; var runningSettings = settings;
var totalAdded = 0; var totalAdded = 0;
var totalSynced = 0; var totalSynced = 0;
var totalPushed = 0;
String? firstError; String? firstError;
for (final account in accounts) { for (final account in accounts) {
final res = await syncAccountFavorites( final res = await syncAccountFavorites(
account: account, account: account,
settings: runningSettings, settings: runningSettings,
pushLocalPawchiveArtists: doPush,
); );
if (res.isSuccess) { if (res.isSuccess) {
runningSettings = res.updatedSettings; runningSettings = res.updatedSettings;
totalAdded += res.newlyAddedToLocal; totalAdded += res.newlyAddedToLocal;
totalPushed += res.pushedToRemoteCount;
totalSynced += res.totalSyncedCount; totalSynced += res.totalSyncedCount;
} else { } else {
firstError ??= res.errorMessage; firstError ??= res.errorMessage;
@@ -368,6 +487,7 @@ class PawchiveSyncService {
return PawchiveSyncResult( return PawchiveSyncResult(
updatedSettings: runningSettings, updatedSettings: runningSettings,
newlyAddedToLocal: totalAdded, newlyAddedToLocal: totalAdded,
pushedToRemoteCount: totalPushed,
totalSyncedCount: totalSynced, totalSyncedCount: totalSynced,
errorMessage: firstError, errorMessage: firstError,
); );
@@ -51,6 +51,7 @@ class AppSettings {
required this.lastFeedScrollOffset, required this.lastFeedScrollOffset,
this.favoriteArtists = const [], this.favoriteArtists = const [],
this.pawchiveAccounts = const [], this.pawchiveAccounts = const [],
this.pawchiveBidirectionalSync = false,
this.skippedUpdateVersion, this.skippedUpdateVersion,
this.lastUpdateCheckAt, this.lastUpdateCheckAt,
this.defaultRatingFilter, this.defaultRatingFilter,
@@ -66,6 +67,7 @@ class AppSettings {
final List<String> favoriteArtists; final List<String> favoriteArtists;
final List<String> pawchiveAccounts; final List<String> pawchiveAccounts;
final bool pawchiveBidirectionalSync;
final List<String> enabledProviderIds; final List<String> enabledProviderIds;
final bool nsfwEnabled; final bool nsfwEnabled;
final int cacheTtlHours; final int cacheTtlHours;
@@ -122,6 +124,7 @@ class AppSettings {
providerPriority: {'gelbooru': 0, 'rule34': 1, 'realbooru': 2}, providerPriority: {'gelbooru': 0, 'rule34': 1, 'realbooru': 2},
favoriteArtists: [], favoriteArtists: [],
pawchiveAccounts: [], pawchiveAccounts: [],
pawchiveBidirectionalSync: false,
themeMode: 'dark', themeMode: 'dark',
languageCode: 'ru', languageCode: 'ru',
appSeedColor: 0xFFE84D8A, appSeedColor: 0xFFE84D8A,
@@ -205,6 +208,7 @@ class AppSettings {
bool clearLastFeedRating = false, bool clearLastFeedRating = false,
List<String>? favoriteArtists, List<String>? favoriteArtists,
List<String>? pawchiveAccounts, List<String>? pawchiveAccounts,
bool? pawchiveBidirectionalSync,
bool? amoledMode, bool? amoledMode,
bool? useDynamicColor, bool? useDynamicColor,
String? gridMode, String? gridMode,
@@ -216,6 +220,8 @@ class AppSettings {
return AppSettings( return AppSettings(
favoriteArtists: favoriteArtists ?? this.favoriteArtists, favoriteArtists: favoriteArtists ?? this.favoriteArtists,
pawchiveAccounts: pawchiveAccounts ?? this.pawchiveAccounts, pawchiveAccounts: pawchiveAccounts ?? this.pawchiveAccounts,
pawchiveBidirectionalSync:
pawchiveBidirectionalSync ?? this.pawchiveBidirectionalSync,
enabledProviderIds: enabledProviderIds ?? this.enabledProviderIds, enabledProviderIds: enabledProviderIds ?? this.enabledProviderIds,
nsfwEnabled: nsfwEnabled ?? this.nsfwEnabled, nsfwEnabled: nsfwEnabled ?? this.nsfwEnabled,
cacheTtlHours: cacheTtlHours ?? this.cacheTtlHours, cacheTtlHours: cacheTtlHours ?? this.cacheTtlHours,
@@ -276,6 +282,7 @@ class AppSettings {
Map<String, dynamic> toJson() => { Map<String, dynamic> toJson() => {
'favoriteArtists': favoriteArtists, 'favoriteArtists': favoriteArtists,
'pawchiveAccounts': pawchiveAccounts, 'pawchiveAccounts': pawchiveAccounts,
'pawchiveBidirectionalSync': pawchiveBidirectionalSync,
'enabledProviderIds': enabledProviderIds, 'enabledProviderIds': enabledProviderIds,
'nsfwEnabled': nsfwEnabled, 'nsfwEnabled': nsfwEnabled,
'cacheTtlHours': cacheTtlHours, 'cacheTtlHours': cacheTtlHours,
@@ -421,6 +428,9 @@ class AppSettings {
pawchiveAccounts: List<String>.from( pawchiveAccounts: List<String>.from(
(json['pawchiveAccounts'] as List?) ?? defaults.pawchiveAccounts, (json['pawchiveAccounts'] as List?) ?? defaults.pawchiveAccounts,
), ),
pawchiveBidirectionalSync:
(json['pawchiveBidirectionalSync'] as bool?) ??
defaults.pawchiveBidirectionalSync,
skippedUpdateVersion: json['skippedUpdateVersion'] as String?, skippedUpdateVersion: json['skippedUpdateVersion'] as String?,
lastUpdateCheckAt: json['lastUpdateCheckAt'] as String?, lastUpdateCheckAt: json['lastUpdateCheckAt'] as String?,
defaultRatingFilter: json['defaultRatingFilter'] as String?, defaultRatingFilter: json['defaultRatingFilter'] as String?,
@@ -439,7 +439,7 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
settings.copyWith(favoriteArtists: current), settings.copyWith(favoriteArtists: current),
); );
if (widget.providerId == 'pawchive') { if (widget.service.isNotEmpty && widget.artistId.isNotEmpty) {
final activeAcc = settings.activePawchiveAccount; final activeAcc = settings.activePawchiveAccount;
if (activeAcc != null) { if (activeAcc != null) {
unawaited(ref.read(pawchiveSyncServiceProvider).toggleRemoteFavorite( unawaited(ref.read(pawchiveSyncServiceProvider).toggleRemoteFavorite(
@@ -78,8 +78,8 @@ class _ArtistsScreenState extends ConsumerState<ArtistsScreen> {
settings.copyWith(favoriteArtists: current), settings.copyWith(favoriteArtists: current),
); );
// Sync with active Pawchive account if artist belongs to Pawchive // Sync with active Pawchive account if available
if (artist.providerId == 'pawchive') { if (artist.service.isNotEmpty && artist.id.isNotEmpty) {
final activeAcc = settings.activePawchiveAccount; final activeAcc = settings.activePawchiveAccount;
if (activeAcc != null) { if (activeAcc != null) {
unawaited(ref.read(pawchiveSyncServiceProvider).toggleRemoteFavorite( unawaited(ref.read(pawchiveSyncServiceProvider).toggleRemoteFavorite(
@@ -33,6 +33,7 @@ class _PawchiveAccountsSheetState extends ConsumerState<PawchiveAccountsSheet>
bool _isAdding = false; bool _isAdding = false;
bool _obscurePassword = true; bool _obscurePassword = true;
String? _syncingAccountId; String? _syncingAccountId;
String? _pushingAccountId;
bool _syncingAll = false; bool _syncingAll = false;
String? _errorMessage; String? _errorMessage;
@@ -207,10 +208,13 @@ class _PawchiveAccountsSheetState extends ConsumerState<PawchiveAccountsSheet>
.read(settingsControllerProvider.notifier) .read(settingsControllerProvider.notifier)
.saveSettings(result.updatedSettings); .saveSettings(result.updatedSettings);
if (mounted) { if (mounted) {
final pushedMsg = result.pushedToRemoteCount > 0
? ', выгружено в Pawchive: +${result.pushedToRemoteCount}'
: '';
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text( content: Text(
'Синхронизировано: ${result.totalSyncedCount} авторов (+${result.newlyAddedToLocal} новых)', 'Синхронизировано: ${result.totalSyncedCount} авторов (+${result.newlyAddedToLocal} новых в приложении$pushedMsg)',
), ),
), ),
); );
@@ -232,6 +236,92 @@ class _PawchiveAccountsSheetState extends ConsumerState<PawchiveAccountsSheet>
} }
} }
Future<void> _pushFavoritesToAccount(PawchiveAccount account) async {
final settings =
ref.read(appSettingsProvider).value ?? AppSettings.defaults;
final totalLocal = settings.favoriteArtists.length;
if (totalLocal == 0) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('В локальном избранном пока нет авторов')),
);
return;
}
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Выгрузить избранное в Pawchive?'),
content: Text(
'Все локальные авторы из приложения ($totalLocal) будут добавлены в избранное аккаунта @${account.username} на сервере Pawchive.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Отмена'),
),
FilledButton.icon(
onPressed: () => Navigator.pop(ctx, true),
icon: const Icon(Icons.cloud_upload_rounded),
label: const Text('Выгрузить'),
),
],
),
);
if (confirmed != true) return;
setState(() => _pushingAccountId = account.id);
try {
final syncService = ref.read(pawchiveSyncServiceProvider);
final result = await syncService.pushLocalFavoritesToAccount(
account: account,
settings: settings,
);
if (result.isSuccess) {
if (result.updatedSettings != null) {
await ref
.read(settingsControllerProvider.notifier)
.saveSettings(result.updatedSettings!);
}
if (mounted) {
if (result.pushedCount > 0) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Выгружено ${result.pushedCount} авторов в @${account.username}! Всего в аккаунте: ${result.totalRemoteCount}.',
),
backgroundColor: Colors.green.shade700,
),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Все авторы (${result.totalLocalCandidates}) уже есть в аккаунте @${account.username}.',
),
),
);
}
}
} else {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Ошибка выгрузки: ${result.errorMessage}'),
backgroundColor: Colors.red.shade700,
),
);
}
}
} finally {
if (mounted) {
setState(() => _pushingAccountId = null);
}
}
}
Future<void> _syncAllAccounts() async { Future<void> _syncAllAccounts() async {
setState(() => _syncingAll = true); setState(() => _syncingAll = true);
try { try {
@@ -245,10 +335,13 @@ class _PawchiveAccountsSheetState extends ConsumerState<PawchiveAccountsSheet>
.read(settingsControllerProvider.notifier) .read(settingsControllerProvider.notifier)
.saveSettings(result.updatedSettings); .saveSettings(result.updatedSettings);
if (mounted) { if (mounted) {
final pushedMsg = result.pushedToRemoteCount > 0
? ', выгружено в Pawchive: +${result.pushedToRemoteCount}'
: '';
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text( content: Text(
'Все аккаунты синхронизированы! Всего: ${result.totalSyncedCount} (+${result.newlyAddedToLocal} новых)', 'Все аккаунты синхронизированы! Всего: ${result.totalSyncedCount} (+${result.newlyAddedToLocal} новых в приложении$pushedMsg)',
), ),
backgroundColor: Colors.green.shade700, backgroundColor: Colors.green.shade700,
), ),
@@ -445,6 +538,78 @@ class _PawchiveAccountsSheetState extends ConsumerState<PawchiveAccountsSheet>
), ),
), ),
...accounts.map((acc) => _buildAccountCard(acc, scheme, theme)), ...accounts.map((acc) => _buildAccountCard(acc, scheme, theme)),
const SizedBox(height: 12),
// Two-way sync & Export to Pawchive panel
Card(
elevation: 0,
color: scheme.surfaceContainerHigh.withValues(alpha: 0.6),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(
color: scheme.outlineVariant.withValues(alpha: 0.3),
),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Icon(Icons.swap_vert_rounded, size: 20, color: scheme.primary),
const SizedBox(width: 8),
Expanded(
child: Text(
'Двусторонняя синхронизация',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
Switch(
value: settings.pawchiveBidirectionalSync,
onChanged: (val) async {
final newSettings = settings.copyWith(
pawchiveBidirectionalSync: val,
);
await ref
.read(settingsControllerProvider.notifier)
.saveSettings(newSettings);
},
),
],
),
Text(
'При обычной синхронизации также выгружать всех локальных авторов на сервер Pawchive.',
style: TextStyle(
color: scheme.onSurfaceVariant,
fontSize: 12,
),
),
const SizedBox(height: 10),
OutlinedButton.icon(
onPressed: (_syncingAll || _pushingAccountId != null)
? null
: () {
final target = settings.activePawchiveAccount ?? accounts.first;
_pushFavoritesToAccount(target);
},
icon: _pushingAccountId != null
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.cloud_upload_rounded, size: 18),
label: Text(
'Выгрузить избранное (${settings.favoriteArtists.length}) в @${(settings.activePawchiveAccount ?? accounts.first).username}',
),
),
],
),
),
),
const SizedBox(height: 16), const SizedBox(height: 16),
], ],
@@ -622,6 +787,8 @@ class _PawchiveAccountsSheetState extends ConsumerState<PawchiveAccountsSheet>
Widget _buildAccountCard(PawchiveAccount acc, ColorScheme scheme, ThemeData theme) { Widget _buildAccountCard(PawchiveAccount acc, ColorScheme scheme, ThemeData theme) {
final isSyncing = _syncingAccountId == acc.id; final isSyncing = _syncingAccountId == acc.id;
final isPushing = _pushingAccountId == acc.id;
final isBusy = isSyncing || isPushing;
return Card( return Card(
margin: const EdgeInsets.only(bottom: 8), margin: const EdgeInsets.only(bottom: 8),
@@ -687,6 +854,17 @@ class _PawchiveAccountsSheetState extends ConsumerState<PawchiveAccountsSheet>
trailing: Row( trailing: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
IconButton(
icon: isPushing
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.cloud_upload_outlined),
tooltip: 'Выгрузить локальное избранное в этот аккаунт',
onPressed: isBusy ? null : () => _pushFavoritesToAccount(acc),
),
IconButton( IconButton(
icon: isSyncing icon: isSyncing
? const SizedBox( ? const SizedBox(
@@ -696,13 +874,15 @@ class _PawchiveAccountsSheetState extends ConsumerState<PawchiveAccountsSheet>
) )
: const Icon(Icons.sync_rounded), : const Icon(Icons.sync_rounded),
tooltip: 'Синхронизировать этот аккаунт', tooltip: 'Синхронизировать этот аккаунт',
onPressed: isSyncing ? null : () => _syncSingleAccount(acc), onPressed: isBusy ? null : () => _syncSingleAccount(acc),
), ),
PopupMenuButton<String>( PopupMenuButton<String>(
icon: const Icon(Icons.more_vert_rounded), icon: const Icon(Icons.more_vert_rounded),
onSelected: (val) { onSelected: (val) {
if (val == 'active') { if (val == 'active') {
_setActiveAccount(acc); _setActiveAccount(acc);
} else if (val == 'push') {
_pushFavoritesToAccount(acc);
} else if (val == 'delete') { } else if (val == 'delete') {
_removeAccount(acc); _removeAccount(acc);
} }
@@ -719,6 +899,16 @@ class _PawchiveAccountsSheetState extends ConsumerState<PawchiveAccountsSheet>
], ],
), ),
), ),
const PopupMenuItem(
value: 'push',
child: Row(
children: [
Icon(Icons.cloud_upload_outlined, size: 20),
SizedBox(width: 8),
Text('Выгрузить в Pawchive'),
],
),
),
const PopupMenuItem( const PopupMenuItem(
value: 'delete', value: 'delete',
child: Row( child: Row(
+1 -1
View File
@@ -1,7 +1,7 @@
name: gel_rule_app name: gel_rule_app
description: Local backend/core layer for a Flutter booru browser app. description: Local backend/core layer for a Flutter booru browser app.
publish_to: "none" publish_to: "none"
version: 2.3.9+49 version: 2.3.10+50
environment: environment:
sdk: ">=3.3.0 <4.0.0" sdk: ">=3.3.0 <4.0.0"
+78
View File
@@ -265,5 +265,83 @@ void main() {
expect(lastMethod, equals('DELETE')); expect(lastMethod, equals('DELETE'));
expect(lastPath, equals('https://pawchive.pw/api/v1/favorites/creator/fanbox/12345')); expect(lastPath, equals('https://pawchive.pw/api/v1/favorites/creator/fanbox/12345'));
}); });
test('pushLocalFavoritesToAccount exports local artists to Pawchive', () async {
final postedPaths = <String>[];
final dio = Dio();
dio.httpClientAdapter = _FakeAdapter((options) async {
if (options.path.endsWith('/api/v1/account/favorites')) {
final payload = jsonEncode([
{
'id': 'already_remote_1',
'service': 'fanbox',
'name': 'AlreadyRemote',
},
]);
return ResponseBody.fromString(
payload,
200,
headers: {
Headers.contentTypeHeader: [Headers.jsonContentType],
},
);
}
if (options.method == 'POST' &&
options.path.contains('/api/v1/favorites/creator/')) {
postedPaths.add(options.path);
return ResponseBody.fromString('ok', 204);
}
return ResponseBody.fromString('Not found', 404);
});
final service = PawchiveSyncService(dio: dio);
const itemAlreadyRemote = FavoriteArtistItem(
id: 'already_remote_1',
service: 'fanbox',
providerId: 'pawchive',
name: 'AlreadyRemote',
);
const itemToUpload1 = FavoriteArtistItem(
id: 'local_creator_99',
service: 'patreon',
providerId: 'kemono',
name: 'LocalPatreonCreator',
);
const itemToUpload2 = FavoriteArtistItem(
id: 'local_creator_88',
service: 'subscribestar',
providerId: 'coomer',
name: 'LocalSubscribeStarCreator',
);
final settings = AppSettings.defaults.copyWith(
favoriteArtists: [
jsonEncode(itemAlreadyRemote.toJson()),
jsonEncode(itemToUpload1.toJson()),
jsonEncode(itemToUpload2.toJson()),
],
);
final account = PawchiveAccount(
id: 'myacc',
username: 'myacc',
sessionCookie: 'session=123',
createdAt: DateTime.now(),
);
final pushResult = await service.pushLocalFavoritesToAccount(
account: account,
settings: settings,
);
expect(pushResult.isSuccess, isTrue);
expect(pushResult.pushedCount, equals(2));
expect(pushResult.totalLocalCandidates, equals(3));
expect(pushResult.totalRemoteCount, equals(3)); // 1 existing + 2 pushed
expect(postedPaths.length, equals(2));
expect(postedPaths[0], contains('/api/v1/favorites/creator/patreon/local_creator_99'));
expect(postedPaths[1], contains('/api/v1/favorites/creator/subscribestar/local_creator_88'));
});
}); });
} }