feat(pawchive): export local favorites to Pawchive and two-way sync (v2.3.10)
This commit is contained in:
@@ -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',
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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'));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user