Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions MauverseIOS/Sources/Core/APIClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
73 changes: 73 additions & 0 deletions MauverseIOS/Sources/Views/ProfileView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -137,13 +137,46 @@ 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<Void, Never>?

var body: some View {
NavigationStack {
Form {
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)
Expand Down Expand Up @@ -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 = "Не удалось загрузить подсказки. Проверьте соединение"
}
}
}
}

Expand Down
16 changes: 12 additions & 4 deletions mauverse-api/apps/database/queries/user_queries.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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]
Expand Down
28 changes: 28 additions & 0 deletions mauverse-api/tests/test_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 = [
Expand Down
26 changes: 23 additions & 3 deletions mauverse_mobile/Pages/ProfilePage.xaml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8" ?>
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
x:Name="PageRoot"
Expand All @@ -8,7 +8,7 @@
Title="Профиль"
x:DataType="viewmodel:ProfileViewModel"
Shell.NavBarIsVisible="False"
xmlns:toolkit="http://schemas.microsoft.com/dotnet/2022/maui/toolkit"
xmlns:toolkit="http://schemas.microsoft.com/dotnet/2022/maui/toolkit"
BackgroundColor="{DynamicResource Background}">
<ContentPage.Behaviors>
<toolkit:EventToCommandBehavior BindingContext="{Binding Source={x:Reference PageRoot}, Path=BindingContext, x:DataType={x:Type ContentPage}}"
Expand All @@ -18,6 +18,13 @@
Command="{Binding CancelPendingOperationsCommand}"
EventName="Disappearing" />
</ContentPage.Behaviors>
<ContentPage.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="/Templates/SearchListTemplate.xaml"/>
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</ContentPage.Resources>
<ScrollView>
<VerticalStackLayout Padding="16,18,16,32" Spacing="20" MaximumWidthRequest="620">
<VerticalStackLayout Spacing="3">
Expand Down Expand Up @@ -61,8 +68,21 @@
IsEnabled="{Binding IsEdit}">
<Entry Text="{Binding Group}"
IsEnabled="{Binding IsEdit}"
Placeholder="Название группы" />
Placeholder="Например, ИС-21">
<Entry.Behaviors>
<toolkit:EventToCommandBehavior BindingContext="{Binding Source={x:Reference PageRoot}, Path=BindingContext, x:DataType={x:Type ContentPage}}"
EventName="TextChanged"
Command="{Binding GroupTextChangedCommand}"/>
</Entry.Behaviors>
</Entry>
</Border>
<Label Text="{Binding GroupHint}"
Style="{StaticResource SectionSubtitle}"
IsVisible="{Binding IsEdit}" />
<CollectionView IsVisible="{Binding IsGroupListVisible}"
MaximumHeightRequest="220"
ItemsSource="{Binding GroupList}"
ItemTemplate="{StaticResource GroupListItemTemplate}" />
</VerticalStackLayout>

<Button Text="{Binding ButtonChangeLabel}"
Expand Down
80 changes: 49 additions & 31 deletions mauverse_mobile/Templates/SearchListTemplate.xaml
Original file line number Diff line number Diff line change
@@ -1,41 +1,59 @@
<?xml version="1.0" encoding="utf-8" ?>
<ResourceDictionary xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:controls="clr-namespace:mau.Controls"
xmlns:models="clr-namespace:mau.Models"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:controls="clr-namespace:mau.Controls"
xmlns:models="clr-namespace:mau.Models"
xmlns:viewmodel="clr-namespace:mau.ViewModel.Services"
xmlns:profile="clr-namespace:mau.ViewModel"
x:Class="mau.Templates.SearchListTemplate">
<DataTemplate x:Key="TeacherListItemTemplate" x:DataType="x:String">
<Border Margin="0,0,0,4"
controls:LiquidGlass.UsesLiquidGlass="True"
Padding="12,8"
MinimumHeightRequest="48"
BackgroundColor="{DynamicResource Surface}"
Stroke="{DynamicResource BorderSubtle}"
StrokeThickness="1"
SemanticProperties.Description="{Binding .}"
SemanticProperties.Hint="Выбрать преподавателя">
<DataTemplate x:Key="GroupListItemTemplate" x:DataType="x:String">
<Border Margin="0,0,0,4"
controls:LiquidGlass.UsesLiquidGlass="True"
Padding="12,8"
MinimumHeightRequest="48"
BackgroundColor="{DynamicResource Surface}"
Stroke="{DynamicResource BorderSubtle}"
StrokeThickness="1"
SemanticProperties.Description="{Binding .}"
SemanticProperties.Hint="Выбрать группу">
<Border.GestureRecognizers>
<TapGestureRecognizer Command="{Binding Path=TeacherSelectedCommand, Source={RelativeSource AncestorType={x:Type viewmodel:InfoViewModel}}, x:DataType={x:Type viewmodel:InfoViewModel}}"
<TapGestureRecognizer Command="{Binding Path=GroupSelectedCommand, Source={RelativeSource AncestorType={x:Type profile:ProfileViewModel}}, x:DataType={x:Type profile:ProfileViewModel}}"
CommandParameter="{Binding .}" />
</Border.GestureRecognizers>
<Label VerticalOptions="Center" Text="{Binding .}" Style="{StaticResource ListLabel}"/>
</Border>
</DataTemplate>
<DataTemplate x:Key="TeacherListItemTemplate" x:DataType="x:String">
<Border Margin="0,0,0,4"
controls:LiquidGlass.UsesLiquidGlass="True"
Padding="12,8"
MinimumHeightRequest="48"
BackgroundColor="{DynamicResource Surface}"
Stroke="{DynamicResource BorderSubtle}"
StrokeThickness="1"
SemanticProperties.Description="{Binding .}"
SemanticProperties.Hint="Выбрать преподавателя">
<Border.GestureRecognizers>
<TapGestureRecognizer Command="{Binding Path=TeacherSelectedCommand, Source={RelativeSource AncestorType={x:Type viewmodel:InfoViewModel}}, x:DataType={x:Type viewmodel:InfoViewModel}}"
CommandParameter="{Binding .}">

</TapGestureRecognizer>
</Border.GestureRecognizers>
<Label VerticalOptions="Center" Text="{Binding .}" Style="{StaticResource ListLabel}"/>
</Border>
</DataTemplate>
<DataTemplate x:Key="ListItemTemplate" x:DataType="x:String">
<ViewCell>
<Grid Padding="12,8" MinimumHeightRequest="48" BackgroundColor="{DynamicResource Surface}">
<Label VerticalOptions="Center" Text="{Binding .}" Style="{StaticResource ListLabel}"/>
</Grid>
</ViewCell>
<Label VerticalOptions="Center" Text="{Binding .}" Style="{StaticResource ListLabel}"/>
</Border>
</DataTemplate>
<DataTemplate x:Key="ListItemTemplate" x:DataType="x:String">
<ViewCell>
<Grid Padding="12,8" MinimumHeightRequest="48" BackgroundColor="{DynamicResource Surface}">
<Label VerticalOptions="Center" Text="{Binding .}" Style="{StaticResource ListLabel}"/>
</Grid>
</ViewCell>
</DataTemplate>
<DataTemplate x:Key="RoomListItemTemplate" x:DataType="models:Room">
<ViewCell>
<Grid Padding="12,8" MinimumHeightRequest="48" BackgroundColor="{DynamicResource Surface}">
<Label VerticalOptions="Center" Text="{Binding Name}" Style="{StaticResource ListLabel}"/>
</Grid>
</ViewCell>
</DataTemplate>
<DataTemplate x:Key="RoomListItemTemplate" x:DataType="models:Room">
<ViewCell>
<Grid Padding="12,8" MinimumHeightRequest="48" BackgroundColor="{DynamicResource Surface}">
<Label VerticalOptions="Center" Text="{Binding Name}" Style="{StaticResource ListLabel}"/>
</Grid>
</ViewCell>
</DataTemplate>
</ResourceDictionary>
</ResourceDictionary>
7 changes: 4 additions & 3 deletions mauverse_mobile/ViewModel/LoginViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -199,21 +199,22 @@ private async Task CreateUserAsync(UserDTO existingUser, CancellationToken cance
if (existingUser.Role == UserRole.Student && !string.IsNullOrWhiteSpace(existingUser.GroupName))
{
var userSubgroups = await _userRequests.GetSubGroupsAsync(existingUser.GroupName, cancellationToken);
while (user.SubGroupId == string.Empty && userSubgroups.SubGroups.Any())
var availableSubgroups = userSubgroups.SubGroups?.ToList() ?? [];
while (user.SubGroupId == string.Empty && availableSubgroups.Count > 0)
{
var subgroup = await Shell.Current.DisplayActionSheetAsync(
"Выберите подгруппу",
"Выбрать позже в профиле",
null,
buttons: userSubgroups.SubGroups.Select(p => p.Name).ToArray());
buttons: availableSubgroups.Select(p => p.Name).ToArray());
if (subgroup is null || subgroup == "Выбрать позже в профиле")
{
await AppShell.DisplaySnackbarAsync(
"Подгруппа не выбрана — укажите её в профиле, чтобы расписание было точным");
break;
}

var selectedSubgroup = userSubgroups.SubGroups.FirstOrDefault(p => p.Name == subgroup);
var selectedSubgroup = availableSubgroups.FirstOrDefault(p => p.Name == subgroup);
if (selectedSubgroup != null)
{
user.SubGroupId = selectedSubgroup.GroupId;
Expand Down
Loading
Loading