Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
402a5436bc | ||
|
|
80ca59d19f | ||
|
|
848fee6ff1 |
@@ -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
@@ -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) {
|
||||||
|
|||||||
@@ -1,4 +1,32 @@
|
|||||||
const lunarisChangelog = [
|
const lunarisChangelog = [
|
||||||
|
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',
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -98,10 +97,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 +132,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 +179,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 +205,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 +213,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 [];
|
||||||
|
|
||||||
@@ -344,7 +385,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',
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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.9+49
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ">=3.3.0 <4.0.0"
|
sdk: ">=3.3.0 <4.0.0"
|
||||||
|
|||||||
@@ -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,7 @@ 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'));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user