implemented global user search integration for Talk chats with role-coded badges and direct chat creation logic

This commit is contained in:
2026-07-12 19:05:27 +02:00
parent 44e45c9b78
commit babc347b18
17 changed files with 580 additions and 82 deletions
+15 -9
View File
@@ -10,9 +10,9 @@ import '../../../state/app/infrastructure/loadable_state/view/loadable_state_con
import '../../../state/app/modules/chat_list/bloc/chat_list_bloc.dart';
import '../../../state/app/modules/chat_list/bloc/chat_list_state.dart';
import '../../../state/app/modules/settings/bloc/settings_cubit.dart';
import '../../../widget/confirm_dialog.dart';
import '../../../widget/demo_restricted.dart';
import '../../../widget/placeholder_view.dart';
import 'data/open_direct_chat.dart';
import 'join_chat.dart';
import 'notification_permission_prompt.dart';
import 'search_chat.dart';
@@ -91,7 +91,15 @@ class _ChatListViewState extends State<_ChatListView> {
if (rooms == null) return;
showSearch(
context: context,
delegate: SearchChat(rooms.data.toList()),
delegate: SearchChat(
rooms.data.toList(),
onStartChat: (username, displayName) =>
openOrCreateDirectChat(
context,
actorId: username,
actorDisplayName: displayName,
),
),
);
},
),
@@ -106,13 +114,11 @@ class _ChatListViewState extends State<_ChatListView> {
username,
) {
if (username == null || !context.mounted) return;
ConfirmDialog(
title: 'Talk-Chat starten',
content:
"Möchtest du einen Talk-Chat mit Nutzer '$username' starten?",
confirmButton: 'Talk-Chat starten',
onConfirmAsync: () => bloc.createDirectChat(username),
).asDialog(context);
openOrCreateDirectChat(
context,
actorId: username,
actorDisplayName: username,
);
});
},
child: const Icon(Icons.add_comment_outlined),
+8 -12
View File
@@ -30,19 +30,19 @@ void openOrCreateDirectChat(
return null;
}
void switchToChat(GetRoomResponseObject room) {
void switchToChat(String token) {
// Pop the previous ChatView first — otherwise it stays in the
// back-stack with a now-mismatched currentToken and renders empty
// on back-swipe. Stop at popups so an open dialog stays alive.
Navigator.of(
context,
).popUntil((route) => route.isFirst || route is PopupRoute);
AppRoutes.openChatByToken(context, room.token);
AppRoutes.openChatByToken(context, token);
}
final existing = findExisting();
if (existing != null) {
switchToChat(existing);
switchToChat(existing.token);
return;
}
@@ -53,15 +53,11 @@ void openOrCreateDirectChat(
'Soll einer erstellt werden?',
confirmButton: 'Erstellen',
onConfirmAsync: () async {
await chatListBloc.createDirectChat(actorId);
final created = findExisting();
if (created == null) {
throw Exception(
'Privatchat konnte nach dem Erstellen nicht gefunden werden.',
);
}
if (context.mounted) {
switchToChat(created);
// The create call returns the new room's token directly, so we open it
// without racing the (asynchronously updated) chat-list state.
final token = await chatListBloc.createDirectChat(actorId);
if (context.mounted && token != null) {
switchToChat(token);
}
},
).asDialog(context);
+20 -26
View File
@@ -2,14 +2,14 @@ import 'package:async/async.dart';
import 'package:flutter/material.dart';
import '../../../api/errors/error_mapper.dart';
import '../../../api/marianumcloud/autocomplete/autocomplete_api.dart';
import '../../../api/marianumcloud/autocomplete/autocomplete_response.dart';
import '../../../model/endpoint_data.dart';
import '../../../api/marianumconnect/queries/user_search/user_search.dart';
import '../../../api/marianumconnect/queries/user_search/user_search_response.dart';
import '../../../widget/app_progress_indicator.dart';
import '../../../widget/placeholder_view.dart';
import 'widgets/user_search_tile.dart';
class JoinChat extends SearchDelegate<String> {
CancelableOperation<AutocompleteResponse>? future;
CancelableOperation<UserSearchResponse>? future;
@override
List<Widget>? buildActions(BuildContext context) => [
@@ -27,7 +27,7 @@ class JoinChat extends SearchDelegate<String> {
},
),
if (query.isNotEmpty)
IconButton(onPressed: () => query = '', icon: const Icon(Icons.delete)),
IconButton(onPressed: () => query = '', icon: const Icon(Icons.clear)),
];
@override
@@ -39,36 +39,30 @@ class JoinChat extends SearchDelegate<String> {
if (query.isEmpty) {
return const PlaceholderView(
text: 'Suche nach benutzern',
text: 'Suche nach Schülerinnen, Schülern und Lehrkräften',
icon: Icons.person_search_outlined,
);
}
future = CancelableOperation.fromFuture(AutocompleteApi().find(query));
return FutureBuilder<AutocompleteResponse>(
future = CancelableOperation.fromFuture(UserSearch().run(query));
return FutureBuilder<UserSearchResponse>(
future: future!.value,
builder: (context, snapshot) {
if (snapshot.hasData) {
final results = snapshot.data!.result;
if (results.isEmpty) {
return PlaceholderView(
icon: Icons.person_off_outlined,
text: 'Keine Treffer für „$query"',
);
}
return ListView.builder(
itemCount: snapshot.data!.data.length,
itemCount: results.length,
itemBuilder: (context, index) {
var object = snapshot.data!.data[index];
var circleAvatar = CircleAvatar(
foregroundImage: Image.network(
'https://${EndpointData().nextcloud().full()}/avatar/${object.id}/128',
).image,
backgroundColor: Theme.of(context).primaryColor,
foregroundColor: Colors.white,
child: const Icon(Icons.person),
);
return ListTile(
leading: circleAvatar,
title: Text(object.label),
subtitle: Text(object.id),
trailing: const Icon(Icons.arrow_right),
onTap: () {
close(context, object.id);
},
final object = results[index];
return UserSearchTile(
user: object,
onTap: () => close(context, object.username),
);
},
);
+226 -26
View File
@@ -1,49 +1,249 @@
import 'dart:async';
import 'package:flutter/material.dart';
import '../../../api/errors/error_mapper.dart';
import '../../../api/marianumcloud/talk/room/get_room_response.dart';
import '../../../api/marianumconnect/queries/user_search/user_search.dart';
import '../../../api/marianumconnect/queries/user_search/user_search_response.dart';
import '../../../widget/app_progress_indicator.dart';
import 'widgets/chat_tile.dart';
import 'widgets/user_search_tile.dart';
class SearchChat extends SearchDelegate<GetRoomResponseObject?> {
List<GetRoomResponseObject> chats;
final void Function(GetRoomResponseObject room)? onTapOverride;
SearchChat(this.chats, {this.onTapOverride});
/// When set, results are extended with a clearly separated "start a new chat"
/// section fed by the MarianumConnect user search. Tapping a person invokes
/// this callback with their username and display name. Left null (e.g. the
/// share picker) the search stays limited to existing chats.
final void Function(String username, String displayName)? onStartChat;
SearchChat(this.chats, {this.onTapOverride, this.onStartChat});
@override
List<Widget>? buildActions(BuildContext context) => [
if (query.isNotEmpty)
IconButton(onPressed: () => query = '', icon: const Icon(Icons.delete)),
IconButton(onPressed: () => query = '', icon: const Icon(Icons.clear)),
];
@override
Widget? buildLeading(BuildContext context) => null;
@override
Widget buildResults(BuildContext context) {
var items =
chats
.where(
(e) =>
e.displayName.toString().toLowerCase().contains(
query.toLowerCase(),
) ||
e.name.toString().toLowerCase().contains(query.toLowerCase()),
)
.toList()
..sort((a, b) => b.lastActivity.compareTo(a.lastActivity));
return ListView.builder(
itemCount: items.length,
itemBuilder: (context, index) {
var item = items.elementAt(index);
return ChatTile(
data: item,
disableContextActions: true,
onTapOverride: onTapOverride,
);
},
);
}
Widget buildResults(BuildContext context) => _SearchChatResults(
chats: chats,
query: query,
onTapOverride: onTapOverride,
onStartChat: onStartChat == null
? null
: (username, displayName) {
close(context, null);
onStartChat!(username, displayName);
},
);
@override
Widget buildSuggestions(BuildContext context) => buildResults(context);
}
class _SearchChatResults extends StatefulWidget {
final List<GetRoomResponseObject> chats;
final String query;
final void Function(GetRoomResponseObject room)? onTapOverride;
final void Function(String username, String displayName)? onStartChat;
const _SearchChatResults({
required this.chats,
required this.query,
required this.onTapOverride,
required this.onStartChat,
});
@override
State<_SearchChatResults> createState() => _SearchChatResultsState();
}
class _SearchChatResultsState extends State<_SearchChatResults> {
static const _debounceDelay = Duration(milliseconds: 350);
static const _minQueryLength = 2;
static const _loadingIndicator = Padding(
padding: EdgeInsets.all(16),
child: Center(child: AppProgressIndicator.medium()),
);
Timer? _debounce;
// Monotonic id so a slow response for an earlier query can never overwrite
// the results of a newer one.
int _requestId = 0;
// null until the first response for the current query settles; previous
// results stay put while the next query is loading, so the list does not
// flash to a spinner on every keystroke.
List<McUserSearchResult>? _users;
Object? _error;
@override
void initState() {
super.initState();
_scheduleUserSearch(widget.query);
}
@override
void didUpdateWidget(_SearchChatResults oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.query != widget.query) _scheduleUserSearch(widget.query);
}
@override
void dispose() {
_debounce?.cancel();
super.dispose();
}
void _scheduleUserSearch(String query) {
if (widget.onStartChat == null) return;
_debounce?.cancel();
final trimmed = query.trim();
if (trimmed.length < _minQueryLength) {
// Invalidate any in-flight response and drop the section content.
_requestId++;
setState(() {
_users = null;
_error = null;
});
return;
}
_debounce = Timer(_debounceDelay, () => _runSearch(trimmed));
}
Future<void> _runSearch(String query) async {
final id = ++_requestId;
try {
final response = await UserSearch().run(query);
if (!mounted || id != _requestId) return;
setState(() {
_users = response.result;
_error = null;
});
} catch (e) {
if (!mounted || id != _requestId) return;
setState(() => _error = e);
}
}
@override
Widget build(BuildContext context) {
final query = widget.query.toLowerCase();
final chatMatches =
widget.chats
.where(
(e) =>
e.displayName.toString().toLowerCase().contains(query) ||
e.name.toString().toLowerCase().contains(query),
)
.toList()
..sort((a, b) => b.lastActivity.compareTo(a.lastActivity));
return ListView(
children: [
...chatMatches.map(
(item) => ChatTile(
data: item,
disableContextActions: true,
onTapOverride: widget.onTapOverride,
),
),
if (widget.onStartChat != null) _buildUserSection(context),
],
);
}
Widget _buildUserSection(BuildContext context) {
if (widget.query.trim().length < _minQueryLength) {
return const SizedBox.shrink();
}
// Usernames that already have a direct chat — they are listed above, so
// the "start new chat" section skips them.
final existing = <String>{
for (final c in widget.chats)
if (c.type == GetRoomResponseObjectConversationType.oneToOne) c.name,
};
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Divider(height: 32),
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
child: Row(
children: [
Icon(
Icons.person_add_alt_1,
size: 20,
color: theme.colorScheme.primary,
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Neuen Chat starten',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
Text(
'Personen aus der Suche',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.hintColor,
),
),
],
),
),
],
),
),
_buildUserResults(context, existing),
],
);
}
Widget _buildUserResults(BuildContext context, Set<String> existing) {
if (_error != null) return _hint(context, errorToUserMessage(_error));
final all = _users;
if (all == null) return _loadingIndicator;
final users = all.where((u) => !existing.contains(u.username)).toList();
if (users.isEmpty) {
return _hint(context, 'Keine weiteren Personen gefunden');
}
return Column(
children: users
.map(
(user) => UserSearchTile(
user: user,
onTap: () => widget.onStartChat!(
user.username,
'${user.firstName} ${user.lastName}',
),
),
)
.toList(),
);
}
Widget _hint(BuildContext context, String text) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 16),
child: Text(
text,
style: theme.textTheme.bodyMedium?.copyWith(color: theme.hintColor),
),
);
}
}
@@ -0,0 +1,66 @@
import 'package:flutter/material.dart';
import '../../../../api/marianumconnect/queries/user_search/user_search_response.dart';
import '../../../../widget/user_avatar.dart';
/// List entry for a MarianumConnect user-search hit: avatar, full name, a muted
/// identifier line and a colour-coded role badge. Shared by the dedicated user
/// search ([JoinChat]) and the "start new chat" section of the chat search.
class UserSearchTile extends StatelessWidget {
final McUserSearchResult user;
final VoidCallback onTap;
const UserSearchTile({super.key, required this.user, required this.onTap});
@override
Widget build(BuildContext context) {
return ListTile(
leading: UserAvatar(id: user.username, isGroup: false),
title: Text('${user.firstName} ${user.lastName}'),
subtitle: Text(_subtitle),
trailing: RoleBadge(userType: user.userType),
onTap: onTap,
);
}
String get _subtitle {
final className = user.className;
if (user.userType == 'STUDENT' &&
className != null &&
className.isNotEmpty) {
return '${user.username} · $className';
}
return user.username;
}
}
/// Compact colour-coded badge distinguishing teachers, students and staff.
class RoleBadge extends StatelessWidget {
final String userType;
const RoleBadge({super.key, required this.userType});
@override
Widget build(BuildContext context) {
final (label, color) = switch (userType) {
'TEACHER' => ('Lehrkraft', Colors.blue),
'STUDENT' => ('Schüler:in', Colors.green),
_ => ('Personal', Colors.orange),
};
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(12),
),
child: Text(
label,
style: TextStyle(
color: color,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
);
}
}