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/HTMLTextCleaning.swift b/MauverseIOS/Sources/Core/HTMLTextCleaning.swift
new file mode 100644
index 0000000..1d822aa
--- /dev/null
+++ b/MauverseIOS/Sources/Core/HTMLTextCleaning.swift
@@ -0,0 +1,75 @@
+import Foundation
+
+enum HTMLTextCleaning {
+ /// Decodes HTML entities (` `, ` `, …) and strips tags for RSS titles/bodies.
+ static func cleanRSS(_ value: String) -> String {
+ let decoded = decodeEntities(value)
+ return decoded
+ .replacingOccurrences(of: "<[^>]+>", with: " ", options: .regularExpression)
+ .replacingOccurrences(of: "[\\u00A0\\u2000-\\u200B\\u202F\\u205F\\u3000]+", with: " ", options: .regularExpression)
+ .replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ }
+
+ static func decodeEntities(_ value: String) -> String {
+ guard !value.isEmpty else { return value }
+
+ var result = value
+ let named: [(String, String)] = [
+ (" ", " "),
+ (" ", " "),
+ (" ", " "),
+ (" ", " "),
+ ("—", "—"),
+ ("–", "–"),
+ ("«", "«"),
+ ("»", "»"),
+ (""", "\""),
+ ("'", "'"),
+ ("'", "'"),
+ ("<", "<"),
+ (">", ">"),
+ ("&", "&")
+ ]
+ // Decode named entities first; & last among pairs that can nest.
+ for (entity, replacement) in named where entity != "&" {
+ result = result.replacingOccurrences(of: entity, with: replacement, options: .caseInsensitive)
+ }
+
+ // Numeric decimal and hex
+ result = replaceNumericEntities(result)
+ result = result.replacingOccurrences(of: "&", with: "&", options: .caseInsensitive)
+ return result
+ }
+
+ private static func replaceNumericEntities(_ value: String) -> String {
+ var output = value
+ let patterns: [(String, (String) -> String?)] = [
+ (#"(\d+);"#, { Int($0).flatMap(unicodeScalarString) }),
+ (#"([0-9a-fA-F]+);"#, { UInt32($0, radix: 16).flatMap(unicodeScalarString) })
+ ]
+ for (pattern, decoder) in patterns {
+ guard let regex = try? NSRegularExpression(pattern: pattern) else { continue }
+ let range = NSRange(output.startIndex..., in: output)
+ let matches = regex.matches(in: output, range: range).reversed()
+ for match in matches {
+ guard match.numberOfRanges >= 2,
+ let full = Range(match.range(at: 0), in: output),
+ let body = Range(match.range(at: 1), in: output),
+ let replacement = decoder(String(output[body])) else { continue }
+ output.replaceSubrange(full, with: replacement)
+ }
+ }
+ return output
+ }
+
+ private static func unicodeScalarString(_ value: Int) -> String? {
+ guard let scalar = UnicodeScalar(value) else { return nil }
+ return String(Character(scalar))
+ }
+
+ private static func unicodeScalarString(_ value: UInt32) -> String? {
+ guard let scalar = UnicodeScalar(value) else { return nil }
+ return String(Character(scalar))
+ }
+}
diff --git a/MauverseIOS/Sources/Core/Models.swift b/MauverseIOS/Sources/Core/Models.swift
index 98a071c..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)
@@ -493,7 +492,17 @@ struct APIMessage: Codable {
}
enum NewsFilter: Int, CaseIterable, Identifiable {
- case all = 0, departments, sport, students, science, international, events, other
+ case all = 0
+ case departments = 1
+ case sport = 2
+ case students = 3
+ case science = 4
+ case international = 5
+ case events = 6
+ case other = 7
+ case applicant = 8
+ case calendar = 9
+
var id: Int { rawValue }
var title: String {
return switch self {
@@ -505,6 +514,8 @@ enum NewsFilter: Int, CaseIterable, Identifiable {
case .international: "Международное"
case .events: "События"
case .other: "Другое"
+ case .applicant: "Абитуриент"
+ case .calendar: "Календарь"
}
}
}
diff --git a/MauverseIOS/Sources/Core/OfficialNewsService.swift b/MauverseIOS/Sources/Core/OfficialNewsService.swift
index 2cbbc0a..6db3490 100644
--- a/MauverseIOS/Sources/Core/OfficialNewsService.swift
+++ b/MauverseIOS/Sources/Core/OfficialNewsService.swift
@@ -21,6 +21,8 @@ actor OfficialNewsService {
case .international: path = "press/inter/rss/"
case .events: path = "press/events/rss/"
case .other: path = "press/information/rss/"
+ case .applicant: path = "abit/news/rss/"
+ case .calendar: path = "press/calendar/rss/"
}
guard let url = URL(string: "https://mauniver.ru/\(path)") else {
@@ -56,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.11.0 (iOS)", forHTTPHeaderField: "User-Agent")
+ request.setValue("MAUverse/1.12.4 (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)
@@ -150,10 +152,10 @@ private final class RSSNewsParser: NSObject, XMLParserDelegate {
guard current != nil else { return }
let value = text.trimmingCharacters(in: .whitespacesAndNewlines)
switch elementName {
- case "title": current?.title = value.cleanedRSS
+ case "title": current?.title = HTMLTextCleaning.cleanRSS(value)
case "link": current?.link = Self.absoluteURL(value)
case "description":
- current?.description = value.cleanedRSS
+ current?.description = HTMLTextCleaning.cleanRSS(value)
if current?.image == nil {
current?.image = Self.firstImageURL(in: value)
}
@@ -203,20 +205,6 @@ private final class RSSNewsParser: NSObject, XMLParserDelegate {
}
}
-private extension String {
- var cleanedRSS: String {
- replacingOccurrences(of: "<[^>]+>", with: " ", options: .regularExpression)
- .replacingOccurrences(of: " ", with: " ")
- .replacingOccurrences(of: "—", with: "—")
- .replacingOccurrences(of: "–", with: "–")
- .replacingOccurrences(of: "«", with: "«")
- .replacingOccurrences(of: "»", with: "»")
- .replacingOccurrences(of: "&", with: "&")
- .replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)
- .trimmingCharacters(in: .whitespacesAndNewlines)
- }
-}
-
private extension Array {
func uniqued(by key: (Element) -> Key) -> [Element] {
var seen = Set()
diff --git a/MauverseIOS/Sources/Core/PhoneNumberFormatting.swift b/MauverseIOS/Sources/Core/PhoneNumberFormatting.swift
new file mode 100644
index 0000000..ab7ff5a
--- /dev/null
+++ b/MauverseIOS/Sources/Core/PhoneNumberFormatting.swift
@@ -0,0 +1,57 @@
+import Foundation
+
+enum PhoneNumberFormatting {
+ /// Murmansk city code used for local 6-digit university PBX numbers.
+ static let murmanskAreaCode = "8152"
+
+ /// Builds a `tel:` URL for Russian / Murmansk numbers.
+ /// Local numbers like `21-38-81 (3045)` become `tel:+78152213881;ext=3045`.
+ static func telURL(from raw: String) -> URL? {
+ let parsed = parse(raw)
+ guard let e164 = parsed.e164 else { return nil }
+ if let extensionCode = parsed.extensionCode, !extensionCode.isEmpty {
+ return URL(string: "tel:\(e164);ext=\(extensionCode)")
+ }
+ return URL(string: "tel:\(e164)")
+ }
+
+ static func parse(_ raw: String) -> (e164: String?, extensionCode: String?) {
+ var working = raw.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !working.isEmpty else { return (nil, nil) }
+
+ var extensionCode: String?
+ if let match = working.range(of: #"\(\d{2,6}\)\s*$"#, options: .regularExpression) {
+ extensionCode = String(working[match]).filter(\.isNumber)
+ working.removeSubrange(match)
+ } else if let match = working.range(
+ of: #"(?i)(?:доб\.?|ext\.?|extension)\s*\d{2,6}\s*$"#,
+ options: .regularExpression
+ ) {
+ extensionCode = String(working[match]).filter(\.isNumber)
+ working.removeSubrange(match)
+ }
+
+ let digits = working.filter(\.isNumber)
+ guard !digits.isEmpty else { return (nil, nil) }
+
+ let e164: String?
+ if digits.count == 6 {
+ // Local Murmansk landline: 21-38-81 → +7 8152 213881
+ e164 = "+7\(murmanskAreaCode)\(digits)"
+ } else if digits.count == 10, digits.hasPrefix(murmanskAreaCode) || digits.hasPrefix("800") {
+ e164 = "+7\(digits)"
+ } else if digits.count == 11, digits.hasPrefix("8") {
+ e164 = "+7\(digits.dropFirst())"
+ } else if digits.count == 11, digits.hasPrefix("7") {
+ e164 = "+\(digits)"
+ } else if working.contains("+"), digits.count >= 11 {
+ e164 = "+\(digits)"
+ } else if digits.count >= 10 {
+ e164 = digits.hasPrefix("7") ? "+\(digits)" : "+7\(digits)"
+ } else {
+ e164 = nil
+ }
+
+ return (e164, extensionCode)
+ }
+}
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/Core/UniversityPortalCatalog.swift b/MauverseIOS/Sources/Core/UniversityPortalCatalog.swift
new file mode 100644
index 0000000..3178161
--- /dev/null
+++ b/MauverseIOS/Sources/Core/UniversityPortalCatalog.swift
@@ -0,0 +1,362 @@
+import Foundation
+
+enum UniversityPortalURLs {
+ static let studentOffice = "https://mauniver.ru/structure/divs/studof/"
+ static let studentFaq = "https://mauniver.ru/student/faq/"
+ static let studentGuide = "https://mauniver.ru/student/guide/"
+ static let studentTimetable = "https://mauniver.ru/student/timetable/"
+ static let studentCommunity = "https://mauniver.ru/student/community/"
+ static let eventsCalendar = "https://mauniver.ru/press/calendar/"
+ static let careerCenter = "https://mauniver.ru/abit/career/"
+ static let hostelRules = "https://mauniver.ru/abit/rules/hostel/"
+ static let virtualHelpdesk = "https://mauniver.ru/services/virtual/"
+ static let rectorReception = "https://mauniver.ru/rector/reception/"
+ static let socialSupport = "https://mauniver.ru/info/docs/ump/"
+ static let libraryCatalog = "https://lib.mauniver.ru/MegaPro/Web"
+
+ static let admissionPortal = "https://priem.mauniver.ru/"
+ static let admissionRules = "https://mauniver.ru/abit/rules/"
+ static let admissionPrograms = "https://mauniver.ru/abit/admission/"
+ static let admissionExams = "https://mauniver.ru/abit/exam/"
+ static let admissionNews = "https://mauniver.ru/abit/news/"
+ static let openDays = "https://mauniver.ru/abit/news/open/"
+ static let admissionFaq = "https://mauniver.ru/abit/faq/"
+ static let foreignApplicants = "https://mauniver.ru/abit/foreign/"
+ static let admissionContacts = "https://mauniver.ru/abit/contact/"
+ static let admissionReception = "https://mauniver.ru/abit/reception/"
+ static let branches = "https://mauniver.ru/structure/branches/"
+
+ static let scienceHome = "https://mauniver.ru/science/"
+ static let scienceNews = "https://mauniver.ru/science/news/"
+ static let scienceFields = "https://mauniver.ru/science/fields/"
+ static let scienceDigest = "https://mauniver.ru/science/events/digest/"
+ static let scienceGrants = "https://mauniver.ru/science/help/grants/"
+ static let sciencePublishing = "https://mauniver.ru/science/izdat/"
+ static let scienceHelp = "https://mauniver.ru/science/help/"
+ static let scienceInnovations = "https://mauniver.ru/science/innovations/"
+
+ static let internationalActivity = "https://mauniver.ru/info/inter/"
+ static let englishSite = "https://mauniver.ru/en/"
+ static let diplomaSupplement = "https://mauniver.ru/info/inter/diplom"
+
+ static let digitalStudentServices = "https://www.mauniver.ru/services/student/"
+ static let webmail = "https://webmail.mauniver.ru/mail/"
+ static let dormItRequest = "https://serviceahd.mauniver.ru/index.php?a=add&category=21"
+ static let intra = "https://intra.mauniver.ru/"
+ static let promt = "https://promt.mauniver.ru/ptf"
+ static let eios = "https://eios.mauniver.ru/moodle/"
+ static let requisites = "https://mauniver.ru/info/address/"
+ static let officialSite = "https://mauniver.ru/"
+ static let privacyPolicy = "https://mauniver.ru/info/docs/pdn/"
+ static let sveden = "https://mauniver.ru/sveden/"
+ static let campusNavigatorSite = "https://mauniver.ru/info/navigation/"
+}
+
+struct PortalLink: Identifiable, Hashable {
+ let title: String
+ let subtitle: String
+ let url: String
+ let systemImage: String
+ var id: String { url + title }
+}
+
+struct PortalSection: Identifiable, Hashable {
+ let title: String
+ let links: [PortalLink]
+ var id: String { title }
+}
+
+struct UniversityContactBlock: Identifiable, Hashable {
+ let title: String
+ let details: String
+ let phone: String?
+ let email: String?
+ let address: String?
+ var id: String { title }
+}
+
+struct CampusBuilding: Identifiable, Hashable {
+ let title: String
+ let address: String
+ let searchQuery: String
+ let mapCity: String
+ var id: String { title + address }
+}
+
+struct CampusBuildingGroup: Identifiable, Hashable {
+ let title: String
+ let transportTip: String
+ let buildings: [CampusBuilding]
+ var id: String { title }
+}
+
+enum UniversityPortalCatalog {
+ static let studentSections: [PortalSection] = [
+ PortalSection(title: "Учёба и сервис", links: [
+ link("Студенческий офис", "Справки, перевод, восстановление и продление билета",
+ UniversityPortalURLs.studentOffice, "building.columns.fill"),
+ link("Частые вопросы", "Стипендии, сессия, общежитие и учебный процесс",
+ UniversityPortalURLs.studentFaq, "questionmark.circle.fill"),
+ link("Путеводитель первокурсника", "Что важно знать в начале обучения",
+ UniversityPortalURLs.studentGuide, "book.fill"),
+ link("Расписание на сайте", "Официальное расписание учебных занятий",
+ UniversityPortalURLs.studentTimetable, "calendar")
+ ]),
+ PortalSection(title: "Жизнь в университете", links: [
+ link("Студенческие объединения", "Наука, спорт, волонтёрство и творчество",
+ UniversityPortalURLs.studentCommunity, "person.3.fill"),
+ link("Календарь событий", "Мероприятия и анонсы пресс-центра МАУ",
+ UniversityPortalURLs.eventsCalendar, "calendar.badge.clock"),
+ link("Карьера и трудоустройство", "Центр карьеры и партнёры университета",
+ UniversityPortalURLs.careerCenter, "briefcase.fill"),
+ link("Общежитие", "Правила заселения и проживания",
+ UniversityPortalURLs.hostelRules, "bed.double.fill")
+ ]),
+ PortalSection(title: "Поддержка и обращения", links: [
+ link("Виртуальная справочная", "Вопросы студенческому офису онлайн",
+ UniversityPortalURLs.virtualHelpdesk, "bubble.left.and.bubble.right.fill"),
+ link("Вопросы ректору", "Виртуальная приёмная ректора",
+ UniversityPortalURLs.rectorReception, "envelope.fill"),
+ link("Поддержка семей и СВО", "Меры социальной поддержки обучающихся",
+ UniversityPortalURLs.socialSupport, "heart.fill"),
+ link("Электронный каталог библиотеки", "Поиск книг и электронных ресурсов МАУ",
+ UniversityPortalURLs.libraryCatalog, "books.vertical.fill")
+ ])
+ ]
+
+ static let applicantSections: [PortalSection] = [
+ PortalSection(title: "Поступление", links: [
+ link("Приёмная кампания", "Программы, квоты и подача документов",
+ UniversityPortalURLs.admissionPortal, "graduationcap.fill"),
+ link("Правила приёма", "Условия, льготы и особые права",
+ UniversityPortalURLs.admissionRules, "doc.richtext.fill"),
+ link("Образовательные программы", "Направления, стоимость и планы приёма",
+ UniversityPortalURLs.admissionPrograms, "book.closed.fill"),
+ link("Расписание экзаменов", "Консультации и вступительные испытания",
+ UniversityPortalURLs.admissionExams, "calendar")
+ ]),
+ PortalSection(title: "Помощь абитуриенту", links: [
+ link("Новости приёмной комиссии", "Списки, приказы и важные даты",
+ UniversityPortalURLs.admissionNews, "megaphone.fill"),
+ link("Дни открытых дверей", "Экскурсии и встречи с институтами",
+ UniversityPortalURLs.openDays, "building.2.fill"),
+ link("FAQ абитуриента", "Ответы на частые вопросы о поступлении",
+ UniversityPortalURLs.admissionFaq, "questionmark.circle.fill"),
+ link("Иностранным абитуриентам", "Поступление для граждан других стран",
+ UniversityPortalURLs.foreignApplicants, "globe")
+ ]),
+ PortalSection(title: "Контакты", links: [
+ link("Приёмная комиссия", "Адреса, телефоны и график работы",
+ UniversityPortalURLs.admissionContacts, "phone.fill"),
+ link("Задать вопрос", "Консультация по поступлению",
+ UniversityPortalURLs.admissionReception, "text.bubble.fill"),
+ link("Общежитие для поступивших", "Как забронировать место",
+ UniversityPortalURLs.hostelRules, "bed.double.fill"),
+ link("Филиалы и колледжи", "Апатиты, Кировск, Полярный и колледжи МАУ",
+ UniversityPortalURLs.branches, "building.2.crop.circle")
+ ])
+ ]
+
+ static let scienceSections: [PortalSection] = [
+ PortalSection(title: "Наука МАУ", links: [
+ link("Раздел «Наука»", "Главная страница научного блока университета",
+ UniversityPortalURLs.scienceHome, "atom"),
+ link("Новости науки", "Анонсы исследований и научных событий",
+ UniversityPortalURLs.scienceNews, "megaphone.fill"),
+ link("Научные направления", "Приоритетные области исследований МАУ",
+ UniversityPortalURLs.scienceFields, "location.north.line.fill"),
+ link("Мероприятия и дайджест", "Конференции, семинары и научный дайджест",
+ UniversityPortalURLs.scienceDigest, "calendar")
+ ]),
+ PortalSection(title: "Исследователю", links: [
+ link("Гранты", "Конкурсы и поддержка научных проектов",
+ UniversityPortalURLs.scienceGrants, "trophy.fill"),
+ link("Помощь исследователю", "Инструкции, базы и доступ к ресурсам",
+ UniversityPortalURLs.scienceHelp, "questionmark.folder.fill"),
+ link("Издательство", "Журналы и публикации МАУ",
+ UniversityPortalURLs.sciencePublishing, "book.fill"),
+ link("Инновации и инфраструктура", "Площадки, лаборатории и инновации",
+ UniversityPortalURLs.scienceInnovations, "gearshape.2.fill")
+ ])
+ ]
+
+ static let internationalSections: [PortalSection] = [
+ PortalSection(title: "International", links: [
+ link("English website", "English-language pages of Murmansk Arctic University",
+ UniversityPortalURLs.englishSite, "globe"),
+ link("Международная деятельность", "Партнёрства, обмен и проекты МАУ",
+ UniversityPortalURLs.internationalActivity, "airplane"),
+ link("Иностранным поступающим", "Поступление для граждан других стран",
+ UniversityPortalURLs.foreignApplicants, "graduationcap.fill"),
+ link("Diploma Supplement", "Европейское приложение к диплому",
+ UniversityPortalURLs.diplomaSupplement, "doc.badge.ellipsis")
+ ])
+ ]
+
+ static let digitalSections: [PortalSection] = [
+ PortalSection(title: "Обучение и документы", links: [
+ link("Цифровые сервисы студентов", "Справки и онлайн-заявки Студенческого офиса",
+ UniversityPortalURLs.digitalStudentServices, "building.columns.fill"),
+ link("ЭИОС", "Электронная информационно-образовательная среда",
+ UniversityPortalURLs.eios, "graduationcap.fill"),
+ link("Электронный каталог библиотеки", "Поиск книг и электронных ресурсов",
+ UniversityPortalURLs.libraryCatalog, "books.vertical.fill")
+ ]),
+ PortalSection(title: "Почта и поддержка", links: [
+ link("Webmail МАУ", "Корпоративная почта @mauniver.ru",
+ UniversityPortalURLs.webmail, "envelope.fill"),
+ link("Заявка по общежитию (УИТ)", "Интернет и ИТ-поддержка в общежитии",
+ UniversityPortalURLs.dormItRequest, "wifi"),
+ link("Intra МАУ", "Внутренний портал для работников",
+ UniversityPortalURLs.intra, "person.2.fill"),
+ link("PROMT Translation Factory", "Сервис перевода для направления «Лингвистика»",
+ UniversityPortalURLs.promt, "character.book.closed.fill")
+ ])
+ ]
+
+ static let admissionContacts: [UniversityContactBlock] = [
+ UniversityContactBlock(
+ title: "Приёмная комиссия (ВО)",
+ details: "Бакалавриат, специалитет, магистратура, аспирантура\nпн–пт 10:00–16:00, перерыв 13:00–14:00",
+ phone: "8 800 350-12-21",
+ email: "priem@mauniver.ru",
+ address: "183010, г. Мурманск, пр. Кирова, д. 1, корпус Л, каб. 112"
+ ),
+ UniversityContactBlock(
+ title: "Приёмная комиссия колледжа МАУ",
+ details: "Среднее профессиональное образование\nпн–пт 10:00–16:00, перерыв 13:00–14:00",
+ phone: "8 8152 21-38-72",
+ email: "priem.spo@mauniver.ru",
+ address: "183038, г. Мурманск, ул. Ленина, 57, каб. 107"
+ ),
+ UniversityContactBlock(
+ title: "Филиал в г. Апатиты",
+ details: "пн–пт 9:00–17:00, сб–вс выходной",
+ phone: nil,
+ email: "priem@arcticsu.ru",
+ address: "184209, г. Апатиты, ул. Лесная, д. 29"
+ ),
+ UniversityContactBlock(
+ title: "Филиал в г. Кировске",
+ details: "пн–пт 9:00–17:00, сб–вс выходной",
+ phone: "8 8153 15-54-08",
+ email: "priem.kirovsk@mauniver.ru",
+ address: "184250, г. Кировск, ул. 50 лет Октября, д. 2, каб. 1116"
+ ),
+ UniversityContactBlock(
+ title: "Филиал в г. Полярный",
+ details: "пн–пт 9:00–17:00, обед 13:00–14:00",
+ phone: "8 8155 17-36-60",
+ email: "priem.pf@mauniver.ru",
+ address: "184651, г. Полярный, ул. Лунина, д. 5"
+ )
+ ]
+
+ static let universityRequisites: [UniversityContactBlock] = [
+ UniversityContactBlock(
+ title: "Адреса МАУ",
+ details: """
+ Юридический адрес: 183010, г. Мурманск, ул. Спортивная, д. 13
+ Почтовый адрес: 183038, г. Мурманск, ул. Капитана Егорова, д. 15
+ Телефон: +7 (8152) 21-38-01
+ Факс: +7 (8152) 45-27-52
+ """,
+ phone: "+7 (8152) 21-38-01",
+ email: "office@mauniver.ru",
+ address: "183010, г. Мурманск, ул. Спортивная, д. 13"
+ ),
+ UniversityContactBlock(
+ title: "Реквизиты для платежей",
+ details: """
+ Получатель: УФК по Нижегородской области (ФГАОУ ВО «МАУ», л/сч 30496Ж46000)
+ ИНН 5190100176
+ КПП 519001001
+ Казначейский счёт 03214643000000013212
+ Банк: ОТЦ №1 ВВГУ Банка России // УФК по Нижегородской области, г. Нижний Новгород
+ БИК 012202102
+ Счёт ЕКС 40102810745370000024
+ ОГРН 1025100848651
+ В назначении платежа за обучение указывайте код 00000000000000000130, ФИО, факультет, специальность, курс.
+ """,
+ phone: nil,
+ email: nil,
+ address: nil
+ )
+ ]
+
+ static let campusGroups: [CampusBuildingGroup] = [
+ CampusBuildingGroup(
+ title: "Южный кампус",
+ transportTip: "Остановки: «МАУ», «переулок Хибинский». Автобусы и маршрутки по ул. Спортивной / Колхозной / Советской.",
+ buildings: [
+ campus("Корпус А", "ул. Спортивная, 13/6"),
+ campus("Корпус Б", "ул. Колхозная, 2"),
+ campus("Корпус В", "ул. Спортивная, 13"),
+ campus("Корпус Г", "ул. Советская, 8А"),
+ campus("Корпус Д", "ул. Советская, 8"),
+ campus("Корпус Е", "ул. Советская, 12А"),
+ campus("Корпус К", "ул. Спортивная, 9"),
+ campus("Корпус Л1", "ул. Кирова, 1"),
+ campus("Корпус Л2", "ул. Кирова, 1"),
+ campus("Корпус М", "ул. Советская, 17"),
+ campus("Корпус Н", "ул. Спортивная, 11"),
+ campus("Корпус П", "ул. Советская, 10"),
+ campus("Корпус С", "ул. Советская, 14"),
+ campus("Корпус Э", "ул. Горького, 14"),
+ campus("Столовая", "ул. Колхозная, 15А"),
+ campus("КСК", "ул. Колхозная, 15")
+ ]
+ ),
+ CampusBuildingGroup(
+ title: "Северный кампус",
+ transportTip: "Остановки: «Капитана Егорова», «Академика Книповича». Удобно добираться от ж/д вокзала и центра.",
+ buildings: [
+ campus("Е15", "ул. Капитана Егорова, 15"),
+ campus("Е16", "ул. Капитана Егорова, 16"),
+ campus("К9", "ул. Коммуны, 9"),
+ campus("Л57", "пр. Ленина, 57")
+ ]
+ ),
+ CampusBuildingGroup(
+ title: "Филиалы",
+ transportTip: "Апатиты, Кировск и Полярный — смотрите расписание пригородного транспорта и пропуска для ЗАТО Полярный.",
+ buildings: [
+ campus("Филиал в г. Апатиты", "ул. Лесная, 29", city: "Апатиты", mapCity: "apatity"),
+ campus("Филиал в г. Кировске", "ул. 50 лет Октября, 2", city: "Кировск", mapCity: "kirovsk"),
+ campus("Филиал в г. Полярный", "ул. Лунина, 5", city: "Полярный", mapCity: "murmansk")
+ ]
+ )
+ ]
+
+ private static func link(
+ _ title: String,
+ _ subtitle: String,
+ _ url: String,
+ _ systemImage: String
+ ) -> PortalLink {
+ PortalLink(title: title, subtitle: subtitle, url: url, systemImage: systemImage)
+ }
+
+ private static func campus(_ title: String, _ address: String) -> CampusBuilding {
+ CampusBuilding(
+ title: title,
+ address: address,
+ searchQuery: "МАУ \(title), \(address), Мурманск",
+ mapCity: "murmansk"
+ )
+ }
+
+ private static func campus(
+ _ title: String,
+ _ address: String,
+ city: String,
+ mapCity: String
+ ) -> CampusBuilding {
+ CampusBuilding(
+ title: title,
+ address: address,
+ searchQuery: "МАУ \(title), \(address), \(city)",
+ mapCity: mapCity
+ )
+ }
+}
diff --git a/MauverseIOS/Sources/Design/DesignSystem.swift b/MauverseIOS/Sources/Design/DesignSystem.swift
index 95042a5..872b56d 100644
--- a/MauverseIOS/Sources/Design/DesignSystem.swift
+++ b/MauverseIOS/Sources/Design/DesignSystem.swift
@@ -7,14 +7,14 @@ enum AppTheme: String, CaseIterable, Identifiable {
var id: String { rawValue }
var title: String {
- return switch self {
+ switch self {
case .system: "Как на iPhone"
case .light: "Светлая"
case .dark: "Тёмная"
}
}
var colorScheme: ColorScheme? {
- return switch self {
+ switch self {
case .system: nil
case .light: .light
case .dark: .dark
@@ -25,17 +25,16 @@ enum AppTheme: String, CaseIterable, Identifiable {
enum MauTheme {
static let blue = Color(red: 0.09, green: 0.46, blue: 0.97)
static let cyan = Color(red: 0.29, green: 0.78, blue: 1.0)
- static let violet = Color(red: 0.47, green: 0.40, blue: 1.0)
+ static let violet = Color(red: 0.38, green: 0.42, blue: 0.92)
static let navy = Color(red: 0.015, green: 0.075, blue: 0.13)
static let success = Color(red: 0.16, green: 0.70, blue: 0.45)
static let ink = Color(uiColor: .label)
static let muted = Color(uiColor: .secondaryLabel)
static let canvas = Color(uiColor: .systemBackground)
static let card = Color(uiColor: .secondarySystemBackground)
- static let lavender = Color(red: 0.88, green: 0.90, blue: 1.0)
static let heroGradient = LinearGradient(
- colors: [blue, Color(red: 0.08, green: 0.27, blue: 0.68), violet],
+ colors: [blue, Color(red: 0.08, green: 0.27, blue: 0.68), cyan.opacity(0.85)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
@@ -55,55 +54,138 @@ enum MauRadius {
static let hero: CGFloat = 30
}
+enum MauMotion {
+ static let snappy = Animation.snappy(duration: 0.28, extraBounce: 0.05)
+ static let soft = Animation.spring(response: 0.42, dampingFraction: 0.86)
+ static let press = Animation.spring(response: 0.22, dampingFraction: 0.72)
+ static let orb = Animation.easeInOut(duration: 9.5).repeatForever(autoreverses: true)
+ static let pulse = Animation.easeInOut(duration: 1.05).repeatForever(autoreverses: true)
+}
+
+/// Atmospheric canvas with drifting glass orbs (respects Reduce Motion).
struct MauBackground: View {
@Environment(\.colorScheme) private var colorScheme
+ @Environment(\.accessibilityReduceMotion) private var reduceMotion
+ @State private var drift = false
var body: some View {
ZStack {
MauTheme.canvas
LinearGradient(
colors: colorScheme == .dark
- ? [MauTheme.navy.opacity(0.96), .black, Color.purple.opacity(0.10)]
- : [Color.blue.opacity(0.055), MauTheme.canvas, Color.purple.opacity(0.045)],
+ ? [MauTheme.navy.opacity(0.97), .black, MauTheme.blue.opacity(0.12)]
+ : [MauTheme.cyan.opacity(0.07), MauTheme.canvas, MauTheme.blue.opacity(0.05)],
startPoint: .topTrailing,
endPoint: .bottomLeading
)
Circle()
- .fill(MauTheme.cyan.opacity(colorScheme == .dark ? 0.13 : 0.10))
- .frame(width: 420, height: 420)
- .blur(radius: 70)
- .offset(x: 190, y: -350)
+ .fill(MauTheme.cyan.opacity(colorScheme == .dark ? 0.16 : 0.12))
+ .frame(width: 440, height: 440)
+ .blur(radius: 78)
+ .offset(x: drift ? 175 : 205, y: drift ? -330 : -360)
Circle()
- .fill(MauTheme.violet.opacity(colorScheme == .dark ? 0.15 : 0.075))
- .frame(width: 390, height: 390)
- .blur(radius: 80)
- .offset(x: -190, y: 390)
+ .fill(MauTheme.blue.opacity(colorScheme == .dark ? 0.18 : 0.09))
+ .frame(width: 400, height: 400)
+ .blur(radius: 88)
+ .offset(x: drift ? -175 : -210, y: drift ? 360 : 410)
+ Circle()
+ .fill(MauTheme.violet.opacity(colorScheme == .dark ? 0.10 : 0.05))
+ .frame(width: 280, height: 280)
+ .blur(radius: 70)
+ .offset(x: drift ? 40 : -20, y: drift ? 120 : 160)
}
.ignoresSafeArea()
+ .onAppear {
+ guard !reduceMotion else { return }
+ withAnimation(MauMotion.orb) { drift = true }
+ }
}
}
+enum MauGlassStyle {
+ case regular
+ case thin
+ case interactive
+}
+
private struct MauGlassModifier: ViewModifier {
let radius: CGFloat
+ let style: MauGlassStyle
+
+ @Environment(\.colorScheme) private var colorScheme
+ @Environment(\.accessibilityReduceTransparency) private var reduceTransparency
@ViewBuilder
func body(content: Content) -> some View {
- if #available(iOS 26.0, *) {
- content.glassEffect(.regular, in: RoundedRectangle(cornerRadius: radius, style: .continuous))
+ if reduceTransparency {
+ content
+ .background(MauTheme.card.opacity(colorScheme == .dark ? 0.94 : 0.96),
+ in: RoundedRectangle(cornerRadius: radius, style: .continuous))
+ .overlay { stroke }
+ } else if #available(iOS 26.0, *) {
+ glass26(content)
+ .overlay { stroke.opacity(0.55) }
} else {
content
- .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: radius, style: .continuous))
- .overlay {
- RoundedRectangle(cornerRadius: radius, style: .continuous)
- .stroke(Color.white.opacity(0.75), lineWidth: 1)
- }
+ .background(fallbackMaterial, in: RoundedRectangle(cornerRadius: radius, style: .continuous))
+ .overlay { stroke }
}
}
+
+ @available(iOS 26.0, *)
+ @ViewBuilder
+ private func glass26(_ content: Content) -> some View {
+ let shape = RoundedRectangle(cornerRadius: radius, style: .continuous)
+ // Keep to stable glassEffect APIs shipped with Xcode 26.5.
+ content.glassEffect(.regular, in: shape)
+ }
+
+ private var fallbackMaterial: Material {
+ switch style {
+ case .thin: .thinMaterial
+ case .regular, .interactive: .ultraThinMaterial
+ }
+ }
+
+ private var stroke: some View {
+ RoundedRectangle(cornerRadius: radius, style: .continuous)
+ .strokeBorder(
+ LinearGradient(
+ colors: colorScheme == .dark
+ ? [Color.white.opacity(0.28), Color.white.opacity(0.06)]
+ : [Color.white.opacity(0.85), Color.white.opacity(0.25)],
+ startPoint: .topLeading,
+ endPoint: .bottomTrailing
+ ),
+ lineWidth: 0.9
+ )
+ }
+}
+
+private struct MauPressStyle: ButtonStyle {
+ @Environment(\.accessibilityReduceMotion) private var reduceMotion
+
+ func makeBody(configuration: Configuration) -> some View {
+ configuration.label
+ .scaleEffect(configuration.isPressed && !reduceMotion ? 0.965 : 1)
+ .opacity(configuration.isPressed ? 0.92 : 1)
+ .animation(MauMotion.press, value: configuration.isPressed)
+ }
}
extension View {
- func mauGlass(radius: CGFloat = 24) -> some View {
- modifier(MauGlassModifier(radius: radius))
+ /// Primary Liquid Glass surface (cards, sheets, chrome).
+ func mauGlass(radius: CGFloat = MauRadius.card, style: MauGlassStyle = .regular) -> some View {
+ modifier(MauGlassModifier(radius: radius, style: style))
+ }
+
+ /// Secondary translucent surface — prefers glass over opaque fills.
+ func mauSurface(radius: CGFloat = MauRadius.card) -> some View {
+ mauGlass(radius: radius, style: .thin)
+ }
+
+ func mauPressable() -> some View {
+ buttonStyle(MauPressStyle())
}
func pageTitle(_ title: String, subtitle: String? = nil) -> some View {
@@ -119,14 +201,6 @@ extension View {
}
.frame(maxWidth: .infinity, alignment: .leading)
}
-
- func mauSurface(radius: CGFloat = MauRadius.card) -> some View {
- background(MauTheme.card.opacity(0.78), in: RoundedRectangle(cornerRadius: radius, style: .continuous))
- .overlay {
- RoundedRectangle(cornerRadius: radius, style: .continuous)
- .stroke(Color.primary.opacity(0.07), lineWidth: 0.75)
- }
- }
}
struct IconTile: View {
@@ -139,6 +213,7 @@ struct IconTile: View {
.foregroundStyle(.white)
.frame(width: 44, height: 44)
.background(color.gradient, in: RoundedRectangle(cornerRadius: 14, style: .continuous))
+ .shadow(color: color.opacity(0.28), radius: 8, y: 3)
}
}
@@ -172,28 +247,35 @@ struct MauStatusPill: View {
.foregroundStyle(color)
.padding(.horizontal, 11)
.padding(.vertical, 7)
- .background(color.opacity(0.12), in: Capsule())
+ .background(color.opacity(0.14), in: Capsule())
+ .overlay {
+ Capsule().strokeBorder(color.opacity(0.22), lineWidth: 0.6)
+ }
}
}
struct SkeletonCard: View {
+ @Environment(\.accessibilityReduceMotion) private var reduceMotion
@State private var pulse = false
+ var height: CGFloat = 150
var body: some View {
VStack(alignment: .leading, spacing: 13) {
- RoundedRectangle(cornerRadius: 14)
- .frame(height: 150)
- RoundedRectangle(cornerRadius: 6)
+ RoundedRectangle(cornerRadius: 14, style: .continuous)
+ .frame(height: height)
+ RoundedRectangle(cornerRadius: 6, style: .continuous)
.frame(height: 18)
.padding(.trailing, 40)
- RoundedRectangle(cornerRadius: 6)
+ RoundedRectangle(cornerRadius: 6, style: .continuous)
.frame(width: 150, height: 12)
}
- .foregroundStyle(MauTheme.muted.opacity(pulse ? 0.12 : 0.23))
+ .foregroundStyle(MauTheme.muted.opacity(pulse ? 0.10 : 0.22))
.padding(16)
- .mauSurface()
- .animation(.easeInOut(duration: 0.9).repeatForever(autoreverses: true), value: pulse)
- .onAppear { pulse = true }
+ .mauGlass(radius: MauRadius.card, style: .thin)
+ .onAppear {
+ guard !reduceMotion else { pulse = true; return }
+ withAnimation(MauMotion.pulse) { pulse = true }
+ }
}
}
@@ -207,7 +289,7 @@ struct LoadingOverlay: View {
}
.padding(.horizontal, 18)
.padding(.vertical, 14)
- .mauGlass(radius: 18)
+ .mauGlass(radius: MauRadius.compact, style: .interactive)
}
}
@@ -219,7 +301,7 @@ struct EmptyState: View {
var body: some View {
VStack(spacing: 12) {
Image(systemName: icon)
- .font(.system(size: 35))
+ .font(.system(size: 35, weight: .medium))
.foregroundStyle(MauTheme.blue)
Text(title).font(.headline)
Text(message)
@@ -229,6 +311,19 @@ struct EmptyState: View {
}
.frame(maxWidth: .infinity)
.padding(28)
- .mauGlass()
+ .mauGlass(radius: MauRadius.card)
+ }
+}
+
+/// Groups sibling glass views so iOS 26 can morph materials together.
+struct MauGlassStack: View {
+ @ViewBuilder var content: () -> Content
+
+ var body: some View {
+ if #available(iOS 26.0, *) {
+ GlassEffectContainer { content() }
+ } else {
+ content()
+ }
}
}
diff --git a/MauverseIOS/Sources/Views/CampusNavigatorView.swift b/MauverseIOS/Sources/Views/CampusNavigatorView.swift
new file mode 100644
index 0000000..e26a053
--- /dev/null
+++ b/MauverseIOS/Sources/Views/CampusNavigatorView.swift
@@ -0,0 +1,132 @@
+import SwiftUI
+
+struct CampusNavigatorView: View {
+ @State private var searchText = ""
+
+ private var filteredGroups: [CampusBuildingGroup] {
+ let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !query.isEmpty else { return UniversityPortalCatalog.campusGroups }
+ return UniversityPortalCatalog.campusGroups.compactMap { group in
+ let buildings = group.buildings.filter {
+ $0.title.localizedCaseInsensitiveContains(query)
+ || $0.address.localizedCaseInsensitiveContains(query)
+ }
+ guard !buildings.isEmpty else { return nil }
+ return CampusBuildingGroup(
+ title: group.title,
+ transportTip: group.transportTip,
+ buildings: buildings
+ )
+ }
+ }
+
+ var body: some View {
+ ZStack {
+ MauBackground()
+ ScrollView {
+ VStack(alignment: .leading, spacing: 18) {
+ VStack(alignment: .leading, spacing: 6) {
+ Text("Навигатор по корпусам")
+ .font(.system(size: 30, weight: .bold, design: .rounded))
+ Text("Маршрут в 2GIS и панорама Яндекс.Карт")
+ .font(.subheadline)
+ .foregroundStyle(MauTheme.muted)
+ }
+
+ TextField("Поиск корпуса или адреса", text: $searchText)
+ .padding(14)
+ .mauGlass(radius: MauRadius.compact, style: .thin)
+
+ if let url = URL(string: UniversityPortalURLs.campusNavigatorSite) {
+ NavigationLink {
+ InAppBrowserView(url: url, title: "Навигатор на сайте")
+ } label: {
+ HStack {
+ Label("Официальный навигатор МАУ", systemImage: "map.fill")
+ .font(.subheadline.weight(.semibold))
+ Spacer()
+ Image(systemName: "arrow.up.right")
+ .font(.caption.bold())
+ }
+ .foregroundStyle(MauTheme.blue)
+ .padding(16)
+ .mauSurface(radius: 18)
+ }
+ .buttonStyle(.plain)
+ }
+
+ if filteredGroups.isEmpty {
+ EmptyState(
+ icon: "magnifyingglass",
+ title: "Ничего не найдено",
+ message: "Попробуйте другое название корпуса"
+ )
+ } else {
+ ForEach(filteredGroups) { group in
+ MauSectionHeader(title: group.title)
+ Text(group.transportTip)
+ .font(.caption)
+ .foregroundStyle(MauTheme.muted)
+ .padding(.bottom, 4)
+
+ ForEach(group.buildings) { building in
+ buildingCard(building)
+ }
+ }
+ }
+ }
+ .padding(20)
+ .padding(.bottom, 30)
+ }
+ }
+ .navigationTitle("Корпуса")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar(.hidden, for: .tabBar)
+ }
+
+ private func buildingCard(_ building: CampusBuilding) -> some View {
+ VStack(alignment: .leading, spacing: 12) {
+ Text(building.title)
+ .font(.headline)
+ .foregroundStyle(MauTheme.ink)
+ Text(building.address)
+ .font(.subheadline)
+ .foregroundStyle(MauTheme.muted)
+
+ HStack(spacing: 10) {
+ if let route = routeURL(for: building) {
+ Link(destination: route) {
+ Label("Маршрут", systemImage: "point.topleft.down.to.point.bottomright.curvepath")
+ }
+ .buttonStyle(.borderedProminent)
+ .controlSize(.small)
+ }
+ if let panorama = panoramaURL(for: building) {
+ Link(destination: panorama) {
+ Label("Панорама", systemImage: "pano.fill")
+ }
+ .buttonStyle(.bordered)
+ .controlSize(.small)
+ }
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(16)
+ .mauSurface(radius: 18)
+ }
+
+ private func routeURL(for building: CampusBuilding) -> URL? {
+ let encoded = building.searchQuery.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed)
+ ?? building.searchQuery
+ return URL(string: "https://2gis.ru/\(building.mapCity)/search/\(encoded)")
+ }
+
+ private func panoramaURL(for building: CampusBuilding) -> URL? {
+ var components = URLComponents(string: "https://yandex.ru/maps/")
+ components?.queryItems = [
+ URLQueryItem(name: "text", value: building.searchQuery),
+ URLQueryItem(name: "l", value: "stv")
+ ]
+ return components?.url
+ }
+}
diff --git a/MauverseIOS/Sources/Views/EventsCalendarView.swift b/MauverseIOS/Sources/Views/EventsCalendarView.swift
new file mode 100644
index 0000000..d798f31
--- /dev/null
+++ b/MauverseIOS/Sources/Views/EventsCalendarView.swift
@@ -0,0 +1,126 @@
+import SwiftUI
+
+@MainActor
+final class EventsCalendarViewModel: ObservableObject {
+ @Published var items: [NewsItem] = []
+ @Published var isLoading = false
+ @Published var error: String?
+
+ func load() async {
+ isLoading = items.isEmpty
+ error = nil
+ do {
+ items = try await OfficialNewsService.shared.load(filter: .calendar)
+ } catch {
+ if items.isEmpty {
+ self.error = error.localizedDescription
+ }
+ }
+ isLoading = false
+ }
+}
+
+struct EventsCalendarView: View {
+ @StateObject private var model = EventsCalendarViewModel()
+
+ var body: some View {
+ ZStack {
+ MauBackground()
+ ScrollView {
+ VStack(alignment: .leading, spacing: 18) {
+ VStack(alignment: .leading, spacing: 6) {
+ Text("Календарь событий")
+ .font(.system(size: 30, weight: .bold, design: .rounded))
+ Text("Анонсы пресс-центра МАУ")
+ .font(.subheadline)
+ .foregroundStyle(MauTheme.muted)
+ }
+
+ if let url = URL(string: UniversityPortalURLs.eventsCalendar) {
+ NavigationLink {
+ InAppBrowserView(url: url, title: "Календарь на сайте")
+ } label: {
+ HStack {
+ Label("Открыть на mauniver.ru", systemImage: "safari.fill")
+ .font(.subheadline.weight(.semibold))
+ Spacer()
+ Image(systemName: "arrow.up.right")
+ .font(.caption.bold())
+ }
+ .foregroundStyle(MauTheme.blue)
+ .padding(16)
+ .mauSurface(radius: 18)
+ }
+ .buttonStyle(.plain)
+ }
+
+ if model.isLoading {
+ SkeletonCard()
+ SkeletonCard()
+ } else if let error = model.error {
+ VStack(spacing: 12) {
+ EmptyState(
+ icon: "calendar.badge.exclamationmark",
+ title: "Календарь недоступен",
+ message: error
+ )
+ Button("Повторить") { Task { await model.load() } }
+ .buttonStyle(.borderedProminent)
+ }
+ } else if model.items.isEmpty {
+ EmptyState(
+ icon: "calendar",
+ title: "Событий пока нет",
+ message: "Загляните позже или откройте календарь на сайте"
+ )
+ } else {
+ LazyVStack(spacing: 12) {
+ ForEach(model.items) { item in
+ eventRow(item)
+ }
+ }
+ }
+ }
+ .padding(20)
+ .padding(.bottom, 30)
+ }
+ .refreshable { await model.load() }
+ }
+ .navigationTitle("Календарь")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar(.hidden, for: .tabBar)
+ .task { if model.items.isEmpty { await model.load() } }
+ }
+
+ @ViewBuilder
+ private func eventRow(_ item: NewsItem) -> some View {
+ if let link = item.link, let url = URL(string: link) {
+ NavigationLink {
+ InAppBrowserView(url: url, title: item.title ?? "Событие")
+ } label: {
+ VStack(alignment: .leading, spacing: 8) {
+ Text(item.title ?? "Событие")
+ .font(.subheadline.weight(.semibold))
+ .foregroundStyle(MauTheme.ink)
+ .multilineTextAlignment(.leading)
+ if let publish = item.publish, !publish.isEmpty {
+ Text(publish)
+ .font(.caption)
+ .foregroundStyle(MauTheme.blue)
+ }
+ if let description = item.description, !description.isEmpty {
+ Text(HTMLTextCleaning.cleanRSS(description))
+ .font(.caption)
+ .foregroundStyle(MauTheme.muted)
+ .lineLimit(3)
+ .multilineTextAlignment(.leading)
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(16)
+ .mauGlass(radius: MauRadius.compact)
+ }
+ .buttonStyle(.plain)
+ }
+ }
+}
diff --git a/MauverseIOS/Sources/Views/HomeView.swift b/MauverseIOS/Sources/Views/HomeView.swift
index 3135dad..01443e1 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")
@@ -214,28 +220,36 @@ struct HomeView: View {
}
private var lessonSkeleton: some View {
- RoundedRectangle(cornerRadius: MauRadius.hero)
- .fill(MauTheme.blue.opacity(0.14))
+ SkeletonCard(height: 120)
.frame(height: 220)
- .overlay { ProgressView().tint(MauTheme.blue) }
}
private var quickActions: some View {
- LazyVGrid(
- columns: [GridItem(.flexible(), spacing: 12), GridItem(.flexible(), spacing: 12)],
- spacing: 12
- ) {
- PremiumQuickCard(title: "Расписание", subtitle: "Занятия и аудитории", icon: "calendar", color: MauTheme.blue) {
- ScheduleView()
- }
- PremiumQuickCard(title: "Сервисы", subtitle: "Все инструменты МАУ", icon: "square.grid.2x2.fill", color: MauTheme.violet) {
- ServicesView()
- }
- PremiumQuickCard(title: "Новости", subtitle: "События университета", icon: "newspaper.fill", color: .orange) {
- NewsView()
- }
- PremiumQuickCard(title: "ЭИОС", subtitle: "Учебный портал", icon: "graduationcap.fill", color: .teal) {
- InAppBrowserView(url: URL(string: "https://eios.mauniver.ru/moodle/")!, title: "ЭИОС")
+ MauGlassStack {
+ LazyVGrid(
+ columns: [GridItem(.flexible(), spacing: 12), GridItem(.flexible(), spacing: 12)],
+ spacing: 12
+ ) {
+ PremiumQuickCard(title: "Расписание", subtitle: "Занятия и аудитории", icon: "calendar", color: MauTheme.blue) {
+ withAnimation(MauMotion.soft) { selectedTab = 1 }
+ }
+ PremiumQuickCard(title: "Сервисы", subtitle: "Все инструменты МАУ", icon: "square.grid.2x2.fill", color: MauTheme.violet) {
+ withAnimation(MauMotion.soft) { selectedTab = 2 }
+ }
+ PremiumQuickCard(title: "Новости", subtitle: "События университета", icon: "newspaper.fill", color: .orange) {
+ withAnimation(MauMotion.soft) { selectedTab = 3 }
+ }
+ NavigationLink {
+ InAppBrowserView(url: URL(string: "https://eios.mauniver.ru/moodle/")!, title: "Учебный портал")
+ } label: {
+ PremiumQuickCardContent(
+ title: "Учёба",
+ subtitle: "Учебный портал",
+ icon: "graduationcap.fill",
+ color: .teal
+ )
+ }
+ .mauPressable()
}
}
}
@@ -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,37 +399,48 @@ 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)
- }
+ Button(action: action) {
+ PremiumQuickCardContent(title: title, subtitle: subtitle, icon: icon, color: color)
+ }
+ .mauPressable()
+ }
+}
+
+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)
}
- .buttonStyle(.plain)
- .mauSurface(radius: 22)
+ .foregroundStyle(MauTheme.ink)
+ .frame(maxWidth: .infinity, minHeight: 105, alignment: .topLeading)
+ .padding(16)
+ .mauGlass(radius: MauRadius.card, style: .interactive)
}
}
diff --git a/MauverseIOS/Sources/Views/InAppBrowserView.swift b/MauverseIOS/Sources/Views/InAppBrowserView.swift
index b5c0fd5..7d1a1df 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.11.0"
+ configuration.applicationNameForUserAgent = "MAUverse/1.12.4"
configuration.defaultWebpagePreferences.allowsContentJavaScript = true
configuration.allowsInlineMediaPlayback = true
webView = WKWebView(frame: .zero, configuration: configuration)
@@ -224,7 +224,7 @@ private struct BrowserLocationBar: View {
}
.padding(.horizontal, 16)
.padding(.vertical, 9)
- .background(.bar)
+ .background(.ultraThinMaterial)
if browser.isLoading {
ProgressView(value: browser.progress)
diff --git a/MauverseIOS/Sources/Views/LoginView.swift b/MauverseIOS/Sources/Views/LoginView.swift
index 4bb6b3a..b5457f7 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()
@@ -46,7 +46,7 @@ struct LoginView: View {
.submitLabel(.next)
.onSubmit { focused = .password }
.padding(18)
- .background(MauTheme.card.opacity(0.78), in: RoundedRectangle(cornerRadius: 18))
+ .mauGlass(radius: MauRadius.compact, style: .thin)
SecureField("Пароль", text: $password)
.textContentType(.password)
@@ -54,13 +54,14 @@ struct LoginView: View {
.submitLabel(.go)
.onSubmit { signIn() }
.padding(18)
- .background(MauTheme.card.opacity(0.78), in: RoundedRectangle(cornerRadius: 18))
+ .mauGlass(radius: MauRadius.compact, style: .thin)
if let error = session.errorMessage {
Text(error)
.font(.footnote)
.foregroundStyle(.red)
.frame(maxWidth: .infinity, alignment: .leading)
+ .transition(.opacity.combined(with: .move(edge: .top)))
}
Button(action: signIn) {
@@ -72,14 +73,15 @@ struct LoginView: View {
.frame(maxWidth: .infinity)
.padding(.vertical, 17)
}
- .buttonStyle(.plain)
+ .mauPressable()
.foregroundStyle(.white)
- .background(MauTheme.heroGradient, in: RoundedRectangle(cornerRadius: 18))
+ .background(MauTheme.heroGradient, in: RoundedRectangle(cornerRadius: 18, style: .continuous))
.shadow(color: MauTheme.blue.opacity(0.22), radius: 14, y: 7)
.disabled(session.isBusy)
}
.padding(20)
- .mauSurface(radius: 28)
+ .mauGlass(radius: 28)
+ .animation(MauMotion.soft, value: session.errorMessage)
Text("Используйте данные от электронной информационно-образовательной среды МАУ")
.font(.caption)
diff --git a/MauverseIOS/Sources/Views/NewsView.swift b/MauverseIOS/Sources/Views/NewsView.swift
index 52f0f37..7554d6d 100644
--- a/MauverseIOS/Sources/Views/NewsView.swift
+++ b/MauverseIOS/Sources/Views/NewsView.swift
@@ -63,17 +63,26 @@ struct NewsView: View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 9) {
ForEach(NewsFilter.allCases) { filter in
- Button(filter.title) {
- model.filter = filter
+ Button {
+ withAnimation(MauMotion.snappy) {
+ model.filter = filter
+ }
Task { await model.load() }
+ } label: {
+ Text(filter.title)
+ .font(.system(size: 14, weight: .semibold))
+ .foregroundStyle(model.filter == filter ? .white : MauTheme.ink)
+ .padding(.horizontal, 15)
+ .padding(.vertical, 10)
+ .background {
+ if model.filter == filter {
+ Capsule().fill(MauTheme.heroGradient)
+ }
+ }
+ .mauGlass(radius: 22, style: .thin)
}
- .font(.system(size: 14, weight: .semibold))
- .foregroundStyle(model.filter == filter ? .white : MauTheme.ink)
- .padding(.horizontal, 15)
- .padding(.vertical, 10)
- .background(model.filter == filter ? AnyShapeStyle(MauTheme.blue.gradient) : AnyShapeStyle(MauTheme.card.opacity(0.78)),
- in: Capsule())
- .buttonStyle(.plain)
+ .mauPressable()
+ .sensoryFeedback(.selection, trigger: model.filter)
}
}
}
@@ -118,15 +127,17 @@ private struct NewsCard: View {
@ViewBuilder
var body: some View {
- if let value = item.link, let url = URL(string: value) {
- NavigationLink(destination: InAppBrowserView(url: url, title: item.title ?? "Новости МАУ")) {
+ Group {
+ if let value = item.link, let url = URL(string: value) {
+ NavigationLink(destination: InAppBrowserView(url: url, title: item.title ?? "Новости МАУ")) {
+ content
+ }
+ .buttonStyle(.plain)
+ } else {
content
}
- .buttonStyle(.plain)
- .mauGlass(radius: 24)
- } else {
- content.mauGlass(radius: 24)
}
+ .mauGlass(radius: MauRadius.card, style: .regular)
}
private var content: some View {
@@ -135,6 +146,7 @@ private struct NewsCard: View {
AsyncImage(url: url) { phase in
if case .success(let image) = phase {
image.resizable().scaledToFill()
+ .transition(.opacity)
} else {
MauTheme.heroGradient
.overlay(Image(systemName: "photo").foregroundStyle(.white.opacity(0.7)))
@@ -164,7 +176,6 @@ private struct NewsCard: View {
.frame(maxWidth: .infinity, minHeight: 106, alignment: .topLeading)
.padding(16)
}
- .background(MauTheme.card.opacity(0.74))
.clipShape(RoundedRectangle(cornerRadius: MauRadius.card, style: .continuous))
}
}
@@ -175,14 +186,18 @@ private struct HeroNewsCard: View {
@ViewBuilder
var body: some View {
- if let value = item.link, let url = URL(string: value) {
- NavigationLink(destination: InAppBrowserView(url: url, title: item.title ?? "Новости МАУ")) {
+ Group {
+ if let value = item.link, let url = URL(string: value) {
+ NavigationLink(destination: InAppBrowserView(url: url, title: item.title ?? "Новости МАУ")) {
+ content
+ }
+ .buttonStyle(.plain)
+ } else {
content
}
- .buttonStyle(.plain)
- } else {
- content
}
+ .mauGlass(radius: MauRadius.hero)
+ .shadow(color: MauTheme.blue.opacity(0.12), radius: 18, y: 10)
}
private var content: some View {
@@ -193,6 +208,7 @@ private struct HeroNewsCard: View {
image
.resizable()
.scaledToFill()
+ .transition(.opacity)
} else {
MauTheme.heroGradient
.overlay {
@@ -231,7 +247,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)
@@ -241,20 +257,12 @@ private struct HeroNewsCard: View {
.padding(20)
}
.frame(maxWidth: .infinity)
- .background(MauTheme.card.opacity(0.82))
.clipShape(RoundedRectangle(cornerRadius: MauRadius.hero, style: .continuous))
- .overlay {
- RoundedRectangle(cornerRadius: MauRadius.hero, style: .continuous)
- .stroke(Color.primary.opacity(0.07), lineWidth: 0.75)
- }
- .shadow(color: .black.opacity(0.12), radius: 18, y: 10)
}
}
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/PortalGuideView.swift b/MauverseIOS/Sources/Views/PortalGuideView.swift
new file mode 100644
index 0000000..19885fe
--- /dev/null
+++ b/MauverseIOS/Sources/Views/PortalGuideView.swift
@@ -0,0 +1,81 @@
+import SwiftUI
+
+struct PortalGuideView: View {
+ let title: String
+ let subtitle: String
+ let sections: [PortalSection]
+
+ var body: some View {
+ ZStack {
+ MauBackground()
+ ScrollView {
+ VStack(alignment: .leading, spacing: 18) {
+ VStack(alignment: .leading, spacing: 6) {
+ Text(title)
+ .font(.system(size: 30, weight: .bold, design: .rounded))
+ Text(subtitle)
+ .font(.subheadline)
+ .foregroundStyle(MauTheme.muted)
+ }
+
+ ForEach(sections) { section in
+ MauSectionHeader(title: section.title)
+ VStack(spacing: 10) {
+ ForEach(section.links) { item in
+ if let url = URL(string: item.url) {
+ NavigationLink {
+ InAppBrowserView(url: url, title: item.title)
+ } label: {
+ PortalLinkRow(link: item)
+ }
+ .buttonStyle(.plain)
+ }
+ }
+ }
+ }
+ }
+ .padding(20)
+ .padding(.bottom, 30)
+ }
+ }
+ .navigationTitle(title)
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar(.hidden, for: .tabBar)
+ }
+}
+
+struct PortalLinkRow: View {
+ let link: PortalLink
+
+ var body: some View {
+ HStack(spacing: 14) {
+ IconTile(systemName: link.systemImage, color: MauTheme.blue)
+ VStack(alignment: .leading, spacing: 4) {
+ Text(link.title)
+ .font(.subheadline.weight(.semibold))
+ .foregroundStyle(MauTheme.ink)
+ .multilineTextAlignment(.leading)
+ Text(link.subtitle)
+ .font(.caption)
+ .foregroundStyle(MauTheme.muted)
+ .multilineTextAlignment(.leading)
+ }
+ Spacer(minLength: 8)
+ Image(systemName: "chevron.right")
+ .font(.caption.bold())
+ .foregroundStyle(MauTheme.muted)
+ }
+ .padding(16)
+ .mauSurface(radius: 20)
+ }
+}
+
+struct DigitalServicesView: View {
+ var body: some View {
+ PortalGuideView(
+ title: "Цифровые сервисы",
+ subtitle: "ЭИОС, почта, библиотека и заявки МАУ",
+ sections: UniversityPortalCatalog.digitalSections
+ )
+ }
+}
diff --git a/MauverseIOS/Sources/Views/ProfileView.swift b/MauverseIOS/Sources/Views/ProfileView.swift
index a053ff4..9cd908f 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.11.0 (28)")
+ Text("MAUverse 1.12.4 (33)")
.font(.caption)
.foregroundStyle(MauTheme.muted)
}
@@ -244,6 +244,18 @@ private struct SettingsView: View {
.padding(20)
.mauSurface()
+ VStack(alignment: .leading, spacing: 12) {
+ Label("Университет", systemImage: "building.columns.fill").font(.headline)
+ settingsLink("Официальный сайт", url: UniversityPortalURLs.officialSite)
+ Divider()
+ settingsLink("Политика персональных данных", url: UniversityPortalURLs.privacyPolicy)
+ Divider()
+ settingsLink("Сведения об образовательной организации", url: UniversityPortalURLs.sveden)
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(20)
+ .mauSurface()
+
VStack(alignment: .leading, spacing: 12) {
Label("О приложении", systemImage: "info.circle.fill").font(.headline)
Text("Нативное приложение Мурманского арктического университета.")
@@ -268,7 +280,7 @@ private struct SettingsView: View {
.foregroundStyle(MauTheme.blue)
}
}
- Text("Версия 1.11.0 • сборка 28").font(.caption)
+ Text("Версия 1.12.4 • сборка 33").font(.caption)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(20)
@@ -282,6 +294,26 @@ private struct SettingsView: View {
.toolbar(.hidden, for: .tabBar)
}
+ @ViewBuilder
+ private func settingsLink(_ title: String, url: String) -> some View {
+ if let destination = URL(string: url) {
+ NavigationLink {
+ InAppBrowserView(url: destination, title: title)
+ } label: {
+ HStack {
+ Text(title)
+ .foregroundStyle(MauTheme.ink)
+ .multilineTextAlignment(.leading)
+ Spacer()
+ Image(systemName: "arrow.up.right")
+ .font(.caption2.bold())
+ .foregroundStyle(MauTheme.blue)
+ }
+ .font(.subheadline.weight(.semibold))
+ }
+ }
+ }
+
private func clearCaches() {
URLCache.shared.removeAllCachedResponses()
let dataStore = WKWebsiteDataStore.default()
diff --git a/MauverseIOS/Sources/Views/RootTabView.swift b/MauverseIOS/Sources/Views/RootTabView.swift
index ad02a7e..f955be9 100644
--- a/MauverseIOS/Sources/Views/RootTabView.swift
+++ b/MauverseIOS/Sources/Views/RootTabView.swift
@@ -1,20 +1,28 @@
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)
+ .sensoryFeedback(.selection, trigger: selectedTab)
+ .animation(MauMotion.soft, value: selectedTab)
}
}
-
diff --git a/MauverseIOS/Sources/Views/ScheduleView.swift b/MauverseIOS/Sources/Views/ScheduleView.swift
index d882a29..8352731 100644
--- a/MauverseIOS/Sources/Views/ScheduleView.swift
+++ b/MauverseIOS/Sources/Views/ScheduleView.swift
@@ -4,6 +4,8 @@ import SwiftUI
final class ScheduleViewModel: ObservableObject {
@Published var items: [ScheduleItem] = []
@Published var selectedDate = Calendar.current.startOfDay(for: Date())
+ @Published var selectedTeacher: String?
+ @Published var selectedRoom: String?
@Published var isLoading = false
@Published var error: String?
private var activeRequest = UUID()
@@ -12,6 +14,32 @@ final class ScheduleViewModel: ObservableObject {
Calendar.current.date(byAdding: .day, value: $0, to: Calendar.current.startOfDay(for: Date()))
}
+ var availableTeachers: [String] {
+ Array(Set(items.compactMap { item -> String? in
+ guard let teacher = item.teacher?.trimmingCharacters(in: .whitespacesAndNewlines),
+ !teacher.isEmpty else { return nil }
+ return teacher
+ })).sorted()
+ }
+
+ var availableRooms: [String] {
+ Array(Set(items.compactMap { item -> String? in
+ guard let room = item.room?.trimmingCharacters(in: .whitespacesAndNewlines),
+ !room.isEmpty else { return nil }
+ return room
+ })).sorted()
+ }
+
+ func selectTeacher(_ teacher: String?) {
+ selectedTeacher = teacher
+ if teacher != nil { selectedRoom = nil }
+ }
+
+ func selectRoom(_ room: String?) {
+ selectedRoom = room
+ if room != nil { selectedTeacher = nil }
+ }
+
func load(user: UserDTO?) async -> ScheduleGroup? {
let request = UUID()
activeRequest = request
@@ -45,6 +73,12 @@ final class ScheduleViewModel: ObservableObject {
)
guard activeRequest == request else { return nil }
items = loaded
+ if let selectedTeacher, !availableTeachers.contains(selectedTeacher) {
+ self.selectedTeacher = nil
+ }
+ if let selectedRoom, !availableRooms.contains(selectedRoom) {
+ self.selectedRoom = nil
+ }
return resolvedGroup
} catch {
guard activeRequest == request else { return nil }
@@ -57,13 +91,21 @@ final class ScheduleViewModel: ObservableObject {
let formats = ["yyyy-MM-dd", "dd.MM.yyyy", "yyyy-MM-dd'T'HH:mm:ss"]
return items.filter { item in
guard let value = item.date else { return false }
- return formats.contains { format in
+ let matchesDate = formats.contains { format in
let parser = DateFormatter()
parser.locale = Locale(identifier: "en_US_POSIX")
parser.dateFormat = format
guard let parsed = parser.date(from: value) else { return false }
return Calendar.current.isDate(parsed, inSameDayAs: date)
}
+ guard matchesDate else { return false }
+ if let selectedTeacher {
+ return item.teacher == selectedTeacher
+ }
+ if let selectedRoom {
+ return item.room == selectedRoom
+ }
+ return true
}
.sorted { ($0.startTime ?? "") < ($1.startTime ?? "") }
}
@@ -103,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) {
@@ -128,15 +178,56 @@ struct ScheduleView: View {
.padding(.vertical, 2)
}
+ if !model.availableTeachers.isEmpty || !model.availableRooms.isEmpty {
+ VStack(alignment: .leading, spacing: 10) {
+ if !model.availableTeachers.isEmpty {
+ Text("Преподаватель")
+ .font(.caption.weight(.semibold))
+ .foregroundStyle(MauTheme.muted)
+ ScrollView(.horizontal, showsIndicators: false) {
+ HStack(spacing: 8) {
+ FilterChip(
+ title: "Все",
+ selected: model.selectedTeacher == nil
+ ) { model.selectTeacher(nil) }
+ ForEach(model.availableTeachers, id: \.self) { teacher in
+ FilterChip(
+ title: teacher,
+ selected: model.selectedTeacher == teacher
+ ) { model.selectTeacher(teacher) }
+ }
+ }
+ }
+ }
+ if !model.availableRooms.isEmpty {
+ Text("Аудитория")
+ .font(.caption.weight(.semibold))
+ .foregroundStyle(MauTheme.muted)
+ ScrollView(.horizontal, showsIndicators: false) {
+ HStack(spacing: 8) {
+ FilterChip(
+ title: "Все",
+ selected: model.selectedRoom == nil
+ ) { model.selectRoom(nil) }
+ ForEach(model.availableRooms, id: \.self) { room in
+ FilterChip(
+ title: room,
+ selected: model.selectedRoom == room
+ ) { model.selectRoom(room) }
+ }
+ }
+ }
+ }
+ }
+ }
+
if model.isLoading {
VStack(spacing: 12) {
ForEach(0..<3, id: \.self) { _ in
- RoundedRectangle(cornerRadius: 22)
- .fill(MauTheme.card.opacity(0.75))
- .frame(height: 112)
- .overlay { ProgressView().opacity(0.45) }
+ SkeletonCard(height: 56)
}
}
+ .transition(.opacity)
} else if let error = model.error {
VStack(spacing: 12) {
EmptyState(icon: "wifi.exclamationmark", title: "Не удалось загрузить", message: error)
@@ -145,12 +236,18 @@ struct ScheduleView: View {
}
} else if model.items(for: model.selectedDate).isEmpty {
EmptyState(icon: "cup.and.saucer.fill", title: "Занятий нет", message: "На выбранную дату расписание пустое")
+ .transition(.opacity.combined(with: .move(edge: .bottom)))
} else {
LazyVStack(spacing: 12) {
ForEach(model.items(for: model.selectedDate), id: \.stableID) { item in
LessonCard(item: item)
+ .transition(.asymmetric(
+ insertion: .opacity.combined(with: .move(edge: .trailing)),
+ removal: .opacity
+ ))
}
}
+ .animation(MauMotion.soft, value: model.selectedDate)
}
}
.padding(20)
@@ -180,20 +277,49 @@ 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)
.frame(width: 58, height: 68)
- .background(selected ? AnyShapeStyle(MauTheme.heroGradient) : AnyShapeStyle(MauTheme.card.opacity(0.82)),
- in: RoundedRectangle(cornerRadius: 19, style: .continuous))
- .overlay {
- RoundedRectangle(cornerRadius: 19, style: .continuous)
- .stroke(selected ? Color.white.opacity(0.22) : Color.primary.opacity(0.06), lineWidth: 0.75)
+ .background {
+ if selected {
+ RoundedRectangle(cornerRadius: 19, style: .continuous)
+ .fill(MauTheme.heroGradient)
+ }
}
+ .mauGlass(radius: 19, style: selected ? .regular : .thin)
.shadow(color: selected ? MauTheme.blue.opacity(0.22) : .clear, radius: 10, y: 5)
+ .animation(MauMotion.snappy, value: selected)
+ }
+}
+
+private struct FilterChip: View {
+ let title: String
+ let selected: Bool
+ let action: () -> Void
+
+ var body: some View {
+ Button {
+ withAnimation(MauMotion.snappy) { action() }
+ } label: {
+ Text(title)
+ .font(.caption.weight(.semibold))
+ .foregroundStyle(selected ? .white : MauTheme.ink)
+ .lineLimit(1)
+ .padding(.horizontal, 12)
+ .padding(.vertical, 8)
+ .background {
+ if selected {
+ Capsule().fill(MauTheme.blue.gradient)
+ }
+ }
+ .mauGlass(radius: 20, style: .thin)
+ }
+ .mauPressable()
+ .sensoryFeedback(.selection, trigger: selected)
}
}
@@ -251,7 +377,7 @@ private struct LessonCard: View {
Spacer()
}
.padding(17)
- .mauSurface(radius: 22)
+ .mauGlass(radius: MauRadius.card, style: .interactive)
}
}
diff --git a/MauverseIOS/Sources/Views/ServicesView.swift b/MauverseIOS/Sources/Views/ServicesView.swift
index 48a5488..c5545c5 100644
--- a/MauverseIOS/Sources/Views/ServicesView.swift
+++ b/MauverseIOS/Sources/Views/ServicesView.swift
@@ -1,7 +1,10 @@
import SwiftUI
enum MauService: String, CaseIterable, Identifiable {
- case eios, forms, messenger, campus, debts, digital, teachers, departments
+ case eios, forms, messenger, campus, debts, digital
+ case studentGuide, applicantGuide, scienceGuide, internationalGuide
+ case teachers, departments, contacts, eventsCalendar
+
var id: String { rawValue }
var title: String {
@@ -11,9 +14,15 @@ enum MauService: String, CaseIterable, Identifiable {
case .messenger: "Мессенджер ЭИОС"
case .campus: "Навигатор по корпусам"
case .debts: "Учебные задолженности"
- case .digital: "Цифровые сервисы МАУ"
+ case .digital: "Цифровые сервисы"
+ case .studentGuide: "Студенту"
+ case .applicantGuide: "Абитуриенту"
+ case .scienceGuide: "Наука"
+ case .internationalGuide: "International"
case .teachers: "Контакты преподавателей"
case .departments: "Подразделения и телефоны"
+ case .contacts: "Контакты и реквизиты"
+ case .eventsCalendar: "Календарь событий"
}
}
@@ -25,18 +34,25 @@ enum MauService: String, CaseIterable, Identifiable {
case .campus: "map.fill"
case .debts: "exclamationmark.circle.fill"
case .digital: "network"
+ case .studentGuide: "person.3.fill"
+ case .applicantGuide: "graduationcap.circle.fill"
+ case .scienceGuide: "atom"
+ case .internationalGuide: "globe"
case .teachers: "person.text.rectangle.fill"
case .departments: "building.2.fill"
+ case .contacts: "doc.text.fill"
+ case .eventsCalendar: "calendar"
}
}
var color: Color {
return switch self {
- case .eios, .teachers: MauTheme.blue
- case .forms, .departments: .purple
- case .messenger, .digital: .teal
- case .campus: .orange
+ case .eios, .teachers, .studentGuide: MauTheme.blue
+ case .forms, .departments, .applicantGuide: .purple
+ case .messenger, .digital, .scienceGuide: .teal
+ case .campus, .eventsCalendar: .orange
case .debts: .red
+ case .internationalGuide, .contacts: MauTheme.violet
}
}
@@ -47,24 +63,30 @@ enum MauService: String, CaseIterable, Identifiable {
case .messenger: "Сообщения в ЭИОС"
case .campus: "Корпуса и маршруты"
case .debts: "Проверка успеваемости"
- case .digital: "Онлайн-ресурсы"
+ case .digital: "Почта, ЭИОС, библиотека"
+ case .studentGuide: "Гид по разделу «Студенту»"
+ case .applicantGuide: "Поступление и приёмная"
+ case .scienceGuide: "Наука и исследования"
+ case .internationalGuide: "English and international"
case .teachers: "Поиск по имени"
case .departments: "Телефоны и кабинеты"
+ case .contacts: "Приёмка и платежи"
+ case .eventsCalendar: "Анонсы пресс-центра"
}
}
}
private enum ServiceCategory: String, CaseIterable, Identifiable {
- case study = "Учёба"
- case communication = "Коммуникации"
- case university = "Университет"
+ case services = "Услуги"
+ case portal = "Портал МАУ"
+ case directories = "Справочники"
var id: String { rawValue }
var services: [MauService] {
return switch self {
- case .study: [.eios, .forms, .debts]
- case .communication: [.messenger, .teachers, .departments]
- case .university: [.campus, .digital]
+ case .services: [.eios, .forms, .messenger, .campus, .debts, .digital]
+ case .portal: [.studentGuide, .applicantGuide, .scienceGuide, .internationalGuide]
+ case .directories: [.teachers, .departments, .contacts, .eventsCalendar]
}
}
}
@@ -79,7 +101,7 @@ struct ServicesView: View {
VStack(alignment: .leading, spacing: 20) {
VStack(alignment: .leading, spacing: 6) {
Text("Сервисы").font(.system(size: 36, weight: .bold, design: .rounded))
- Text("Учёба и университет — в одном месте")
+ Text("Учебные сервисы и разделы сайта МАУ")
.font(.subheadline)
.foregroundStyle(MauTheme.muted)
}
@@ -113,8 +135,8 @@ struct ServicesView: View {
.padding(16)
.frame(maxWidth: .infinity, minHeight: 135, alignment: .topLeading)
}
- .buttonStyle(.plain)
- .mauSurface(radius: 23)
+ .mauPressable()
+ .mauGlass(radius: MauRadius.card, style: .interactive)
}
}
}
@@ -134,21 +156,52 @@ private struct ServiceDestination: View {
var body: some View {
switch service {
case .eios:
- WebServiceView(title: service.title, url: "https://eios.mauniver.ru/moodle/")
+ WebServiceView(title: service.title, url: UniversityPortalURLs.eios)
case .digital:
- WebServiceView(title: service.title, url: "https://mauniver.ru/services/")
+ DigitalServicesView()
case .messenger:
- WebServiceView(title: service.title, url: "https://eios.mauniver.ru/moodle/message/index.php")
+ WebServiceView(
+ title: service.title,
+ url: "https://eios.mauniver.ru/moodle/message/index.php"
+ )
case .forms:
FormsView()
case .campus:
- CampusView()
+ CampusNavigatorView()
case .debts:
DebtsView()
case .teachers:
TeacherContactsView()
case .departments:
DepartmentsView()
+ case .studentGuide:
+ PortalGuideView(
+ title: "Студенту",
+ subtitle: "Офис, FAQ, общежитие и поддержка",
+ sections: UniversityPortalCatalog.studentSections
+ )
+ case .applicantGuide:
+ PortalGuideView(
+ title: "Абитуриенту",
+ subtitle: "Поступление, экзамены и контакты приёмной",
+ sections: UniversityPortalCatalog.applicantSections
+ )
+ case .scienceGuide:
+ PortalGuideView(
+ title: "Наука",
+ subtitle: "Направления, гранты и публикации МАУ",
+ sections: UniversityPortalCatalog.scienceSections
+ )
+ case .internationalGuide:
+ PortalGuideView(
+ title: "International",
+ subtitle: "English site and international activity",
+ sections: UniversityPortalCatalog.internationalSections
+ )
+ case .contacts:
+ UniversityContactsView()
+ case .eventsCalendar:
+ EventsCalendarView()
}
}
}
@@ -172,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: "Справка для перевода",
@@ -323,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)
@@ -336,6 +392,7 @@ private struct OnlineFormLink: Identifiable {
let subtitle: String
let icon: String
let url: String?
+ var isNative: Bool = false
var id: String { title }
}
@@ -547,79 +604,6 @@ private struct CertificateRequestView: View {
}
}
-private struct Campus: Identifiable {
- let id = UUID()
- let code: String
- let address: String
-}
-
-private struct CampusView: View {
- private let south = [
- Campus(code: "А", address: "Спортивная, 13/6"),
- Campus(code: "Б", address: "Колхозная, 2"),
- Campus(code: "В", address: "Спортивная, 13"),
- Campus(code: "Г", address: "Советская, 8А"),
- Campus(code: "Д", address: "Советская, 8"),
- Campus(code: "Е", address: "Советская, 12А"),
- Campus(code: "К", address: "Спортивная, 9"),
- Campus(code: "Л1 / Л2", address: "Кирова, 1"),
- Campus(code: "М", address: "Советская, 17"),
- Campus(code: "Н", address: "Спортивная, 11"),
- Campus(code: "П", address: "Советская, 10"),
- Campus(code: "С", address: "Советская, 14"),
- Campus(code: "Э", address: "Горького, 14")
- ]
- private let north = [
- Campus(code: "Е15", address: "Капитана Егорова, 15"),
- Campus(code: "Е16", address: "Капитана Егорова, 16"),
- Campus(code: "К9", address: "Коммуны, 9"),
- Campus(code: "Л57", address: "проспект Ленина, 57")
- ]
-
- var body: some View {
- ZStack {
- MauBackground()
- List {
- CampusSection(title: "Южный кампус", campuses: south)
- CampusSection(title: "Северный кампус", campuses: north)
- }
- .scrollContentBackground(.hidden)
- }
- .navigationTitle("Корпуса")
- .toolbar(.hidden, for: .tabBar)
- }
-}
-
-private struct CampusSection: View {
- let title: String
- let campuses: [Campus]
- var body: some View {
- Section(title) {
- ForEach(campuses) { campus in
- if let url = mapURL(for: campus) {
- NavigationLink(destination: InAppBrowserView(url: url, title: "Корпус \(campus.code)")) {
- HStack {
- Text(campus.code)
- .font(.headline)
- .foregroundStyle(MauTheme.blue)
- .frame(width: 54, alignment: .leading)
- Text(campus.address).foregroundStyle(MauTheme.ink)
- Spacer()
- Image(systemName: "location.fill").foregroundStyle(MauTheme.blue)
- }
- }
- }
- }
- }
- }
-
- private func mapURL(for campus: Campus) -> URL? {
- let query = "Мурманск, \(campus.address)"
- let encoded = query.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? query
- return URL(string: "https://2gis.ru/murmansk/search/\(encoded)")
- }
-}
-
@MainActor
private final class DebtsModel: ObservableObject {
@Published var semesters: [Semester] = []
@@ -725,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 {
@@ -733,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 }
}
@@ -786,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",
@@ -961,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") }
}
@@ -983,10 +967,22 @@ 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? {
- let normalized = value.filter { $0.isNumber || $0 == "+" }
- guard normalized.filter(\.isNumber).count >= 5 else { return nil }
- return URL(string: "tel:\(normalized)")
+ PhoneNumberFormatting.telURL(from: value)
}
}
diff --git a/MauverseIOS/Sources/Views/UniversityContactsView.swift b/MauverseIOS/Sources/Views/UniversityContactsView.swift
new file mode 100644
index 0000000..874f9c0
--- /dev/null
+++ b/MauverseIOS/Sources/Views/UniversityContactsView.swift
@@ -0,0 +1,141 @@
+import SwiftUI
+import UIKit
+
+struct UniversityContactsView: View {
+ @State private var copiedMessage: String?
+
+ var body: some View {
+ ZStack {
+ MauBackground()
+ ScrollView {
+ VStack(alignment: .leading, spacing: 18) {
+ VStack(alignment: .leading, spacing: 6) {
+ Text("Контакты и реквизиты")
+ .font(.system(size: 30, weight: .bold, design: .rounded))
+ Text("Приёмная комиссия, филиалы и платежи")
+ .font(.subheadline)
+ .foregroundStyle(MauTheme.muted)
+ }
+
+ if let url = URL(string: UniversityPortalURLs.requisites) {
+ NavigationLink {
+ InAppBrowserView(url: url, title: "Адреса и реквизиты")
+ } label: {
+ HStack {
+ Label("Страница на сайте МАУ", systemImage: "safari.fill")
+ .font(.subheadline.weight(.semibold))
+ Spacer()
+ Image(systemName: "arrow.up.right")
+ .font(.caption.bold())
+ }
+ .foregroundStyle(MauTheme.blue)
+ .padding(16)
+ .mauSurface(radius: 18)
+ }
+ .buttonStyle(.plain)
+ }
+
+ MauSectionHeader(title: "Приёмная комиссия")
+ ForEach(UniversityPortalCatalog.admissionContacts) { block in
+ contactCard(block)
+ }
+
+ MauSectionHeader(title: "Реквизиты университета")
+ ForEach(UniversityPortalCatalog.universityRequisites) { block in
+ contactCard(block)
+ }
+ }
+ .padding(20)
+ .padding(.bottom, 30)
+ }
+ }
+ .navigationTitle("Контакты")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar(.hidden, for: .tabBar)
+ .overlay(alignment: .bottom) {
+ if let copiedMessage {
+ Text(copiedMessage)
+ .font(.footnote.weight(.semibold))
+ .padding(.horizontal, 14)
+ .padding(.vertical, 10)
+ .background(.ultraThinMaterial, in: Capsule())
+ .overlay {
+ Capsule().strokeBorder(Color.white.opacity(0.25), lineWidth: 0.6)
+ }
+ .padding(.bottom, 24)
+ .transition(.move(edge: .bottom).combined(with: .opacity))
+ }
+ }
+ .animation(.easeInOut(duration: 0.2), value: copiedMessage)
+ }
+
+ private func contactCard(_ block: UniversityContactBlock) -> some View {
+ VStack(alignment: .leading, spacing: 12) {
+ Text(block.title)
+ .font(.headline)
+ .foregroundStyle(MauTheme.ink)
+ Text(block.details)
+ .font(.caption)
+ .foregroundStyle(MauTheme.muted)
+ .fixedSize(horizontal: false, vertical: true)
+
+ HStack(spacing: 10) {
+ 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)
+ }
+ }
+ Button {
+ copyText(block.details, label: "Скопировано")
+ } label: {
+ Label("Копировать", systemImage: "doc.on.doc")
+ }
+ .buttonStyle(.bordered)
+ .controlSize(.small)
+ }
+
+ if let email = block.email {
+ 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)
+ }
+ .foregroundStyle(MauTheme.blue)
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(16)
+ .mauSurface(radius: 20)
+ }
+
+ private func phoneURL(_ phone: String) -> URL? {
+ PhoneNumberFormatting.telURL(from: phone)
+ }
+
+ private func copyText(_ value: String, label: String) {
+ UIPasteboard.general.string = value
+ copiedMessage = label
+ Task {
+ try? await Task.sleep(nanoseconds: 1_600_000_000)
+ if copiedMessage == label { copiedMessage = nil }
+ }
+ }
+}
diff --git a/MauverseIOS/project.yml b/MauverseIOS/project.yml
index d22104b..fb2c6ad 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.0
- CURRENT_PROJECT_VERSION: 29
+ MARKETING_VERSION: 1.12.4
+ CURRENT_PROJECT_VERSION: 33
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/IOS_LIQUID_GLASS.md b/docs/IOS_LIQUID_GLASS.md
index aca33fe..6ebc49d 100644
--- a/docs/IOS_LIQUID_GLASS.md
+++ b/docs/IOS_LIQUID_GLASS.md
@@ -1,58 +1,47 @@
-# Native iPhone app (SwiftUI)
+# iOS Liquid Glass — production pass (1.12.4)
-The shipping iPhone application is the native SwiftUI client in `MauverseIOS`.
-Android remains the .NET MAUI client in `mauverse_mobile`. Both talk to the same
-MAUverse API and share the same bundle id `com.pmi4freal.mauverse3`.
+Built: 2026-08-03
+Client: `MauverseIOS` (SwiftUI), deployment iOS 18+, glass API gated iOS 26+
-## Rendering
+## Intent
-- iOS 26 and newer use the system Liquid Glass appearance for navigation and
- translucent surfaces.
-- iOS 18–25 use native SwiftUI / UIKit materials.
-- Reduce Transparency, Increase Contrast, and Reduce Motion remain controlled
- by iOS.
+Ship a coherent Liquid Glass campus app: translucent materials everywhere cards live, intentional motion, no matte-on-glass bugs, Reduce Transparency / Reduce Motion respected.
-## Build and run
+## Contract
-Requires a Mac with Xcode 26.5 (or newer matching `project.yml`) and
-[XcodeGen](https://github.com/yonaskolb/XcodeGen):
+| API | Meaning |
+| --- | --- |
+| `mauGlass(radius:style:)` | Primary glass. iOS 26 → `glassEffect(.regular)`; older → materials; Reduce Transparency → solid card |
+| `mauSurface` | Alias of **thin glass** (not opaque fill) |
+| `MauGlassStack` | `GlassEffectContainer` on iOS 26 for sibling morph |
+| `mauPressable()` | Scale/opacity press spring |
+| `MauMotion` | `snappy` / `soft` / `press` / `orb` / `pulse` |
+| `MauBackground` | Canvas + drifting cyan/navy orbs (orb motion off if Reduce Motion) |
-```bash
-brew install xcodegen
-cd MauverseIOS
-xcodegen generate --spec project.yml
-open MAUverse.xcodeproj
-```
+Styles: `.regular` (cards), `.thin` (secondary / fields), `.interactive` (tappable tiles).
-Set `MAUVERSE_SCHEDULE_TOKEN` in the Xcode build settings or scheme environment
-when calling the schedule API. A physical iPhone is recommended for final
-material, accessibility, dark-mode, and performance checks.
+## What changed in 1.12.4
-For App Store distribution, configure the team, provisioning profile, and
-release signing on the Mac. Do not publish an unsigned or Debug-signed archive.
+- Redesigned `DesignSystem.swift` (materials, a11y, motion tokens, press, glass stack, pulsing glass skeletons).
+- Home: glass quick actions in `MauGlassStack`, press, skeleton, soft tab switches.
+- Schedule: glass date/filter chips + lesson cards, skeleton loaders, list transitions.
+- News: fixed double-layer card (opaque under glass); hero glass; animated filter chips.
+- Services: interactive glass tiles.
+- Login: glass form + fields + error appear.
+- Campus search glass; events HTML cleaned; contacts toast stroked.
+- RootTab: selection haptic + soft animation.
+- Brand gradient: less purple, more Arctic cyan/navy.
-## Unsigned GitHub CI artifact
+## Smoke (device, before production)
-The `ios-release-unsigned` job (display name
-`Native SwiftUI iPhone Release (unsigned)`) publishes
-`mauverse-native-ios-arm64-release-unsigned`. It contains:
+1. iOS 26: glass on Home / Schedule / News / Services; tab bar system glass.
+2. Dark mode + Reduce Transparency (solids, no broken strokes).
+3. Reduce Motion (no orb drift / press scale still subtle).
+4. Login → Home → each tab; news filters; schedule day chips.
+5. Dynamic Type large; VoiceOver on quick cards.
-- `mauverse-native-ios-arm64-unsigned.ipa`;
-- `checksums.txt`;
-- `build-metadata.txt`.
+## Follow-ups (post-ship if needed)
-The IPA is only an unsigned container for later signing. The job does not import
-an Apple certificate or provisioning profile and fails if the application
-contains `_CodeSignature` or `embedded.mobileprovision`. Sign the extracted
-application with your own matching certificate, entitlements, and provisioning
-profile, then recreate the IPA.
-
-## Required smoke checks
-
-1. Login, logout, and restored session.
-2. All five tabs: Home, Schedule, Services, News, and Profile.
-3. Service routes, in-app browser, messenger, and forms.
-4. Light and dark appearance.
-5. iOS Reduce Transparency and Increase Contrast.
-6. Dynamic Type and VoiceOver labels.
-7. iPhone portrait and landscape layouts.
+- Custom glass Form for certificate / profile editor.
+- Departments off system `List`.
+- Matched-geometry date chip pill.
diff --git a/docs/RELEASE_1.12.0.md b/docs/RELEASE_1.12.0.md
index 33008f3..5c4a515 100644
--- a/docs/RELEASE_1.12.0.md
+++ b/docs/RELEASE_1.12.0.md
@@ -2,8 +2,9 @@
Built: 2026-08-02
-Classification: engineering release candidate, not production-approved until
-`docs/RELEASE_CHECKLIST.md` gates pass on the exact promoted commit and artifact.
+Classification: engineering release published as GitHub Release `v1.12.0`.
+Not production-approved until remaining `docs/RELEASE_CHECKLIST.md` gates pass
+on the exact promoted signed artifacts.
## Targets
@@ -13,8 +14,17 @@ Classification: engineering release candidate, not production-approved until
| iPhone (SwiftUI) | `MauverseIOS` | `1.12.0` | `CURRENT_PROJECT_VERSION` 29 |
- Package: `com.pmi4freal.mauverse3`
-- Release branch: `cursor/university-portal-parity-010d`
-- Pull request: https://github.com/decoder-dev/mauverse/pull/16
+- Release commit: `f846ddc9de327ba448b2ab2ed7213d462a67ab4d` (`main`)
+- Tag / GitHub Release: https://github.com/decoder-dev/mauverse/releases/tag/v1.12.0
+- Pull request: https://github.com/decoder-dev/mauverse/pull/16 (merged)
+- CI on release commit: https://github.com/decoder-dev/mauverse/actions/runs/30763288979
+
+## Published artifacts (unsigned)
+
+| Artifact | SHA-256 |
+| --- | --- |
+| `mauverse-android-arm64-unsigned.aab` | `6e6b78a22621a520912f487004fafe7b00cc0c351f8d60b14686a961656eb030` |
+| `mauverse-native-ios-arm64-unsigned.ipa` | `561cc033124e1bda04380a62a52453516a000895ca11eb4e5ce816c1f0276837` |
## Product changes
@@ -44,15 +54,9 @@ Classification: engineering release candidate, not production-approved until
## Verification
-- Local `mauverse_mobile.Tests` and `mauverse-api` unit tests on the RC branch.
-- Hosted CI must be green for `backend`, `api-image`, `android-release`, and
- `Native SwiftUI iPhone Release (unsigned)` on the exact release commit.
-- Production signing, device smoke tests, and API traffic promotion remain open
- gates from `docs/RELEASE_CHECKLIST.md`.
-
-## Promotion gates still required
-
-- [ ] All four CI jobs green on the exact release commit.
+- [x] All four CI jobs green on `f846ddc` (`backend`, `api-image`,
+ `android-release`, `Native SwiftUI iPhone Release (unsigned)`).
+- [x] GitHub Release `v1.12.0` published with unsigned AAB/IPA and checksums.
- [ ] Protected production Android keystore signing (not `CN=Android Debug`).
- [ ] Apple distribution signing of the native IPA on an approved Mac.
- [ ] Device smoke test from `docs/RELEASE_CHECKLIST.md`.
diff --git a/docs/RELEASE_1.12.1.md b/docs/RELEASE_1.12.1.md
new file mode 100644
index 0000000..b8c9494
--- /dev/null
+++ b/docs/RELEASE_1.12.1.md
@@ -0,0 +1,47 @@
+# MAUverce 1.12.1 release record
+
+Built: 2026-08-02
+
+Classification: engineering release published as GitHub Release `v1.12.1`.
+Not production-approved until remaining `docs/RELEASE_CHECKLIST.md` gates pass
+on the exact promoted signed artifacts.
+
+## Targets
+
+| Client | Path | Version | Build |
+| --- | --- | --- | --- |
+| Android (MAUI) | `mauverse_mobile` | `1.12.0` | `versionCode` 29 |
+| iPhone (SwiftUI) | `MauverseIOS` | `1.12.1` | `CURRENT_PROJECT_VERSION` 30 |
+
+- Package: `com.pmi4freal.mauverse3`
+- Release commit: `23749894a01e295b4ac1260c7d5d336e161cb43a` (`main`)
+- Tag / GitHub Release: https://github.com/decoder-dev/mauverse/releases/tag/v1.12.1
+- Pull request: https://github.com/decoder-dev/mauverse/pull/18 (merged)
+- CI on release commit: https://github.com/decoder-dev/mauverse/actions/runs/30764355781
+
+## Published artifacts (unsigned)
+
+| Artifact | SHA-256 |
+| --- | --- |
+| `mauverse-android-arm64-unsigned.aab` | `7af8a3de23cae9cd0e5eba956af0ac7c95723458209716d44e84c6885814f833` |
+| `mauverse-native-ios-arm64-unsigned.ipa` | `1315cf660d53e409d279b9bbbe5ce31d299b189154a5a0435cf05d3c85b37afc` |
+
+## iPhone changes
+
+- Portal guides: Студенту, Абитуриенту, Наука, International
+- Curated digital services hub
+- Native events calendar (`/press/calendar/rss/`)
+- News filters Абитуриент / Календарь
+- Contacts and payment requisites with call/copy
+- Campus branches, transport tips, 2GIS route, Yandex panorama
+- Settings university links (site, PDN, sveden)
+- Schedule teacher/room filter chips (exclusive selection)
+
+## Verification
+
+- [x] All four CI jobs green on `2374989`.
+- [x] GitHub Release `v1.12.1` published with unsigned AAB/IPA and checksums.
+- [ ] Protected production Android keystore signing (not `CN=Android Debug`).
+- [ ] Apple distribution signing of the native IPA on an approved Mac.
+- [ ] Device smoke test from `docs/RELEASE_CHECKLIST.md`.
+- [ ] Production API TLS, readiness, Moodle/mail monitoring, and rollback owner.
diff --git a/docs/RELEASE_1.12.2.md b/docs/RELEASE_1.12.2.md
new file mode 100644
index 0000000..c7ded27
--- /dev/null
+++ b/docs/RELEASE_1.12.2.md
@@ -0,0 +1,44 @@
+# MAUverce 1.12.2 release record
+
+Built: 2026-08-02
+
+Classification: engineering release published as GitHub Release `v1.12.2`.
+Not production-approved until remaining `docs/RELEASE_CHECKLIST.md` gates pass
+on the exact promoted signed artifacts.
+
+## Targets
+
+| Client | Path | Version | Build |
+| --- | --- | --- | --- |
+| Android (MAUI) | `mauverse_mobile` | `1.12.2` | `versionCode` 31 |
+| iPhone (SwiftUI) | `MauverseIOS` | `1.12.2` | `CURRENT_PROJECT_VERSION` 31 |
+
+- Package: `com.pmi4freal.mauverse3`
+- Release commit: `e1c315d4203c4feb07fc2164771c685d4c96b66c` (`main`)
+- Tag / GitHub Release: https://github.com/decoder-dev/mauverse/releases/tag/v1.12.2
+- Pull requests: https://github.com/decoder-dev/mauverse/pull/21 (product), https://github.com/decoder-dev/mauverse/pull/22 (release)
+- CI on release artifacts: https://github.com/decoder-dev/mauverse/actions/runs/30767400489
+
+## Published artifacts (unsigned)
+
+| Artifact | SHA-256 |
+| --- | --- |
+| `mauverse-android-arm64-unsigned.aab` | `7b11c5dcce5fe11ccad1da8e0e8eeb89a0264528e4001cf3da4ecaab3206b244` |
+| `mauverse-native-ios-arm64-unsigned.ipa` | `0327900a2446474a247e03e8b46afdbfe610cebb9fd5f16a7749b0ad7290eb16` |
+
+## Product changes
+
+- News titles: decode HTML entities (` `, ` `, numeric entities) so amounts render as `100 000` instead of literal ` `.
+- API RSS parser: same thin-space cleanup after `html.unescape`.
+- Phones: Murmansk local 6-digit PBX numbers dial as `+7 8152 …`; trailing `(3045)` / `доб. 3045` become `;ext=` extensions instead of being concatenated into a bogus long number.
+- Android department contacts: tap-to-call uses the same formatter.
+- Android Release: suppress CA1822 on `DetailTelephoneViewModel.CallAsync` for `warnaserror` CI.
+
+## Verification
+
+- [x] All four CI jobs green on PR #22 / run `30767400489`.
+- [x] GitHub Release `v1.12.2` published with unsigned AAB/IPA and checksums.
+- [ ] Protected production Android keystore signing (not `CN=Android Debug`).
+- [ ] Apple distribution signing of the native IPA on an approved Mac.
+- [ ] Device smoke test from `docs/RELEASE_CHECKLIST.md`.
+- [ ] Production API TLS, readiness, Moodle/mail monitoring, and rollback owner.
diff --git a/docs/RELEASE_1.12.3.md b/docs/RELEASE_1.12.3.md
new file mode 100644
index 0000000..3568c69
--- /dev/null
+++ b/docs/RELEASE_1.12.3.md
@@ -0,0 +1,43 @@
+# MAUverce 1.12.3 release record
+
+Built: 2026-08-03
+
+Classification: engineering release published as GitHub Release `v1.12.3`.
+Not production-approved until remaining `docs/RELEASE_CHECKLIST.md` gates pass
+on the exact promoted signed artifacts.
+
+## Targets
+
+| Client | Path | Version | Build |
+| --- | --- | --- | --- |
+| Android (MAUI) | `mauverse_mobile` | `1.12.3` | `versionCode` 32 |
+| iPhone (SwiftUI) | `MauverseIOS` | `1.12.3` | `CURRENT_PROJECT_VERSION` 32 |
+
+- Package: `com.pmi4freal.mauverse3`
+- Release commit: `acfa97762f1bf69f7b56ab93a0c5f5f52d768e75` (`main`)
+- Tag / GitHub Release: https://github.com/decoder-dev/mauverse/releases/tag/v1.12.3
+- Pull request: https://github.com/decoder-dev/mauverse/pull/24 (merged)
+- CI on release artifacts: https://github.com/decoder-dev/mauverse/actions/runs/30841992477
+
+## Published artifacts (unsigned)
+
+| Artifact | SHA-256 |
+| --- | --- |
+| `mauverse-android-arm64-unsigned.aab` | `eca26b3387d0f281bef8d4633ff759f3f49e8c02c650f24b51a345838a735b6c` |
+| `mauverse-native-ios-arm64-unsigned.ipa` | `080c5f3f8526294624c4414692dd263e456851cedae11c0d91ee671f1fb18092` |
+
+## Product changes
+
+- Dumb-user UX/bug audit schemes in `docs/UX_AUDIT_1.12.3.md`.
+- Android: StateContainer loading states, news filter/error copy, HTML decode in news details, subgroup cancel path, teacher tap-to-search, schedule tab rename, friendlier login labels, certificate DOB, null guards.
+- iOS: stop Moodle group id as schedule UID, native certificate form, configurable API base URL, tab-switching quick actions, retry cards, Russian date chips, softer jargon, phone/mailto contacts.
+- Android engineering signing docs: `docs/ANDROID_SIGNING.md`.
+
+## Verification
+
+- [x] All four CI jobs green on PR #24 / run `30841992477`.
+- [x] GitHub Release `v1.12.3` published with unsigned AAB/IPA and checksums.
+- [ ] Protected production Android keystore signing (not engineering / debug cert).
+- [ ] Apple distribution signing of the native IPA on an approved Mac.
+- [ ] Device smoke test from `docs/RELEASE_CHECKLIST.md`.
+- [ ] Production API TLS, readiness, Moodle/mail monitoring, and rollback owner.
diff --git a/docs/RELEASE_1.12.4.md b/docs/RELEASE_1.12.4.md
new file mode 100644
index 0000000..82d360e
--- /dev/null
+++ b/docs/RELEASE_1.12.4.md
@@ -0,0 +1,43 @@
+# MAUverce 1.12.4 release record
+
+Built: 2026-08-03
+
+Classification: engineering release published as GitHub Release `v1.12.4`.
+Not production-approved until remaining `docs/RELEASE_CHECKLIST.md` gates pass
+on the exact promoted signed artifacts.
+
+## Targets
+
+| Client | Path | Version | Build |
+| --- | --- | --- | --- |
+| Android (MAUI) | `mauverse_mobile` | `1.12.4` | `versionCode` 33 |
+| iPhone (SwiftUI) | `MauverseIOS` | `1.12.4` | `CURRENT_PROJECT_VERSION` 33 |
+
+- Package: `com.pmi4freal.mauverse3`
+- Release commit: `c652ae2d1af6aa8cf9f8bbd9eba8c80d1ce3c0f2` (`main`)
+- Tag / GitHub Release: https://github.com/decoder-dev/mauverse/releases/tag/v1.12.4
+- Pull request: https://github.com/decoder-dev/mauverse/pull/26 (merged)
+- CI on release artifacts: https://github.com/decoder-dev/mauverse/actions/runs/30843250564
+
+## Published artifacts (unsigned)
+
+| Artifact | SHA-256 |
+| --- | --- |
+| `mauverse-android-arm64-unsigned.aab` | `05a88513468f20beea83c0b96789e00f34380ab927408dc1cc58327088c4b22a` |
+| `mauverse-native-ios-arm64-unsigned.ipa` | `7f0d246e66c8eb56de4081408754e5b50d7a7361a74112600ffb10795072ce3f` |
+
+## Product changes
+
+- iOS Liquid Glass production pass (`docs/IOS_LIQUID_GLASS.md`).
+- DesignSystem: `mauGlass` / thin `mauSurface`, motion, press, a11y, glass stack.
+- Home, Schedule, News, Services, Login, Campus on glass; NewsCard opaque-under-glass fixed.
+- Chip/tab animations; calendar HTML cleanup.
+
+## Verification
+
+- [x] All four CI jobs green on PR #26 / run `30843250564`.
+- [x] GitHub Release `v1.12.4` published with unsigned AAB/IPA and checksums.
+- [ ] Protected production Android keystore signing.
+- [ ] Apple distribution signing of the native IPA on an approved Mac.
+- [ ] Device smoke from `docs/RELEASE_CHECKLIST.md` + Liquid Glass checklist.
+- [ ] Production API TLS, readiness, Moodle/mail monitoring, and rollback owner.
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-api/apps/gateway/utils/parser.py b/mauverse-api/apps/gateway/utils/parser.py
index 7a2aa4d..459da39 100644
--- a/mauverse-api/apps/gateway/utils/parser.py
+++ b/mauverse-api/apps/gateway/utils/parser.py
@@ -221,6 +221,26 @@ def get_teacher_info_json(self, teacher: TeacherInfo) -> list[Any] | dict[str, A
return payload
+_THIN_SPACES = (
+ "\xa0", # nbsp
+ "\u2002", # ensp
+ "\u2003", # emsp
+ "\u2009", # thinsp
+ "\u200a",
+ "\u202f",
+ "\u205f",
+)
+
+
+def _clean_rss_text(value: str) -> str:
+ text = html.unescape(value or "")
+ for space in _THIN_SPACES:
+ text = text.replace(space, " ")
+ text = re.sub(r"<[^>]+>", "", text)
+ text = re.sub(r"\s+", " ", text).strip()
+ return text.replace("\n", "")
+
+
class RssParser:
def __init__(self) -> None:
self.base_url = parser_config.MAIN_URL
@@ -247,16 +267,8 @@ def get_rss_data(self, parser_type: ParserType) -> list[dict[str, Any]]:
if any(node is None for node in (title_node, link_node, description_node, date_node)):
continue
- title = re.sub(
- r"<[^>]+>",
- "",
- html.unescape(title_node.text).replace("\xa0", " "),
- ).replace("\n", "")
- description = re.sub(
- r"<[^>]+>",
- "",
- html.unescape(description_node.text).replace("\xa0", " "),
- ).replace("\n", "")
+ title = _clean_rss_text(title_node.text)
+ description = _clean_rss_text(description_node.text)
try:
local_date = date_parser.parse(date_node.text)
except (OverflowError, ValueError):
diff --git a/mauverse-api/requirements.txt b/mauverse-api/requirements.txt
index c25adb4..1e6036a 100644
--- a/mauverse-api/requirements.txt
+++ b/mauverse-api/requirements.txt
@@ -1,7 +1,7 @@
babel==2.17.0
beautifulsoup4==4.15.0
click==8.4.2
-fastapi==0.139.0
+fastapi==0.141.1
lxml==6.1.1
mysql-connector-python==9.7.0
pydantic==2.13.4
diff --git a/mauverse_mobile.Tests/PhoneNumberFormattingTests.cs b/mauverse_mobile.Tests/PhoneNumberFormattingTests.cs
new file mode 100644
index 0000000..651a5ac
--- /dev/null
+++ b/mauverse_mobile.Tests/PhoneNumberFormattingTests.cs
@@ -0,0 +1,26 @@
+using mau.Utils;
+using Xunit;
+
+namespace Mauverse.Mobile.Tests;
+
+public sealed class PhoneNumberFormattingTests
+{
+ [Theory]
+ [InlineData("21-38-81 (3045)", "+78152213881;ext=3045")]
+ [InlineData("40-33-39", "+78152403339")]
+ [InlineData("8 8152 21-38-72", "+78152213872")]
+ [InlineData("+7 (8152) 21-38-01", "+78152213801")]
+ [InlineData("8 800 350-12-21", "+78003501221")]
+ [InlineData("21-38-81 доб. 3045", "+78152213881;ext=3045")]
+ public void FormatsMurmanskAndRussianNumbers(string raw, string expected)
+ {
+ Assert.Equal(expected, PhoneNumberFormatting.ToDialString(raw));
+ }
+
+ [Fact]
+ public void RejectsEmptyInput()
+ {
+ Assert.Null(PhoneNumberFormatting.ToDialString(" "));
+ Assert.Null(PhoneNumberFormatting.ToDialString(null));
+ }
+}
diff --git a/mauverse_mobile.Tests/mauverse_mobile.Tests.csproj b/mauverse_mobile.Tests/mauverse_mobile.Tests.csproj
index fa31e03..e0f0735 100644
--- a/mauverse_mobile.Tests/mauverse_mobile.Tests.csproj
+++ b/mauverse_mobile.Tests/mauverse_mobile.Tests.csproj
@@ -33,6 +33,8 @@
Link="Production\UniversityContactsCatalog.cs" />
+
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/Templates/TelephoneInfoTemplate.xaml b/mauverse_mobile/Templates/TelephoneInfoTemplate.xaml
index f28ac2f..1a4566f 100644
--- a/mauverse_mobile/Templates/TelephoneInfoTemplate.xaml
+++ b/mauverse_mobile/Templates/TelephoneInfoTemplate.xaml
@@ -3,6 +3,7 @@
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:toolkit="http://schemas.microsoft.com/dotnet/2022/maui/toolkit"
xmlns:viewmodel="clr-namespace:mau.ViewModel.Services"
+ xmlns:dialogs="clr-namespace:mau.ViewModel.Dialogs"
xmlns:converters="clr-namespace:mau.Utils.Converters"
xmlns:controls="clr-namespace:mau.Controls"
xmlns:dto="clr-namespace:mau.DTOModels"
@@ -70,12 +71,24 @@
-
-
+
+ IsVisible="{Binding Phone2, Converter={StaticResource HideLabelConverter}}">
+
+
+
+
diff --git a/mauverse_mobile/Utils/PhoneNumberFormatting.cs b/mauverse_mobile/Utils/PhoneNumberFormatting.cs
new file mode 100644
index 0000000..ba5f032
--- /dev/null
+++ b/mauverse_mobile/Utils/PhoneNumberFormatting.cs
@@ -0,0 +1,65 @@
+using System.Text.RegularExpressions;
+
+namespace mau.Utils;
+
+///
+/// Formats university phone numbers for the platform dialer.
+/// Local Murmansk PBX numbers like "21-38-81 (3045)" become "+78152213881;ext=3045".
+///
+public static partial class PhoneNumberFormatting
+{
+ public const string MurmanskAreaCode = "8152";
+
+ public static string? ToDialString(string? raw)
+ {
+ if (string.IsNullOrWhiteSpace(raw))
+ return null;
+
+ var working = raw.Trim();
+ string? extension = null;
+
+ var paren = TrailingParenExtension().Match(working);
+ if (paren.Success)
+ {
+ extension = paren.Groups[1].Value;
+ working = working[..paren.Index].TrimEnd();
+ }
+ else
+ {
+ var labeled = TrailingLabeledExtension().Match(working);
+ if (labeled.Success)
+ {
+ extension = labeled.Groups[1].Value;
+ working = working[..labeled.Index].TrimEnd();
+ }
+ }
+
+ var digits = new string(working.Where(char.IsDigit).ToArray());
+ if (digits.Length == 0)
+ return null;
+
+ string? e164 = digits.Length switch
+ {
+ 6 => $"+7{MurmanskAreaCode}{digits}",
+ 10 when digits.StartsWith(MurmanskAreaCode, StringComparison.Ordinal)
+ || digits.StartsWith("800", StringComparison.Ordinal)
+ => $"+7{digits}",
+ 11 when digits.StartsWith('8') => $"+7{digits[1..]}",
+ 11 when digits.StartsWith('7') => $"+{digits}",
+ >= 10 when digits.StartsWith('7') => $"+{digits}",
+ >= 10 => $"+7{digits}",
+ _ => null
+ };
+
+ if (e164 is null)
+ return null;
+
+ return string.IsNullOrEmpty(extension) ? e164 : $"{e164};ext={extension}";
+ }
+
+ [GeneratedRegex(@"\((\d{2,6})\)\s*$", RegexOptions.CultureInvariant)]
+ private static partial Regex TrailingParenExtension();
+
+ [GeneratedRegex(@"(?i)(?:доб\.?|ext\.?|extension)\s*(\d{2,6})\s*$", RegexOptions.CultureInvariant)]
+ private static partial Regex TrailingLabeledExtension();
+}
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/DetailTelephoneViewModel.cs b/mauverse_mobile/ViewModel/Dialogs/DetailTelephoneViewModel.cs
index c098ace..5b672e0 100644
--- a/mauverse_mobile/ViewModel/Dialogs/DetailTelephoneViewModel.cs
+++ b/mauverse_mobile/ViewModel/Dialogs/DetailTelephoneViewModel.cs
@@ -6,13 +6,6 @@
using mau.Utils;
using mau.Utils.API.Interaface;
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Text;
-using System.Threading.Tasks;
-using System.Windows.Input;
-
namespace mau.ViewModel.Dialogs
{
public partial class DetailTelephoneViewModel : BaseViewModel
@@ -53,5 +46,33 @@ async Task GetPhone(DeptInfoDTO dept)
IsBusy = false;
}
}
+
+ [RelayCommand]
+ [System.Diagnostics.CodeAnalysis.SuppressMessage(
+ "Performance",
+ "CA1822:Mark members as static",
+ Justification = "RelayCommand generation requires the instance command contract used by XAML.")]
+ async Task CallAsync(string? rawPhone)
+ {
+ if (string.IsNullOrWhiteSpace(rawPhone))
+ return;
+
+ try
+ {
+ var dial = PhoneNumberFormatting.ToDialString(rawPhone);
+ if (string.IsNullOrWhiteSpace(dial))
+ {
+ await AppShell.DisplaySnackbarAsync("Некорректный номер телефона");
+ return;
+ }
+
+ PhoneDialer.Default.Open(dial);
+ }
+ catch (Exception ex)
+ {
+ System.Diagnostics.Debug.WriteLine(ex);
+ await AppShell.DisplaySnackbarAsync("Не удалось открыть телефон");
+ }
+ }
}
}
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/UniversityContactsViewModel.cs b/mauverse_mobile/ViewModel/Services/UniversityContactsViewModel.cs
index 5175b90..f817cb1 100644
--- a/mauverse_mobile/ViewModel/Services/UniversityContactsViewModel.cs
+++ b/mauverse_mobile/ViewModel/Services/UniversityContactsViewModel.cs
@@ -37,11 +37,14 @@ async Task CallAsync(UniversityContactBlock? block)
try
{
- var digits = new string(block.Phone.Where(c => char.IsDigit(c) || c == '+').ToArray());
- if (digits.StartsWith('8') && digits.Length >= 11)
- digits = "+7" + digits[1..];
+ var dial = PhoneNumberFormatting.ToDialString(block.Phone);
+ if (string.IsNullOrWhiteSpace(dial))
+ {
+ await AppShell.DisplaySnackbarAsync("Некорректный номер телефона");
+ return;
+ }
- PhoneDialer.Default.Open(digits);
+ PhoneDialer.Default.Open(dial);
}
catch (Exception ex)
{
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 e91cdf5..cf2a0ea 100644
--- a/mauverse_mobile/mau.csproj
+++ b/mauverse_mobile/mau.csproj
@@ -16,8 +16,8 @@
MAUverse
com.pmi4freal.mauverse3
- 1.12.0
- 29
+ 1.12.4
+ 33
https://app.mauniver.ru/dev/mauverse/
None
@@ -77,4 +77,6 @@
+
+