diff --git a/MauverseIOS/Sources/Core/Models.swift b/MauverseIOS/Sources/Core/Models.swift index 98a071c..1aeaacc 100644 --- a/MauverseIOS/Sources/Core/Models.swift +++ b/MauverseIOS/Sources/Core/Models.swift @@ -493,7 +493,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 +515,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..1a374e5 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.1 (iOS)", forHTTPHeaderField: "User-Agent") request.setValue("application/rss+xml, application/xml, text/xml", forHTTPHeaderField: "Accept") let (data, response) = try await URLSession.shared.data(for: request) diff --git a/MauverseIOS/Sources/Core/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/InAppBrowserView.swift b/MauverseIOS/Sources/Views/InAppBrowserView.swift index b5c0fd5..db5a850 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.1" configuration.defaultWebpagePreferences.allowsContentJavaScript = true configuration.allowsInlineMediaPlayback = true webView = WKWebView(frame: .zero, configuration: configuration) 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..119fcda 100644 --- a/MauverseIOS/Sources/Views/ProfileView.swift +++ b/MauverseIOS/Sources/Views/ProfileView.swift @@ -73,7 +73,7 @@ struct ProfileView: View { .buttonStyle(.plain) .mauSurface() - Text("MAUverse 1.11.0 (28)") + Text("MAUverse 1.12.1 (30)") .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.1 • сборка 30").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/ScheduleView.swift b/MauverseIOS/Sources/Views/ScheduleView.swift index d882a29..f75a31f 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 ?? "") } } @@ -128,6 +170,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 @@ -197,6 +282,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..9505a7c 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() } } } @@ -547,79 +600,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] = [] diff --git a/MauverseIOS/Sources/Views/UniversityContactsView.swift b/MauverseIOS/Sources/Views/UniversityContactsView.swift new file mode 100644 index 0000000..0c7cb6b --- /dev/null +++ b/MauverseIOS/Sources/Views/UniversityContactsView.swift @@ -0,0 +1,125 @@ +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, let url = phoneURL(phone) { + Link(destination: url) { + Label("Позвонить", systemImage: "phone.fill") + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + } + Button { + copyText(block.details, label: "Скопировано") + } label: { + Label("Копировать", systemImage: "doc.on.doc") + } + .buttonStyle(.bordered) + .controlSize(.small) + } + + if let email = block.email { + Button { + copyText(email, label: "E-mail скопирован") + } label: { + Label(email, systemImage: "envelope") + .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? { + let digits = phone.filter { $0.isNumber || $0 == "+" } + return URL(string: "tel:\(digits)") + } + + 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..6cf9924 100644 --- a/MauverseIOS/project.yml +++ b/MauverseIOS/project.yml @@ -28,8 +28,8 @@ 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.1 + CURRENT_PROJECT_VERSION: 30 MAUVERSE_SCHEDULE_TOKEN: "" TARGETED_DEVICE_FAMILY: "1" SUPPORTS_MACCATALYST: NO 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.1.md b/docs/RELEASE_1.12.1.md new file mode 100644 index 0000000..bb576b4 --- /dev/null +++ b/docs/RELEASE_1.12.1.md @@ -0,0 +1,32 @@ +# MAUverce 1.12.1 release record + +Built: 2026-08-02 + +Classification: engineering release candidate for native iOS portal parity with +Android 1.12.0 surfaces. + +## 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` +- Branch: `cursor/ios-portal-parity-010d` + +## 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 + +- Hosted `Native SwiftUI iPhone Release (unsigned)` must be green on the release commit. +- Production Apple signing and device smoke tests remain open gates.