Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37f2dbf47d | ||
|
|
4bbe04dfd8 |
@@ -10,7 +10,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/Version-2.0.11-blueviolet?style=for-the-badge" alt="Version 2.0.11" />
|
||||
<img src="https://img.shields.io/badge/Version-2.0.13-blueviolet?style=for-the-badge" alt="Version 2.0.13" />
|
||||
<img src="https://img.shields.io/badge/Flutter-3.47+-02569B?style=for-the-badge&logo=flutter&logoColor=white" alt="Flutter" />
|
||||
<img src="https://img.shields.io/badge/Platform-Android%20%7C%20Linux%20%7C%20Windows-brightgreen?style=for-the-badge" alt="Platforms" />
|
||||
<img src="https://img.shields.io/badge/Gitea-RarDog%2FLunaris-orange?style=for-the-badge&logo=gitea&logoColor=white" alt="Gitea" />
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
const appDisplayVersion = '2.0.11';
|
||||
const appBuildNumber = 33;
|
||||
const appDisplayVersion = '2.0.13';
|
||||
const appBuildNumber = 35;
|
||||
const appReleaseChannel = 'Stable';
|
||||
|
||||
@@ -1,4 +1,27 @@
|
||||
const lunarisChangelog = [
|
||||
LunarisChange(
|
||||
version: '2.0.13',
|
||||
title: 'Toolbar Section Dividers & Post Card Badge Stacking Fix',
|
||||
bullets: [
|
||||
'Toolbar Section Dividers: added subtle vertical dividers between Actions (Reset/Refresh), Rating, Providers, and Top Periods to prevent visual clutter.',
|
||||
'Post Card Badge Deconfliction: moved Rating badge (Explicit/Questionable/Safe) to the bottom-left beneath the Seen/Downloaded indicators.',
|
||||
'Separated Provider & Media Badges: booru source (e.g. Gelbooru) remains top-left and Video/GIF badge remains top-right, preventing them from overlapping on narrow screens.',
|
||||
'Expanded Bottom Gradient: increased bottom cinematic gradient height to 64dp for clean text and badge contrast.',
|
||||
],
|
||||
),
|
||||
LunarisChange(
|
||||
version: '2.0.12',
|
||||
title: 'Feed 2.0: Pinterest Modern Redesign & Sleek Cards',
|
||||
bullets: [
|
||||
'Feed 2.0 Design: modern squircle post cards with 14dp border radius, subtle micro-borders, and depth shadows.',
|
||||
'Unified Media Badges: sleek glassmorphism capsules for booru providers, ratings, video/GIF indicators, and status badges.',
|
||||
'Cinematic Bottom Gradient: gentle shading on post cards for clean presentation without obscuring art.',
|
||||
'Tactile Floating Favorite Button: circular glassmorphism heart button with neon glow and instant feedback.',
|
||||
'Compact 2-Tier Feed Toolbar: 22dp search capsule, filter count badge, and single unified horizontal scroll for sources and time periods.',
|
||||
'Quick Filter & Actions: direct access to random post, selection mode, rating picker, and active filter resets.',
|
||||
'Polished Search Presets: stylish bookmark capsules for quick access to saved search queries.',
|
||||
],
|
||||
),
|
||||
LunarisChange(
|
||||
version: '2.0.11',
|
||||
title: 'Mobile Video Player Redesign & Format Recognition Fix',
|
||||
|
||||
@@ -802,15 +802,22 @@ class _SearchPresetsBar extends StatelessWidget {
|
||||
);
|
||||
},
|
||||
child: FilterChip(
|
||||
avatar: Icon(
|
||||
active ? Icons.bookmark_rounded : Icons.bookmark_border_rounded,
|
||||
size: 15,
|
||||
color: active ? scheme.primary : scheme.onSurfaceVariant,
|
||||
),
|
||||
label: Text(name),
|
||||
selected: active,
|
||||
onSelected: (_) => onApply(raw),
|
||||
selectedColor: scheme.primaryContainer,
|
||||
selectedColor: scheme.primaryContainer.withValues(alpha: 0.8),
|
||||
checkmarkColor: scheme.onPrimaryContainer,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
labelStyle: TextStyle(
|
||||
color: active ? scheme.onPrimaryContainer : scheme.onSurface,
|
||||
fontWeight:
|
||||
active ? FontWeight.w600 : FontWeight.normal,
|
||||
fontWeight: active ? FontWeight.w700 : FontWeight.w500,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -77,6 +77,8 @@ class _FeedToolbarState extends State<FeedToolbar> {
|
||||
}
|
||||
|
||||
Widget _buildDesktop(BuildContext context) {
|
||||
final hasActiveFilters = widget.selectedProviderIds.isNotEmpty || widget.rating != null;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
|
||||
child: Column(
|
||||
@@ -100,34 +102,42 @@ class _FeedToolbarState extends State<FeedToolbar> {
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
IconButton.filledTonal(
|
||||
tooltip: widget.selectionMode ? 'Exit select' : 'Select posts',
|
||||
tooltip: widget.selectionMode ? 'Выйти из выбора' : 'Выбрать посты',
|
||||
onPressed: widget.onToggleSelectionMode,
|
||||
icon: Icon(widget.selectionMode
|
||||
? Icons.check_box_rounded
|
||||
: Icons.check_box_outline_blank_rounded),
|
||||
),
|
||||
IconButton.filledTonal(
|
||||
tooltip: 'Providers',
|
||||
onPressed: widget.onProviderFilter,
|
||||
icon: const Icon(Icons.hub_rounded),
|
||||
const SizedBox(width: 4),
|
||||
Badge(
|
||||
isLabelVisible: hasActiveFilters,
|
||||
child: IconButton.filledTonal(
|
||||
tooltip: 'Провайдеры',
|
||||
onPressed: widget.onProviderFilter,
|
||||
icon: const Icon(Icons.hub_rounded),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
IconButton.filledTonal(
|
||||
tooltip: 'Rating',
|
||||
tooltip: 'Рейтинг',
|
||||
onPressed: widget.onRatingFilter,
|
||||
icon: const Icon(Icons.tune_rounded),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
IconButton(
|
||||
tooltip: 'Clear filters',
|
||||
tooltip: 'Очистить фильтры',
|
||||
onPressed: widget.onClearFilters,
|
||||
icon: const Icon(Icons.filter_alt_off_rounded),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
IconButton(
|
||||
tooltip: 'Refresh',
|
||||
tooltip: 'Обновить',
|
||||
onPressed: widget.onRefresh,
|
||||
icon: const Icon(Icons.refresh_rounded),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
IconButton.filledTonal(
|
||||
tooltip: 'Random post',
|
||||
tooltip: 'Случайный пост',
|
||||
onPressed: widget.onRandom,
|
||||
icon: const Icon(Icons.casino_rounded),
|
||||
),
|
||||
@@ -154,7 +164,7 @@ class _FeedToolbarState extends State<FeedToolbar> {
|
||||
if (widget.providers.isNotEmpty) ...[
|
||||
FilterChip(
|
||||
selected: widget.selectedProviderIds.isEmpty,
|
||||
label: const Text('All providers'),
|
||||
label: const Text('Все источники'),
|
||||
onSelected: (_) => widget.onQuickProviderToggle('__all__'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
@@ -162,14 +172,16 @@ class _FeedToolbarState extends State<FeedToolbar> {
|
||||
FilterChip(
|
||||
selected:
|
||||
widget.selectedProviderIds.contains(provider.id),
|
||||
avatar: const Icon(Icons.hub_rounded, size: 16),
|
||||
avatar: const Icon(Icons.hub_rounded, size: 15),
|
||||
label: Text(provider.name),
|
||||
onSelected: (_) =>
|
||||
widget.onQuickProviderToggle(provider.id),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
const SizedBox(width: 8),
|
||||
const SizedBox(width: 4),
|
||||
const _ToolbarDivider(),
|
||||
const SizedBox(width: 10),
|
||||
],
|
||||
for (final period in TopPeriodFilter.values) ...[
|
||||
ChoiceChip(
|
||||
@@ -188,25 +200,62 @@ class _FeedToolbarState extends State<FeedToolbar> {
|
||||
}
|
||||
|
||||
Widget _buildMobile(BuildContext context) {
|
||||
final hasActiveFilters = widget.selectedProviderIds.isNotEmpty || widget.rating != null;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 6, 8, 6),
|
||||
padding: const EdgeInsets.fromLTRB(10, 8, 10, 6),
|
||||
child: Column(
|
||||
children: [
|
||||
TagInputSearchBar(
|
||||
initialValue: _query,
|
||||
suggestions: widget.tagSuggestions,
|
||||
onChanged: (value) {
|
||||
_query = value;
|
||||
widget.onSearchChanged(value);
|
||||
},
|
||||
onSubmitted: widget.onSearch,
|
||||
onSuggestionApplied: (value) {
|
||||
_query = value;
|
||||
widget.onSuggestionTap(value);
|
||||
},
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TagInputSearchBar(
|
||||
initialValue: _query,
|
||||
suggestions: widget.tagSuggestions,
|
||||
onChanged: (value) {
|
||||
_query = value;
|
||||
widget.onSearchChanged(value);
|
||||
},
|
||||
onSubmitted: widget.onSearch,
|
||||
onSuggestionApplied: (value) {
|
||||
_query = value;
|
||||
widget.onSuggestionTap(value);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Badge(
|
||||
isLabelVisible: hasActiveFilters,
|
||||
child: IconButton.filledTonal(
|
||||
visualDensity: VisualDensity.compact,
|
||||
tooltip: 'Фильтры',
|
||||
onPressed: widget.onProviderFilter,
|
||||
icon: const Icon(Icons.tune_rounded, size: 20),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
IconButton.filledTonal(
|
||||
visualDensity: VisualDensity.compact,
|
||||
tooltip: 'Случайный пост',
|
||||
onPressed: widget.onRandom,
|
||||
icon: const Icon(Icons.casino_rounded, size: 20),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
IconButton.filledTonal(
|
||||
visualDensity: VisualDensity.compact,
|
||||
tooltip: widget.selectionMode ? 'Выйти из выбора' : 'Выбрать посты',
|
||||
onPressed: widget.onToggleSelectionMode,
|
||||
icon: Icon(
|
||||
widget.selectionMode
|
||||
? Icons.check_box_rounded
|
||||
: Icons.check_box_outline_blank_rounded,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (widget.providerStatusMessage != null) ...[
|
||||
const SizedBox(height: 6),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: InputChip(
|
||||
@@ -214,55 +263,54 @@ class _FeedToolbarState extends State<FeedToolbar> {
|
||||
label: Text(widget.providerStatusMessage!),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
],
|
||||
const SizedBox(height: 7),
|
||||
_HorizontalWheelScroller(
|
||||
height: 40,
|
||||
height: 38,
|
||||
builder: (controller) => ListView(
|
||||
controller: controller,
|
||||
scrollDirection: Axis.horizontal,
|
||||
primary: false,
|
||||
children: [
|
||||
IconButton.filledTonal(
|
||||
tooltip:
|
||||
widget.selectionMode ? 'Exit select' : 'Select posts',
|
||||
onPressed: widget.onToggleSelectionMode,
|
||||
icon: Icon(widget.selectionMode
|
||||
? Icons.check_box_rounded
|
||||
: Icons.check_box_outline_blank_rounded),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
IconButton.filledTonal(
|
||||
tooltip: 'Rating',
|
||||
onPressed: widget.onRatingFilter,
|
||||
icon: const Icon(Icons.tune_rounded),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
IconButton(
|
||||
tooltip: 'Clear filters',
|
||||
onPressed: widget.onClearFilters,
|
||||
icon: const Icon(Icons.filter_alt_off_rounded),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Refresh',
|
||||
if (hasActiveFilters || widget.selectedTags.isNotEmpty) ...[
|
||||
ActionChip(
|
||||
visualDensity: VisualDensity.compact,
|
||||
avatar: const Icon(Icons.filter_alt_off_rounded, size: 15),
|
||||
label: const Text('Сбросить'),
|
||||
onPressed: widget.onClearFilters,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
],
|
||||
ActionChip(
|
||||
visualDensity: VisualDensity.compact,
|
||||
avatar: const Icon(Icons.refresh_rounded, size: 15),
|
||||
label: const Text('Обновить'),
|
||||
onPressed: widget.onRefresh,
|
||||
icon: const Icon(Icons.refresh_rounded),
|
||||
),
|
||||
IconButton.filledTonal(
|
||||
tooltip: 'Random post',
|
||||
onPressed: widget.onRandom,
|
||||
icon: const Icon(Icons.casino_rounded),
|
||||
const SizedBox(width: 6),
|
||||
const _ToolbarDivider(),
|
||||
const SizedBox(width: 6),
|
||||
FilterChip(
|
||||
visualDensity: VisualDensity.compact,
|
||||
selected: widget.rating != null,
|
||||
avatar: const Icon(Icons.shield_outlined, size: 15),
|
||||
label: Text(widget.rating ?? 'Рейтинг'),
|
||||
onSelected: (_) => widget.onRatingFilter(),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const SizedBox(width: 6),
|
||||
const _ToolbarDivider(),
|
||||
const SizedBox(width: 6),
|
||||
if (widget.providers.isNotEmpty) ...[
|
||||
FilterChip(
|
||||
visualDensity: VisualDensity.compact,
|
||||
selected: widget.selectedProviderIds.isEmpty,
|
||||
label: const Text('All'),
|
||||
label: const Text('Все'),
|
||||
onSelected: (_) => widget.onQuickProviderToggle('__all__'),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
for (final provider in widget.providers) ...[
|
||||
FilterChip(
|
||||
visualDensity: VisualDensity.compact,
|
||||
selected:
|
||||
widget.selectedProviderIds.contains(provider.id),
|
||||
label: Text(provider.name),
|
||||
@@ -271,20 +319,13 @@ class _FeedToolbarState extends State<FeedToolbar> {
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
],
|
||||
const SizedBox(width: 2),
|
||||
const _ToolbarDivider(),
|
||||
const SizedBox(width: 6),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
_HorizontalWheelScroller(
|
||||
height: 36,
|
||||
builder: (controller) => ListView(
|
||||
controller: controller,
|
||||
scrollDirection: Axis.horizontal,
|
||||
primary: false,
|
||||
children: [
|
||||
for (final period in TopPeriodFilter.values) ...[
|
||||
ChoiceChip(
|
||||
visualDensity: VisualDensity.compact,
|
||||
selected: widget.topPeriodFilter == period,
|
||||
label: Text(period.label),
|
||||
onSelected: (_) => widget.onTopPeriodChanged(period),
|
||||
@@ -300,6 +341,20 @@ class _FeedToolbarState extends State<FeedToolbar> {
|
||||
}
|
||||
}
|
||||
|
||||
class _ToolbarDivider extends StatelessWidget {
|
||||
const _ToolbarDivider();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: 1,
|
||||
height: 20,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 2, vertical: 8),
|
||||
color: Theme.of(context).colorScheme.outlineVariant.withValues(alpha: 0.4),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HorizontalWheelScroller extends StatefulWidget {
|
||||
const _HorizontalWheelScroller({
|
||||
required this.builder,
|
||||
|
||||
@@ -121,15 +121,16 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).inputDecorationTheme.fillColor ??
|
||||
scheme.surfaceContainerHighest.withValues(alpha: 0.38),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
border: Border.all(
|
||||
color: _focusNode.hasFocus
|
||||
? scheme.primary.withValues(alpha: 0.45)
|
||||
: Colors.transparent,
|
||||
? scheme.primary.withValues(alpha: 0.6)
|
||||
: Colors.white.withValues(alpha: 0.08),
|
||||
width: 1.0,
|
||||
),
|
||||
),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
onTap: _focusNode.requestFocus,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7),
|
||||
|
||||
+251
-153
@@ -177,114 +177,161 @@ class _PostCardState extends ConsumerState<PostCard>
|
||||
],
|
||||
);
|
||||
},
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Stack(
|
||||
children: [
|
||||
AspectRatio(
|
||||
aspectRatio: aspect.clamp(0.28, 2.2),
|
||||
child: BlurContent(
|
||||
enabled: widget.blurExplicit && sensitive,
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final dpr = MediaQuery.devicePixelRatioOf(context);
|
||||
final cacheWidth =
|
||||
(constraints.maxWidth * dpr).round().clamp(320, 1800);
|
||||
final headers = _headersFor(post);
|
||||
final videoUrls = MediaUrlSelector.video(post);
|
||||
if (!mobile &&
|
||||
_hovered &&
|
||||
_resolvedPost != null &&
|
||||
MediaUrlSelector.isVideo(post) &&
|
||||
videoUrls.isNotEmpty) {
|
||||
return _FeedVideoPreview(
|
||||
videoUrl: videoUrls.first,
|
||||
headers: headers,
|
||||
fallbackImageUrl: imageUrl,
|
||||
cacheWidth: cacheWidth,
|
||||
);
|
||||
}
|
||||
return CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
httpHeaders: headers,
|
||||
memCacheWidth: cacheWidth,
|
||||
maxWidthDiskCache: cacheWidth,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (context, url) => const LoadingSkeleton(),
|
||||
errorWidget: (context, url, error) => ColoredBox(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.surfaceContainerHighest,
|
||||
child: const Center(
|
||||
child: Icon(Icons.broken_image_rounded),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(
|
||||
color: Colors.white.withValues(alpha: 0.09),
|
||||
width: 0.8,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.18),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(13.2),
|
||||
child: Stack(
|
||||
children: [
|
||||
AspectRatio(
|
||||
aspectRatio: aspect.clamp(0.28, 2.2),
|
||||
child: BlurContent(
|
||||
enabled: widget.blurExplicit && sensitive,
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final dpr = MediaQuery.devicePixelRatioOf(context);
|
||||
final cacheWidth =
|
||||
(constraints.maxWidth * dpr).round().clamp(320, 1800);
|
||||
final headers = _headersFor(post);
|
||||
final videoUrls = MediaUrlSelector.video(post);
|
||||
if (!mobile &&
|
||||
_hovered &&
|
||||
_resolvedPost != null &&
|
||||
MediaUrlSelector.isVideo(post) &&
|
||||
videoUrls.isNotEmpty) {
|
||||
return _FeedVideoPreview(
|
||||
videoUrl: videoUrls.first,
|
||||
headers: headers,
|
||||
fallbackImageUrl: imageUrl,
|
||||
cacheWidth: cacheWidth,
|
||||
);
|
||||
}
|
||||
return CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
httpHeaders: headers,
|
||||
memCacheWidth: cacheWidth,
|
||||
maxWidthDiskCache: cacheWidth,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (context, url) => const LoadingSkeleton(),
|
||||
errorWidget: (context, url, error) => ColoredBox(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.surfaceContainerHighest,
|
||||
child: const Center(
|
||||
child: Icon(Icons.broken_image_rounded),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (widget.showBadges) ...[
|
||||
// Bottom cinematic gradient overlay for badges and favorite
|
||||
Positioned(
|
||||
left: 8,
|
||||
top: 8,
|
||||
child: _ProviderBadge(name: post.providerName),
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
height: 64,
|
||||
child: IgnorePointer(
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.bottomCenter,
|
||||
end: Alignment.topCenter,
|
||||
colors: [
|
||||
Colors.black.withValues(alpha: 0.72),
|
||||
Colors.transparent,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
right: 8,
|
||||
top: 8,
|
||||
child: RatingBadge(rating: post.rating),
|
||||
),
|
||||
Positioned(
|
||||
left: 8,
|
||||
bottom: 8,
|
||||
child: _MediaBadge(fileType: post.fileType),
|
||||
),
|
||||
if (widget.isViewed)
|
||||
const Positioned(
|
||||
if (widget.showBadges) ...[
|
||||
Positioned(
|
||||
left: 8,
|
||||
top: 8,
|
||||
child: _ProviderBadge(name: post.providerName),
|
||||
),
|
||||
if (post.fileType.toLowerCase().contains('video') ||
|
||||
post.fileType.toLowerCase().contains('webm') ||
|
||||
post.fileType.toLowerCase().contains('mp4') ||
|
||||
post.fileType.toLowerCase().contains('gif'))
|
||||
Positioned(
|
||||
right: 8,
|
||||
top: 8,
|
||||
child: _MediaBadge(fileType: post.fileType),
|
||||
),
|
||||
Positioned(
|
||||
left: 8,
|
||||
bottom: 8,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (widget.isViewed || widget.isDownloaded) ...[
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (widget.isViewed) const _SeenBadge(),
|
||||
if (widget.isViewed && widget.isDownloaded)
|
||||
const SizedBox(width: 4),
|
||||
if (widget.isDownloaded)
|
||||
const _DownloadedBadge(),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
],
|
||||
RatingBadge(rating: post.rating),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
if (widget.selected)
|
||||
Positioned.fill(
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.primary
|
||||
.withValues(alpha: 0.24),
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
width: 3,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(13),
|
||||
),
|
||||
child: const Align(
|
||||
alignment: Alignment.topRight,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(8),
|
||||
child: Icon(Icons.check_circle_rounded),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (mobile)
|
||||
Positioned(
|
||||
right: 8,
|
||||
bottom: 8,
|
||||
child: _SeenBadge(),
|
||||
),
|
||||
if (widget.isDownloaded)
|
||||
Positioned(
|
||||
right: widget.isViewed ? 72 : 8,
|
||||
bottom: 8,
|
||||
child: const _DownloadedBadge(),
|
||||
),
|
||||
],
|
||||
if (widget.selected)
|
||||
Positioned.fill(
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.primary
|
||||
.withValues(alpha: 0.24),
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
width: 3,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Align(
|
||||
alignment: Alignment.topRight,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(8),
|
||||
child: Icon(Icons.check_circle_rounded),
|
||||
),
|
||||
child: _FavoriteButton(
|
||||
isFavorite: widget.isFavorite,
|
||||
onPressed: widget.onFavorite,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (mobile)
|
||||
Positioned(
|
||||
right: 8,
|
||||
bottom: 8,
|
||||
child: _FavoriteButton(
|
||||
isFavorite: widget.isFavorite,
|
||||
onPressed: widget.onFavorite,
|
||||
),
|
||||
),
|
||||
// Double-tap heart animation overlay
|
||||
if (_showHeart)
|
||||
Positioned.fill(
|
||||
@@ -369,7 +416,8 @@ class _PostCardState extends ConsumerState<PostCard>
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -635,13 +683,37 @@ class _FavoriteButton extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return IconButton.filledTonal(
|
||||
visualDensity: VisualDensity.compact,
|
||||
tooltip: isFavorite ? 'Remove favorite' : 'Favorite',
|
||||
onPressed: onPressed,
|
||||
icon: Icon(
|
||||
isFavorite ? Icons.favorite_rounded : Icons.favorite_border_rounded,
|
||||
size: 18,
|
||||
return InkResponse(
|
||||
onTap: onPressed,
|
||||
radius: 20,
|
||||
child: AnimatedContainer(
|
||||
duration: AppMotion.duration(context, 150),
|
||||
width: 34,
|
||||
height: 34,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.58),
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: isFavorite
|
||||
? Colors.redAccent.withValues(alpha: 0.6)
|
||||
: Colors.white.withValues(alpha: 0.16),
|
||||
width: 0.9,
|
||||
),
|
||||
boxShadow: [
|
||||
if (isFavorite)
|
||||
BoxShadow(
|
||||
color: Colors.redAccent.withValues(alpha: 0.35),
|
||||
blurRadius: 10,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Center(
|
||||
child: Icon(
|
||||
isFavorite ? Icons.favorite_rounded : Icons.favorite_border_rounded,
|
||||
color: isFavorite ? Colors.redAccent : Colors.white,
|
||||
size: 18,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -654,16 +726,23 @@ class _ProviderBadge extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DecoratedBox(
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3.5),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.55),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
color: Colors.black.withValues(alpha: 0.62),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: Colors.white.withValues(alpha: 0.12),
|
||||
width: 0.7,
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3),
|
||||
child: Text(
|
||||
name,
|
||||
style: const TextStyle(fontSize: 11, color: Colors.white),
|
||||
child: Text(
|
||||
name,
|
||||
style: const TextStyle(
|
||||
fontSize: 10.5,
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.2,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -678,25 +757,32 @@ class _MediaBadge extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final type = _type(fileType);
|
||||
return DecoratedBox(
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3.5),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.55),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(_icon(type), size: 12, color: Colors.white),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
type,
|
||||
style: const TextStyle(fontSize: 11, color: Colors.white),
|
||||
),
|
||||
],
|
||||
color: Colors.black.withValues(alpha: 0.62),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: Colors.white.withValues(alpha: 0.12),
|
||||
width: 0.7,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(_icon(type), size: 13, color: Colors.white),
|
||||
const SizedBox(width: 3.5),
|
||||
Text(
|
||||
type,
|
||||
style: const TextStyle(
|
||||
fontSize: 10.5,
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: 0.3,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -723,25 +809,31 @@ class _SeenBadge extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DecoratedBox(
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3.5),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.55),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 7, vertical: 3),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.visibility_rounded, size: 12, color: Colors.white),
|
||||
SizedBox(width: 4),
|
||||
Text(
|
||||
'seen',
|
||||
style: TextStyle(fontSize: 11, color: Colors.white),
|
||||
),
|
||||
],
|
||||
color: Colors.black.withValues(alpha: 0.62),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: Colors.white.withValues(alpha: 0.12),
|
||||
width: 0.7,
|
||||
),
|
||||
),
|
||||
child: const Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.visibility_rounded, size: 12, color: Colors.white70),
|
||||
SizedBox(width: 3.5),
|
||||
Text(
|
||||
'seen',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: Colors.white70,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -751,14 +843,20 @@ class _DownloadedBadge extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DecoratedBox(
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3.5),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.55),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
color: Colors.black.withValues(alpha: 0.62),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: Colors.white.withValues(alpha: 0.12),
|
||||
width: 0.7,
|
||||
),
|
||||
),
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 7, vertical: 3),
|
||||
child: Icon(Icons.download_done_rounded, size: 13, color: Colors.white),
|
||||
child: const Icon(
|
||||
Icons.download_done_rounded,
|
||||
size: 13,
|
||||
color: Colors.white,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -95,7 +95,7 @@ class PostMasonryGrid extends StatelessWidget {
|
||||
itemBuilder: (context, index) {
|
||||
if (index >= posts.length) return const LoadingSkeleton();
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: SizedBox.expand(
|
||||
child: FittedBox(
|
||||
fit: BoxFit.cover,
|
||||
|
||||
@@ -14,16 +14,23 @@ class RatingBadge extends StatelessWidget {
|
||||
'explicit' || 'e' => Colors.red,
|
||||
_ => Colors.blueGrey,
|
||||
};
|
||||
return DecoratedBox(
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6.5, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.85),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
color: color.withValues(alpha: 0.88),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: Colors.white.withValues(alpha: 0.18),
|
||||
width: 0.7,
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3),
|
||||
child: Text(
|
||||
rating.isEmpty ? 'unknown' : rating,
|
||||
style: const TextStyle(fontSize: 11, color: Colors.white),
|
||||
child: Text(
|
||||
rating.isEmpty ? 'unknown' : rating,
|
||||
style: const TextStyle(
|
||||
fontSize: 10.5,
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.2,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
name: gel_rule_app
|
||||
description: Local backend/core layer for a Flutter booru browser app.
|
||||
publish_to: "none"
|
||||
version: 2.0.11+33
|
||||
version: 2.0.13+35
|
||||
|
||||
environment:
|
||||
sdk: ">=3.3.0 <4.0.0"
|
||||
|
||||
Reference in New Issue
Block a user