diff --git a/.gitignore b/.gitignore
index 37df0d1..1657f90 100644
--- a/.gitignore
+++ b/.gitignore
@@ -24,6 +24,7 @@
*.p12
*.pfx
*.mobileprovision
+**/Directory.Build.signing.props
# Generated artifacts
.artifacts/
diff --git a/MauverseIOS/Resources/Info.plist b/MauverseIOS/Resources/Info.plist
index 06197e4..a36e11d 100644
--- a/MauverseIOS/Resources/Info.plist
+++ b/MauverseIOS/Resources/Info.plist
@@ -22,6 +22,8 @@
$(CURRENT_PROJECT_VERSION)
MAUverseScheduleToken
$(MAUVERSE_SCHEDULE_TOKEN)
+ MAUVERSE_API_BASE_URL
+ $(MAUVERSE_API_BASE_URL)
UILaunchScreen
UISupportedInterfaceOrientations
diff --git a/MauverseIOS/Sources/Core/APIClient.swift b/MauverseIOS/Sources/Core/APIClient.swift
index b3eefd6..d497aef 100644
--- a/MauverseIOS/Sources/Core/APIClient.swift
+++ b/MauverseIOS/Sources/Core/APIClient.swift
@@ -20,10 +20,16 @@ extension Notification.Name {
final class APIClient {
static let shared = APIClient()
- private let baseURL = URL(string: "https://app.mauniver.ru/dev/mauverse/")!
+ private let baseURL: URL
private let session: URLSession
private init() {
+ if let configured = Bundle.main.object(forInfoDictionaryKey: "MAUVERSE_API_BASE_URL") as? String,
+ let url = URL(string: configured), !configured.isEmpty {
+ baseURL = url
+ } else {
+ baseURL = URL(string: "https://app.mauniver.ru/dev/mauverse/")!
+ }
let configuration = URLSessionConfiguration.default
configuration.timeoutIntervalForRequest = 30
configuration.waitsForConnectivity = true
diff --git a/MauverseIOS/Sources/Core/Models.swift b/MauverseIOS/Sources/Core/Models.swift
index 1aeaacc..e10b185 100644
--- a/MauverseIOS/Sources/Core/Models.swift
+++ b/MauverseIOS/Sources/Core/Models.swift
@@ -128,7 +128,6 @@ struct UserDTO: Codable, Equatable {
subGroupId = values.decodeLossyStringIfPresent(forKey: .subGroupId)
?? values.decodeLossyStringIfPresent(forKey: .legacySubGroupId)
scheduleGroupUID = try values.decodeIfPresent(String.self, forKey: .scheduleGroupUID)
- ?? values.decodeLossyStringIfPresent(forKey: .apiGroupId)
groupName = try values.decodeIfPresent(String.self, forKey: .groupName)
?? values.decodeLossyStringIfPresent(forKey: .legacyGroupName)
speciality = try values.decodeIfPresent(String.self, forKey: .speciality)
diff --git a/MauverseIOS/Sources/Core/OfficialNewsService.swift b/MauverseIOS/Sources/Core/OfficialNewsService.swift
index 5f09a61..4b7c422 100644
--- a/MauverseIOS/Sources/Core/OfficialNewsService.swift
+++ b/MauverseIOS/Sources/Core/OfficialNewsService.swift
@@ -58,7 +58,7 @@ actor OfficialNewsService {
private func fetch(url: URL, cachePolicy: URLRequest.CachePolicy) async throws -> [NewsItem] {
var request = URLRequest(url: url, cachePolicy: cachePolicy, timeoutInterval: 30)
request.timeoutInterval = 30
- request.setValue("MAUverse/1.12.2 (iOS)", forHTTPHeaderField: "User-Agent")
+ request.setValue("MAUverse/1.12.3 (iOS)", forHTTPHeaderField: "User-Agent")
request.setValue("application/rss+xml, application/xml, text/xml", forHTTPHeaderField: "Accept")
let (data, response) = try await URLSession.shared.data(for: request)
diff --git a/MauverseIOS/Sources/Core/SessionStore.swift b/MauverseIOS/Sources/Core/SessionStore.swift
index 38bf393..7bd1a64 100644
--- a/MauverseIOS/Sources/Core/SessionStore.swift
+++ b/MauverseIOS/Sources/Core/SessionStore.swift
@@ -20,6 +20,10 @@ final class SessionStore: ObservableObject {
UserDefaults.standard.removeObject(forKey: storageKey)
}
}
+ if var current = user {
+ Self.clearMistakenScheduleUID(¤t)
+ user = current
+ }
sessionObserver = NotificationCenter.default.addObserver(
forName: .mauverseSessionExpired,
object: nil,
@@ -63,9 +67,7 @@ final class SessionStore: ObservableObject {
)
}
authenticated.username = authenticated.username ?? normalizedUsername
- if authenticated.scheduleGroupUID?.isEmpty != false {
- authenticated.scheduleGroupUID = authenticated.groupId
- }
+ Self.clearMistakenScheduleUID(&authenticated)
user = authenticated
persist()
await refreshProfile()
@@ -83,6 +85,7 @@ final class SessionStore: ObservableObject {
func signOut() {
user = nil
+ errorMessage = nil
KeychainStore.delete(account: storageKey)
UserDefaults.standard.removeObject(forKey: storageKey)
}
@@ -118,4 +121,15 @@ final class SessionStore: ObservableObject {
guard let user, let data = try? JSONEncoder().encode(user) else { return }
KeychainStore.write(data, account: storageKey)
}
+
+ /// Moodle numeric group ids were previously copied into scheduleGroupUID and broke lookups.
+ private static func clearMistakenScheduleUID(_ user: inout UserDTO) {
+ guard let uid = user.scheduleGroupUID, !uid.isEmpty else {
+ user.scheduleGroupUID = nil
+ return
+ }
+ if let groupId = user.groupId, uid == groupId {
+ user.scheduleGroupUID = nil
+ }
+ }
}
diff --git a/MauverseIOS/Sources/Views/HomeView.swift b/MauverseIOS/Sources/Views/HomeView.swift
index 3135dad..4a7380f 100644
--- a/MauverseIOS/Sources/Views/HomeView.swift
+++ b/MauverseIOS/Sources/Views/HomeView.swift
@@ -85,6 +85,7 @@ final class HomeViewModel: ObservableObject {
struct HomeView: View {
@EnvironmentObject private var session: SessionStore
+ @Binding var selectedTab: Int
@StateObject private var model = HomeViewModel()
private var firstName: String {
@@ -106,17 +107,21 @@ struct HomeView: View {
HomeDataErrorCard(
title: "Расписание недоступно",
message: error,
- icon: "calendar.badge.exclamationmark"
+ icon: "calendar.badge.exclamationmark",
+ retry: { Task { await model.load(user: session.user) } }
)
} else {
- NextLessonCard(item: model.nextLesson, group: session.user?.groupName)
+ Button { selectedTab = 1 } label: {
+ NextLessonCard(item: model.nextLesson, group: session.user?.groupName)
+ }
+ .buttonStyle(.plain)
}
MauSectionHeader(title: "Быстрый доступ")
quickActions
if !model.notifications.isEmpty {
- MauSectionHeader(title: "Обновления ЭИОС")
+ MauSectionHeader(title: "Уведомления портала")
ScrollView(.horizontal, showsIndicators: false) {
LazyHStack(spacing: 12) {
ForEach(model.notifications) { notification in
@@ -132,7 +137,7 @@ struct HomeView: View {
.font(.title3.bold())
.foregroundStyle(MauTheme.ink)
Spacer()
- NavigationLink(destination: NewsView()) {
+ Button { selectedTab = 3 } label: {
HStack(spacing: 4) {
Text("Все новости")
Image(systemName: "chevron.right")
@@ -147,7 +152,8 @@ struct HomeView: View {
HomeDataErrorCard(
title: "Новости не обновились",
message: error,
- icon: "newspaper"
+ icon: "newspaper",
+ retry: { Task { await model.load(user: session.user) } }
)
}
}
@@ -171,7 +177,7 @@ struct HomeView: View {
.font(.system(size: 38, weight: .bold, design: .rounded))
.foregroundStyle(MauTheme.ink)
HStack(spacing: 8) {
- MauStatusPill(title: "Сессия защищена", icon: "lock.fill")
+ MauStatusPill(title: "Вход сохранён", icon: "lock.fill")
if let group = session.user?.groupName, !group.isEmpty {
Text(group)
.font(.caption.weight(.semibold))
@@ -181,7 +187,7 @@ struct HomeView: View {
}
}
Spacer()
- NavigationLink(destination: ProfileView()) {
+ Button { selectedTab = 4 } label: {
ZStack {
Circle().fill(MauTheme.blue.opacity(0.13))
Image(systemName: "person.crop.circle.fill")
@@ -226,17 +232,25 @@ struct HomeView: View {
spacing: 12
) {
PremiumQuickCard(title: "Расписание", subtitle: "Занятия и аудитории", icon: "calendar", color: MauTheme.blue) {
- ScheduleView()
+ selectedTab = 1
}
PremiumQuickCard(title: "Сервисы", subtitle: "Все инструменты МАУ", icon: "square.grid.2x2.fill", color: MauTheme.violet) {
- ServicesView()
+ selectedTab = 2
}
PremiumQuickCard(title: "Новости", subtitle: "События университета", icon: "newspaper.fill", color: .orange) {
- NewsView()
+ selectedTab = 3
}
- PremiumQuickCard(title: "ЭИОС", subtitle: "Учебный портал", icon: "graduationcap.fill", color: .teal) {
- InAppBrowserView(url: URL(string: "https://eios.mauniver.ru/moodle/")!, title: "ЭИОС")
+ NavigationLink {
+ InAppBrowserView(url: URL(string: "https://eios.mauniver.ru/moodle/")!, title: "Учебный портал")
+ } label: {
+ PremiumQuickCardContent(
+ title: "Учёба",
+ subtitle: "Учебный портал",
+ icon: "graduationcap.fill",
+ color: .teal
+ )
}
+ .buttonStyle(.plain)
}
}
@@ -298,18 +312,26 @@ private struct HomeDataErrorCard: View {
let title: String
let message: String
let icon: String
+ var retry: (() -> Void)?
var body: some View {
- HStack(spacing: 14) {
- IconTile(systemName: icon, color: .orange)
- VStack(alignment: .leading, spacing: 4) {
- Text(title).font(.headline)
- Text(message)
- .font(.caption)
- .foregroundStyle(MauTheme.muted)
- .lineLimit(2)
+ VStack(alignment: .leading, spacing: 12) {
+ HStack(spacing: 14) {
+ IconTile(systemName: icon, color: .orange)
+ VStack(alignment: .leading, spacing: 4) {
+ Text(title).font(.headline)
+ Text(message)
+ .font(.caption)
+ .foregroundStyle(MauTheme.muted)
+ .lineLimit(3)
+ }
+ Spacer()
+ }
+ if let retry {
+ Button("Повторить", action: retry)
+ .font(.subheadline.weight(.semibold))
+ .foregroundStyle(MauTheme.blue)
}
- Spacer()
}
.padding(17)
.mauSurface(radius: 22)
@@ -377,36 +399,47 @@ private struct NextLessonCard: View {
}
}
-private struct PremiumQuickCard: View {
+private struct PremiumQuickCard: View {
let title: String
let subtitle: String
let icon: String
let color: Color
- @ViewBuilder let destination: () -> Destination
+ let action: () -> Void
var body: some View {
- NavigationLink(destination: destination()) {
- VStack(alignment: .leading, spacing: 15) {
- HStack {
- IconTile(systemName: icon, color: color)
- Spacer()
- Image(systemName: "arrow.up.right")
- .font(.caption.bold())
- .foregroundStyle(MauTheme.muted)
- }
- VStack(alignment: .leading, spacing: 3) {
- Text(title).font(.headline)
- Text(subtitle)
- .font(.caption)
- .foregroundStyle(MauTheme.muted)
- .lineLimit(1)
- }
- }
- .foregroundStyle(MauTheme.ink)
- .frame(maxWidth: .infinity, minHeight: 105, alignment: .topLeading)
- .padding(16)
+ Button(action: action) {
+ PremiumQuickCardContent(title: title, subtitle: subtitle, icon: icon, color: color)
}
.buttonStyle(.plain)
+ }
+}
+
+private struct PremiumQuickCardContent: View {
+ let title: String
+ let subtitle: String
+ let icon: String
+ let color: Color
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 15) {
+ HStack {
+ IconTile(systemName: icon, color: color)
+ Spacer()
+ Image(systemName: "arrow.up.right")
+ .font(.caption.bold())
+ .foregroundStyle(MauTheme.muted)
+ }
+ VStack(alignment: .leading, spacing: 3) {
+ Text(title).font(.headline)
+ Text(subtitle)
+ .font(.caption)
+ .foregroundStyle(MauTheme.muted)
+ .lineLimit(1)
+ }
+ }
+ .foregroundStyle(MauTheme.ink)
+ .frame(maxWidth: .infinity, minHeight: 105, alignment: .topLeading)
+ .padding(16)
.mauSurface(radius: 22)
}
}
diff --git a/MauverseIOS/Sources/Views/InAppBrowserView.swift b/MauverseIOS/Sources/Views/InAppBrowserView.swift
index 7ffb94d..8eee4fc 100644
--- a/MauverseIOS/Sources/Views/InAppBrowserView.swift
+++ b/MauverseIOS/Sources/Views/InAppBrowserView.swift
@@ -23,7 +23,7 @@ final class InAppBrowserController: NSObject, ObservableObject, WKNavigationDele
let configuration = WKWebViewConfiguration()
configuration.websiteDataStore = .default()
- configuration.applicationNameForUserAgent = "MAUverse/1.12.2"
+ configuration.applicationNameForUserAgent = "MAUverse/1.12.3"
configuration.defaultWebpagePreferences.allowsContentJavaScript = true
configuration.allowsInlineMediaPlayback = true
webView = WKWebView(frame: .zero, configuration: configuration)
diff --git a/MauverseIOS/Sources/Views/LoginView.swift b/MauverseIOS/Sources/Views/LoginView.swift
index 4bb6b3a..ec7badc 100644
--- a/MauverseIOS/Sources/Views/LoginView.swift
+++ b/MauverseIOS/Sources/Views/LoginView.swift
@@ -38,7 +38,7 @@ struct LoginView: View {
}
VStack(spacing: 16) {
- TextField("Логин ЭИОС", text: $username)
+ TextField("Логин учебного портала", text: $username)
.textContentType(.username)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
diff --git a/MauverseIOS/Sources/Views/NewsView.swift b/MauverseIOS/Sources/Views/NewsView.swift
index 52f0f37..e4bb48e 100644
--- a/MauverseIOS/Sources/Views/NewsView.swift
+++ b/MauverseIOS/Sources/Views/NewsView.swift
@@ -231,7 +231,7 @@ private struct HeroNewsCard: View {
.lineLimit(4)
.fixedSize(horizontal: false, vertical: true)
if let description = item.description {
- Text(description.strippingHTML)
+ Text(description.cleanedForDisplay)
.font(.system(size: 13, weight: .regular))
.foregroundStyle(MauTheme.muted)
.lineLimit(2)
@@ -252,9 +252,7 @@ private struct HeroNewsCard: View {
}
private extension String {
- var strippingHTML: String {
- replacingOccurrences(of: "<[^>]+>", with: "", options: .regularExpression)
- .replacingOccurrences(of: " ", with: " ")
- .replacingOccurrences(of: "&", with: "&")
+ var cleanedForDisplay: String {
+ HTMLTextCleaning.cleanRSS(self)
}
}
diff --git a/MauverseIOS/Sources/Views/ProfileView.swift b/MauverseIOS/Sources/Views/ProfileView.swift
index 573831f..58f0de2 100644
--- a/MauverseIOS/Sources/Views/ProfileView.swift
+++ b/MauverseIOS/Sources/Views/ProfileView.swift
@@ -28,7 +28,7 @@ struct ProfileView: View {
.font(.subheadline)
.foregroundStyle(.white.opacity(0.72))
HStack(spacing: 8) {
- Label("Сессия в Keychain", systemImage: "lock.fill")
+ Label("Вход сохранён", systemImage: "lock.fill")
if let group = session.user?.groupName, !group.isEmpty {
Text("•")
Text(group)
@@ -73,7 +73,7 @@ struct ProfileView: View {
.buttonStyle(.plain)
.mauSurface()
- Text("MAUverse 1.12.2 (31)")
+ Text("MAUverse 1.12.3 (32)")
.font(.caption)
.foregroundStyle(MauTheme.muted)
}
@@ -280,7 +280,7 @@ private struct SettingsView: View {
.foregroundStyle(MauTheme.blue)
}
}
- Text("Версия 1.12.2 • сборка 31").font(.caption)
+ Text("Версия 1.12.3 • сборка 32").font(.caption)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(20)
diff --git a/MauverseIOS/Sources/Views/RootTabView.swift b/MauverseIOS/Sources/Views/RootTabView.swift
index ad02a7e..d60c0cd 100644
--- a/MauverseIOS/Sources/Views/RootTabView.swift
+++ b/MauverseIOS/Sources/Views/RootTabView.swift
@@ -1,20 +1,26 @@
import SwiftUI
struct RootTabView: View {
+ @State private var selectedTab = 0
+
var body: some View {
- TabView {
- NavigationStack { HomeView() }
+ TabView(selection: $selectedTab) {
+ NavigationStack { HomeView(selectedTab: $selectedTab) }
.tabItem { Label("Главная", systemImage: "house.fill") }
+ .tag(0)
NavigationStack { ScheduleView() }
.tabItem { Label("Расписание", systemImage: "calendar") }
+ .tag(1)
NavigationStack { ServicesView() }
.tabItem { Label("Сервисы", systemImage: "square.grid.2x2.fill") }
+ .tag(2)
NavigationStack { NewsView() }
.tabItem { Label("Новости", systemImage: "newspaper.fill") }
+ .tag(3)
NavigationStack { ProfileView() }
.tabItem { Label("Профиль", systemImage: "person.crop.circle.fill") }
+ .tag(4)
}
.tint(MauTheme.blue)
}
}
-
diff --git a/MauverseIOS/Sources/Views/ScheduleView.swift b/MauverseIOS/Sources/Views/ScheduleView.swift
index f75a31f..41ac7a9 100644
--- a/MauverseIOS/Sources/Views/ScheduleView.swift
+++ b/MauverseIOS/Sources/Views/ScheduleView.swift
@@ -145,20 +145,28 @@ struct ScheduleView: View {
.disabled(model.isLoading)
}
- HStack(spacing: 12) {
- IconTile(systemName: "person.3.fill")
- VStack(alignment: .leading, spacing: 3) {
- Text(session.user?.groupName ?? "Группа не указана")
- .font(.headline)
- Text(session.user?.speciality ?? "Изменить можно в профиле")
- .font(.caption)
+ NavigationLink {
+ ProfileView()
+ } label: {
+ HStack(spacing: 12) {
+ IconTile(systemName: "person.3.fill")
+ VStack(alignment: .leading, spacing: 3) {
+ Text(session.user?.groupName ?? "Группа не указана")
+ .font(.headline)
+ Text(session.user?.speciality ?? "Нажмите, чтобы указать группу в профиле")
+ .font(.caption)
+ .foregroundStyle(MauTheme.muted)
+ .lineLimit(1)
+ }
+ Spacer()
+ Image(systemName: "chevron.right")
+ .font(.caption.bold())
.foregroundStyle(MauTheme.muted)
- .lineLimit(1)
}
- Spacer()
+ .padding(17)
+ .mauSurface(radius: 22)
}
- .padding(17)
- .mauSurface(radius: 22)
+ .buttonStyle(.plain)
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 10) {
@@ -265,9 +273,9 @@ private struct DateChip: View {
var body: some View {
VStack(spacing: 5) {
- Text(date.formatted(.dateTime.weekday(.abbreviated)).uppercased())
+ Text(date.formatted(.dateTime.weekday(.abbreviated).locale(Locale(identifier: "ru_RU"))).uppercased())
.font(.caption2.weight(.bold))
- Text(date.formatted(.dateTime.day()))
+ Text(date.formatted(.dateTime.day().locale(Locale(identifier: "ru_RU"))))
.font(.title3.bold())
}
.foregroundStyle(selected ? .white : MauTheme.ink)
diff --git a/MauverseIOS/Sources/Views/ServicesView.swift b/MauverseIOS/Sources/Views/ServicesView.swift
index c310453..0b22923 100644
--- a/MauverseIOS/Sources/Views/ServicesView.swift
+++ b/MauverseIOS/Sources/Views/ServicesView.swift
@@ -225,7 +225,8 @@ private struct FormsView: View {
title: "Справка об обучении",
subtitle: "Обычная, гербовая или электронная",
icon: "doc.badge.plus",
- url: "https://mauniver.ru/services/student/"
+ url: nil,
+ isNative: true
),
OnlineFormLink(
title: "Справка для перевода",
@@ -376,7 +377,9 @@ private struct FormsView: View {
@ViewBuilder
private func destination(for form: OnlineFormLink) -> some View {
- if let value = form.url, let url = URL(string: value) {
+ if form.isNative {
+ CertificateRequestView()
+ } else if let value = form.url, let url = URL(string: value) {
InAppBrowserView(url: url, title: form.title)
} else {
EmptyState(icon: "link.badge.plus", title: "Ссылка недоступна", message: form.title)
@@ -389,6 +392,7 @@ private struct OnlineFormLink: Identifiable {
let subtitle: String
let icon: String
let url: String?
+ var isNative: Bool = false
var id: String { title }
}
@@ -705,6 +709,7 @@ private final class TeachersModel: ObservableObject {
@Published var teachers: [Teacher] = []
@Published var isLoading = false
@Published var error: String?
+ @Published var hint: String?
private var activeRequest = UUID()
func search(_ text: String, user: UserDTO?) async {
@@ -713,11 +718,13 @@ private final class TeachersModel: ObservableObject {
let query = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard query.count >= 2 else {
teachers = []
- error = "Введите минимум две буквы фамилии"
+ error = nil
+ hint = query.isEmpty ? nil : "Введите минимум две буквы фамилии"
return
}
isLoading = true
error = nil
+ hint = nil
defer {
if activeRequest == request { isLoading = false }
}
@@ -766,7 +773,12 @@ private struct TeacherContactsView: View {
.mauGlass(radius: 20)
if model.isLoading { LoadingOverlay(title: "Ищем") }
- else if let error = model.error { EmptyState(icon: "wifi.exclamationmark", title: "Ошибка", message: error) }
+ else if let error = model.error {
+ EmptyState(icon: "wifi.exclamationmark", title: "Не удалось найти", message: error)
+ }
+ else if let hint = model.hint {
+ EmptyState(icon: "character.cursor.ibeam", title: "Продолжите ввод", message: hint)
+ }
else if query.isEmpty {
EmptyState(
icon: "person.text.rectangle",
@@ -941,16 +953,8 @@ private struct ContactCard: View {
Text(subtitle).font(.caption).foregroundStyle(MauTheme.muted)
}
HStack {
- if let phone, let destination = phoneURL(phone) {
- Link(destination: destination) {
- Label(phone, systemImage: "phone.fill")
- }
- }
- if let phone2, let destination = phoneURL(phone2) {
- Link(destination: destination) {
- Label(phone2, systemImage: "phone.fill")
- }
- }
+ phoneLabel(phone)
+ phoneLabel(phone2)
if let email, let url = URL(string: "mailto:\(email.trimmed)") {
Link(destination: url) { Image(systemName: "envelope.fill") }
}
@@ -963,6 +967,20 @@ private struct ContactCard: View {
.mauGlass(radius: 22)
}
+ @ViewBuilder
+ private func phoneLabel(_ value: String?) -> some View {
+ if let value, !value.trimmed.isEmpty {
+ if let destination = phoneURL(value) {
+ Link(destination: destination) {
+ Label(value, systemImage: "phone.fill")
+ }
+ } else {
+ Label(value, systemImage: "phone")
+ .foregroundStyle(MauTheme.muted)
+ }
+ }
+ }
+
private func phoneURL(_ value: String) -> URL? {
PhoneNumberFormatting.telURL(from: value)
}
diff --git a/MauverseIOS/Sources/Views/UniversityContactsView.swift b/MauverseIOS/Sources/Views/UniversityContactsView.swift
index a5d1c55..ae9c0ef 100644
--- a/MauverseIOS/Sources/Views/UniversityContactsView.swift
+++ b/MauverseIOS/Sources/Views/UniversityContactsView.swift
@@ -77,12 +77,18 @@ struct UniversityContactsView: View {
.fixedSize(horizontal: false, vertical: true)
HStack(spacing: 10) {
- if let phone = block.phone, let url = phoneURL(phone) {
- Link(destination: url) {
- Label("Позвонить", systemImage: "phone.fill")
+ if let phone = block.phone {
+ if let url = phoneURL(phone) {
+ Link(destination: url) {
+ Label("Позвонить", systemImage: "phone.fill")
+ }
+ .buttonStyle(.borderedProminent)
+ .controlSize(.small)
+ } else {
+ Text(phone)
+ .font(.caption.weight(.semibold))
+ .foregroundStyle(MauTheme.muted)
}
- .buttonStyle(.borderedProminent)
- .controlSize(.small)
}
Button {
copyText(block.details, label: "Скопировано")
@@ -94,13 +100,21 @@ struct UniversityContactsView: View {
}
if let email = block.email {
- Button {
- copyText(email, label: "E-mail скопирован")
- } label: {
- Label(email, systemImage: "envelope")
- .font(.caption.weight(.semibold))
+ HStack(spacing: 10) {
+ if let mailURL = URL(string: "mailto:\(email)") {
+ Link(destination: mailURL) {
+ Label(email, systemImage: "envelope.fill")
+ .font(.caption.weight(.semibold))
+ }
+ }
+ Button {
+ copyText(email, label: "E-mail скопирован")
+ } label: {
+ Image(systemName: "doc.on.doc")
+ .font(.caption.weight(.semibold))
+ }
+ .buttonStyle(.plain)
}
- .buttonStyle(.plain)
.foregroundStyle(MauTheme.blue)
}
}
diff --git a/MauverseIOS/project.yml b/MauverseIOS/project.yml
index 403007f..e7460e2 100644
--- a/MauverseIOS/project.yml
+++ b/MauverseIOS/project.yml
@@ -28,9 +28,10 @@ targets:
INFOPLIST_FILE: Resources/Info.plist
GENERATE_INFOPLIST_FILE: NO
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
- MARKETING_VERSION: 1.12.2
- CURRENT_PROJECT_VERSION: 31
+ MARKETING_VERSION: 1.12.3
+ CURRENT_PROJECT_VERSION: 32
MAUVERSE_SCHEDULE_TOKEN: ""
+ MAUVERSE_API_BASE_URL: "https://app.mauniver.ru/dev/mauverse/"
TARGETED_DEVICE_FAMILY: "1"
SUPPORTS_MACCATALYST: NO
scheme:
diff --git a/docs/ANDROID_SIGNING.md b/docs/ANDROID_SIGNING.md
new file mode 100644
index 0000000..36d9f50
--- /dev/null
+++ b/docs/ANDROID_SIGNING.md
@@ -0,0 +1,34 @@
+# Android signing (engineering)
+
+Production Play keystores stay out of the repository (see `docs/CI_AND_RELEASE.md`).
+For local sideload / engineering Release builds:
+
+1. Generate a keystore (already done under `.artifacts/signing/` in cloud agents; regenerate locally if needed):
+
+```bash
+keytool -genkeypair -v \
+ -keystore .artifacts/signing/mauverse-engineering.keystore \
+ -alias mauverse \
+ -keyalg RSA -keysize 2048 -validity 10000 \
+ -storepass mauverse-eng \
+ -keypass mauverse-eng \
+ -dname "CN=MAUverse Engineering, OU=MAUverse, O=decoder-dev, L=Murmansk, ST=Murmansk, C=RU"
+```
+
+2. Copy `mauverse_mobile/Directory.Build.signing.props.example` → `mauverse_mobile/Directory.Build.signing.props` (gitignored).
+
+3. Build:
+
+```bash
+cd mauverse_mobile
+dotnet build mau.csproj -c Release -f net10.0-android -p:AndroidPackageFormats=aab
+```
+
+4. Verify signer is not missing:
+
+```bash
+jarsigner -verify -verbose -certs \
+ bin/Release/net10.0-android/android-arm64/com.pmi4freal.mauverse3-Signed.aab | head
+```
+
+Engineering cert `CN=MAUverse Engineering` is for sideload only — not for Play Store.
diff --git a/docs/UX_AUDIT_1.12.3.md b/docs/UX_AUDIT_1.12.3.md
new file mode 100644
index 0000000..6b2a867
--- /dev/null
+++ b/docs/UX_AUDIT_1.12.3.md
@@ -0,0 +1,78 @@
+# UX / bug audit — dumbest-user schemes (1.12.3)
+
+Built: 2026-08-03
+Clients: Android MAUI (`mauverse_mobile`) + iOS SwiftUI (`MauverseIOS`)
+
+Perspective: a confused freshman who skips text, taps the biggest control, and assumes every blue thing calls or opens something.
+
+---
+
+## Convenient (keep)
+
+- Five clear tabs; Russian snackbars; offline schedule/news caches.
+- Login field validation; logout wipe confirmation.
+- Campus route/panorama; contacts call/copy; Murmansk phone + extension dialing.
+- iOS Keychain session; news HTML entity cleaning on RSS path.
+- In-app browser chrome (back/forward/reload).
+
+---
+
+## Scheme A — Bugs (fix)
+
+| ID | Issue | Platform | Fix |
+| --- | --- | --- | --- |
+| B1 | `CanStateChange` defaults false → Loading/Empty never shows | Android | Init `CanStateChange = true` in `BaseViewModel` |
+| B2 | News Appearing always reloads Default; chip desync | Android | `LoadData` uses `SelectedButton?.FilterType` |
+| B3 | News/events network fail shown as “empty category” | Android | Distinct error empty copy / snackbar + state |
+| B4 | Login subgroup dismiss saves empty subgroup | Android | Require subgroup or send to Profile setup |
+| B5 | `CurrentUser` null races after logout | Android | Null-guards on Profile/Schedule |
+| B6 | News HTML entities in detail popup | Android | HtmlDecode/strip before bind |
+| B7 | Moodle `groupId` treated as schedule UID | iOS | Stop copying; always resolve via `findGroup` |
+| B8 | Native certificate form never linked | iOS | Wire `CertificateRequestView` like Android `IsNative` |
+| B9 | Hardcoded `/dev/mauverse/` API base | iOS | Build-configurable base URL (parity with Android) |
+| B10 | Teacher search needs «Найти» after suggestion tap | Android | Auto-run search on teacher select |
+| B11 | Android CA1822 / Release already fixed | — | done in 1.12.2 |
+
+---
+
+## Scheme B — Inconvenient (improve)
+
+| ID | Pain | Platform | Fix |
+| --- | --- | --- | --- |
+| U1 | Tab «Пары» vs page «Расписание» | Android | Rename tab to «Расписание» |
+| U2 | Legend «Нет занятий» always looks like today empty | Android | Reword caption |
+| U3 | Teacher short query shown as wifi «Ошибка» | iOS | Hint style, not error icon |
+| U4 | Home pushes duplicate tab roots | iOS | Switch selected tab instead of NavigationLink push |
+| U5 | Schedule group row not tappable | iOS | Link to Profile editor |
+| U6 | Soften Keychain / ЭИОС jargon | Both | Friendlier labels |
+| U7 | Certificate DOB prefilled 01.01.2000 | Android | Empty until chosen |
+| U8 | International guide English subtitle | Android | Russian description |
+| U9 | Date chips English locale | iOS | `ru_RU` |
+| U10 | Home error without Retry | iOS | Retry button |
+| U11 | Phone label not obvious call CTA | Android | Keep Primary color + ensure tap works |
+| U12 | Unparseable phone hidden | iOS | Show number + «Не удалось набрать» path |
+
+---
+
+## Scheme C — Android signing (end)
+
+| Step | Action |
+| --- | --- |
+| C1 | Generate local engineering upload keystore (gitignored) |
+| C2 | Add optional signing props via `Directory.Build.signing.props` (gitignored) + documented template |
+| C3 | Document `keytool` + `dotnet build` with signing props |
+| C4 | Produce signed AAB/APK and verify signer ≠ missing; engineering cert OK for sideload |
+
+Production Play keystore stays out of repo (existing policy).
+
+---
+
+## Implementation status (2026-08-03)
+
+Implemented on branch `cursor/ux-audit-fixes-010d` for release **1.12.3**:
+
+- Android: B1–B6, B10, U1–U2, U6–U8 (+ profile/schedule null guards)
+- iOS: B7–B9, U3–U6, U9–U10, U12, B8 (+ mailto on contacts)
+- Signing: C1–C4 docs + engineering keystore path + optional `Directory.Build.signing.props`
+
+Deferred (follow-up): soft refresh without LoadingPage, telephone search, teacher schedule role parity, settings cache confirm.
diff --git a/mauverse_mobile/AppShell.xaml b/mauverse_mobile/AppShell.xaml
index d4099ca..4b649b3 100644
--- a/mauverse_mobile/AppShell.xaml
+++ b/mauverse_mobile/AppShell.xaml
@@ -24,7 +24,7 @@
Icon="home_icon"
ContentTemplate="{DataTemplate local:MainPage}"
Route="MainPage" />
-
diff --git a/mauverse_mobile/Directory.Build.signing.props.example b/mauverse_mobile/Directory.Build.signing.props.example
new file mode 100644
index 0000000..771f2b7
--- /dev/null
+++ b/mauverse_mobile/Directory.Build.signing.props.example
@@ -0,0 +1,10 @@
+
+
+
+ true
+ $(MSBuildThisFileDirectory)../.artifacts/signing/mauverse-engineering.keystore
+ mauverse-eng
+ mauverse
+ mauverse-eng
+
+
diff --git a/mauverse_mobile/Pages/LoginPage.xaml b/mauverse_mobile/Pages/LoginPage.xaml
index 4f30349..965dd47 100644
--- a/mauverse_mobile/Pages/LoginPage.xaml
+++ b/mauverse_mobile/Pages/LoginPage.xaml
@@ -77,12 +77,12 @@
TextColor="{DynamicResource TextSecondary}" />
+ Text="Логин учебного портала" />
+ Text="Пароль учебного портала" />
-
-
-
-
-
+
+
+
+
+
@@ -25,81 +25,81 @@
-
-
-
-
-
-
+
+
+
+
+
+ ItemSpacing="6"/>
-
-
-
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/mauverse_mobile/Pages/SchedulePage.xaml b/mauverse_mobile/Pages/SchedulePage.xaml
index 5483e26..0edacbb 100644
--- a/mauverse_mobile/Pages/SchedulePage.xaml
+++ b/mauverse_mobile/Pages/SchedulePage.xaml
@@ -1,25 +1,25 @@
-
-
-
-
-
+ xmlns:toolkit="http://schemas.microsoft.com/dotnet/2022/maui/toolkit"
+ x:DataType="viewmodel:ScheduleViewModel"
+ Shell.NavBarIsVisible="False"
+ BackgroundColor="{DynamicResource Background}">
+
+
+
+
@@ -28,168 +28,168 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/mauverse_mobile/ViewModel/BaseViewModel.cs b/mauverse_mobile/ViewModel/BaseViewModel.cs
index 17a4eb1..c6ced5c 100644
--- a/mauverse_mobile/ViewModel/BaseViewModel.cs
+++ b/mauverse_mobile/ViewModel/BaseViewModel.cs
@@ -37,7 +37,7 @@ protected virtual void CancelPendingOperations()
bool _isBusy;
[ObservableProperty]
- bool _canStateChange;
+ bool _canStateChange = true;
[ObservableProperty]
string _currentState = States.Loading;
diff --git a/mauverse_mobile/ViewModel/Dialogs/DetailsNewsViewModel.cs b/mauverse_mobile/ViewModel/Dialogs/DetailsNewsViewModel.cs
index 4708ce3..6e99e68 100644
--- a/mauverse_mobile/ViewModel/Dialogs/DetailsNewsViewModel.cs
+++ b/mauverse_mobile/ViewModel/Dialogs/DetailsNewsViewModel.cs
@@ -25,14 +25,25 @@ public DetailsNewsViewModel(
{
_popup = popup;
_navigation = navigation;
- Title = news.Title;
+ Title = CleanNewsText(news.Title);
ImageUrl = news.Image;
- Description = news.Description;
+ Description = CleanNewsText(news.Description);
Url = news.Link;
Publish = news.Publish;
GoToFull = new AsyncRelayCommand(OpenArticleAsync);
}
+ private static string CleanNewsText(string? value)
+ {
+ if (string.IsNullOrWhiteSpace(value))
+ return string.Empty;
+
+ var decoded = System.Net.WebUtility.HtmlDecode(value);
+ decoded = System.Text.RegularExpressions.Regex.Replace(decoded, "<[^>]+>", " ");
+ decoded = System.Text.RegularExpressions.Regex.Replace(decoded, @"\s+", " ");
+ return decoded.Trim();
+ }
+
public IAsyncRelayCommand GoToFull { get; }
private async Task OpenArticleAsync(string? url)
diff --git a/mauverse_mobile/ViewModel/LoginViewModel.cs b/mauverse_mobile/ViewModel/LoginViewModel.cs
index 098fea4..d87c52e 100644
--- a/mauverse_mobile/ViewModel/LoginViewModel.cs
+++ b/mauverse_mobile/ViewModel/LoginViewModel.cs
@@ -203,11 +203,13 @@ private async Task CreateUserAsync(UserDTO existingUser, CancellationToken cance
{
var subgroup = await Shell.Current.DisplayActionSheetAsync(
"Выберите подгруппу",
- null,
+ "Выбрать позже в профиле",
null,
buttons: userSubgroups.SubGroups.Select(p => p.Name).ToArray());
- if (subgroup is null)
+ if (subgroup is null || subgroup == "Выбрать позже в профиле")
{
+ await AppShell.DisplaySnackbarAsync(
+ "Подгруппа не выбрана — укажите её в профиле, чтобы расписание было точным");
break;
}
diff --git a/mauverse_mobile/ViewModel/News/NewsViewModel.cs b/mauverse_mobile/ViewModel/News/NewsViewModel.cs
index 63373a2..466a142 100644
--- a/mauverse_mobile/ViewModel/News/NewsViewModel.cs
+++ b/mauverse_mobile/ViewModel/News/NewsViewModel.cs
@@ -37,6 +37,9 @@ public partial class NewsViewModel : BaseViewModel
[ObservableProperty]
List _filterButtons = [];
+ [ObservableProperty]
+ string _emptyMessage = "Новостей в этой категории нет";
+
public bool IsLoading => CurrentState == States.Loading;
public bool HasContent => CurrentState == States.Success;
public bool IsEmpty => CurrentState == States.Empty;
@@ -67,7 +70,7 @@ async Task FilterData()
[RelayCommand]
async Task LoadData()
{
- await LoadNews(RssData.Default);
+ await LoadNews(SelectedButton?.FilterType ?? RssData.Default);
}
async Task LoadNews(RssData filterType)
@@ -112,6 +115,7 @@ async Task LoadNewsCore(RssData filterType, CancellationToken cancellationToken)
News = [.. await _parserRequests.GetNewsAsync(filterType, cancellationToken)];
cancellationToken.ThrowIfCancellationRequested();
_loadedFilter = filterType;
+ EmptyMessage = "Новостей в этой категории нет";
SetState(News.Count == 0 ? States.Empty : States.Success);
}
catch (OperationCanceledException)
@@ -120,6 +124,8 @@ async Task LoadNewsCore(RssData filterType, CancellationToken cancellationToken)
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine(ex);
+ News = [];
+ EmptyMessage = "Не удалось загрузить новости. Потяните вниз или откройте вкладку снова";
SetState(States.Empty);
await AppShell.DisplaySnackbarAsync("Не удалось загрузить новости");
}
diff --git a/mauverse_mobile/ViewModel/ProfileViewModel.cs b/mauverse_mobile/ViewModel/ProfileViewModel.cs
index f3d36b0..f2764ab 100644
--- a/mauverse_mobile/ViewModel/ProfileViewModel.cs
+++ b/mauverse_mobile/ViewModel/ProfileViewModel.cs
@@ -61,7 +61,27 @@ public ProfileViewModel(DbConnect context,
_userRequests = userRequests;
_validationRequests = validationRequests;
_navigation = navigation;
- _user = CurrentUser;
+ _user = CurrentUser ?? new User();
+ if (CurrentUser is null)
+ {
+ ButtonExitLabel = "Выйти";
+ ButtonChangeLabel = "Выбрать подгруппу";
+ PageTitle = "Личный кабинет";
+ MainSectionLabel = "Основная информация";
+ DescriptionLabel = "Специальность";
+ NameLabel = "Полное имя";
+ GroupLabel = "Группа";
+ CreditBookLabel = "Номер зачетной книжки";
+ _loadData = new AsyncRelayCommand(LoadDataAsync);
+ Edit = new AsyncRelayCommand(cancellationToken =>
+ RunSafelyAsync(EditInfoAsync, "Не удалось сохранить изменения", cancellationToken));
+ Change = new AsyncRelayCommand(cancellationToken =>
+ RunSafelyAsync(ChangeSubgroupAsync, "Не удалось изменить подгруппу", cancellationToken));
+ OpenSettings = new AsyncRelayCommand(OpenSettingsAsync);
+ Exit = new AsyncRelayCommand(ConfirmExitAsync);
+ return;
+ }
+
if (CurrentUser.Role == UserRole.Student)
{
IsCreditBookPanelIsVisible = true;
diff --git a/mauverse_mobile/ViewModel/Schedules/BaseScheduleViewModel.cs b/mauverse_mobile/ViewModel/Schedules/BaseScheduleViewModel.cs
index c6d8b71..ca13c7e 100644
--- a/mauverse_mobile/ViewModel/Schedules/BaseScheduleViewModel.cs
+++ b/mauverse_mobile/ViewModel/Schedules/BaseScheduleViewModel.cs
@@ -139,6 +139,11 @@ public async Task> LoadSchedule(
CancellationToken cancellationToken = default)
{
List schedules = new();
+ if (CurrentUser is null && string.IsNullOrEmpty(teacher) && roomId == 0)
+ {
+ return ScheduleList;
+ }
+
if (!string.IsNullOrEmpty(teacher))
{
var searchTeacher = teacher.Split(' ');
@@ -148,7 +153,7 @@ public async Task> LoadSchedule(
{
schedules = new(await _scheduleRequests.GetSchedulesAsync(room_id: roomId, cancellationToken));
}
- else if (CurrentUser.Role == UserRole.Teacher)
+ else if (CurrentUser!.Role == UserRole.Teacher)
{
schedules = [.. await _scheduleRequests.GetSchedulesAsync(teacher: CurrentUser.FullName.Split(' ', StringSplitOptions.RemoveEmptyEntries), cancellationToken)];
}
diff --git a/mauverse_mobile/ViewModel/Schedules/ScheduleViewModel.cs b/mauverse_mobile/ViewModel/Schedules/ScheduleViewModel.cs
index 98014c4..26845a8 100644
--- a/mauverse_mobile/ViewModel/Schedules/ScheduleViewModel.cs
+++ b/mauverse_mobile/ViewModel/Schedules/ScheduleViewModel.cs
@@ -272,7 +272,9 @@ async Task FilterScheduleAsync(
async Task LoadSubgroupsFilterAsync(CancellationToken cancellationToken)
{
- if (CurrentUser.Role != UserRole.Student || string.IsNullOrWhiteSpace(CurrentUser.GroupName))
+ if (CurrentUser is null ||
+ CurrentUser.Role != UserRole.Student ||
+ string.IsNullOrWhiteSpace(CurrentUser.GroupName))
{
IsHaveSubgroups = false;
Subgroups = [];
diff --git a/mauverse_mobile/ViewModel/Services/CertificateRequestViewModel.cs b/mauverse_mobile/ViewModel/Services/CertificateRequestViewModel.cs
index f904e72..5c62354 100644
--- a/mauverse_mobile/ViewModel/Services/CertificateRequestViewModel.cs
+++ b/mauverse_mobile/ViewModel/Services/CertificateRequestViewModel.cs
@@ -74,7 +74,7 @@ public partial class CertificateRequestViewModel : BaseViewModel
private string _phone = string.Empty;
[ObservableProperty]
- private DateTime _birthDate = new(2000, 1, 1);
+ private DateTime _birthDate = DateTime.Today;
[ObservableProperty]
private string? _institute;
@@ -208,8 +208,8 @@ await _formsService.SubmitCertificateAsync(
return "Проверьте контактный e-mail";
if (Phone.Length > 32 || Phone.Count(char.IsDigit) is < 10 or > 15)
return "Проверьте номер телефона";
- if (BirthDate < MinimumBirthDate || BirthDate > MaximumBirthDate)
- return "Проверьте дату рождения";
+ if (BirthDate < MinimumBirthDate || BirthDate > MaximumBirthDate.AddYears(-14))
+ return "Укажите дату рождения";
if (string.IsNullOrWhiteSpace(Institute))
return "Выберите факультет или институт";
if (string.IsNullOrWhiteSpace(StudyForm))
diff --git a/mauverse_mobile/ViewModel/Services/InfoViewModel.cs b/mauverse_mobile/ViewModel/Services/InfoViewModel.cs
index 39380af..4c8f838 100644
--- a/mauverse_mobile/ViewModel/Services/InfoViewModel.cs
+++ b/mauverse_mobile/ViewModel/Services/InfoViewModel.cs
@@ -134,7 +134,7 @@ async Task GetTeacherInfo(CancellationToken cancellationToken)
}
[RelayCommand]
- void TeacherSelected(string teacher)
+ async Task TeacherSelected(string teacher)
{
if (string.IsNullOrWhiteSpace(teacher) || teacher == "Ничего не найдено")
{
@@ -145,6 +145,7 @@ void TeacherSelected(string teacher)
TeacherName = teacher;
IsListVisible = false;
TeacherList.Clear();
+ await GetTeacherInfo(CancellationToken.None);
}
protected override void CancelPendingOperations()
diff --git a/mauverse_mobile/ViewModel/Services/UniversityGuideViewModel.cs b/mauverse_mobile/ViewModel/Services/UniversityGuideViewModel.cs
index c45976c..26323d5 100644
--- a/mauverse_mobile/ViewModel/Services/UniversityGuideViewModel.cs
+++ b/mauverse_mobile/ViewModel/Services/UniversityGuideViewModel.cs
@@ -46,7 +46,7 @@ public static UniversityGuideViewModel CreateScience(IAppNavigationService navig
public static UniversityGuideViewModel CreateInternational(IAppNavigationService navigation) =>
new(
navigation,
- "English site, exchange and international admissions",
+ "Англоязычный сайт, обмен и поступление для иностранных студентов",
UniversityGuideCatalog.InternationalSections);
public static UniversityGuideViewModel CreateDigital(IAppNavigationService navigation) =>
diff --git a/mauverse_mobile/mau.csproj b/mauverse_mobile/mau.csproj
index 7905e82..1721572 100644
--- a/mauverse_mobile/mau.csproj
+++ b/mauverse_mobile/mau.csproj
@@ -16,8 +16,8 @@
MAUverse
com.pmi4freal.mauverse3
- 1.12.2
- 31
+ 1.12.3
+ 32
https://app.mauniver.ru/dev/mauverse/
None
@@ -77,4 +77,6 @@
+
+