198 lines
6.4 KiB
Dart
198 lines
6.4 KiB
Dart
import 'package:flutter/material.dart';
|
|
|
|
import '../../../../api/errors/error_mapper.dart';
|
|
import '../../../../extensions/date_time.dart';
|
|
import '../../../../state/app/infrastructure/loadable_state/bloc/loadable_state_bloc.dart';
|
|
import '../../../../state/app/infrastructure/loadable_state/bloc/loadable_state_state.dart';
|
|
import '../../../../state/app/infrastructure/loadable_state/view/loadable_state_background_loading.dart';
|
|
import '../../../../state/app/infrastructure/loadable_state/view/loadable_state_error_bar.dart';
|
|
import '../../../../state/app/infrastructure/loadable_state/view/loadable_state_error_screen.dart';
|
|
import '../../../../state/app/infrastructure/loadable_state/view/loadable_state_primary_loading.dart';
|
|
import '../../../../state/app/infrastructure/utility_widgets/bloc_module.dart';
|
|
import '../../../../widget/placeholder_view.dart';
|
|
import '../../../../widget/user_avatar.dart';
|
|
import '../data/chat_message_matcher.dart';
|
|
import '../widgets/highlighted_linkify.dart';
|
|
import 'chat_search_controller.dart';
|
|
|
|
/// Renders a [ChatSearchController] with the same loading / error views as
|
|
/// `FilesSearchResults`.
|
|
class ChatSearchResults extends StatelessWidget {
|
|
final ChatSearchController controller;
|
|
final ValueChanged<ChatSearchHit> onResultTap;
|
|
|
|
const ChatSearchResults({
|
|
required this.controller,
|
|
required this.onResultTap,
|
|
super.key,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) =>
|
|
BlocModule<LoadableStateBloc, LoadableStateState>(
|
|
create: (_) => LoadableStateBloc(),
|
|
child: (context, bloc, _) {
|
|
bloc.reFetch = controller.retry;
|
|
return ListenableBuilder(
|
|
listenable: controller,
|
|
builder: (context, _) => _buildBody(context),
|
|
);
|
|
},
|
|
);
|
|
|
|
Widget _buildBody(BuildContext context) {
|
|
if (controller.query.trim().isEmpty) {
|
|
return const PlaceholderView(
|
|
icon: Icons.search,
|
|
text: 'Tippen, um im Chat zu suchen.',
|
|
);
|
|
}
|
|
final combined = controller.combinedResults;
|
|
final hasContent = combined.isNotEmpty;
|
|
final hasError = controller.serverError != null;
|
|
final isLoading = controller.serverLoading;
|
|
|
|
final showPrimaryLoading = isLoading && !hasContent;
|
|
final showBackgroundLoading = isLoading && hasContent;
|
|
final showErrorScreen = hasError && !hasContent && !isLoading;
|
|
final showErrorBar = hasError && hasContent;
|
|
final showEmpty = !hasContent && !hasError && !isLoading;
|
|
|
|
final errorMessage = hasError
|
|
? errorToUserMessage(controller.serverError)
|
|
: null;
|
|
|
|
return Column(
|
|
children: [
|
|
LoadableStateErrorBar(
|
|
visible: showErrorBar,
|
|
hasContent: hasContent,
|
|
message: errorMessage,
|
|
),
|
|
LoadableStateBackgroundLoading(visible: showBackgroundLoading),
|
|
Expanded(
|
|
child: Stack(
|
|
children: [
|
|
LoadableStatePrimaryLoading(visible: showPrimaryLoading),
|
|
LoadableStateErrorScreen(
|
|
visible: showErrorScreen,
|
|
message: errorMessage,
|
|
),
|
|
if (showEmpty)
|
|
const PlaceholderView(
|
|
icon: Icons.search_off_outlined,
|
|
text: 'Keine Treffer gefunden.',
|
|
),
|
|
if (hasContent) _resultList(combined),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _resultList(List<ChatSearchHit> hits) {
|
|
final showFooter = controller.canLoadMore || controller.loadingMore;
|
|
return NotificationListener<ScrollNotification>(
|
|
onNotification: (n) {
|
|
if (n.metrics.extentAfter < 400) controller.loadMore();
|
|
return false;
|
|
},
|
|
child: ListView.builder(
|
|
padding: EdgeInsets.zero,
|
|
itemCount: hits.length + (showFooter ? 1 : 0),
|
|
itemBuilder: (context, index) {
|
|
if (index == hits.length) {
|
|
// A short first page never scrolls, so the footer also triggers
|
|
// the next page when it gets built.
|
|
WidgetsBinding.instance.addPostFrameCallback(
|
|
(_) => controller.loadMore(),
|
|
);
|
|
return const Padding(
|
|
padding: EdgeInsets.all(16),
|
|
child: Center(child: CircularProgressIndicator()),
|
|
);
|
|
}
|
|
final hit = hits[index];
|
|
return _ChatSearchTile(
|
|
key: ValueKey(hit.messageId),
|
|
hit: hit,
|
|
query: controller.query,
|
|
onTap: () => onResultTap(hit),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ChatSearchTile extends StatelessWidget {
|
|
final ChatSearchHit hit;
|
|
final String query;
|
|
final VoidCallback onTap;
|
|
|
|
const _ChatSearchTile({
|
|
required this.hit,
|
|
required this.query,
|
|
required this.onTap,
|
|
super.key,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final base =
|
|
theme.textTheme.bodyMedium ?? DefaultTextStyle.of(context).style;
|
|
final actorId = hit.actorId;
|
|
final sentAt = hit.sentAt;
|
|
return ListTile(
|
|
leading: actorId != null
|
|
? UserAvatar(
|
|
id: actorId,
|
|
size: 20,
|
|
semanticLabel: hit.actorDisplayName,
|
|
)
|
|
: const CircleAvatar(radius: 20, child: Icon(Icons.info_outline)),
|
|
title: Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text.rich(
|
|
TextSpan(
|
|
children: buildHighlightedSpans(
|
|
text: hit.actorDisplayName ?? 'System',
|
|
query: query,
|
|
baseStyle: theme.textTheme.titleSmall ?? base,
|
|
),
|
|
),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
if (sentAt != null)
|
|
Padding(
|
|
padding: const EdgeInsets.only(left: 8),
|
|
child: Text(
|
|
sentAt.year == DateTime.now().year
|
|
? sentAt.formatDateShortHm()
|
|
: sentAt.formatDate(),
|
|
style: theme.textTheme.bodySmall,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
subtitle: Text.rich(
|
|
TextSpan(
|
|
children: buildHighlightedSpans(
|
|
text: hit.text,
|
|
query: query,
|
|
baseStyle: base.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
|
),
|
|
),
|
|
maxLines: 3,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
onTap: onTap,
|
|
);
|
|
}
|
|
}
|