diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index a5d112b..a2b42a3 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -123,7 +123,7 @@ jobs:
java-version: "17"
- name: Restore NuGet cache
- uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5
+ uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ${{ env.NUGET_PACKAGES }}
key: ${{ runner.os }}-nuget-${{ hashFiles('mauverse_mobile/global.json', 'mauverse_mobile/mau.csproj', 'mauverse_mobile.Tests/mauverse_mobile.Tests.csproj') }}
@@ -177,23 +177,15 @@ jobs:
compression-level: 0
ios-release-unsigned:
- name: iPhone Release configuration (unsigned)
+ name: Native SwiftUI iPhone Release (unsigned)
runs-on: macos-26
- timeout-minutes: 60
+ timeout-minutes: 35
env:
- NUGET_PACKAGES: ${{ github.workspace }}/.nuget/packages
- defaults:
- run:
- working-directory: mauverse_mobile
+ MAUVERSE_SCHEDULE_TOKEN: ${{ secrets.MAUVERSE_SCHEDULE_TOKEN }}
steps:
- name: Check out sources
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- - name: Set up .NET SDK from global.json
- uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5
- with:
- global-json-file: mauverse_mobile/global.json
-
- name: Select Xcode 26.5
shell: bash
run: |
@@ -202,56 +194,48 @@ jobs:
test -d "$developer_dir"
sudo xcode-select --switch "$developer_dir"
- - name: Report Apple toolchain
+ - name: Install project generator
+ run: brew install xcodegen
+
+ - name: Generate native Xcode project
+ working-directory: MauverseIOS
+ run: xcodegen generate --spec project.yml
+
+ - name: Report Apple toolchain and project
shell: bash
run: |
set -euo pipefail
xcodebuild -version
xcrun --sdk iphoneos --show-sdk-version
- dotnet --info
+ xcodegen --version
+ xcodebuild -project MauverseIOS/MAUverse.xcodeproj -list
- - name: Restore NuGet cache
- uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5
- with:
- path: ${{ env.NUGET_PACKAGES }}
- key: ${{ runner.os }}-ios-nuget-${{ hashFiles('mauverse_mobile/global.json', 'mauverse_mobile/mau.csproj') }}
- restore-keys: |
- ${{ runner.os }}-ios-nuget-
- ${{ runner.os }}-nuget-
-
- - name: Install MAUI iOS workload
- run: dotnet workload install maui-ios --version 10.0.300.3
-
- - name: Restore iPhone target
- run: >-
- dotnet restore mau.csproj
- -p:TargetFramework=net10.0-ios
- -p:RuntimeIdentifier=ios-arm64
-
- - name: Publish unsigned iPhone application
- run: >-
- dotnet publish mau.csproj
- --configuration Release
- --framework net10.0-ios
- --runtime ios-arm64
- --no-restore
- --warnaserror
- -p:ArchiveOnBuild=false
- -p:BuildIpa=false
- -p:CodesignRequireProvisioningProfile=false
- -p:ContinuousIntegrationBuild=true
- -p:CreatePackage=false
- -p:EnableCodeSigning=false
- -p:EnableDefaultCodesignEntitlements=false
- -maxcpucount:1
+ - name: Build native iPhone application without signing
+ shell: bash
+ run: |
+ set -euo pipefail
+ test -n "$MAUVERSE_SCHEDULE_TOKEN"
+ xcodebuild \
+ -project MauverseIOS/MAUverse.xcodeproj \
+ -scheme MAUverse \
+ -configuration Release \
+ -sdk iphoneos \
+ -destination 'generic/platform=iOS' \
+ -derivedDataPath "$PWD/build/DerivedData" \
+ CODE_SIGNING_ALLOWED=NO \
+ CODE_SIGNING_REQUIRED=NO \
+ CODE_SIGN_IDENTITY="" \
+ DEVELOPMENT_TEAM="" \
+ MAUVERSE_SCHEDULE_TOKEN="$MAUVERSE_SCHEDULE_TOKEN" \
+ clean build
- name: Verify and package unsigned application
shell: bash
run: |
set -euo pipefail
- publish_dir="bin/Release/net10.0-ios/ios-arm64/publish"
- app_path="$(find "$publish_dir" -maxdepth 1 -type d -name '*.app' -print -quit)"
+ products_dir="build/DerivedData/Build/Products/Release-iphoneos"
+ app_path="$(find "$products_dir" -maxdepth 1 -type d -name '*.app' -print -quit)"
test -n "$app_path"
if codesign --verify "$app_path" >/dev/null 2>&1; then
@@ -267,39 +251,39 @@ jobs:
exit 1
fi
- artifact_dir="bin/Release/net10.0-ios/unsigned-artifact"
+ artifact_dir="build/unsigned-artifact"
payload_dir="$artifact_dir/Payload"
mkdir -p "$payload_dir"
ditto "$app_path" "$payload_dir/$(basename "$app_path")"
(
cd "$artifact_dir"
- /usr/bin/zip -qry "../mauverce-ios-arm64-unsigned.ipa" Payload
+ /usr/bin/zip -qry "../mauverse-native-ios-arm64-unsigned.ipa" Payload
)
{
echo "commit=${GITHUB_SHA}"
- echo "target=net10.0-ios"
+ echo "target=native-swiftui"
echo "runtime=ios-arm64"
echo "signed=false"
echo "provisioning_profile=false"
echo "xcode=$(xcodebuild -version | tr '\n' ' ')"
echo "iphoneos_sdk=$(xcrun --sdk iphoneos --show-sdk-version)"
- } > "bin/Release/net10.0-ios/build-metadata.txt"
+ } > "build/build-metadata.txt"
(
- cd bin/Release/net10.0-ios
- shasum -a 256 mauverce-ios-arm64-unsigned.ipa > checksums.txt
+ cd build
+ shasum -a 256 mauverse-native-ios-arm64-unsigned.ipa > checksums.txt
)
- name: Upload unsigned iPhone artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
- name: mauverce-ios-arm64-release-unsigned
+ name: mauverse-native-ios-arm64-release-unsigned
path: |
- mauverse_mobile/bin/Release/net10.0-ios/mauverce-ios-arm64-unsigned.ipa
- mauverse_mobile/bin/Release/net10.0-ios/checksums.txt
- mauverse_mobile/bin/Release/net10.0-ios/build-metadata.txt
+ build/mauverse-native-ios-arm64-unsigned.ipa
+ build/checksums.txt
+ build/build-metadata.txt
if-no-files-found: error
retention-days: 14
compression-level: 0
diff --git a/.gitignore b/.gitignore
index 5af0dda..37df0d1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -34,3 +34,11 @@
*.log
.DS_Store
Thumbs.db
+
+# Native iOS (XcodeGen output and local builds)
+MauverseIOS/*.xcodeproj/
+MauverseIOS/*.xcworkspace/
+build/
+**/DerivedData/
+*.xcuserstate
+xcuserdata/
diff --git a/MauverseIOS/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon.png b/MauverseIOS/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon.png
new file mode 100644
index 0000000..f5e3fe5
Binary files /dev/null and b/MauverseIOS/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon.png differ
diff --git a/MauverseIOS/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/MauverseIOS/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json
new file mode 100644
index 0000000..513af2b
--- /dev/null
+++ b/MauverseIOS/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -0,0 +1,15 @@
+{
+ "images": [
+ {
+ "filename": "AppIcon.png",
+ "idiom": "universal",
+ "platform": "ios",
+ "size": "1024x1024"
+ }
+ ],
+ "info": {
+ "author": "xcode",
+ "version": 1
+ }
+}
+
diff --git a/MauverseIOS/Resources/Assets.xcassets/Contents.json b/MauverseIOS/Resources/Assets.xcassets/Contents.json
new file mode 100644
index 0000000..a1304aa
--- /dev/null
+++ b/MauverseIOS/Resources/Assets.xcassets/Contents.json
@@ -0,0 +1,7 @@
+{
+ "info": {
+ "author": "xcode",
+ "version": 1
+ }
+}
+
diff --git a/MauverseIOS/Resources/Info.plist b/MauverseIOS/Resources/Info.plist
new file mode 100644
index 0000000..06197e4
--- /dev/null
+++ b/MauverseIOS/Resources/Info.plist
@@ -0,0 +1,34 @@
+
+
+
+
+ CFBundleDevelopmentRegion
+ ru
+ CFBundleDisplayName
+ MAUverse
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ $(PRODUCT_NAME)
+ CFBundlePackageType
+ $(PRODUCT_BUNDLE_PACKAGE_TYPE)
+ CFBundleShortVersionString
+ $(MARKETING_VERSION)
+ CFBundleVersion
+ $(CURRENT_PROJECT_VERSION)
+ MAUverseScheduleToken
+ $(MAUVERSE_SCHEDULE_TOKEN)
+ UILaunchScreen
+
+ UISupportedInterfaceOrientations
+
+ UIInterfaceOrientationPortrait
+
+ UIApplicationSupportsIndirectInputEvents
+
+
+
diff --git a/MauverseIOS/Resources/PrivacyInfo.xcprivacy b/MauverseIOS/Resources/PrivacyInfo.xcprivacy
new file mode 100644
index 0000000..dfaa951
--- /dev/null
+++ b/MauverseIOS/Resources/PrivacyInfo.xcprivacy
@@ -0,0 +1,13 @@
+
+
+
+
+ NSPrivacyAccessedAPITypes
+
+ NSPrivacyCollectedDataTypes
+
+ NSPrivacyTracking
+
+
+
+
diff --git a/MauverseIOS/Sources/Core/APIClient.swift b/MauverseIOS/Sources/Core/APIClient.swift
new file mode 100644
index 0000000..b3eefd6
--- /dev/null
+++ b/MauverseIOS/Sources/Core/APIClient.swift
@@ -0,0 +1,390 @@
+import Foundation
+
+enum APIError: LocalizedError {
+ case invalidResponse
+ case server(String)
+ case network(String)
+
+ var errorDescription: String? {
+ return switch self {
+ case .invalidResponse: "Сервер вернул некорректный ответ"
+ case .server(let message): message
+ case .network(let message): message
+ }
+ }
+}
+
+extension Notification.Name {
+ static let mauverseSessionExpired = Notification.Name("mauverse.session.expired")
+}
+
+final class APIClient {
+ static let shared = APIClient()
+ private let baseURL = URL(string: "https://app.mauniver.ru/dev/mauverse/")!
+ private let session: URLSession
+
+ private init() {
+ let configuration = URLSessionConfiguration.default
+ configuration.timeoutIntervalForRequest = 30
+ configuration.waitsForConnectivity = true
+ session = URLSession(configuration: configuration)
+ }
+
+ func get(
+ _ path: String,
+ query: [URLQueryItem] = [],
+ user: UserDTO? = nil
+ ) async throws -> Response {
+ var components = URLComponents(url: baseURL.appendingPathComponent(path), resolvingAgainstBaseURL: false)!
+ components.queryItems = query.isEmpty ? nil : query
+ var request = URLRequest(url: components.url!)
+ request.httpMethod = "GET"
+ addHeaders(to: &request, user: user)
+ return try await execute(request, retryOnTransient: true)
+ }
+
+ func post(
+ _ path: String,
+ body: Body,
+ user: UserDTO? = nil,
+ retryOnTransient: Bool = false
+ ) async throws -> Response {
+ var request = URLRequest(url: baseURL.appendingPathComponent(path))
+ request.httpMethod = "POST"
+ let encoder = JSONEncoder()
+ encoder.keyEncodingStrategy = .convertToSnakeCase
+ request.httpBody = try encoder.encode(body)
+ addHeaders(to: &request, user: user)
+ return try await execute(request, retryOnTransient: retryOnTransient)
+ }
+
+ func post(
+ _ path: String,
+ user: UserDTO? = nil,
+ retryOnTransient: Bool = false
+ ) async throws -> Response {
+ var request = URLRequest(url: baseURL.appendingPathComponent(path))
+ request.httpMethod = "POST"
+ addHeaders(to: &request, user: user)
+ return try await execute(request, retryOnTransient: retryOnTransient)
+ }
+
+ private func addHeaders(to request: inout URLRequest, user: UserDTO?) {
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ request.setValue("application/json", forHTTPHeaderField: "Accept")
+ if let token = user?.token ?? user?.privateToken {
+ request.setValue(token, forHTTPHeaderField: "X-Auth-Token")
+ }
+ if let username = user?.username {
+ request.setValue(username, forHTTPHeaderField: "X-Auth-Username")
+ }
+ }
+
+ private func execute(
+ _ request: URLRequest,
+ retryOnTransient: Bool
+ ) async throws -> Response {
+ let data: Data
+ let response: URLResponse
+ do {
+ (data, response) = try await performRequest(
+ request,
+ retryOnTransient: retryOnTransient
+ )
+ } catch let error as URLError {
+ throw APIError.network(Self.networkMessage(for: error))
+ }
+ guard let http = response as? HTTPURLResponse else { throw APIError.invalidResponse }
+ if http.statusCode == 401 {
+ if request.value(forHTTPHeaderField: "X-Auth-Token") != nil {
+ NotificationCenter.default.post(name: .mauverseSessionExpired, object: nil)
+ throw APIError.server("Сессия истекла. Войдите в аккаунт повторно")
+ }
+ }
+ guard (200..<300).contains(http.statusCode) else {
+ throw APIError.server(Self.serverMessage(status: http.statusCode, data: data))
+ }
+ guard !data.isEmpty else { throw APIError.invalidResponse }
+ do {
+ return try decoder.decode(Response.self, from: data)
+ } catch {
+ throw APIError.server("Сервис вернул данные в новом формате. Обновите приложение")
+ }
+ }
+
+ private func performRequest(
+ _ request: URLRequest,
+ retryOnTransient: Bool
+ ) async throws -> (Data, URLResponse) {
+ let attempts = retryOnTransient ? 2 : 1
+ var lastError: Error?
+ for attempt in 0.. String {
+ if let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
+ if let detail = object["detail"] as? String, !detail.isEmpty { return detail }
+ if let error = object["error"] as? String, !error.isEmpty { return error }
+ if let validation = object["detail"] as? [[String: Any]] {
+ let messages = validation.compactMap { $0["msg"] as? String }
+ if !messages.isEmpty {
+ return "Проверьте введённые данные: \(messages.joined(separator: ", "))"
+ }
+ }
+ }
+ return switch status {
+ case 400: "Сервер не принял запрос"
+ case 403: "Недостаточно прав для просмотра этих данных"
+ case 404: "Запрошенные данные не найдены"
+ case 422: "Проверьте заполнение обязательных полей"
+ case 429: "Слишком много запросов. Попробуйте немного позже"
+ case 500...599: "Сервис МАУ временно недоступен"
+ default: "Ошибка сервера: \(status)"
+ }
+ }
+
+ private static func networkMessage(for error: URLError) -> String {
+ return switch error.code {
+ case .notConnectedToInternet: "Нет подключения к интернету"
+ case .timedOut: "Сервер не ответил вовремя"
+ case .cannotFindHost, .cannotConnectToHost, .dnsLookupFailed:
+ "Не удалось подключиться к сервису МАУ"
+ case .cancelled: "Запрос отменён"
+ default: "Ошибка сети. Попробуйте ещё раз"
+ }
+ }
+
+ private static func isTransient(_ code: URLError.Code) -> Bool {
+ [.timedOut, .networkConnectionLost, .cannotConnectToHost, .dnsLookupFailed]
+ .contains(code)
+ }
+
+ private var decoder: JSONDecoder {
+ let decoder = JSONDecoder()
+ decoder.keyDecodingStrategy = .convertFromSnakeCase
+ return decoder
+ }
+}
+
+actor ScheduleAPIClient {
+ static let shared = ScheduleAPIClient()
+ private let baseURL = URL(string: "https://api-schedule.mauniver.ru/")!
+ private let session: URLSession
+ private var groupsCache: [ScheduleGroup]?
+ private var scheduleCache: [String: [ScheduleItem]] = [:]
+ private let groupsCacheKey = "mauverse.schedule.groups.v1"
+ private let groupsCacheDateKey = "mauverse.schedule.groups.date.v1"
+ private let scheduleCachePrefix = "mauverse.schedule.items.v1."
+
+ private init() {
+ let configuration = URLSessionConfiguration.default
+ configuration.timeoutIntervalForRequest = 30
+ configuration.waitsForConnectivity = true
+ session = URLSession(configuration: configuration)
+ }
+
+ func schedule(uid: String, start: String, end: String) async throws -> [ScheduleItem] {
+ let key = "\(uid)|\(start)|\(end)"
+ do {
+ let response: ScheduleResponse = try await get(
+ "groups/\(escaped(uid))/schedule/\(start)/\(end)"
+ )
+ guard response.success != false else {
+ throw APIError.server("Расписание временно не готово")
+ }
+ let result = response.timetable.map(\.appItem)
+ scheduleCache[key] = result
+ if let data = try? JSONEncoder().encode(result) {
+ UserDefaults.standard.set(data, forKey: persistedScheduleKey(key))
+ }
+ return result
+ } catch {
+ if let cached = scheduleCache[key] { return cached }
+ if let data = UserDefaults.standard.data(forKey: persistedScheduleKey(key)),
+ let cached = try? JSONDecoder().decode([ScheduleItem].self, from: data) {
+ scheduleCache[key] = cached
+ return cached
+ }
+ throw error
+ }
+ }
+
+ func groups() async throws -> [ScheduleGroup] {
+ if let groupsCache { return groupsCache }
+ if let cached = persistedGroups(), isGroupsCacheFresh {
+ groupsCache = cached
+ return cached
+ }
+ let faculties: ScheduleFacultyResponse = try await get("faculties")
+ var result: [ScheduleGroup] = []
+ for faculty in faculties.courses {
+ let response: ScheduleGroupsResponse = try await get(
+ "faculties/\(faculty.facId)/groups/main"
+ )
+ result.append(contentsOf: response.groups)
+ }
+ let sorted = result.sorted {
+ $0.group.localizedStandardCompare($1.group) == .orderedAscending
+ }
+ groupsCache = sorted
+ if let data = try? JSONEncoder().encode(sorted) {
+ UserDefaults.standard.set(data, forKey: groupsCacheKey)
+ UserDefaults.standard.set(Date(), forKey: groupsCacheDateKey)
+ }
+ return sorted
+ }
+
+ func findGroup(named name: String) async throws -> ScheduleGroup? {
+ let normalized = name.trimmingCharacters(in: .whitespacesAndNewlines)
+ return try await groups().first {
+ $0.group.compare(normalized, options: [.caseInsensitive, .diacriticInsensitive]) == .orderedSame
+ }
+ }
+
+ func teachers(matching name: String) async throws -> [ScheduleTeacher] {
+ let query = [URLQueryItem(name: "name", value: name)]
+ let response: ScheduleTeachersResponse = try await get("teachers/search", query: query)
+ return response.teachers
+ }
+
+ func clearCache() {
+ groupsCache = nil
+ scheduleCache.removeAll()
+ UserDefaults.standard.removeObject(forKey: groupsCacheKey)
+ UserDefaults.standard.removeObject(forKey: groupsCacheDateKey)
+ for key in UserDefaults.standard.dictionaryRepresentation().keys
+ where key.hasPrefix(scheduleCachePrefix) {
+ UserDefaults.standard.removeObject(forKey: key)
+ }
+ }
+
+ private func get(
+ _ path: String,
+ query: [URLQueryItem] = []
+ ) async throws -> Response {
+ guard let token = Bundle.main.object(forInfoDictionaryKey: "MAUverseScheduleToken") as? String,
+ !token.isEmpty,
+ !token.contains("$(") else {
+ throw APIError.server("Токен API расписания не настроен")
+ }
+ var components = URLComponents(
+ url: baseURL.appendingPathComponent(path),
+ resolvingAgainstBaseURL: false
+ )!
+ components.queryItems = query.isEmpty ? nil : query
+ var request = URLRequest(url: components.url!)
+ request.httpMethod = "GET"
+ request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
+ request.setValue("application/json", forHTTPHeaderField: "Accept")
+
+ let data: Data
+ let response: URLResponse
+ do {
+ (data, response) = try await scheduleData(for: request)
+ } catch let error as URLError {
+ throw APIError.network(APIClientNetworkMessage.message(for: error))
+ }
+ guard let http = response as? HTTPURLResponse else { throw APIError.invalidResponse }
+ guard (200..<300).contains(http.statusCode) else {
+ let message = switch http.statusCode {
+ case 401, 403: "Токен API расписания недействителен"
+ case 404: "Данные расписания не найдены"
+ default: APIClient.serverMessage(status: http.statusCode, data: data)
+ }
+ throw APIError.server(message)
+ }
+ do {
+ let decoder = JSONDecoder()
+ return try decoder.decode(Response.self, from: data)
+ } catch {
+ throw APIError.server("API расписания вернул данные в новом формате")
+ }
+ }
+
+ private func scheduleData(for request: URLRequest) async throws -> (Data, URLResponse) {
+ var lastError: Error?
+ for attempt in 0..<2 {
+ do {
+ let result = try await session.data(for: request)
+ if attempt == 0,
+ let http = result.1 as? HTTPURLResponse,
+ [502, 503, 504].contains(http.statusCode) {
+ try await Task.sleep(for: .milliseconds(350))
+ continue
+ }
+ return result
+ } catch let error as URLError where [
+ .timedOut, .networkConnectionLost, .cannotConnectToHost, .dnsLookupFailed
+ ].contains(error.code) {
+ lastError = error
+ if attempt == 0 { try await Task.sleep(for: .milliseconds(350)) }
+ }
+ }
+ throw lastError ?? APIError.invalidResponse
+ }
+
+ private var isGroupsCacheFresh: Bool {
+ guard let date = UserDefaults.standard.object(forKey: groupsCacheDateKey) as? Date else {
+ return false
+ }
+ return Date().timeIntervalSince(date) < 6 * 60 * 60
+ }
+
+ private func persistedGroups() -> [ScheduleGroup]? {
+ guard let data = UserDefaults.standard.data(forKey: groupsCacheKey) else { return nil }
+ return try? JSONDecoder().decode([ScheduleGroup].self, from: data)
+ }
+
+ private func persistedScheduleKey(_ key: String) -> String {
+ scheduleCachePrefix + key.replacingOccurrences(of: "|", with: ".")
+ }
+
+ private func escaped(_ value: String) -> String {
+ value.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? value
+ }
+}
+
+private enum APIClientNetworkMessage {
+ static func message(for error: URLError) -> String {
+ return switch error.code {
+ case .notConnectedToInternet: "Нет подключения к интернету"
+ case .timedOut: "API расписания не ответил вовремя"
+ case .cannotFindHost, .cannotConnectToHost, .dnsLookupFailed:
+ "Не удалось подключиться к API расписания"
+ default: "Ошибка сети при загрузке расписания"
+ }
+ }
+}
+
+struct LoginRequest: Encodable { let username: String; let password: String }
+struct GroupRequest: Encodable { let groupName: String }
+struct DepartmentRequest: Encodable { let departmentId: Int; let name: String }
+struct TeacherRequest: Encodable { let name: String }
+struct DebtRequest: Encodable {
+ let creditBook: String
+ var semesterNumber: Int? = nil
+}
+struct ScheduleRequest: Encodable {
+ let startDate: String
+ let endDate: String
+ let groupId: Int?
+ let subgroupId: Int?
+}
diff --git a/MauverseIOS/Sources/Core/KeychainStore.swift b/MauverseIOS/Sources/Core/KeychainStore.swift
new file mode 100644
index 0000000..3e4bece
--- /dev/null
+++ b/MauverseIOS/Sources/Core/KeychainStore.swift
@@ -0,0 +1,51 @@
+import Foundation
+import Security
+
+enum KeychainStore {
+ private static let service = "com.pmi4freal.mauverse3"
+
+ static func read(account: String) -> Data? {
+ let query: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: service,
+ kSecAttrAccount as String: account,
+ kSecReturnData as String: true,
+ kSecMatchLimit as String: kSecMatchLimitOne
+ ]
+ var result: CFTypeRef?
+ guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess else {
+ return nil
+ }
+ return result as? Data
+ }
+
+ @discardableResult
+ static func write(_ data: Data, account: String) -> Bool {
+ let query: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: service,
+ kSecAttrAccount as String: account
+ ]
+ let attributes: [String: Any] = [
+ kSecValueData as String: data,
+ kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
+ ]
+ let status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
+ if status == errSecItemNotFound {
+ var item = query
+ attributes.forEach { item[$0.key] = $0.value }
+ return SecItemAdd(item as CFDictionary, nil) == errSecSuccess
+ }
+ return status == errSecSuccess
+ }
+
+ static func delete(account: String) {
+ let query: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: service,
+ kSecAttrAccount as String: account
+ ]
+ SecItemDelete(query as CFDictionary)
+ }
+}
+
diff --git a/MauverseIOS/Sources/Core/Models.swift b/MauverseIOS/Sources/Core/Models.swift
new file mode 100644
index 0000000..98a071c
--- /dev/null
+++ b/MauverseIOS/Sources/Core/Models.swift
@@ -0,0 +1,532 @@
+import Foundation
+
+enum UserRole: Int, Codable {
+ case all = -1
+ case student = 0
+ case teacher = 1
+}
+
+struct UserDTO: Codable, Equatable {
+ var userId: Int?
+ var username: String?
+ var firstName: String?
+ var fullName: String?
+ var role: Int?
+ var creditBook: String?
+ var groupId: String?
+ var subGroupId: String?
+ var scheduleGroupUID: String?
+ var groupName: String?
+ var speciality: String?
+ var token: String?
+ var privateToken: String?
+ var error: String?
+ var detail: String?
+
+ var displayName: String {
+ let value = fullName ?? firstName ?? "Студент"
+ return value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "Студент" : value
+ }
+
+ var greetingName: String {
+ let login = username?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+ let directName = firstName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+ if !directName.isEmpty, directName.caseInsensitiveCompare(login) != .orderedSame {
+ return directName.split(whereSeparator: \.isWhitespace).first.map(String.init) ?? "Студент"
+ }
+
+ let normalizedFullName = fullName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+ guard !normalizedFullName.isEmpty,
+ normalizedFullName.caseInsensitiveCompare(login) != .orderedSame else {
+ return "Студент"
+ }
+ let parts = normalizedFullName.split(whereSeparator: \.isWhitespace)
+ // Russian passport-style ФИО is normally «Фамилия Имя Отчество».
+ // Moodle's two-part display name is normally «Имя Фамилия».
+ let index = parts.count >= 3 ? 1 : 0
+ return parts.indices.contains(index) ? String(parts[index]) : "Студент"
+ }
+
+ enum CodingKeys: String, CodingKey {
+ case userId = "userid"
+ case username
+ case firstName = "firstname"
+ case fullName = "fullname"
+ case role = "roleid"
+ case creditBook = "credit_book"
+ case groupId = "group_id"
+ case subGroupId = "subgroup_id"
+ case scheduleGroupUID
+ case groupName = "groupname"
+ case speciality
+ case token
+ case privateToken = "private_token"
+ case error
+ case detail
+ case legacyUserId = "userId"
+ case legacyFirstName = "firstName"
+ case legacyFullName = "fullName"
+ case legacyRole = "role"
+ case legacyCreditBook = "creditBook"
+ case legacyGroupId = "groupId"
+ case apiGroupId = "groupid"
+ case legacySubGroupId = "subGroupId"
+ case legacyGroupName = "groupName"
+ case legacyPrivateToken = "privateToken"
+ }
+
+ init(
+ userId: Int? = nil,
+ username: String? = nil,
+ firstName: String? = nil,
+ fullName: String? = nil,
+ role: Int? = nil,
+ creditBook: String? = nil,
+ groupId: String? = nil,
+ subGroupId: String? = nil,
+ scheduleGroupUID: String? = nil,
+ groupName: String? = nil,
+ speciality: String? = nil,
+ token: String? = nil,
+ privateToken: String? = nil,
+ error: String? = nil,
+ detail: String? = nil
+ ) {
+ self.userId = userId
+ self.username = username
+ self.firstName = firstName
+ self.fullName = fullName
+ self.role = role
+ self.creditBook = creditBook
+ self.groupId = groupId
+ self.subGroupId = subGroupId
+ self.scheduleGroupUID = scheduleGroupUID
+ self.groupName = groupName
+ self.speciality = speciality
+ self.token = token
+ self.privateToken = privateToken
+ self.error = error
+ self.detail = detail
+ }
+
+ init(from decoder: Decoder) throws {
+ let values = try decoder.container(keyedBy: CodingKeys.self)
+ userId = values.decodeLossyIntIfPresent(forKey: .userId)
+ ?? values.decodeLossyIntIfPresent(forKey: .legacyUserId)
+ username = try values.decodeIfPresent(String.self, forKey: .username)
+ firstName = try values.decodeIfPresent(String.self, forKey: .firstName)
+ ?? values.decodeLossyStringIfPresent(forKey: .legacyFirstName)
+ fullName = try values.decodeIfPresent(String.self, forKey: .fullName)
+ ?? values.decodeLossyStringIfPresent(forKey: .legacyFullName)
+ role = values.decodeLossyIntIfPresent(forKey: .role)
+ ?? values.decodeLossyIntIfPresent(forKey: .legacyRole)
+ creditBook = values.decodeLossyStringIfPresent(forKey: .creditBook)
+ ?? values.decodeLossyStringIfPresent(forKey: .legacyCreditBook)
+ groupId = values.decodeLossyStringIfPresent(forKey: .groupId)
+ ?? values.decodeLossyStringIfPresent(forKey: .apiGroupId)
+ ?? values.decodeLossyStringIfPresent(forKey: .legacyGroupId)
+ 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)
+ token = try values.decodeIfPresent(String.self, forKey: .token)
+ privateToken = try values.decodeIfPresent(String.self, forKey: .privateToken)
+ ?? values.decodeLossyStringIfPresent(forKey: .legacyPrivateToken)
+ error = try values.decodeIfPresent(String.self, forKey: .error)
+ detail = try values.decodeIfPresent(String.self, forKey: .detail)
+ }
+
+ func encode(to encoder: Encoder) throws {
+ var values = encoder.container(keyedBy: CodingKeys.self)
+ try values.encodeIfPresent(userId, forKey: .userId)
+ try values.encodeIfPresent(username, forKey: .username)
+ try values.encodeIfPresent(firstName, forKey: .firstName)
+ try values.encodeIfPresent(fullName, forKey: .fullName)
+ try values.encodeIfPresent(role, forKey: .role)
+ try values.encodeIfPresent(creditBook, forKey: .creditBook)
+ try values.encodeIfPresent(groupId, forKey: .groupId)
+ try values.encodeIfPresent(subGroupId, forKey: .subGroupId)
+ try values.encodeIfPresent(scheduleGroupUID, forKey: .scheduleGroupUID)
+ try values.encodeIfPresent(groupName, forKey: .groupName)
+ try values.encodeIfPresent(speciality, forKey: .speciality)
+ try values.encodeIfPresent(token, forKey: .token)
+ try values.encodeIfPresent(privateToken, forKey: .privateToken)
+ try values.encodeIfPresent(error, forKey: .error)
+ try values.encodeIfPresent(detail, forKey: .detail)
+ }
+}
+
+struct ScheduleItem: Codable, Identifiable, Hashable {
+ var id: Int?
+ var name: String?
+ var teacher: String?
+ var room: String?
+ var pairType: String?
+ var startTime: String?
+ var endTime: String?
+ var date: String?
+ var externalId: String?
+
+ var stableID: String {
+ externalId ?? "\(id ?? 0)-\(date ?? "")-\(startTime ?? "")-\(name ?? "")"
+ }
+}
+
+struct ScheduleResponse: Decodable {
+ let update: String?
+ let success: Bool?
+ let timetable: [ScheduleAPIItem]
+}
+
+struct ScheduleAPIItem: Decodable {
+ let id: Int?
+ let date: String?
+ let slot: String?
+ let dayOfWeek: String?
+ let type: String?
+ let disciplines: String?
+ let room: String?
+ let teacher: String?
+
+ enum CodingKeys: String, CodingKey {
+ case id, date, slot, type, disciplines, room, teacher
+ case dayOfWeek = "day_of_week"
+ }
+
+ var appItem: ScheduleItem {
+ let times = (slot ?? "").components(separatedBy: " - ")
+ return ScheduleItem(
+ id: id,
+ name: disciplines,
+ teacher: teacher,
+ room: room,
+ pairType: type,
+ startTime: Self.normalizedTime(times.first),
+ endTime: times.count > 1 ? Self.normalizedTime(times[1]) : nil,
+ date: date,
+ externalId: nil
+ )
+ }
+
+ private static func normalizedTime(_ value: String?) -> String? {
+ let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+ guard !trimmed.isEmpty else { return nil }
+ let parts = trimmed.split(separator: ":")
+ guard parts.count >= 2, let hour = Int(parts[0]), let minute = Int(parts[1]) else {
+ return trimmed
+ }
+ return String(format: "%02d:%02d", hour, minute)
+ }
+}
+
+struct ScheduleFacultyResponse: Decodable {
+ let success: Bool?
+ let courses: [ScheduleFaculty]
+}
+
+struct ScheduleFaculty: Decodable, Identifiable {
+ let facId: Int
+ let facultee: String
+ var id: Int { facId }
+
+ enum CodingKeys: String, CodingKey {
+ case facId = "fac_id"
+ case facultee
+ }
+}
+
+struct ScheduleGroupsResponse: Decodable {
+ let success: Bool?
+ let groups: [ScheduleGroup]
+}
+
+struct ScheduleGroup: Codable, Identifiable, Hashable {
+ let groupId: Int
+ let group: String
+ let speciality: String?
+ let uid: String
+ var id: Int { groupId }
+
+ enum CodingKeys: String, CodingKey {
+ case groupId = "group_id"
+ case group, speciality
+ case uid = "UID"
+ }
+}
+
+struct ScheduleTeachersResponse: Decodable {
+ let success: Bool?
+ let teachers: [ScheduleTeacher]
+}
+
+struct ScheduleTeacher: Decodable, Identifiable {
+ let teacherId: Int
+ let teacher: String
+ let uid: String
+ var id: Int { teacherId }
+
+ enum CodingKeys: String, CodingKey {
+ case teacherId = "teacher_id"
+ case teacher
+ case uid = "UID"
+ }
+}
+
+struct NewsItem: Codable, Identifiable, Hashable {
+ var title: String?
+ var description: String?
+ var image: String?
+ var link: String?
+ var publish: String?
+
+ var id: String { link ?? "\(title ?? "")-\(publish ?? "")" }
+}
+
+struct GroupDTO: Codable, Identifiable, Hashable {
+ var groupId: Int?
+ var groupName: String?
+ var name: String?
+ var speciality: String?
+ var id: String { "\(groupId ?? 0)-\(groupName ?? name ?? "")" }
+ var title: String { groupName ?? name ?? "Группа" }
+}
+
+struct SubGroupItem: Codable, Identifiable, Hashable {
+ var groupId: Int?
+ var name: String?
+ var id: String { "\(groupId ?? 0)-\(name ?? "")" }
+}
+
+struct SubGroupDTO: Codable {
+ var groupId: Int?
+ var speciality: String?
+ var subGroups: [SubGroupItem]?
+}
+
+struct Department: Decodable, Identifiable, Hashable {
+ var name: String?
+ var id: Int?
+
+ enum CodingKeys: String, CodingKey {
+ case name, title, id, departmentId
+ }
+
+ init(from decoder: Decoder) throws {
+ let values = try decoder.container(keyedBy: CodingKeys.self)
+ name = try values.decodeIfPresent(String.self, forKey: .name)
+ ?? values.decodeLossyStringIfPresent(forKey: .title)
+ id = values.decodeLossyIntIfPresent(forKey: .id)
+ ?? values.decodeLossyIntIfPresent(forKey: .departmentId)
+ }
+}
+
+struct Telephone: Decodable, Identifiable, Hashable {
+ var title: String?
+ var person: String?
+ var phone: String?
+ var phone2: String?
+ var fax: String?
+ var building2: String?
+ var room: String?
+ var depEmail: String?
+ var id: String { "\(title ?? "")-\(person ?? "")-\(phone ?? "")" }
+
+ enum CodingKeys: String, CodingKey {
+ case title, post, person, name, phone, telephone, phone2, fax, building2, building, room
+ case depEmail, email
+ }
+
+ init(from decoder: Decoder) throws {
+ let values = try decoder.container(keyedBy: CodingKeys.self)
+ title = try values.decodeIfPresent(String.self, forKey: .title)
+ ?? values.decodeLossyStringIfPresent(forKey: .post)
+ person = try values.decodeIfPresent(String.self, forKey: .person)
+ ?? values.decodeLossyStringIfPresent(forKey: .name)
+ phone = try values.decodeIfPresent(String.self, forKey: .phone)
+ ?? values.decodeLossyStringIfPresent(forKey: .telephone)
+ phone2 = try values.decodeIfPresent(String.self, forKey: .phone2)
+ fax = try values.decodeIfPresent(String.self, forKey: .fax)
+ building2 = try values.decodeIfPresent(String.self, forKey: .building2)
+ ?? values.decodeLossyStringIfPresent(forKey: .building)
+ room = try values.decodeIfPresent(String.self, forKey: .room)
+ depEmail = try values.decodeIfPresent(String.self, forKey: .depEmail)
+ ?? values.decodeLossyStringIfPresent(forKey: .email)
+ }
+}
+
+struct Debt: Codable, Identifiable, Hashable {
+ var firstName: String?
+ var surname: String?
+ var lastName: String?
+ var group: String?
+ var discipline: String?
+ var markType: String?
+ var id: String { "\(discipline ?? "")-\(markType ?? "")-\(group ?? "")" }
+
+ enum CodingKeys: String, CodingKey {
+ case firstName = "firstname"
+ case surname
+ case lastName = "lastname"
+ case group
+ case discipline
+ case markType = "marktype"
+ }
+}
+
+struct Semester: Codable, Identifiable, Hashable {
+ var semesterNumber: Int?
+ var semester: String?
+ var semesterSubtitle: String?
+ var debts: [Debt]?
+ var id: String { "\(semesterNumber ?? 0)-\(semester ?? "")" }
+
+ enum CodingKeys: String, CodingKey {
+ case semesterNumber = "semesternumber"
+ case semester
+ case semesterSubtitle
+ case debts
+ }
+}
+
+struct SemesterResponse: Codable {
+ var semesters: [Semester]?
+ var error: String?
+ var detail: String?
+}
+
+struct DebtResponse: Codable {
+ var debts: [Debt]?
+ var error: String?
+ var detail: String?
+}
+
+struct StudentFormField: Codable, Identifiable, Hashable {
+ let title: String
+ let value: String
+ var id: String { title }
+}
+
+struct StudentFormRequest: Encodable {
+ let sender: String
+ let username: String
+ let text: [StudentFormField]
+
+ enum CodingKeys: String, CodingKey {
+ case sender = "from"
+ case username
+ case text
+ }
+}
+
+struct SuccessResponse: Codable {
+ var success: Bool?
+ var error: String?
+ var detail: String?
+}
+
+struct MoodleNotification: Codable, Identifiable, Hashable {
+ var id: Int
+ var subject: String?
+ var smallMessage: String?
+ var contextURLName: String?
+ var contextURL: String?
+ var userFromFullName: String?
+ var timeCreated: Int?
+ var timeCreatedString: String?
+
+ enum CodingKeys: String, CodingKey {
+ case id, subject
+ case smallMessage = "smallmessage"
+ case contextURLName = "contexturlname"
+ case contextURL = "contexturl"
+ case userFromFullName = "userfromfullname"
+ case timeCreated = "timecreated"
+ case timeCreatedString = "timecreatedstring"
+ }
+
+ var title: String {
+ [contextURLName, subject, smallMessage].compactMap { value in
+ let text = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+ return text.isEmpty ? nil : text
+ }.first ?? "Уведомление ЭИОС"
+ }
+
+ var subtitle: String {
+ let text = subject?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+ if !text.isEmpty, text != title { return text }
+ let sender = userFromFullName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+ return sender.isEmpty ? "Электронная образовательная среда" : sender
+ }
+
+ var destination: URL? {
+ guard let contextURL, let url = URL(string: contextURL),
+ url.scheme?.lowercased() == "https",
+ url.host?.lowercased().hasSuffix("mauniver.ru") == true else { return nil }
+ return url
+ }
+}
+
+struct NotificationRequest: Encodable {
+ let token: String
+ let userId: Int
+}
+
+struct Teacher: Codable, Identifiable, Hashable {
+ var id: Int?
+ var teacherId: Int?
+ var name: String?
+ var fullName: String?
+ var email: String?
+ var phone: String?
+ var post: String?
+ var extras: String?
+ var stableID: String { "\(teacherId ?? id ?? 0)-\(fullName ?? name ?? "")" }
+}
+
+struct APIMessage: Codable {
+ var error: String?
+ var detail: String?
+ var statusCode: Int?
+}
+
+enum NewsFilter: Int, CaseIterable, Identifiable {
+ case all = 0, departments, sport, students, science, international, events, other
+ var id: Int { rawValue }
+ var title: String {
+ return switch self {
+ case .all: "Все"
+ case .departments: "Подразделения"
+ case .sport: "Спорт"
+ case .students: "Студентам"
+ case .science: "Наука"
+ case .international: "Международное"
+ case .events: "События"
+ case .other: "Другое"
+ }
+ }
+}
+
+private extension KeyedDecodingContainer {
+ func decodeLossyStringIfPresent(forKey key: Key) -> String? {
+ if let value = try? decode(String.self, forKey: key) {
+ return value
+ }
+ if let value = try? decode(Int.self, forKey: key) {
+ return String(value)
+ }
+ return nil
+ }
+
+ func decodeLossyIntIfPresent(forKey key: Key) -> Int? {
+ if let value = try? decode(Int.self, forKey: key) {
+ return value
+ }
+ if let value = try? decode(String.self, forKey: key) {
+ return Int(value)
+ }
+ return nil
+ }
+}
diff --git a/MauverseIOS/Sources/Core/OfficialNewsService.swift b/MauverseIOS/Sources/Core/OfficialNewsService.swift
new file mode 100644
index 0000000..2cbbc0a
--- /dev/null
+++ b/MauverseIOS/Sources/Core/OfficialNewsService.swift
@@ -0,0 +1,225 @@
+import Foundation
+
+actor OfficialNewsService {
+ static let shared = OfficialNewsService()
+ private var cache: [NewsFilter: [NewsItem]] = [:]
+ private let cachePrefix = "mauverse.news.cache.v2."
+
+ private init() {}
+
+ func load(filter: NewsFilter) async throws -> [NewsItem] {
+ if cache[filter] == nil, let persisted = persistedItems(for: filter) {
+ cache[filter] = persisted
+ }
+ let path: String
+ switch filter {
+ case .all: path = "press/news/rss/"
+ case .departments: path = "press/deps/rss/"
+ case .sport: path = "press/sport/rss/"
+ case .students: path = "press/community/rss/"
+ case .science: path = "press/science/rss/"
+ case .international: path = "press/inter/rss/"
+ case .events: path = "press/events/rss/"
+ case .other: path = "press/information/rss/"
+ }
+
+ guard let url = URL(string: "https://mauniver.ru/\(path)") else {
+ throw APIError.invalidResponse
+ }
+ do {
+ let loaded: [NewsItem]
+ do {
+ loaded = try await fetch(url: url, cachePolicy: .reloadRevalidatingCacheData)
+ } catch let error as URLError where Self.isTransient(error.code) {
+ loaded = try await fetch(url: url, cachePolicy: .returnCacheDataElseLoad)
+ }
+ if !loaded.isEmpty {
+ cache[filter] = loaded
+ persist(loaded, for: filter)
+ }
+ return loaded
+ } catch {
+ if let cached = cache[filter], !cached.isEmpty {
+ return cached
+ }
+ throw error
+ }
+ }
+
+ func clearCache() {
+ cache.removeAll()
+ for filter in NewsFilter.allCases {
+ UserDefaults.standard.removeObject(forKey: cachePrefix + String(filter.rawValue))
+ }
+ }
+
+ 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("application/rss+xml, application/xml, text/xml", forHTTPHeaderField: "Accept")
+
+ let (data, response) = try await URLSession.shared.data(for: request)
+ guard let http = response as? HTTPURLResponse else {
+ throw APIError.invalidResponse
+ }
+ guard (200..<300).contains(http.statusCode) else {
+ throw APIError.server("Сайт МАУ временно недоступен: \(http.statusCode)")
+ }
+
+ let parser = RSSNewsParser()
+ return try parser.parse(data)
+ .uniqued(by: \.id)
+ .filter { $0.title?.isEmpty == false && $0.link?.isEmpty == false }
+ }
+
+ private static func isTransient(_ code: URLError.Code) -> Bool {
+ return switch code {
+ case .timedOut, .networkConnectionLost, .cannotConnectToHost, .dnsLookupFailed:
+ true
+ default:
+ false
+ }
+ }
+
+ private func persistedItems(for filter: NewsFilter) -> [NewsItem]? {
+ guard let data = UserDefaults.standard.data(forKey: cachePrefix + String(filter.rawValue))
+ else { return nil }
+ return try? JSONDecoder().decode([NewsItem].self, from: data)
+ }
+
+ private func persist(_ items: [NewsItem], for filter: NewsFilter) {
+ guard let data = try? JSONEncoder().encode(items) else { return }
+ UserDefaults.standard.set(data, forKey: cachePrefix + String(filter.rawValue))
+ }
+}
+
+private final class RSSNewsParser: NSObject, XMLParserDelegate {
+ private var items: [NewsItem] = []
+ private var current: NewsItem?
+ private var element = ""
+ private var text = ""
+ private var parsingError: Error?
+
+ func parse(_ data: Data) throws -> [NewsItem] {
+ let parser = XMLParser(data: data)
+ parser.delegate = self
+ guard parser.parse() else {
+ throw parser.parserError ?? parsingError ?? APIError.invalidResponse
+ }
+ return items
+ }
+
+ func parser(
+ _ parser: XMLParser,
+ didStartElement elementName: String,
+ namespaceURI: String?,
+ qualifiedName qName: String?,
+ attributes attributeDict: [String: String] = [:]
+ ) {
+ element = elementName
+ text = ""
+ if elementName == "item" {
+ current = NewsItem(
+ title: nil,
+ description: nil,
+ image: nil,
+ link: nil,
+ publish: nil
+ )
+ } else if elementName == "enclosure",
+ let url = attributeDict["url"],
+ attributeDict["type"]?.hasPrefix("image/") != false {
+ current?.image = Self.absoluteURL(url)
+ } else if (elementName == "media:content" || elementName == "media:thumbnail"),
+ let url = attributeDict["url"] {
+ current?.image = Self.absoluteURL(url)
+ }
+ }
+
+ func parser(_ parser: XMLParser, foundCharacters string: String) {
+ text += string
+ }
+
+ func parser(
+ _ parser: XMLParser,
+ didEndElement elementName: String,
+ namespaceURI: String?,
+ qualifiedName qName: String?
+ ) {
+ guard current != nil else { return }
+ let value = text.trimmingCharacters(in: .whitespacesAndNewlines)
+ switch elementName {
+ case "title": current?.title = value.cleanedRSS
+ case "link": current?.link = Self.absoluteURL(value)
+ case "description":
+ current?.description = value.cleanedRSS
+ if current?.image == nil {
+ current?.image = Self.firstImageURL(in: value)
+ }
+ case "pubDate": current?.publish = Self.formattedDate(value)
+ case "item":
+ if let current { items.append(current) }
+ current = nil
+ default: break
+ }
+ text = ""
+ }
+
+ func parser(_ parser: XMLParser, parseErrorOccurred parseError: Error) {
+ parsingError = parseError
+ }
+
+ private static func formattedDate(_ value: String) -> String {
+ let input = DateFormatter()
+ input.locale = Locale(identifier: "en_US_POSIX")
+ input.dateFormat = "EEE, dd MMM yyyy HH:mm:ss Z"
+ guard let date = input.date(from: value) else { return value }
+ return date.formatted(
+ .dateTime
+ .locale(Locale(identifier: "ru_RU"))
+ .day()
+ .month(.wide)
+ .year()
+ )
+ }
+
+ private static func absoluteURL(_ value: String) -> String {
+ guard let url = URL(string: value, relativeTo: URL(string: "https://mauniver.ru/")) else {
+ return value
+ }
+ return url.absoluteURL.absoluteString
+ }
+
+ private static func firstImageURL(in html: String) -> String? {
+ guard let expression = try? NSRegularExpression(
+ pattern: #"
]+src=["']([^"']+)["']"#,
+ options: [.caseInsensitive]
+ ) else { return nil }
+ let range = NSRange(html.startIndex..., in: html)
+ guard let match = expression.firstMatch(in: html, range: range),
+ let urlRange = Range(match.range(at: 1), in: html) else { return nil }
+ return absoluteURL(String(html[urlRange]))
+ }
+}
+
+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()
+ return filter { seen.insert(key($0)).inserted }
+ }
+}
diff --git a/MauverseIOS/Sources/Core/SessionStore.swift b/MauverseIOS/Sources/Core/SessionStore.swift
new file mode 100644
index 0000000..38bf393
--- /dev/null
+++ b/MauverseIOS/Sources/Core/SessionStore.swift
@@ -0,0 +1,121 @@
+import Combine
+import Foundation
+
+@MainActor
+final class SessionStore: ObservableObject {
+ @Published private(set) var user: UserDTO?
+ @Published var isBusy = false
+ @Published var errorMessage: String?
+
+ private let storageKey = "mauverse.native.session"
+ private var sessionObserver: NSObjectProtocol?
+
+ init() {
+ if let data = KeychainStore.read(account: storageKey) {
+ user = try? JSONDecoder().decode(UserDTO.self, from: data)
+ } else if let legacy = UserDefaults.standard.data(forKey: storageKey) {
+ user = try? JSONDecoder().decode(UserDTO.self, from: legacy)
+ if user != nil {
+ KeychainStore.write(legacy, account: storageKey)
+ UserDefaults.standard.removeObject(forKey: storageKey)
+ }
+ }
+ sessionObserver = NotificationCenter.default.addObserver(
+ forName: .mauverseSessionExpired,
+ object: nil,
+ queue: .main
+ ) { [weak self] _ in
+ Task { @MainActor in
+ self?.signOut()
+ self?.errorMessage = "Сессия истекла. Войдите в аккаунт повторно"
+ }
+ }
+ if user != nil {
+ Task { await refreshProfile() }
+ }
+ }
+
+ deinit {
+ if let sessionObserver {
+ NotificationCenter.default.removeObserver(sessionObserver)
+ }
+ }
+
+ func signIn(username: String, password: String) async {
+ let normalizedUsername = username.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !normalizedUsername.isEmpty, !password.isEmpty else {
+ errorMessage = "Введите логин и пароль"
+ return
+ }
+ isBusy = true
+ errorMessage = nil
+ defer { isBusy = false }
+ do {
+ var authenticated: UserDTO = try await APIClient.shared.post(
+ "auth",
+ body: LoginRequest(username: normalizedUsername, password: password)
+ )
+ guard authenticated.token?.isEmpty == false else {
+ throw APIError.server(
+ authenticated.detail
+ ?? authenticated.error
+ ?? "Сервер не вернул токен авторизации"
+ )
+ }
+ authenticated.username = authenticated.username ?? normalizedUsername
+ if authenticated.scheduleGroupUID?.isEmpty != false {
+ authenticated.scheduleGroupUID = authenticated.groupId
+ }
+ user = authenticated
+ persist()
+ await refreshProfile()
+ } catch {
+ errorMessage = error.localizedDescription
+ }
+ }
+
+ func update(_ transform: (inout UserDTO) -> Void) {
+ guard var current = user else { return }
+ transform(¤t)
+ user = current
+ persist()
+ }
+
+ func signOut() {
+ user = nil
+ KeychainStore.delete(account: storageKey)
+ UserDefaults.standard.removeObject(forKey: storageKey)
+ }
+
+ func refreshProfile() async {
+ guard let current = user else { return }
+ do {
+ let profile: UserDTO = try await APIClient.shared.post(
+ "get_user_info",
+ user: current,
+ retryOnTransient: true
+ )
+ guard user?.username == current.username else { return }
+ var merged = current
+ merged.userId = profile.userId ?? current.userId
+ merged.firstName = profile.firstName ?? current.firstName
+ merged.fullName = profile.fullName ?? current.fullName
+ merged.role = profile.role ?? current.role
+ merged.creditBook = profile.creditBook ?? current.creditBook
+ merged.groupId = profile.groupId ?? current.groupId
+ merged.groupName = profile.groupName ?? current.groupName
+ merged.speciality = profile.speciality ?? current.speciality
+ merged.scheduleGroupUID = profile.scheduleGroupUID ?? current.scheduleGroupUID
+ user = merged
+ persist()
+ } catch {
+ // Keep the saved profile available offline. A real 401 response
+ // independently invalidates the session in APIClient.
+ }
+ }
+
+ private func persist() {
+ guard let user, let data = try? JSONEncoder().encode(user) else { return }
+ KeychainStore.write(data, account: storageKey)
+ }
+}
diff --git a/MauverseIOS/Sources/Design/DesignSystem.swift b/MauverseIOS/Sources/Design/DesignSystem.swift
new file mode 100644
index 0000000..95042a5
--- /dev/null
+++ b/MauverseIOS/Sources/Design/DesignSystem.swift
@@ -0,0 +1,234 @@
+import SwiftUI
+
+enum AppTheme: String, CaseIterable, Identifiable {
+ case system
+ case light
+ case dark
+
+ var id: String { rawValue }
+ var title: String {
+ return switch self {
+ case .system: "Как на iPhone"
+ case .light: "Светлая"
+ case .dark: "Тёмная"
+ }
+ }
+ var colorScheme: ColorScheme? {
+ return switch self {
+ case .system: nil
+ case .light: .light
+ case .dark: .dark
+ }
+ }
+}
+
+enum MauTheme {
+ static let blue = Color(red: 0.09, green: 0.46, blue: 0.97)
+ static let cyan = Color(red: 0.29, green: 0.78, blue: 1.0)
+ static let violet = Color(red: 0.47, green: 0.40, blue: 1.0)
+ static let navy = Color(red: 0.015, green: 0.075, blue: 0.13)
+ static let success = Color(red: 0.16, green: 0.70, blue: 0.45)
+ static let ink = Color(uiColor: .label)
+ static let muted = Color(uiColor: .secondaryLabel)
+ static let canvas = Color(uiColor: .systemBackground)
+ static let card = Color(uiColor: .secondarySystemBackground)
+ static let lavender = Color(red: 0.88, green: 0.90, blue: 1.0)
+
+ static let heroGradient = LinearGradient(
+ colors: [blue, Color(red: 0.08, green: 0.27, blue: 0.68), violet],
+ startPoint: .topLeading,
+ endPoint: .bottomTrailing
+ )
+}
+
+enum MauSpacing {
+ static let xs: CGFloat = 6
+ static let sm: CGFloat = 10
+ static let md: CGFloat = 16
+ static let lg: CGFloat = 22
+ static let xl: CGFloat = 30
+}
+
+enum MauRadius {
+ static let compact: CGFloat = 16
+ static let card: CGFloat = 24
+ static let hero: CGFloat = 30
+}
+
+struct MauBackground: View {
+ @Environment(\.colorScheme) private var colorScheme
+
+ var body: some View {
+ ZStack {
+ MauTheme.canvas
+ LinearGradient(
+ colors: colorScheme == .dark
+ ? [MauTheme.navy.opacity(0.96), .black, Color.purple.opacity(0.10)]
+ : [Color.blue.opacity(0.055), MauTheme.canvas, Color.purple.opacity(0.045)],
+ startPoint: .topTrailing,
+ endPoint: .bottomLeading
+ )
+ Circle()
+ .fill(MauTheme.cyan.opacity(colorScheme == .dark ? 0.13 : 0.10))
+ .frame(width: 420, height: 420)
+ .blur(radius: 70)
+ .offset(x: 190, y: -350)
+ Circle()
+ .fill(MauTheme.violet.opacity(colorScheme == .dark ? 0.15 : 0.075))
+ .frame(width: 390, height: 390)
+ .blur(radius: 80)
+ .offset(x: -190, y: 390)
+ }
+ .ignoresSafeArea()
+ }
+}
+
+private struct MauGlassModifier: ViewModifier {
+ let radius: CGFloat
+
+ @ViewBuilder
+ func body(content: Content) -> some View {
+ if #available(iOS 26.0, *) {
+ content.glassEffect(.regular, in: RoundedRectangle(cornerRadius: radius, style: .continuous))
+ } else {
+ content
+ .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: radius, style: .continuous))
+ .overlay {
+ RoundedRectangle(cornerRadius: radius, style: .continuous)
+ .stroke(Color.white.opacity(0.75), lineWidth: 1)
+ }
+ }
+ }
+}
+
+extension View {
+ func mauGlass(radius: CGFloat = 24) -> some View {
+ modifier(MauGlassModifier(radius: radius))
+ }
+
+ func pageTitle(_ title: String, subtitle: String? = nil) -> some View {
+ VStack(alignment: .leading, spacing: 7) {
+ Text(title)
+ .font(.system(size: 34, weight: .bold, design: .rounded))
+ .foregroundStyle(MauTheme.ink)
+ if let subtitle {
+ Text(subtitle)
+ .font(.subheadline)
+ .foregroundStyle(MauTheme.muted)
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+
+ func mauSurface(radius: CGFloat = MauRadius.card) -> some View {
+ background(MauTheme.card.opacity(0.78), in: RoundedRectangle(cornerRadius: radius, style: .continuous))
+ .overlay {
+ RoundedRectangle(cornerRadius: radius, style: .continuous)
+ .stroke(Color.primary.opacity(0.07), lineWidth: 0.75)
+ }
+ }
+}
+
+struct IconTile: View {
+ let systemName: String
+ var color = MauTheme.blue
+
+ var body: some View {
+ Image(systemName: systemName)
+ .font(.system(size: 20, weight: .semibold))
+ .foregroundStyle(.white)
+ .frame(width: 44, height: 44)
+ .background(color.gradient, in: RoundedRectangle(cornerRadius: 14, style: .continuous))
+ }
+}
+
+struct MauSectionHeader: View {
+ let title: String
+ var action: String?
+
+ var body: some View {
+ HStack(alignment: .firstTextBaseline) {
+ Text(title)
+ .font(.title3.bold())
+ .foregroundStyle(MauTheme.ink)
+ Spacer()
+ if let action {
+ Text(action)
+ .font(.subheadline.weight(.semibold))
+ .foregroundStyle(MauTheme.blue)
+ }
+ }
+ }
+}
+
+struct MauStatusPill: View {
+ let title: String
+ var icon = "checkmark.circle.fill"
+ var color = MauTheme.success
+
+ var body: some View {
+ Label(title, systemImage: icon)
+ .font(.caption.weight(.semibold))
+ .foregroundStyle(color)
+ .padding(.horizontal, 11)
+ .padding(.vertical, 7)
+ .background(color.opacity(0.12), in: Capsule())
+ }
+}
+
+struct SkeletonCard: View {
+ @State private var pulse = false
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 13) {
+ RoundedRectangle(cornerRadius: 14)
+ .frame(height: 150)
+ RoundedRectangle(cornerRadius: 6)
+ .frame(height: 18)
+ .padding(.trailing, 40)
+ RoundedRectangle(cornerRadius: 6)
+ .frame(width: 150, height: 12)
+ }
+ .foregroundStyle(MauTheme.muted.opacity(pulse ? 0.12 : 0.23))
+ .padding(16)
+ .mauSurface()
+ .animation(.easeInOut(duration: 0.9).repeatForever(autoreverses: true), value: pulse)
+ .onAppear { pulse = true }
+ }
+}
+
+struct LoadingOverlay: View {
+ let title: String
+
+ var body: some View {
+ HStack(spacing: 12) {
+ ProgressView()
+ Text(title).font(.subheadline.weight(.medium))
+ }
+ .padding(.horizontal, 18)
+ .padding(.vertical, 14)
+ .mauGlass(radius: 18)
+ }
+}
+
+struct EmptyState: View {
+ let icon: String
+ let title: String
+ let message: String
+
+ var body: some View {
+ VStack(spacing: 12) {
+ Image(systemName: icon)
+ .font(.system(size: 35))
+ .foregroundStyle(MauTheme.blue)
+ Text(title).font(.headline)
+ Text(message)
+ .font(.subheadline)
+ .foregroundStyle(MauTheme.muted)
+ .multilineTextAlignment(.center)
+ }
+ .frame(maxWidth: .infinity)
+ .padding(28)
+ .mauGlass()
+ }
+}
diff --git a/MauverseIOS/Sources/MAUverseApp.swift b/MauverseIOS/Sources/MAUverseApp.swift
new file mode 100644
index 0000000..a1b4742
--- /dev/null
+++ b/MauverseIOS/Sources/MAUverseApp.swift
@@ -0,0 +1,21 @@
+import SwiftUI
+
+@main
+struct MAUverseApp: App {
+ @StateObject private var session = SessionStore()
+ @AppStorage("mauverse.appearance") private var appearance = AppTheme.system.rawValue
+
+ var body: some Scene {
+ WindowGroup {
+ Group {
+ if session.user == nil {
+ LoginView()
+ } else {
+ RootTabView()
+ }
+ }
+ .environmentObject(session)
+ .preferredColorScheme(AppTheme(rawValue: appearance)?.colorScheme)
+ }
+ }
+}
diff --git a/MauverseIOS/Sources/Views/HomeView.swift b/MauverseIOS/Sources/Views/HomeView.swift
new file mode 100644
index 0000000..3135dad
--- /dev/null
+++ b/MauverseIOS/Sources/Views/HomeView.swift
@@ -0,0 +1,462 @@
+import SwiftUI
+
+@MainActor
+final class HomeViewModel: ObservableObject {
+ @Published var nextLesson: ScheduleItem?
+ @Published var featuredNews: NewsItem?
+ @Published var notifications: [MoodleNotification] = []
+ @Published var isLoading = false
+ @Published var lessonError: String?
+ @Published var newsError: String?
+ private var activeRequest = UUID()
+
+ func load(user: UserDTO?) async {
+ let request = UUID()
+ activeRequest = request
+ isLoading = true
+ lessonError = nil
+ newsError = nil
+ defer {
+ if activeRequest == request { isLoading = false }
+ }
+
+ let newsTask = Task { try await OfficialNewsService.shared.load(filter: .all) }
+ let notificationTask = Task { () throws -> [MoodleNotification] in
+ guard let token = user?.token, !token.isEmpty, let userId = user?.userId else { return [] }
+ return try await APIClient.shared.post(
+ "get_notifications",
+ body: NotificationRequest(token: token, userId: userId),
+ user: user,
+ retryOnTransient: true
+ )
+ }
+ if let user {
+ do {
+ var uid = user.scheduleGroupUID
+ if uid?.isEmpty != false, let name = user.groupName, !name.isEmpty {
+ uid = try await ScheduleAPIClient.shared.findGroup(named: name)?.uid
+ }
+ if let uid, !uid.isEmpty {
+ let formatter = DateFormatter()
+ formatter.locale = Locale(identifier: "en_US_POSIX")
+ formatter.dateFormat = "yyyy-MM-dd"
+ let start = Date()
+ let end = Calendar.current.date(byAdding: .day, value: 14, to: start) ?? start
+ let lessons = try await ScheduleAPIClient.shared.schedule(
+ uid: uid,
+ start: formatter.string(from: start),
+ end: formatter.string(from: end)
+ )
+ guard activeRequest == request else { return }
+ nextLesson = lessons
+ .filter { Self.lessonDate($0) >= Date().addingTimeInterval(-60 * 15) }
+ .sorted(by: Self.isEarlier)
+ .first
+ }
+ } catch {
+ guard activeRequest == request else { return }
+ lessonError = error.localizedDescription
+ }
+ }
+ do {
+ let loadedNews = try await newsTask.value.first
+ guard activeRequest == request else { return }
+ featuredNews = loadedNews
+ } catch {
+ guard activeRequest == request else { return }
+ newsError = error.localizedDescription
+ }
+ if let loaded = try? await notificationTask.value, activeRequest == request {
+ notifications = Array(loaded.prefix(8))
+ }
+ }
+
+ private static func isEarlier(_ lhs: ScheduleItem, _ rhs: ScheduleItem) -> Bool {
+ lessonDate(lhs) < lessonDate(rhs)
+ }
+
+ private static func lessonDate(_ item: ScheduleItem) -> Date {
+ let formatter = DateFormatter()
+ formatter.locale = Locale(identifier: "en_US_POSIX")
+ formatter.dateFormat = "yyyy-MM-dd HH:mm"
+ return formatter.date(from: "\(item.date ?? "") \(item.startTime ?? "00:00")") ?? .distantFuture
+ }
+}
+
+struct HomeView: View {
+ @EnvironmentObject private var session: SessionStore
+ @StateObject private var model = HomeViewModel()
+
+ private var firstName: String {
+ session.user?.greetingName ?? "Студент"
+ }
+
+ var body: some View {
+ ZStack {
+ MauBackground()
+ ScrollView {
+ VStack(alignment: .leading, spacing: MauSpacing.lg) {
+ header
+
+ if session.user?.groupName?.isEmpty != false {
+ missingGroupCard
+ } else if model.isLoading {
+ lessonSkeleton
+ } else if let error = model.lessonError {
+ HomeDataErrorCard(
+ title: "Расписание недоступно",
+ message: error,
+ icon: "calendar.badge.exclamationmark"
+ )
+ } else {
+ NextLessonCard(item: model.nextLesson, group: session.user?.groupName)
+ }
+
+ MauSectionHeader(title: "Быстрый доступ")
+ quickActions
+
+ if !model.notifications.isEmpty {
+ MauSectionHeader(title: "Обновления ЭИОС")
+ ScrollView(.horizontal, showsIndicators: false) {
+ LazyHStack(spacing: 12) {
+ ForEach(model.notifications) { notification in
+ MoodleNotificationCard(notification: notification)
+ }
+ }
+ }
+ }
+
+ if let news = model.featuredNews {
+ HStack(alignment: .firstTextBaseline) {
+ Text("Главное в МАУ")
+ .font(.title3.bold())
+ .foregroundStyle(MauTheme.ink)
+ Spacer()
+ NavigationLink(destination: NewsView()) {
+ HStack(spacing: 4) {
+ Text("Все новости")
+ Image(systemName: "chevron.right")
+ .font(.caption.bold())
+ }
+ .font(.subheadline.weight(.semibold))
+ .foregroundStyle(MauTheme.blue)
+ }
+ }
+ FeaturedNewsCard(item: news)
+ } else if let error = model.newsError {
+ HomeDataErrorCard(
+ title: "Новости не обновились",
+ message: error,
+ icon: "newspaper"
+ )
+ }
+ }
+ .padding(.horizontal, 20)
+ .padding(.top, 14)
+ .padding(.bottom, 96)
+ }
+ .refreshable { await model.load(user: session.user) }
+ }
+ .toolbar(.hidden, for: .navigationBar)
+ .task { await model.load(user: session.user) }
+ }
+
+ private var header: some View {
+ HStack(alignment: .top, spacing: 14) {
+ VStack(alignment: .leading, spacing: 5) {
+ Text(greeting)
+ .font(.subheadline.weight(.medium))
+ .foregroundStyle(MauTheme.muted)
+ Text(firstName)
+ .font(.system(size: 38, weight: .bold, design: .rounded))
+ .foregroundStyle(MauTheme.ink)
+ HStack(spacing: 8) {
+ MauStatusPill(title: "Сессия защищена", icon: "lock.fill")
+ if let group = session.user?.groupName, !group.isEmpty {
+ Text(group)
+ .font(.caption.weight(.semibold))
+ .foregroundStyle(MauTheme.muted)
+ .lineLimit(1)
+ }
+ }
+ }
+ Spacer()
+ NavigationLink(destination: ProfileView()) {
+ ZStack {
+ Circle().fill(MauTheme.blue.opacity(0.13))
+ Image(systemName: "person.crop.circle.fill")
+ .font(.system(size: 38))
+ .symbolRenderingMode(.hierarchical)
+ .foregroundStyle(MauTheme.blue)
+ }
+ .frame(width: 52, height: 52)
+ }
+ }
+ }
+
+ private var missingGroupCard: some View {
+ NavigationLink(destination: ProfileView()) {
+ HStack(spacing: 15) {
+ IconTile(systemName: "person.3.fill", color: .orange)
+ VStack(alignment: .leading, spacing: 4) {
+ Text("Добавьте учебную группу").font(.headline)
+ Text("Покажем ближайшее занятие и актуальное расписание")
+ .font(.caption)
+ .foregroundStyle(MauTheme.muted)
+ }
+ Spacer()
+ Image(systemName: "chevron.right").foregroundStyle(MauTheme.muted)
+ }
+ .padding(18)
+ }
+ .buttonStyle(.plain)
+ .mauGlass(radius: MauRadius.card)
+ }
+
+ private var lessonSkeleton: some View {
+ RoundedRectangle(cornerRadius: MauRadius.hero)
+ .fill(MauTheme.blue.opacity(0.14))
+ .frame(height: 220)
+ .overlay { ProgressView().tint(MauTheme.blue) }
+ }
+
+ private var quickActions: some View {
+ LazyVGrid(
+ columns: [GridItem(.flexible(), spacing: 12), GridItem(.flexible(), spacing: 12)],
+ spacing: 12
+ ) {
+ PremiumQuickCard(title: "Расписание", subtitle: "Занятия и аудитории", icon: "calendar", color: MauTheme.blue) {
+ ScheduleView()
+ }
+ PremiumQuickCard(title: "Сервисы", subtitle: "Все инструменты МАУ", icon: "square.grid.2x2.fill", color: MauTheme.violet) {
+ ServicesView()
+ }
+ PremiumQuickCard(title: "Новости", subtitle: "События университета", icon: "newspaper.fill", color: .orange) {
+ NewsView()
+ }
+ PremiumQuickCard(title: "ЭИОС", subtitle: "Учебный портал", icon: "graduationcap.fill", color: .teal) {
+ InAppBrowserView(url: URL(string: "https://eios.mauniver.ru/moodle/")!, title: "ЭИОС")
+ }
+ }
+ }
+
+ private var greeting: String {
+ let hour = Calendar.current.component(.hour, from: Date())
+ return switch hour {
+ case 5..<12: "Доброе утро,"
+ case 12..<18: "Добрый день,"
+ default: "Добрый вечер,"
+ }
+ }
+}
+
+private struct MoodleNotificationCard: View {
+ let notification: MoodleNotification
+
+ var body: some View {
+ Group {
+ if let destination = notification.destination {
+ NavigationLink {
+ InAppBrowserView(url: destination, title: notification.title)
+ } label: {
+ content
+ }
+ } else {
+ content
+ }
+ }
+ .buttonStyle(.plain)
+ }
+
+ private var content: some View {
+ VStack(alignment: .leading, spacing: 9) {
+ Label("ЭИОС", systemImage: "bell.badge.fill")
+ .font(.caption.bold())
+ .foregroundStyle(MauTheme.blue)
+ Text(notification.title)
+ .font(.subheadline.weight(.semibold))
+ .foregroundStyle(MauTheme.ink)
+ .lineLimit(3)
+ Text(notification.subtitle)
+ .font(.caption)
+ .foregroundStyle(MauTheme.muted)
+ .lineLimit(2)
+ Spacer(minLength: 0)
+ if let value = notification.timeCreatedString, !value.isEmpty {
+ Text(value)
+ .font(.caption2)
+ .foregroundStyle(MauTheme.muted)
+ }
+ }
+ .padding(16)
+ .frame(width: 272, height: 164, alignment: .topLeading)
+ .mauSurface(radius: 22)
+ }
+}
+
+private struct HomeDataErrorCard: View {
+ let title: String
+ let message: String
+ let icon: String
+
+ 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)
+ }
+ Spacer()
+ }
+ .padding(17)
+ .mauSurface(radius: 22)
+ }
+}
+
+private struct NextLessonCard: View {
+ let item: ScheduleItem?
+ let group: String?
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 20) {
+ HStack {
+ Label("БЛИЖАЙШЕЕ ЗАНЯТИЕ", systemImage: "sparkles")
+ .font(.caption2.bold())
+ .tracking(0.8)
+ Spacer()
+ Text(group ?? "")
+ .font(.caption.weight(.semibold))
+ .lineLimit(1)
+ }
+ .foregroundStyle(.white.opacity(0.82))
+
+ if let item {
+ VStack(alignment: .leading, spacing: 8) {
+ Text(item.name ?? "Занятие")
+ .font(.system(size: 25, weight: .bold, design: .rounded))
+ .lineLimit(2)
+ Text([item.startTime, item.endTime].compactMap { $0 }.joined(separator: " — "))
+ .font(.system(size: 32, weight: .bold, design: .rounded))
+ }
+ HStack(spacing: 16) {
+ if let room = item.room {
+ Label(room, systemImage: "mappin.and.ellipse")
+ }
+ if let type = item.pairType {
+ Label(type, systemImage: "book.closed.fill")
+ }
+ }
+ .font(.caption.weight(.semibold))
+ .lineLimit(1)
+ } else {
+ VStack(alignment: .leading, spacing: 8) {
+ Text("Расписание свободно")
+ .font(.system(size: 25, weight: .bold, design: .rounded))
+ Text("Новых занятий на ближайшие две недели нет")
+ .font(.subheadline)
+ .foregroundStyle(.white.opacity(0.76))
+ }
+ }
+ }
+ .foregroundStyle(.white)
+ .frame(maxWidth: .infinity, minHeight: 176, alignment: .topLeading)
+ .padding(22)
+ .background(MauTheme.heroGradient, in: RoundedRectangle(cornerRadius: MauRadius.hero, style: .continuous))
+ .overlay(alignment: .topTrailing) {
+ Circle()
+ .fill(.white.opacity(0.12))
+ .frame(width: 130, height: 130)
+ .blur(radius: 2)
+ .offset(x: 42, y: -45)
+ }
+ .clipShape(RoundedRectangle(cornerRadius: MauRadius.hero, style: .continuous))
+ .shadow(color: MauTheme.blue.opacity(0.24), radius: 22, y: 12)
+ }
+}
+
+private struct PremiumQuickCard: View {
+ let title: String
+ let subtitle: String
+ let icon: String
+ let color: Color
+ @ViewBuilder let destination: () -> Destination
+
+ 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)
+ }
+ .buttonStyle(.plain)
+ .mauSurface(radius: 22)
+ }
+}
+
+private struct FeaturedNewsCard: View {
+ let item: NewsItem
+
+ @ViewBuilder
+ var body: some View {
+ if let link = item.link, let url = URL(string: link) {
+ NavigationLink(destination: InAppBrowserView(url: url, title: item.title ?? "Новости МАУ")) {
+ content
+ }
+ .buttonStyle(.plain)
+ } else {
+ content
+ }
+ }
+
+ private var content: some View {
+ ZStack(alignment: .bottomLeading) {
+ AsyncImage(url: URL(string: item.image ?? "")) { phase in
+ if case .success(let image) = phase {
+ image
+ .resizable()
+ .scaledToFill()
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .clipped()
+ } else {
+ MauTheme.heroGradient
+ }
+ }
+ .frame(height: 250)
+ .clipped()
+
+ LinearGradient(colors: [.clear, .black.opacity(0.86)], startPoint: .top, endPoint: .bottom)
+
+ VStack(alignment: .leading, spacing: 8) {
+ Text(item.publish ?? "МАУ")
+ .font(.caption.bold())
+ .foregroundStyle(MauTheme.cyan)
+ Text(item.title ?? "Новости университета")
+ .font(.title3.bold())
+ .foregroundStyle(.white)
+ .lineLimit(3)
+ }
+ .padding(20)
+ }
+ .frame(maxWidth: .infinity)
+ .frame(height: 250)
+ .clipShape(RoundedRectangle(cornerRadius: MauRadius.hero, style: .continuous))
+ }
+}
diff --git a/MauverseIOS/Sources/Views/InAppBrowserView.swift b/MauverseIOS/Sources/Views/InAppBrowserView.swift
new file mode 100644
index 0000000..b5c0fd5
--- /dev/null
+++ b/MauverseIOS/Sources/Views/InAppBrowserView.swift
@@ -0,0 +1,302 @@
+import Combine
+import SwiftUI
+import WebKit
+
+final class InAppBrowserController: NSObject, ObservableObject, WKNavigationDelegate, WKUIDelegate {
+ @Published private(set) var title: String
+ @Published private(set) var currentURL: URL?
+ @Published private(set) var progress = 0.0
+ @Published private(set) var isLoading = false
+ @Published private(set) var canGoBack = false
+ @Published private(set) var canGoForward = false
+ @Published private(set) var errorMessage: String?
+
+ let webView: WKWebView
+ private let initialURL: URL
+ private let fallbackTitle: String
+ private var observations: [NSKeyValueObservation] = []
+
+ init(url: URL, title: String) {
+ initialURL = url
+ fallbackTitle = title
+ self.title = title
+
+ let configuration = WKWebViewConfiguration()
+ configuration.websiteDataStore = .default()
+ configuration.applicationNameForUserAgent = "MAUverse/1.11.0"
+ configuration.defaultWebpagePreferences.allowsContentJavaScript = true
+ configuration.allowsInlineMediaPlayback = true
+ webView = WKWebView(frame: .zero, configuration: configuration)
+
+ super.init()
+
+ webView.navigationDelegate = self
+ webView.uiDelegate = self
+ webView.allowsBackForwardNavigationGestures = true
+ webView.allowsLinkPreview = true
+ webView.isOpaque = false
+ webView.backgroundColor = .clear
+ webView.scrollView.backgroundColor = .clear
+
+ observations = [
+ webView.observe(\.estimatedProgress, options: [.new]) { [weak self] webView, _ in
+ DispatchQueue.main.async { self?.progress = webView.estimatedProgress }
+ },
+ webView.observe(\.title, options: [.new]) { [weak self] webView, _ in
+ DispatchQueue.main.async {
+ self?.title = webView.title?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty
+ ?? self?.fallbackTitle
+ ?? "Браузер"
+ }
+ },
+ webView.observe(\.url, options: [.new]) { [weak self] webView, _ in
+ DispatchQueue.main.async { self?.currentURL = webView.url }
+ },
+ webView.observe(\.canGoBack, options: [.new]) { [weak self] webView, _ in
+ DispatchQueue.main.async { self?.canGoBack = webView.canGoBack }
+ },
+ webView.observe(\.canGoForward, options: [.new]) { [weak self] webView, _ in
+ DispatchQueue.main.async { self?.canGoForward = webView.canGoForward }
+ }
+ ]
+ loadInitialPage()
+ }
+
+ func loadInitialPage() {
+ errorMessage = nil
+ webView.load(URLRequest(url: initialURL, cachePolicy: .useProtocolCachePolicy, timeoutInterval: 45))
+ }
+
+ func goBack() {
+ guard webView.canGoBack else { return }
+ webView.goBack()
+ }
+
+ func goForward() {
+ guard webView.canGoForward else { return }
+ webView.goForward()
+ }
+
+ func reloadOrStop() {
+ if webView.isLoading {
+ webView.stopLoading()
+ } else if webView.url == nil {
+ loadInitialPage()
+ } else {
+ errorMessage = nil
+ webView.reload()
+ }
+ }
+
+ func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
+ isLoading = true
+ errorMessage = nil
+ }
+
+ func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
+ isLoading = false
+ progress = 1
+ updateNavigationState(webView)
+ }
+
+ func webView(
+ _ webView: WKWebView,
+ didFail navigation: WKNavigation!,
+ withError error: Error
+ ) {
+ handle(error, webView: webView)
+ }
+
+ func webView(
+ _ webView: WKWebView,
+ didFailProvisionalNavigation navigation: WKNavigation!,
+ withError error: Error
+ ) {
+ handle(error, webView: webView)
+ }
+
+ func webView(
+ _ webView: WKWebView,
+ decidePolicyFor navigationAction: WKNavigationAction,
+ decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
+ ) {
+ guard let url = navigationAction.request.url else {
+ decisionHandler(.cancel)
+ return
+ }
+ let scheme = url.scheme?.lowercased()
+ if scheme == "http" || scheme == "https" || scheme == "about" {
+ decisionHandler(.allow)
+ } else {
+ if UIApplication.shared.canOpenURL(url) {
+ UIApplication.shared.open(url)
+ }
+ decisionHandler(.cancel)
+ }
+ }
+
+ func webView(
+ _ webView: WKWebView,
+ createWebViewWith configuration: WKWebViewConfiguration,
+ for navigationAction: WKNavigationAction,
+ windowFeatures: WKWindowFeatures
+ ) -> WKWebView? {
+ if navigationAction.targetFrame == nil, let requestURL = navigationAction.request.url {
+ webView.load(URLRequest(url: requestURL))
+ }
+ return nil
+ }
+
+ private func handle(_ error: Error, webView: WKWebView) {
+ if (error as? URLError)?.code == .cancelled { return }
+ isLoading = false
+ errorMessage = (error as? URLError)?.code == .notConnectedToInternet
+ ? "Нет подключения к интернету"
+ : "Не удалось открыть страницу"
+ updateNavigationState(webView)
+ }
+
+ private func updateNavigationState(_ webView: WKWebView) {
+ currentURL = webView.url
+ canGoBack = webView.canGoBack
+ canGoForward = webView.canGoForward
+ }
+}
+
+struct InAppBrowserView: View {
+ @StateObject private var browser: InAppBrowserController
+
+ init(url: URL, title: String) {
+ _browser = StateObject(wrappedValue: InAppBrowserController(url: url, title: title))
+ }
+
+ var body: some View {
+ ZStack {
+ MauBackground()
+ BrowserWebView(webView: browser.webView)
+
+ if let error = browser.errorMessage {
+ VStack(spacing: 16) {
+ EmptyState(
+ icon: "wifi.exclamationmark",
+ title: "Страница недоступна",
+ message: error
+ )
+ Button("Попробовать снова") { browser.reloadOrStop() }
+ .buttonStyle(.borderedProminent)
+ }
+ .padding(24)
+ }
+ }
+ .safeAreaInset(edge: .top, spacing: 0) {
+ BrowserLocationBar(browser: browser)
+ }
+ .safeAreaInset(edge: .bottom, spacing: 0) {
+ BrowserToolbar(browser: browser)
+ }
+ .navigationTitle(browser.title)
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar(.hidden, for: .tabBar)
+ }
+}
+
+private struct BrowserWebView: UIViewRepresentable {
+ let webView: WKWebView
+
+ func makeUIView(context: Context) -> WKWebView { webView }
+ func updateUIView(_ uiView: WKWebView, context: Context) {}
+}
+
+private struct BrowserLocationBar: View {
+ @ObservedObject var browser: InAppBrowserController
+
+ var body: some View {
+ VStack(spacing: 0) {
+ HStack(spacing: 8) {
+ Image(systemName: browser.currentURL?.scheme == "https" ? "lock.fill" : "globe")
+ .font(.caption2)
+ .foregroundStyle(browser.currentURL?.scheme == "https" ? .green : MauTheme.muted)
+ Text(browser.currentURL?.host ?? "Загрузка…")
+ .font(.caption.weight(.semibold))
+ .foregroundStyle(MauTheme.muted)
+ .lineLimit(1)
+ Spacer()
+ }
+ .padding(.horizontal, 16)
+ .padding(.vertical, 9)
+ .background(.bar)
+
+ if browser.isLoading {
+ ProgressView(value: browser.progress)
+ .progressViewStyle(.linear)
+ .tint(MauTheme.blue)
+ }
+ }
+ }
+}
+
+private struct BrowserToolbar: View {
+ @ObservedObject var browser: InAppBrowserController
+
+ var body: some View {
+ HStack {
+ toolbarButton("chevron.left", enabled: browser.canGoBack, action: browser.goBack)
+ Spacer()
+ toolbarButton("chevron.right", enabled: browser.canGoForward, action: browser.goForward)
+ Spacer()
+ toolbarButton(browser.isLoading ? "xmark" : "arrow.clockwise", action: browser.reloadOrStop)
+ Spacer()
+ if let url = browser.currentURL {
+ ShareLink(item: url) {
+ Image(systemName: "square.and.arrow.up")
+ .frame(width: 44, height: 44)
+ }
+ .foregroundStyle(MauTheme.ink)
+ } else {
+ toolbarButton("square.and.arrow.up", enabled: false, action: {})
+ }
+ Spacer()
+ Menu {
+ if let url = browser.currentURL {
+ Button {
+ UIPasteboard.general.url = url
+ } label: {
+ Label("Скопировать ссылку", systemImage: "doc.on.doc")
+ }
+ }
+ Button {
+ browser.loadInitialPage()
+ } label: {
+ Label("На первую страницу", systemImage: "house")
+ }
+ } label: {
+ Image(systemName: "ellipsis")
+ .frame(width: 44, height: 44)
+ }
+ .foregroundStyle(MauTheme.ink)
+ }
+ .font(.system(size: 18, weight: .semibold))
+ .padding(.horizontal, 18)
+ .padding(.vertical, 7)
+ .mauGlass(radius: 28)
+ .padding(.horizontal, 12)
+ .padding(.bottom, 8)
+ }
+
+ private func toolbarButton(
+ _ systemName: String,
+ enabled: Bool = true,
+ action: @escaping () -> Void
+ ) -> some View {
+ Button(action: action) {
+ Image(systemName: systemName)
+ .frame(width: 44, height: 44)
+ }
+ .disabled(!enabled)
+ .foregroundStyle(enabled ? MauTheme.ink : MauTheme.muted.opacity(0.4))
+ }
+}
+
+private extension String {
+ var nilIfEmpty: String? { isEmpty ? nil : self }
+}
diff --git a/MauverseIOS/Sources/Views/LoginView.swift b/MauverseIOS/Sources/Views/LoginView.swift
new file mode 100644
index 0000000..4bb6b3a
--- /dev/null
+++ b/MauverseIOS/Sources/Views/LoginView.swift
@@ -0,0 +1,100 @@
+import SwiftUI
+
+struct LoginView: View {
+ @EnvironmentObject private var session: SessionStore
+ @State private var username = ""
+ @State private var password = ""
+ @FocusState private var focused: Field?
+
+ private enum Field { case username, password }
+
+ var body: some View {
+ ZStack {
+ MauBackground()
+ ScrollView {
+ VStack(spacing: 26) {
+ Spacer(minLength: 54)
+ VStack(spacing: 14) {
+ ZStack {
+ RoundedRectangle(cornerRadius: 30, style: .continuous)
+ .fill(MauTheme.heroGradient)
+ Image(systemName: "snowflake")
+ .font(.system(size: 45, weight: .medium))
+ .foregroundStyle(.white)
+ }
+ .frame(width: 96, height: 96)
+ .overlay {
+ RoundedRectangle(cornerRadius: 30, style: .continuous)
+ .stroke(.white.opacity(0.24), lineWidth: 1)
+ }
+ .shadow(color: MauTheme.blue.opacity(0.28), radius: 28, y: 14)
+
+ Text("MAUverse")
+ .font(.system(size: 40, weight: .bold, design: .rounded))
+ Text("Цифровая среда Арктического университета")
+ .font(.subheadline)
+ .foregroundStyle(MauTheme.muted)
+ .multilineTextAlignment(.center)
+ }
+
+ VStack(spacing: 16) {
+ TextField("Логин ЭИОС", text: $username)
+ .textContentType(.username)
+ .textInputAutocapitalization(.never)
+ .autocorrectionDisabled()
+ .focused($focused, equals: .username)
+ .submitLabel(.next)
+ .onSubmit { focused = .password }
+ .padding(18)
+ .background(MauTheme.card.opacity(0.78), in: RoundedRectangle(cornerRadius: 18))
+
+ SecureField("Пароль", text: $password)
+ .textContentType(.password)
+ .focused($focused, equals: .password)
+ .submitLabel(.go)
+ .onSubmit { signIn() }
+ .padding(18)
+ .background(MauTheme.card.opacity(0.78), in: RoundedRectangle(cornerRadius: 18))
+
+ if let error = session.errorMessage {
+ Text(error)
+ .font(.footnote)
+ .foregroundStyle(.red)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+
+ Button(action: signIn) {
+ HStack {
+ if session.isBusy { ProgressView().tint(.white) }
+ Text(session.isBusy ? "Входим…" : "Войти")
+ .fontWeight(.semibold)
+ }
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 17)
+ }
+ .buttonStyle(.plain)
+ .foregroundStyle(.white)
+ .background(MauTheme.heroGradient, in: RoundedRectangle(cornerRadius: 18))
+ .shadow(color: MauTheme.blue.opacity(0.22), radius: 14, y: 7)
+ .disabled(session.isBusy)
+ }
+ .padding(20)
+ .mauSurface(radius: 28)
+
+ Text("Используйте данные от электронной информационно-образовательной среды МАУ")
+ .font(.caption)
+ .foregroundStyle(MauTheme.muted)
+ .multilineTextAlignment(.center)
+ .padding(.horizontal, 24)
+ }
+ .padding(.horizontal, 20)
+ .padding(.bottom, 30)
+ }
+ }
+ }
+
+ private func signIn() {
+ focused = nil
+ Task { await session.signIn(username: username, password: password) }
+ }
+}
diff --git a/MauverseIOS/Sources/Views/NewsView.swift b/MauverseIOS/Sources/Views/NewsView.swift
new file mode 100644
index 0000000..52f0f37
--- /dev/null
+++ b/MauverseIOS/Sources/Views/NewsView.swift
@@ -0,0 +1,260 @@
+import SwiftUI
+
+@MainActor
+final class NewsViewModel: ObservableObject {
+ @Published var items: [NewsItem] = []
+ @Published var filter: NewsFilter = .all
+ @Published var isLoading = false
+ @Published var error: String?
+ private var activeRequest = UUID()
+ private var loadedFilter: NewsFilter?
+
+ func load() async {
+ let request = UUID()
+ activeRequest = request
+ let requestedFilter = filter
+ if loadedFilter != requestedFilter {
+ items = []
+ }
+ isLoading = items.isEmpty
+ error = nil
+ do {
+ let loaded = try await OfficialNewsService.shared.load(filter: requestedFilter)
+ guard activeRequest == request, filter == requestedFilter else { return }
+ items = loaded
+ loadedFilter = requestedFilter
+ } catch {
+ guard activeRequest == request else { return }
+ if (error as? URLError)?.code != .cancelled, items.isEmpty {
+ self.error = Self.friendlyMessage(for: error)
+ }
+ }
+ if activeRequest == request { isLoading = false }
+ }
+
+ private static func friendlyMessage(for error: Error) -> String {
+ if let urlError = error as? URLError {
+ return switch urlError.code {
+ case .notConnectedToInternet: "Проверьте подключение к интернету"
+ case .timedOut: "Сайт МАУ отвечает слишком долго. Попробуйте ещё раз"
+ default: "Не удалось обновить ленту. Потяните экран ещё раз"
+ }
+ }
+ return error.localizedDescription
+ }
+}
+
+struct NewsView: View {
+ @StateObject private var model = NewsViewModel()
+
+ var body: some View {
+ ZStack {
+ MauBackground()
+ ScrollView {
+ VStack(alignment: .leading, spacing: 18) {
+ VStack(alignment: .leading, spacing: 6) {
+ Text("Новости")
+ .font(.system(size: 36, weight: .bold, design: .rounded))
+ Text("Главное в жизни университета")
+ .font(.subheadline)
+ .foregroundStyle(MauTheme.muted)
+ }
+
+ ScrollView(.horizontal, showsIndicators: false) {
+ HStack(spacing: 9) {
+ ForEach(NewsFilter.allCases) { filter in
+ Button(filter.title) {
+ model.filter = filter
+ Task { await model.load() }
+ }
+ .font(.system(size: 14, weight: .semibold))
+ .foregroundStyle(model.filter == filter ? .white : MauTheme.ink)
+ .padding(.horizontal, 15)
+ .padding(.vertical, 10)
+ .background(model.filter == filter ? AnyShapeStyle(MauTheme.blue.gradient) : AnyShapeStyle(MauTheme.card.opacity(0.78)),
+ in: Capsule())
+ .buttonStyle(.plain)
+ }
+ }
+ }
+
+ if model.isLoading {
+ VStack(spacing: 16) {
+ SkeletonCard()
+ SkeletonCard()
+ }
+ } else if let error = model.error {
+ VStack(spacing: 12) {
+ EmptyState(icon: "wifi.exclamationmark", title: "Новости недоступны", message: error)
+ Button("Повторить") { Task { await model.load() } }
+ .buttonStyle(.borderedProminent)
+ }
+ } else if model.items.isEmpty {
+ EmptyState(icon: "newspaper", title: "Новостей пока нет", message: "Попробуйте выбрать другую категорию")
+ } else {
+ LazyVStack(spacing: 14) {
+ if let first = model.items.first {
+ HeroNewsCard(item: first, category: model.filter.title)
+ }
+ ForEach(Array(model.items.dropFirst())) {
+ NewsCard(item: $0, category: model.filter.title)
+ }
+ }
+ }
+ }
+ .padding(20)
+ .padding(.bottom, 96)
+ }
+ .refreshable { await model.load() }
+ }
+ .navigationBarTitleDisplayMode(.inline)
+ .task { if model.items.isEmpty { await model.load() } }
+ }
+}
+
+private struct NewsCard: View {
+ let item: NewsItem
+ let category: String
+
+ @ViewBuilder
+ var body: some View {
+ if let value = item.link, let url = URL(string: value) {
+ NavigationLink(destination: InAppBrowserView(url: url, title: item.title ?? "Новости МАУ")) {
+ content
+ }
+ .buttonStyle(.plain)
+ .mauGlass(radius: 24)
+ } else {
+ content.mauGlass(radius: 24)
+ }
+ }
+
+ private var content: some View {
+ HStack(spacing: 0) {
+ if let source = item.image, let url = URL(string: source) {
+ AsyncImage(url: url) { phase in
+ if case .success(let image) = phase {
+ image.resizable().scaledToFill()
+ } else {
+ MauTheme.heroGradient
+ .overlay(Image(systemName: "photo").foregroundStyle(.white.opacity(0.7)))
+ }
+ }
+ .frame(width: 120, height: 138)
+ .clipped()
+ }
+ VStack(alignment: .leading, spacing: 7) {
+ Text(category.uppercased())
+ .font(.system(size: 9, weight: .bold))
+ .tracking(0.7)
+ .foregroundStyle(MauTheme.blue)
+ Text(item.title ?? "Новость")
+ .font(.system(size: 15, weight: .semibold))
+ .multilineTextAlignment(.leading)
+ .foregroundStyle(MauTheme.ink)
+ .lineLimit(3)
+ .minimumScaleFactor(0.9)
+ Spacer(minLength: 0)
+ if let date = item.publish {
+ Text(date)
+ .font(.caption2)
+ .foregroundStyle(MauTheme.muted)
+ }
+ }
+ .frame(maxWidth: .infinity, minHeight: 106, alignment: .topLeading)
+ .padding(16)
+ }
+ .background(MauTheme.card.opacity(0.74))
+ .clipShape(RoundedRectangle(cornerRadius: MauRadius.card, style: .continuous))
+ }
+}
+
+private struct HeroNewsCard: View {
+ let item: NewsItem
+ let category: String
+
+ @ViewBuilder
+ var body: some View {
+ if let value = item.link, let url = URL(string: value) {
+ NavigationLink(destination: InAppBrowserView(url: url, title: item.title ?? "Новости МАУ")) {
+ content
+ }
+ .buttonStyle(.plain)
+ } else {
+ content
+ }
+ }
+
+ private var content: some View {
+ VStack(alignment: .leading, spacing: 0) {
+ if let source = item.image, let url = URL(string: source) {
+ AsyncImage(url: url) { phase in
+ if case .success(let image) = phase {
+ image
+ .resizable()
+ .scaledToFill()
+ } else {
+ MauTheme.heroGradient
+ .overlay {
+ Image(systemName: "newspaper.fill")
+ .font(.system(size: 38))
+ .foregroundStyle(.white.opacity(0.72))
+ }
+ }
+ }
+ .frame(maxWidth: .infinity)
+ .frame(height: 205)
+ .clipped()
+ } else {
+ MauTheme.heroGradient
+ .frame(height: 205)
+ }
+
+ VStack(alignment: .leading, spacing: 9) {
+ HStack {
+ Text(category.uppercased())
+ .font(.caption2.bold())
+ .tracking(0.8)
+ .foregroundStyle(MauTheme.blue)
+ Spacer()
+ if let date = item.publish {
+ Text(date)
+ .font(.caption2)
+ .foregroundStyle(MauTheme.muted)
+ .lineLimit(1)
+ }
+ }
+ Text(item.title ?? "Новости университета")
+ .font(.system(size: 19, weight: .bold, design: .rounded))
+ .foregroundStyle(MauTheme.ink)
+ .multilineTextAlignment(.leading)
+ .lineLimit(4)
+ .fixedSize(horizontal: false, vertical: true)
+ if let description = item.description {
+ Text(description.strippingHTML)
+ .font(.system(size: 13, weight: .regular))
+ .foregroundStyle(MauTheme.muted)
+ .lineLimit(2)
+ .multilineTextAlignment(.leading)
+ }
+ }
+ .padding(20)
+ }
+ .frame(maxWidth: .infinity)
+ .background(MauTheme.card.opacity(0.82))
+ .clipShape(RoundedRectangle(cornerRadius: MauRadius.hero, style: .continuous))
+ .overlay {
+ RoundedRectangle(cornerRadius: MauRadius.hero, style: .continuous)
+ .stroke(Color.primary.opacity(0.07), lineWidth: 0.75)
+ }
+ .shadow(color: .black.opacity(0.12), radius: 18, y: 10)
+ }
+}
+
+private extension String {
+ var strippingHTML: String {
+ replacingOccurrences(of: "<[^>]+>", with: "", options: .regularExpression)
+ .replacingOccurrences(of: " ", with: " ")
+ .replacingOccurrences(of: "&", with: "&")
+ }
+}
diff --git a/MauverseIOS/Sources/Views/ProfileView.swift b/MauverseIOS/Sources/Views/ProfileView.swift
new file mode 100644
index 0000000..a053ff4
--- /dev/null
+++ b/MauverseIOS/Sources/Views/ProfileView.swift
@@ -0,0 +1,301 @@
+import SwiftUI
+import WebKit
+
+struct ProfileView: View {
+ @EnvironmentObject private var session: SessionStore
+ @State private var showingEditor = false
+ @State private var showingLogout = false
+
+ var body: some View {
+ ZStack {
+ MauBackground()
+ ScrollView {
+ VStack(spacing: 22) {
+ VStack(spacing: 13) {
+ ZStack {
+ Circle().fill(.white.opacity(0.16))
+ Image(systemName: "person.fill")
+ .font(.system(size: 42, weight: .medium))
+ .foregroundStyle(.white)
+ }
+ .frame(width: 86, height: 86)
+ .overlay(Circle().stroke(.white.opacity(0.25), lineWidth: 1))
+
+ Text(session.user?.displayName ?? "Пользователь")
+ .font(.system(size: 25, weight: .bold, design: .rounded))
+ .multilineTextAlignment(.center)
+ Text(session.user?.username ?? "")
+ .font(.subheadline)
+ .foregroundStyle(.white.opacity(0.72))
+ HStack(spacing: 8) {
+ Label("Сессия в Keychain", systemImage: "lock.fill")
+ if let group = session.user?.groupName, !group.isEmpty {
+ Text("•")
+ Text(group)
+ }
+ }
+ .font(.caption.weight(.semibold))
+ .foregroundStyle(.white.opacity(0.82))
+ }
+ .foregroundStyle(.white)
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 26)
+ .padding(.horizontal, 20)
+ .background(MauTheme.heroGradient, in: RoundedRectangle(cornerRadius: MauRadius.hero, style: .continuous))
+ .shadow(color: MauTheme.blue.opacity(0.2), radius: 20, y: 10)
+
+ VStack(alignment: .leading, spacing: 18) {
+ Label("Учебные данные", systemImage: "graduationcap.fill")
+ .font(.headline)
+ .foregroundStyle(MauTheme.blue)
+ ProfileRow(label: "Группа", value: session.user?.groupName)
+ Divider()
+ ProfileRow(label: "Специальность", value: session.user?.speciality)
+ Divider()
+ ProfileRow(label: "Зачётная книжка", value: session.user?.creditBook)
+ }
+ .padding(20)
+ .mauSurface()
+
+ VStack(spacing: 0) {
+ Button { showingEditor = true } label: {
+ ProfileActionRow(title: "Учебные данные", icon: "pencil", color: MauTheme.blue)
+ }
+ Divider().padding(.leading, 58)
+ NavigationLink(destination: SettingsView()) {
+ ProfileActionRow(title: "Настройки", icon: "gearshape.fill", color: MauTheme.violet)
+ }
+ Divider().padding(.leading, 58)
+ Button(role: .destructive) { showingLogout = true } label: {
+ ProfileActionRow(title: "Выйти из аккаунта", icon: "rectangle.portrait.and.arrow.right", color: .red)
+ }
+ }
+ .buttonStyle(.plain)
+ .mauSurface()
+
+ Text("MAUverse 1.11.0 (28)")
+ .font(.caption)
+ .foregroundStyle(MauTheme.muted)
+ }
+ .padding(20)
+ .padding(.bottom, 96)
+ }
+ }
+ .navigationTitle("Профиль")
+ .navigationBarTitleDisplayMode(.inline)
+ .sheet(isPresented: $showingEditor) { ProfileEditor() }
+ .confirmationDialog("Выйти из аккаунта?", isPresented: $showingLogout, titleVisibility: .visible) {
+ Button("Выйти", role: .destructive) { session.signOut() }
+ }
+ }
+}
+
+private struct ProfileActionRow: View {
+ let title: String
+ let icon: String
+ let color: Color
+
+ var body: some View {
+ HStack(spacing: 14) {
+ Image(systemName: icon)
+ .font(.system(size: 17, weight: .semibold))
+ .foregroundStyle(color)
+ .frame(width: 34, height: 34)
+ .background(color.opacity(0.11), in: RoundedRectangle(cornerRadius: 10))
+ Text(title)
+ .font(.subheadline.weight(.semibold))
+ .foregroundStyle(title.contains("Выйти") ? .red : MauTheme.ink)
+ Spacer()
+ Image(systemName: "chevron.right")
+ .font(.caption.bold())
+ .foregroundStyle(MauTheme.muted)
+ }
+ .padding(.horizontal, 16)
+ .padding(.vertical, 13)
+ }
+}
+
+private struct ProfileRow: View {
+ let label: String
+ let value: String?
+ var body: some View {
+ HStack(alignment: .top) {
+ Text(label).foregroundStyle(MauTheme.muted)
+ Spacer()
+ Text(value?.isEmpty == false ? value! : "Не указано")
+ .fontWeight(.medium)
+ .multilineTextAlignment(.trailing)
+ }
+ .font(.subheadline)
+ }
+}
+
+private struct ProfileEditor: View {
+ @EnvironmentObject private var session: SessionStore
+ @Environment(\.dismiss) private var dismiss
+ @State private var group = ""
+ @State private var creditBook = ""
+ @State private var isSaving = false
+ @State private var errorMessage: String?
+
+ var body: some View {
+ NavigationStack {
+ Form {
+ Section("Расписание") {
+ TextField("Учебная группа", text: $group)
+ .textInputAutocapitalization(.characters)
+ if let errorMessage {
+ Text(errorMessage)
+ .font(.footnote)
+ .foregroundStyle(.red)
+ }
+ }
+ Section("Задолженности") {
+ TextField("Номер зачётной книжки", text: $creditBook)
+ .keyboardType(.numberPad)
+ }
+ Section {
+ Text("Данные сохраняются только на этом iPhone и используются для запросов к сервисам МАУ.")
+ .font(.footnote)
+ .foregroundStyle(MauTheme.muted)
+ }
+ }
+ .navigationTitle("Учебные данные")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Отмена") { dismiss() }
+ }
+ ToolbarItem(placement: .confirmationAction) {
+ Button(isSaving ? "Проверяем…" : "Сохранить") { save() }
+ .disabled(isSaving)
+ }
+ }
+ .onAppear {
+ group = session.user?.groupName ?? ""
+ creditBook = session.user?.creditBook ?? ""
+ }
+ }
+ }
+
+ private func save() {
+ isSaving = true
+ errorMessage = nil
+ Task {
+ do {
+ let normalized = group.trimmingCharacters(in: .whitespacesAndNewlines)
+ let resolved = normalized.isEmpty
+ ? nil
+ : try await ScheduleAPIClient.shared.findGroup(named: normalized)
+ if !normalized.isEmpty, resolved == nil {
+ throw APIError.server("Группа не найдена в актуальном расписании")
+ }
+ session.update {
+ $0.groupName = resolved?.group ?? normalized
+ $0.groupId = resolved.map { String($0.groupId) }
+ $0.scheduleGroupUID = resolved?.uid
+ $0.speciality = resolved?.speciality ?? $0.speciality
+ $0.creditBook = creditBook.trimmingCharacters(in: .whitespacesAndNewlines)
+ }
+ dismiss()
+ } catch {
+ errorMessage = error.localizedDescription
+ }
+ isSaving = false
+ }
+ }
+}
+
+private struct SettingsView: View {
+ @State private var cleared = false
+ @AppStorage("mauverse.appearance") private var appearance = AppTheme.system.rawValue
+
+ var body: some View {
+ ZStack {
+ MauBackground()
+ ScrollView {
+ VStack(spacing: 15) {
+ VStack(alignment: .leading, spacing: 13) {
+ Label("Оформление", systemImage: "circle.lefthalf.filled").font(.headline)
+ Picker("Тема", selection: $appearance) {
+ ForEach(AppTheme.allCases) { theme in
+ Text(theme.title).tag(theme.rawValue)
+ }
+ }
+ .pickerStyle(.segmented)
+ Text("В тёмной теме используется белый текст, в светлой — чёрный.")
+ .font(.footnote)
+ .foregroundStyle(MauTheme.muted)
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(20)
+ .mauSurface()
+
+ VStack(alignment: .leading, spacing: 13) {
+ Label("Кэш приложения", systemImage: "internaldrive.fill").font(.headline)
+ Text("Изображения новостей и веб-страниц очищаются системой автоматически.")
+ .font(.subheadline).foregroundStyle(MauTheme.muted)
+ Button(cleared ? "Готово" : "Очистить временные данные") {
+ clearCaches()
+ }
+ .buttonStyle(.borderedProminent)
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(20)
+ .mauSurface()
+
+ VStack(alignment: .leading, spacing: 12) {
+ Label("О приложении", systemImage: "info.circle.fill").font(.headline)
+ Text("Нативное приложение Мурманского арктического университета.")
+ .font(.subheadline).foregroundStyle(MauTheme.muted)
+ Divider()
+ HStack {
+ Text("Разработчик")
+ .foregroundStyle(MauTheme.muted)
+ Spacer()
+ NavigationLink(
+ destination: InAppBrowserView(
+ url: URL(string: "https://github.com/decoder-dev")!,
+ title: "decoder-dev"
+ )
+ ) {
+ HStack(spacing: 5) {
+ Text("decoder-dev")
+ Image(systemName: "arrow.up.right")
+ .font(.caption2.bold())
+ }
+ .font(.subheadline.weight(.semibold))
+ .foregroundStyle(MauTheme.blue)
+ }
+ }
+ Text("Версия 1.11.0 • сборка 28").font(.caption)
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(20)
+ .mauSurface()
+ }
+ .padding(20)
+ .padding(.bottom, 30)
+ }
+ }
+ .navigationTitle("Настройки")
+ .toolbar(.hidden, for: .tabBar)
+ }
+
+ private func clearCaches() {
+ URLCache.shared.removeAllCachedResponses()
+ let dataStore = WKWebsiteDataStore.default()
+ dataStore.fetchDataRecords(ofTypes: WKWebsiteDataStore.allWebsiteDataTypes()) { records in
+ dataStore.removeData(
+ ofTypes: WKWebsiteDataStore.allWebsiteDataTypes(),
+ for: records
+ ) {
+ Task { @MainActor in cleared = true }
+ }
+ }
+ Task {
+ await OfficialNewsService.shared.clearCache()
+ await ScheduleAPIClient.shared.clearCache()
+ }
+ }
+}
diff --git a/MauverseIOS/Sources/Views/RootTabView.swift b/MauverseIOS/Sources/Views/RootTabView.swift
new file mode 100644
index 0000000..ad02a7e
--- /dev/null
+++ b/MauverseIOS/Sources/Views/RootTabView.swift
@@ -0,0 +1,20 @@
+import SwiftUI
+
+struct RootTabView: View {
+ var body: some View {
+ TabView {
+ NavigationStack { HomeView() }
+ .tabItem { Label("Главная", systemImage: "house.fill") }
+ NavigationStack { ScheduleView() }
+ .tabItem { Label("Расписание", systemImage: "calendar") }
+ NavigationStack { ServicesView() }
+ .tabItem { Label("Сервисы", systemImage: "square.grid.2x2.fill") }
+ NavigationStack { NewsView() }
+ .tabItem { Label("Новости", systemImage: "newspaper.fill") }
+ NavigationStack { ProfileView() }
+ .tabItem { Label("Профиль", systemImage: "person.crop.circle.fill") }
+ }
+ .tint(MauTheme.blue)
+ }
+}
+
diff --git a/MauverseIOS/Sources/Views/ScheduleView.swift b/MauverseIOS/Sources/Views/ScheduleView.swift
new file mode 100644
index 0000000..d882a29
--- /dev/null
+++ b/MauverseIOS/Sources/Views/ScheduleView.swift
@@ -0,0 +1,281 @@
+import SwiftUI
+
+@MainActor
+final class ScheduleViewModel: ObservableObject {
+ @Published var items: [ScheduleItem] = []
+ @Published var selectedDate = Calendar.current.startOfDay(for: Date())
+ @Published var isLoading = false
+ @Published var error: String?
+ private var activeRequest = UUID()
+
+ let dates: [Date] = (0..<14).compactMap {
+ Calendar.current.date(byAdding: .day, value: $0, to: Calendar.current.startOfDay(for: Date()))
+ }
+
+ func load(user: UserDTO?) async -> ScheduleGroup? {
+ let request = UUID()
+ activeRequest = request
+ guard let user else {
+ error = "Необходимо войти в аккаунт"
+ items = []
+ return nil
+ }
+ isLoading = true
+ error = nil
+ defer {
+ if activeRequest == request { isLoading = false }
+ }
+ let formatter = DateFormatter()
+ formatter.locale = Locale(identifier: "en_US_POSIX")
+ formatter.dateFormat = "yyyy-MM-dd"
+ do {
+ var resolvedGroup: ScheduleGroup?
+ var uid = user.scheduleGroupUID
+ if uid?.isEmpty != false, let groupName = user.groupName, !groupName.isEmpty {
+ resolvedGroup = try await ScheduleAPIClient.shared.findGroup(named: groupName)
+ uid = resolvedGroup?.uid
+ }
+ guard let uid, !uid.isEmpty else {
+ throw APIError.server("Выберите существующую учебную группу в профиле")
+ }
+ let loaded = try await ScheduleAPIClient.shared.schedule(
+ uid: uid,
+ start: formatter.string(from: dates.first ?? Date()),
+ end: formatter.string(from: dates.last ?? Date())
+ )
+ guard activeRequest == request else { return nil }
+ items = loaded
+ return resolvedGroup
+ } catch {
+ guard activeRequest == request else { return nil }
+ self.error = error.localizedDescription
+ return nil
+ }
+ }
+
+ func items(for date: Date) -> [ScheduleItem] {
+ 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 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)
+ }
+ }
+ .sorted { ($0.startTime ?? "") < ($1.startTime ?? "") }
+ }
+}
+
+struct ScheduleView: View {
+ @EnvironmentObject private var session: SessionStore
+ @StateObject private var model = ScheduleViewModel()
+
+ var body: some View {
+ ZStack {
+ MauBackground()
+ ScrollView {
+ VStack(alignment: .leading, spacing: 20) {
+ HStack(alignment: .top) {
+ VStack(alignment: .leading, spacing: 6) {
+ Text("Расписание")
+ .font(.system(size: 36, weight: .bold, design: .rounded))
+ Text(model.selectedDate.formatted(
+ .dateTime
+ .locale(Locale(identifier: "ru_RU"))
+ .weekday(.wide)
+ .day()
+ .month(.wide)
+ ))
+ .font(.subheadline)
+ .foregroundStyle(MauTheme.muted)
+ }
+ Spacer()
+ Button { Task { await reload() } } label: {
+ Image(systemName: "arrow.clockwise")
+ .font(.headline)
+ .frame(width: 44, height: 44)
+ }
+ .buttonStyle(.plain)
+ .mauGlass(radius: 16)
+ .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)
+ .foregroundStyle(MauTheme.muted)
+ .lineLimit(1)
+ }
+ Spacer()
+ }
+ .padding(17)
+ .mauSurface(radius: 22)
+
+ ScrollView(.horizontal, showsIndicators: false) {
+ HStack(spacing: 10) {
+ ForEach(model.dates, id: \.self) { date in
+ DateChip(date: date, selected: Calendar.current.isDate(date, inSameDayAs: model.selectedDate))
+ .onTapGesture { withAnimation(.snappy) { model.selectedDate = date } }
+ }
+ }
+ .padding(.vertical, 2)
+ }
+
+ if model.isLoading {
+ VStack(spacing: 12) {
+ ForEach(0..<3, id: \.self) { _ in
+ RoundedRectangle(cornerRadius: 22)
+ .fill(MauTheme.card.opacity(0.75))
+ .frame(height: 112)
+ .overlay { ProgressView().opacity(0.45) }
+ }
+ }
+ } else if let error = model.error {
+ VStack(spacing: 12) {
+ EmptyState(icon: "wifi.exclamationmark", title: "Не удалось загрузить", message: error)
+ Button("Повторить") { Task { await reload() } }
+ .buttonStyle(.borderedProminent)
+ }
+ } else if model.items(for: model.selectedDate).isEmpty {
+ EmptyState(icon: "cup.and.saucer.fill", title: "Занятий нет", message: "На выбранную дату расписание пустое")
+ } else {
+ LazyVStack(spacing: 12) {
+ ForEach(model.items(for: model.selectedDate), id: \.stableID) { item in
+ LessonCard(item: item)
+ }
+ }
+ }
+ }
+ .padding(20)
+ .padding(.bottom, 96)
+ }
+ .refreshable { await reload() }
+ }
+ .navigationBarTitleDisplayMode(.inline)
+ .task { if model.items.isEmpty { await reload() } }
+ }
+
+ private func reload() async {
+ if let group = await model.load(user: session.user) {
+ session.update {
+ $0.scheduleGroupUID = group.uid
+ $0.groupId = String(group.groupId)
+ $0.groupName = group.group
+ $0.speciality = group.speciality
+ }
+ }
+ }
+}
+
+private struct DateChip: View {
+ let date: Date
+ let selected: Bool
+
+ var body: some View {
+ VStack(spacing: 5) {
+ Text(date.formatted(.dateTime.weekday(.abbreviated)).uppercased())
+ .font(.caption2.weight(.bold))
+ Text(date.formatted(.dateTime.day()))
+ .font(.title3.bold())
+ }
+ .foregroundStyle(selected ? .white : MauTheme.ink)
+ .frame(width: 58, height: 68)
+ .background(selected ? AnyShapeStyle(MauTheme.heroGradient) : AnyShapeStyle(MauTheme.card.opacity(0.82)),
+ in: RoundedRectangle(cornerRadius: 19, style: .continuous))
+ .overlay {
+ RoundedRectangle(cornerRadius: 19, style: .continuous)
+ .stroke(selected ? Color.white.opacity(0.22) : Color.primary.opacity(0.06), lineWidth: 0.75)
+ }
+ .shadow(color: selected ? MauTheme.blue.opacity(0.22) : .clear, radius: 10, y: 5)
+ }
+}
+
+private struct LessonCard: View {
+ let item: ScheduleItem
+ private var kind: LessonKind { LessonKind(value: item.pairType) }
+
+ var body: some View {
+ HStack(alignment: .top, spacing: 15) {
+ VStack(spacing: 2) {
+ Text(item.startTime ?? "—").font(.subheadline.bold())
+ Text(item.endTime ?? "").font(.caption2).foregroundStyle(MauTheme.muted)
+ }
+ .frame(width: 52)
+
+ VStack(spacing: 0) {
+ Circle()
+ .fill(kind.color)
+ .frame(width: 12, height: 12)
+ .overlay(Circle().stroke(kind.color.opacity(0.22), lineWidth: 6))
+ Rectangle()
+ .fill(kind.color.opacity(0.2))
+ .frame(width: 2, height: 64)
+ }
+ .padding(.top, 4)
+
+ VStack(alignment: .leading, spacing: 6) {
+ HStack(alignment: .top) {
+ Text(item.name ?? "Занятие")
+ .font(.headline)
+ Spacer(minLength: 8)
+ Text(kind.title)
+ .font(.caption2.bold())
+ .foregroundStyle(kind.color)
+ .padding(.horizontal, 8)
+ .padding(.vertical, 5)
+ .background(kind.color.opacity(0.11), in: Capsule())
+ }
+ if let teacher = item.teacher, !teacher.isEmpty {
+ Label(teacher, systemImage: "person.fill")
+ .font(.caption)
+ .foregroundStyle(MauTheme.muted)
+ }
+ HStack {
+ if let room = item.room, !room.isEmpty {
+ Label(room, systemImage: "mappin.and.ellipse")
+ }
+ if let type = item.pairType, !type.isEmpty {
+ Text(type)
+ }
+ }
+ .font(.caption)
+ .foregroundStyle(MauTheme.blue)
+ }
+ Spacer()
+ }
+ .padding(17)
+ .mauSurface(radius: 22)
+ }
+}
+
+private struct LessonKind {
+ let title: String
+ let color: Color
+
+ init(value: String?) {
+ let normalized = value?.lowercased() ?? ""
+ if normalized.contains("экзам") || normalized.contains("зач") {
+ title = value ?? "Контроль"
+ color = .red
+ } else if normalized.contains("лабо") {
+ title = value ?? "Лабораторная"
+ color = MauTheme.violet
+ } else if normalized.contains("практ") {
+ title = value ?? "Практика"
+ color = .orange
+ } else if normalized.contains("консульт") {
+ title = value ?? "Консультация"
+ color = .teal
+ } else {
+ title = value?.isEmpty == false ? value! : "Лекция"
+ color = MauTheme.blue
+ }
+ }
+}
diff --git a/MauverseIOS/Sources/Views/ServicesView.swift b/MauverseIOS/Sources/Views/ServicesView.swift
new file mode 100644
index 0000000..48a5488
--- /dev/null
+++ b/MauverseIOS/Sources/Views/ServicesView.swift
@@ -0,0 +1,1000 @@
+import SwiftUI
+
+enum MauService: String, CaseIterable, Identifiable {
+ case eios, forms, messenger, campus, debts, digital, teachers, departments
+ var id: String { rawValue }
+
+ var title: String {
+ return switch self {
+ case .eios: "ЭИОС"
+ case .forms: "Онлайн-формы"
+ case .messenger: "Мессенджер ЭИОС"
+ case .campus: "Навигатор по корпусам"
+ case .debts: "Учебные задолженности"
+ case .digital: "Цифровые сервисы МАУ"
+ case .teachers: "Контакты преподавателей"
+ case .departments: "Подразделения и телефоны"
+ }
+ }
+
+ var icon: String {
+ return switch self {
+ case .eios: "graduationcap.fill"
+ case .forms: "doc.text.fill"
+ case .messenger: "message.fill"
+ case .campus: "map.fill"
+ case .debts: "exclamationmark.circle.fill"
+ case .digital: "network"
+ case .teachers: "person.text.rectangle.fill"
+ case .departments: "building.2.fill"
+ }
+ }
+
+ var color: Color {
+ return switch self {
+ case .eios, .teachers: MauTheme.blue
+ case .forms, .departments: .purple
+ case .messenger, .digital: .teal
+ case .campus: .orange
+ case .debts: .red
+ }
+ }
+
+ var subtitle: String {
+ return switch self {
+ case .eios: "Курсы и задания"
+ case .forms: "Справки и обращения"
+ case .messenger: "Сообщения в ЭИОС"
+ case .campus: "Корпуса и маршруты"
+ case .debts: "Проверка успеваемости"
+ case .digital: "Онлайн-ресурсы"
+ case .teachers: "Поиск по имени"
+ case .departments: "Телефоны и кабинеты"
+ }
+ }
+}
+
+private enum ServiceCategory: String, CaseIterable, Identifiable {
+ case study = "Учёба"
+ case communication = "Коммуникации"
+ case university = "Университет"
+ var id: String { rawValue }
+
+ var services: [MauService] {
+ return switch self {
+ case .study: [.eios, .forms, .debts]
+ case .communication: [.messenger, .teachers, .departments]
+ case .university: [.campus, .digital]
+ }
+ }
+}
+
+struct ServicesView: View {
+ private let columns = [GridItem(.flexible(), spacing: 13), GridItem(.flexible(), spacing: 13)]
+
+ var body: some View {
+ ZStack {
+ MauBackground()
+ ScrollView {
+ VStack(alignment: .leading, spacing: 20) {
+ VStack(alignment: .leading, spacing: 6) {
+ Text("Сервисы").font(.system(size: 36, weight: .bold, design: .rounded))
+ Text("Учёба и университет — в одном месте")
+ .font(.subheadline)
+ .foregroundStyle(MauTheme.muted)
+ }
+
+ ForEach(ServiceCategory.allCases) { category in
+ MauSectionHeader(title: category.rawValue)
+ LazyVGrid(columns: columns, spacing: 13) {
+ ForEach(category.services) { service in
+ NavigationLink(destination: ServiceDestination(service: service)) {
+ VStack(alignment: .leading, spacing: 16) {
+ HStack {
+ IconTile(systemName: service.icon, color: service.color)
+ Spacer()
+ Image(systemName: "arrow.up.right")
+ .font(.caption.bold())
+ .foregroundStyle(MauTheme.muted)
+ }
+ VStack(alignment: .leading, spacing: 4) {
+ Text(service.title)
+ .font(.subheadline.weight(.semibold))
+ .foregroundStyle(MauTheme.ink)
+ .multilineTextAlignment(.leading)
+ .lineLimit(2)
+ Text(service.subtitle)
+ .font(.caption2)
+ .foregroundStyle(MauTheme.muted)
+ .lineLimit(1)
+ }
+ .frame(maxWidth: .infinity, minHeight: 42, alignment: .topLeading)
+ }
+ .padding(16)
+ .frame(maxWidth: .infinity, minHeight: 135, alignment: .topLeading)
+ }
+ .buttonStyle(.plain)
+ .mauSurface(radius: 23)
+ }
+ }
+ }
+ }
+ .padding(20)
+ .padding(.bottom, 96)
+ }
+ }
+ .navigationBarTitleDisplayMode(.inline)
+ }
+}
+
+private struct ServiceDestination: View {
+ let service: MauService
+
+ @ViewBuilder
+ var body: some View {
+ switch service {
+ case .eios:
+ WebServiceView(title: service.title, url: "https://eios.mauniver.ru/moodle/")
+ case .digital:
+ WebServiceView(title: service.title, url: "https://mauniver.ru/services/")
+ case .messenger:
+ WebServiceView(title: service.title, url: "https://eios.mauniver.ru/moodle/message/index.php")
+ case .forms:
+ FormsView()
+ case .campus:
+ CampusView()
+ case .debts:
+ DebtsView()
+ case .teachers:
+ TeacherContactsView()
+ case .departments:
+ DepartmentsView()
+ }
+ }
+}
+
+private struct WebServiceView: View {
+ let title: String
+ let url: String
+
+ var body: some View {
+ if let destination = URL(string: url) {
+ InAppBrowserView(url: destination, title: title)
+ } else {
+ EmptyState(icon: "link.badge.plus", title: "Ссылка недоступна", message: url)
+ }
+ }
+}
+
+private struct FormsView: View {
+ private let forms = [
+ OnlineFormLink(
+ title: "Справка об обучении",
+ subtitle: "Обычная, гербовая или электронная",
+ icon: "doc.badge.plus",
+ url: "https://mauniver.ru/services/student/"
+ ),
+ OnlineFormLink(
+ title: "Справка для перевода",
+ subtitle: "Перечень дисциплин и оценок",
+ icon: "doc.text.fill",
+ url: "https://mauniver.ru/services/student/perevod/"
+ ),
+ OnlineFormLink(
+ title: "Справка о стипендии",
+ subtitle: "Выплаты за выбранный период",
+ icon: "banknote.fill",
+ url: "https://mauniver.ru/services/student/spravka/"
+ ),
+ OnlineFormLink(
+ title: "Архивная справка",
+ subtitle: "Для выпускников и бывших студентов",
+ icon: "archivebox.fill",
+ url: "https://mauniver.ru/services/student/archive/"
+ ),
+ OnlineFormLink(
+ title: "Архивная справка для отчисленных",
+ subtitle: "С перечнем дисциплин и оценок",
+ icon: "doc.on.doc.fill",
+ url: "https://mauniver.ru/services/student/archive-expl/"
+ ),
+ OnlineFormLink(
+ title: "Справка-вызов",
+ subtitle: "Для предоставления работодателю",
+ icon: "briefcase.fill",
+ url: "https://mauniver.ru/services/student/vyzov/"
+ ),
+ OnlineFormLink(
+ title: "Справка для налоговой",
+ subtitle: "Для социального налогового вычета",
+ icon: "checkmark.seal.fill",
+ url: "https://mauniver.ru/services/student/nalog/"
+ ),
+ OnlineFormLink(
+ title: "Дубликат диплома",
+ subtitle: "Заявление на повторную выдачу",
+ icon: "doc.on.doc.fill",
+ url: "https://mauniver.ru/services/student/diplom/"
+ ),
+ OnlineFormLink(
+ title: "Счёт за обучение",
+ subtitle: "Платные образовательные услуги",
+ icon: "creditcard.fill",
+ url: "https://mauniver.ru/services/student/application/"
+ ),
+ OnlineFormLink(
+ title: "Онлайн-сервисы ММРК",
+ subtitle: "Справки и заявления колледжа",
+ icon: "building.columns.fill",
+ url: "https://mauniver.ru/structure/branches/mmrc/online/"
+ ),
+ OnlineFormLink(
+ title: "Справочная студенческого офиса",
+ subtitle: "Задать вопрос об учебном процессе",
+ icon: "questionmark.bubble.fill",
+ url: "https://mauniver.ru/services/virtual/"
+ ),
+ OnlineFormLink(
+ title: "Справочная библиотеки",
+ subtitle: "Получить помощь библиотекаря",
+ icon: "books.vertical.fill",
+ url: "https://mauniver.ru/structure/divs/library/guide/"
+ ),
+ OnlineFormLink(
+ title: "Виртуальная приёмная ректора",
+ subtitle: "Направить официальное обращение",
+ icon: "envelope.badge.fill",
+ url: "https://mauniver.ru/rector/reception/"
+ ),
+ OnlineFormLink(
+ title: "Вопрос приёмной комиссии",
+ subtitle: "Обращение по вопросам поступления",
+ icon: "person.crop.circle.badge.questionmark",
+ url: "https://mauniver.ru/abit/reception/"
+ ),
+ OnlineFormLink(
+ title: "Стать волонтёром МАУ",
+ subtitle: "Присоединиться к волонтёрскому движению",
+ icon: "heart.fill",
+ url: "https://mauniver.ru/student/community/volunteer/"
+ ),
+ OnlineFormLink(
+ title: "Поддержка молодых семей",
+ subtitle: "Направить обращение и документы",
+ icon: "figure.2.and.child.holdinghands",
+ url: "https://mauniver.ru/services/student/material/"
+ ),
+ OnlineFormLink(
+ title: "Поддержка участников СВО",
+ subtitle: "Единое окно поддержки студентов",
+ icon: "shield.fill",
+ url: "https://mauniver.ru/services/student/support-svo/"
+ ),
+ OnlineFormLink(
+ title: "Обратная связь «Моё образование»",
+ subtitle: "Исправление данных в ГИС СЦОС",
+ icon: "graduationcap.fill",
+ url: "https://mauniver.ru/services/student/gis-scos/"
+ ),
+ OnlineFormLink(
+ title: "Все официальные формы",
+ subtitle: "Актуальный каталог онлайн-сервисов МАУ",
+ icon: "safari.fill",
+ url: "https://mauniver.ru/services/student/"
+ )
+ ]
+
+ var body: some View {
+ ZStack {
+ MauBackground()
+ ScrollView {
+ VStack(spacing: 13) {
+ ForEach(forms) { form in
+ NavigationLink(destination: destination(for: form)) {
+ HStack(spacing: 14) {
+ IconTile(systemName: form.icon)
+ VStack(alignment: .leading, spacing: 3) {
+ Text(form.title).font(.headline)
+ Text(form.subtitle)
+ .font(.caption)
+ .foregroundStyle(MauTheme.muted)
+ }
+ Spacer()
+ Image(systemName: "chevron.right").foregroundStyle(MauTheme.muted)
+ }
+ .foregroundStyle(MauTheme.ink)
+ .padding(17)
+ }
+ .buttonStyle(.plain)
+ .mauGlass(radius: 22)
+ }
+ Text("Все страницы открываются во внутреннем браузере MAUverse.")
+ .font(.footnote)
+ .foregroundStyle(MauTheme.muted)
+ .padding()
+ }
+ .padding(20)
+ }
+ }
+ .navigationTitle("Онлайн-формы")
+ .navigationBarTitleDisplayMode(.large)
+ .toolbar(.hidden, for: .tabBar)
+ }
+
+ @ViewBuilder
+ private func destination(for form: OnlineFormLink) -> some View {
+ 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)
+ }
+ }
+}
+
+private struct OnlineFormLink: Identifiable {
+ let title: String
+ let subtitle: String
+ let icon: String
+ let url: String?
+ var id: String { title }
+}
+
+private struct CertificateRequestView: View {
+ @EnvironmentObject private var session: SessionStore
+ @Environment(\.dismiss) private var dismiss
+
+ @State private var fullName = ""
+ @State private var email = ""
+ @State private var phone = ""
+ @State private var birthDate = Date(timeIntervalSince1970: 946_684_800)
+ @State private var institute = ""
+ @State private var studyForm = ""
+ @State private var funding = ""
+ @State private var certificateType = ""
+ @State private var copies = 1
+ @State private var delivery = ""
+ @State private var postalAddress = ""
+ @State private var universityEmail = ""
+ @State private var comment = ""
+ @State private var consent = false
+ @State private var isSending = false
+ @State private var errorMessage: String?
+ @State private var sent = false
+
+ private let institutes = [
+ "Морская академия",
+ "Институт прикладных арктических технологий",
+ "Естественно-технологический институт",
+ "Институт педагогики и психологии",
+ "Институт креативных индустрий и предпринимательства",
+ "Институт интеллектуальных систем и цифровых технологий",
+ "Институт гуманитарных и социальных наук",
+ "Медико-биологический институт",
+ "Юридический факультет",
+ "Факультет физической культуры и спорта"
+ ]
+ private let studyForms = ["Очная", "Заочная", "Очно-заочная"]
+ private let fundingSources = ["Бюджет", "Договор"]
+ private let certificateTypes = [
+ "Простая, с печатью Студенческого офиса",
+ "Гербовая, с гербовой печатью Университета",
+ "Электронная, с электронной подписью"
+ ]
+ private let deliveryMethods = [
+ "Лично: Спортивная, 13, кабинет 203В",
+ "Лично: Егорова, 16, кабинет 112",
+ "Почта России",
+ "Электронная почта в домене @mauniver.ru"
+ ]
+
+ private var isPostalDelivery: Bool { delivery == deliveryMethods[2] }
+ private var isEmailDelivery: Bool { delivery == deliveryMethods[3] }
+
+ var body: some View {
+ Form {
+ Section("Контактные данные") {
+ TextField("ФИО как в паспорте", text: $fullName)
+ .textContentType(.name)
+ TextField("Контактный e-mail", text: $email)
+ .keyboardType(.emailAddress)
+ .textInputAutocapitalization(.never)
+ TextField("Телефон", text: $phone)
+ .keyboardType(.phonePad)
+ DatePicker(
+ "Дата рождения",
+ selection: $birthDate,
+ in: Date(timeIntervalSince1970: -1_262_304_000)...Date(),
+ displayedComponents: .date
+ )
+ }
+
+ Section("Учебные данные") {
+ selectionPicker("Факультет или институт", selection: $institute, values: institutes)
+ selectionPicker("Форма обучения", selection: $studyForm, values: studyForms)
+ selectionPicker("Источник финансирования", selection: $funding, values: fundingSources)
+ }
+
+ Section("Документ") {
+ selectionPicker("Вид справки", selection: $certificateType, values: certificateTypes)
+ .onChange(of: certificateType) { _, value in
+ if value == certificateTypes[2] { delivery = deliveryMethods[3] }
+ }
+ Stepper("Количество экземпляров: \(copies)", value: $copies, in: 1...5)
+ selectionPicker("Способ получения", selection: $delivery, values: deliveryMethods)
+ .disabled(certificateType == certificateTypes[2])
+ if isPostalDelivery {
+ TextField("Полный почтовый адрес", text: $postalAddress, axis: .vertical)
+ .lineLimit(2...4)
+ }
+ if isEmailDelivery {
+ TextField("Адрес @mauniver.ru", text: $universityEmail)
+ .keyboardType(.emailAddress)
+ .textInputAutocapitalization(.never)
+ }
+ TextField("Комментарий (необязательно)", text: $comment, axis: .vertical)
+ .lineLimit(2...5)
+ }
+
+ Section {
+ Toggle("Согласие на обработку персональных данных", isOn: $consent)
+ if let errorMessage {
+ Text(errorMessage).font(.footnote).foregroundStyle(.red)
+ }
+ Button {
+ Task { await submit() }
+ } label: {
+ HStack {
+ Spacer()
+ if isSending { ProgressView() }
+ Text(isSending ? "Отправляем…" : "Отправить заявку")
+ .fontWeight(.semibold)
+ Spacer()
+ }
+ }
+ .disabled(isSending)
+ }
+ }
+ .navigationTitle("Заказ справки")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar(.hidden, for: .tabBar)
+ .onAppear {
+ if fullName.isEmpty { fullName = session.user?.displayName ?? "" }
+ }
+ .alert("Заявка отправлена", isPresented: $sent) {
+ Button("Готово") { dismiss() }
+ } message: {
+ Text("Студенческий офис получил вашу заявку.")
+ }
+ }
+
+ private func selectionPicker(
+ _ title: String,
+ selection: Binding,
+ values: [String]
+ ) -> some View {
+ Picker(title, selection: selection) {
+ Text("Не выбрано").tag("")
+ ForEach(values, id: \.self) { Text($0).tag($0) }
+ }
+ }
+
+ @MainActor
+ private func submit() async {
+ errorMessage = validate()
+ guard errorMessage == nil else { return }
+ isSending = true
+ defer { isSending = false }
+
+ var fields = [
+ StudentFormField(title: "ФИО (как в паспорте)", value: fullName.trimmed),
+ StudentFormField(title: "E-mail", value: email.trimmed),
+ StudentFormField(title: "Телефон", value: phone.trimmed),
+ StudentFormField(
+ title: "Дата рождения",
+ value: birthDate.formatted(.dateTime.locale(Locale(identifier: "ru_RU")).day().month().year())
+ ),
+ StudentFormField(title: "Факультет / институт", value: institute),
+ StudentFormField(title: "Форма обучения", value: studyForm),
+ StudentFormField(title: "Источник финансирования", value: funding),
+ StudentFormField(title: "Вид справки", value: certificateType),
+ StudentFormField(title: "Количество справок", value: String(copies)),
+ StudentFormField(title: "Способ получения документа", value: delivery)
+ ]
+ if isPostalDelivery {
+ fields.append(StudentFormField(title: "Почтовый адрес", value: postalAddress.trimmed))
+ }
+ if isEmailDelivery {
+ fields.append(StudentFormField(
+ title: "Электронный адрес в домене @mauniver.ru",
+ value: universityEmail.trimmed
+ ))
+ }
+ if !comment.trimmed.isEmpty {
+ fields.append(StudentFormField(title: "Комментарий", value: comment.trimmed))
+ }
+
+ do {
+ let response: SuccessResponse = try await APIClient.shared.post(
+ "send_order",
+ body: StudentFormRequest(sender: email.trimmed, username: fullName.trimmed, text: fields),
+ user: session.user
+ )
+ guard response.success == true else {
+ throw APIError.server(response.detail ?? response.error ?? "Сервер не подтвердил отправку")
+ }
+ sent = true
+ } catch {
+ errorMessage = error.localizedDescription
+ }
+ }
+
+ private func validate() -> String? {
+ if fullName.trimmed.split(separator: " ").count < 2 { return "Укажите полное ФИО" }
+ if !email.trimmed.isEmail { return "Проверьте контактный e-mail" }
+ if !(10...15).contains(phone.filter(\.isNumber).count) { return "Проверьте номер телефона" }
+ if institute.isEmpty { return "Выберите факультет или институт" }
+ if studyForm.isEmpty { return "Выберите форму обучения" }
+ if funding.isEmpty { return "Выберите источник финансирования" }
+ if certificateType.isEmpty { return "Выберите вид справки" }
+ if delivery.isEmpty { return "Выберите способ получения" }
+ if isPostalDelivery && postalAddress.trimmed.isEmpty { return "Укажите почтовый адрес" }
+ if isEmailDelivery && !universityEmail.trimmed.lowercased().hasSuffix("@mauniver.ru") {
+ return "Укажите корректный адрес @mauniver.ru"
+ }
+ if comment.count > 2_000 { return "Комментарий не должен превышать 2000 символов" }
+ if !consent { return "Подтвердите согласие на обработку данных" }
+ return nil
+ }
+}
+
+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] = []
+ @Published var isLoading = false
+ @Published var error: String?
+
+ func load(user: UserDTO?) async {
+ guard let user,
+ let creditBook = user.creditBook?.trimmed,
+ !creditBook.isEmpty else {
+ error = "Укажите номер зачётной книжки в профиле"
+ semesters = []
+ return
+ }
+ isLoading = true
+ error = nil
+ defer { isLoading = false }
+ do {
+ let response: SemesterResponse = try await APIClient.shared.post(
+ "get_semesters",
+ body: DebtRequest(creditBook: creditBook),
+ user: user,
+ retryOnTransient: true
+ )
+ if let message = response.error ?? response.detail {
+ throw APIError.server(message)
+ }
+ var loaded = response.semesters ?? []
+ for index in loaded.indices {
+ guard let number = loaded[index].semesterNumber else { continue }
+ let debts: DebtResponse = try await APIClient.shared.post(
+ "get_debts",
+ body: DebtRequest(creditBook: creditBook, semesterNumber: number),
+ user: user,
+ retryOnTransient: true
+ )
+ if let message = debts.error ?? debts.detail {
+ throw APIError.server(message)
+ }
+ loaded[index].debts = debts.debts ?? []
+ }
+ semesters = loaded
+ } catch { self.error = error.localizedDescription }
+ }
+}
+
+private struct DebtsView: View {
+ @EnvironmentObject private var session: SessionStore
+ @StateObject private var model = DebtsModel()
+
+ var body: some View {
+ ZStack {
+ MauBackground()
+ ScrollView {
+ VStack(spacing: 15) {
+ if model.isLoading {
+ LoadingOverlay(title: "Проверяем задолженности")
+ } else if let error = model.error {
+ VStack(spacing: 12) {
+ EmptyState(icon: "exclamationmark.circle", title: "Нет данных", message: error)
+ Button("Повторить") { Task { await model.load(user: session.user) } }
+ .buttonStyle(.borderedProminent)
+ }
+ } else if model.semesters.isEmpty {
+ EmptyState(icon: "checkmark.seal.fill", title: "Задолженностей нет", message: "Данные по указанной зачётной книжке не найдены")
+ } else {
+ ForEach(model.semesters) { semester in
+ VStack(alignment: .leading, spacing: 12) {
+ Text(semester.semester ?? "Семестр \(semester.semesterNumber ?? 0)").font(.headline)
+ Text(semester.semesterSubtitle ?? "")
+ .font(.caption).foregroundStyle(MauTheme.muted)
+ ForEach(semester.debts ?? []) { debt in
+ HStack {
+ VStack(alignment: .leading) {
+ Text(debt.discipline ?? "Дисциплина")
+ Text(debt.markType ?? "Задолженность")
+ .font(.caption).foregroundStyle(.red)
+ }
+ Spacer()
+ }
+ .padding(12)
+ .background(Color.red.opacity(0.06), in: RoundedRectangle(cornerRadius: 14))
+ }
+ }
+ .padding(18)
+ .mauGlass()
+ }
+ }
+ }
+ .padding(20)
+ .padding(.bottom, 30)
+ }
+ .refreshable { await model.load(user: session.user) }
+ }
+ .navigationTitle("Задолженности")
+ .toolbar(.hidden, for: .tabBar)
+ .task { await model.load(user: session.user) }
+ }
+}
+
+@MainActor
+private final class TeachersModel: ObservableObject {
+ @Published var teachers: [Teacher] = []
+ @Published var isLoading = false
+ @Published var error: String?
+ private var activeRequest = UUID()
+
+ func search(_ text: String, user: UserDTO?) async {
+ let request = UUID()
+ activeRequest = request
+ let query = text.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard query.count >= 2 else {
+ teachers = []
+ error = "Введите минимум две буквы фамилии"
+ return
+ }
+ isLoading = true
+ error = nil
+ defer {
+ if activeRequest == request { isLoading = false }
+ }
+ do {
+ let loaded = try await ScheduleAPIClient.shared.teachers(matching: query).map {
+ Teacher(
+ id: $0.teacherId,
+ teacherId: $0.teacherId,
+ name: $0.teacher,
+ fullName: $0.teacher,
+ email: nil,
+ phone: nil,
+ post: "Преподаватель МАУ",
+ extras: $0.uid
+ )
+ }
+ guard activeRequest == request else { return }
+ teachers = loaded
+ } catch {
+ guard activeRequest == request else { return }
+ self.error = error.localizedDescription
+ }
+ }
+}
+
+private struct TeacherContactsView: View {
+ @EnvironmentObject private var session: SessionStore
+ @StateObject private var model = TeachersModel()
+ @State private var query = ""
+
+ var body: some View {
+ ZStack {
+ MauBackground()
+ VStack(spacing: 14) {
+ HStack {
+ TextField("Фамилия преподавателя", text: $query)
+ .textInputAutocapitalization(.words)
+ .submitLabel(.search)
+ .onSubmit { Task { await model.search(query, user: session.user) } }
+ Button { Task { await model.search(query, user: session.user) } } label: {
+ Image(systemName: "magnifyingglass").font(.headline)
+ }
+ .disabled(model.isLoading)
+ }
+ .padding(16)
+ .mauGlass(radius: 20)
+
+ if model.isLoading { LoadingOverlay(title: "Ищем") }
+ else if let error = model.error { EmptyState(icon: "wifi.exclamationmark", title: "Ошибка", message: error) }
+ else if query.isEmpty {
+ EmptyState(
+ icon: "person.text.rectangle",
+ title: "Найдите преподавателя",
+ message: "Введите фамилию или часть имени"
+ )
+ } else if model.teachers.isEmpty {
+ EmptyState(icon: "person.slash", title: "Ничего не найдено", message: "Уточните запрос")
+ }
+ else {
+ ScrollView {
+ LazyVStack(spacing: 12) {
+ ForEach(model.teachers, id: \.stableID) { teacher in
+ ContactCard(
+ title: teacher.fullName ?? teacher.name ?? "Преподаватель",
+ subtitle: teacher.post,
+ phone: teacher.phone,
+ email: teacher.email
+ )
+ }
+ }
+ }
+ .scrollDismissesKeyboard(.interactively)
+ }
+ }
+ .padding(20)
+ }
+ .navigationTitle("Преподаватели")
+ .toolbar(.hidden, for: .tabBar)
+ }
+}
+
+@MainActor
+private final class DepartmentsModel: ObservableObject {
+ @Published var departments: [Department] = []
+ @Published var contacts: [Telephone] = []
+ @Published var isLoading = false
+ @Published var isLoadingContacts = false
+ @Published var error: String?
+ private var activeContactsRequest = UUID()
+
+ func load(user: UserDTO?) async {
+ isLoading = true
+ error = nil
+ defer { isLoading = false }
+ do {
+ let loaded: [Department] = try await APIClient.shared.get("get_depts_json", user: user)
+ departments = loaded.filter { $0.id != nil && $0.name?.trimmed.isEmpty == false }
+ }
+ catch { self.error = error.localizedDescription }
+ }
+
+ func contacts(for department: Department, user: UserDTO?) async {
+ let request = UUID()
+ activeContactsRequest = request
+ contacts = []
+ error = nil
+ isLoadingContacts = true
+ defer {
+ if activeContactsRequest == request { isLoadingContacts = false }
+ }
+ do {
+ let loaded: [Telephone] = try await APIClient.shared.post(
+ "get_contacts_json",
+ body: DepartmentRequest(departmentId: department.id ?? 0, name: department.name ?? ""),
+ user: user,
+ retryOnTransient: true
+ )
+ guard activeContactsRequest == request else { return }
+ contacts = loaded
+ } catch {
+ guard activeContactsRequest == request else { return }
+ self.error = error.localizedDescription
+ }
+ }
+}
+
+private struct DepartmentsView: View {
+ @EnvironmentObject private var session: SessionStore
+ @StateObject private var model = DepartmentsModel()
+ @State private var selected: Department?
+
+ var body: some View {
+ ZStack {
+ MauBackground()
+ if model.isLoading && model.departments.isEmpty {
+ LoadingOverlay(title: "Загружаем подразделения")
+ } else if let error = model.error, model.departments.isEmpty {
+ VStack(spacing: 12) {
+ EmptyState(icon: "wifi.exclamationmark", title: "Ошибка загрузки", message: error)
+ Button("Повторить") { Task { await model.load(user: session.user) } }
+ .buttonStyle(.borderedProminent)
+ }
+ .padding(20)
+ } else if model.departments.isEmpty {
+ EmptyState(
+ icon: "building.2",
+ title: "Подразделения не найдены",
+ message: "Справочник МАУ пока пуст"
+ )
+ .padding(20)
+ } else {
+ List(model.departments) { department in
+ Button {
+ selected = department
+ Task { await model.contacts(for: department, user: session.user) }
+ } label: {
+ HStack {
+ Text(department.name ?? "Подразделение").foregroundStyle(MauTheme.ink)
+ Spacer()
+ Image(systemName: "chevron.right").foregroundStyle(MauTheme.muted)
+ }
+ }
+ }
+ .refreshable { await model.load(user: session.user) }
+ .scrollContentBackground(.hidden)
+ }
+ }
+ .navigationTitle("Подразделения")
+ .toolbar(.hidden, for: .tabBar)
+ .task { await model.load(user: session.user) }
+ .sheet(item: $selected) { department in
+ NavigationStack {
+ ScrollView {
+ LazyVStack(spacing: 12) {
+ if model.isLoadingContacts {
+ LoadingOverlay(title: "Загружаем контакты")
+ } else if let error = model.error {
+ EmptyState(icon: "wifi.exclamationmark", title: "Ошибка", message: error)
+ Button("Повторить") {
+ Task { await model.contacts(for: department, user: session.user) }
+ }
+ .buttonStyle(.borderedProminent)
+ } else if model.contacts.isEmpty {
+ EmptyState(
+ icon: "phone.down",
+ title: "Контакты не найдены",
+ message: "У подразделения пока нет опубликованных контактов"
+ )
+ }
+ ForEach(model.contacts) { contact in
+ ContactCard(
+ title: contact.person ?? contact.title ?? "Контакт",
+ subtitle: [contact.building2, contact.room].compactMap { $0 }.joined(separator: ", "),
+ phone: contact.phone,
+ phone2: contact.phone2,
+ email: contact.depEmail
+ )
+ }
+ }
+ .padding(20)
+ }
+ .background(MauBackground())
+ .navigationTitle(department.name ?? "Контакты")
+ .navigationBarTitleDisplayMode(.inline)
+ }
+ }
+ }
+}
+
+private struct ContactCard: View {
+ let title: String
+ let subtitle: String?
+ let phone: String?
+ var phone2: String? = nil
+ let email: String?
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ Text(title).font(.headline)
+ if let subtitle, !subtitle.isEmpty {
+ 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")
+ }
+ }
+ if let email, let url = URL(string: "mailto:\(email.trimmed)") {
+ Link(destination: url) { Image(systemName: "envelope.fill") }
+ }
+ }
+ .font(.subheadline)
+ .foregroundStyle(MauTheme.blue)
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(17)
+ .mauGlass(radius: 22)
+ }
+
+ 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)")
+ }
+}
+
+private extension String {
+ var trimmed: String { trimmingCharacters(in: .whitespacesAndNewlines) }
+
+ var isEmail: Bool {
+ let parts = trimmed.split(separator: "@", omittingEmptySubsequences: false)
+ return parts.count == 2 && !parts[0].isEmpty && parts[1].contains(".")
+ }
+}
diff --git a/MauverseIOS/project.yml b/MauverseIOS/project.yml
new file mode 100644
index 0000000..d22104b
--- /dev/null
+++ b/MauverseIOS/project.yml
@@ -0,0 +1,37 @@
+name: MAUverse
+options:
+ bundleIdPrefix: com.pmi4freal
+ deploymentTarget:
+ iOS: "18.0"
+ xcodeVersion: "26.5"
+settings:
+ base:
+ SWIFT_VERSION: "5.0"
+ SWIFT_STRICT_CONCURRENCY: minimal
+ IPHONEOS_DEPLOYMENT_TARGET: "18.0"
+ CODE_SIGN_STYLE: Manual
+ DEVELOPMENT_TEAM: ""
+targets:
+ MAUverse:
+ type: application
+ platform: iOS
+ sources:
+ - path: Sources
+ - path: Resources
+ buildPhase: resources
+ excludes:
+ - Info.plist
+ settings:
+ base:
+ PRODUCT_BUNDLE_IDENTIFIER: com.pmi4freal.mauverse3
+ PRODUCT_NAME: MAUverse
+ INFOPLIST_FILE: Resources/Info.plist
+ GENERATE_INFOPLIST_FILE: NO
+ ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
+ MARKETING_VERSION: 1.12.0
+ CURRENT_PROJECT_VERSION: 29
+ MAUVERSE_SCHEDULE_TOKEN: ""
+ TARGETED_DEVICE_FAMILY: "1"
+ SUPPORTS_MACCATALYST: NO
+ scheme:
+ gatherCoverageData: false
diff --git a/README.md b/README.md
index a85ca7b..a0f5d6a 100644
--- a/README.md
+++ b/README.md
@@ -1,14 +1,17 @@
# MAUverce
MAUverce is a student mobile application for Murmansk Arctic University. The
-repository contains a .NET 9 MAUI client and a FastAPI gateway.
+repository contains a .NET MAUI Android client, a native SwiftUI iPhone client,
+and a FastAPI gateway.
## Repository layout
-- `mauverse_mobile` - .NET MAUI application for Android and iPhone.
+- `mauverse_mobile` - .NET MAUI application for Android.
+- `MauverseIOS` - native SwiftUI application for iPhone.
- `mauverse-api` - FastAPI gateway for EIOS, schedules, news, debts, contacts,
and student forms.
-- `.github/workflows/ci.yml` - backend, container, and Android verification.
+- `.github/workflows/ci.yml` - backend, container, Android, and native iPhone
+ verification.
- `docs` - production audit, release process, and release records.
## Backend development
@@ -73,19 +76,26 @@ engineering build and must not be distributed as a production release.
## iPhone development
-The iPhone target keeps the full MAUverse feature set and uses native Liquid
-Glass navigation and content surfaces on iOS 26, with an adaptive UIKit material
-fallback on iOS 15-25. Building and signing iOS requires a Mac with Xcode 26 or
-newer. See [iPhone and Liquid Glass](docs/IOS_LIQUID_GLASS.md).
+The shipping iPhone app is native SwiftUI in `MauverseIOS`. It uses system
+Liquid Glass on iOS 26 and native materials on iOS 18–25. Generate the Xcode
+project with XcodeGen, then build or archive in Xcode 26.5+. See
+[native iPhone app](docs/IOS_LIQUID_GLASS.md).
+
+```bash
+brew install xcodegen
+cd MauverseIOS
+xcodegen generate --spec project.yml
+open MAUverse.xcodeproj
+```
## CI and releases
GitHub Actions uses minimal `contents: read` permission, immutable action SHAs,
pip and NuGet caches, bounded job timeouts, and concurrency cancellation. It
runs backend tests, Ruff, compileall, a non-root container health smoke test,
-Android and iPhone Release-configuration builds. CI uploads unsigned Android
-packages and an unsigned `ios-arm64` IPA container; it never imports signing
-certificates or provisioning profiles.
+the Android MAUI Release build, and the native SwiftUI iPhone Release build. CI
+uploads unsigned Android packages and an unsigned native iPhone IPA container;
+it never imports signing certificates or provisioning profiles.
Production signing, device smoke testing, deployment, and rollback are explicit
promotion gates. See [CI and release engineering](docs/CI_AND_RELEASE.md), the
diff --git a/docs/CI_AND_RELEASE.md b/docs/CI_AND_RELEASE.md
index 41744af..6da0650 100644
--- a/docs/CI_AND_RELEASE.md
+++ b/docs/CI_AND_RELEASE.md
@@ -11,7 +11,7 @@ same ref.
| `backend` | Python 3.11 install, all discovered unit tests, `pip check`, Ruff, compileall | No artifact |
| `api-image` | Docker build, UID is non-root, container reaches healthy state | No registry push |
| `android-release` | SDK from `global.json`, JDK 17, MAUI workload, mobile tests, warning-free Release build | Unsigned APK, AAB, and SHA-256 file, retained 14 days |
-| `ios-release-unsigned` | macOS 26, Xcode/iPhoneOS SDK report, MAUI iOS workload, warning-free `ios-arm64` Release publish, explicit signature/provisioning audit | Unsigned IPA container, build metadata, and SHA-256 file, retained 14 days |
+| `ios-release-unsigned` | macOS 26, Xcode 26.5, XcodeGen, unsigned SwiftUI `MauverseIOS` device build, signature/provisioning audit | Unsigned native IPA container, build metadata, and SHA-256 file, retained 14 days |
All GitHub-maintained actions are referenced by immutable commit SHA with the
major version recorded in a comment. Dependabot checks those references and the
@@ -43,12 +43,14 @@ dotnet build mau.csproj --configuration Release --framework net10.0-android \
The CI artifact is intentionally unsigned. A green Android job proves that the
Release configuration compiles; it does not authorize distribution.
-The iPhone job produces `mauverce-ios-arm64-unsigned.ipa`. This is a ZIP
-container with an unsigned device `.app` under `Payload/`; it is not installable
-until it is signed with an Apple Distribution or Apple Development certificate
-and a matching provisioning profile. CI imports no certificate, reads no Apple
-signing secret, and fails if `_CodeSignature` or `embedded.mobileprovision` is
-present.
+The iPhone job builds the native SwiftUI app in `MauverseIOS` and produces
+`mauverse-native-ios-arm64-unsigned.ipa`. This is a ZIP container with an
+unsigned device `.app` under `Payload/`; it is not installable until it is
+signed with an Apple Distribution or Apple Development certificate and a
+matching provisioning profile. CI imports no certificate and fails if
+`_CodeSignature` or `embedded.mobileprovision` is present. The schedule API
+token is injected at build time from the `MAUVERSE_SCHEDULE_TOKEN` repository
+secret.
## Production promotion
diff --git a/docs/IOS_LIQUID_GLASS.md b/docs/IOS_LIQUID_GLASS.md
index 892712c..aca33fe 100644
--- a/docs/IOS_LIQUID_GLASS.md
+++ b/docs/IOS_LIQUID_GLASS.md
@@ -1,58 +1,57 @@
-# iPhone and Liquid Glass
+# Native iPhone app (SwiftUI)
-The iPhone application uses the same MAUverse pages, view models, API clients,
-cache, authentication, and navigation routes as the Android application.
+The shipping iPhone application is the native SwiftUI client in `MauverseIOS`.
+Android remains the .NET MAUI client in `mauverse_mobile`. Both talk to the same
+MAUverse API and share the same bundle id `com.pmi4freal.mauverse3`.
## Rendering
-- iOS 26 and newer use `UIGlassEffect` for marked content surfaces.
-- iOS 15 through 25 fall back to the adaptive UIKit system material.
-- Shell navigation and tab bars keep their native translucent appearance, so
- iOS 26 renders Apple's current Liquid Glass treatment automatically.
+- iOS 26 and newer use the system Liquid Glass appearance for navigation and
+ translucent surfaces.
+- iOS 18–25 use native SwiftUI / UIKit materials.
- Reduce Transparency, Increase Contrast, and Reduce Motion remain controlled
- by iOS because the implementation uses native UIKit materials.
+ by iOS.
## Build and run
-Use a Mac with .NET 10, the .NET MAUI workload, Xcode 26 or newer, and an Apple
-development signing identity:
+Requires a Mac with Xcode 26.5 (or newer matching `project.yml`) and
+[XcodeGen](https://github.com/yonaskolb/XcodeGen):
```bash
-cd mauverse_mobile
-dotnet workload restore
-dotnet restore mau.csproj
-dotnet build mau.csproj -f net10.0-ios -c Debug
+brew install xcodegen
+cd MauverseIOS
+xcodegen generate --spec project.yml
+open MAUverse.xcodeproj
```
-Open the solution in Visual Studio Code with the .NET MAUI extension or use the
-command line to select an iPhone simulator/device. A physical iPhone is
-recommended for the final material, accessibility, dark-mode, and performance
-checks.
+Set `MAUVERSE_SCHEDULE_TOKEN` in the Xcode build settings or scheme environment
+when calling the schedule API. A physical iPhone is recommended for final
+material, accessibility, dark-mode, and performance checks.
For App Store distribution, configure the team, provisioning profile, and
-release signing on the Mac. Do not publish a Debug-signed archive.
+release signing on the Mac. Do not publish an unsigned or Debug-signed archive.
## Unsigned GitHub CI artifact
-The `ios-release-unsigned` GitHub Actions job publishes
-`mauverce-ios-arm64-release-unsigned`. It contains:
+The `ios-release-unsigned` job (display name
+`Native SwiftUI iPhone Release (unsigned)`) publishes
+`mauverse-native-ios-arm64-release-unsigned`. It contains:
-- `mauverce-ios-arm64-unsigned.ipa`;
+- `mauverse-native-ios-arm64-unsigned.ipa`;
- `checksums.txt`;
- `build-metadata.txt`.
The IPA is only an unsigned container for later signing. The job does not import
-an Apple certificate or provisioning profile and verifies that the application
-contains neither `_CodeSignature` nor `embedded.mobileprovision` before upload.
-Sign the extracted application with your own matching certificate, entitlements,
-and provisioning profile, then recreate the IPA. Never store an unprotected
-private key in the repository or in a downloadable CI artifact.
+an Apple certificate or provisioning profile and fails if the application
+contains `_CodeSignature` or `embedded.mobileprovision`. Sign the extracted
+application with your own matching certificate, entitlements, and provisioning
+profile, then recreate the IPA.
## Required smoke checks
1. Login, logout, and restored session.
2. All five tabs: Home, Schedule, Services, News, and Profile.
-3. Service routes, popups, embedded browser, messenger, and forms.
+3. Service routes, in-app browser, messenger, and forms.
4. Light and dark appearance.
5. iOS Reduce Transparency and Increase Contrast.
6. Dynamic Type and VoiceOver labels.
diff --git a/docs/RELEASE_1.12.0.md b/docs/RELEASE_1.12.0.md
new file mode 100644
index 0000000..33008f3
--- /dev/null
+++ b/docs/RELEASE_1.12.0.md
@@ -0,0 +1,60 @@
+# MAUverce 1.12.0 release record
+
+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.
+
+## Targets
+
+| Client | Path | Version | Build |
+| --- | --- | --- | --- |
+| Android (MAUI) | `mauverse_mobile` | `1.12.0` | `versionCode` 29 |
+| 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
+
+## Product changes
+
+### Android (MAUI)
+
+- Portal guides from mauniver.ru: Студенту, Абитуриенту, Наука, International.
+- Digital services hub: ЭИОС, библиотека, webmail, dorm IT ticket, Intra, PROMT.
+- Native events calendar from `/press/calendar/rss/` plus site calendar link.
+- News filters: Абитуриент (`/abit/news/rss/`) and Календарь.
+- Contacts and payment requisites screen with call/copy actions.
+- Campus navigator: branch campuses, transport tips, route and panorama actions.
+- Schedule filters: room and teacher selection no longer block each other.
+- Settings links: official site, personal-data policy, sveden.
+
+### iPhone (SwiftUI)
+
+- Ships the native SwiftUI client from `MauverseIOS` (not MAUI iOS).
+- Existing feature set: login/session, home, schedule, services, news, profile,
+ in-app browser, campus/digital service entry points.
+- CI builds an unsigned `mauverse-native-ios-arm64-unsigned.ipa` via XcodeGen +
+ Xcode 26.5 with code signing disabled.
+
+## API changes
+
+- `ParserType.APPLICANT` (`news_type=8`) → `/abit/news/rss/`
+- `ParserType.CALENDAR` (`news_type=9`) → `/press/calendar/rss/`
+
+## 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.
+- [ ] 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.
+- [ ] Privacy / forms retention approval if distributing forms more widely.
diff --git a/mauverse-api/apps/database/settings.py b/mauverse-api/apps/database/settings.py
index 750faf0..1ef8f36 100644
--- a/mauverse-api/apps/database/settings.py
+++ b/mauverse-api/apps/database/settings.py
@@ -105,6 +105,8 @@ class ParsingSettings(BaseSettings):
SCIENCE_URL: str = "/press/science/rss/"
STUDENTS_URL: str = "/press/community/rss/"
OTHER_URL: str = "/press/smi/rss/"
+ APPLICANT_URL: str = "/abit/news/rss/"
+ CALENDAR_URL: str = "/press/calendar/rss/"
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
@field_validator("MAIN_URL")
diff --git a/mauverse-api/apps/gateway/utils/parser.py b/mauverse-api/apps/gateway/utils/parser.py
index 1345f22..7a2aa4d 100644
--- a/mauverse-api/apps/gateway/utils/parser.py
+++ b/mauverse-api/apps/gateway/utils/parser.py
@@ -62,6 +62,8 @@ class ParserType(enum.Enum):
INTERNATIONAL = 5, parser_config.INTERNATIONAL_URL
EVENTS = 6, parser_config.EVENTS_URL
OTHER = 7, parser_config.OTHER_URL
+ APPLICANT = 8, parser_config.APPLICANT_URL
+ CALENDAR = 9, parser_config.CALENDAR_URL
def __init__(self, parser_id: int, url: str) -> None:
self._id = parser_id
diff --git a/mauverse-api/tests/test_parser_types.py b/mauverse-api/tests/test_parser_types.py
new file mode 100644
index 0000000..29f593c
--- /dev/null
+++ b/mauverse-api/tests/test_parser_types.py
@@ -0,0 +1,19 @@
+import unittest
+
+from apps.gateway.utils.parser import ParserType
+
+
+class ParserTypeTests(unittest.TestCase):
+ def test_applicant_and_calendar_feeds_are_registered(self) -> None:
+ self.assertEqual(ParserType.APPLICANT.id, 8)
+ self.assertEqual(ParserType.CALENDAR.id, 9)
+ self.assertEqual(ParserType.APPLICANT.url, "/abit/news/rss/")
+ self.assertEqual(ParserType.CALENDAR.url, "/press/calendar/rss/")
+
+ def test_from_id_resolves_new_feeds(self) -> None:
+ self.assertIs(ParserType.from_id(8), ParserType.APPLICANT)
+ self.assertIs(ParserType.from_id(9), ParserType.CALENDAR)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/mauverse_mobile.Tests/UniversityPortalTests.cs b/mauverse_mobile.Tests/UniversityPortalTests.cs
new file mode 100644
index 0000000..0ec009f
--- /dev/null
+++ b/mauverse_mobile.Tests/UniversityPortalTests.cs
@@ -0,0 +1,97 @@
+using mau.Models;
+using mau.Utils.Services;
+using Xunit;
+
+namespace Mauverse.Mobile.Tests;
+
+public sealed class UniversityPortalTests
+{
+ [Fact]
+ public void StudentPortalUrlsStayOnUniversityHosts()
+ {
+ Assert.Contains(
+ UniversityPortalUrls.StudentUrls,
+ url => url.Contains("/structure/divs/studof/", StringComparison.Ordinal));
+ Assert.Contains(
+ UniversityPortalUrls.StudentUrls,
+ url => url.Contains("/student/faq/", StringComparison.Ordinal));
+ Assert.Contains(
+ UniversityPortalUrls.StudentUrls,
+ url => url.Contains("lib.mauniver.ru", StringComparison.Ordinal));
+
+ Assert.All(UniversityPortalUrls.StudentUrls, AssertUniversityHttps);
+ }
+
+ [Fact]
+ public void ApplicantPortalUrlsIncludeAdmissionPortal()
+ {
+ Assert.Contains(
+ UniversityPortalUrls.ApplicantUrls,
+ url => url.StartsWith("https://priem.mauniver.ru", StringComparison.OrdinalIgnoreCase));
+ Assert.Contains(
+ UniversityPortalUrls.ApplicantUrls,
+ url => url.Contains("/structure/branches/", StringComparison.Ordinal));
+
+ Assert.All(UniversityPortalUrls.ApplicantUrls, AssertUniversityHttps);
+ }
+
+ [Fact]
+ public void ScienceInternationalAndDigitalUrlsStayOnUniversityHosts()
+ {
+ Assert.Contains(UniversityPortalUrls.ScienceUrls, url => url.Contains("/science/", StringComparison.Ordinal));
+ Assert.Contains(UniversityPortalUrls.InternationalUrls, url => url.Contains("/en/", StringComparison.Ordinal));
+ Assert.Contains(UniversityPortalUrls.DigitalServiceUrls, url => url.Contains("webmail.mauniver.ru", StringComparison.Ordinal));
+ Assert.Contains(UniversityPortalUrls.DigitalServiceUrls, url => url.Contains("serviceahd.mauniver.ru", StringComparison.Ordinal));
+
+ Assert.All(UniversityPortalUrls.ScienceUrls, AssertUniversityHttps);
+ Assert.All(UniversityPortalUrls.InternationalUrls, AssertUniversityHttps);
+ Assert.All(UniversityPortalUrls.DigitalServiceUrls, AssertUniversityHttps);
+ }
+
+ [Fact]
+ public void ContactsCatalogIncludesAdmissionAndPaymentDetails()
+ {
+ Assert.Contains(
+ UniversityContactsCatalog.AdmissionContacts,
+ block => block.Phone is not null && block.Phone.Contains("800", StringComparison.Ordinal));
+ Assert.Contains(
+ UniversityContactsCatalog.UniversityRequisites,
+ block => block.Details.Contains("5190100176", StringComparison.Ordinal));
+ }
+
+ [Theory]
+ [InlineData(BrowserDestinationRegistry.LibraryKey, "lib.mauniver.ru")]
+ [InlineData(BrowserDestinationRegistry.EventsCalendarKey, "mauniver.ru")]
+ [InlineData(BrowserDestinationRegistry.PrivacyPolicyKey, "mauniver.ru")]
+ [InlineData(BrowserDestinationRegistry.SvedenKey, "mauniver.ru")]
+ [InlineData(BrowserDestinationRegistry.CampusNavigatorSiteKey, "mauniver.ru")]
+ [InlineData(BrowserDestinationRegistry.OfficialSiteKey, "mauniver.ru")]
+ public void KnownPortalDestinationsResolveToUniversityHosts(string key, string hostFragment)
+ {
+ var request = BrowserDestinationRegistry.GetRequired(key);
+
+ Assert.Contains(hostFragment, request.Uri.IdnHost, StringComparison.OrdinalIgnoreCase);
+ Assert.True(request.IsInternalUri(request.Uri));
+ }
+
+ [Fact]
+ public void CampusBuildingSupportsBranchMapCities()
+ {
+ var branch = new CampusBuilding(
+ "Филиал в г. Апатиты",
+ "ул. Лесная, 29",
+ "МАУ Филиал в г. Апатиты, ул. Лесная, 29, Апатиты",
+ "apatity");
+
+ Assert.Equal("apatity", branch.MapCity);
+ Assert.Contains("Апатиты", branch.SearchQuery, StringComparison.Ordinal);
+ }
+
+ static void AssertUniversityHttps(string url)
+ {
+ Assert.True(Uri.TryCreate(url, UriKind.Absolute, out var uri));
+ Assert.Equal(Uri.UriSchemeHttps, uri.Scheme);
+ Assert.True(BrowserRequest.IsUniversityHost(uri.IdnHost));
+ Assert.True(BrowserDestinationRegistry.IsUniversityUri(uri));
+ }
+}
diff --git a/mauverse_mobile.Tests/mauverse_mobile.Tests.csproj b/mauverse_mobile.Tests/mauverse_mobile.Tests.csproj
index a16ddfd..fa31e03 100644
--- a/mauverse_mobile.Tests/mauverse_mobile.Tests.csproj
+++ b/mauverse_mobile.Tests/mauverse_mobile.Tests.csproj
@@ -21,8 +21,16 @@
+
+
+
+
diff --git a/mauverse_mobile/AppShell.xaml.cs b/mauverse_mobile/AppShell.xaml.cs
index d63b885..6c2046c 100644
--- a/mauverse_mobile/AppShell.xaml.cs
+++ b/mauverse_mobile/AppShell.xaml.cs
@@ -24,6 +24,13 @@ public AppShell()
Routing.RegisterRoute("services/study_info/details", typeof(DetailStudentDebtPage));
Routing.RegisterRoute("services/forms", typeof(OnlineFormsPage));
Routing.RegisterRoute("services/forms/certificate", typeof(CertificateRequestPage));
+ Routing.RegisterRoute("services/student_guide", typeof(StudentGuidePage));
+ Routing.RegisterRoute("services/applicant", typeof(ApplicantGuidePage));
+ Routing.RegisterRoute("services/science_guide", typeof(ScienceGuidePage));
+ Routing.RegisterRoute("services/international", typeof(InternationalGuidePage));
+ Routing.RegisterRoute("services/digital", typeof(DigitalServicesPage));
+ Routing.RegisterRoute("services/contacts", typeof(UniversityContactsPage));
+ Routing.RegisterRoute("services/events", typeof(EventsCalendarPage));
Routing.RegisterRoute("profile/settings", typeof(SettingsPage));
Routing.RegisterRoute(BrowserDestinationRegistry.InternalBrowserRoute, typeof(InternalBrowserPage));
Navigated += OnNavigated;
diff --git a/mauverse_mobile/MauiProgram.cs b/mauverse_mobile/MauiProgram.cs
index 73309f9..130810e 100644
--- a/mauverse_mobile/MauiProgram.cs
+++ b/mauverse_mobile/MauiProgram.cs
@@ -95,6 +95,13 @@ public static MauiApp CreateMauiApp()
builder.Services.AddTransient();
builder.Services.AddTransient();
builder.Services.AddTransient();
+ builder.Services.AddTransient();
+ builder.Services.AddTransient();
+ builder.Services.AddTransient();
+ builder.Services.AddTransient();
+ builder.Services.AddTransient();
+ builder.Services.AddTransient();
+ builder.Services.AddTransient();
builder.Services.AddTransient();
builder.Services.AddTransient();
builder.Services.AddTransient();
diff --git a/mauverse_mobile/Models/CampusBuilding.cs b/mauverse_mobile/Models/CampusBuilding.cs
index d59f245..82504ec 100644
--- a/mauverse_mobile/Models/CampusBuilding.cs
+++ b/mauverse_mobile/Models/CampusBuilding.cs
@@ -3,4 +3,6 @@ namespace mau.Models;
public sealed record CampusBuilding(
string Title,
string Address,
- string SearchQuery);
+ string SearchQuery,
+ string MapCity = "murmansk");
+
diff --git a/mauverse_mobile/Models/UniversityContactBlock.cs b/mauverse_mobile/Models/UniversityContactBlock.cs
new file mode 100644
index 0000000..bb6e087
--- /dev/null
+++ b/mauverse_mobile/Models/UniversityContactBlock.cs
@@ -0,0 +1,8 @@
+namespace mau.Models;
+
+public sealed record UniversityContactBlock(
+ string Title,
+ string Details,
+ string? Phone = null,
+ string? Email = null,
+ string? Address = null);
diff --git a/mauverse_mobile/Models/UniversityGuideLink.cs b/mauverse_mobile/Models/UniversityGuideLink.cs
new file mode 100644
index 0000000..3304bd4
--- /dev/null
+++ b/mauverse_mobile/Models/UniversityGuideLink.cs
@@ -0,0 +1,7 @@
+namespace mau.Models;
+
+public sealed record UniversityGuideLink(
+ string Title,
+ string Description,
+ string Url,
+ string Glyph);
diff --git a/mauverse_mobile/Models/UniversityGuideSection.cs b/mauverse_mobile/Models/UniversityGuideSection.cs
new file mode 100644
index 0000000..b3a0143
--- /dev/null
+++ b/mauverse_mobile/Models/UniversityGuideSection.cs
@@ -0,0 +1,5 @@
+namespace mau.Models;
+
+public sealed record UniversityGuideSection(
+ string Title,
+ IReadOnlyList Links);
diff --git a/mauverse_mobile/Pages/ApplicantGuidePage.xaml b/mauverse_mobile/Pages/ApplicantGuidePage.xaml
new file mode 100644
index 0000000..913bc16
--- /dev/null
+++ b/mauverse_mobile/Pages/ApplicantGuidePage.xaml
@@ -0,0 +1,91 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/mauverse_mobile/Pages/ApplicantGuidePage.xaml.cs b/mauverse_mobile/Pages/ApplicantGuidePage.xaml.cs
new file mode 100644
index 0000000..fb7eedb
--- /dev/null
+++ b/mauverse_mobile/Pages/ApplicantGuidePage.xaml.cs
@@ -0,0 +1,14 @@
+using mau.Utils.Services.Interface;
+using mau.ViewModel.Services;
+
+namespace mau;
+
+public partial class ApplicantGuidePage : ContentPage
+{
+ public ApplicantGuidePage(IAppNavigationService navigation)
+ {
+ InitializeComponent();
+ BindingContext = UniversityGuideViewModel.CreateApplicant(navigation);
+ Shell.SetTabBarIsVisible(this, false);
+ }
+}
diff --git a/mauverse_mobile/Pages/CampusNavigatorPage.xaml b/mauverse_mobile/Pages/CampusNavigatorPage.xaml
index 952fb2c..c1ca4fe 100644
--- a/mauverse_mobile/Pages/CampusNavigatorPage.xaml
+++ b/mauverse_mobile/Pages/CampusNavigatorPage.xaml
@@ -19,22 +19,17 @@
Stroke="{DynamicResource BorderSubtle}"
StrokeThickness="1"
SemanticProperties.Description="{Binding Title}"
- SemanticProperties.Hint="Открыть маршрут в 2GIS">
-
-
-
+ SemanticProperties.Hint="Корпус кампуса МАУ">
-
+ VerticalOptions="Start">
-
-
+
+
+
+
+
@@ -75,10 +83,51 @@
RowSpacing="16"
MaximumWidthRequest="720">
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/mauverse_mobile/Pages/DigitalServicesPage.xaml b/mauverse_mobile/Pages/DigitalServicesPage.xaml
new file mode 100644
index 0000000..3af2955
--- /dev/null
+++ b/mauverse_mobile/Pages/DigitalServicesPage.xaml
@@ -0,0 +1,91 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/mauverse_mobile/Pages/DigitalServicesPage.xaml.cs b/mauverse_mobile/Pages/DigitalServicesPage.xaml.cs
new file mode 100644
index 0000000..fc74515
--- /dev/null
+++ b/mauverse_mobile/Pages/DigitalServicesPage.xaml.cs
@@ -0,0 +1,14 @@
+using mau.Utils.Services.Interface;
+using mau.ViewModel.Services;
+
+namespace mau;
+
+public partial class DigitalServicesPage : ContentPage
+{
+ public DigitalServicesPage(IAppNavigationService navigation)
+ {
+ InitializeComponent();
+ BindingContext = UniversityGuideViewModel.CreateDigital(navigation);
+ Shell.SetTabBarIsVisible(this, false);
+ }
+}
diff --git a/mauverse_mobile/Pages/EventsCalendarPage.xaml b/mauverse_mobile/Pages/EventsCalendarPage.xaml
new file mode 100644
index 0000000..3b1f305
--- /dev/null
+++ b/mauverse_mobile/Pages/EventsCalendarPage.xaml
@@ -0,0 +1,119 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/mauverse_mobile/Pages/EventsCalendarPage.xaml.cs b/mauverse_mobile/Pages/EventsCalendarPage.xaml.cs
new file mode 100644
index 0000000..87df491
--- /dev/null
+++ b/mauverse_mobile/Pages/EventsCalendarPage.xaml.cs
@@ -0,0 +1,19 @@
+using mau.Database;
+using mau.Utils.API.Interaface;
+using mau.Utils.Services.Interface;
+using mau.ViewModel.Services;
+
+namespace mau;
+
+public partial class EventsCalendarPage : ContentPage
+{
+ public EventsCalendarPage(
+ DbConnect context,
+ IParserRequests parserRequests,
+ IAppNavigationService navigation)
+ {
+ InitializeComponent();
+ BindingContext = new EventsCalendarViewModel(context, parserRequests, navigation);
+ Shell.SetTabBarIsVisible(this, false);
+ }
+}
diff --git a/mauverse_mobile/Pages/InternationalGuidePage.xaml b/mauverse_mobile/Pages/InternationalGuidePage.xaml
new file mode 100644
index 0000000..73a85bc
--- /dev/null
+++ b/mauverse_mobile/Pages/InternationalGuidePage.xaml
@@ -0,0 +1,91 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/mauverse_mobile/Pages/InternationalGuidePage.xaml.cs b/mauverse_mobile/Pages/InternationalGuidePage.xaml.cs
new file mode 100644
index 0000000..ad87931
--- /dev/null
+++ b/mauverse_mobile/Pages/InternationalGuidePage.xaml.cs
@@ -0,0 +1,14 @@
+using mau.Utils.Services.Interface;
+using mau.ViewModel.Services;
+
+namespace mau;
+
+public partial class InternationalGuidePage : ContentPage
+{
+ public InternationalGuidePage(IAppNavigationService navigation)
+ {
+ InitializeComponent();
+ BindingContext = UniversityGuideViewModel.CreateInternational(navigation);
+ Shell.SetTabBarIsVisible(this, false);
+ }
+}
diff --git a/mauverse_mobile/Pages/OnlineFormsPage.xaml b/mauverse_mobile/Pages/OnlineFormsPage.xaml
index be8b83b..7f5764e 100644
--- a/mauverse_mobile/Pages/OnlineFormsPage.xaml
+++ b/mauverse_mobile/Pages/OnlineFormsPage.xaml
@@ -89,6 +89,13 @@
+
+
+
+
+
diff --git a/mauverse_mobile/Pages/SchedulePage.xaml b/mauverse_mobile/Pages/SchedulePage.xaml
index 46f2919..5483e26 100644
--- a/mauverse_mobile/Pages/SchedulePage.xaml
+++ b/mauverse_mobile/Pages/SchedulePage.xaml
@@ -110,7 +110,7 @@
IsVisible="{Binding RoomFilterListIsVisible}"
ItemsSource="{Binding RoomsList}"
SelectedItem="{Binding SelectedRoom}"
- ItemTemplate="{StaticResource ListItemTemplate}">
+ ItemTemplate="{StaticResource RoomListItemTemplate}">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/mauverse_mobile/Pages/ScienceGuidePage.xaml.cs b/mauverse_mobile/Pages/ScienceGuidePage.xaml.cs
new file mode 100644
index 0000000..e348a48
--- /dev/null
+++ b/mauverse_mobile/Pages/ScienceGuidePage.xaml.cs
@@ -0,0 +1,14 @@
+using mau.Utils.Services.Interface;
+using mau.ViewModel.Services;
+
+namespace mau;
+
+public partial class ScienceGuidePage : ContentPage
+{
+ public ScienceGuidePage(IAppNavigationService navigation)
+ {
+ InitializeComponent();
+ BindingContext = UniversityGuideViewModel.CreateScience(navigation);
+ Shell.SetTabBarIsVisible(this, false);
+ }
+}
diff --git a/mauverse_mobile/Pages/SettingsPage.xaml b/mauverse_mobile/Pages/SettingsPage.xaml
index 9f98c6a..dc44d82 100644
--- a/mauverse_mobile/Pages/SettingsPage.xaml
+++ b/mauverse_mobile/Pages/SettingsPage.xaml
@@ -303,6 +303,141 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/mauverse_mobile/Pages/SettingsPage.xaml.cs b/mauverse_mobile/Pages/SettingsPage.xaml.cs
index deb3e88..860cf21 100644
--- a/mauverse_mobile/Pages/SettingsPage.xaml.cs
+++ b/mauverse_mobile/Pages/SettingsPage.xaml.cs
@@ -12,10 +12,16 @@ public SettingsPage(
DbConnect context,
ICacheService persistentCache,
IMemoryCache memoryCache,
- IThemeService themeService)
+ IThemeService themeService,
+ IAppNavigationService navigation)
{
InitializeComponent();
- BindingContext = new SettingsViewModel(context, persistentCache, memoryCache, themeService);
+ BindingContext = new SettingsViewModel(
+ context,
+ persistentCache,
+ memoryCache,
+ themeService,
+ navigation);
Shell.SetTabBarIsVisible(this, false);
}
}
diff --git a/mauverse_mobile/Pages/StudentGuidePage.xaml b/mauverse_mobile/Pages/StudentGuidePage.xaml
new file mode 100644
index 0000000..b0fc6fd
--- /dev/null
+++ b/mauverse_mobile/Pages/StudentGuidePage.xaml
@@ -0,0 +1,91 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/mauverse_mobile/Pages/StudentGuidePage.xaml.cs b/mauverse_mobile/Pages/StudentGuidePage.xaml.cs
new file mode 100644
index 0000000..adabf6b
--- /dev/null
+++ b/mauverse_mobile/Pages/StudentGuidePage.xaml.cs
@@ -0,0 +1,14 @@
+using mau.Utils.Services.Interface;
+using mau.ViewModel.Services;
+
+namespace mau;
+
+public partial class StudentGuidePage : ContentPage
+{
+ public StudentGuidePage(IAppNavigationService navigation)
+ {
+ InitializeComponent();
+ BindingContext = UniversityGuideViewModel.CreateStudent(navigation);
+ Shell.SetTabBarIsVisible(this, false);
+ }
+}
diff --git a/mauverse_mobile/Pages/UniversityContactsPage.xaml b/mauverse_mobile/Pages/UniversityContactsPage.xaml
new file mode 100644
index 0000000..08b4c6a
--- /dev/null
+++ b/mauverse_mobile/Pages/UniversityContactsPage.xaml
@@ -0,0 +1,105 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/mauverse_mobile/Pages/UniversityContactsPage.xaml.cs b/mauverse_mobile/Pages/UniversityContactsPage.xaml.cs
new file mode 100644
index 0000000..16d5ed6
--- /dev/null
+++ b/mauverse_mobile/Pages/UniversityContactsPage.xaml.cs
@@ -0,0 +1,14 @@
+using mau.Utils.Services.Interface;
+using mau.ViewModel.Services;
+
+namespace mau;
+
+public partial class UniversityContactsPage : ContentPage
+{
+ public UniversityContactsPage(IAppNavigationService navigation)
+ {
+ InitializeComponent();
+ BindingContext = new UniversityContactsViewModel(navigation);
+ Shell.SetTabBarIsVisible(this, false);
+ }
+}
diff --git a/mauverse_mobile/Templates/SearchListTemplate.xaml b/mauverse_mobile/Templates/SearchListTemplate.xaml
index 90031f7..386e9b5 100644
--- a/mauverse_mobile/Templates/SearchListTemplate.xaml
+++ b/mauverse_mobile/Templates/SearchListTemplate.xaml
@@ -2,6 +2,7 @@
@@ -30,4 +31,11 @@
+
+
+
+
+
+
+
diff --git a/mauverse_mobile/Utils/RssData.cs b/mauverse_mobile/Utils/RssData.cs
index 0a0a3d1..5b904a0 100644
--- a/mauverse_mobile/Utils/RssData.cs
+++ b/mauverse_mobile/Utils/RssData.cs
@@ -9,5 +9,7 @@ public enum RssData
Science = 4,
International = 5,
Events = 6,
- Other = 7
+ Other = 7,
+ Applicant = 8,
+ Calendar = 9
}
diff --git a/mauverse_mobile/Utils/Services/BrowserDestinationRegistry.cs b/mauverse_mobile/Utils/Services/BrowserDestinationRegistry.cs
index fa8eca0..758923d 100644
--- a/mauverse_mobile/Utils/Services/BrowserDestinationRegistry.cs
+++ b/mauverse_mobile/Utils/Services/BrowserDestinationRegistry.cs
@@ -7,6 +7,12 @@ public static class BrowserDestinationRegistry
public const string InternalBrowserRoute = "browser/internal";
public const string EiosKey = "eios";
public const string MauDigitalServicesKey = "mau-digital-services";
+ public const string LibraryKey = "library";
+ public const string EventsCalendarKey = "events-calendar";
+ public const string OfficialSiteKey = "official-site";
+ public const string PrivacyPolicyKey = "privacy-policy";
+ public const string SvedenKey = "sveden";
+ public const string CampusNavigatorSiteKey = "campus-navigator-site";
private static readonly Dictionary KnownDestinations =
new Dictionary(StringComparer.OrdinalIgnoreCase)
@@ -16,7 +22,25 @@ public static class BrowserDestinationRegistry
new Uri("https://eios.mauniver.ru/moodle/", UriKind.Absolute)),
[MauDigitalServicesKey] = new(
"Цифровые сервисы МАУ",
- new Uri("https://www.mauniver.ru/services/student/", UriKind.Absolute))
+ new Uri("https://www.mauniver.ru/services/student/", UriKind.Absolute)),
+ [LibraryKey] = new(
+ "Библиотека МАУ",
+ new Uri("https://lib.mauniver.ru/MegaPro/Web", UriKind.Absolute)),
+ [EventsCalendarKey] = new(
+ "Календарь событий",
+ new Uri("https://mauniver.ru/press/calendar/", UriKind.Absolute)),
+ [OfficialSiteKey] = new(
+ "Сайт МАУ",
+ new Uri("https://mauniver.ru/", UriKind.Absolute)),
+ [PrivacyPolicyKey] = new(
+ "Политика персональных данных",
+ new Uri("https://mauniver.ru/info/docs/pdn/", UriKind.Absolute)),
+ [SvedenKey] = new(
+ "Сведения об образовательной организации",
+ new Uri("https://mauniver.ru/sveden/", UriKind.Absolute)),
+ [CampusNavigatorSiteKey] = new(
+ "Навигатор по кампусу",
+ new Uri("https://mauniver.ru/info/navigation/", UriKind.Absolute))
};
private static readonly HashSet DownloadExtensions = new(StringComparer.OrdinalIgnoreCase)
@@ -67,6 +91,9 @@ public static BrowserRequest CreateUniversityNews(string title, Uri uri) =>
public static BrowserRequest CreateUniversityNotification(string title, Uri uri) =>
CreateUniversityRequest(title, uri);
+ public static BrowserRequest CreateUniversityPage(string title, Uri uri) =>
+ CreateUniversityRequest(title, uri);
+
public static bool IsUniversityUri(Uri? uri) =>
BrowserRequest.IsHttpOrHttps(uri) && BrowserRequest.IsUniversityHost(uri!.IdnHost);
diff --git a/mauverse_mobile/Utils/Services/UniversityContactsCatalog.cs b/mauverse_mobile/Utils/Services/UniversityContactsCatalog.cs
new file mode 100644
index 0000000..6925a58
--- /dev/null
+++ b/mauverse_mobile/Utils/Services/UniversityContactsCatalog.cs
@@ -0,0 +1,60 @@
+using mau.Models;
+
+namespace mau.Utils.Services;
+
+public static class UniversityContactsCatalog
+{
+ public static IReadOnlyList AdmissionContacts { get; } =
+ [
+ new(
+ "Приёмная комиссия (ВО)",
+ "Бакалавриат, специалитет, магистратура, аспирантура\nпн–пт 10:00–16:00, перерыв 13:00–14:00",
+ Phone: "8 800 350-12-21",
+ Email: "priem@mauniver.ru",
+ Address: "183010, г. Мурманск, пр. Кирова, д. 1, корпус Л, каб. 112"),
+ new(
+ "Приёмная комиссия колледжа МАУ",
+ "Среднее профессиональное образование\nпн–пт 10:00–16:00, перерыв 13:00–14:00",
+ Phone: "8 8152 21-38-72",
+ Email: "priem.spo@mauniver.ru",
+ Address: "183038, г. Мурманск, ул. Ленина, 57, каб. 107"),
+ new(
+ "Филиал в г. Апатиты",
+ "пн–пт 9:00–17:00, сб–вс выходной",
+ Email: "priem@arcticsu.ru",
+ Address: "184209, г. Апатиты, ул. Лесная, д. 29"),
+ new(
+ "Филиал в г. Кировске",
+ "пн–пт 9:00–17:00, сб–вс выходной",
+ Phone: "8 8153 15-54-08",
+ Email: "priem.kirovsk@mauniver.ru",
+ Address: "184250, г. Кировск, ул. 50 лет Октября, д. 2, каб. 1116"),
+ new(
+ "Филиал в г. Полярный",
+ "пн–пт 9:00–17:00, обед 13:00–14:00",
+ Phone: "8 8155 17-36-60",
+ Email: "priem.pf@mauniver.ru",
+ Address: "184651, г. Полярный, ул. Лунина, д. 5")
+ ];
+
+ public static IReadOnlyList UniversityRequisites { get; } =
+ [
+ new(
+ "Адреса МАУ",
+ "Юридический адрес: 183010, г. Мурманск, ул. Спортивная, д. 13\n" +
+ "Почтовый адрес: 183038, г. Мурманск, ул. Капитана Егорова, д. 15\n" +
+ "Телефон: +7 (8152) 21-38-01\nФакс: +7 (8152) 45-27-52",
+ Phone: "+7 (8152) 21-38-01",
+ Email: "office@mauniver.ru",
+ Address: "183010, г. Мурманск, ул. Спортивная, д. 13"),
+ new(
+ "Реквизиты для платежей",
+ "Получатель: УФК по Нижегородской области (ФГАОУ ВО «МАУ», л/сч 30496Ж46000)\n" +
+ "ИНН 5190100176\nКПП 519001001\n" +
+ "Казначейский счёт 03214643000000013212\n" +
+ "Банк: ОТЦ №1 ВВГУ Банка России // УФК по Нижегородской области, г. Нижний Новгород\n" +
+ "БИК 012202102\nСчёт ЕКС 40102810745370000024\n" +
+ "ОГРН 1025100848651\n" +
+ "В назначении платежа за обучение указывайте код 00000000000000000130, ФИО, факультет, специальность, курс.")
+ ];
+}
diff --git a/mauverse_mobile/Utils/Services/UniversityGuideCatalog.cs b/mauverse_mobile/Utils/Services/UniversityGuideCatalog.cs
new file mode 100644
index 0000000..815ad31
--- /dev/null
+++ b/mauverse_mobile/Utils/Services/UniversityGuideCatalog.cs
@@ -0,0 +1,289 @@
+using mau.Models;
+using mau.Resources.Fonts;
+
+namespace mau.Utils.Services;
+
+public static class UniversityGuideCatalog
+{
+ public static IReadOnlyList StudentSections { get; } =
+ [
+ new(
+ "Учёба и сервис",
+ [
+ Link(
+ "Студенческий офис",
+ "Справки, перевод, восстановление и продление билета",
+ UniversityPortalUrls.StudentOffice,
+ FluentUI.building_home_24_regular),
+ Link(
+ "Частые вопросы",
+ "Стипендии, сессия, общежитие и учебный процесс",
+ UniversityPortalUrls.StudentFaq,
+ FluentUI.question_24_regular),
+ Link(
+ "Путеводитель первокурсника",
+ "Что важно знать в начале обучения",
+ UniversityPortalUrls.StudentGuide,
+ FluentUI.book_compass_24_regular),
+ Link(
+ "Расписание на сайте",
+ "Официальное расписание учебных занятий",
+ UniversityPortalUrls.StudentTimetable,
+ FluentUI.calendar_ltr_24_regular)
+ ]),
+ new(
+ "Жизнь в университете",
+ [
+ Link(
+ "Студенческие объединения",
+ "Наука, спорт, волонтёрство и творчество",
+ UniversityPortalUrls.StudentCommunity,
+ FluentUI.people_community_24_regular),
+ Link(
+ "Календарь событий",
+ "Мероприятия и анонсы пресс-центра МАУ",
+ UniversityPortalUrls.EventsCalendar,
+ FluentUI.calendar_ltr_24_regular),
+ Link(
+ "Карьера и трудоустройство",
+ "Центр карьеры и партнёры университета",
+ UniversityPortalUrls.CareerCenter,
+ FluentUI.briefcase_24_regular),
+ Link(
+ "Общежитие",
+ "Правила заселения и проживания",
+ UniversityPortalUrls.HostelRules,
+ FluentUI.bed_24_regular)
+ ]),
+ new(
+ "Поддержка и обращения",
+ [
+ Link(
+ "Виртуальная справочная",
+ "Вопросы студенческому офису онлайн",
+ UniversityPortalUrls.VirtualHelpdesk,
+ FluentUI.person_feedback_24_regular),
+ Link(
+ "Вопросы ректору",
+ "Виртуальная приёмная ректора",
+ UniversityPortalUrls.RectorReception,
+ FluentUI.mail_inbox_24_regular),
+ Link(
+ "Поддержка семей и СВО",
+ "Меры социальной поддержки обучающихся",
+ UniversityPortalUrls.SocialSupport,
+ FluentUI.heart_24_regular),
+ Link(
+ "Электронный каталог библиотеки",
+ "Поиск книг и электронных ресурсов МАУ",
+ UniversityPortalUrls.LibraryCatalog,
+ FluentUI.library_24_regular)
+ ])
+ ];
+
+ public static IReadOnlyList ApplicantSections { get; } =
+ [
+ new(
+ "Поступление",
+ [
+ Link(
+ "Приёмная кампания",
+ "Программы, квоты и подача документов",
+ UniversityPortalUrls.AdmissionPortal,
+ FluentUI.hat_graduation_24_regular),
+ Link(
+ "Правила приёма",
+ "Условия, льготы и особые права",
+ UniversityPortalUrls.AdmissionRules,
+ FluentUI.document_ribbon_24_regular),
+ Link(
+ "Образовательные программы",
+ "Направления, стоимость и планы приёма",
+ UniversityPortalUrls.AdmissionPrograms,
+ FluentUI.book_open_24_regular),
+ Link(
+ "Расписание экзаменов",
+ "Консультации и вступительные испытания",
+ UniversityPortalUrls.AdmissionExams,
+ FluentUI.calendar_ltr_24_regular)
+ ]),
+ new(
+ "Помощь абитуриенту",
+ [
+ Link(
+ "Новости приёмной комиссии",
+ "Списки, приказы и важные даты",
+ UniversityPortalUrls.AdmissionNews,
+ FluentUI.megaphone_loud_24_regular),
+ Link(
+ "Дни открытых дверей",
+ "Экскурсии и встречи с институтами",
+ UniversityPortalUrls.OpenDays,
+ FluentUI.building_home_24_regular),
+ Link(
+ "FAQ абитуриента",
+ "Ответы на частые вопросы о поступлении",
+ UniversityPortalUrls.AdmissionFaq,
+ FluentUI.question_24_regular),
+ Link(
+ "Иностранным абитуриентам",
+ "Поступление для граждан других стран",
+ UniversityPortalUrls.ForeignApplicants,
+ FluentUI.globe_24_regular)
+ ]),
+ new(
+ "Контакты",
+ [
+ Link(
+ "Приёмная комиссия",
+ "Адреса, телефоны и график работы",
+ UniversityPortalUrls.AdmissionContacts,
+ FluentUI.call_24_regular),
+ Link(
+ "Задать вопрос",
+ "Консультация по поступлению",
+ UniversityPortalUrls.AdmissionReception,
+ FluentUI.person_feedback_24_regular),
+ Link(
+ "Общежитие для поступивших",
+ "Как забронировать место",
+ UniversityPortalUrls.HostelRules,
+ FluentUI.bed_24_regular),
+ Link(
+ "Филиалы и колледжи",
+ "Апатиты, Кировск, Полярный и колледжи МАУ",
+ UniversityPortalUrls.Branches,
+ FluentUI.building_multiple_24_regular)
+ ])
+ ];
+
+ public static IReadOnlyList ScienceSections { get; } =
+ [
+ new(
+ "Наука МАУ",
+ [
+ Link(
+ "Раздел «Наука»",
+ "Главная страница научного блока университета",
+ UniversityPortalUrls.ScienceHome,
+ FluentUI.beaker_24_regular),
+ Link(
+ "Новости науки",
+ "Анонсы исследований и научных событий",
+ UniversityPortalUrls.ScienceNews,
+ FluentUI.megaphone_loud_24_regular),
+ Link(
+ "Научные направления",
+ "Приоритетные области исследований МАУ",
+ UniversityPortalUrls.ScienceFields,
+ FluentUI.book_compass_24_regular),
+ Link(
+ "Мероприятия и дайджест",
+ "Конференции, семинары и научный дайджест",
+ UniversityPortalUrls.ScienceDigest,
+ FluentUI.calendar_ltr_24_regular)
+ ]),
+ new(
+ "Исследователю",
+ [
+ Link(
+ "Гранты",
+ "Конкурсы и поддержка научных проектов",
+ UniversityPortalUrls.ScienceGrants,
+ FluentUI.trophy_24_regular),
+ Link(
+ "Помощь исследователю",
+ "Инструкции, базы и доступ к ресурсам",
+ UniversityPortalUrls.ScienceHelp,
+ FluentUI.book_question_mark_24_regular),
+ Link(
+ "Издательство",
+ "Журналы и публикации МАУ",
+ UniversityPortalUrls.SciencePublishing,
+ FluentUI.book_open_24_regular),
+ Link(
+ "Инновации и инфраструктура",
+ "Площадки, лаборатории и инновации",
+ UniversityPortalUrls.ScienceInnovations,
+ FluentUI.building_factory_24_regular)
+ ])
+ ];
+
+ public static IReadOnlyList InternationalSections { get; } =
+ [
+ new(
+ "International",
+ [
+ Link(
+ "English website",
+ "English-language pages of Murmansk Arctic University",
+ UniversityPortalUrls.EnglishSite,
+ FluentUI.globe_24_regular),
+ Link(
+ "Международная деятельность",
+ "Партнёрства, обмен и проекты МАУ",
+ UniversityPortalUrls.InternationalActivity,
+ FluentUI.airplane_24_regular),
+ Link(
+ "Иностранным поступающим",
+ "Поступление для граждан других стран",
+ UniversityPortalUrls.ForeignApplicants,
+ FluentUI.hat_graduation_24_regular),
+ Link(
+ "Diploma Supplement",
+ "Европейское приложение к диплому",
+ UniversityPortalUrls.DiplomaSupplement,
+ FluentUI.document_ribbon_24_regular)
+ ])
+ ];
+
+ public static IReadOnlyList DigitalSections { get; } =
+ [
+ new(
+ "Обучение и документы",
+ [
+ Link(
+ "Цифровые сервисы студентов",
+ "Справки и онлайн-заявки Студенческого офиса",
+ UniversityPortalUrls.DigitalStudentServices,
+ FluentUI.building_24_regular),
+ Link(
+ "ЭИОС",
+ "Электронная информационно-образовательная среда",
+ UniversityPortalUrls.Eios,
+ FluentUI.book_open_microphone_24_regular),
+ Link(
+ "Электронный каталог библиотеки",
+ "Поиск книг и электронных ресурсов",
+ UniversityPortalUrls.LibraryCatalog,
+ FluentUI.library_24_regular)
+ ]),
+ new(
+ "Почта и поддержка",
+ [
+ Link(
+ "Webmail МАУ",
+ "Корпоративная почта @mauniver.ru",
+ UniversityPortalUrls.Webmail,
+ FluentUI.mail_inbox_24_regular),
+ Link(
+ "Заявка по общежитию (УИТ)",
+ "Интернет и ИТ-поддержка в общежитии",
+ UniversityPortalUrls.DormItRequest,
+ FluentUI.wifi_1_24_regular),
+ Link(
+ "Intra МАУ",
+ "Внутренний портал для работников",
+ UniversityPortalUrls.Intra,
+ FluentUI.building_people_24_regular),
+ Link(
+ "PROMT Translation Factory",
+ "Сервис перевода для направления «Лингвистика»",
+ UniversityPortalUrls.Promt,
+ FluentUI.translate_24_regular)
+ ])
+ ];
+
+ static UniversityGuideLink Link(string title, string description, string url, string glyph) =>
+ new(title, description, url, glyph);
+}
diff --git a/mauverse_mobile/Utils/Services/UniversityPortalUrls.cs b/mauverse_mobile/Utils/Services/UniversityPortalUrls.cs
new file mode 100644
index 0000000..733ad62
--- /dev/null
+++ b/mauverse_mobile/Utils/Services/UniversityPortalUrls.cs
@@ -0,0 +1,120 @@
+namespace mau.Utils.Services;
+
+///
+/// Stable HTTPS destinations mirrored from mauniver.ru for guides and unit tests.
+///
+public static class UniversityPortalUrls
+{
+ public const string StudentOffice = "https://mauniver.ru/structure/divs/studof/";
+ public const string StudentFaq = "https://mauniver.ru/student/faq/";
+ public const string StudentGuide = "https://mauniver.ru/student/guide/";
+ public const string StudentTimetable = "https://mauniver.ru/student/timetable/";
+ public const string StudentCommunity = "https://mauniver.ru/student/community/";
+ public const string EventsCalendar = "https://mauniver.ru/press/calendar/";
+ public const string CareerCenter = "https://mauniver.ru/abit/career/";
+ public const string HostelRules = "https://mauniver.ru/abit/rules/hostel/";
+ public const string VirtualHelpdesk = "https://mauniver.ru/services/virtual/";
+ public const string RectorReception = "https://mauniver.ru/rector/reception/";
+ public const string SocialSupport = "https://mauniver.ru/info/docs/ump/";
+ public const string LibraryCatalog = "https://lib.mauniver.ru/MegaPro/Web";
+
+ public const string AdmissionPortal = "https://priem.mauniver.ru/";
+ public const string AdmissionRules = "https://mauniver.ru/abit/rules/";
+ public const string AdmissionPrograms = "https://mauniver.ru/abit/admission/";
+ public const string AdmissionExams = "https://mauniver.ru/abit/exam/";
+ public const string AdmissionNews = "https://mauniver.ru/abit/news/";
+ public const string OpenDays = "https://mauniver.ru/abit/news/open/";
+ public const string AdmissionFaq = "https://mauniver.ru/abit/faq/";
+ public const string ForeignApplicants = "https://mauniver.ru/abit/foreign/";
+ public const string AdmissionContacts = "https://mauniver.ru/abit/contact/";
+ public const string AdmissionReception = "https://mauniver.ru/abit/reception/";
+ public const string Branches = "https://mauniver.ru/structure/branches/";
+
+ public const string ScienceHome = "https://mauniver.ru/science/";
+ public const string ScienceNews = "https://mauniver.ru/science/news/";
+ public const string ScienceFields = "https://mauniver.ru/science/fields/";
+ public const string ScienceEvents = "https://mauniver.ru/science/events/";
+ public const string ScienceDigest = "https://mauniver.ru/science/events/digest/";
+ public const string ScienceGrants = "https://mauniver.ru/science/help/grants/";
+ public const string SciencePublishing = "https://mauniver.ru/science/izdat/";
+ public const string ScienceHelp = "https://mauniver.ru/science/help/";
+ public const string ScienceInnovations = "https://mauniver.ru/science/innovations/";
+ public const string ScienceStructure = "https://mauniver.ru/science/structure/";
+
+ public const string InternationalActivity = "https://mauniver.ru/info/inter/";
+ public const string EnglishSite = "https://mauniver.ru/en/";
+ public const string DiplomaSupplement = "https://mauniver.ru/info/inter/diplom";
+
+ public const string DigitalStudentServices = "https://www.mauniver.ru/services/student/";
+ public const string Webmail = "https://webmail.mauniver.ru/mail/";
+ public const string DormItRequest = "https://serviceahd.mauniver.ru/index.php?a=add&category=21";
+ public const string Intra = "https://intra.mauniver.ru/";
+ public const string Promt = "https://promt.mauniver.ru/ptf";
+ public const string Eios = "https://eios.mauniver.ru/moodle/";
+ public const string Requisites = "https://mauniver.ru/info/address/";
+
+ public static IReadOnlyList StudentUrls { get; } =
+ [
+ StudentOffice,
+ StudentFaq,
+ StudentGuide,
+ StudentTimetable,
+ StudentCommunity,
+ EventsCalendar,
+ CareerCenter,
+ HostelRules,
+ VirtualHelpdesk,
+ RectorReception,
+ SocialSupport,
+ LibraryCatalog
+ ];
+
+ public static IReadOnlyList ApplicantUrls { get; } =
+ [
+ AdmissionPortal,
+ AdmissionRules,
+ AdmissionPrograms,
+ AdmissionExams,
+ AdmissionNews,
+ OpenDays,
+ AdmissionFaq,
+ ForeignApplicants,
+ AdmissionContacts,
+ AdmissionReception,
+ HostelRules,
+ Branches
+ ];
+
+ public static IReadOnlyList ScienceUrls { get; } =
+ [
+ ScienceHome,
+ ScienceNews,
+ ScienceFields,
+ ScienceEvents,
+ ScienceDigest,
+ ScienceGrants,
+ SciencePublishing,
+ ScienceHelp,
+ ScienceInnovations,
+ ScienceStructure
+ ];
+
+ public static IReadOnlyList InternationalUrls { get; } =
+ [
+ InternationalActivity,
+ EnglishSite,
+ ForeignApplicants,
+ DiplomaSupplement
+ ];
+
+ public static IReadOnlyList DigitalServiceUrls { get; } =
+ [
+ DigitalStudentServices,
+ LibraryCatalog,
+ Webmail,
+ DormItRequest,
+ Intra,
+ Promt,
+ Eios
+ ];
+}
diff --git a/mauverse_mobile/ViewModel/News/NewsViewModel.cs b/mauverse_mobile/ViewModel/News/NewsViewModel.cs
index baa257f..63373a2 100644
--- a/mauverse_mobile/ViewModel/News/NewsViewModel.cs
+++ b/mauverse_mobile/ViewModel/News/NewsViewModel.cs
@@ -169,6 +169,18 @@ static List LoadFilterData()
FilterName = "Наука",
FilterType = RssData.Science,
},
+ new Buttons {
+ FilterName = "Международное",
+ FilterType = RssData.International,
+ },
+ new Buttons {
+ FilterName = "Абитуриент",
+ FilterType = RssData.Applicant,
+ },
+ new Buttons {
+ FilterName = "Календарь",
+ FilterType = RssData.Calendar,
+ },
new Buttons {
FilterName = "СМИ о нас",
FilterType = RssData.Other,
diff --git a/mauverse_mobile/ViewModel/Schedules/ScheduleViewModel.cs b/mauverse_mobile/ViewModel/Schedules/ScheduleViewModel.cs
index ab2c925..98014c4 100644
--- a/mauverse_mobile/ViewModel/Schedules/ScheduleViewModel.cs
+++ b/mauverse_mobile/ViewModel/Schedules/ScheduleViewModel.cs
@@ -152,12 +152,27 @@ async Task LoadData(CancellationToken cancellationToken)
[RelayCommand]
async Task FilterData(CancellationToken cancellationToken)
{
- if (SelectedRoom?.RoomId == 0 || TeacherName == "Ничего не найдено")
+ var teacher = TeacherName?.Trim() ?? string.Empty;
+ if (string.Equals(teacher, "Ничего не найдено", StringComparison.Ordinal))
return;
- var roomId = SelectedRoom?.RoomId;
- RoomName = SelectedRoom?.Name ?? string.Empty;
- await FilterScheduleAsync(roomId: roomId ?? 0, teacher: TeacherName, cancellationToken);
+ if (!string.IsNullOrWhiteSpace(teacher))
+ {
+ SelectedRoom = null;
+ RoomName = string.Empty;
+ await FilterScheduleAsync(teacher: teacher, cancellationToken: cancellationToken);
+ }
+ else if (SelectedRoom is { RoomId: > 0 })
+ {
+ TeacherName = string.Empty;
+ RoomName = SelectedRoom.Name;
+ await FilterScheduleAsync(roomId: SelectedRoom.RoomId, cancellationToken: cancellationToken);
+ }
+ else
+ {
+ return;
+ }
+
TeacherList.Clear();
RoomsList.Clear();
TeacherFilterListIsVisible = false;
diff --git a/mauverse_mobile/ViewModel/Services/CampusNavigatorViewModel.cs b/mauverse_mobile/ViewModel/Services/CampusNavigatorViewModel.cs
index a34f76a..02ec98d 100644
--- a/mauverse_mobile/ViewModel/Services/CampusNavigatorViewModel.cs
+++ b/mauverse_mobile/ViewModel/Services/CampusNavigatorViewModel.cs
@@ -2,6 +2,7 @@
using CommunityToolkit.Mvvm.Input;
using mau.Models;
+using mau.Utils.Services;
using mau.Utils.Services.Interface;
using System.Collections.ObjectModel;
@@ -39,6 +40,13 @@ public partial class CampusNavigatorViewModel : ObservableObject
Building("Л57", "пр. Ленина, 57")
];
+ private static readonly IReadOnlyList Branches =
+ [
+ Building("Филиал в г. Апатиты", "ул. Лесная, 29", "apatity", "Апатиты"),
+ Building("Филиал в г. Кировске", "ул. 50 лет Октября, 2", "kirovsk", "Кировск"),
+ Building("Филиал в г. Полярный", "ул. Лунина, 5", "murmansk", "Полярный")
+ ];
+
[ObservableProperty]
private string _searchText = string.Empty;
@@ -70,7 +78,8 @@ private async Task OpenRouteAsync(CampusBuilding? building)
try
{
var query = Uri.EscapeDataString(building.SearchQuery);
- var routeUri = new Uri($"https://2gis.ru/murmansk/search/{query}");
+ var city = string.IsNullOrWhiteSpace(building.MapCity) ? "murmansk" : building.MapCity;
+ var routeUri = new Uri($"https://2gis.ru/{city}/search/{query}");
var opened = await _navigation.OpenExternalAsync(routeUri);
if (!opened)
@@ -83,6 +92,53 @@ private async Task OpenRouteAsync(CampusBuilding? building)
}
}
+ [RelayCommand]
+ private async Task OpenPanoramaAsync(CampusBuilding? building)
+ {
+ if (building is null)
+ return;
+
+ if (Connectivity.Current.NetworkAccess != NetworkAccess.Internet)
+ {
+ await AppShell.DisplaySnackbarAsync("Для панорамы требуется интернет");
+ return;
+ }
+
+ try
+ {
+ var query = Uri.EscapeDataString(building.SearchQuery);
+ var panoramaUri = new Uri($"https://yandex.ru/maps/?text={query}&l=stv");
+ var opened = await _navigation.OpenExternalAsync(panoramaUri);
+ if (!opened)
+ await AppShell.DisplaySnackbarAsync("Не удалось открыть панораму");
+ }
+ catch (Exception ex)
+ {
+ System.Diagnostics.Debug.WriteLine(ex);
+ await AppShell.DisplaySnackbarAsync("Не удалось открыть панораму");
+ }
+ }
+
+ [RelayCommand]
+ private async Task OpenOfficialNavigatorAsync()
+ {
+ if (Connectivity.Current.NetworkAccess != NetworkAccess.Internet)
+ {
+ await AppShell.DisplaySnackbarAsync("Для открытия навигатора требуется интернет");
+ return;
+ }
+
+ try
+ {
+ await _navigation.OpenKnownBrowserAsync(BrowserDestinationRegistry.CampusNavigatorSiteKey);
+ }
+ catch (Exception ex)
+ {
+ System.Diagnostics.Debug.WriteLine(ex);
+ await AppShell.DisplaySnackbarAsync("Не удалось открыть навигатор на сайте");
+ }
+ }
+
private void ApplyFilter()
{
var query = SearchText.Trim();
@@ -90,12 +146,16 @@ private void ApplyFilter()
AddGroup(
"Южный кампус",
- "Остановки: МАУ, переулок Хибинский",
+ "Остановки: «МАУ», «переулок Хибинский». Автобусы и маршрутки по ул. Спортивной / Колхозной / Советской.",
Filter(SouthCampus, query));
AddGroup(
"Северный кампус",
- "Остановки: Капитана Егорова, Академика Книповича",
+ "Остановки: «Капитана Егорова», «Академика Книповича». Удобно добираться от ж/д вокзала и центра.",
Filter(NorthCampus, query));
+ AddGroup(
+ "Филиалы",
+ "Апатиты, Кировск и Полярный — смотрите расписание пригородного транспорта и пропуска для ЗАТО Полярный.",
+ Filter(Branches, query));
OnPropertyChanged(nameof(HasResults));
OnPropertyChanged(nameof(ShowEmptyState));
@@ -118,4 +178,11 @@ private void AddGroup(string name, string stops, IEnumerable sou
private static CampusBuilding Building(string title, string address) =>
new(title, address, $"МАУ {title}, {address}, Мурманск");
+
+ private static CampusBuilding Building(
+ string title,
+ string address,
+ string mapCity,
+ string cityName) =>
+ new(title, address, $"МАУ {title}, {address}, {cityName}", mapCity);
}
diff --git a/mauverse_mobile/ViewModel/Services/EventsCalendarViewModel.cs b/mauverse_mobile/ViewModel/Services/EventsCalendarViewModel.cs
new file mode 100644
index 0000000..51bf92f
--- /dev/null
+++ b/mauverse_mobile/ViewModel/Services/EventsCalendarViewModel.cs
@@ -0,0 +1,132 @@
+using CommunityToolkit.Maui.Views;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+
+using mau.Database;
+using mau.Dialogs;
+using mau.DTOModels;
+using mau.Utils;
+using mau.Utils.API.Interaface;
+using mau.Utils.Services;
+using mau.Utils.Services.Interface;
+
+namespace mau.ViewModel.Services;
+
+public partial class EventsCalendarViewModel : BaseViewModel
+{
+ readonly IParserRequests _parserRequests;
+ readonly IAppNavigationService _navigation;
+ CancellationTokenSource? _loadCts;
+ Task? _loadTask;
+ bool _loaded;
+
+ [ObservableProperty]
+ List _events = [];
+
+ public bool IsLoading => CurrentState == States.Loading;
+ public bool HasContent => CurrentState == States.Success;
+ public bool IsEmpty => CurrentState == States.Empty;
+
+ public EventsCalendarViewModel(
+ DbConnect context,
+ IParserRequests parserRequests,
+ IAppNavigationService navigation) : base(context)
+ {
+ _parserRequests = parserRequests;
+ _navigation = navigation;
+ }
+
+ [RelayCommand]
+ async Task LoadData()
+ {
+ if (_loaded && Events.Count > 0)
+ return;
+
+ if (_loadTask is not null)
+ {
+ await _loadTask;
+ return;
+ }
+
+ var cancellationSource = new CancellationTokenSource();
+ var previous = Interlocked.Exchange(ref _loadCts, cancellationSource);
+ previous?.Cancel();
+ var loadTask = LoadCoreAsync(cancellationSource.Token);
+ _loadTask = loadTask;
+ try
+ {
+ await loadTask;
+ }
+ finally
+ {
+ if (ReferenceEquals(_loadTask, loadTask))
+ {
+ _loadTask = null;
+ Interlocked.CompareExchange(ref _loadCts, null, cancellationSource);
+ }
+
+ cancellationSource.Dispose();
+ }
+ }
+
+ [RelayCommand]
+ async Task OpenEvent(RssDTO? item)
+ {
+ if (item is null)
+ return;
+
+ var details = new DetailNewsPopup(item, _navigation);
+ await Shell.Current.CurrentPage.ShowPopupAsync(details);
+ }
+
+ [RelayCommand]
+ async Task OpenSiteCalendar()
+ {
+ if (Connectivity.Current.NetworkAccess != NetworkAccess.Internet)
+ {
+ await AppShell.DisplaySnackbarAsync("Для открытия календаря требуется интернет");
+ return;
+ }
+
+ try
+ {
+ await _navigation.OpenKnownBrowserAsync(BrowserDestinationRegistry.EventsCalendarKey);
+ }
+ catch (Exception ex)
+ {
+ System.Diagnostics.Debug.WriteLine(ex);
+ await AppShell.DisplaySnackbarAsync("Не удалось открыть календарь на сайте");
+ }
+ }
+
+ async Task LoadCoreAsync(CancellationToken cancellationToken)
+ {
+ SetState(States.Loading);
+ try
+ {
+ Events = [.. await _parserRequests.GetNewsAsync(RssData.Calendar, cancellationToken)];
+ cancellationToken.ThrowIfCancellationRequested();
+ _loaded = true;
+ SetState(Events.Count == 0 ? States.Empty : States.Success);
+ }
+ catch (OperationCanceledException)
+ {
+ }
+ catch (Exception ex)
+ {
+ System.Diagnostics.Debug.WriteLine(ex);
+ SetState(States.Empty);
+ await AppShell.DisplaySnackbarAsync("Не удалось загрузить события");
+ }
+ }
+
+ void SetState(string state)
+ {
+ CurrentState = state;
+ OnPropertyChanged(nameof(IsLoading));
+ OnPropertyChanged(nameof(HasContent));
+ OnPropertyChanged(nameof(IsEmpty));
+ }
+
+ protected override void CancelPendingOperations() => _loadCts?.Cancel();
+}
diff --git a/mauverse_mobile/ViewModel/Services/OnlineFormsViewModel.cs b/mauverse_mobile/ViewModel/Services/OnlineFormsViewModel.cs
index 6ce1571..bd848cb 100644
--- a/mauverse_mobile/ViewModel/Services/OnlineFormsViewModel.cs
+++ b/mauverse_mobile/ViewModel/Services/OnlineFormsViewModel.cs
@@ -71,6 +71,45 @@ public OnlineFormsViewModel(IAppNavigationService navigation)
FluentUI.receipt_money_24_regular)
];
+ public IReadOnlyList SupportForms { get; } =
+ [
+ new(
+ "Виртуальная справочная",
+ "Вопрос студенческому офису или библиотеке",
+ "https://mauniver.ru/services/virtual/",
+ FluentUI.person_feedback_24_regular),
+ new(
+ "Вопросы ректору",
+ "Обращение в виртуальную приёмную",
+ "https://mauniver.ru/rector/reception/",
+ FluentUI.mail_inbox_24_regular),
+ new(
+ "Стать волонтёром МАУ",
+ "Заявка в волонтёрские объединения",
+ "https://mauniver.ru/student/community/volunteer/",
+ FluentUI.heart_24_regular),
+ new(
+ "Поддержка молодых семей",
+ "Обращение по мерам семейной поддержки",
+ $"{FormsBaseUrl}/material/",
+ FluentUI.people_community_24_regular),
+ new(
+ "Поддержка участников СВО",
+ "Единое окно мер поддержки",
+ $"{FormsBaseUrl}/support-svo/",
+ FluentUI.shield_24_regular),
+ new(
+ "Портал «Моё образование»",
+ "Обратная связь по ГИС СЦОС",
+ $"{FormsBaseUrl}/gis-scos/",
+ FluentUI.document_checkmark_24_regular),
+ new(
+ "Вопрос приёмной комиссии",
+ "Консультация для абитуриентов",
+ "https://mauniver.ru/abit/reception/",
+ FluentUI.hat_graduation_24_regular)
+ ];
+
[RelayCommand]
private async Task OpenFormAsync(StudentOnlineForm? form)
{
diff --git a/mauverse_mobile/ViewModel/Services/ServicesViewModel.cs b/mauverse_mobile/ViewModel/Services/ServicesViewModel.cs
index 16b2b4a..3bb4f4a 100644
--- a/mauverse_mobile/ViewModel/Services/ServicesViewModel.cs
+++ b/mauverse_mobile/ViewModel/Services/ServicesViewModel.cs
@@ -2,6 +2,7 @@
using mau.DTOModels;
using mau.Resources.Fonts;
using mau.Utils;
+using mau.Utils.Services;
using mau.Utils.Services.Interface;
using CommunityToolkit.Mvvm.Input;
@@ -38,13 +39,23 @@ public ServicesViewModel(DbConnect context, IAppNavigationService navigation) :
CreateService("Мессенджер ЭИОС", FluentUI.chat_24_regular, "chats"),
CreateService("Навигатор по корпусам", FluentUI.map_24_regular, "campus"),
CreateService("Учебные задолженности", FluentUI.book_24_regular, "study_info"),
- CreateService("Цифровые сервисы МАУ", FluentUI.building_24_regular, "students")
+ CreateService("Цифровые сервисы", FluentUI.building_24_regular, "digital")
+ ];
+
+ ServiceDTO[] portalServices =
+ [
+ CreateService("Студенту", FluentUI.people_community_24_regular, "student_guide"),
+ CreateService("Абитуриенту", FluentUI.hat_graduation_24_regular, "applicant"),
+ CreateService("Наука", FluentUI.beaker_24_regular, "science_guide"),
+ CreateService("International", FluentUI.globe_24_regular, "international")
];
ServiceDTO[] directories =
[
CreateService("Контакты преподавателей", FluentUI.person_support_24_regular, "teacher_info"),
- CreateService("Подразделения и телефоны", FluentUI.phone_24_regular, "telephones")
+ CreateService("Подразделения и телефоны", FluentUI.phone_24_regular, "telephones"),
+ CreateService("Контакты и реквизиты", FluentUI.document_one_page_24_regular, "contacts"),
+ CreateService("Календарь событий", FluentUI.calendar_ltr_24_regular, "events")
];
ServiceRows =
@@ -52,12 +63,15 @@ public ServicesViewModel(DbConnect context, IAppNavigationService navigation) :
new("Услуги", universityServices[0], universityServices[1]),
new(string.Empty, universityServices[2], universityServices[3]),
new(string.Empty, universityServices[4], universityServices[5]),
- new("Справочники", directories[0], directories[1])
+ new("Портал МАУ", portalServices[0], portalServices[1]),
+ new(string.Empty, portalServices[2], portalServices[3]),
+ new("Справочники", directories[0], directories[1]),
+ new(string.Empty, directories[2], directories[3])
];
SelectService = new AsyncRelayCommand(OpenServiceAsync);
}
- public string PageDescription { get; } = "Учебные и цифровые инструменты";
+ public string PageDescription { get; } = "Учебные сервисы и разделы сайта МАУ";
public IReadOnlyList ServiceRows { get; }
public IAsyncRelayCommand SelectService { get; }
@@ -75,12 +89,6 @@ async Task OpenServiceAsync(string? page)
return;
}
- if (string.Equals(page, "students", StringComparison.Ordinal))
- {
- await _navigation.OpenKnownBrowserAsync(BrowserDestinationRegistry.MauDigitalServicesKey);
- return;
- }
-
await _navigation.NavigateAsync($"services/{page}");
}
catch (Exception ex)
diff --git a/mauverse_mobile/ViewModel/Services/UniversityContactsViewModel.cs b/mauverse_mobile/ViewModel/Services/UniversityContactsViewModel.cs
new file mode 100644
index 0000000..5175b90
--- /dev/null
+++ b/mauverse_mobile/ViewModel/Services/UniversityContactsViewModel.cs
@@ -0,0 +1,112 @@
+using CommunityToolkit.Mvvm.Input;
+
+using mau.Models;
+using mau.Utils;
+using mau.Utils.Services;
+using mau.Utils.Services.Interface;
+
+namespace mau.ViewModel.Services;
+
+public partial class UniversityContactsViewModel
+{
+ readonly IAppNavigationService _navigation;
+
+ public UniversityContactsViewModel(IAppNavigationService navigation)
+ {
+ _navigation = navigation;
+ }
+
+ public string PageDescription { get; } =
+ "Телефоны приёмной комиссии и платёжные реквизиты МАУ";
+
+ public IReadOnlyList AdmissionContacts { get; } =
+ UniversityContactsCatalog.AdmissionContacts;
+
+ public IReadOnlyList UniversityRequisites { get; } =
+ UniversityContactsCatalog.UniversityRequisites;
+
+ [RelayCommand]
+ [System.Diagnostics.CodeAnalysis.SuppressMessage(
+ "Performance",
+ "CA1822:Mark members as static",
+ Justification = "RelayCommand generation requires the instance command contract used by XAML.")]
+ async Task CallAsync(UniversityContactBlock? block)
+ {
+ if (block is null || string.IsNullOrWhiteSpace(block.Phone))
+ return;
+
+ try
+ {
+ var digits = new string(block.Phone.Where(c => char.IsDigit(c) || c == '+').ToArray());
+ if (digits.StartsWith('8') && digits.Length >= 11)
+ digits = "+7" + digits[1..];
+
+ PhoneDialer.Default.Open(digits);
+ }
+ catch (Exception ex)
+ {
+ System.Diagnostics.Debug.WriteLine(ex);
+ await AppShell.DisplaySnackbarAsync("Не удалось открыть телефон");
+ }
+ }
+
+ [RelayCommand]
+ [System.Diagnostics.CodeAnalysis.SuppressMessage(
+ "Performance",
+ "CA1822:Mark members as static",
+ Justification = "RelayCommand generation requires the instance command contract used by XAML.")]
+ async Task CopyAsync(UniversityContactBlock? block)
+ {
+ if (block is null)
+ return;
+
+ try
+ {
+ var text = string.Join(
+ Environment.NewLine,
+ new[]
+ {
+ block.Title,
+ block.Details,
+ block.Address,
+ block.Phone,
+ block.Email
+ }.Where(static part => !string.IsNullOrWhiteSpace(part)));
+
+ await Clipboard.Default.SetTextAsync(text);
+ await AppShell.DisplaySnackbarAsync("Скопировано");
+ }
+ catch (Exception ex)
+ {
+ System.Diagnostics.Debug.WriteLine(ex);
+ await AppShell.DisplaySnackbarAsync("Не удалось скопировать");
+ }
+ }
+
+ [RelayCommand]
+ async Task OpenRequisitesPageAsync()
+ {
+ if (Connectivity.Current.NetworkAccess != NetworkAccess.Internet)
+ {
+ await AppShell.DisplaySnackbarAsync("Для открытия страницы требуется интернет");
+ return;
+ }
+
+ try
+ {
+ if (!ExternalUri.TryCreateHttp(UniversityPortalUrls.Requisites, out var uri))
+ {
+ await AppShell.DisplaySnackbarAsync("Ссылка недоступна");
+ return;
+ }
+
+ await _navigation.OpenBrowserAsync(
+ BrowserDestinationRegistry.CreateUniversityPage("Реквизиты МАУ", uri));
+ }
+ catch (Exception ex)
+ {
+ System.Diagnostics.Debug.WriteLine(ex);
+ await AppShell.DisplaySnackbarAsync("Не удалось открыть реквизиты");
+ }
+ }
+}
diff --git a/mauverse_mobile/ViewModel/Services/UniversityGuideViewModel.cs b/mauverse_mobile/ViewModel/Services/UniversityGuideViewModel.cs
new file mode 100644
index 0000000..c45976c
--- /dev/null
+++ b/mauverse_mobile/ViewModel/Services/UniversityGuideViewModel.cs
@@ -0,0 +1,87 @@
+using CommunityToolkit.Mvvm.Input;
+
+using mau.Models;
+using mau.Utils;
+using mau.Utils.Services;
+using mau.Utils.Services.Interface;
+
+namespace mau.ViewModel.Services;
+
+public partial class UniversityGuideViewModel
+{
+ readonly IAppNavigationService _navigation;
+
+ public UniversityGuideViewModel(
+ IAppNavigationService navigation,
+ string pageDescription,
+ IReadOnlyList sections)
+ {
+ _navigation = navigation;
+ PageDescription = pageDescription;
+ Sections = sections;
+ }
+
+ public string PageDescription { get; }
+
+ public IReadOnlyList Sections { get; }
+
+ public static UniversityGuideViewModel CreateStudent(IAppNavigationService navigation) =>
+ new(
+ navigation,
+ "Разделы официального сайта для обучающихся МАУ",
+ UniversityGuideCatalog.StudentSections);
+
+ public static UniversityGuideViewModel CreateApplicant(IAppNavigationService navigation) =>
+ new(
+ navigation,
+ "Поступление, программы и контакты приёмной комиссии",
+ UniversityGuideCatalog.ApplicantSections);
+
+ public static UniversityGuideViewModel CreateScience(IAppNavigationService navigation) =>
+ new(
+ navigation,
+ "Новости, гранты, издательство и инфраструктура науки МАУ",
+ UniversityGuideCatalog.ScienceSections);
+
+ public static UniversityGuideViewModel CreateInternational(IAppNavigationService navigation) =>
+ new(
+ navigation,
+ "English site, exchange and international admissions",
+ UniversityGuideCatalog.InternationalSections);
+
+ public static UniversityGuideViewModel CreateDigital(IAppNavigationService navigation) =>
+ new(
+ navigation,
+ "ЭИОС, библиотека, почта, Intra и заявки УИТ",
+ UniversityGuideCatalog.DigitalSections);
+
+ [RelayCommand]
+ async Task OpenLinkAsync(UniversityGuideLink? link)
+ {
+ if (link is null)
+ return;
+
+ if (Connectivity.Current.NetworkAccess != NetworkAccess.Internet)
+ {
+ await AppShell.DisplaySnackbarAsync("Для открытия раздела требуется интернет");
+ return;
+ }
+
+ try
+ {
+ if (!ExternalUri.TryCreateHttp(link.Url, out var uri))
+ {
+ await AppShell.DisplaySnackbarAsync("Ссылка недоступна");
+ return;
+ }
+
+ await _navigation.OpenBrowserAsync(
+ BrowserDestinationRegistry.CreateUniversityPage(link.Title, uri));
+ }
+ catch (Exception ex)
+ {
+ System.Diagnostics.Debug.WriteLine(ex);
+ await AppShell.DisplaySnackbarAsync("Не удалось открыть раздел");
+ }
+ }
+}
diff --git a/mauverse_mobile/ViewModel/SettingsViewModel.cs b/mauverse_mobile/ViewModel/SettingsViewModel.cs
index f7df930..f2e2605 100644
--- a/mauverse_mobile/ViewModel/SettingsViewModel.cs
+++ b/mauverse_mobile/ViewModel/SettingsViewModel.cs
@@ -14,16 +14,19 @@ public partial class SettingsViewModel : BaseViewModel
readonly ICacheService _persistentCache;
readonly IMemoryCache _memoryCache;
readonly IThemeService _themeService;
+ readonly IAppNavigationService _navigation;
public SettingsViewModel(
DbConnect context,
ICacheService persistentCache,
IMemoryCache memoryCache,
- IThemeService themeService) : base(context)
+ IThemeService themeService,
+ IAppNavigationService navigation) : base(context)
{
_persistentCache = persistentCache;
_memoryCache = memoryCache;
_themeService = themeService;
+ _navigation = navigation;
_selectedThemeIndex = (int)_themeService.CurrentMode;
UpdateThemeSelection(_selectedThemeIndex);
}
@@ -191,6 +194,29 @@ async Task CopyDiagnostics()
}
}
+ [RelayCommand]
+ async Task OpenUniversityLink(string? key)
+ {
+ if (string.IsNullOrWhiteSpace(key))
+ return;
+
+ if (Connectivity.Current.NetworkAccess != NetworkAccess.Internet)
+ {
+ await AppShell.DisplaySnackbarAsync("Для открытия ссылки требуется интернет");
+ return;
+ }
+
+ try
+ {
+ await _navigation.OpenKnownBrowserAsync(key);
+ }
+ catch (Exception ex)
+ {
+ System.Diagnostics.Debug.WriteLine(ex);
+ await AppShell.DisplaySnackbarAsync("Не удалось открыть раздел сайта");
+ }
+ }
+
void UpdateThemeSelection(int value)
{
IsSystemThemeSelected = value == (int)ThemeMode.System;
diff --git a/mauverse_mobile/mau.csproj b/mauverse_mobile/mau.csproj
index 9bbf27c..e91cdf5 100644
--- a/mauverse_mobile/mau.csproj
+++ b/mauverse_mobile/mau.csproj
@@ -1,8 +1,8 @@
- net10.0-android
- $(TargetFrameworks);net10.0-ios;net10.0-maccatalyst
- $(TargetFrameworks);net10.0-windows10.0.19041.0
+ net10.0-android
+ $(TargetFrameworks);net10.0-ios;net10.0-maccatalyst
+ $(TargetFrameworks);net10.0-windows10.0.19041.0
Exe
mau
@@ -14,10 +14,10 @@
false
true
- MAUverse
+ MAUverse
com.pmi4freal.mauverse3
- 1.8.5
- 20
+ 1.12.0
+ 29
https://app.mauniver.ru/dev/mauverse/
None
@@ -48,8 +48,8 @@
- aab;apk
- true
+ aab;apk
+ true
@@ -68,13 +68,13 @@
-
-
-
-
-
-
-
+
+
+
+
+
+
+