Compare commits

...
4 Commits
14 changed files with 1340 additions and 548 deletions
+4 -4
View File
@@ -10,7 +10,7 @@
</p>
<p align="center">
<img src="https://img.shields.io/badge/Version-2.0.9-blueviolet?style=for-the-badge" alt="Version 2.0.9" />
<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" />
@@ -69,9 +69,9 @@
🔗 **[Gitea Releases (RarDog/Lunaris)](https://gitea.rardogsynapse.online/RarDog/Lunaris/releases)**
- **Android**: `Lunaris-v2.0.9.apk`
- **Linux (x64)**: `Lunaris-v2.0.9-linux-x64.tar.gz`
- **Windows**: `LunarisSetup-v2.0.9.exe` / `LunarisPortable-v2.0.9.zip`
- **Android**: `Lunaris-v2.0.10.apk`
- **Linux (x64)**: `Lunaris-v2.0.10-linux-x64.tar.gz`
- **Windows**: `LunarisSetup-v2.0.10.exe` / `LunarisPortable-v2.0.10.zip`
---
+2 -2
View File
@@ -1,3 +1,3 @@
const appDisplayVersion = '2.0.9';
const appBuildNumber = 31;
const appDisplayVersion = '2.0.13';
const appBuildNumber = 35;
const appReleaseChannel = 'Stable';
+47
View File
@@ -1,4 +1,51 @@
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',
bullets: [
'Fixed "Failed to recognize file format" error on initial video load by providing synchronous Referer headers and automatic retry.',
'Modern Mobile Video Player: cinematic top and bottom gradient glassmorphism overlays with immersive controls.',
'Double-Tap Seek: double-tap left side to seek -10s, right side to seek +10s with glowing ripple animations.',
'Hold for 2X Speed Boost: press and hold video to temporarily play at 2.0x with glowing badge, release to resume regular speed.',
'Vertical Volume Swipe: swipe up/down on the right half of the screen to adjust player volume with a floating badge.',
'Playback Speed Selector: quick chip button supporting 0.5x, 0.75x, 1.0x, 1.25x, 1.5x, 2.0x speeds.',
'Screen Lock Mode: lock player controls with one tap to prevent accidental touches during video watching.',
'Sleek Neon Seekbar with live network buffer progress indicator.',
],
),
LunarisChange(
version: '2.0.10',
title: 'Fix "and" Tag Operator Retention, Suggestion Preceding Drafts & AND Chip Styling',
bullets: [
'Fixed "and" operator disappearing when submitting search or applying autocompletions.',
'Preserved preceding draft tokens (e.g. typing "cat and d" then picking "dog" now retains "cat", "AND", and "dog").',
'Supported multiple "and" separators without accidental deduplication.',
'Distinct visual styling for AND separator chips with secondary theme container and alt-route icon.',
],
),
LunarisChange(
version: '2.0.9',
title: 'Instant Tag Suggestions, Persistent Disk Cache, Expanded History & Match Highlighting',
+9 -7
View File
@@ -28,13 +28,15 @@ class SearchService {
}
List<String> parseTags(String query) {
return query
.trim()
.split(RegExp(r'\s+'))
.map(sanitizeToken)
.where((tag) => tag.isNotEmpty && tag != 'and')
.toSet()
.toList();
final seen = <String>{};
final result = <String>[];
for (final raw in query.trim().split(RegExp(r'\s+'))) {
final tag = raw.trim();
if (tag.isEmpty) continue;
if (tag.toLowerCase() != 'and' && !seen.add(tag.toLowerCase())) continue;
result.add(tag);
}
return result;
}
Future<Result<void>> saveSearch(
@@ -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,
File diff suppressed because it is too large Load Diff
+45 -16
View File
@@ -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),
@@ -242,18 +243,34 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
Widget _buildTagChip(BuildContext context, String tag) {
final scheme = Theme.of(context).colorScheme;
final isAnd = tag.toLowerCase() == 'and';
return InputChip(
label: Text(tag),
avatar: const Icon(Icons.tag_rounded, size: 16),
label: Text(
isAnd ? 'AND' : tag,
style: TextStyle(
color: isAnd
? scheme.onSecondaryContainer
: scheme.onPrimaryContainer,
fontWeight: isAnd ? FontWeight.w900 : FontWeight.w700,
letterSpacing: isAnd ? 0.8 : 0,
),
),
avatar: Icon(
isAnd ? Icons.alt_route_rounded : Icons.tag_rounded,
size: 16,
color: isAnd ? scheme.secondary : scheme.primary,
),
deleteIcon: const Icon(Icons.close_rounded, size: 16),
onPressed: () => _editTag(tag),
onDeleted: () => _removeTag(tag),
backgroundColor: scheme.primaryContainer.withValues(alpha: 0.54),
labelStyle: TextStyle(
color: scheme.onPrimaryContainer,
fontWeight: FontWeight.w700,
backgroundColor: isAnd
? scheme.secondaryContainer.withValues(alpha: 0.7)
: scheme.primaryContainer.withValues(alpha: 0.54),
side: BorderSide(
color: isAnd
? scheme.secondary.withValues(alpha: 0.4)
: scheme.primary.withValues(alpha: 0.18),
),
side: BorderSide(color: scheme.primary.withValues(alpha: 0.18)),
visualDensity: VisualDensity.compact,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
);
@@ -306,7 +323,9 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
_localDirty = true;
setState(() {
for (final tag in additions) {
if (!_tags.contains(tag)) _tags.add(tag);
if (tag.toLowerCase() == 'and' || !_tags.contains(tag)) {
_tags.add(tag);
}
}
_controller.clear();
});
@@ -359,10 +378,19 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
_localDirty = true;
setState(() {
if (draft.isNotEmpty) {
final draftTags = _parseTags(draft);
_tags.removeWhere(draftTags.contains);
final tokens = _parseTags(draft);
if (tokens.isNotEmpty) {
final prefixTokens = tokens.sublist(0, tokens.length - 1);
for (final token in prefixTokens) {
if (token.toLowerCase() == 'and' || !_tags.contains(token)) {
_tags.add(token);
}
}
}
}
if (!_tags.contains(suggestion)) {
_tags.add(suggestion);
}
if (!_tags.contains(suggestion)) _tags.add(suggestion);
_controller.clear();
});
_notifyChanged();
@@ -387,7 +415,8 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
final result = <String>[];
for (final raw in query.trim().split(RegExp(r'\s+'))) {
final tag = raw.trim();
if (tag.isEmpty || !seen.add(tag)) continue;
if (tag.isEmpty) continue;
if (tag.toLowerCase() != 'and' && !seen.add(tag.toLowerCase())) continue;
result.add(tag);
}
return result;
+251 -153
View File
@@ -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,
),
);
}
+1 -1
View File
@@ -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,
+15 -8
View File
@@ -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
View File
@@ -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.9+31
version: 2.0.13+35
environment:
sdk: ">=3.3.0 <4.0.0"
+11
View File
@@ -43,6 +43,17 @@ void main() {
expect(service.parseTags(' cat cute cat '), ['cat', 'cute']);
});
test('parses query preserving and operators and grouping', () {
expect(
service.parseTags('cat and dog and fox'),
['cat', 'and', 'dog', 'and', 'fox'],
);
expect(
service.parseTags('(cat and dog) anthro'),
['(cat', 'and', 'dog)', 'anthro'],
);
});
test('recent searches are ordered by searchedAt', () async {
await service.saveSearch('old', 1);
await Future<void>.delayed(const Duration(milliseconds: 1));
@@ -288,6 +288,57 @@ void main() {
expect(size.height, lessThan(96));
expect(find.text('touhou'), findsOneWidget);
});
testWidgets('preserves AND operator chips when submitted', (tester) async {
String submitted = '';
await tester.pumpWidget(_Harness(
child: TagInputSearchBar(
initialValue: 'cat and dog',
onSubmitted: (query) => submitted = query,
),
));
expect(find.text('cat'), findsOneWidget);
expect(find.text('AND'), findsOneWidget);
expect(find.text('dog'), findsOneWidget);
await tester.showKeyboard(find.byType(TextField));
await tester.testTextInput.receiveAction(TextInputAction.search);
await tester.pumpAndSettle();
expect(submitted, 'cat and dog');
});
testWidgets('applying suggestion preserves preceding tokens and AND operator',
(tester) async {
String applied = '';
await tester.pumpWidget(_Harness(
child: TagInputSearchBar(
suggestions: const [
TagSuggestion(
name: 'dog',
category: TagCategory.general,
postCount: 50,
providerId: 'test',
),
],
onSuggestionApplied: (query) => applied = query,
onSubmitted: (_) {},
),
));
await tester.enterText(find.byType(TextField), 'cat and d');
await tester.pumpAndSettle();
expect(find.text('dog'), findsOneWidget);
await tester.tap(find.text('dog'));
await tester.pumpAndSettle();
expect(applied, 'cat and dog');
expect(find.text('cat'), findsOneWidget);
expect(find.text('AND'), findsOneWidget);
expect(find.text('dog'), findsOneWidget);
});
}
class _Harness extends StatelessWidget {