feat: swipeable media pager, favorite artists, optimized video preview, Gitea updates and v2.0.2 release
This commit is contained in:
@@ -1,3 +1,3 @@
|
||||
const appDisplayVersion = '2.0.1';
|
||||
const appBuildNumber = 23;
|
||||
const appDisplayVersion = '2.0.2';
|
||||
const appBuildNumber = 24;
|
||||
const appReleaseChannel = 'Stable';
|
||||
|
||||
@@ -1,4 +1,16 @@
|
||||
const lunarisChangelog = [
|
||||
LunarisChange(
|
||||
version: '2.0.2',
|
||||
title: 'Swipeable media pager, favorite artists, performance and Pawchive',
|
||||
bullets: [
|
||||
'Full horizontal post pager across mobile and desktop in Feed, Artists, Favorites, Collections, and Viewed history.',
|
||||
'Eliminated video preview stutter and lag in grid feed by using high-performance cached thumbnails and on-demand playback.',
|
||||
'Added favorite artists bar at the top of the Artists tab with quick access and recent photos/videos stream.',
|
||||
'Multi-image posts now feature an in-post navigation indicator with image switching.',
|
||||
'New Pawchive provider support and cleaned up deprecated providers.',
|
||||
'In-app update checks and downloads connected directly to RarDog Gitea releases.',
|
||||
],
|
||||
),
|
||||
LunarisChange(
|
||||
version: '2.0.1',
|
||||
title: 'Localization, zoom and favorite downloads',
|
||||
|
||||
+21
-5
@@ -40,11 +40,27 @@ final appRouterProvider = Provider<GoRouter>((ref) {
|
||||
),
|
||||
GoRoute(
|
||||
path: '/post/:providerId/:postId',
|
||||
builder: (context, state) => PostDetailsScreen(
|
||||
providerId: state.pathParameters['providerId']!,
|
||||
postId: state.pathParameters['postId']!,
|
||||
initialPost: state.extra is Post ? state.extra! as Post : null,
|
||||
),
|
||||
builder: (context, state) {
|
||||
final extra = state.extra;
|
||||
final Post? initialPost;
|
||||
final List<Post>? postsList;
|
||||
if (extra is PostNavigationContext) {
|
||||
initialPost = extra.currentPost;
|
||||
postsList = extra.posts;
|
||||
} else if (extra is Post) {
|
||||
initialPost = extra;
|
||||
postsList = null;
|
||||
} else {
|
||||
initialPost = null;
|
||||
postsList = null;
|
||||
}
|
||||
return PostDetailsScreen(
|
||||
providerId: state.pathParameters['providerId']!,
|
||||
postId: state.pathParameters['postId']!,
|
||||
initialPost: initialPost,
|
||||
postsList: postsList,
|
||||
);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: '/post/:providerId/:postId/similar',
|
||||
|
||||
@@ -9,6 +9,7 @@ class AppUpdateInfo {
|
||||
this.apkUrl,
|
||||
this.windowsInstallerUrl,
|
||||
this.portableZipUrl,
|
||||
this.linuxTarGzUrl,
|
||||
});
|
||||
|
||||
final String version;
|
||||
@@ -20,7 +21,11 @@ class AppUpdateInfo {
|
||||
final String? apkUrl;
|
||||
final String? windowsInstallerUrl;
|
||||
final String? portableZipUrl;
|
||||
final String? linuxTarGzUrl;
|
||||
|
||||
bool get hasAssets =>
|
||||
apkUrl != null || windowsInstallerUrl != null || portableZipUrl != null;
|
||||
apkUrl != null ||
|
||||
windowsInstallerUrl != null ||
|
||||
portableZipUrl != null ||
|
||||
linuxTarGzUrl != null;
|
||||
}
|
||||
|
||||
@@ -114,3 +114,14 @@ class Post {
|
||||
const {},
|
||||
);
|
||||
}
|
||||
|
||||
class PostNavigationContext {
|
||||
const PostNavigationContext({
|
||||
required this.currentPost,
|
||||
required this.posts,
|
||||
});
|
||||
|
||||
final Post currentPost;
|
||||
final List<Post> posts;
|
||||
}
|
||||
|
||||
|
||||
@@ -40,10 +40,10 @@ class ProviderRepository {
|
||||
updatedAt: now,
|
||||
),
|
||||
ContentProviderConfig(
|
||||
id: 'safebooru',
|
||||
name: 'Safebooru',
|
||||
baseUrl: 'https://safebooru.org',
|
||||
apiType: 'gelbooru',
|
||||
id: 'realbooru',
|
||||
name: 'Realbooru',
|
||||
baseUrl: 'https://realbooru.com',
|
||||
apiType: 'realbooru_html',
|
||||
enabled: true,
|
||||
priority: 2,
|
||||
timeoutSeconds: 20,
|
||||
@@ -51,44 +51,16 @@ class ProviderRepository {
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
),
|
||||
ContentProviderConfig(
|
||||
id: 'konachan',
|
||||
name: 'Konachan',
|
||||
baseUrl: 'https://konachan.com',
|
||||
apiType: 'moebooru',
|
||||
enabled: true,
|
||||
priority: 3,
|
||||
timeoutSeconds: 20,
|
||||
customHeaders: const {},
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
),
|
||||
ContentProviderConfig(
|
||||
id: 'yandere',
|
||||
name: 'Yande.re',
|
||||
baseUrl: 'https://yande.re',
|
||||
apiType: 'moebooru',
|
||||
enabled: true,
|
||||
priority: 4,
|
||||
timeoutSeconds: 20,
|
||||
customHeaders: const {
|
||||
'query.api_version': '2',
|
||||
'query.include_tags': '1',
|
||||
'query.filter': '1',
|
||||
},
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
),
|
||||
ContentProviderConfig(
|
||||
id: 'e621',
|
||||
name: 'e621',
|
||||
baseUrl: 'https://e621.net',
|
||||
apiType: 'e621',
|
||||
enabled: true,
|
||||
priority: 5,
|
||||
priority: 3,
|
||||
timeoutSeconds: 20,
|
||||
customHeaders: const {
|
||||
'User-Agent': 'Lunaris/2.0.1 Flutter local booru browser',
|
||||
'User-Agent': 'Lunaris/2.0.2 Flutter local booru browser',
|
||||
},
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
@@ -99,76 +71,10 @@ class ProviderRepository {
|
||||
baseUrl: 'https://e926.net',
|
||||
apiType: 'e621',
|
||||
enabled: true,
|
||||
priority: 6,
|
||||
priority: 4,
|
||||
timeoutSeconds: 20,
|
||||
customHeaders: const {
|
||||
'User-Agent': 'Lunaris/2.0.1 Flutter local booru browser',
|
||||
},
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
),
|
||||
ContentProviderConfig(
|
||||
id: 'realbooru',
|
||||
name: 'Realbooru',
|
||||
baseUrl: 'https://realbooru.com',
|
||||
apiType: 'realbooru_html',
|
||||
enabled: true,
|
||||
priority: 7,
|
||||
timeoutSeconds: 20,
|
||||
customHeaders: const {},
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
),
|
||||
ContentProviderConfig(
|
||||
id: 'xbooru',
|
||||
name: 'Xbooru',
|
||||
baseUrl: 'https://xbooru.com',
|
||||
apiType: 'gelbooru',
|
||||
enabled: true,
|
||||
priority: 8,
|
||||
timeoutSeconds: 20,
|
||||
customHeaders: const {},
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
),
|
||||
ContentProviderConfig(
|
||||
id: 'paheal',
|
||||
name: 'Rule34 Paheal',
|
||||
baseUrl: 'https://rule34.paheal.net',
|
||||
apiType: 'paheal',
|
||||
enabled: true,
|
||||
priority: 9,
|
||||
timeoutSeconds: 20,
|
||||
customHeaders: const {
|
||||
'User-Agent': 'Lunaris/2.0.1 Flutter local booru browser',
|
||||
},
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
),
|
||||
ContentProviderConfig(
|
||||
id: 'kemono',
|
||||
name: 'Kemono',
|
||||
baseUrl: 'https://kemono.su',
|
||||
apiType: 'kemono',
|
||||
enabled: true,
|
||||
priority: 10,
|
||||
timeoutSeconds: 20,
|
||||
customHeaders: const {
|
||||
'query.creator_api_base': 'https://kemono-api.mbaharip.com',
|
||||
},
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
),
|
||||
ContentProviderConfig(
|
||||
id: 'coomer',
|
||||
name: 'Coomer',
|
||||
baseUrl: 'https://coomer.su',
|
||||
apiType: 'coomer',
|
||||
enabled: false,
|
||||
priority: 11,
|
||||
timeoutSeconds: 20,
|
||||
customHeaders: const {
|
||||
'query.creator_api_base': 'https://kemono-api.mbaharip.com',
|
||||
'User-Agent': 'Lunaris/2.0.2 Flutter local booru browser',
|
||||
},
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
@@ -179,7 +85,7 @@ class ProviderRepository {
|
||||
baseUrl: 'https://pawchive.pw',
|
||||
apiType: 'pawchive',
|
||||
enabled: true,
|
||||
priority: 12,
|
||||
priority: 5,
|
||||
timeoutSeconds: 20,
|
||||
customHeaders: const {},
|
||||
createdAt: now,
|
||||
@@ -192,11 +98,22 @@ class ProviderRepository {
|
||||
return _databaseService.safeWrite((isar) async {
|
||||
final existingProviders =
|
||||
await isar.providerConfigEntitys.where().findAll();
|
||||
const removedIds = {
|
||||
'safebooru',
|
||||
'konachan',
|
||||
'yandere',
|
||||
'xbooru',
|
||||
'paheal',
|
||||
'kemono',
|
||||
'coomer',
|
||||
};
|
||||
for (final provider in existingProviders) {
|
||||
if (provider.providerId == 'cosbooru') {
|
||||
provider.enabled = false;
|
||||
provider.updatedAt = DateTime.now();
|
||||
await isar.providerConfigEntitys.put(provider);
|
||||
} else if (removedIds.contains(provider.providerId)) {
|
||||
await isar.providerConfigEntitys.delete(provider.isarId);
|
||||
}
|
||||
}
|
||||
final seeds = seedProviders();
|
||||
|
||||
@@ -48,12 +48,14 @@ class AppSettings {
|
||||
required this.lastFeedProviderIds,
|
||||
required this.lastFeedTopPeriod,
|
||||
required this.lastFeedScrollOffset,
|
||||
this.favoriteArtists = const [],
|
||||
this.skippedUpdateVersion,
|
||||
this.lastUpdateCheckAt,
|
||||
this.defaultRatingFilter,
|
||||
this.lastFeedRating,
|
||||
});
|
||||
|
||||
final List<String> favoriteArtists;
|
||||
final List<String> enabledProviderIds;
|
||||
final bool nsfwEnabled;
|
||||
final int cacheTtlHours;
|
||||
@@ -96,11 +98,12 @@ class AppSettings {
|
||||
final String? lastFeedRating;
|
||||
|
||||
static const defaults = AppSettings(
|
||||
enabledProviderIds: ['gelbooru', 'rule34', 'safebooru'],
|
||||
enabledProviderIds: ['gelbooru', 'rule34', 'realbooru'],
|
||||
nsfwEnabled: true,
|
||||
cacheTtlHours: 24,
|
||||
cacheMaxItems: 2000,
|
||||
providerPriority: {'gelbooru': 0, 'rule34': 1, 'safebooru': 2},
|
||||
providerPriority: {'gelbooru': 0, 'rule34': 1, 'realbooru': 2},
|
||||
favoriteArtists: [],
|
||||
themeMode: 'dark',
|
||||
languageCode: 'ru',
|
||||
appSeedColor: 0xFFE84D8A,
|
||||
@@ -180,8 +183,10 @@ class AppSettings {
|
||||
String? defaultRatingFilter,
|
||||
String? lastFeedRating,
|
||||
bool clearLastFeedRating = false,
|
||||
List<String>? favoriteArtists,
|
||||
}) {
|
||||
return AppSettings(
|
||||
favoriteArtists: favoriteArtists ?? this.favoriteArtists,
|
||||
enabledProviderIds: enabledProviderIds ?? this.enabledProviderIds,
|
||||
nsfwEnabled: nsfwEnabled ?? this.nsfwEnabled,
|
||||
cacheTtlHours: cacheTtlHours ?? this.cacheTtlHours,
|
||||
@@ -233,6 +238,7 @@ class AppSettings {
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'favoriteArtists': favoriteArtists,
|
||||
'enabledProviderIds': enabledProviderIds,
|
||||
'nsfwEnabled': nsfwEnabled,
|
||||
'cacheTtlHours': cacheTtlHours,
|
||||
@@ -365,6 +371,9 @@ class AppSettings {
|
||||
lastFeedScrollOffset:
|
||||
(json['lastFeedScrollOffset'] as num?)?.toDouble() ??
|
||||
defaults.lastFeedScrollOffset,
|
||||
favoriteArtists: List<String>.from(
|
||||
(json['favoriteArtists'] as List?) ?? defaults.favoriteArtists,
|
||||
),
|
||||
skippedUpdateVersion: json['skippedUpdateVersion'] as String?,
|
||||
lastUpdateCheckAt: json['lastUpdateCheckAt'] as String?,
|
||||
defaultRatingFilter: json['defaultRatingFilter'] as String?,
|
||||
|
||||
@@ -11,9 +11,9 @@ class UpdateService {
|
||||
UpdateService(this._dio, this._settingsService);
|
||||
|
||||
static const latestReleaseUrl =
|
||||
'https://gitea.rardogsynapse.online/api/v1/repos/RarDog/RuleGelApp/releases/latest';
|
||||
'https://gitea.rardogsynapse.online/api/v1/repos/RarDog/Lunaris/releases/latest';
|
||||
static const releasesUrl =
|
||||
'https://gitea.rardogsynapse.online/api/v1/repos/RarDog/RuleGelApp/releases';
|
||||
'https://gitea.rardogsynapse.online/api/v1/repos/RarDog/Lunaris/releases';
|
||||
|
||||
final Dio _dio;
|
||||
final SettingsService _settingsService;
|
||||
@@ -100,6 +100,7 @@ class UpdateService {
|
||||
String? apkUrl;
|
||||
String? installerUrl;
|
||||
String? portableUrl;
|
||||
String? linuxTarGzUrl;
|
||||
for (final item in assets.whereType<Map>()) {
|
||||
final name = (item['name'] ?? '').toString().toLowerCase();
|
||||
final url = (item['browser_download_url'] ?? '').toString();
|
||||
@@ -107,6 +108,9 @@ class UpdateService {
|
||||
if (name.endsWith('.apk')) apkUrl = url;
|
||||
if (name.endsWith('.exe')) installerUrl = url;
|
||||
if (name.endsWith('.zip')) portableUrl = url;
|
||||
if (name.endsWith('.tar.gz') || name.endsWith('.tgz')) {
|
||||
linuxTarGzUrl = url;
|
||||
}
|
||||
}
|
||||
return AppUpdateInfo(
|
||||
version: _versionFromTag(tag),
|
||||
@@ -118,6 +122,7 @@ class UpdateService {
|
||||
apkUrl: apkUrl,
|
||||
windowsInstallerUrl: installerUrl,
|
||||
portableZipUrl: portableUrl,
|
||||
linuxTarGzUrl: linuxTarGzUrl,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -125,6 +130,9 @@ class UpdateService {
|
||||
if (Platform.isAndroid) {
|
||||
return info.apkUrl;
|
||||
}
|
||||
if (Platform.isLinux) {
|
||||
return info.linuxTarGzUrl ?? info.portableZipUrl;
|
||||
}
|
||||
if (Platform.isWindows) {
|
||||
return info.windowsInstallerUrl ?? info.portableZipUrl;
|
||||
}
|
||||
@@ -137,6 +145,7 @@ class UpdateService {
|
||||
parsed?.pathSegments.isEmpty ?? true ? '' : parsed!.pathSegments.last;
|
||||
if (fromUrl.contains('.')) return fromUrl;
|
||||
if (Platform.isAndroid) return 'Lunaris-v${info.version}.apk';
|
||||
if (Platform.isLinux) return 'Lunaris-v${info.version}-linux-x64.tar.gz';
|
||||
if (Platform.isWindows) return 'LunarisSetup-v${info.version}.exe';
|
||||
return 'LunarisPortable-v${info.version}.zip';
|
||||
}
|
||||
|
||||
@@ -88,7 +88,10 @@ class ArtistPostsScreen extends ConsumerWidget {
|
||||
viewedKeys: viewedKeys,
|
||||
onOpen: (post) => context.push(
|
||||
'/post/${post.providerId}/${post.id}',
|
||||
extra: post,
|
||||
extra: PostNavigationContext(
|
||||
currentPost: post,
|
||||
posts: items,
|
||||
),
|
||||
),
|
||||
onFavorite: (post) async {
|
||||
if (favoriteKeys.contains(post.cacheKey)) {
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import 'dart:convert';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../app/app.dart';
|
||||
import '../../../app/responsive.dart';
|
||||
import '../../../backend/backend.dart';
|
||||
import '../../../core/utils/result.dart';
|
||||
import '../../../shared/widgets/adaptive_scaffold.dart';
|
||||
import '../../../shared/widgets/empty_view.dart';
|
||||
import '../../../shared/widgets/error_view.dart';
|
||||
import '../../settings/presentation/settings_controller.dart';
|
||||
import 'artist_posts_screen.dart';
|
||||
|
||||
final artistProviderConfigsProvider =
|
||||
FutureProvider<List<ContentProviderConfig>>((ref) async {
|
||||
@@ -37,6 +41,40 @@ class _ArtistsScreenState extends ConsumerState<ArtistsScreen> {
|
||||
Object? _error;
|
||||
List<ArtistProfile> _artists = const [];
|
||||
|
||||
String? _selectedFavoriteArtistKey;
|
||||
|
||||
Future<void> _toggleFavoriteArtist(
|
||||
AppSettings settings,
|
||||
ArtistProfile artist,
|
||||
) async {
|
||||
final current = List<String>.from(settings.favoriteArtists);
|
||||
final item = _FavoriteArtistItem(
|
||||
id: artist.id,
|
||||
service: artist.service,
|
||||
providerId: artist.providerId,
|
||||
name: artist.displayName,
|
||||
avatarUrl: artist.avatarUrl,
|
||||
);
|
||||
final key = item.key;
|
||||
final index = current.indexWhere((e) {
|
||||
try {
|
||||
final parsed =
|
||||
_FavoriteArtistItem.fromJson(jsonDecode(e) as Map<String, dynamic>);
|
||||
return parsed.key == key;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
if (index >= 0) {
|
||||
current.removeAt(index);
|
||||
} else {
|
||||
current.insert(0, jsonEncode(item.toJson()));
|
||||
}
|
||||
await ref.read(settingsControllerProvider.notifier).saveSettings(
|
||||
settings.copyWith(favoriteArtists: current),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -55,6 +93,27 @@ class _ArtistsScreenState extends ConsumerState<ArtistsScreen> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final configs = ref.watch(artistProviderConfigsProvider);
|
||||
final settings =
|
||||
ref.watch(appSettingsProvider).value ?? AppSettings.defaults;
|
||||
final favoriteItems = settings.favoriteArtists
|
||||
.map((e) {
|
||||
try {
|
||||
return _FavoriteArtistItem.fromJson(
|
||||
jsonDecode(e) as Map<String, dynamic>);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.whereType<_FavoriteArtistItem>()
|
||||
.toList();
|
||||
final favoriteKeys = favoriteItems.map((e) => e.key).toSet();
|
||||
final selectedFav = favoriteItems.firstWhere(
|
||||
(e) => e.key == _selectedFavoriteArtistKey,
|
||||
orElse: () => favoriteItems.isNotEmpty
|
||||
? favoriteItems.first
|
||||
: const _FavoriteArtistItem.empty(),
|
||||
);
|
||||
|
||||
return AdaptiveScaffold(
|
||||
title: 'Artists',
|
||||
actions: [
|
||||
@@ -71,7 +130,7 @@ class _ArtistsScreenState extends ConsumerState<ArtistsScreen> {
|
||||
if (items.isEmpty) {
|
||||
return const EmptyView(
|
||||
title: 'No artist providers',
|
||||
message: 'Enable Kemono or Coomer in Providers.',
|
||||
message: 'Enable an artist provider (such as Pawchive) in Providers.',
|
||||
);
|
||||
}
|
||||
_providerId ??= items.first.id;
|
||||
@@ -91,6 +150,32 @@ class _ArtistsScreenState extends ConsumerState<ArtistsScreen> {
|
||||
},
|
||||
onSearch: () => _refresh(items),
|
||||
),
|
||||
_FavoriteArtistsSection(
|
||||
favorites: favoriteItems,
|
||||
selectedKey: selectedFav.isEmpty ? null : selectedFav.key,
|
||||
onSelect: (fav) =>
|
||||
setState(() => _selectedFavoriteArtistKey = fav.key),
|
||||
onOpenArtist: (fav) => context.push(
|
||||
'/artists/${fav.providerId}/${fav.service}/${fav.id}?name=${Uri.encodeComponent(fav.name)}',
|
||||
),
|
||||
onRemoveFavorite: (fav) async {
|
||||
final updated = List<String>.from(settings.favoriteArtists)
|
||||
..removeWhere((e) {
|
||||
try {
|
||||
return _FavoriteArtistItem.fromJson(
|
||||
jsonDecode(e) as Map<String, dynamic>)
|
||||
.key ==
|
||||
fav.key;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
await ref
|
||||
.read(settingsControllerProvider.notifier)
|
||||
.saveSettings(
|
||||
settings.copyWith(favoriteArtists: updated));
|
||||
},
|
||||
),
|
||||
Expanded(
|
||||
child: _error != null
|
||||
? ErrorView(
|
||||
@@ -129,8 +214,13 @@ class _ArtistsScreenState extends ConsumerState<ArtistsScreen> {
|
||||
return const _ArtistSkeletonCard();
|
||||
}
|
||||
final artist = _artists[index];
|
||||
final isFav = favoriteKeys.contains(
|
||||
'${artist.providerId}:${artist.service}:${artist.id}');
|
||||
return _ArtistCard(
|
||||
artist: artist,
|
||||
isFavorite: isFav,
|
||||
onToggleFavorite: () =>
|
||||
_toggleFavoriteArtist(settings, artist),
|
||||
onTap: () => context.push(
|
||||
'/artists/${artist.providerId}/${artist.service}/${artist.id}?name=${Uri.encodeComponent(artist.displayName)}',
|
||||
),
|
||||
@@ -276,10 +366,17 @@ class _ArtistsHeader extends StatelessWidget {
|
||||
}
|
||||
|
||||
class _ArtistCard extends StatelessWidget {
|
||||
const _ArtistCard({required this.artist, required this.onTap});
|
||||
const _ArtistCard({
|
||||
required this.artist,
|
||||
required this.onTap,
|
||||
required this.isFavorite,
|
||||
required this.onToggleFavorite,
|
||||
});
|
||||
|
||||
final ArtistProfile artist;
|
||||
final VoidCallback onTap;
|
||||
final bool isFavorite;
|
||||
final VoidCallback onToggleFavorite;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -334,7 +431,15 @@ class _ArtistCard extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const SizedBox(width: 4),
|
||||
IconButton(
|
||||
tooltip: isFavorite ? 'В избранном' : 'В избранное',
|
||||
onPressed: onToggleFavorite,
|
||||
icon: Icon(
|
||||
isFavorite ? Icons.star_rounded : Icons.star_outline_rounded,
|
||||
color: isFavorite ? Colors.amber : scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const Icon(Icons.chevron_right_rounded),
|
||||
],
|
||||
),
|
||||
@@ -344,6 +449,359 @@ class _ArtistCard extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _FavoriteArtistItem {
|
||||
const _FavoriteArtistItem({
|
||||
required this.id,
|
||||
required this.service,
|
||||
required this.providerId,
|
||||
required this.name,
|
||||
this.avatarUrl,
|
||||
});
|
||||
|
||||
const _FavoriteArtistItem.empty()
|
||||
: id = '',
|
||||
service = '',
|
||||
providerId = '',
|
||||
name = '',
|
||||
avatarUrl = null;
|
||||
|
||||
final String id;
|
||||
final String service;
|
||||
final String providerId;
|
||||
final String name;
|
||||
final String? avatarUrl;
|
||||
|
||||
bool get isEmpty => id.isEmpty;
|
||||
String get key => '$providerId:$service:$id';
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'service': service,
|
||||
'providerId': providerId,
|
||||
'name': name,
|
||||
'avatarUrl': avatarUrl,
|
||||
};
|
||||
|
||||
factory _FavoriteArtistItem.fromJson(Map<String, dynamic> json) =>
|
||||
_FavoriteArtistItem(
|
||||
id: (json['id'] ?? '').toString(),
|
||||
service: (json['service'] ?? '').toString(),
|
||||
providerId: (json['providerId'] ?? '').toString(),
|
||||
name: (json['name'] ?? '').toString(),
|
||||
avatarUrl: json['avatarUrl'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
class _FavoriteArtistsSection extends ConsumerWidget {
|
||||
const _FavoriteArtistsSection({
|
||||
required this.favorites,
|
||||
required this.selectedKey,
|
||||
required this.onSelect,
|
||||
required this.onOpenArtist,
|
||||
required this.onRemoveFavorite,
|
||||
});
|
||||
|
||||
final List<_FavoriteArtistItem> favorites;
|
||||
final String? selectedKey;
|
||||
final ValueChanged<_FavoriteArtistItem> onSelect;
|
||||
final ValueChanged<_FavoriteArtistItem> onOpenArtist;
|
||||
final ValueChanged<_FavoriteArtistItem> onRemoveFavorite;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
if (favorites.isEmpty) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.fromLTRB(16, 4, 16, 8),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.surfaceContainerHighest
|
||||
.withValues(alpha: 0.4),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.outlineVariant
|
||||
.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.star_rounded, color: Colors.amber, size: 22),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Нажмите звёздочку ★ на карточке автора, чтобы добавить его в любимые и смотреть его медиа здесь.',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final selected = favorites.firstWhere(
|
||||
(f) => f.key == selectedKey,
|
||||
orElse: () => favorites.first,
|
||||
);
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.fromLTRB(16, 4, 16, 10),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.surfaceContainerHighest
|
||||
.withValues(alpha: 0.45),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.outlineVariant
|
||||
.withValues(alpha: 0.35),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(14, 10, 14, 4),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.star_rounded, color: Colors.amber, size: 20),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'Любимые авторы (${favorites.length})',
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
TextButton(
|
||||
onPressed: () => onOpenArtist(selected),
|
||||
style: TextButton.styleFrom(
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
),
|
||||
child: const Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('Все работы', style: TextStyle(fontSize: 12)),
|
||||
SizedBox(width: 2),
|
||||
Icon(Icons.arrow_forward_rounded, size: 14),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
height: 84,
|
||||
child: ListView.separated(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: favorites.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(width: 10),
|
||||
itemBuilder: (context, index) {
|
||||
final fav = favorites[index];
|
||||
final isSelected = fav.key == selected.key;
|
||||
return InkWell(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
onTap: () => onSelect(fav),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? Theme.of(context)
|
||||
.colorScheme
|
||||
.primaryContainer
|
||||
.withValues(alpha: 0.6)
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(
|
||||
color: isSelected
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Colors.transparent,
|
||||
width: 1.5,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 44,
|
||||
height: 44,
|
||||
child: ClipOval(
|
||||
child: fav.avatarUrl != null &&
|
||||
fav.avatarUrl!.isNotEmpty
|
||||
? CachedNetworkImage(
|
||||
imageUrl: fav.avatarUrl!,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (_, __) => const CircleAvatar(
|
||||
child:
|
||||
Icon(Icons.person_rounded, size: 20),
|
||||
),
|
||||
errorWidget: (_, __, ___) =>
|
||||
const CircleAvatar(
|
||||
child:
|
||||
Icon(Icons.person_rounded, size: 20),
|
||||
),
|
||||
)
|
||||
: CircleAvatar(
|
||||
child: Text(
|
||||
fav.name.isNotEmpty
|
||||
? fav.name[0].toUpperCase()
|
||||
: '?',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
SizedBox(
|
||||
width: 68,
|
||||
child: Text(
|
||||
fav.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: isSelected
|
||||
? FontWeight.bold
|
||||
: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
_FavoriteArtistMediaStrip(artist: selected),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FavoriteArtistMediaStrip extends ConsumerWidget {
|
||||
const _FavoriteArtistMediaStrip({required this.artist});
|
||||
|
||||
final _FavoriteArtistItem artist;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final query = ArtistWorkQuery(
|
||||
providerId: artist.providerId,
|
||||
service: artist.service,
|
||||
artistId: artist.id,
|
||||
artistName: artist.name,
|
||||
);
|
||||
final asyncPosts = ref.watch(artistPostsProvider(query));
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 4, 12, 10),
|
||||
child: asyncPosts.when(
|
||||
loading: () => const SizedBox(
|
||||
height: 90,
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: 22,
|
||||
height: 22,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
),
|
||||
),
|
||||
error: (_, __) => const SizedBox.shrink(),
|
||||
data: (posts) {
|
||||
if (posts.isEmpty) {
|
||||
return const SizedBox(
|
||||
height: 36,
|
||||
child: Center(
|
||||
child: Text('Нет доступных фото или видео',
|
||||
style: TextStyle(fontSize: 12)),
|
||||
),
|
||||
);
|
||||
}
|
||||
return SizedBox(
|
||||
height: 96,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: posts.length.clamp(0, 15),
|
||||
separatorBuilder: (_, __) => const SizedBox(width: 8),
|
||||
itemBuilder: (context, index) {
|
||||
final post = posts[index];
|
||||
final isVideo = MediaUrlSelector.isVideo(post);
|
||||
return InkWell(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
onTap: () => context.push(
|
||||
'/post/${post.providerId}/${post.id}',
|
||||
extra: PostNavigationContext(
|
||||
currentPost: post,
|
||||
posts: posts,
|
||||
),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Stack(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 96,
|
||||
height: 96,
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: post.previewUrl.isNotEmpty
|
||||
? post.previewUrl
|
||||
: post.sampleUrl,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (_, __) => ColoredBox(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.surfaceContainerHighest,
|
||||
),
|
||||
errorWidget: (_, __, ___) => ColoredBox(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.surfaceContainerHighest,
|
||||
child: const Icon(Icons.broken_image_rounded,
|
||||
size: 24),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (isVideo)
|
||||
Positioned(
|
||||
bottom: 6,
|
||||
right: 6,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.7),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(Icons.play_arrow_rounded,
|
||||
color: Colors.white, size: 14),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ArtistAvatar extends StatelessWidget {
|
||||
const _ArtistAvatar({required this.artist});
|
||||
|
||||
|
||||
@@ -51,7 +51,10 @@ class CollectionDetailsScreen extends ConsumerWidget {
|
||||
favoriteKeys: favoriteKeys,
|
||||
onOpen: (post) => context.push(
|
||||
'/post/${post.providerId}/${post.id}',
|
||||
extra: post,
|
||||
extra: PostNavigationContext(
|
||||
currentPost: post,
|
||||
posts: items,
|
||||
),
|
||||
),
|
||||
onFavorite: (post) async {
|
||||
if (favoriteKeys.contains(post.cacheKey)) {
|
||||
|
||||
@@ -121,7 +121,10 @@ class _FavoritesScreenState extends ConsumerState<FavoritesScreen> {
|
||||
downloadedKeys: downloadedKeys,
|
||||
onOpen: (post) => context.push(
|
||||
'/post/${post.providerId}/${post.id}',
|
||||
extra: post,
|
||||
extra: PostNavigationContext(
|
||||
currentPost: post,
|
||||
posts: posts,
|
||||
),
|
||||
),
|
||||
onFavorite: (post) =>
|
||||
ref.read(favoritesControllerProvider.notifier).remove(post),
|
||||
|
||||
@@ -296,7 +296,10 @@ class _FeedScreenState extends ConsumerState<FeedScreen>
|
||||
selectedKeys: _selectedKeys,
|
||||
onOpen: (post) => context.push(
|
||||
'/post/${post.providerId}/${post.id}',
|
||||
extra: post,
|
||||
extra: PostNavigationContext(
|
||||
currentPost: post,
|
||||
posts: state.posts,
|
||||
),
|
||||
),
|
||||
onPreview: (post) => _showPreview(context, post),
|
||||
onToggleSelected: (post) => _toggleSelected(post),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dart:ui';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
@@ -39,12 +40,14 @@ class PostDetailsScreen extends ConsumerWidget {
|
||||
required this.providerId,
|
||||
required this.postId,
|
||||
this.initialPost,
|
||||
this.postsList,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final String providerId;
|
||||
final String postId;
|
||||
final Post? initialPost;
|
||||
final List<Post>? postsList;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
@@ -57,8 +60,9 @@ class PostDetailsScreen extends ConsumerWidget {
|
||||
final settings =
|
||||
ref.watch(appSettingsProvider).value ?? AppSettings.defaults;
|
||||
final strings = ref.watch(appStringsProvider);
|
||||
final feedPosts =
|
||||
ref.watch(feedControllerProvider).value?.posts ?? const <Post>[];
|
||||
final feedPosts = postsList ??
|
||||
ref.watch(feedControllerProvider).value?.posts ??
|
||||
const <Post>[];
|
||||
final favoriteKeys = ref.watch(favoriteKeysProvider).value ?? <String>{};
|
||||
return AdaptiveScaffold(
|
||||
title: strings.post,
|
||||
@@ -698,17 +702,26 @@ class _MobilePostPagerState extends State<_MobilePostPager> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PageView.builder(
|
||||
controller: _controller,
|
||||
physics: _mediaGestureLocked
|
||||
? const NeverScrollableScrollPhysics()
|
||||
: const PageScrollPhysics(),
|
||||
itemCount: widget.posts.length,
|
||||
onPageChanged: (index) {
|
||||
_prefetchAround(index);
|
||||
},
|
||||
itemBuilder: (context, index) {
|
||||
final initialPost = widget.posts[index];
|
||||
return ScrollConfiguration(
|
||||
behavior: ScrollConfiguration.of(context).copyWith(
|
||||
dragDevices: {
|
||||
PointerDeviceKind.touch,
|
||||
PointerDeviceKind.mouse,
|
||||
PointerDeviceKind.trackpad,
|
||||
PointerDeviceKind.stylus,
|
||||
},
|
||||
),
|
||||
child: PageView.builder(
|
||||
controller: _controller,
|
||||
physics: _mediaGestureLocked
|
||||
? const NeverScrollableScrollPhysics()
|
||||
: const PageScrollPhysics(),
|
||||
itemCount: widget.posts.length,
|
||||
onPageChanged: (index) {
|
||||
_prefetchAround(index);
|
||||
},
|
||||
itemBuilder: (context, index) {
|
||||
final initialPost = widget.posts[index];
|
||||
return Consumer(
|
||||
builder: (context, ref, _) {
|
||||
final args = PostDetailsArgs(
|
||||
@@ -735,8 +748,9 @@ class _MobilePostPagerState extends State<_MobilePostPager> {
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _setMediaGestureLocked(bool locked) {
|
||||
if (_mediaGestureLocked == locked) return;
|
||||
|
||||
@@ -219,9 +219,54 @@ class _PostMediaViewerState extends State<PostMediaViewer>
|
||||
);
|
||||
},
|
||||
);
|
||||
final child = _ZoomableImage(
|
||||
onGestureLockChanged: widget.onMediaGestureLockChanged,
|
||||
child: image,
|
||||
final child = Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
_ZoomableImage(
|
||||
onGestureLockChanged: widget.onMediaGestureLockChanged,
|
||||
child: image,
|
||||
),
|
||||
if (_imageUrls.length > 1)
|
||||
Positioned(
|
||||
bottom: 14,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.65),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: Colors.white24, width: 0.8),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (_imageIndex > 0)
|
||||
GestureDetector(
|
||||
onTap: () => setState(() => _imageIndex--),
|
||||
child: const Icon(Icons.chevron_left_rounded,
|
||||
size: 20, color: Colors.white),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Text(
|
||||
'${_imageIndex + 1} / ${_imageUrls.length}',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_imageIndex < _imageUrls.length - 1)
|
||||
GestureDetector(
|
||||
onTap: () => setState(() => _imageIndex++),
|
||||
child: const Icon(Icons.chevron_right_rounded,
|
||||
size: 20, color: Colors.white),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
|
||||
@@ -52,10 +52,19 @@ class ViewedScreen extends ConsumerWidget {
|
||||
post: item.post,
|
||||
viewedAt: item.viewedAt,
|
||||
isFavorite: favoriteKeys.contains(item.post.cacheKey),
|
||||
onOpen: () => context.push(
|
||||
'/post/${item.post.providerId}/${item.post.id}',
|
||||
extra: item.post,
|
||||
),
|
||||
onOpen: () {
|
||||
final allPosts = [
|
||||
for (final g in groups)
|
||||
for (final i in g.items) i.post
|
||||
];
|
||||
context.push(
|
||||
'/post/${item.post.providerId}/${item.post.id}',
|
||||
extra: PostNavigationContext(
|
||||
currentPost: item.post,
|
||||
posts: allPosts,
|
||||
),
|
||||
);
|
||||
},
|
||||
onFavorite: () => _toggleFavorite(
|
||||
ref,
|
||||
item.post,
|
||||
|
||||
@@ -154,7 +154,9 @@ class _PostCardState extends ConsumerState<PostCard> {
|
||||
(constraints.maxWidth * dpr).round().clamp(320, 1800);
|
||||
final headers = _headersFor(post);
|
||||
final videoUrls = MediaUrlSelector.video(post);
|
||||
if (_resolvedPost != null &&
|
||||
if (!mobile &&
|
||||
_hovered &&
|
||||
_resolvedPost != null &&
|
||||
MediaUrlSelector.isVideo(post) &&
|
||||
videoUrls.isNotEmpty) {
|
||||
return _FeedVideoPreview(
|
||||
|
||||
Reference in New Issue
Block a user