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) }), + (#"&#x([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..4b7c422 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.3 (iOS)", forHTTPHeaderField: "User-Agent") request.setValue("application/rss+xml, application/xml, text/xml", forHTTPHeaderField: "Accept") let (data, response) = try await URLSession.shared.data(for: request) @@ -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/Views/CampusNavigatorView.swift b/MauverseIOS/Sources/Views/CampusNavigatorView.swift new file mode 100644 index 0000000..f18fdb6 --- /dev/null +++ b/MauverseIOS/Sources/Views/CampusNavigatorView.swift @@ -0,0 +1,131 @@ +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) + .textFieldStyle(.roundedBorder) + + 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..d5ebdcc --- /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(description) + .font(.caption) + .foregroundStyle(MauTheme.muted) + .lineLimit(3) + .multilineTextAlignment(.leading) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(16) + .mauSurface(radius: 18) + } + .buttonStyle(.plain) + } + } +} diff --git a/MauverseIOS/Sources/Views/HomeView.swift b/MauverseIOS/Sources/Views/HomeView.swift index 3135dad..4a7380f 100644 --- a/MauverseIOS/Sources/Views/HomeView.swift +++ b/MauverseIOS/Sources/Views/HomeView.swift @@ -85,6 +85,7 @@ final class HomeViewModel: ObservableObject { struct HomeView: View { @EnvironmentObject private var session: SessionStore + @Binding var selectedTab: Int @StateObject private var model = HomeViewModel() private var firstName: String { @@ -106,17 +107,21 @@ struct HomeView: View { HomeDataErrorCard( title: "Расписание недоступно", message: error, - icon: "calendar.badge.exclamationmark" + icon: "calendar.badge.exclamationmark", + retry: { Task { await model.load(user: session.user) } } ) } else { - NextLessonCard(item: model.nextLesson, group: session.user?.groupName) + Button { selectedTab = 1 } label: { + NextLessonCard(item: model.nextLesson, group: session.user?.groupName) + } + .buttonStyle(.plain) } MauSectionHeader(title: "Быстрый доступ") quickActions if !model.notifications.isEmpty { - MauSectionHeader(title: "Обновления ЭИОС") + MauSectionHeader(title: "Уведомления портала") ScrollView(.horizontal, showsIndicators: false) { LazyHStack(spacing: 12) { ForEach(model.notifications) { notification in @@ -132,7 +137,7 @@ struct HomeView: View { .font(.title3.bold()) .foregroundStyle(MauTheme.ink) Spacer() - NavigationLink(destination: NewsView()) { + Button { selectedTab = 3 } label: { HStack(spacing: 4) { Text("Все новости") Image(systemName: "chevron.right") @@ -147,7 +152,8 @@ struct HomeView: View { HomeDataErrorCard( title: "Новости не обновились", message: error, - icon: "newspaper" + icon: "newspaper", + retry: { Task { await model.load(user: session.user) } } ) } } @@ -171,7 +177,7 @@ struct HomeView: View { .font(.system(size: 38, weight: .bold, design: .rounded)) .foregroundStyle(MauTheme.ink) HStack(spacing: 8) { - MauStatusPill(title: "Сессия защищена", icon: "lock.fill") + MauStatusPill(title: "Вход сохранён", icon: "lock.fill") if let group = session.user?.groupName, !group.isEmpty { Text(group) .font(.caption.weight(.semibold)) @@ -181,7 +187,7 @@ struct HomeView: View { } } Spacer() - NavigationLink(destination: ProfileView()) { + Button { selectedTab = 4 } label: { ZStack { Circle().fill(MauTheme.blue.opacity(0.13)) Image(systemName: "person.crop.circle.fill") @@ -226,17 +232,25 @@ struct HomeView: View { spacing: 12 ) { PremiumQuickCard(title: "Расписание", subtitle: "Занятия и аудитории", icon: "calendar", color: MauTheme.blue) { - ScheduleView() + selectedTab = 1 } PremiumQuickCard(title: "Сервисы", subtitle: "Все инструменты МАУ", icon: "square.grid.2x2.fill", color: MauTheme.violet) { - ServicesView() + selectedTab = 2 } PremiumQuickCard(title: "Новости", subtitle: "События университета", icon: "newspaper.fill", color: .orange) { - NewsView() + selectedTab = 3 } - PremiumQuickCard(title: "ЭИОС", subtitle: "Учебный портал", icon: "graduationcap.fill", color: .teal) { - InAppBrowserView(url: URL(string: "https://eios.mauniver.ru/moodle/")!, title: "ЭИОС") + NavigationLink { + InAppBrowserView(url: URL(string: "https://eios.mauniver.ru/moodle/")!, title: "Учебный портал") + } label: { + PremiumQuickCardContent( + title: "Учёба", + subtitle: "Учебный портал", + icon: "graduationcap.fill", + color: .teal + ) } + .buttonStyle(.plain) } } @@ -298,18 +312,26 @@ private struct HomeDataErrorCard: View { let title: String let message: String let icon: String + var retry: (() -> Void)? var body: some View { - HStack(spacing: 14) { - IconTile(systemName: icon, color: .orange) - VStack(alignment: .leading, spacing: 4) { - Text(title).font(.headline) - Text(message) - .font(.caption) - .foregroundStyle(MauTheme.muted) - .lineLimit(2) + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 14) { + IconTile(systemName: icon, color: .orange) + VStack(alignment: .leading, spacing: 4) { + Text(title).font(.headline) + Text(message) + .font(.caption) + .foregroundStyle(MauTheme.muted) + .lineLimit(3) + } + Spacer() + } + if let retry { + Button("Повторить", action: retry) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(MauTheme.blue) } - Spacer() } .padding(17) .mauSurface(radius: 22) @@ -377,36 +399,47 @@ private struct NextLessonCard: View { } } -private struct PremiumQuickCard: View { +private struct PremiumQuickCard: View { let title: String let subtitle: String let icon: String let color: Color - @ViewBuilder let destination: () -> Destination + let action: () -> Void var body: some View { - NavigationLink(destination: destination()) { - VStack(alignment: .leading, spacing: 15) { - HStack { - IconTile(systemName: icon, color: color) - Spacer() - Image(systemName: "arrow.up.right") - .font(.caption.bold()) - .foregroundStyle(MauTheme.muted) - } - VStack(alignment: .leading, spacing: 3) { - Text(title).font(.headline) - Text(subtitle) - .font(.caption) - .foregroundStyle(MauTheme.muted) - .lineLimit(1) - } - } - .foregroundStyle(MauTheme.ink) - .frame(maxWidth: .infinity, minHeight: 105, alignment: .topLeading) - .padding(16) + Button(action: action) { + PremiumQuickCardContent(title: title, subtitle: subtitle, icon: icon, color: color) } .buttonStyle(.plain) + } +} + +private struct PremiumQuickCardContent: View { + let title: String + let subtitle: String + let icon: String + let color: Color + + var body: some View { + VStack(alignment: .leading, spacing: 15) { + HStack { + IconTile(systemName: icon, color: color) + Spacer() + Image(systemName: "arrow.up.right") + .font(.caption.bold()) + .foregroundStyle(MauTheme.muted) + } + VStack(alignment: .leading, spacing: 3) { + Text(title).font(.headline) + Text(subtitle) + .font(.caption) + .foregroundStyle(MauTheme.muted) + .lineLimit(1) + } + } + .foregroundStyle(MauTheme.ink) + .frame(maxWidth: .infinity, minHeight: 105, alignment: .topLeading) + .padding(16) .mauSurface(radius: 22) } } diff --git a/MauverseIOS/Sources/Views/InAppBrowserView.swift b/MauverseIOS/Sources/Views/InAppBrowserView.swift index b5c0fd5..8eee4fc 100644 --- a/MauverseIOS/Sources/Views/InAppBrowserView.swift +++ b/MauverseIOS/Sources/Views/InAppBrowserView.swift @@ -23,7 +23,7 @@ final class InAppBrowserController: NSObject, ObservableObject, WKNavigationDele let configuration = WKWebViewConfiguration() configuration.websiteDataStore = .default() - configuration.applicationNameForUserAgent = "MAUverse/1.11.0" + configuration.applicationNameForUserAgent = "MAUverse/1.12.3" configuration.defaultWebpagePreferences.allowsContentJavaScript = true configuration.allowsInlineMediaPlayback = true webView = WKWebView(frame: .zero, configuration: configuration) diff --git a/MauverseIOS/Sources/Views/LoginView.swift b/MauverseIOS/Sources/Views/LoginView.swift index 4bb6b3a..ec7badc 100644 --- a/MauverseIOS/Sources/Views/LoginView.swift +++ b/MauverseIOS/Sources/Views/LoginView.swift @@ -38,7 +38,7 @@ struct LoginView: View { } VStack(spacing: 16) { - TextField("Логин ЭИОС", text: $username) + TextField("Логин учебного портала", text: $username) .textContentType(.username) .textInputAutocapitalization(.never) .autocorrectionDisabled() diff --git a/MauverseIOS/Sources/Views/NewsView.swift b/MauverseIOS/Sources/Views/NewsView.swift index 52f0f37..e4bb48e 100644 --- a/MauverseIOS/Sources/Views/NewsView.swift +++ b/MauverseIOS/Sources/Views/NewsView.swift @@ -231,7 +231,7 @@ private struct HeroNewsCard: View { .lineLimit(4) .fixedSize(horizontal: false, vertical: true) if let description = item.description { - Text(description.strippingHTML) + Text(description.cleanedForDisplay) .font(.system(size: 13, weight: .regular)) .foregroundStyle(MauTheme.muted) .lineLimit(2) @@ -252,9 +252,7 @@ private struct HeroNewsCard: View { } private extension String { - var strippingHTML: String { - replacingOccurrences(of: "<[^>]+>", with: "", options: .regularExpression) - .replacingOccurrences(of: " ", with: " ") - .replacingOccurrences(of: "&", with: "&") + var cleanedForDisplay: String { + HTMLTextCleaning.cleanRSS(self) } } diff --git a/MauverseIOS/Sources/Views/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..58f0de2 100644 --- a/MauverseIOS/Sources/Views/ProfileView.swift +++ b/MauverseIOS/Sources/Views/ProfileView.swift @@ -28,7 +28,7 @@ struct ProfileView: View { .font(.subheadline) .foregroundStyle(.white.opacity(0.72)) HStack(spacing: 8) { - Label("Сессия в Keychain", systemImage: "lock.fill") + Label("Вход сохранён", systemImage: "lock.fill") if let group = session.user?.groupName, !group.isEmpty { Text("•") Text(group) @@ -73,7 +73,7 @@ struct ProfileView: View { .buttonStyle(.plain) .mauSurface() - Text("MAUverse 1.11.0 (28)") + Text("MAUverse 1.12.3 (32)") .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.3 • сборка 32").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..d60c0cd 100644 --- a/MauverseIOS/Sources/Views/RootTabView.swift +++ b/MauverseIOS/Sources/Views/RootTabView.swift @@ -1,20 +1,26 @@ import SwiftUI struct RootTabView: View { + @State private var selectedTab = 0 + var body: some View { - TabView { - NavigationStack { HomeView() } + TabView(selection: $selectedTab) { + NavigationStack { HomeView(selectedTab: $selectedTab) } .tabItem { Label("Главная", systemImage: "house.fill") } + .tag(0) NavigationStack { ScheduleView() } .tabItem { Label("Расписание", systemImage: "calendar") } + .tag(1) NavigationStack { ServicesView() } .tabItem { Label("Сервисы", systemImage: "square.grid.2x2.fill") } + .tag(2) NavigationStack { NewsView() } .tabItem { Label("Новости", systemImage: "newspaper.fill") } + .tag(3) NavigationStack { ProfileView() } .tabItem { Label("Профиль", systemImage: "person.crop.circle.fill") } + .tag(4) } .tint(MauTheme.blue) } } - diff --git a/MauverseIOS/Sources/Views/ScheduleView.swift b/MauverseIOS/Sources/Views/ScheduleView.swift index d882a29..41ac7a9 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,6 +178,49 @@ 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 @@ -180,9 +273,9 @@ private struct DateChip: View { var body: some View { VStack(spacing: 5) { - Text(date.formatted(.dateTime.weekday(.abbreviated)).uppercased()) + Text(date.formatted(.dateTime.weekday(.abbreviated).locale(Locale(identifier: "ru_RU"))).uppercased()) .font(.caption2.weight(.bold)) - Text(date.formatted(.dateTime.day())) + Text(date.formatted(.dateTime.day().locale(Locale(identifier: "ru_RU")))) .font(.title3.bold()) } .foregroundStyle(selected ? .white : MauTheme.ink) @@ -197,6 +290,28 @@ private struct DateChip: View { } } +private struct FilterChip: View { + let title: String + let selected: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + Text(title) + .font(.caption.weight(.semibold)) + .foregroundStyle(selected ? .white : MauTheme.ink) + .lineLimit(1) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background( + selected ? AnyShapeStyle(MauTheme.blue.gradient) : AnyShapeStyle(MauTheme.card.opacity(0.85)), + in: Capsule() + ) + } + .buttonStyle(.plain) + } +} + private struct LessonCard: View { let item: ScheduleItem private var kind: LessonKind { LessonKind(value: item.pairType) } diff --git a/MauverseIOS/Sources/Views/ServicesView.swift b/MauverseIOS/Sources/Views/ServicesView.swift index 48a5488..0b22923 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) } @@ -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..ae9c0ef --- /dev/null +++ b/MauverseIOS/Sources/Views/UniversityContactsView.swift @@ -0,0 +1,138 @@ +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()) + .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..e7460e2 100644 --- a/MauverseIOS/project.yml +++ b/MauverseIOS/project.yml @@ -28,9 +28,10 @@ targets: INFOPLIST_FILE: Resources/Info.plist GENERATE_INFOPLIST_FILE: NO ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon - MARKETING_VERSION: 1.12.0 - CURRENT_PROJECT_VERSION: 29 + MARKETING_VERSION: 1.12.3 + CURRENT_PROJECT_VERSION: 32 MAUVERSE_SCHEDULE_TOKEN: "" + MAUVERSE_API_BASE_URL: "https://app.mauniver.ru/dev/mauverse/" TARGETED_DEVICE_FAMILY: "1" SUPPORTS_MACCATALYST: NO scheme: diff --git a/docs/ANDROID_SIGNING.md b/docs/ANDROID_SIGNING.md new file mode 100644 index 0000000..36d9f50 --- /dev/null +++ b/docs/ANDROID_SIGNING.md @@ -0,0 +1,34 @@ +# Android signing (engineering) + +Production Play keystores stay out of the repository (see `docs/CI_AND_RELEASE.md`). +For local sideload / engineering Release builds: + +1. Generate a keystore (already done under `.artifacts/signing/` in cloud agents; regenerate locally if needed): + +```bash +keytool -genkeypair -v \ + -keystore .artifacts/signing/mauverse-engineering.keystore \ + -alias mauverse \ + -keyalg RSA -keysize 2048 -validity 10000 \ + -storepass mauverse-eng \ + -keypass mauverse-eng \ + -dname "CN=MAUverse Engineering, OU=MAUverse, O=decoder-dev, L=Murmansk, ST=Murmansk, C=RU" +``` + +2. Copy `mauverse_mobile/Directory.Build.signing.props.example` → `mauverse_mobile/Directory.Build.signing.props` (gitignored). + +3. Build: + +```bash +cd mauverse_mobile +dotnet build mau.csproj -c Release -f net10.0-android -p:AndroidPackageFormats=aab +``` + +4. Verify signer is not missing: + +```bash +jarsigner -verify -verbose -certs \ + bin/Release/net10.0-android/android-arm64/com.pmi4freal.mauverse3-Signed.aab | head +``` + +Engineering cert `CN=MAUverse Engineering` is for sideload only — not for Play Store. diff --git a/docs/IOS_LIQUID_GLASS.md b/docs/IOS_LIQUID_GLASS.md index aca33fe..a7b52fc 100644 --- a/docs/IOS_LIQUID_GLASS.md +++ b/docs/IOS_LIQUID_GLASS.md @@ -47,11 +47,24 @@ contains `_CodeSignature` or `embedded.mobileprovision`. Sign the extracted application with your own matching certificate, entitlements, and provisioning profile, then recreate the IPA. +## Portal parity with Android + +`MauverseIOS` mirrors the Android portal surfaces from mauniver.ru: + +- Guides: Студенту, Абитуриенту, Наука, International +- Digital services hub (ЭИОС, library, webmail, dorm IT, Intra, PROMT) +- Events calendar from `/press/calendar/rss/` +- News filters Абитуриент and Календарь +- Contacts and payment requisites +- Campus navigator with branches, transport tips, route and panorama +- Settings links to official site, personal-data policy, and sveden +- Schedule teacher/room chips (mutually exclusive) + ## 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. +3. Service routes, portal guides, calendar, contacts, in-app browser, messenger, and forms. 4. Light and dark appearance. 5. iOS Reduce Transparency and Increase Contrast. 6. Dynamic Type and VoiceOver labels. 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/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_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}" />