implemented global user search integration for Talk chats with role-coded badges and direct chat creation logic
This commit is contained in:
@@ -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<McUserSearchResult> _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<McUserSearchResult> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'data/demo_breaker.dart';
|
import 'data/demo_breaker.dart';
|
||||||
import 'data/demo_holidays.dart';
|
import 'data/demo_holidays.dart';
|
||||||
import 'data/demo_timetable.dart';
|
import 'data/demo_timetable.dart';
|
||||||
|
import 'data/demo_users.dart';
|
||||||
|
|
||||||
/// Single source of truth for the MarianumConnect demo responses. The demo
|
/// Single source of truth for the MarianumConnect demo responses. The demo
|
||||||
/// interceptor asks this for the body of any MC request. Read endpoints reuse
|
/// 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();
|
return DemoTimetable.holidays().result.map((e) => e.toJson()).toList();
|
||||||
case 'holidays':
|
case 'holidays':
|
||||||
return DemoHolidays.upcoming().map((e) => e.toJson()).toList();
|
return DemoHolidays.upcoming().map((e) => e.toJson()).toList();
|
||||||
|
case 'users/search':
|
||||||
|
return DemoUsers.search(query['q']?.toString() ?? '')
|
||||||
|
.map((e) => e.toJson())
|
||||||
|
.toList();
|
||||||
case 'breaker':
|
case 'breaker':
|
||||||
return DemoBreaker.none().toJson();
|
return DemoBreaker.none().toJson();
|
||||||
case 'timetable/elements/teachers':
|
case 'timetable/elements/teachers':
|
||||||
|
|||||||
@@ -1,16 +1,22 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
import 'package:http/http.dart';
|
import 'package:http/http.dart';
|
||||||
|
|
||||||
import '../talk_api.dart';
|
import '../talk_api.dart';
|
||||||
import 'create_room_params.dart';
|
import 'create_room_params.dart';
|
||||||
|
import 'create_room_response.dart';
|
||||||
|
|
||||||
class CreateRoom extends TalkApi {
|
class CreateRoom extends TalkApi<CreateRoomResponse> {
|
||||||
CreateRoomParams params;
|
CreateRoomParams params;
|
||||||
|
|
||||||
CreateRoom(this.params) : super('v4/room', params);
|
CreateRoom(this.params) : super('v4/room', params);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Null assemble(String raw) => null;
|
CreateRoomResponse assemble(String raw) {
|
||||||
|
final decoded = jsonDecode(raw) as Map<String, dynamic>;
|
||||||
|
return CreateRoomResponse.fromJson(decoded['ocs'] as Map<String, dynamic>);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Response>? request(
|
Future<Response>? request(
|
||||||
|
|||||||
@@ -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<String, dynamic> json) =>
|
||||||
|
_$CreateRoomResponseFromJson(json);
|
||||||
|
Map<String, dynamic> toJson() => _$CreateRoomResponseToJson(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonSerializable()
|
||||||
|
class CreateRoomResponseData {
|
||||||
|
final String token;
|
||||||
|
|
||||||
|
CreateRoomResponseData(this.token);
|
||||||
|
|
||||||
|
factory CreateRoomResponseData.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$CreateRoomResponseDataFromJson(json);
|
||||||
|
Map<String, dynamic> toJson() => _$CreateRoomResponseDataToJson(this);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'create_room_response.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// JsonSerializableGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
CreateRoomResponse _$CreateRoomResponseFromJson(Map<String, dynamic> json) =>
|
||||||
|
CreateRoomResponse(
|
||||||
|
CreateRoomResponseData.fromJson(json['data'] as Map<String, dynamic>),
|
||||||
|
)
|
||||||
|
..headers = (json['headers'] as Map<String, dynamic>?)?.map(
|
||||||
|
(k, e) => MapEntry(k, e as String),
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$CreateRoomResponseToJson(CreateRoomResponse instance) =>
|
||||||
|
<String, dynamic>{
|
||||||
|
'headers': ?instance.headers,
|
||||||
|
'data': instance.data.toJson(),
|
||||||
|
};
|
||||||
|
|
||||||
|
CreateRoomResponseData _$CreateRoomResponseDataFromJson(
|
||||||
|
Map<String, dynamic> json,
|
||||||
|
) => CreateRoomResponseData(json['token'] as String);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$CreateRoomResponseDataToJson(
|
||||||
|
CreateRoomResponseData instance,
|
||||||
|
) => <String, dynamic>{'token': instance.token};
|
||||||
@@ -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<UserSearchResponse> run(String query) async {
|
||||||
|
try {
|
||||||
|
final response = await _dio.get<List<dynamic>>(
|
||||||
|
MarianumConnectEndpoint.resolve('users/search'),
|
||||||
|
queryParameters: {'q': query},
|
||||||
|
);
|
||||||
|
final list = response.data!
|
||||||
|
.map((e) => McUserSearchResult.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
return UserSearchResponse(result: list);
|
||||||
|
} on DioException catch (e) {
|
||||||
|
throw mapMarianumConnectError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String, dynamic> json) =>
|
||||||
|
_$McUserSearchResultFromJson(json);
|
||||||
|
Map<String, dynamic> toJson() => _$McUserSearchResultToJson(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonSerializable(explicitToJson: true)
|
||||||
|
class UserSearchResponse extends ApiResponse {
|
||||||
|
final List<McUserSearchResult> result;
|
||||||
|
|
||||||
|
UserSearchResponse({required this.result});
|
||||||
|
|
||||||
|
factory UserSearchResponse.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$UserSearchResponseFromJson(json);
|
||||||
|
Map<String, dynamic> toJson() => _$UserSearchResponseToJson(this);
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'user_search_response.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// JsonSerializableGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
McUserSearchResult _$McUserSearchResultFromJson(Map<String, dynamic> 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<String, dynamic> _$McUserSearchResultToJson(McUserSearchResult instance) =>
|
||||||
|
<String, dynamic>{
|
||||||
|
'username': instance.username,
|
||||||
|
'firstName': instance.firstName,
|
||||||
|
'lastName': instance.lastName,
|
||||||
|
'userType': instance.userType,
|
||||||
|
'className': instance.className,
|
||||||
|
};
|
||||||
|
|
||||||
|
UserSearchResponse _$UserSearchResponseFromJson(Map<String, dynamic> json) =>
|
||||||
|
UserSearchResponse(
|
||||||
|
result: (json['result'] as List<dynamic>)
|
||||||
|
.map((e) => McUserSearchResult.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList(),
|
||||||
|
)
|
||||||
|
..headers = (json['headers'] as Map<String, dynamic>?)?.map(
|
||||||
|
(k, e) => MapEntry(k, e as String),
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$UserSearchResponseToJson(UserSearchResponse instance) =>
|
||||||
|
<String, dynamic>{
|
||||||
|
'headers': ?instance.headers,
|
||||||
|
'result': instance.result.map((e) => e.toJson()).toList(),
|
||||||
|
};
|
||||||
@@ -100,9 +100,12 @@ class ChatListBloc
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> createDirectChat(String invite) async {
|
/// Creates (or resolves) a 1:1 chat and returns its room token, or null in
|
||||||
await repo.data.createDirectRoom(invite);
|
/// demo mode. Refreshes the list so the room shows up.
|
||||||
|
Future<String?> createDirectChat(String invite) async {
|
||||||
|
final token = await repo.data.createDirectRoom(invite);
|
||||||
await refresh();
|
await refresh();
|
||||||
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
int? lastReadMessageFor(String token) {
|
int? lastReadMessageFor(String token) {
|
||||||
|
|||||||
@@ -20,8 +20,13 @@ class ChatListDataProvider {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> createDirectRoom(String invite) {
|
/// Returns the token of the created (or already existing) 1:1 room, or null
|
||||||
if (DemoMode.active) return Future.value();
|
/// in demo mode where no room is actually created.
|
||||||
return CreateRoom(CreateRoomParams(roomType: 1, invite: invite)).run();
|
Future<String?> createDirectRoom(String invite) async {
|
||||||
|
if (DemoMode.active) return null;
|
||||||
|
final response = await CreateRoom(
|
||||||
|
CreateRoomParams(roomType: 1, invite: invite),
|
||||||
|
).run();
|
||||||
|
return response.data.token;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ class SearchMarianumDates extends SearchDelegate<MarianumDate?> {
|
|||||||
@override
|
@override
|
||||||
List<Widget>? buildActions(BuildContext context) => [
|
List<Widget>? buildActions(BuildContext context) => [
|
||||||
if (query.isNotEmpty)
|
if (query.isNotEmpty)
|
||||||
IconButton(onPressed: () => query = '', icon: const Icon(Icons.delete)),
|
IconButton(onPressed: () => query = '', icon: const Icon(Icons.clear)),
|
||||||
];
|
];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ class SearchMarianumMessages extends SearchDelegate<MarianumMessage?> {
|
|||||||
@override
|
@override
|
||||||
List<Widget>? buildActions(BuildContext context) => [
|
List<Widget>? buildActions(BuildContext context) => [
|
||||||
if (query.isNotEmpty)
|
if (query.isNotEmpty)
|
||||||
IconButton(onPressed: () => query = '', icon: const Icon(Icons.delete)),
|
IconButton(onPressed: () => query = '', icon: const Icon(Icons.clear)),
|
||||||
];
|
];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -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_bloc.dart';
|
||||||
import '../../../state/app/modules/chat_list/bloc/chat_list_state.dart';
|
import '../../../state/app/modules/chat_list/bloc/chat_list_state.dart';
|
||||||
import '../../../state/app/modules/settings/bloc/settings_cubit.dart';
|
import '../../../state/app/modules/settings/bloc/settings_cubit.dart';
|
||||||
import '../../../widget/confirm_dialog.dart';
|
|
||||||
import '../../../widget/demo_restricted.dart';
|
import '../../../widget/demo_restricted.dart';
|
||||||
import '../../../widget/placeholder_view.dart';
|
import '../../../widget/placeholder_view.dart';
|
||||||
|
import 'data/open_direct_chat.dart';
|
||||||
import 'join_chat.dart';
|
import 'join_chat.dart';
|
||||||
import 'notification_permission_prompt.dart';
|
import 'notification_permission_prompt.dart';
|
||||||
import 'search_chat.dart';
|
import 'search_chat.dart';
|
||||||
@@ -91,7 +91,15 @@ class _ChatListViewState extends State<_ChatListView> {
|
|||||||
if (rooms == null) return;
|
if (rooms == null) return;
|
||||||
showSearch(
|
showSearch(
|
||||||
context: context,
|
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,
|
username,
|
||||||
) {
|
) {
|
||||||
if (username == null || !context.mounted) return;
|
if (username == null || !context.mounted) return;
|
||||||
ConfirmDialog(
|
openOrCreateDirectChat(
|
||||||
title: 'Talk-Chat starten',
|
context,
|
||||||
content:
|
actorId: username,
|
||||||
"Möchtest du einen Talk-Chat mit Nutzer '$username' starten?",
|
actorDisplayName: username,
|
||||||
confirmButton: 'Talk-Chat starten',
|
);
|
||||||
onConfirmAsync: () => bloc.createDirectChat(username),
|
|
||||||
).asDialog(context);
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
child: const Icon(Icons.add_comment_outlined),
|
child: const Icon(Icons.add_comment_outlined),
|
||||||
|
|||||||
@@ -30,19 +30,19 @@ void openOrCreateDirectChat(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
void switchToChat(GetRoomResponseObject room) {
|
void switchToChat(String token) {
|
||||||
// Pop the previous ChatView first — otherwise it stays in the
|
// Pop the previous ChatView first — otherwise it stays in the
|
||||||
// back-stack with a now-mismatched currentToken and renders empty
|
// back-stack with a now-mismatched currentToken and renders empty
|
||||||
// on back-swipe. Stop at popups so an open dialog stays alive.
|
// on back-swipe. Stop at popups so an open dialog stays alive.
|
||||||
Navigator.of(
|
Navigator.of(
|
||||||
context,
|
context,
|
||||||
).popUntil((route) => route.isFirst || route is PopupRoute);
|
).popUntil((route) => route.isFirst || route is PopupRoute);
|
||||||
AppRoutes.openChatByToken(context, room.token);
|
AppRoutes.openChatByToken(context, token);
|
||||||
}
|
}
|
||||||
|
|
||||||
final existing = findExisting();
|
final existing = findExisting();
|
||||||
if (existing != null) {
|
if (existing != null) {
|
||||||
switchToChat(existing);
|
switchToChat(existing.token);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,15 +53,11 @@ void openOrCreateDirectChat(
|
|||||||
'Soll einer erstellt werden?',
|
'Soll einer erstellt werden?',
|
||||||
confirmButton: 'Erstellen',
|
confirmButton: 'Erstellen',
|
||||||
onConfirmAsync: () async {
|
onConfirmAsync: () async {
|
||||||
await chatListBloc.createDirectChat(actorId);
|
// The create call returns the new room's token directly, so we open it
|
||||||
final created = findExisting();
|
// without racing the (asynchronously updated) chat-list state.
|
||||||
if (created == null) {
|
final token = await chatListBloc.createDirectChat(actorId);
|
||||||
throw Exception(
|
if (context.mounted && token != null) {
|
||||||
'Privatchat konnte nach dem Erstellen nicht gefunden werden.',
|
switchToChat(token);
|
||||||
);
|
|
||||||
}
|
|
||||||
if (context.mounted) {
|
|
||||||
switchToChat(created);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
).asDialog(context);
|
).asDialog(context);
|
||||||
|
|||||||
@@ -2,14 +2,14 @@ import 'package:async/async.dart';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../../../api/errors/error_mapper.dart';
|
import '../../../api/errors/error_mapper.dart';
|
||||||
import '../../../api/marianumcloud/autocomplete/autocomplete_api.dart';
|
import '../../../api/marianumconnect/queries/user_search/user_search.dart';
|
||||||
import '../../../api/marianumcloud/autocomplete/autocomplete_response.dart';
|
import '../../../api/marianumconnect/queries/user_search/user_search_response.dart';
|
||||||
import '../../../model/endpoint_data.dart';
|
|
||||||
import '../../../widget/app_progress_indicator.dart';
|
import '../../../widget/app_progress_indicator.dart';
|
||||||
import '../../../widget/placeholder_view.dart';
|
import '../../../widget/placeholder_view.dart';
|
||||||
|
import 'widgets/user_search_tile.dart';
|
||||||
|
|
||||||
class JoinChat extends SearchDelegate<String> {
|
class JoinChat extends SearchDelegate<String> {
|
||||||
CancelableOperation<AutocompleteResponse>? future;
|
CancelableOperation<UserSearchResponse>? future;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<Widget>? buildActions(BuildContext context) => [
|
List<Widget>? buildActions(BuildContext context) => [
|
||||||
@@ -27,7 +27,7 @@ class JoinChat extends SearchDelegate<String> {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
if (query.isNotEmpty)
|
if (query.isNotEmpty)
|
||||||
IconButton(onPressed: () => query = '', icon: const Icon(Icons.delete)),
|
IconButton(onPressed: () => query = '', icon: const Icon(Icons.clear)),
|
||||||
];
|
];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -39,36 +39,30 @@ class JoinChat extends SearchDelegate<String> {
|
|||||||
|
|
||||||
if (query.isEmpty) {
|
if (query.isEmpty) {
|
||||||
return const PlaceholderView(
|
return const PlaceholderView(
|
||||||
text: 'Suche nach benutzern',
|
text: 'Suche nach Schülerinnen, Schülern und Lehrkräften',
|
||||||
icon: Icons.person_search_outlined,
|
icon: Icons.person_search_outlined,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
future = CancelableOperation.fromFuture(AutocompleteApi().find(query));
|
future = CancelableOperation.fromFuture(UserSearch().run(query));
|
||||||
return FutureBuilder<AutocompleteResponse>(
|
return FutureBuilder<UserSearchResponse>(
|
||||||
future: future!.value,
|
future: future!.value,
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
if (snapshot.hasData) {
|
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(
|
return ListView.builder(
|
||||||
itemCount: snapshot.data!.data.length,
|
itemCount: results.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
var object = snapshot.data!.data[index];
|
final object = results[index];
|
||||||
var circleAvatar = CircleAvatar(
|
return UserSearchTile(
|
||||||
foregroundImage: Image.network(
|
user: object,
|
||||||
'https://${EndpointData().nextcloud().full()}/avatar/${object.id}/128',
|
onTap: () => close(context, object.username),
|
||||||
).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);
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,49 +1,249 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../../api/errors/error_mapper.dart';
|
||||||
import '../../../api/marianumcloud/talk/room/get_room_response.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/chat_tile.dart';
|
||||||
|
import 'widgets/user_search_tile.dart';
|
||||||
|
|
||||||
class SearchChat extends SearchDelegate<GetRoomResponseObject?> {
|
class SearchChat extends SearchDelegate<GetRoomResponseObject?> {
|
||||||
List<GetRoomResponseObject> chats;
|
List<GetRoomResponseObject> chats;
|
||||||
final void Function(GetRoomResponseObject room)? onTapOverride;
|
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
|
@override
|
||||||
List<Widget>? buildActions(BuildContext context) => [
|
List<Widget>? buildActions(BuildContext context) => [
|
||||||
if (query.isNotEmpty)
|
if (query.isNotEmpty)
|
||||||
IconButton(onPressed: () => query = '', icon: const Icon(Icons.delete)),
|
IconButton(onPressed: () => query = '', icon: const Icon(Icons.clear)),
|
||||||
];
|
];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget? buildLeading(BuildContext context) => null;
|
Widget? buildLeading(BuildContext context) => null;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget buildResults(BuildContext context) {
|
Widget buildResults(BuildContext context) => _SearchChatResults(
|
||||||
var items =
|
chats: chats,
|
||||||
chats
|
query: query,
|
||||||
.where(
|
onTapOverride: onTapOverride,
|
||||||
(e) =>
|
onStartChat: onStartChat == null
|
||||||
e.displayName.toString().toLowerCase().contains(
|
? null
|
||||||
query.toLowerCase(),
|
: (username, displayName) {
|
||||||
) ||
|
close(context, null);
|
||||||
e.name.toString().toLowerCase().contains(query.toLowerCase()),
|
onStartChat!(username, displayName);
|
||||||
)
|
},
|
||||||
.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,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget buildSuggestions(BuildContext context) => buildResults(context);
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user