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