feat: import Chinese-localized Buzz source snapshot
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
Signed-off-by: cls_宁波本机 <908705107@qq.com>
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import '../../shared/relay/relay.dart';
|
||||
import '../../shared/theme/theme_provider.dart';
|
||||
|
||||
const _recentSearchesPrefsKey = 'recent_searches_v1';
|
||||
const _maxRecentSearches = 6;
|
||||
|
||||
/// Device-local history of explicitly submitted searches, newest first.
|
||||
///
|
||||
/// Queries are scoped by community and account so searches from one identity
|
||||
/// cannot appear after switching to another.
|
||||
class RecentSearchesNotifier extends Notifier<List<String>> {
|
||||
late String _prefsKey;
|
||||
|
||||
@override
|
||||
List<String> build() {
|
||||
final config = ref.watch(relayConfigProvider);
|
||||
final pubkey = ref.watch(myPubkeyProvider) ?? 'anon';
|
||||
_prefsKey = '$_recentSearchesPrefsKey:${config.baseUrl}:$pubkey';
|
||||
|
||||
final prefs = ref.read(savedPrefsProvider);
|
||||
final stored =
|
||||
readMigratedPref<List<String>>(
|
||||
prefs,
|
||||
canonicalKey: _prefsKey,
|
||||
legacyKey: '$_recentSearchesPrefsKey:${config.storedOrigin}:$pubkey',
|
||||
read: prefs.getStringList,
|
||||
write: prefs.setStringList,
|
||||
) ??
|
||||
const [];
|
||||
return List.unmodifiable(
|
||||
stored
|
||||
.map((query) => query.trim())
|
||||
.where((query) => query.isNotEmpty)
|
||||
.take(_maxRecentSearches),
|
||||
);
|
||||
}
|
||||
|
||||
/// Records [query] as the most recent search after trimming whitespace.
|
||||
///
|
||||
/// Empty queries are ignored. Existing matches are deduplicated
|
||||
/// case-insensitively, the newest spelling is retained, the history is capped
|
||||
/// at six entries, and the result is persisted for the current community and
|
||||
/// account.
|
||||
void record(String query) {
|
||||
final trimmed = query.trim();
|
||||
if (trimmed.isEmpty) return;
|
||||
|
||||
final normalized = trimmed.toLowerCase();
|
||||
final next = [
|
||||
trimmed,
|
||||
...state.where((item) => item.toLowerCase() != normalized),
|
||||
].take(_maxRecentSearches).toList(growable: false);
|
||||
_persist(next);
|
||||
}
|
||||
|
||||
/// Clears the current community and account's history and persists it empty.
|
||||
void clear() => _persist(const []);
|
||||
|
||||
void _persist(List<String> searches) {
|
||||
state = List.unmodifiable(searches);
|
||||
ref.read(savedPrefsProvider).setStringList(_prefsKey, searches);
|
||||
}
|
||||
}
|
||||
|
||||
/// Provides device-local recent searches scoped to the active community and
|
||||
/// account.
|
||||
final recentSearchesProvider =
|
||||
NotifierProvider<RecentSearchesNotifier, List<String>>(
|
||||
RecentSearchesNotifier.new,
|
||||
);
|
||||
@@ -0,0 +1,999 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
||||
import '../../shared/mentions/agent_identity_provider.dart';
|
||||
import '../../shared/mentions/mention_tags.dart';
|
||||
import '../../shared/l10n/l10n.dart';
|
||||
import '../../shared/theme/theme.dart';
|
||||
import '../../shared/widgets/avatar_image.dart';
|
||||
import '../../shared/widgets/buzz_loading_indicator.dart';
|
||||
import '../../shared/widgets/filter_chip_bar.dart';
|
||||
import '../../shared/widgets/frosted_app_bar.dart';
|
||||
import '../../shared/widgets/frosted_scaffold.dart';
|
||||
import '../../shared/widgets/message_author_meta.dart';
|
||||
import '../channels/channel.dart';
|
||||
import '../channels/channel_detail_page.dart';
|
||||
import '../channels/channel_management_provider.dart';
|
||||
import '../channels/channels_provider.dart';
|
||||
import '../channels/small_avatar.dart';
|
||||
import '../channels/message_content.dart';
|
||||
import '../channels/date_formatters.dart';
|
||||
import '../forum/forum_thread_page.dart';
|
||||
import '../profile/profile_provider.dart';
|
||||
import '../profile/user_cache_provider.dart';
|
||||
import '../profile/user_profile.dart';
|
||||
import 'recent_searches_provider.dart';
|
||||
import 'search_provider.dart';
|
||||
|
||||
part 'search_page/motion_field.dart';
|
||||
enum _SearchFilter { all, messages, channels, people }
|
||||
|
||||
const _searchFieldMinHeight = 36.0;
|
||||
const _searchIdleFieldHeight = 45.0;
|
||||
const _searchIdleTextSize = 15.0;
|
||||
const _searchFieldVerticalPadding = Grid.xxs;
|
||||
const _searchFieldMoveDuration = Duration(milliseconds: 160);
|
||||
const _searchTitleReturnDuration = Duration(milliseconds: 80);
|
||||
const _searchCancelEnterDuration = Duration(milliseconds: 80);
|
||||
const _searchCancelExitDuration = Duration(milliseconds: 60);
|
||||
const _searchIdleFieldTopInset = Grid.half;
|
||||
const _searchActiveFieldTopOffset = 42.0;
|
||||
const _searchBottomOverlap =
|
||||
_searchActiveFieldTopOffset + _searchIdleFieldTopInset;
|
||||
const _searchFilterChipVerticalPadding = Grid.xxs;
|
||||
const _searchFilterBarVerticalPadding = Grid.xxs;
|
||||
const _searchHeaderFiltersMinHeight = Grid.xl;
|
||||
const _searchActiveFieldRightInsetMin = 72.0;
|
||||
|
||||
/// Reserves the Cancel action's scaled label, padding, and app-bar edge inset.
|
||||
double _searchActiveFieldRightInset(BuildContext context) {
|
||||
final textPainter = TextPainter(
|
||||
text: TextSpan(
|
||||
text: context.l10n.searchCancel,
|
||||
style: filterChipTextStyle.copyWith(fontWeight: FontWeight.w500),
|
||||
),
|
||||
textScaler: MediaQuery.textScalerOf(context),
|
||||
textDirection: Directionality.of(context),
|
||||
)..layout();
|
||||
final cancelWidth = textPainter.width + Grid.half * 2 + Grid.twelve;
|
||||
return cancelWidth > _searchActiveFieldRightInsetMin
|
||||
? cancelWidth
|
||||
: _searchActiveFieldRightInsetMin;
|
||||
}
|
||||
|
||||
double _searchFieldHeight(BuildContext context) {
|
||||
const style = searchInputTextStyle;
|
||||
final scaledFontSize = MediaQuery.textScalerOf(
|
||||
context,
|
||||
).scale(style.fontSize ?? 15);
|
||||
final contentHeight =
|
||||
scaledFontSize * (style.height ?? 1) + _searchFieldVerticalPadding * 2;
|
||||
return contentHeight > _searchFieldMinHeight
|
||||
? contentHeight
|
||||
: _searchFieldMinHeight;
|
||||
}
|
||||
|
||||
double _idleSearchFieldHeight(BuildContext context) {
|
||||
final scaledFontSize = MediaQuery.textScalerOf(
|
||||
context,
|
||||
).scale(_searchIdleTextSize);
|
||||
final contentHeight =
|
||||
scaledFontSize * (20 / _searchIdleTextSize) +
|
||||
_searchFieldVerticalPadding * 2;
|
||||
return contentHeight > _searchIdleFieldHeight
|
||||
? contentHeight
|
||||
: _searchIdleFieldHeight;
|
||||
}
|
||||
|
||||
double _searchHeaderFiltersHeight(BuildContext context) {
|
||||
const style = filterChipTextStyle;
|
||||
final scaledLabelHeight =
|
||||
MediaQuery.textScalerOf(context).scale(style.fontSize ?? 15) *
|
||||
(style.height ?? 1);
|
||||
final chipHeight = scaledLabelHeight + _searchFilterChipVerticalPadding * 2;
|
||||
final contentHeight = chipHeight + _searchFilterBarVerticalPadding * 2;
|
||||
return contentHeight > _searchHeaderFiltersMinHeight
|
||||
? contentHeight
|
||||
: _searchHeaderFiltersMinHeight;
|
||||
}
|
||||
|
||||
class SearchPage extends HookConsumerWidget {
|
||||
const SearchPage({this.tabReselection, super.key});
|
||||
|
||||
/// Notifies this page when its already-selected tab is tapped again.
|
||||
final ValueListenable<int>? tabReselection;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final searchState = ref.watch(searchProvider);
|
||||
final currentPubkey = ref
|
||||
.watch(profileProvider)
|
||||
.whenData((value) => value?.pubkey)
|
||||
.value;
|
||||
final activeFilter = useState(_SearchFilter.all);
|
||||
final textController = useTextEditingController();
|
||||
final focusNode = useFocusNode();
|
||||
final isSearchEditing = useState(false);
|
||||
final showSearchTitle = useState(true);
|
||||
final isTabActivationInFlight = useRef(false);
|
||||
final reduceMotion = MediaQuery.disableAnimationsOf(context);
|
||||
final searchSurfaceColor = navigationSearchSurface(context);
|
||||
final searchPrimaryColor = navigationPrimaryForeground(context);
|
||||
final searchPlaceholderColor = navigationSecondaryForeground(context);
|
||||
final headerTitleStyle = context.textTheme.titleMedium?.copyWith(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w600,
|
||||
);
|
||||
final compactSearchFieldHeight = _searchFieldHeight(context);
|
||||
final idleSearchFieldHeight = _idleSearchFieldHeight(context);
|
||||
final searchHeaderFiltersHeight = _searchHeaderFiltersHeight(context);
|
||||
final searchActiveFieldRightInset = _searchActiveFieldRightInset(context);
|
||||
// Cancel remains an accessible target without giving the text action a
|
||||
// visual button treatment.
|
||||
final searchControlHeight = compactSearchFieldHeight > Grid.xl
|
||||
? compactSearchFieldHeight
|
||||
: Grid.xl;
|
||||
final searchHeaderBottomHeight = isSearchEditing.value
|
||||
? _searchIdleFieldTopInset +
|
||||
compactSearchFieldHeight +
|
||||
searchHeaderFiltersHeight
|
||||
: idleSearchFieldHeight + _searchIdleFieldTopInset + Grid.xxs;
|
||||
final topSectionHeight = frostedAppBarHeight(
|
||||
context,
|
||||
titleStyle: headerTitleStyle,
|
||||
bottomHeight: searchHeaderBottomHeight,
|
||||
);
|
||||
|
||||
void activateSearch() {
|
||||
showSearchTitle.value = false;
|
||||
isSearchEditing.value = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (context.mounted && isSearchEditing.value) {
|
||||
focusNode.requestFocus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void deactivateSearch() {
|
||||
if (!isSearchEditing.value) return;
|
||||
// The field is painted above the title while it returns to its idle
|
||||
// position. Start this fade now so the title is already there beneath it
|
||||
// and is revealed by the field's motion instead of arriving afterward.
|
||||
showSearchTitle.value = true;
|
||||
isSearchEditing.value = false;
|
||||
}
|
||||
|
||||
useEffect(() {
|
||||
final tabReselection = this.tabReselection;
|
||||
if (tabReselection == null) return null;
|
||||
|
||||
void reactivateSearch() {
|
||||
// The same tab gesture can report focus loss after this callback. Keep
|
||||
// that notification from starting a competing return animation while
|
||||
// the normal field activation path restores focus.
|
||||
isTabActivationInFlight.value = true;
|
||||
activateSearch();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
isTabActivationInFlight.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
tabReselection.addListener(reactivateSearch);
|
||||
return () => tabReselection.removeListener(reactivateSearch);
|
||||
}, [tabReselection, focusNode]);
|
||||
|
||||
useEffect(() {
|
||||
void resetIdlePromptWhenFocusLeaves() {
|
||||
if (!focusNode.hasFocus && !isTabActivationInFlight.value) {
|
||||
deactivateSearch();
|
||||
}
|
||||
}
|
||||
|
||||
focusNode.addListener(resetIdlePromptWhenFocusLeaves);
|
||||
return () => focusNode.removeListener(resetIdlePromptWhenFocusLeaves);
|
||||
}, [focusNode]);
|
||||
|
||||
void runRecentSearch(String query) {
|
||||
textController.value = TextEditingValue(
|
||||
text: query,
|
||||
selection: TextSelection.collapsed(offset: query.length),
|
||||
);
|
||||
activateSearch();
|
||||
ref.read(recentSearchesProvider.notifier).record(query);
|
||||
ref.read(searchProvider.notifier).search(query);
|
||||
}
|
||||
|
||||
return FrostedScaffold(
|
||||
backgroundColor: context.colors.surface,
|
||||
// Keep the empty state centered in the page rather than the portion left
|
||||
// above the keyboard.
|
||||
resizeToAvoidBottomInset: false,
|
||||
appBar: FrostedAppBar(
|
||||
automaticallyImplyLeading: false,
|
||||
horizontalInset: Grid.twelve,
|
||||
showBottomDivider: true,
|
||||
bottomDividerOpacity: 0.06,
|
||||
titleStyle: headerTitleStyle,
|
||||
// Keep this mounted through the search-field morph so it can fade in
|
||||
// beneath the returning field rather than popping in afterward.
|
||||
title: IgnorePointer(
|
||||
child: AnimatedOpacity(
|
||||
key: const Key('search-header-title-opacity'),
|
||||
duration: reduceMotion ? Duration.zero : _searchTitleReturnDuration,
|
||||
curve: Curves.easeOutCubic,
|
||||
opacity: showSearchTitle.value ? 1 : 0,
|
||||
child: Text(
|
||||
context.l10n.searchTitle,
|
||||
key: const ValueKey('search-header-title'),
|
||||
style: headerTitleStyle,
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
AnimatedSwitcher(
|
||||
duration: reduceMotion ? Duration.zero : _searchCancelEnterDuration,
|
||||
reverseDuration: reduceMotion
|
||||
? Duration.zero
|
||||
: _searchCancelExitDuration,
|
||||
transitionBuilder: (child, animation) {
|
||||
final curvedAnimation = CurvedAnimation(
|
||||
parent: animation,
|
||||
curve: Curves.easeOutCubic,
|
||||
reverseCurve: Curves.easeInCubic,
|
||||
);
|
||||
return SizeTransition(
|
||||
sizeFactor: curvedAnimation,
|
||||
axis: Axis.horizontal,
|
||||
axisAlignment: 1,
|
||||
child: FadeTransition(
|
||||
opacity: curvedAnimation,
|
||||
child: SlideTransition(
|
||||
position: Tween<Offset>(
|
||||
begin: const Offset(0.35, 0),
|
||||
end: Offset.zero,
|
||||
).animate(curvedAnimation),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: isSearchEditing.value
|
||||
? Semantics(
|
||||
key: const Key('search-cancel'),
|
||||
button: true,
|
||||
label: context.l10n.searchCancelSemantic,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () {
|
||||
textController.clear();
|
||||
ref.read(searchProvider.notifier).clear();
|
||||
deactivateSearch();
|
||||
focusNode.unfocus();
|
||||
},
|
||||
child: SizedBox(
|
||||
height: searchControlHeight,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: Grid.half,
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
context.l10n.searchCancel,
|
||||
style: filterChipTextStyle.copyWith(
|
||||
color: context.colors.primary,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink(key: ValueKey('search-cancel-hidden')),
|
||||
),
|
||||
],
|
||||
bottomHeight: searchHeaderBottomHeight,
|
||||
bottomOverlap: _searchBottomOverlap,
|
||||
bottom: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
AnimatedPositioned(
|
||||
duration: reduceMotion ? Duration.zero : _searchFieldMoveDuration,
|
||||
curve: Curves.easeInOutCubic,
|
||||
left: Grid.gutter,
|
||||
right: isSearchEditing.value
|
||||
? searchActiveFieldRightInset
|
||||
: Grid.gutter,
|
||||
top: isSearchEditing.value
|
||||
? _searchIdleFieldTopInset
|
||||
: _searchBottomOverlap + _searchIdleFieldTopInset,
|
||||
height: isSearchEditing.value
|
||||
? compactSearchFieldHeight
|
||||
: idleSearchFieldHeight,
|
||||
// Do not key this subtree by the visual state: replacing the
|
||||
// TextField immediately after its first tap can detach its
|
||||
// native input connection before the keyboard is shown.
|
||||
child: SizedBox(
|
||||
key: const Key('search-field-container'),
|
||||
child: _SearchMotionField(
|
||||
controller: textController,
|
||||
focusNode: focusNode,
|
||||
iconColor: searchPrimaryColor,
|
||||
inputColor: searchPrimaryColor,
|
||||
placeholderColor: searchPlaceholderColor,
|
||||
surfaceColor: searchSurfaceColor,
|
||||
isSearchEditing: isSearchEditing.value,
|
||||
reduceMotion: reduceMotion,
|
||||
motionDuration: _searchFieldMoveDuration,
|
||||
onTap: activateSearch,
|
||||
onChanged: (value) =>
|
||||
ref.read(searchProvider.notifier).search(value),
|
||||
onSubmitted: (value) {
|
||||
final query = value.trim();
|
||||
if (query.isEmpty) return;
|
||||
ref.read(recentSearchesProvider.notifier).record(query);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned.fill(
|
||||
child: AnimatedSwitcher(
|
||||
duration: reduceMotion
|
||||
? Duration.zero
|
||||
: _searchCancelEnterDuration,
|
||||
switchInCurve: Curves.easeOutCubic,
|
||||
switchOutCurve: Curves.easeInCubic,
|
||||
transitionBuilder: (child, animation) => FadeTransition(
|
||||
opacity: animation,
|
||||
child: SlideTransition(
|
||||
position: Tween<Offset>(
|
||||
begin: const Offset(0, 0.2),
|
||||
end: Offset.zero,
|
||||
).animate(animation),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
child: isSearchEditing.value
|
||||
? Align(
|
||||
alignment: Alignment.topCenter,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(top: _searchBottomOverlap),
|
||||
child: SizedBox(
|
||||
key: const ValueKey('search-header-filters'),
|
||||
height: searchHeaderFiltersHeight,
|
||||
child: FilterChipBar<_SearchFilter>(
|
||||
expandItems: true,
|
||||
visualDensity: const VisualDensity(
|
||||
horizontal: -2,
|
||||
),
|
||||
chipVerticalPadding:
|
||||
_searchFilterChipVerticalPadding,
|
||||
barVerticalPadding:
|
||||
_searchFilterBarVerticalPadding,
|
||||
selected: activeFilter.value,
|
||||
onSelected: (f) => activeFilter.value = f,
|
||||
items: [
|
||||
for (final f in _SearchFilter.values)
|
||||
FilterChipItem(id: f, label: f.label(context)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink(
|
||||
key: ValueKey('search-header-filters-hidden'),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
body: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
SizedBox(height: topSectionHeight),
|
||||
Expanded(
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(Radii.dialog),
|
||||
),
|
||||
child: ColoredBox(
|
||||
color: context.colors.surface,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Expanded(
|
||||
child: _SearchBody(
|
||||
state: searchState,
|
||||
filter: activeFilter.value,
|
||||
currentPubkey: currentPubkey,
|
||||
onRecentSearchSelected: runRecentSearch,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SearchBody extends ConsumerWidget {
|
||||
final SearchState state;
|
||||
final _SearchFilter filter;
|
||||
final String? currentPubkey;
|
||||
final ValueChanged<String> onRecentSearchSelected;
|
||||
|
||||
const _SearchBody({
|
||||
required this.state,
|
||||
required this.filter,
|
||||
required this.currentPubkey,
|
||||
required this.onRecentSearchSelected,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
if (state.query.isEmpty) {
|
||||
final recentSearches = ref.watch(recentSearchesProvider);
|
||||
if (recentSearches.isNotEmpty) {
|
||||
return _RecentSearches(
|
||||
searches: recentSearches,
|
||||
onSelected: onRecentSearchSelected,
|
||||
onClear: ref.read(recentSearchesProvider.notifier).clear,
|
||||
);
|
||||
}
|
||||
|
||||
return Center(
|
||||
child: Padding(
|
||||
key: const Key('search-empty-state'),
|
||||
padding: const EdgeInsets.symmetric(horizontal: Grid.gutter),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
LucideIcons.search,
|
||||
size: 64,
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(height: Grid.xs),
|
||||
Text(
|
||||
context.l10n.searchPrompt,
|
||||
textAlign: TextAlign.center,
|
||||
style: context.textTheme.bodyMedium?.copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final showChannels =
|
||||
filter == _SearchFilter.all || filter == _SearchFilter.channels;
|
||||
final showPeople =
|
||||
filter == _SearchFilter.all || filter == _SearchFilter.people;
|
||||
final showMessages =
|
||||
filter == _SearchFilter.all || filter == _SearchFilter.messages;
|
||||
|
||||
final hasAnyResults =
|
||||
state.channelResults.isNotEmpty ||
|
||||
state.userResults.isNotEmpty ||
|
||||
state.messageResults.isNotEmpty;
|
||||
void recordResultSelection() =>
|
||||
ref.read(recentSearchesProvider.notifier).record(state.query);
|
||||
|
||||
if (!state.isLoading && !hasAnyResults) {
|
||||
return Padding(
|
||||
key: const Key('search-no-results-state'),
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.viewInsetsOf(context).bottom,
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
context.l10n.searchNoResults(state.query),
|
||||
style: context.textTheme.bodyMedium?.copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView(
|
||||
key: const Key('search-results-list'),
|
||||
padding: EdgeInsets.only(
|
||||
bottom:
|
||||
Grid.xl +
|
||||
MediaQuery.paddingOf(context).bottom +
|
||||
MediaQuery.viewInsetsOf(context).bottom,
|
||||
),
|
||||
children: [
|
||||
if (showChannels && state.channelResults.isNotEmpty)
|
||||
_ChannelsSection(
|
||||
channels: state.channelResults,
|
||||
onResultSelected: recordResultSelection,
|
||||
),
|
||||
if (showPeople && state.userResults.isNotEmpty)
|
||||
_PeopleSection(
|
||||
users: state.userResults,
|
||||
onResultSelected: recordResultSelection,
|
||||
),
|
||||
if (showMessages && state.messageResults.isNotEmpty)
|
||||
_MessagesSection(
|
||||
hits: state.messageResults,
|
||||
currentPubkey: currentPubkey,
|
||||
onResultSelected: recordResultSelection,
|
||||
),
|
||||
if (state.isLoading)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(Grid.sm),
|
||||
child: Center(
|
||||
child: BuzzLoadingIndicator(
|
||||
size: 36,
|
||||
semanticLabel: context.l10n.loadingMoreResults,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RecentSearches extends StatelessWidget {
|
||||
final List<String> searches;
|
||||
final ValueChanged<String> onSelected;
|
||||
final VoidCallback onClear;
|
||||
|
||||
const _RecentSearches({
|
||||
required this.searches,
|
||||
required this.onSelected,
|
||||
required this.onClear,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListView(
|
||||
key: const Key('recent-searches-list'),
|
||||
padding: EdgeInsets.only(
|
||||
bottom:
|
||||
Grid.xl +
|
||||
MediaQuery.paddingOf(context).bottom +
|
||||
MediaQuery.viewInsetsOf(context).bottom,
|
||||
),
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
Grid.gutter,
|
||||
Grid.xs,
|
||||
Grid.xxs,
|
||||
Grid.half,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
context.l10n.recentSearches,
|
||||
key: const Key('recent-searches-heading'),
|
||||
style: activityContextTextStyle.copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
key: const Key('clear-recent-searches'),
|
||||
onPressed: onClear,
|
||||
child: Text(
|
||||
context.l10n.clear,
|
||||
style: activityContextTextStyle.copyWith(
|
||||
color: context.colors.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
for (var index = 0; index < searches.length; index++)
|
||||
InkWell(
|
||||
key: ValueKey('recent-search-$index'),
|
||||
onTap: () => onSelected(searches[index]),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(minHeight: Grid.xl),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: Grid.gutter,
|
||||
vertical: Grid.twelve,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
LucideIcons.clock,
|
||||
size: 18,
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: Grid.twelve),
|
||||
Expanded(
|
||||
child: Text(
|
||||
searches[index],
|
||||
style: contentListTitleTextStyle.copyWith(
|
||||
color: context.colors.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
LucideIcons.chevronRight,
|
||||
size: 16,
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ChannelsSection extends StatelessWidget {
|
||||
final List<Channel> channels;
|
||||
final VoidCallback onResultSelected;
|
||||
|
||||
const _ChannelsSection({
|
||||
required this.channels,
|
||||
required this.onResultSelected,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_SectionLabel(label: context.l10n.searchChannels),
|
||||
for (final channel in channels)
|
||||
ListTile(
|
||||
key: ValueKey('search-channel-row-${channel.id}'),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: Grid.gutter),
|
||||
leading: Icon(
|
||||
channelIcon(channel),
|
||||
key: ValueKey('search-channel-leading-${channel.id}'),
|
||||
size: 20,
|
||||
),
|
||||
title: Text(
|
||||
channel.name,
|
||||
key: ValueKey('search-channel-title-${channel.id}'),
|
||||
style: contentListTitleTextStyle,
|
||||
),
|
||||
subtitle: Text(
|
||||
context.l10n.memberCount(channel.memberCount),
|
||||
style: contentListBodyTextStyle.copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
trailing: !channel.isMember && !channel.isDm
|
||||
? Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: Grid.half + 2,
|
||||
vertical: 3,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: context.colors.primary.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(Radii.sm),
|
||||
),
|
||||
child: Text(
|
||||
context.l10n.open,
|
||||
style: context.textTheme.labelSmall?.copyWith(
|
||||
color: context.colors.primary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
onTap: () {
|
||||
onResultSelected();
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => ChannelDetailPage(channel: channel),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PeopleSection extends ConsumerWidget {
|
||||
final List<DirectoryUser> users;
|
||||
final VoidCallback onResultSelected;
|
||||
|
||||
const _PeopleSection({required this.users, required this.onResultSelected});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_SectionLabel(label: context.l10n.searchPeople),
|
||||
for (final user in users)
|
||||
ListTile(
|
||||
key: ValueKey('search-person-row-${user.pubkey}'),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: Grid.gutter),
|
||||
leading: AvatarImage(
|
||||
key: ValueKey('search-person-leading-${user.pubkey}'),
|
||||
imageUrl: user.avatarUrl,
|
||||
radius: 20,
|
||||
fallback: Text(user.label.substring(0, 1).toUpperCase()),
|
||||
),
|
||||
title: Text(
|
||||
user.label,
|
||||
key: ValueKey('search-person-title-${user.pubkey}'),
|
||||
style: contentListTitleTextStyle,
|
||||
),
|
||||
subtitle: Text(
|
||||
user.secondaryLabel,
|
||||
style: contentListBodyTextStyle.copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
onTap: () async {
|
||||
onResultSelected();
|
||||
final channel = await ref
|
||||
.read(channelActionsProvider)
|
||||
.openDm(pubkeys: [user.pubkey]);
|
||||
if (!context.mounted) return;
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => ChannelDetailPage(channel: channel),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MessagesSection extends HookConsumerWidget {
|
||||
final List<SearchHit> hits;
|
||||
final String? currentPubkey;
|
||||
final VoidCallback onResultSelected;
|
||||
|
||||
const _MessagesSection({
|
||||
required this.hits,
|
||||
required this.currentPubkey,
|
||||
required this.onResultSelected,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final profiles = ref.watch(userCacheProvider);
|
||||
final channels = ref.watch(channelsProvider).value ?? [];
|
||||
|
||||
// Preload author profiles.
|
||||
final preloadPubkeys = {
|
||||
for (final hit in hits) hit.pubkey.toLowerCase(),
|
||||
for (final hit in hits) ...mentionedPubkeysFromTags(hit.tags),
|
||||
}.toList()..sort();
|
||||
final preloadPubkeysKey = preloadPubkeys.join('\u0000');
|
||||
useEffect(() {
|
||||
ref.read(userCacheProvider.notifier).preload(preloadPubkeys);
|
||||
return null;
|
||||
}, [preloadPubkeysKey]);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_SectionLabel(label: context.l10n.searchMessages),
|
||||
for (final hit in hits)
|
||||
_MessageTile(
|
||||
hit: hit,
|
||||
authorProfile: profiles[hit.pubkey.toLowerCase()],
|
||||
userCache: profiles,
|
||||
channel: channels.where((c) => c.id == hit.channelId).firstOrNull,
|
||||
currentPubkey: currentPubkey,
|
||||
onResultSelected: onResultSelected,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MessageTile extends ConsumerWidget {
|
||||
final SearchHit hit;
|
||||
final UserProfile? authorProfile;
|
||||
final Map<String, UserProfile> userCache;
|
||||
final Channel? channel;
|
||||
final String? currentPubkey;
|
||||
final VoidCallback onResultSelected;
|
||||
|
||||
const _MessageTile({
|
||||
required this.hit,
|
||||
required this.authorProfile,
|
||||
required this.userCache,
|
||||
required this.channel,
|
||||
required this.currentPubkey,
|
||||
required this.onResultSelected,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final authorName = authorProfile?.label ?? shortPubkey(hit.pubkey);
|
||||
final timeAgo = relativeTime(hit.createdAt, l10n: context.l10n);
|
||||
final channelName = hit.channelName?.trim().replaceFirst(RegExp(r'^#'), '');
|
||||
final hasChannelName = channelName != null && channelName.isNotEmpty;
|
||||
final isDm = channel?.isDm ?? false;
|
||||
final profileMentionNames = {
|
||||
for (final pubkey in mentionedPubkeysFromTags(hit.tags))
|
||||
if (userCache[pubkey]?.displayName?.trim().isNotEmpty == true)
|
||||
pubkey: userCache[pubkey]!.displayName!.trim(),
|
||||
};
|
||||
final mentionPubkeys = mentionedPubkeysFromTags(hit.tags);
|
||||
final knownAgentPubkeys = channel == null
|
||||
? ref.watch(knownAgentPubkeysProvider)
|
||||
: ref.watch(agentMentionPubkeysProvider(channel!.id));
|
||||
final agentMentionPubkeys = agentPubkeysWithProfileOwners(
|
||||
knownAgentPubkeys: knownAgentPubkeys,
|
||||
profileOwnedAgentPubkeys: [
|
||||
for (final profile in userCache.values)
|
||||
if (profile.ownerPubkey != null) profile.pubkey,
|
||||
],
|
||||
);
|
||||
final mentionNames = mentionNamesWithDirectoryLabels(
|
||||
mentionPubkeys: mentionPubkeys,
|
||||
profileMentionNames: profileMentionNames,
|
||||
directoryDisplayNames: ref.watch(agentDirectoryDisplayNamesProvider),
|
||||
agentMentionPubkeys: agentMentionPubkeys,
|
||||
);
|
||||
|
||||
return ListTile(
|
||||
key: ValueKey('search-message-row-${hit.eventId}'),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: Grid.gutter),
|
||||
titleAlignment: ListTileTitleAlignment.top,
|
||||
horizontalTitleGap: messageAvatarContentGap,
|
||||
leading: SmallAvatar(
|
||||
key: ValueKey('search-message-avatar-${hit.eventId}'),
|
||||
pubkey: hit.pubkey,
|
||||
userCache: userCache,
|
||||
size: compactMessageAvatarSize,
|
||||
),
|
||||
title: MessageAuthorMeta(
|
||||
displayName: authorName,
|
||||
username: messageUsernameLabel(authorProfile),
|
||||
timestamp: timeAgo,
|
||||
nameColor: context.colors.onSurface,
|
||||
metadataColor: context.colors.onSurfaceVariant,
|
||||
displayNameKey: ValueKey('search-message-author-${hit.eventId}'),
|
||||
usernameKey: ValueKey('search-message-username-${hit.eventId}'),
|
||||
timestampKey: ValueKey('search-message-timestamp-${hit.eventId}'),
|
||||
),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 2),
|
||||
Row(
|
||||
key: ValueKey('search-message-context-${hit.eventId}'),
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
isDm
|
||||
? context.l10n.directMessage
|
||||
: hasChannelName
|
||||
? context.l10n.messageIn
|
||||
: context.l10n.messageLabel,
|
||||
style: activityContextTextStyle.copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (!isDm && hasChannelName) ...[
|
||||
const SizedBox(width: Grid.half),
|
||||
Flexible(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: Grid.half + Grid.quarter,
|
||||
vertical: Grid.quarter / 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: context.colors.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(Radii.xs),
|
||||
),
|
||||
child: Text(
|
||||
'#$channelName',
|
||||
key: ValueKey('search-message-channel-${hit.eventId}'),
|
||||
style: activityContextTextStyle.copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: Grid.half),
|
||||
MessageContent(
|
||||
key: ValueKey('search-message-body-${hit.eventId}'),
|
||||
content: hit.content,
|
||||
mentionNames: mentionNames,
|
||||
agentMentionPubkeys: agentMentionPubkeys,
|
||||
tags: hit.tags,
|
||||
maxLines: 2,
|
||||
baseStyle: activityPreviewTextStyle.copyWith(
|
||||
color: context.colors.onSurface,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
onTap: () {
|
||||
onResultSelected();
|
||||
_navigateToHit(context, hit, channel);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _navigateToHit(BuildContext context, SearchHit hit, Channel? channel) {
|
||||
if (channel == null) return;
|
||||
|
||||
if (hit.kind == 45001) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => ForumThreadPage(
|
||||
channelId: channel.id,
|
||||
postEventId: hit.eventId,
|
||||
currentPubkey: currentPubkey,
|
||||
isMember: channel.isMember,
|
||||
isArchived: channel.isArchived,
|
||||
),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => ChannelDetailPage(channel: channel),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionLabel extends StatelessWidget {
|
||||
final String label;
|
||||
|
||||
const _SectionLabel({required this.label});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
Grid.gutter,
|
||||
Grid.xs,
|
||||
Grid.gutter,
|
||||
Grid.half,
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
key: ValueKey('search-section-${label.toLowerCase()}'),
|
||||
style: activityContextTextStyle.copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
extension on _SearchFilter {
|
||||
String label(BuildContext context) => switch (this) {
|
||||
_SearchFilter.all => context.l10n.filterAll,
|
||||
_SearchFilter.messages => context.l10n.searchMessages,
|
||||
_SearchFilter.channels => context.l10n.searchChannels,
|
||||
_SearchFilter.people => context.l10n.searchPeople,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
part of '../search_page.dart';
|
||||
|
||||
const _searchIdleIconSize = 26.0;
|
||||
const _searchCompactIconSize = 18.0;
|
||||
const _searchIdleIconInset = Grid.xxs;
|
||||
const _searchIdleTextInset =
|
||||
_searchIdleIconInset + _searchIdleIconSize + Grid.xxs;
|
||||
const _searchCompactTextInset =
|
||||
_searchIdleIconInset + _searchCompactIconSize + Grid.xxs;
|
||||
|
||||
class _SearchMotionField extends StatelessWidget {
|
||||
final TextEditingController controller;
|
||||
final FocusNode focusNode;
|
||||
final Color iconColor;
|
||||
final Color inputColor;
|
||||
final Color placeholderColor;
|
||||
final Color surfaceColor;
|
||||
final bool isSearchEditing;
|
||||
final bool reduceMotion;
|
||||
final Duration motionDuration;
|
||||
final VoidCallback onTap;
|
||||
final ValueChanged<String> onChanged;
|
||||
final ValueChanged<String> onSubmitted;
|
||||
|
||||
const _SearchMotionField({
|
||||
required this.controller,
|
||||
required this.focusNode,
|
||||
required this.iconColor,
|
||||
required this.inputColor,
|
||||
required this.placeholderColor,
|
||||
required this.surfaceColor,
|
||||
required this.isSearchEditing,
|
||||
required this.reduceMotion,
|
||||
required this.motionDuration,
|
||||
required this.onTap,
|
||||
required this.onChanged,
|
||||
required this.onSubmitted,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: surfaceColor,
|
||||
borderRadius: BorderRadius.circular(Radii.lg),
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: TextField(
|
||||
key: const Key('search-field'),
|
||||
controller: controller,
|
||||
focusNode: focusNode,
|
||||
decoration: InputDecoration(
|
||||
hintText: isSearchEditing ? null : context.l10n.searchPrompt,
|
||||
hintStyle: searchInputTextStyle.copyWith(
|
||||
color: placeholderColor,
|
||||
fontSize: _searchIdleTextSize,
|
||||
height: 20 / _searchIdleTextSize,
|
||||
),
|
||||
border: InputBorder.none,
|
||||
enabledBorder: InputBorder.none,
|
||||
focusedBorder: InputBorder.none,
|
||||
isDense: true,
|
||||
contentPadding: EdgeInsets.only(
|
||||
left: isSearchEditing
|
||||
? _searchCompactTextInset
|
||||
: _searchIdleTextInset,
|
||||
right: Grid.xxs,
|
||||
top: isSearchEditing ? _searchFieldVerticalPadding : 0,
|
||||
bottom: isSearchEditing ? _searchFieldVerticalPadding : 0,
|
||||
),
|
||||
),
|
||||
style: searchInputTextStyle.copyWith(color: inputColor),
|
||||
textAlignVertical: TextAlignVertical.center,
|
||||
textAlign: TextAlign.start,
|
||||
textInputAction: TextInputAction.search,
|
||||
onTap: onTap,
|
||||
onChanged: onChanged,
|
||||
onSubmitted: onSubmitted,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
IgnorePointer(
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: _searchIdleIconInset),
|
||||
child: AnimatedScale(
|
||||
duration: reduceMotion ? Duration.zero : motionDuration,
|
||||
curve: Curves.easeInOutCubic,
|
||||
scale: isSearchEditing
|
||||
? _searchCompactIconSize / _searchIdleIconSize
|
||||
: 1,
|
||||
child: Icon(
|
||||
LucideIcons.search,
|
||||
key: const Key('search-moving-icon'),
|
||||
size: _searchIdleIconSize,
|
||||
color: iconColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import '../../shared/relay/relay.dart';
|
||||
import '../channels/channel.dart';
|
||||
import '../channels/channel_management_provider.dart';
|
||||
import '../channels/channels_provider.dart';
|
||||
|
||||
@immutable
|
||||
class SearchHit {
|
||||
final String eventId;
|
||||
final String content;
|
||||
final int kind;
|
||||
final String pubkey;
|
||||
final String? channelId;
|
||||
final String? channelName;
|
||||
final int createdAt;
|
||||
final double score;
|
||||
final List<List<String>> tags;
|
||||
|
||||
const SearchHit({
|
||||
required this.eventId,
|
||||
required this.content,
|
||||
required this.kind,
|
||||
required this.pubkey,
|
||||
this.channelId,
|
||||
this.channelName,
|
||||
required this.createdAt,
|
||||
required this.score,
|
||||
this.tags = const [],
|
||||
});
|
||||
|
||||
factory SearchHit.fromJson(Map<String, dynamic> json) => SearchHit(
|
||||
eventId: json['event_id'] as String,
|
||||
content: json['content'] as String? ?? '',
|
||||
kind: json['kind'] as int? ?? 9,
|
||||
pubkey: json['pubkey'] as String,
|
||||
channelId: json['channel_id'] as String?,
|
||||
channelName: json['channel_name'] as String?,
|
||||
createdAt: json['created_at'] as int? ?? 0,
|
||||
score: (json['score'] as num?)?.toDouble() ?? 0.0,
|
||||
tags:
|
||||
(json['tags'] as List<dynamic>?)
|
||||
?.map((t) => (t as List<dynamic>).map((e) => e as String).toList())
|
||||
.toList() ??
|
||||
const [],
|
||||
);
|
||||
}
|
||||
|
||||
@immutable
|
||||
class SearchState {
|
||||
final String query;
|
||||
final List<SearchHit> messageResults;
|
||||
final List<DirectoryUser> userResults;
|
||||
final List<Channel> channelResults;
|
||||
final bool isLoading;
|
||||
final String? error;
|
||||
|
||||
const SearchState({
|
||||
this.query = '',
|
||||
this.messageResults = const [],
|
||||
this.userResults = const [],
|
||||
this.channelResults = const [],
|
||||
this.isLoading = false,
|
||||
this.error,
|
||||
});
|
||||
|
||||
const SearchState.initial()
|
||||
: query = '',
|
||||
messageResults = const [],
|
||||
userResults = const [],
|
||||
channelResults = const [],
|
||||
isLoading = false,
|
||||
error = null;
|
||||
|
||||
SearchState copyWith({
|
||||
String? query,
|
||||
List<SearchHit>? messageResults,
|
||||
List<DirectoryUser>? userResults,
|
||||
List<Channel>? channelResults,
|
||||
bool? isLoading,
|
||||
String? error,
|
||||
}) => SearchState(
|
||||
query: query ?? this.query,
|
||||
messageResults: messageResults ?? this.messageResults,
|
||||
userResults: userResults ?? this.userResults,
|
||||
channelResults: channelResults ?? this.channelResults,
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
error: error ?? this.error,
|
||||
);
|
||||
}
|
||||
|
||||
class SearchNotifier extends Notifier<SearchState> {
|
||||
Timer? _debounce;
|
||||
|
||||
@override
|
||||
SearchState build() {
|
||||
// Watch relayConfig so search state resets on community switch.
|
||||
ref.watch(relayConfigProvider);
|
||||
ref.onDispose(() {
|
||||
_debounce?.cancel();
|
||||
});
|
||||
return const SearchState.initial();
|
||||
}
|
||||
|
||||
void search(String query) {
|
||||
_debounce?.cancel();
|
||||
|
||||
final trimmed = query.trim();
|
||||
if (trimmed.isEmpty) {
|
||||
state = const SearchState.initial();
|
||||
return;
|
||||
}
|
||||
|
||||
state = SearchState(query: trimmed, isLoading: true);
|
||||
|
||||
_debounce = Timer(const Duration(milliseconds: 300), () {
|
||||
_executeSearch(trimmed);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _executeSearch(String query) async {
|
||||
// If the query changed while debouncing, bail out.
|
||||
if (state.query != query) return;
|
||||
|
||||
// Fire all three lookups in parallel.
|
||||
_searchMessages(query);
|
||||
_searchUsers(query);
|
||||
_searchChannels(query);
|
||||
}
|
||||
|
||||
Future<void> _searchMessages(String query) async {
|
||||
try {
|
||||
final session = ref.read(relaySessionProvider.notifier);
|
||||
final events = await session.fetchHistory(
|
||||
NostrFilter(
|
||||
kinds: const [9, 40002, 45001, 45003],
|
||||
search: query,
|
||||
limit: 20,
|
||||
),
|
||||
);
|
||||
|
||||
// Channel-name lookup from the cached channels list — not all hits
|
||||
// will have a known channel (e.g. ones we're not a member of).
|
||||
final channelsById = {
|
||||
for (final c in ref.read(channelsProvider).value ?? const <Channel>[])
|
||||
c.id: c.name,
|
||||
};
|
||||
|
||||
final hits = events
|
||||
.map(
|
||||
(e) => SearchHit(
|
||||
eventId: e.id,
|
||||
content: e.content,
|
||||
kind: e.kind,
|
||||
pubkey: e.pubkey,
|
||||
channelId: e.channelId,
|
||||
channelName: e.channelId != null
|
||||
? channelsById[e.channelId!]
|
||||
: null,
|
||||
createdAt: e.createdAt,
|
||||
score: 0.0,
|
||||
tags: e.tags,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
|
||||
if (state.query != query) return;
|
||||
state = state.copyWith(messageResults: hits, isLoading: false);
|
||||
} catch (e) {
|
||||
if (state.query != query) return;
|
||||
state = state.copyWith(isLoading: false, error: e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _searchUsers(String query) async {
|
||||
try {
|
||||
final users = await ref
|
||||
.read(channelActionsProvider)
|
||||
.searchUsers(query, limit: 8);
|
||||
|
||||
if (state.query != query) return;
|
||||
state = state.copyWith(
|
||||
userResults: users,
|
||||
isLoading: state.messageResults.isEmpty && state.channelResults.isEmpty,
|
||||
);
|
||||
} catch (_) {
|
||||
// User search failure is non-critical — keep existing results.
|
||||
}
|
||||
}
|
||||
|
||||
void _searchChannels(String query) {
|
||||
final channels = ref.read(channelsProvider).value ?? [];
|
||||
final lowerQuery = query.toLowerCase();
|
||||
final matches = channels.where((c) {
|
||||
if (c.isDm) return false;
|
||||
return c.name.toLowerCase().contains(lowerQuery) ||
|
||||
c.description.toLowerCase().contains(lowerQuery);
|
||||
}).toList();
|
||||
|
||||
if (state.query != query) return;
|
||||
state = state.copyWith(
|
||||
channelResults: matches,
|
||||
isLoading: state.messageResults.isEmpty && state.userResults.isEmpty,
|
||||
);
|
||||
}
|
||||
|
||||
void clear() {
|
||||
_debounce?.cancel();
|
||||
state = const SearchState.initial();
|
||||
}
|
||||
}
|
||||
|
||||
final searchProvider = NotifierProvider<SearchNotifier, SearchState>(
|
||||
SearchNotifier.new,
|
||||
);
|
||||
Reference in New Issue
Block a user