diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index a5d112b..303ef31 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -104,6 +104,7 @@ jobs:
timeout-minutes: 45
env:
NUGET_PACKAGES: ${{ github.workspace }}/.nuget/packages
+ MAUVERSE_SCHEDULE_TOKEN: ${{ secrets.MAUVERSE_SCHEDULE_TOKEN }}
defaults:
run:
working-directory: mauverse_mobile
@@ -144,6 +145,8 @@ jobs:
run: dotnet test ../mauverse_mobile.Tests/mauverse_mobile.Tests.csproj --configuration Release --no-restore --warnaserror
- name: Build Release configuration
+ env:
+ MAUVERSE_SCHEDULE_TOKEN: ${{ secrets.MAUVERSE_SCHEDULE_TOKEN }}
run: >-
dotnet build mau.csproj
--configuration Release
@@ -152,48 +155,70 @@ jobs:
--no-restore
--warnaserror
-p:ContinuousIntegrationBuild=true
+ -p:MauverseScheduleToken="$MAUVERSE_SCHEDULE_TOKEN"
-maxcpucount:1
+ - name: Stage unsigned AAB
+ shell: bash
+ run: |
+ mapfile -t bundles < <(find bin/Release/net10.0-android \
+ -type f -name '*.aab' ! -name '*-Signed.aab' -print)
+ test "${#bundles[@]}" -eq 1
+ mkdir -p artifacts/android
+ cp "${bundles[0]}" artifacts/android/mauverse-android-arm64-unsigned.aab
+
+ - name: Package unsigned APK
+ env:
+ MAUVERSE_SCHEDULE_TOKEN: ${{ secrets.MAUVERSE_SCHEDULE_TOKEN }}
+ run: >-
+ dotnet build mau.csproj
+ --target:PackageForAndroid
+ --configuration Release
+ --framework net10.0-android
+ --runtime android-arm64
+ --no-restore
+ --warnaserror
+ -p:AndroidPackageFormats=apk
+ -p:ContinuousIntegrationBuild=true
+ -p:MauverseScheduleToken="$MAUVERSE_SCHEDULE_TOKEN"
+ -maxcpucount:1
+
+ - name: Stage unsigned APK
+ shell: bash
+ run: |
+ mapfile -t packages < <(find bin/Release/net10.0-android \
+ -type f -name '*.apk' ! -name '*-Signed.apk' -print)
+ test "${#packages[@]}" -eq 1
+ cp "${packages[0]}" artifacts/android/mauverse-android-arm64-unsigned.apk
+
- name: Create unsigned artifact checksums
shell: bash
run: |
- mapfile -t artifacts < <(find bin/Release/net10.0-android \
- -type f \( -name '*.aab' -o \( -name '*.apk' ! -name '*-Signed.apk' \) \) \
+ mapfile -t artifacts < <(find artifacts/android \
+ -type f \( -name '*.aab' -o -name '*.apk' \) \
-print | sort)
- test "${#artifacts[@]}" -ge 2
- sha256sum "${artifacts[@]}" > bin/Release/net10.0-android/checksums.txt
+ test "${#artifacts[@]}" -eq 2
+ sha256sum "${artifacts[@]}" > artifacts/android/checksums-android.txt
- name: Upload unsigned CI artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: mauverce-android-release-unsigned
- path: |
- mauverse_mobile/bin/Release/net10.0-android/**/*.apk
- !mauverse_mobile/bin/Release/net10.0-android/**/*-Signed.apk
- mauverse_mobile/bin/Release/net10.0-android/**/*.aab
- mauverse_mobile/bin/Release/net10.0-android/checksums.txt
+ path: mauverse_mobile/artifacts/android/
if-no-files-found: error
retention-days: 14
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 +227,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
-
- - 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
+ xcodegen --version
+ xcodebuild -project MauverseIOS/MAUverse.xcodeproj -list
- - 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 +284,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/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..1ef2077
--- /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.11.0
+ CURRENT_PROJECT_VERSION: 28
+ MAUVERSE_SCHEDULE_TOKEN: ""
+ TARGETED_DEVICE_FAMILY: "1"
+ SUPPORTS_MACCATALYST: NO
+ scheme:
+ gatherCoverageData: false
diff --git a/README.md b/README.md
index a85ca7b..b915256 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,24 @@
# MAUverce
+## Native iPhone app
+
+The native SwiftUI implementation lives in `MauverseIOS`. It targets iPhone,
+uses the system Liquid Glass appearance on iOS 26, and falls back to native
+materials on iOS 18–25.
+
+Generate the Xcode project and build it locally:
+
+```bash
+brew install xcodegen
+cd MauverseIOS
+xcodegen generate
+open MAUverse.xcodeproj
+```
+
+The `ios-release-unsigned` GitHub Actions job builds with Xcode 26.5 and
+explicitly disables code signing. Its artifact contains an unsigned IPA that
+can be signed with your own Apple certificate and provisioning profile.
+
MAUverce is a student mobile application for Murmansk Arctic University. The
repository contains a .NET 9 MAUI client and a FastAPI gateway.
diff --git a/mauverse_mobile.Tests/UserGreetingTests.cs b/mauverse_mobile.Tests/UserGreetingTests.cs
new file mode 100644
index 0000000..91df786
--- /dev/null
+++ b/mauverse_mobile.Tests/UserGreetingTests.cs
@@ -0,0 +1,31 @@
+using mau.Utils;
+using Xunit;
+
+namespace Mauverse.Mobile.Tests;
+
+public sealed class UserGreetingTests
+{
+ [Fact]
+ public void UsesFirstNameInsteadOfLogin()
+ {
+ Assert.Equal("Иван", UserGreeting.ResolveFirstName("Иван", "Иванов Иван Иванович", "student01"));
+ }
+
+ [Fact]
+ public void IgnoresLoginAccidentallyReturnedAsFirstName()
+ {
+ Assert.Equal("Иван", UserGreeting.ResolveFirstName("student01", "Иванов Иван Иванович", "student01"));
+ }
+
+ [Fact]
+ public void UsesNeutralFallbackWhenOnlyLoginIsAvailable()
+ {
+ Assert.Equal("Студент", UserGreeting.ResolveFirstName("student01", "", "student01"));
+ }
+
+ [Fact]
+ public void UsesFirstPartForMoodleTwoPartDisplayName()
+ {
+ Assert.Equal("Иван", UserGreeting.ResolveFirstName("", "Иван Иванов", "student01"));
+ }
+}
diff --git a/mauverse_mobile.Tests/mauverse_mobile.Tests.csproj b/mauverse_mobile.Tests/mauverse_mobile.Tests.csproj
index a16ddfd..a456e4b 100644
--- a/mauverse_mobile.Tests/mauverse_mobile.Tests.csproj
+++ b/mauverse_mobile.Tests/mauverse_mobile.Tests.csproj
@@ -25,6 +25,8 @@
Link="Production\BrowserDestinationRegistry.cs" />
+
diff --git a/mauverse_mobile/DTOModels/UserDTO.cs b/mauverse_mobile/DTOModels/UserDTO.cs
index 6c1598a..0f9cd23 100644
--- a/mauverse_mobile/DTOModels/UserDTO.cs
+++ b/mauverse_mobile/DTOModels/UserDTO.cs
@@ -3,21 +3,30 @@
namespace mau.DTOModels;
-public sealed class UserDTO : BaseDTO
-{
- public int UserId { get; set; }
- public string Username { get; set; } = string.Empty;
- public string FirstName { get; set; } = string.Empty;
- public string FullName { get; set; } = string.Empty;
+public sealed class UserDTO : BaseDTO
+{
+ [JsonPropertyName("userid")]
+ public int UserId { get; set; }
+ public string Username { get; set; } = string.Empty;
+
+ [JsonPropertyName("firstname")]
+ public string FirstName { get; set; } = string.Empty;
+
+ [JsonPropertyName("fullname")]
+ public string FullName { get; set; } = string.Empty;
[JsonPropertyName("roleid")]
public UserRole Role { get; set; }
- public string CreditBook { get; set; } = string.Empty;
- public string GroupId { get; set; } = string.Empty;
- public string SubGroupId { get; set; } = string.Empty;
- public string GroupName { get; set; } = string.Empty;
+ public string CreditBook { get; set; } = string.Empty;
+
+ public string GroupId { get; set; } = string.Empty;
+
+ public string SubGroupId { get; set; } = string.Empty;
+
+ [JsonPropertyName("groupname")]
+ public string GroupName { get; set; } = string.Empty;
public string Speciality { get; set; } = string.Empty;
public string Token { get; set; } = string.Empty;
- public string PrivateToken { get; set; } = string.Empty;
-}
+ public string PrivateToken { get; set; } = string.Empty;
+}
diff --git a/mauverse_mobile/DTOModels/UserInfoDTO.cs b/mauverse_mobile/DTOModels/UserInfoDTO.cs
index e994a17..002e597 100644
--- a/mauverse_mobile/DTOModels/UserInfoDTO.cs
+++ b/mauverse_mobile/DTOModels/UserInfoDTO.cs
@@ -1,14 +1,18 @@
-using mau.Models;
-using mau.Utils;
+using mau.Models;
+using mau.Utils;
+using System.Text.Json.Serialization;
namespace mau.DTOModels;
public sealed class UserInfoDTO : BaseDTO
{
- public string GroupName { get; set; } = string.Empty;
- public UserRole RoleId { get; set; } = UserRole.All;
+ [JsonPropertyName("groupname")]
+ public string GroupName { get; set; } = string.Empty;
+
+ [JsonPropertyName("roleid")]
+ public UserRole RoleId { get; set; } = UserRole.All;
public string Username { get; set; } = string.Empty;
- public string GroupId { get; set; } = string.Empty;
+ public string GroupId { get; set; } = string.Empty;
public string Speciality { get; set; } = string.Empty;
public IEnumerable SubGroups { get; set; } = [];
}
diff --git a/mauverse_mobile/Pages/NewsPage.xaml b/mauverse_mobile/Pages/NewsPage.xaml
index a144ff6..5409b8a 100644
--- a/mauverse_mobile/Pages/NewsPage.xaml
+++ b/mauverse_mobile/Pages/NewsPage.xaml
@@ -81,12 +81,15 @@
VerticalTextAlignment="Center" />
-
+
+
+
diff --git a/mauverse_mobile/Pages/OnlineFormsPage.xaml b/mauverse_mobile/Pages/OnlineFormsPage.xaml
index be8b83b..82439bc 100644
--- a/mauverse_mobile/Pages/OnlineFormsPage.xaml
+++ b/mauverse_mobile/Pages/OnlineFormsPage.xaml
@@ -84,7 +84,7 @@
-
diff --git a/mauverse_mobile/Utils/API/Interaface/IParserRequests.cs b/mauverse_mobile/Utils/API/Interaface/IParserRequests.cs
index be80050..392762c 100644
--- a/mauverse_mobile/Utils/API/Interaface/IParserRequests.cs
+++ b/mauverse_mobile/Utils/API/Interaface/IParserRequests.cs
@@ -9,9 +9,10 @@ public interface IParserRequests
Task> GetTelephonesAsync(
DeptInfoDTO department,
CancellationToken cancellationToken = default);
- Task> GetNewsAsync(
- RssData type,
- CancellationToken cancellationToken = default);
+ Task> GetNewsAsync(
+ RssData type,
+ bool forceRefresh = false,
+ CancellationToken cancellationToken = default);
Task GetTeacherInfoAsync(
string teacher,
CancellationToken cancellationToken = default);
diff --git a/mauverse_mobile/Utils/API/ParserRequests.cs b/mauverse_mobile/Utils/API/ParserRequests.cs
index 3da6a64..c4e99e5 100644
--- a/mauverse_mobile/Utils/API/ParserRequests.cs
+++ b/mauverse_mobile/Utils/API/ParserRequests.cs
@@ -35,11 +35,15 @@ await GetListAsync(
token => _apiService.GetAsync>("/get_depts_json", token),
cancellationToken);
- public async Task> GetNewsAsync(RssData type, CancellationToken cancellationToken = default) =>
- await GetListAsync(
- $"news-{(int)type}",
- token => _apiService.GetAsync>($"/news?news_type={(int)type}", token),
- cancellationToken);
+ public async Task> GetNewsAsync(
+ RssData type,
+ bool forceRefresh = false,
+ CancellationToken cancellationToken = default) =>
+ await GetListAsync(
+ $"news-{(int)type}",
+ token => _apiService.GetAsync>($"/news?news_type={(int)type}", token),
+ cancellationToken,
+ forceRefresh);
public async Task> GetRoomsAsync(string room, CancellationToken cancellationToken = default)
{
@@ -106,20 +110,25 @@ await GetListAsync(
token),
cancellationToken);
- private async Task> GetListAsync(
- string key,
- Func>> fetch,
- CancellationToken cancellationToken)
- {
- if (_memoryCache.TryGetValue(key, out List? memoryValue) && memoryValue is not null)
- return memoryValue;
+ private async Task> GetListAsync(
+ string key,
+ Func>> fetch,
+ CancellationToken cancellationToken,
+ bool forceRefresh = false)
+ {
+ if (!forceRefresh &&
+ _memoryCache.TryGetValue(key, out List? memoryValue) &&
+ memoryValue is not null)
+ return memoryValue;
var requestLock = _requestLocks.GetOrAdd(key, static _ => new SemaphoreSlim(1, 1));
await requestLock.WaitAsync(cancellationToken);
try
{
- if (_memoryCache.TryGetValue(key, out memoryValue) && memoryValue is not null)
- return memoryValue;
+ if (!forceRefresh &&
+ _memoryCache.TryGetValue(key, out memoryValue) &&
+ memoryValue is not null)
+ return memoryValue;
Exception? requestError = null;
if (Connectivity.Current.NetworkAccess == NetworkAccess.Internet)
diff --git a/mauverse_mobile/Utils/API/ScheduleRequests.cs b/mauverse_mobile/Utils/API/ScheduleRequests.cs
index a2695f5..6a44dd3 100644
--- a/mauverse_mobile/Utils/API/ScheduleRequests.cs
+++ b/mauverse_mobile/Utils/API/ScheduleRequests.cs
@@ -1,88 +1,271 @@
-using System.Globalization;
-using mau.DTOModels;
-using mau.Models;
-using mau.Utils.API.Interaface;
-using mau.Utils.Services.Interface;
-
-namespace mau.Utils.API;
-
-public sealed class ScheduleRequests(IAPIService apiService) : IScheduleRequests, IDisposable
-{
-#pragma warning disable CA1707 // Existing ViewModels use these public parameter names as named arguments.
- public Task> GetSchedulesAsync(
- string group_id,
- string subgroup_id,
- CancellationToken cancellationToken = default) =>
- GetScheduleAsync(
- groupId: group_id,
- subgroupId: subgroup_id,
- cancellationToken: cancellationToken);
-
- public Task> GetSchedulesAsync(
- string[] teacher,
- CancellationToken cancellationToken = default) =>
- GetScheduleAsync(teacher: teacher, cancellationToken: cancellationToken);
-
- public Task> GetSchedulesAsync(
- int room_id,
- CancellationToken cancellationToken = default) =>
- GetScheduleAsync(roomId: room_id, cancellationToken: cancellationToken);
-#pragma warning restore CA1707
-
- private async Task> GetScheduleAsync(
- string groupId = "",
- string subgroupId = "",
- string[]? teacher = null,
- int roomId = 0,
- CancellationToken cancellationToken = default)
- {
- var mondayDate = GetWeekStart(DateTime.Now);
- var monday = mondayDate.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
- var sunday = mondayDate.AddDays(13).ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
- var request = CreateScheduleRequest(monday, sunday, groupId, subgroupId, teacher, roomId);
- var schedule = await apiService.PostAsync("/get_schedule", request, cancellationToken);
- return schedule.Schedules;
- }
-
- private static DateTime GetWeekStart(DateTime date)
- {
- var difference = date.DayOfWeek == DayOfWeek.Sunday
- ? -6
- : DayOfWeek.Monday - date.DayOfWeek;
- return date.Date.AddDays(difference);
- }
-
- private static Dictionary CreateScheduleRequest(
- string monday,
- string sunday,
- string groupId,
- string subgroupId,
- string[]? teacher,
- int roomId)
- {
- var request = new Dictionary
- {
- ["start_date"] = monday,
- ["end_date"] = sunday
- };
-
- if (!string.IsNullOrEmpty(groupId))
- request["group_id"] = groupId;
- if (!string.IsNullOrEmpty(subgroupId))
- request["subgroup_id"] = subgroupId;
- if (roomId != 0)
- request["room_id"] = roomId.ToString(CultureInfo.InvariantCulture);
-
- if (teacher is { Length: > 0 })
- {
- request["teacher_first_name"] = teacher[0];
- request["teacher_second_name"] = teacher.Length > 1 ? teacher[1] : string.Empty;
- if (teacher.Length > 2)
- request["teacher_last_name"] = teacher[2];
- }
-
- return request;
- }
-
- public void Dispose() => GC.SuppressFinalize(this);
-}
+using System.Globalization;
+using System.Net.Http.Headers;
+using System.Reflection;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using mau.DTOModels;
+using mau.Models;
+using mau.Utils.API.Interaface;
+using mau.Utils.Services.Interface;
+
+namespace mau.Utils.API;
+
+public sealed class ScheduleRequests : IScheduleRequests, IDisposable
+{
+ private static readonly Uri ScheduleBaseUri = new("https://api-schedule.mauniver.ru/");
+ private static readonly HttpClient ScheduleTransport = new()
+ {
+ Timeout = TimeSpan.FromSeconds(25)
+ };
+ private static readonly JsonSerializerOptions ScheduleJson = new(JsonSerializerDefaults.Web)
+ {
+ PropertyNameCaseInsensitive = true
+ };
+
+ private readonly IAPIService _legacyApi;
+ private readonly string _scheduleToken;
+
+ public ScheduleRequests(IAPIService apiService)
+ {
+ _legacyApi = apiService;
+ _scheduleToken = typeof(ScheduleRequests).Assembly
+ .GetCustomAttributes()
+ .FirstOrDefault(attribute => attribute.Key == "MauverseScheduleToken")
+ ?.Value
+ ?.Trim() ?? string.Empty;
+ }
+
+#pragma warning disable CA1707
+ public async Task> GetSchedulesAsync(
+ string group_id,
+ string subgroup_id,
+ CancellationToken cancellationToken = default)
+ {
+ var scheduleUid = !string.IsNullOrWhiteSpace(subgroup_id)
+ ? subgroup_id.Trim()
+ : group_id.Trim();
+ if (!string.IsNullOrWhiteSpace(_scheduleToken) && !string.IsNullOrWhiteSpace(scheduleUid))
+ {
+ try
+ {
+ return await GetCurrentScheduleAsync(scheduleUid, cancellationToken);
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception exception)
+ {
+ // The authenticated MAUverse API remains a compatibility fallback
+ // for accounts whose group identifier has not yet been migrated to UID.
+ System.Diagnostics.Debug.WriteLine(exception);
+ }
+ }
+
+ return await GetLegacyScheduleAsync(
+ groupId: group_id,
+ subgroupId: subgroup_id,
+ cancellationToken: cancellationToken);
+ }
+
+ public Task> GetSchedulesAsync(
+ string[] teacher,
+ CancellationToken cancellationToken = default) =>
+ GetLegacyScheduleAsync(teacher: teacher, cancellationToken: cancellationToken);
+
+ public Task> GetSchedulesAsync(
+ int room_id,
+ CancellationToken cancellationToken = default) =>
+ GetLegacyScheduleAsync(roomId: room_id, cancellationToken: cancellationToken);
+#pragma warning restore CA1707
+
+ private async Task> GetCurrentScheduleAsync(
+ string groupUid,
+ CancellationToken cancellationToken)
+ {
+ var monday = GetWeekStart(DateTime.Now);
+ var start = monday.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
+ var end = monday.AddDays(13).ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
+ var escapedUid = Uri.EscapeDataString(groupUid);
+ var requestUri = new Uri(
+ ScheduleBaseUri,
+ $"groups/{escapedUid}/schedule/{start}/{end}");
+
+ using var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
+ request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _scheduleToken);
+ request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
+
+ using var response = await SendScheduleAsync(request, cancellationToken);
+ if (!response.IsSuccessStatusCode)
+ {
+ throw new HttpRequestException(
+ $"API расписания вернул ошибку {(int)response.StatusCode}",
+ null,
+ response.StatusCode);
+ }
+
+ await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
+ var payload = await JsonSerializer.DeserializeAsync(
+ stream,
+ ScheduleJson,
+ cancellationToken);
+ if (payload is null || payload.Success == false)
+ throw new InvalidDataException("API расписания вернул некорректный ответ");
+
+ return payload.Timetable.Select(MapSchedule).ToArray();
+ }
+
+ private static async Task SendScheduleAsync(
+ HttpRequestMessage request,
+ CancellationToken cancellationToken)
+ {
+ HttpResponseMessage? lastResponse = null;
+ for (var attempt = 0; attempt < 2; attempt++)
+ {
+ using var attemptRequest = await CloneAsync(request, cancellationToken);
+ try
+ {
+ var response = await ScheduleTransport.SendAsync(
+ attemptRequest,
+ HttpCompletionOption.ResponseHeadersRead,
+ cancellationToken);
+ if (attempt == 0 &&
+ response.StatusCode is System.Net.HttpStatusCode.BadGateway or
+ System.Net.HttpStatusCode.ServiceUnavailable or
+ System.Net.HttpStatusCode.GatewayTimeout)
+ {
+ lastResponse?.Dispose();
+ lastResponse = response;
+ await Task.Delay(350, cancellationToken);
+ continue;
+ }
+
+ lastResponse?.Dispose();
+ return response;
+ }
+ catch (HttpRequestException) when (attempt == 0)
+ {
+ await Task.Delay(350, cancellationToken);
+ }
+ }
+
+ return lastResponse ?? throw new HttpRequestException("API расписания недоступен");
+ }
+
+ private static async Task CloneAsync(
+ HttpRequestMessage source,
+ CancellationToken cancellationToken)
+ {
+ var clone = new HttpRequestMessage(source.Method, source.RequestUri);
+ foreach (var header in source.Headers)
+ clone.Headers.TryAddWithoutValidation(header.Key, header.Value);
+ if (source.Content is not null)
+ {
+ var bytes = await source.Content.ReadAsByteArrayAsync(cancellationToken);
+ clone.Content = new ByteArrayContent(bytes);
+ foreach (var header in source.Content.Headers)
+ clone.Content.Headers.TryAddWithoutValidation(header.Key, header.Value);
+ }
+ return clone;
+ }
+
+ private static Schedule MapSchedule(ScheduleApiItem item)
+ {
+ var slot = (item.Slot ?? string.Empty).Split(
+ " - ",
+ StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
+ _ = DateTime.TryParseExact(
+ item.Date,
+ ["yyyy-MM-dd", "dd.MM.yyyy"],
+ CultureInfo.InvariantCulture,
+ DateTimeStyles.None,
+ out var date);
+
+ return new Schedule
+ {
+ Id = item.Id,
+ ExternalId = item.Id,
+ Date = date,
+ Name = item.Disciplines ?? string.Empty,
+ Teacher = item.Teacher ?? string.Empty,
+ Room = item.Room ?? string.Empty,
+ PairType = item.Type ?? string.Empty,
+ StartTime = slot.ElementAtOrDefault(0) ?? string.Empty,
+ EndTime = slot.ElementAtOrDefault(1) ?? string.Empty
+ };
+ }
+
+ private async Task> GetLegacyScheduleAsync(
+ string groupId = "",
+ string subgroupId = "",
+ string[]? teacher = null,
+ int roomId = 0,
+ CancellationToken cancellationToken = default)
+ {
+ var mondayDate = GetWeekStart(DateTime.Now);
+ var monday = mondayDate.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
+ var sunday = mondayDate.AddDays(13).ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
+ var request = CreateLegacyRequest(monday, sunday, groupId, subgroupId, teacher, roomId);
+ var schedule = await _legacyApi.PostAsync(
+ "/get_schedule",
+ request,
+ cancellationToken);
+ return schedule.Schedules;
+ }
+
+ private static DateTime GetWeekStart(DateTime date)
+ {
+ var difference = date.DayOfWeek == DayOfWeek.Sunday
+ ? -6
+ : DayOfWeek.Monday - date.DayOfWeek;
+ return date.Date.AddDays(difference);
+ }
+
+ private static Dictionary CreateLegacyRequest(
+ string monday,
+ string sunday,
+ string groupId,
+ string subgroupId,
+ string[]? teacher,
+ int roomId)
+ {
+ var request = new Dictionary
+ {
+ ["start_date"] = monday,
+ ["end_date"] = sunday
+ };
+ if (!string.IsNullOrEmpty(groupId))
+ request["group_id"] = groupId;
+ if (!string.IsNullOrEmpty(subgroupId))
+ request["subgroup_id"] = subgroupId;
+ if (roomId != 0)
+ request["room_id"] = roomId.ToString(CultureInfo.InvariantCulture);
+ if (teacher is { Length: > 0 })
+ {
+ request["teacher_first_name"] = teacher[0];
+ request["teacher_second_name"] = teacher.ElementAtOrDefault(1) ?? string.Empty;
+ if (teacher.Length > 2)
+ request["teacher_last_name"] = teacher[2];
+ }
+ return request;
+ }
+
+ public void Dispose() => GC.SuppressFinalize(this);
+
+ private sealed class ScheduleApiResponse
+ {
+ public bool? Success { get; set; }
+ public List Timetable { get; set; } = [];
+ }
+
+ private sealed class ScheduleApiItem
+ {
+ public int Id { get; set; }
+ public string? Date { get; set; }
+ public string? Slot { get; set; }
+ public string? Type { get; set; }
+ public string? Disciplines { get; set; }
+ public string? Room { get; set; }
+ public string? Teacher { get; set; }
+ }
+}
diff --git a/mauverse_mobile/Utils/Services/BrowserDestinationRegistry.cs b/mauverse_mobile/Utils/Services/BrowserDestinationRegistry.cs
index fa8eca0..24eae5f 100644
--- a/mauverse_mobile/Utils/Services/BrowserDestinationRegistry.cs
+++ b/mauverse_mobile/Utils/Services/BrowserDestinationRegistry.cs
@@ -14,10 +14,10 @@ public static class BrowserDestinationRegistry
[EiosKey] = new(
"ЭИОС",
new Uri("https://eios.mauniver.ru/moodle/", UriKind.Absolute)),
- [MauDigitalServicesKey] = new(
- "Цифровые сервисы МАУ",
- new Uri("https://www.mauniver.ru/services/student/", UriKind.Absolute))
- };
+ [MauDigitalServicesKey] = new(
+ "Цифровые сервисы МАУ",
+ new Uri("https://mauniver.ru/services/", UriKind.Absolute))
+ };
private static readonly HashSet DownloadExtensions = new(StringComparer.OrdinalIgnoreCase)
{
diff --git a/mauverse_mobile/Utils/UserGreeting.cs b/mauverse_mobile/Utils/UserGreeting.cs
new file mode 100644
index 0000000..70aaafb
--- /dev/null
+++ b/mauverse_mobile/Utils/UserGreeting.cs
@@ -0,0 +1,29 @@
+namespace mau.Utils;
+
+public static class UserGreeting
+{
+ public static string ResolveFirstName(string? firstName, string? fullName, string? username)
+ {
+ var login = username?.Trim() ?? string.Empty;
+ var directName = firstName?.Trim() ?? string.Empty;
+ if (directName.Length > 0 &&
+ !string.Equals(directName, login, StringComparison.OrdinalIgnoreCase))
+ {
+ return directName
+ .Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
+ .FirstOrDefault() ?? "Студент";
+ }
+
+ var normalizedFullName = fullName?.Trim() ?? string.Empty;
+ if (normalizedFullName.Length == 0 ||
+ string.Equals(normalizedFullName, login, StringComparison.OrdinalIgnoreCase))
+ {
+ return "Студент";
+ }
+
+ var parts = normalizedFullName.Split(
+ ' ',
+ StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+ return parts.Length >= 3 ? parts[1] : parts.FirstOrDefault() ?? "Студент";
+ }
+}
diff --git a/mauverse_mobile/ViewModel/MainViewModel.cs b/mauverse_mobile/ViewModel/MainViewModel.cs
index 9277448..693dc5f 100644
--- a/mauverse_mobile/ViewModel/MainViewModel.cs
+++ b/mauverse_mobile/ViewModel/MainViewModel.cs
@@ -145,7 +145,7 @@ void PrepareHeader()
{
var now = DateTime.Now;
TodayLabel = Capitalize(now.ToString("dddd, d MMMM", CultureInfo.GetCultureInfo("ru-RU")));
- GreetingsLabel = $"{GetGreeting(now.Hour)}, {GetFirstName()}";
+ GreetingsLabel = $"{GetGreeting(now.Hour)}, {GetFirstName()}";
IsProfileSetupRequired = CurrentUser?.Role == UserRole.Student &&
(string.IsNullOrWhiteSpace(CurrentUser.GroupName) || string.IsNullOrWhiteSpace(CurrentUser.GroupId));
ContextLabel = CurrentUser?.Role == UserRole.Student
@@ -256,15 +256,11 @@ static TimeSpan ParseTime(string value) =>
_ => "Доброй ночи"
};
- static string GetFirstName()
- {
- if (!string.IsNullOrWhiteSpace(CurrentUser?.FirstName))
- return CurrentUser.FirstName.Trim();
-
- return CurrentUser?.FullName
- ?.Split(' ', StringSplitOptions.RemoveEmptyEntries)
- .FirstOrDefault() ?? "студент";
- }
+ static string GetFirstName()
+ => UserGreeting.ResolveFirstName(
+ CurrentUser?.FirstName,
+ CurrentUser?.FullName,
+ CurrentUser?.Username);
static string Capitalize(string value) => string.IsNullOrEmpty(value)
? value
diff --git a/mauverse_mobile/ViewModel/News/NewsViewModel.cs b/mauverse_mobile/ViewModel/News/NewsViewModel.cs
index baa257f..b3c72ab 100644
--- a/mauverse_mobile/ViewModel/News/NewsViewModel.cs
+++ b/mauverse_mobile/ViewModel/News/NewsViewModel.cs
@@ -37,9 +37,11 @@ public partial class NewsViewModel : BaseViewModel
[ObservableProperty]
List _filterButtons = [];
- public bool IsLoading => CurrentState == States.Loading;
- public bool HasContent => CurrentState == States.Success;
- public bool IsEmpty => CurrentState == States.Empty;
+ public bool IsLoading => CurrentState == States.Loading;
+ public bool HasContent => CurrentState == States.Success;
+ public bool IsEmpty => CurrentState == States.Empty;
+ public bool IsRefreshing => _isRefreshing;
+ bool _isRefreshing;
public NewsViewModel(
DbConnect context,
@@ -58,22 +60,38 @@ async Task GetFullInfo(RssDTO news)
await GetNewsDescription(news);
}
- [RelayCommand]
- async Task FilterData()
- {
- await LoadNews(SelectedButton?.FilterType ?? RssData.Default);
- }
+ [RelayCommand]
+ async Task FilterData()
+ {
+ await LoadNews(SelectedButton?.FilterType ?? RssData.Default, forceRefresh: false);
+ }
[RelayCommand]
- async Task LoadData()
- {
- await LoadNews(RssData.Default);
- }
-
- async Task LoadNews(RssData filterType)
- {
- if (_loadedFilter == filterType)
- return;
+ async Task LoadData()
+ {
+ await LoadNews(SelectedButton?.FilterType ?? RssData.Default, forceRefresh: false);
+ }
+
+ [RelayCommand]
+ async Task Refresh()
+ {
+ _isRefreshing = true;
+ OnPropertyChanged(nameof(IsRefreshing));
+ try
+ {
+ await LoadNews(SelectedButton?.FilterType ?? RssData.Default, forceRefresh: true);
+ }
+ finally
+ {
+ _isRefreshing = false;
+ OnPropertyChanged(nameof(IsRefreshing));
+ }
+ }
+
+ async Task LoadNews(RssData filterType, bool forceRefresh)
+ {
+ if (!forceRefresh && _loadedFilter == filterType)
+ return;
if (_loadTask is not null && _loadingFilter == filterType)
{
@@ -85,7 +103,7 @@ async Task LoadNews(RssData filterType)
var previousSource = Interlocked.Exchange(ref _loadCts, cancellationSource);
previousSource?.Cancel();
_loadingFilter = filterType;
- var loadTask = LoadNewsCore(filterType, cancellationSource.Token);
+ var loadTask = LoadNewsCore(filterType, forceRefresh, cancellationSource.Token);
_loadTask = loadTask;
try
{
@@ -104,24 +122,36 @@ async Task LoadNews(RssData filterType)
}
}
- async Task LoadNewsCore(RssData filterType, CancellationToken cancellationToken)
- {
- SetState(States.Loading);
- try
- {
- News = [.. await _parserRequests.GetNewsAsync(filterType, cancellationToken)];
- cancellationToken.ThrowIfCancellationRequested();
- _loadedFilter = filterType;
- SetState(News.Count == 0 ? States.Empty : States.Success);
+ async Task LoadNewsCore(
+ RssData filterType,
+ bool forceRefresh,
+ CancellationToken cancellationToken)
+ {
+ var hadContent = News.Count > 0 && _loadedFilter == filterType;
+ if (!hadContent)
+ SetState(States.Loading);
+ try
+ {
+ var freshNews = await _parserRequests.GetNewsAsync(
+ filterType,
+ forceRefresh,
+ cancellationToken);
+ cancellationToken.ThrowIfCancellationRequested();
+ News = [.. freshNews];
+ _loadedFilter = filterType;
+ SetState(News.Count == 0 ? States.Empty : States.Success);
}
catch (OperationCanceledException)
{
}
catch (Exception ex)
{
- System.Diagnostics.Debug.WriteLine(ex);
- SetState(States.Empty);
- await AppShell.DisplaySnackbarAsync("Не удалось загрузить новости");
+ System.Diagnostics.Debug.WriteLine(ex);
+ SetState(hadContent ? States.Success : States.Empty);
+ await AppShell.DisplaySnackbarAsync(
+ hadContent
+ ? "Не удалось обновить новости — показаны сохранённые данные"
+ : "Не удалось загрузить новости");
}
}
@@ -177,6 +207,10 @@ static List LoadFilterData()
return buttons;
}
- protected override void CancelPendingOperations() => _loadCts?.Cancel();
+ protected override void CancelPendingOperations()
+ {
+ _loadCts?.Cancel();
+ RefreshCommand.Cancel();
+ }
}
}
diff --git a/mauverse_mobile/ViewModel/Services/OnlineFormsViewModel.cs b/mauverse_mobile/ViewModel/Services/OnlineFormsViewModel.cs
index 6ce1571..1b6e9d2 100644
--- a/mauverse_mobile/ViewModel/Services/OnlineFormsViewModel.cs
+++ b/mauverse_mobile/ViewModel/Services/OnlineFormsViewModel.cs
@@ -19,12 +19,11 @@ public OnlineFormsViewModel(IAppNavigationService navigation)
public IReadOnlyList CertificateForms { get; } =
[
- new(
- "Справка об обучении",
- "Обычная, гербовая или электронная",
- $"{FormsBaseUrl}/",
- FluentUI.certificate_24_regular,
- true),
+ new(
+ "Справка об обучении",
+ "Обычная, гербовая или электронная",
+ $"{FormsBaseUrl}/",
+ FluentUI.certificate_24_regular),
new(
"Справка для перевода",
"С перечнем дисциплин и оценок",
@@ -64,12 +63,57 @@ public OnlineFormsViewModel(IAppNavigationService navigation)
"Заявление на повторную выдачу",
$"{FormsBaseUrl}/diplom/",
FluentUI.document_copy_24_regular),
- new(
- "Счёт за обучение",
- "Для платных образовательных услуг",
- $"{FormsBaseUrl}/application/",
- FluentUI.receipt_money_24_regular)
- ];
+ new(
+ "Счёт за обучение",
+ "Для платных образовательных услуг",
+ $"{FormsBaseUrl}/application/",
+ FluentUI.receipt_money_24_regular),
+ new(
+ "Онлайн-сервисы ММРК",
+ "Справки и заявления колледжа",
+ "https://mauniver.ru/structure/branches/mmrc/online/",
+ FluentUI.building_24_regular),
+ new(
+ "Справочная студенческого офиса",
+ "Задать вопрос об учебном процессе",
+ "https://mauniver.ru/services/virtual/",
+ FluentUI.chat_help_24_regular),
+ new(
+ "Справочная библиотеки",
+ "Получить помощь библиотекаря",
+ "https://mauniver.ru/structure/divs/library/guide/",
+ FluentUI.book_question_mark_24_regular),
+ new(
+ "Виртуальная приёмная ректора",
+ "Направить официальное обращение",
+ "https://mauniver.ru/rector/reception/",
+ FluentUI.mail_24_regular),
+ new(
+ "Вопрос приёмной комиссии",
+ "Обращение по вопросам поступления",
+ "https://mauniver.ru/abit/reception/",
+ FluentUI.person_question_mark_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.hat_graduation_24_regular)
+ ];
[RelayCommand]
private async Task OpenFormAsync(StudentOnlineForm? form)
diff --git a/mauverse_mobile/ViewModel/SettingsViewModel.cs b/mauverse_mobile/ViewModel/SettingsViewModel.cs
index f7df930..0f8559e 100644
--- a/mauverse_mobile/ViewModel/SettingsViewModel.cs
+++ b/mauverse_mobile/ViewModel/SettingsViewModel.cs
@@ -45,7 +45,7 @@ public SettingsViewModel(
"Performance",
"CA1822:Mark members as static",
Justification = "The instance member is part of the compiled XAML binding contract.")]
- public string AppCreditsLabel => "Сделано УИТ с любовью к студентам";
+ public string AppCreditsLabel => "Разработчик: decoder-dev";
[System.Diagnostics.CodeAnalysis.SuppressMessage(
"Performance",
diff --git a/mauverse_mobile/mau.csproj b/mauverse_mobile/mau.csproj
index 9bbf27c..06d6fac 100644
--- a/mauverse_mobile/mau.csproj
+++ b/mauverse_mobile/mau.csproj
@@ -16,9 +16,10 @@
MAUverse
com.pmi4freal.mauverse3
- 1.8.5
- 20
- https://app.mauniver.ru/dev/mauverse/
+ 1.11.0
+ 28
+ https://app.mauniver.ru/dev/mauverse/
+
None
21.0
@@ -29,10 +30,14 @@
-
- <_Parameter1>MauverseApiBaseUrl
- <_Parameter2>$(MauverseApiBaseUrl)
-
+
+ <_Parameter1>MauverseApiBaseUrl
+ <_Parameter2>$(MauverseApiBaseUrl)
+
+
+ <_Parameter1>MauverseScheduleToken
+ <_Parameter2>$(MauverseScheduleToken)
+