From ce0e3e93dc1706fdb2777ea14df422753cda6339 Mon Sep 17 00:00:00 2001 From: decoder-dev Date: Tue, 1 Sep 2026 00:33:23 +0000 Subject: [PATCH] feat: group input hints and small bug fixes Add autocomplete hints when entering a study group in profile on Android (MAUI) and iOS, backed by /get_groups and cached schedule groups. Fix null-safe subgroup handling in profile/login flows and tighten get_subgroups API matching to exact group codes instead of broad LIKE. Co-authored-by: D3C0Y --- MauverseIOS/Sources/Core/APIClient.swift | 10 ++ MauverseIOS/Sources/Views/ProfileView.swift | 73 ++++++++++ .../apps/database/queries/user_queries.py | 16 ++- mauverse-api/tests/test_core.py | 28 ++++ mauverse_mobile/Pages/ProfilePage.xaml | 26 +++- .../Templates/SearchListTemplate.xaml | 80 ++++++----- mauverse_mobile/ViewModel/LoginViewModel.cs | 7 +- mauverse_mobile/ViewModel/ProfileViewModel.cs | 131 ++++++++++++++++-- 8 files changed, 317 insertions(+), 54 deletions(-) diff --git a/MauverseIOS/Sources/Core/APIClient.swift b/MauverseIOS/Sources/Core/APIClient.swift index d497aef..4f8119c 100644 --- a/MauverseIOS/Sources/Core/APIClient.swift +++ b/MauverseIOS/Sources/Core/APIClient.swift @@ -265,6 +265,16 @@ actor ScheduleAPIClient { } } + func suggestGroups(matching query: String, limit: Int = 5) async throws -> [String] { + let normalized = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard normalized.count >= 2 else { return [] } + return try await groups() + .map(\.group) + .filter { $0.localizedStandardContains(normalized) } + .prefix(limit) + .map { $0 } + } + func teachers(matching name: String) async throws -> [ScheduleTeacher] { let query = [URLQueryItem(name: "name", value: name)] let response: ScheduleTeachersResponse = try await get("teachers/search", query: query) diff --git a/MauverseIOS/Sources/Views/ProfileView.swift b/MauverseIOS/Sources/Views/ProfileView.swift index 9cd908f..a6c6090 100644 --- a/MauverseIOS/Sources/Views/ProfileView.swift +++ b/MauverseIOS/Sources/Views/ProfileView.swift @@ -137,6 +137,10 @@ private struct ProfileEditor: View { @State private var creditBook = "" @State private var isSaving = false @State private var errorMessage: String? + @State private var groupHint: String? = "Введите код группы, например ИС-21. Появятся подсказки из расписания." + @State private var groupSuggestions: [String] = [] + @State private var isLoadingSuggestions = false + @State private var suggestionTask: Task? var body: some View { NavigationStack { @@ -144,6 +148,35 @@ private struct ProfileEditor: View { Section("Расписание") { TextField("Учебная группа", text: $group) .textInputAutocapitalization(.characters) + .onChange(of: group) { _, newValue in + scheduleSuggestions(for: newValue) + } + if let groupHint { + Text(groupHint) + .font(.footnote) + .foregroundStyle(MauTheme.muted) + } + if isLoadingSuggestions { + ProgressView() + .controlSize(.small) + } else if !groupSuggestions.isEmpty { + ForEach(groupSuggestions, id: \.self) { suggestion in + Button { + group = suggestion + groupSuggestions = [] + groupHint = "Группа выбрана из списка расписания" + } label: { + HStack { + Text(suggestion) + .foregroundStyle(MauTheme.ink) + Spacer() + Image(systemName: "arrow.up.left") + .font(.caption.bold()) + .foregroundStyle(MauTheme.blue) + } + } + } + } if let errorMessage { Text(errorMessage) .font(.footnote) @@ -175,6 +208,46 @@ private struct ProfileEditor: View { group = session.user?.groupName ?? "" creditBook = session.user?.creditBook ?? "" } + .onDisappear { + suggestionTask?.cancel() + } + } + } + + private func scheduleSuggestions(for value: String) { + suggestionTask?.cancel() + let query = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard query.count >= 2 else { + groupSuggestions = [] + isLoadingSuggestions = false + groupHint = query.isEmpty + ? "Введите код группы, например ИС-21. Появятся подсказки из расписания." + : "Введите минимум 2 символа — покажем подсказки из расписания" + return + } + + suggestionTask = Task { + try? await Task.sleep(for: .milliseconds(150)) + guard !Task.isCancelled else { return } + await MainActor.run { isLoadingSuggestions = true } + do { + let suggestions = try await ScheduleAPIClient.shared.suggestGroups(matching: query) + guard !Task.isCancelled else { return } + await MainActor.run { + groupSuggestions = suggestions + isLoadingSuggestions = false + groupHint = suggestions.isEmpty + ? "Группа не найдена. Проверьте код, например ИС-21" + : "Выберите группу из списка или продолжите ввод" + } + } catch { + guard !Task.isCancelled else { return } + await MainActor.run { + groupSuggestions = [] + isLoadingSuggestions = false + groupHint = "Не удалось загрузить подсказки. Проверьте соединение" + } + } } } diff --git a/mauverse-api/apps/database/queries/user_queries.py b/mauverse-api/apps/database/queries/user_queries.py index 151f039..ffbd415 100644 --- a/mauverse-api/apps/database/queries/user_queries.py +++ b/mauverse-api/apps/database/queries/user_queries.py @@ -38,7 +38,7 @@ def get_group_id(group_name: str) -> dict[str, Any]: FROM 1c.groups WHERE groups.group = %s; """ - group_info = execute_query(schedule_db_connect, group_query, (group_name,)) + group_info = execute_query(schedule_db_connect, group_query, (group_name.upper(),)) if len(group_info) == 1: result["groupid"] = group_info[0][0] @@ -50,14 +50,22 @@ def get_group_id(group_name: str) -> dict[str, Any]: def get_subgroups(group_name: str) -> dict[str, Any]: result = {} + normalized = group_name.strip().upper() + if not normalized: + return {"error": "Не указана группа", "subgroups": []} + group_query = """ SELECT UID, groups.group, speciality, UID_mg FROM 1c.groups - WHERE groups.group LIKE %s - Order by maingroup_id asc; + WHERE groups.group = %s OR groups.group LIKE %s + ORDER BY maingroup_id ASC; """ - group_info = execute_query(schedule_db_connect, group_query, (f"%{group_name}%",)) + group_info = execute_query( + schedule_db_connect, + group_query, + (normalized, f"{normalized}-%"), + ) if len(group_info) > 0: result["groupid"] = group_info[0][0] diff --git a/mauverse-api/tests/test_core.py b/mauverse-api/tests/test_core.py index ecbed16..d9b3836 100644 --- a/mauverse-api/tests/test_core.py +++ b/mauverse-api/tests/test_core.py @@ -25,6 +25,7 @@ get_debts_by_credit_book_query as database_get_debts_by_credit_book_query, ) from apps.database.queries.user_queries import ( + get_subgroups as database_get_subgroups, get_user_info_query as database_get_user_info_query, ) from apps.database.settings import DBSettings, MailSettings, ParsingSettings, mail_config @@ -905,6 +906,33 @@ def test_docker_uses_single_worker_for_process_local_rate_limits(self): self.assertIn('"--workers", "1"', contents) self.assertNotIn('"--workers", "2"', contents) + @patch("apps.database.queries.user_queries.execute_query") + def test_get_subgroups_uses_exact_group_match(self, execute): + execute.return_value = [ + ("uid-main", "ИС-21", "Информатика", ""), + ("uid-sub", "ИС-21-1", "Информатика", "uid-main"), + ] + + result = database_get_subgroups("ис-21") + + execute.assert_called_once() + query, params = execute.call_args[0][1], execute.call_args[0][2] + self.assertIn("groups.group = %s", query) + self.assertEqual(("ИС-21", "ИС-21-%"), params) + self.assertEqual("uid-main", result["groupid"]) + self.assertEqual("Информатика", result["speciality"]) + self.assertEqual( + [{"groupid": "uid-sub", "name": "ИС-21-1"}], + result["subgroups"], + ) + + @patch("apps.database.queries.user_queries.execute_query") + def test_get_subgroups_rejects_empty_group(self, execute): + result = database_get_subgroups(" ") + + execute.assert_not_called() + self.assertEqual({"error": "Не указана группа", "subgroups": []}, result) + @patch("apps.database.queries.user_queries.execute_query") def test_ambiguous_local_user_profile_fails_closed(self, execute): execute.return_value = [ diff --git a/mauverse_mobile/Pages/ProfilePage.xaml b/mauverse_mobile/Pages/ProfilePage.xaml index 66ec7d7..5d43fb2 100644 --- a/mauverse_mobile/Pages/ProfilePage.xaml +++ b/mauverse_mobile/Pages/ProfilePage.xaml @@ -1,4 +1,4 @@ - + + + + + + + + @@ -61,8 +68,21 @@ IsEnabled="{Binding IsEdit}"> + Placeholder="Например, ИС-21"> + + + + +