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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion MauverseIOS/Sources/Core/Models.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -505,6 +515,8 @@ enum NewsFilter: Int, CaseIterable, Identifiable {
case .international: "Международное"
case .events: "События"
case .other: "Другое"
case .applicant: "Абитуриент"
case .calendar: "Календарь"
}
}
}
Expand Down
4 changes: 3 additions & 1 deletion MauverseIOS/Sources/Core/OfficialNewsService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down
362 changes: 362 additions & 0 deletions MauverseIOS/Sources/Core/UniversityPortalCatalog.swift

Large diffs are not rendered by default.

131 changes: 131 additions & 0 deletions MauverseIOS/Sources/Views/CampusNavigatorView.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
126 changes: 126 additions & 0 deletions MauverseIOS/Sources/Views/EventsCalendarView.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
2 changes: 1 addition & 1 deletion MauverseIOS/Sources/Views/InAppBrowserView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
81 changes: 81 additions & 0 deletions MauverseIOS/Sources/Views/PortalGuideView.swift
Original file line number Diff line number Diff line change
@@ -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
)
}
}
Loading
Loading