From babc347b18a8a6666fcf20b339b59ff912db5f01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Sun, 12 Jul 2026 19:05:27 +0200 Subject: [PATCH] implemented global user search integration for Talk chats with role-coded badges and direct chat creation logic --- lib/api/demo/data/demo_users.dart | 50 ++++ lib/api/demo/demo_marianumconnect.dart | 5 + .../talk/create_room/create_room.dart | 10 +- .../create_room/create_room_response.dart | 27 ++ .../create_room/create_room_response.g.dart | 29 ++ .../queries/user_search/user_search.dart | 30 +++ .../user_search/user_search_response.dart | 40 +++ .../user_search/user_search_response.g.dart | 41 +++ .../chat_list/bloc/chat_list_bloc.dart | 7 +- .../chat_list_data_provider.dart | 11 +- .../marianum_dates/search_marianum_dates.dart | 2 +- .../search_marianum_messages.dart | 2 +- lib/view/pages/talk/chat_list.dart | 24 +- .../pages/talk/data/open_direct_chat.dart | 20 +- lib/view/pages/talk/join_chat.dart | 46 ++-- lib/view/pages/talk/search_chat.dart | 252 ++++++++++++++++-- .../pages/talk/widgets/user_search_tile.dart | 66 +++++ 17 files changed, 580 insertions(+), 82 deletions(-) create mode 100644 lib/api/demo/data/demo_users.dart create mode 100644 lib/api/marianumcloud/talk/create_room/create_room_response.dart create mode 100644 lib/api/marianumcloud/talk/create_room/create_room_response.g.dart create mode 100644 lib/api/marianumconnect/queries/user_search/user_search.dart create mode 100644 lib/api/marianumconnect/queries/user_search/user_search_response.dart create mode 100644 lib/api/marianumconnect/queries/user_search/user_search_response.g.dart create mode 100644 lib/view/pages/talk/widgets/user_search_tile.dart diff --git a/lib/api/demo/data/demo_users.dart b/lib/api/demo/data/demo_users.dart new file mode 100644 index 0000000..edad676 --- /dev/null +++ b/lib/api/demo/data/demo_users.dart @@ -0,0 +1,50 @@ +import '../../marianumconnect/queries/user_search/user_search_response.dart'; + +/// Demo fixtures for the Talk user search — a small mixed set of teachers and +/// students, filtered client-side so the demo search feels responsive. +class DemoUsers { + const DemoUsers._(); + + static final List _all = [ + McUserSearchResult( + username: 'm.muster', + firstName: 'Maria', + lastName: 'Mustermann', + userType: 'TEACHER', + ), + McUserSearchResult( + username: 'j.beispiel', + firstName: 'Jonas', + lastName: 'Beispiel', + userType: 'TEACHER', + ), + McUserSearchResult( + username: 'l.schueler', + firstName: 'Lena', + lastName: 'Schüler', + userType: 'STUDENT', + className: '9c', + ), + McUserSearchResult( + username: 'p.probe', + firstName: 'Paul', + lastName: 'Probe', + userType: 'STUDENT', + className: 'Q2', + ), + ]; + + static List search(String query) { + final q = query.trim().toLowerCase(); + if (q.length < 2) return const []; + return _all + .where( + (u) => + u.firstName.toLowerCase().contains(q) || + u.lastName.toLowerCase().contains(q) || + u.username.toLowerCase().contains(q) || + '${u.firstName} ${u.lastName}'.toLowerCase().contains(q), + ) + .toList(); + } +} diff --git a/lib/api/demo/demo_marianumconnect.dart b/lib/api/demo/demo_marianumconnect.dart index b98ebf1..b925e7c 100644 --- a/lib/api/demo/demo_marianumconnect.dart +++ b/lib/api/demo/demo_marianumconnect.dart @@ -1,6 +1,7 @@ import 'data/demo_breaker.dart'; import 'data/demo_holidays.dart'; import 'data/demo_timetable.dart'; +import 'data/demo_users.dart'; /// Single source of truth for the MarianumConnect demo responses. The demo /// interceptor asks this for the body of any MC request. Read endpoints reuse @@ -32,6 +33,10 @@ class DemoMarianumConnect { return DemoTimetable.holidays().result.map((e) => e.toJson()).toList(); case 'holidays': return DemoHolidays.upcoming().map((e) => e.toJson()).toList(); + case 'users/search': + return DemoUsers.search(query['q']?.toString() ?? '') + .map((e) => e.toJson()) + .toList(); case 'breaker': return DemoBreaker.none().toJson(); case 'timetable/elements/teachers': diff --git a/lib/api/marianumcloud/talk/create_room/create_room.dart b/lib/api/marianumcloud/talk/create_room/create_room.dart index 626fd11..0eeb5be 100644 --- a/lib/api/marianumcloud/talk/create_room/create_room.dart +++ b/lib/api/marianumcloud/talk/create_room/create_room.dart @@ -1,16 +1,22 @@ +import 'dart:convert'; + import 'package:http/http.dart' as http; import 'package:http/http.dart'; import '../talk_api.dart'; import 'create_room_params.dart'; +import 'create_room_response.dart'; -class CreateRoom extends TalkApi { +class CreateRoom extends TalkApi { CreateRoomParams params; CreateRoom(this.params) : super('v4/room', params); @override - Null assemble(String raw) => null; + CreateRoomResponse assemble(String raw) { + final decoded = jsonDecode(raw) as Map; + return CreateRoomResponse.fromJson(decoded['ocs'] as Map); + } @override Future? request( diff --git a/lib/api/marianumcloud/talk/create_room/create_room_response.dart b/lib/api/marianumcloud/talk/create_room/create_room_response.dart new file mode 100644 index 0000000..954f5f2 --- /dev/null +++ b/lib/api/marianumcloud/talk/create_room/create_room_response.dart @@ -0,0 +1,27 @@ +import 'package:json_annotation/json_annotation.dart'; + +import '../../../api_response.dart'; + +part 'create_room_response.g.dart'; + +@JsonSerializable(explicitToJson: true) +class CreateRoomResponse extends ApiResponse { + final CreateRoomResponseData data; + + CreateRoomResponse(this.data); + + factory CreateRoomResponse.fromJson(Map json) => + _$CreateRoomResponseFromJson(json); + Map toJson() => _$CreateRoomResponseToJson(this); +} + +@JsonSerializable() +class CreateRoomResponseData { + final String token; + + CreateRoomResponseData(this.token); + + factory CreateRoomResponseData.fromJson(Map json) => + _$CreateRoomResponseDataFromJson(json); + Map toJson() => _$CreateRoomResponseDataToJson(this); +} diff --git a/lib/api/marianumcloud/talk/create_room/create_room_response.g.dart b/lib/api/marianumcloud/talk/create_room/create_room_response.g.dart new file mode 100644 index 0000000..f079e1c --- /dev/null +++ b/lib/api/marianumcloud/talk/create_room/create_room_response.g.dart @@ -0,0 +1,29 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'create_room_response.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +CreateRoomResponse _$CreateRoomResponseFromJson(Map json) => + CreateRoomResponse( + CreateRoomResponseData.fromJson(json['data'] as Map), + ) + ..headers = (json['headers'] as Map?)?.map( + (k, e) => MapEntry(k, e as String), + ); + +Map _$CreateRoomResponseToJson(CreateRoomResponse instance) => + { + 'headers': ?instance.headers, + 'data': instance.data.toJson(), + }; + +CreateRoomResponseData _$CreateRoomResponseDataFromJson( + Map json, +) => CreateRoomResponseData(json['token'] as String); + +Map _$CreateRoomResponseDataToJson( + CreateRoomResponseData instance, +) => {'token': instance.token}; diff --git a/lib/api/marianumconnect/queries/user_search/user_search.dart b/lib/api/marianumconnect/queries/user_search/user_search.dart new file mode 100644 index 0000000..5951b36 --- /dev/null +++ b/lib/api/marianumconnect/queries/user_search/user_search.dart @@ -0,0 +1,30 @@ +import 'package:dio/dio.dart'; + +import '../../errors/marianumconnect_error.dart'; +import '../../marianumconnect_api.dart'; +import '../../marianumconnect_endpoint.dart'; +import 'user_search_response.dart'; + +/// Searches active users (students, teachers, staff) via the MarianumConnect +/// mobile API. Returns each match's Nextcloud username plus role, so the Talk +/// search can start a direct chat and label results without hitting Nextcloud. +class UserSearch { + final Dio _dio; + + UserSearch({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio(); + + Future run(String query) async { + try { + final response = await _dio.get>( + MarianumConnectEndpoint.resolve('users/search'), + queryParameters: {'q': query}, + ); + final list = response.data! + .map((e) => McUserSearchResult.fromJson(e as Map)) + .toList(); + return UserSearchResponse(result: list); + } on DioException catch (e) { + throw mapMarianumConnectError(e); + } + } +} diff --git a/lib/api/marianumconnect/queries/user_search/user_search_response.dart b/lib/api/marianumconnect/queries/user_search/user_search_response.dart new file mode 100644 index 0000000..7c647fe --- /dev/null +++ b/lib/api/marianumconnect/queries/user_search/user_search_response.dart @@ -0,0 +1,40 @@ +import 'package:json_annotation/json_annotation.dart'; + +import '../../../api_response.dart'; + +part 'user_search_response.g.dart'; + +@JsonSerializable(explicitToJson: true) +class McUserSearchResult { + /// Nextcloud-/Talk-Username — dient als `invite` beim Chat-Start. + final String username; + final String firstName; + final String lastName; + + /// STUDENT, TEACHER oder STAFF. + final String userType; + final String? className; + + McUserSearchResult({ + required this.username, + required this.firstName, + required this.lastName, + required this.userType, + this.className, + }); + + factory McUserSearchResult.fromJson(Map json) => + _$McUserSearchResultFromJson(json); + Map toJson() => _$McUserSearchResultToJson(this); +} + +@JsonSerializable(explicitToJson: true) +class UserSearchResponse extends ApiResponse { + final List result; + + UserSearchResponse({required this.result}); + + factory UserSearchResponse.fromJson(Map json) => + _$UserSearchResponseFromJson(json); + Map toJson() => _$UserSearchResponseToJson(this); +} diff --git a/lib/api/marianumconnect/queries/user_search/user_search_response.g.dart b/lib/api/marianumconnect/queries/user_search/user_search_response.g.dart new file mode 100644 index 0000000..d9f7e46 --- /dev/null +++ b/lib/api/marianumconnect/queries/user_search/user_search_response.g.dart @@ -0,0 +1,41 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'user_search_response.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +McUserSearchResult _$McUserSearchResultFromJson(Map json) => + McUserSearchResult( + username: json['username'] as String, + firstName: json['firstName'] as String, + lastName: json['lastName'] as String, + userType: json['userType'] as String, + className: json['className'] as String?, + ); + +Map _$McUserSearchResultToJson(McUserSearchResult instance) => + { + 'username': instance.username, + 'firstName': instance.firstName, + 'lastName': instance.lastName, + 'userType': instance.userType, + 'className': instance.className, + }; + +UserSearchResponse _$UserSearchResponseFromJson(Map json) => + UserSearchResponse( + result: (json['result'] as List) + .map((e) => McUserSearchResult.fromJson(e as Map)) + .toList(), + ) + ..headers = (json['headers'] as Map?)?.map( + (k, e) => MapEntry(k, e as String), + ); + +Map _$UserSearchResponseToJson(UserSearchResponse instance) => + { + 'headers': ?instance.headers, + 'result': instance.result.map((e) => e.toJson()).toList(), + }; diff --git a/lib/state/app/modules/chat_list/bloc/chat_list_bloc.dart b/lib/state/app/modules/chat_list/bloc/chat_list_bloc.dart index 31e9889..9ceec58 100644 --- a/lib/state/app/modules/chat_list/bloc/chat_list_bloc.dart +++ b/lib/state/app/modules/chat_list/bloc/chat_list_bloc.dart @@ -100,9 +100,12 @@ class ChatListBloc } } - Future createDirectChat(String invite) async { - await repo.data.createDirectRoom(invite); + /// Creates (or resolves) a 1:1 chat and returns its room token, or null in + /// demo mode. Refreshes the list so the room shows up. + Future createDirectChat(String invite) async { + final token = await repo.data.createDirectRoom(invite); await refresh(); + return token; } int? lastReadMessageFor(String token) { diff --git a/lib/state/app/modules/chat_list/data_provider/chat_list_data_provider.dart b/lib/state/app/modules/chat_list/data_provider/chat_list_data_provider.dart index 6189bc3..0c9fc16 100644 --- a/lib/state/app/modules/chat_list/data_provider/chat_list_data_provider.dart +++ b/lib/state/app/modules/chat_list/data_provider/chat_list_data_provider.dart @@ -20,8 +20,13 @@ class ChatListDataProvider { ); } - Future createDirectRoom(String invite) { - if (DemoMode.active) return Future.value(); - return CreateRoom(CreateRoomParams(roomType: 1, invite: invite)).run(); + /// Returns the token of the created (or already existing) 1:1 room, or null + /// in demo mode where no room is actually created. + Future createDirectRoom(String invite) async { + if (DemoMode.active) return null; + final response = await CreateRoom( + CreateRoomParams(roomType: 1, invite: invite), + ).run(); + return response.data.token; } } diff --git a/lib/view/pages/marianum_dates/search_marianum_dates.dart b/lib/view/pages/marianum_dates/search_marianum_dates.dart index 293bbf9..19745a4 100644 --- a/lib/view/pages/marianum_dates/search_marianum_dates.dart +++ b/lib/view/pages/marianum_dates/search_marianum_dates.dart @@ -22,7 +22,7 @@ class SearchMarianumDates extends SearchDelegate { @override List? buildActions(BuildContext context) => [ if (query.isNotEmpty) - IconButton(onPressed: () => query = '', icon: const Icon(Icons.delete)), + IconButton(onPressed: () => query = '', icon: const Icon(Icons.clear)), ]; @override diff --git a/lib/view/pages/marianum_message/search_marianum_messages.dart b/lib/view/pages/marianum_message/search_marianum_messages.dart index 31de81a..d03686a 100644 --- a/lib/view/pages/marianum_message/search_marianum_messages.dart +++ b/lib/view/pages/marianum_message/search_marianum_messages.dart @@ -22,7 +22,7 @@ class SearchMarianumMessages extends SearchDelegate { @override List? buildActions(BuildContext context) => [ if (query.isNotEmpty) - IconButton(onPressed: () => query = '', icon: const Icon(Icons.delete)), + IconButton(onPressed: () => query = '', icon: const Icon(Icons.clear)), ]; @override diff --git a/lib/view/pages/talk/chat_list.dart b/lib/view/pages/talk/chat_list.dart index 3199bd6..88f7f63 100644 --- a/lib/view/pages/talk/chat_list.dart +++ b/lib/view/pages/talk/chat_list.dart @@ -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), diff --git a/lib/view/pages/talk/data/open_direct_chat.dart b/lib/view/pages/talk/data/open_direct_chat.dart index 0a5ecbd..6ec48b7 100644 --- a/lib/view/pages/talk/data/open_direct_chat.dart +++ b/lib/view/pages/talk/data/open_direct_chat.dart @@ -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); diff --git a/lib/view/pages/talk/join_chat.dart b/lib/view/pages/talk/join_chat.dart index 51188f2..def6e98 100644 --- a/lib/view/pages/talk/join_chat.dart +++ b/lib/view/pages/talk/join_chat.dart @@ -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 { - CancelableOperation? future; + CancelableOperation? future; @override List? buildActions(BuildContext context) => [ @@ -27,7 +27,7 @@ class JoinChat extends SearchDelegate { }, ), 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 { 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( + future = CancelableOperation.fromFuture(UserSearch().run(query)); + return FutureBuilder( 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), ); }, ); diff --git a/lib/view/pages/talk/search_chat.dart b/lib/view/pages/talk/search_chat.dart index 768daba..af76f38 100644 --- a/lib/view/pages/talk/search_chat.dart +++ b/lib/view/pages/talk/search_chat.dart @@ -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 { List 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? 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 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? _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 _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 = { + 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 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), + ), + ); + } +} diff --git a/lib/view/pages/talk/widgets/user_search_tile.dart b/lib/view/pages/talk/widgets/user_search_tile.dart new file mode 100644 index 0000000..0943b8a --- /dev/null +++ b/lib/view/pages/talk/widgets/user_search_tile.dart @@ -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, + ), + ), + ); + } +}