feat: swipeable media pager, favorite artists, optimized video preview, Gitea updates and v2.0.2 release

This commit is contained in:
2026-09-03 18:09:38 +03:00
parent cc5a1a4f82
commit e419c7ad78
23 changed files with 837 additions and 266 deletions
+2 -2
View File
@@ -1,3 +1,3 @@
const appDisplayVersion = '2.0.1';
const appBuildNumber = 23;
const appDisplayVersion = '2.0.2';
const appBuildNumber = 24;
const appReleaseChannel = 'Stable';
+12
View File
@@ -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
View File
@@ -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',
+6 -1
View File
@@ -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;
}
+11
View File
@@ -114,3 +114,14 @@ class Post {
const {},
);
}
class PostNavigationContext {
const PostNavigationContext({
required this.currentPost,
required this.posts,
});
final Post currentPost;
final List<Post> posts;
}
+20 -103
View File
@@ -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();
+11 -2
View File
@@ -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 -2
View File
@@ -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,
+3 -1
View File
@@ -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(