Compare commits

...
4 Commits
Author SHA1 Message Date
RarDog 96affe60e7 feat(pawchive): export local favorites to Pawchive and two-way sync (v2.3.10) 2026-09-04 15:54:27 +03:00
RarDog 402a5436bc fix(pawchive): fix favorites API endpoints and login 404 error (v2.3.9) 2026-09-04 15:42:03 +03:00
RarDog 80ca59d19f feat: ABI-aware auto-update — pick correct per-ABI APK from Gitea
- Android: expose getSupportedAbis/getPrimaryAbi via rulegel/device channel (Build.SUPPORTED_ABIS)
- AppUpdateInfo: add apkArm64Url, apkArmv7Url, apkX86_64Url fields + apkUrlForAbi() selector
- UpdateService: parse per-ABI APK assets by filename pattern, async assetUrlForCurrentPlatform
- app.dart + settings_screen.dart: await new async URL resolver
- Bump version to 2.3.8+48
2026-09-04 14:55:50 +03:00
RarDog 848fee6ff1 fix: lazy-resolve image dimensions for providers without width/height metadata (Pawchive)
- Convert _PostInfoCard to StatefulWidget
- Lazily decode real pixel dimensions (e.g. 1920×1080) from network when post.width==0 && post.height==0
- Show '… × …' while resolving, actual size once decoded
- Hide resolution badge for video/link posts where dimensions can't be resolved
- In-memory cache prevents redundant re-downloads
- Bump version to 2.3.7+47
2026-09-04 14:42:07 +03:00
14 changed files with 697 additions and 44 deletions
@@ -54,6 +54,8 @@ class MainActivity : FlutterActivity() {
setPreferredRefreshRate(hz.toFloat()) setPreferredRefreshRate(hz.toFloat())
result.success(null) result.success(null)
} }
"getSupportedAbis" -> result.success(Build.SUPPORTED_ABIS.toList())
"getPrimaryAbi" -> result.success(Build.SUPPORTED_ABIS.firstOrNull() ?: "armeabi-v7a")
else -> result.notImplemented() else -> result.notImplemented()
} }
} catch (error: Throwable) { } catch (error: Throwable) {
+1 -1
View File
@@ -149,7 +149,7 @@ class _AppOverlayState extends ConsumerState<_AppOverlay> {
Future<void> _downloadUpdate(AppUpdateInfo info) async { Future<void> _downloadUpdate(AppUpdateInfo info) async {
final updateService = ref.read(updateServiceProvider); final updateService = ref.read(updateServiceProvider);
final assetUrl = updateService.assetUrlForCurrentPlatform(info); final assetUrl = await updateService.assetUrlForCurrentPlatform(info);
if (assetUrl == null) { if (assetUrl == null) {
final url = Uri.tryParse(info.htmlUrl); final url = Uri.tryParse(info.htmlUrl);
if (url != null) { if (url != null) {
+38
View File
@@ -1,4 +1,42 @@
const lunarisChangelog = [ const lunarisChangelog = [
LunarisChange(
version: '2.3.10',
title: 'Выгрузка локального избранного в Pawchive и двусторонняя синхронизация',
bullets: [
'Экспорт локального избранного: добавлена возможность выгрузить всех ваших локальных авторов из приложения прямо в аккаунт Pawchive на сервере в 1 клик.',
'Двусторонняя синхронизация: переключатель двусторонней синхронизации объединяет коллекции — скачивает новых авторов с сервера и отправляет локальных на сервер Pawchive.',
'Кнопка выгрузки в карточке аккаунта: быстрая кнопка со стрелкой вверх и пункт контекстного меню «Выгрузить в Pawchive» для любого подключённого аккаунта.',
'Синхронизация в реальном времени с любых провайдеров: при добавлении автора в избранное в лентах Kemono, Coomer или Pawchive изменение мгновенно передаётся на сервер Pawchive.',
],
),
LunarisChange(
version: '2.3.9',
title: 'Исправление входа и синхронизации аккаунтов Pawchive',
bullets: [
'Исправлены API эндпоинты Pawchive: пути запросов избранных авторов и управления подписками приведены в соответствие со спецификацией API v1 (/api/v1/account/favorites, /api/v1/favorites/creator/...).',
'Устранена ошибка 404: авторизация по логину и паролю, а также по session cookie теперь успешно завершается и сразу синхронизирует всех авторов.',
'Информативные ошибки: понятные сообщения на русском при неверном пароле или сбое сети вместо сырых исключений Dio.',
],
),
LunarisChange(
version: '2.3.8',
title: 'ABI-Aware Auto-Update — правильный APK для вашего процессора',
bullets: [
'Определение архитектуры Android: приложение читает Build.SUPPORTED_ABIS (arm64-v8a, armeabi-v7a, x86_64) через нативный канал.',
'Точный APK из Gitea: при обновлении скачивается per-ABI APK (app-arm64-v8a-release.apk и т.д.), а не один общий файл.',
'Fallback: нет per-ABI APK → универсальный APK → страница релиза в браузере.',
'Кеш ABI: архитектура определяется один раз за сеанс.',
],
),
LunarisChange(
version: '2.3.7',
title: 'Lazy Media Dimensions for Pawchive & Unknown-Size Providers',
bullets: [
'Auto-Resolve Image Dimensions: when a provider (such as Pawchive) does not supply width/height metadata, the app now lazily resolves the real pixel dimensions by decoding the image in the background and displays them as soon as they are ready.',
'No More "0 × 0": the resolution badge now shows "… × …" while dimensions are being fetched, and the correct resolution (e.g. 1920 × 1080) once decoded. The badge is hidden entirely for video/link posts where dimensions cannot be resolved.',
'Session Cache: resolved image dimensions are cached in memory for the lifetime of the app so repeated visits to the same post do not trigger re-downloads.',
],
),
LunarisChange( LunarisChange(
version: '2.3.6', version: '2.3.6',
title: 'Pawchive Accounts Integration & Favorites Synchronization', title: 'Pawchive Accounts Integration & Favorites Synchronization',
+26
View File
@@ -7,6 +7,9 @@ class AppUpdateInfo {
required this.htmlUrl, required this.htmlUrl,
required this.publishedAt, required this.publishedAt,
this.apkUrl, this.apkUrl,
this.apkArm64Url,
this.apkArmv7Url,
this.apkX86_64Url,
this.windowsInstallerUrl, this.windowsInstallerUrl,
this.portableZipUrl, this.portableZipUrl,
this.linuxTarGzUrl, this.linuxTarGzUrl,
@@ -18,13 +21,36 @@ class AppUpdateInfo {
final String body; final String body;
final String htmlUrl; final String htmlUrl;
final DateTime? publishedAt; final DateTime? publishedAt;
/// Generic APK (fat / universal build). Used as a fallback.
final String? apkUrl; final String? apkUrl;
/// Per-ABI APKs produced by `flutter build apk --split-per-abi`.
final String? apkArm64Url; // app-arm64-v8a-release.apk
final String? apkArmv7Url; // app-armeabi-v7a-release.apk
final String? apkX86_64Url; // app-x86_64-release.apk
final String? windowsInstallerUrl; final String? windowsInstallerUrl;
final String? portableZipUrl; final String? portableZipUrl;
final String? linuxTarGzUrl; final String? linuxTarGzUrl;
/// Returns the best APK URL for [abi], falling back to the generic APK.
String? apkUrlForAbi(String abi) {
if (abi.contains('arm64') || abi == 'arm64-v8a') {
return apkArm64Url ?? apkUrl;
}
if (abi.contains('x86_64')) {
return apkX86_64Url ?? apkUrl;
}
// armeabi-v7a and everything else
return apkArmv7Url ?? apkArm64Url ?? apkUrl;
}
bool get hasAssets => bool get hasAssets =>
apkUrl != null || apkUrl != null ||
apkArm64Url != null ||
apkArmv7Url != null ||
apkX86_64Url != null ||
windowsInstallerUrl != null || windowsInstallerUrl != null ||
portableZipUrl != null || portableZipUrl != null ||
linuxTarGzUrl != null; linuxTarGzUrl != null;
+180 -19
View File
@@ -3,7 +3,6 @@ import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/http/dio_client.dart'; import '../../core/http/dio_client.dart';
import '../di/backend_providers.dart';
import '../models/favorite_artist_item.dart'; import '../models/favorite_artist_item.dart';
import '../models/pawchive_account.dart'; import '../models/pawchive_account.dart';
import 'settings_service.dart'; import 'settings_service.dart';
@@ -17,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;
@@ -98,10 +117,25 @@ class PawchiveSyncService {
} }
// Check if redirect points back to login with flash error // Check if redirect points back to login with flash error
if (redirectLocation.contains('/account/login')) { if (redirectLocation.contains('/account/login') ||
redirectLocation.contains('/login')) {
throw StateError('Неверный логин или пароль'); throw StateError('Неверный логин или пароль');
} }
// Check for flash errors in session cookie if present
try {
final dotIdx = sessionValue.indexOf('.');
final payloadBase64 =
dotIdx != -1 ? sessionValue.substring(0, dotIdx) : sessionValue;
final normalizedB64 = base64Url.normalize(payloadBase64);
final decoded = utf8.decode(base64Url.decode(normalizedB64));
if (decoded.contains('is incorrect') || decoded.contains('_flashes')) {
throw StateError('Неверный логин или пароль');
}
} catch (e) {
if (e is StateError) rethrow;
}
// Verify session and fetch initial artists // Verify session and fetch initial artists
final remoteArtists = await fetchRemoteFavoriteArtists( final remoteArtists = await fetchRemoteFavoriteArtists(
baseUrl: normalizedBase, baseUrl: normalizedBase,
@@ -118,6 +152,15 @@ class PawchiveSyncService {
isActive: true, isActive: true,
syncedArtistsCount: remoteArtists.length, syncedArtistsCount: remoteArtists.length,
); );
} on DioException catch (e) {
final code = e.response?.statusCode;
if (code == 401 || code == 403) {
throw StateError('Неверный логин или пароль');
} else if (code != null) {
throw StateError('Ошибка сервера Pawchive (HTTP $code)');
} else {
throw StateError('Ошибка сети: ${e.message ?? e.type.name}');
}
} catch (e) { } catch (e) {
if (e is StateError || e is ArgumentError) rethrow; if (e is StateError || e is ArgumentError) rethrow;
throw StateError('Ошибка входа в Pawchive: $e'); throw StateError('Ошибка входа в Pawchive: $e');
@@ -156,7 +199,17 @@ class PawchiveSyncService {
isActive: true, isActive: true,
syncedArtistsCount: remoteArtists.length, syncedArtistsCount: remoteArtists.length,
); );
} on DioException catch (e) {
final code = e.response?.statusCode;
if (code == 401 || code == 403) {
throw StateError('Сессия недействительна или истекла');
} else if (code != null) {
throw StateError('Ошибка сервера Pawchive (HTTP $code)');
} else {
throw StateError('Ошибка сети: ${e.message ?? e.type.name}');
}
} catch (e) { } catch (e) {
if (e is StateError || e is ArgumentError) rethrow;
throw StateError('Сессия недействительна или истекла: $e'); throw StateError('Сессия недействительна или истекла: $e');
} }
} }
@@ -172,7 +225,7 @@ class PawchiveSyncService {
: baseUrl; : baseUrl;
final response = await _dio.get<dynamic>( final response = await _dio.get<dynamic>(
'$normalizedBase/account/favorites', '$normalizedBase/api/v1/account/favorites',
queryParameters: {'type': 'artist'}, queryParameters: {'type': 'artist'},
options: Options( options: Options(
headers: { headers: {
@@ -180,9 +233,17 @@ class PawchiveSyncService {
'Accept': 'application/json', 'Accept': 'application/json',
'User-Agent': 'Lunaris/2.0.1 Flutter local booru browser', 'User-Agent': 'Lunaris/2.0.1 Flutter local booru browser',
}, },
validateStatus: (status) => status != null && status < 500,
), ),
); );
if (response.statusCode == 401 || response.statusCode == 403) {
throw StateError('Сессия истекла или недействительна');
}
if (response.statusCode != 200) {
throw StateError('Ошибка сервера Pawchive (HTTP ${response.statusCode})');
}
final data = response.data; final data = response.data;
if (data is! List) return const []; if (data is! List) return const [];
@@ -208,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(
@@ -221,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 (_) {}
} }
@@ -263,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) {
@@ -279,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;
@@ -327,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,
); );
@@ -344,7 +505,7 @@ class PawchiveSyncService {
? account.baseUrl.substring(0, account.baseUrl.length - 1) ? account.baseUrl.substring(0, account.baseUrl.length - 1)
: account.baseUrl; : account.baseUrl;
final path = '$normalizedBase/favorites/creator/$service/$artistId'; final path = '$normalizedBase/api/v1/favorites/creator/$service/$artistId';
final options = Options( final options = Options(
headers: { headers: {
'Cookie': 'session=$cleanCookie', 'Cookie': 'session=$cleanCookie',
@@ -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?,
+60 -3
View File
@@ -1,6 +1,8 @@
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'dart:io'; import 'dart:io';
import 'package:flutter/services.dart';
import '../../app/app_version.dart'; import '../../app/app_version.dart';
import '../../core/errors/failure.dart'; import '../../core/errors/failure.dart';
import '../../core/utils/result.dart'; import '../../core/utils/result.dart';
@@ -15,9 +17,39 @@ class UpdateService {
static const releasesUrl = static const releasesUrl =
'https://gitea.rardogsynapse.online/api/v1/repos/RarDog/Lunaris/releases'; 'https://gitea.rardogsynapse.online/api/v1/repos/RarDog/Lunaris/releases';
static const _deviceChannel = MethodChannel('rulegel/device');
final Dio _dio; final Dio _dio;
final SettingsService _settingsService; final SettingsService _settingsService;
// Cached primary ABI (e.g. "arm64-v8a").
String? _cachedPrimaryAbi;
/// Returns the primary ABI of the running device.
/// On non-Android platforms returns an empty string.
Future<String> getPrimaryAbi() async {
if (_cachedPrimaryAbi != null) return _cachedPrimaryAbi!;
if (!Platform.isAndroid) return '';
try {
final abi = await _deviceChannel.invokeMethod<String>('getPrimaryAbi');
_cachedPrimaryAbi = abi ?? 'armeabi-v7a';
} catch (_) {
_cachedPrimaryAbi = 'armeabi-v7a';
}
return _cachedPrimaryAbi!;
}
/// Returns all supported ABIs of the running device.
Future<List<String>> getSupportedAbis() async {
if (!Platform.isAndroid) return const [];
try {
final abis = await _deviceChannel.invokeMethod<List<Object?>>('getSupportedAbis');
return abis?.whereType<String>().toList() ?? const [];
} catch (_) {
return const [];
}
}
Future<Result<AppUpdateInfo?>> checkForUpdates({bool force = false}) async { Future<Result<AppUpdateInfo?>> checkForUpdates({bool force = false}) async {
final settingsResult = await _settingsService.getSettings(); final settingsResult = await _settingsService.getSettings();
final settings = settingsResult is Success<AppSettings> final settings = settingsResult is Success<AppSettings>
@@ -97,21 +129,40 @@ class UpdateService {
AppUpdateInfo _releaseFromJson(Map<String, dynamic> json) { AppUpdateInfo _releaseFromJson(Map<String, dynamic> json) {
final tag = (json['tag_name'] ?? '').toString(); final tag = (json['tag_name'] ?? '').toString();
final assets = (json['assets'] as List?) ?? const []; final assets = (json['assets'] as List?) ?? const [];
String? apkUrl; String? apkUrl;
String? apkArm64Url;
String? apkArmv7Url;
String? apkX86_64Url;
String? installerUrl; String? installerUrl;
String? portableUrl; String? portableUrl;
String? linuxTarGzUrl; String? linuxTarGzUrl;
for (final item in assets.whereType<Map>()) { for (final item in assets.whereType<Map>()) {
final name = (item['name'] ?? '').toString().toLowerCase(); final name = (item['name'] ?? '').toString().toLowerCase();
final url = (item['browser_download_url'] ?? '').toString(); final url = (item['browser_download_url'] ?? '').toString();
if (url.isEmpty) continue; if (url.isEmpty) continue;
if (name.endsWith('.apk')) apkUrl = url;
if (name.endsWith('.apk')) {
// Detect per-ABI APKs by name pattern produced by --split-per-abi.
if (name.contains('arm64-v8a') || name.contains('arm64_v8a')) {
apkArm64Url = url;
} else if (name.contains('armeabi-v7a') || name.contains('armeabi_v7a')) {
apkArmv7Url = url;
} else if (name.contains('x86_64')) {
apkX86_64Url = url;
} else {
// Universal / fat APK
apkUrl = url;
}
}
if (name.endsWith('.exe')) installerUrl = url; if (name.endsWith('.exe')) installerUrl = url;
if (name.endsWith('.zip')) portableUrl = url; if (name.endsWith('.zip')) portableUrl = url;
if (name.endsWith('.tar.gz') || name.endsWith('.tgz')) { if (name.endsWith('.tar.gz') || name.endsWith('.tgz')) {
linuxTarGzUrl = url; linuxTarGzUrl = url;
} }
} }
return AppUpdateInfo( return AppUpdateInfo(
version: _versionFromTag(tag), version: _versionFromTag(tag),
tagName: tag, tagName: tag,
@@ -120,15 +171,21 @@ class UpdateService {
htmlUrl: (json['html_url'] ?? '').toString(), htmlUrl: (json['html_url'] ?? '').toString(),
publishedAt: DateTime.tryParse((json['published_at'] ?? '').toString()), publishedAt: DateTime.tryParse((json['published_at'] ?? '').toString()),
apkUrl: apkUrl, apkUrl: apkUrl,
apkArm64Url: apkArm64Url,
apkArmv7Url: apkArmv7Url,
apkX86_64Url: apkX86_64Url,
windowsInstallerUrl: installerUrl, windowsInstallerUrl: installerUrl,
portableZipUrl: portableUrl, portableZipUrl: portableUrl,
linuxTarGzUrl: linuxTarGzUrl, linuxTarGzUrl: linuxTarGzUrl,
); );
} }
String? assetUrlForCurrentPlatform(AppUpdateInfo info) { /// Returns the download URL for the current platform/ABI.
/// Pass [abi] (e.g. from [getPrimaryAbi]) on Android for per-ABI selection.
Future<String?> assetUrlForCurrentPlatform(AppUpdateInfo info) async {
if (Platform.isAndroid) { if (Platform.isAndroid) {
return info.apkUrl; final abi = await getPrimaryAbi();
return info.apkUrlForAbi(abi);
} }
if (Platform.isLinux) { if (Platform.isLinux) {
return info.linuxTarGzUrl ?? info.portableZipUrl; return info.linuxTarGzUrl ?? info.portableZipUrl;
@@ -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(
@@ -1185,7 +1185,7 @@ class _CommentsSectionState extends ConsumerState<_CommentsSection> {
} }
} }
class _PostInfoCard extends StatelessWidget { class _PostInfoCard extends StatefulWidget {
const _PostInfoCard({ const _PostInfoCard({
required this.post, required this.post,
required this.strings, required this.strings,
@@ -1198,8 +1198,90 @@ class _PostInfoCard extends StatelessWidget {
final DownloadedMedia? localMedia; final DownloadedMedia? localMedia;
final int? fileSizeBytes; final int? fileSizeBytes;
@override
State<_PostInfoCard> createState() => _PostInfoCardState();
}
class _PostInfoCardState extends State<_PostInfoCard> {
// Lazily resolved dimensions (used when post.width/height == 0).
int? _resolvedWidth;
int? _resolvedHeight;
bool _resolvingDimensions = false;
static final _dimensionCache = <String, (int, int)>{};
static bool _isImageType(String fileType) {
const imageTypes = {'image', 'jpeg', 'jpg', 'png', 'gif', 'webp', 'avif'};
return imageTypes.contains(fileType.toLowerCase());
}
@override
void initState() {
super.initState();
_maybeResolveDimensions();
}
@override
void didUpdateWidget(_PostInfoCard old) {
super.didUpdateWidget(old);
if (old.post.fileUrl != widget.post.fileUrl) {
_resolvedWidth = null;
_resolvedHeight = null;
_resolvingDimensions = false;
_maybeResolveDimensions();
}
}
void _maybeResolveDimensions() {
final post = widget.post;
// Only attempt lazy resolution when dimensions are unknown (0) and it's
// an image (not video/link which we can't resolve this way).
if (post.width != 0 || post.height != 0) return;
if (!_isImageType(post.fileType)) return;
final url = post.fileUrl.isNotEmpty ? post.fileUrl : post.sampleUrl;
if (url.isEmpty) return;
// Check cache first.
if (_dimensionCache.containsKey(url)) {
final cached = _dimensionCache[url]!;
_resolvedWidth = cached.$1;
_resolvedHeight = cached.$2;
return;
}
if (_resolvingDimensions) return;
_resolvingDimensions = true;
final imageProvider = NetworkImage(url);
final completer = imageProvider.resolve(ImageConfiguration.empty);
completer.addListener(
ImageStreamListener(
(info, _) {
final w = info.image.width;
final h = info.image.height;
_dimensionCache[url] = (w, h);
if (mounted) {
setState(() {
_resolvedWidth = w;
_resolvedHeight = h;
_resolvingDimensions = false;
});
}
},
onError: (_, __) {
if (mounted) setState(() => _resolvingDimensions = false);
},
),
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final post = widget.post;
final strings = widget.strings;
final localMedia = widget.localMedia;
final fileSizeBytes = widget.fileSizeBytes;
final theme = Theme.of(context); final theme = Theme.of(context);
final scheme = theme.colorScheme; final scheme = theme.colorScheme;
final artists = post.tagGroups['artist'] ?? final artists = post.tagGroups['artist'] ??
@@ -1208,6 +1290,14 @@ class _PostInfoCard extends StatelessWidget {
.map((t) => t.replaceFirst('artist:', '')) .map((t) => t.replaceFirst('artist:', ''))
.toList(); .toList();
// Determine which dimensions to display.
final displayWidth = post.width != 0 ? post.width : _resolvedWidth;
final displayHeight = post.height != 0 ? post.height : _resolvedHeight;
final hasDimensions = displayWidth != null && displayHeight != null;
final dimensionLabel = hasDimensions
? '$displayWidth × $displayHeight'
: (_resolvingDimensions ? '… × …' : null);
return Container( return Container(
padding: const EdgeInsets.all(14), padding: const EdgeInsets.all(14),
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -1293,7 +1383,7 @@ class _PostInfoCard extends StatelessWidget {
), ),
), ),
Text( Text(
_formatDate(post.createdAt), _formatPostDate(post.createdAt),
style: theme.textTheme.bodySmall?.copyWith( style: theme.textTheme.bodySmall?.copyWith(
color: scheme.onSurfaceVariant, color: scheme.onSurfaceVariant,
), ),
@@ -1321,8 +1411,8 @@ class _PostInfoCard extends StatelessWidget {
), ),
const SizedBox(width: 4), const SizedBox(width: 4),
Text( Text(
fileSizeBytes != null && fileSizeBytes! > 0 fileSizeBytes != null && fileSizeBytes > 0
? DownloadedMediaService.formatBytes(fileSizeBytes!) ? DownloadedMediaService.formatBytes(fileSizeBytes)
: (strings.ru ? 'Офлайн' : 'Offline'), : (strings.ru ? 'Офлайн' : 'Offline'),
style: theme.textTheme.labelSmall?.copyWith( style: theme.textTheme.labelSmall?.copyWith(
color: scheme.onPrimaryContainer, color: scheme.onPrimaryContainer,
@@ -1341,10 +1431,11 @@ class _PostInfoCard extends StatelessWidget {
runSpacing: 8, runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center, crossAxisAlignment: WrapCrossAlignment.center,
children: [ children: [
_SpecBadge( if (dimensionLabel != null)
icon: Icons.aspect_ratio_rounded, _SpecBadge(
label: '${post.width} × ${post.height}', icon: Icons.aspect_ratio_rounded,
), label: dimensionLabel,
),
if (post.fileType.isNotEmpty) if (post.fileType.isNotEmpty)
_SpecBadge( _SpecBadge(
icon: Icons.insert_drive_file_outlined, icon: Icons.insert_drive_file_outlined,
@@ -1373,10 +1464,10 @@ class _PostInfoCard extends StatelessWidget {
), ),
); );
} }
}
static String _formatDate(DateTime date) { String _formatPostDate(DateTime date) {
return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
}
} }
class _SpecBadge extends StatelessWidget { class _SpecBadge extends StatelessWidget {
@@ -1030,7 +1030,7 @@ class _SettingsContentState extends ConsumerState<_SettingsContent> {
Future<void> _downloadUpdate(WidgetRef ref, AppUpdateInfo info) async { Future<void> _downloadUpdate(WidgetRef ref, AppUpdateInfo info) async {
final updateService = ref.read(updateServiceProvider); final updateService = ref.read(updateServiceProvider);
final assetUrl = updateService.assetUrlForCurrentPlatform(info); final assetUrl = await updateService.assetUrlForCurrentPlatform(info);
if (assetUrl == null) { if (assetUrl == null) {
final url = Uri.tryParse(info.htmlUrl); final url = Uri.tryParse(info.htmlUrl);
if (url != null) { if (url != null) {
+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.6+46 version: 2.3.10+50
environment: environment:
sdk: ">=3.3.0 <4.0.0" sdk: ">=3.3.0 <4.0.0"
+80 -2
View File
@@ -253,7 +253,7 @@ void main() {
); );
expect(addResult, isTrue); expect(addResult, isTrue);
expect(lastMethod, equals('POST')); expect(lastMethod, equals('POST'));
expect(lastPath, equals('https://pawchive.pw/favorites/creator/fanbox/12345')); expect(lastPath, equals('https://pawchive.pw/api/v1/favorites/creator/fanbox/12345'));
final removeResult = await service.toggleRemoteFavorite( final removeResult = await service.toggleRemoteFavorite(
account: account, account: account,
@@ -263,7 +263,85 @@ void main() {
); );
expect(removeResult, isTrue); expect(removeResult, isTrue);
expect(lastMethod, equals('DELETE')); expect(lastMethod, equals('DELETE'));
expect(lastPath, equals('https://pawchive.pw/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'));
}); });
}); });
} }