Add viewed history, diagnostics, tag chips and SWF viewer
This commit is contained in:
@@ -1,6 +1,9 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../../backend/backend.dart';
|
||||
|
||||
class AppSearchBar extends StatefulWidget {
|
||||
const AppSearchBar({
|
||||
@@ -20,29 +23,449 @@ class AppSearchBar extends StatefulWidget {
|
||||
State<AppSearchBar> createState() => _AppSearchBarState();
|
||||
}
|
||||
|
||||
class _AppSearchBarState extends State<AppSearchBar> {
|
||||
class TagInputSearchBar extends StatefulWidget {
|
||||
const TagInputSearchBar({
|
||||
required this.onSubmitted,
|
||||
this.onChanged,
|
||||
this.onSuggestionApplied,
|
||||
this.initialValue,
|
||||
this.hintText = 'Search tags',
|
||||
this.suggestions = const [],
|
||||
super.key,
|
||||
});
|
||||
|
||||
final ValueChanged<String> onSubmitted;
|
||||
final ValueChanged<String>? onChanged;
|
||||
final ValueChanged<String>? onSuggestionApplied;
|
||||
final String? initialValue;
|
||||
final String hintText;
|
||||
final List<TagSuggestion> suggestions;
|
||||
|
||||
@override
|
||||
State<TagInputSearchBar> createState() => _TagInputSearchBarState();
|
||||
}
|
||||
|
||||
class _TagInputSearchBarState extends State<TagInputSearchBar> {
|
||||
late final TextEditingController _controller;
|
||||
late final FocusNode _focusNode;
|
||||
Timer? _debounce;
|
||||
List<String> _tags = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = TextEditingController(text: widget.initialValue);
|
||||
_controller = TextEditingController();
|
||||
_focusNode = FocusNode();
|
||||
_focusNode.addListener(_handleFocusChanged);
|
||||
_setFromQuery(widget.initialValue ?? '');
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant AppSearchBar oldWidget) {
|
||||
void didUpdateWidget(covariant TagInputSearchBar oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
final next = widget.initialValue ?? '';
|
||||
if (oldWidget.initialValue != widget.initialValue &&
|
||||
_controller.text != next) {
|
||||
_controller.text = next;
|
||||
if (!_focusNode.hasFocus && oldWidget.initialValue != next) {
|
||||
_setFromQuery(next);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_debounce?.cancel();
|
||||
_focusNode.removeListener(_handleFocusChanged);
|
||||
_focusNode.dispose();
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _handleFocusChanged() {
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).inputDecorationTheme.fillColor ??
|
||||
scheme.surfaceContainerHighest.withValues(alpha: 0.38),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: _focusNode.hasFocus
|
||||
? scheme.primary.withValues(alpha: 0.45)
|
||||
: Colors.transparent,
|
||||
),
|
||||
),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
onTap: _focusNode.requestFocus,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 7),
|
||||
child: Icon(Icons.search_rounded,
|
||||
color: scheme.onSurfaceVariant),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 6,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
for (final tag in _tags) _buildTagChip(context, tag),
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(
|
||||
minWidth: 120,
|
||||
maxWidth: 320,
|
||||
),
|
||||
child: Focus(
|
||||
onKeyEvent: _handleKeyEvent,
|
||||
child: TextField(
|
||||
controller: _controller,
|
||||
focusNode: _focusNode,
|
||||
autocorrect: false,
|
||||
enableSuggestions: false,
|
||||
textInputAction: TextInputAction.search,
|
||||
inputFormatters: [
|
||||
_TagCommitFormatter(onCommit: _commitDraft),
|
||||
],
|
||||
onSubmitted: (_) => _submit(),
|
||||
onChanged: (_) => _notifyChangedDebounced(),
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
border: InputBorder.none,
|
||||
enabledBorder: InputBorder.none,
|
||||
focusedBorder: InputBorder.none,
|
||||
filled: false,
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(vertical: 8),
|
||||
hintText:
|
||||
_tags.isEmpty ? widget.hintText : 'tag',
|
||||
prefixIcon: null,
|
||||
suffixIcon: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Clear',
|
||||
visualDensity: VisualDensity.compact,
|
||||
icon: const Icon(Icons.close_rounded),
|
||||
onPressed: _clear,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (widget.suggestions.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
_TagSuggestionDropdown(
|
||||
suggestions: widget.suggestions,
|
||||
onSelected: _applySuggestion,
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTagChip(BuildContext context, String tag) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return InputChip(
|
||||
label: Text(tag),
|
||||
avatar: const Icon(Icons.tag_rounded, size: 16),
|
||||
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,
|
||||
),
|
||||
side: BorderSide(color: scheme.primary.withValues(alpha: 0.18)),
|
||||
visualDensity: VisualDensity.compact,
|
||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
);
|
||||
}
|
||||
|
||||
KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) {
|
||||
if (event is! KeyDownEvent) return KeyEventResult.ignored;
|
||||
if (event.logicalKey != LogicalKeyboardKey.backspace) {
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
if (_controller.text.isNotEmpty || _tags.isEmpty) {
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
final tag = _tags.removeLast();
|
||||
_controller.value = TextEditingValue(
|
||||
text: tag,
|
||||
selection: TextSelection.collapsed(offset: tag.length),
|
||||
);
|
||||
setState(() {});
|
||||
_notifyChanged();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
void _setFromQuery(String query) {
|
||||
_tags = _parseTags(query);
|
||||
_controller.clear();
|
||||
}
|
||||
|
||||
void _commitDraft(String value) {
|
||||
final additions = _parseTags(value);
|
||||
if (additions.isEmpty) return;
|
||||
setState(() {
|
||||
for (final tag in additions) {
|
||||
if (!_tags.contains(tag)) _tags.add(tag);
|
||||
}
|
||||
_controller.clear();
|
||||
});
|
||||
_notifyChanged();
|
||||
}
|
||||
|
||||
void _submit() {
|
||||
_debounce?.cancel();
|
||||
_commitDraft(_controller.text);
|
||||
widget.onSubmitted(_query);
|
||||
}
|
||||
|
||||
void _clear() {
|
||||
_debounce?.cancel();
|
||||
setState(() {
|
||||
_tags = [];
|
||||
_controller.clear();
|
||||
});
|
||||
_notifyChanged();
|
||||
_focusNode.requestFocus();
|
||||
}
|
||||
|
||||
void _editTag(String tag) {
|
||||
setState(() {
|
||||
_tags = _tags.where((item) => item != tag).toList(growable: true);
|
||||
_controller.value = TextEditingValue(
|
||||
text: tag,
|
||||
selection: TextSelection.collapsed(offset: tag.length),
|
||||
);
|
||||
});
|
||||
_notifyChanged();
|
||||
_focusNode.requestFocus();
|
||||
}
|
||||
|
||||
void _removeTag(String tag) {
|
||||
setState(() {
|
||||
_tags = _tags.where((item) => item != tag).toList(growable: true);
|
||||
});
|
||||
_notifyChanged();
|
||||
}
|
||||
|
||||
void _applySuggestion(String suggestion) {
|
||||
final draft = _controller.text.trim();
|
||||
setState(() {
|
||||
if (draft.isNotEmpty) {
|
||||
final draftTags = _parseTags(draft);
|
||||
_tags.removeWhere(draftTags.contains);
|
||||
}
|
||||
if (!_tags.contains(suggestion)) _tags.add(suggestion);
|
||||
_controller.clear();
|
||||
});
|
||||
_notifyChanged();
|
||||
widget.onSuggestionApplied?.call(_query);
|
||||
_focusNode.requestFocus();
|
||||
}
|
||||
|
||||
void _notifyChangedDebounced() {
|
||||
_debounce?.cancel();
|
||||
_debounce = Timer(const Duration(milliseconds: 250), _notifyChanged);
|
||||
}
|
||||
|
||||
void _notifyChanged() {
|
||||
widget.onChanged?.call(_query);
|
||||
}
|
||||
|
||||
List<String> _parseTags(String query) {
|
||||
final seen = <String>{};
|
||||
final result = <String>[];
|
||||
for (final raw in query.trim().split(RegExp(r'\s+'))) {
|
||||
final tag = raw.trim();
|
||||
if (tag.isEmpty || !seen.add(tag)) continue;
|
||||
result.add(tag);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
String get _query {
|
||||
final draft = _controller.text.trim();
|
||||
final all = [..._tags, if (draft.isNotEmpty) draft];
|
||||
return all.join(' ');
|
||||
}
|
||||
}
|
||||
|
||||
class _TagCommitFormatter extends TextInputFormatter {
|
||||
_TagCommitFormatter({required this.onCommit});
|
||||
|
||||
final ValueChanged<String> onCommit;
|
||||
|
||||
@override
|
||||
TextEditingValue formatEditUpdate(
|
||||
TextEditingValue oldValue,
|
||||
TextEditingValue newValue,
|
||||
) {
|
||||
if (newValue.text.endsWith(' ') || newValue.text.endsWith('\n')) {
|
||||
final value = newValue.text.trim();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => onCommit(value));
|
||||
return const TextEditingValue();
|
||||
}
|
||||
return newValue;
|
||||
}
|
||||
}
|
||||
|
||||
class _TagSuggestionDropdown extends StatelessWidget {
|
||||
const _TagSuggestionDropdown({
|
||||
required this.suggestions,
|
||||
required this.onSelected,
|
||||
});
|
||||
|
||||
final List<TagSuggestion> suggestions;
|
||||
final ValueChanged<String> onSelected;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 620),
|
||||
child: Material(
|
||||
elevation: 8,
|
||||
shadowColor: Colors.black.withValues(alpha: 0.18),
|
||||
color: scheme.surface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: ListView.separated(
|
||||
shrinkWrap: true,
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
itemCount: suggestions.length > 8 ? 8 : suggestions.length,
|
||||
separatorBuilder: (_, __) => Divider(
|
||||
height: 1,
|
||||
indent: 52,
|
||||
color: scheme.outlineVariant.withValues(alpha: 0.55),
|
||||
),
|
||||
itemBuilder: (context, index) {
|
||||
final suggestion = suggestions[index];
|
||||
return InkWell(
|
||||
onTap: () => onSelected(suggestion.name),
|
||||
child: Padding(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.tag_rounded, color: scheme.primary, size: 20),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Text(
|
||||
suggestion.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
_TagSuggestionBadge(suggestion.categoryLabel),
|
||||
if (suggestion.postCount > 0) ...[
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
_compactCount(suggestion.postCount),
|
||||
style:
|
||||
Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _compactCount(int value) {
|
||||
if (value >= 1000000) return '${(value / 1000000).toStringAsFixed(1)}M';
|
||||
if (value >= 1000) return '${(value / 1000).toStringAsFixed(1)}K';
|
||||
return '$value';
|
||||
}
|
||||
}
|
||||
|
||||
class _TagSuggestionBadge extends StatelessWidget {
|
||||
const _TagSuggestionBadge(this.label);
|
||||
|
||||
final String label;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.primaryContainer.withValues(alpha: 0.55),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
child: Text(
|
||||
label,
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: scheme.onPrimaryContainer,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AppSearchBarState extends State<AppSearchBar> {
|
||||
late final TextEditingController _controller;
|
||||
late final FocusNode _focusNode;
|
||||
Timer? _debounce;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = TextEditingController(text: widget.initialValue);
|
||||
_focusNode = FocusNode();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant AppSearchBar oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
final next = widget.initialValue ?? '';
|
||||
if (_focusNode.hasFocus) return;
|
||||
if (oldWidget.initialValue != widget.initialValue &&
|
||||
_controller.text != next) {
|
||||
_controller.value = TextEditingValue(
|
||||
text: next,
|
||||
selection: TextSelection.collapsed(offset: next.length),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_debounce?.cancel();
|
||||
_focusNode.dispose();
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
@@ -51,8 +474,14 @@ class _AppSearchBarState extends State<AppSearchBar> {
|
||||
Widget build(BuildContext context) {
|
||||
return TextField(
|
||||
controller: _controller,
|
||||
focusNode: _focusNode,
|
||||
autocorrect: false,
|
||||
enableSuggestions: false,
|
||||
textInputAction: TextInputAction.search,
|
||||
onSubmitted: widget.onSubmitted,
|
||||
onSubmitted: (value) {
|
||||
_debounce?.cancel();
|
||||
widget.onSubmitted(value);
|
||||
},
|
||||
onChanged: (value) {
|
||||
_debounce?.cancel();
|
||||
_debounce = Timer(const Duration(milliseconds: 250), () {
|
||||
@@ -66,8 +495,10 @@ class _AppSearchBarState extends State<AppSearchBar> {
|
||||
tooltip: 'Clear',
|
||||
icon: const Icon(Icons.close_rounded),
|
||||
onPressed: () {
|
||||
_debounce?.cancel();
|
||||
_controller.clear();
|
||||
widget.onChanged?.call('');
|
||||
_focusNode.requestFocus();
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
@@ -13,6 +13,7 @@ class AppShell extends StatelessWidget {
|
||||
_Destination('Feed', Icons.dashboard_rounded, '/'),
|
||||
_Destination('Search', Icons.search_rounded, '/search'),
|
||||
_Destination('Favorites', Icons.favorite_rounded, '/favorites'),
|
||||
_Destination('Viewed', Icons.history_rounded, '/viewed'),
|
||||
_Destination(
|
||||
'Collections', Icons.collections_bookmark_rounded, '/collections'),
|
||||
_Destination('Providers', Icons.hub_rounded, '/providers'),
|
||||
|
||||
@@ -12,6 +12,7 @@ class PostCard extends StatefulWidget {
|
||||
required this.blurExplicit,
|
||||
required this.showBadges,
|
||||
required this.isFavorite,
|
||||
required this.isViewed,
|
||||
required this.onOpen,
|
||||
required this.onFavorite,
|
||||
this.onAddToCollection,
|
||||
@@ -22,6 +23,7 @@ class PostCard extends StatefulWidget {
|
||||
final bool blurExplicit;
|
||||
final bool showBadges;
|
||||
final bool isFavorite;
|
||||
final bool isViewed;
|
||||
final VoidCallback onOpen;
|
||||
final VoidCallback onFavorite;
|
||||
final VoidCallback? onAddToCollection;
|
||||
@@ -110,6 +112,12 @@ class _PostCardState extends State<PostCard> {
|
||||
bottom: 8,
|
||||
child: _MediaBadge(fileType: post.fileType),
|
||||
),
|
||||
if (widget.isViewed)
|
||||
const Positioned(
|
||||
right: 8,
|
||||
bottom: 8,
|
||||
child: _SeenBadge(),
|
||||
),
|
||||
],
|
||||
if (mobile)
|
||||
Positioned(
|
||||
@@ -279,6 +287,34 @@ class _MediaBadge extends StatelessWidget {
|
||||
};
|
||||
}
|
||||
|
||||
class _SeenBadge extends StatelessWidget {
|
||||
const _SeenBadge();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DecoratedBox(
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> showAddToCollectionPicker(
|
||||
BuildContext context, {
|
||||
required List<Collection> collections,
|
||||
|
||||
@@ -17,6 +17,7 @@ class PostMasonryGrid extends StatelessWidget {
|
||||
required this.onFavorite,
|
||||
this.onAddToCollection,
|
||||
this.favoriteKeys = const {},
|
||||
this.viewedKeys = const {},
|
||||
this.loading = false,
|
||||
this.controller,
|
||||
super.key,
|
||||
@@ -31,6 +32,7 @@ class PostMasonryGrid extends StatelessWidget {
|
||||
final ValueChanged<Post> onFavorite;
|
||||
final ValueChanged<Post>? onAddToCollection;
|
||||
final Set<String> favoriteKeys;
|
||||
final Set<String> viewedKeys;
|
||||
final bool loading;
|
||||
final ScrollController? controller;
|
||||
|
||||
@@ -52,6 +54,7 @@ class PostMasonryGrid extends StatelessWidget {
|
||||
blurExplicit: blurExplicit && !nsfwEnabled,
|
||||
showBadges: showBadges,
|
||||
isFavorite: favoriteKeys.contains(post.cacheKey),
|
||||
isViewed: viewedKeys.contains(post.cacheKey),
|
||||
onOpen: () => onOpen(post),
|
||||
onFavorite: () => onFavorite(post),
|
||||
onAddToCollection:
|
||||
|
||||
Reference in New Issue
Block a user