From 4fbf91b15fc633ecddabfdf58dc1d44a8f8808b1 Mon Sep 17 00:00:00 2001 From: D3C0Y Date: Wed, 29 Jul 2026 02:59:20 +0300 Subject: [PATCH 01/14] Add native SwiftUI iPhone app --- .github/workflows/ci.yml | 100 ++-- .../AppIcon.appiconset/AppIcon.png | Bin 0 -> 85571 bytes .../AppIcon.appiconset/Contents.json | 15 + .../Resources/Assets.xcassets/Contents.json | 7 + MauverseIOS/Resources/Info.plist | 32 ++ MauverseIOS/Resources/PrivacyInfo.xcprivacy | 13 + MauverseIOS/Sources/Core/APIClient.swift | 97 ++++ MauverseIOS/Sources/Core/Models.swift | 147 ++++++ MauverseIOS/Sources/Core/SessionStore.swift | 57 +++ MauverseIOS/Sources/Design/DesignSystem.swift | 116 +++++ MauverseIOS/Sources/MAUverseApp.swift | 21 + MauverseIOS/Sources/Views/HomeView.swift | 138 ++++++ MauverseIOS/Sources/Views/LoginView.swift | 96 ++++ MauverseIOS/Sources/Views/NewsView.swift | 137 ++++++ MauverseIOS/Sources/Views/ProfileView.swift | 185 +++++++ MauverseIOS/Sources/Views/RootTabView.swift | 20 + MauverseIOS/Sources/Views/ScheduleView.swift | 190 +++++++ MauverseIOS/Sources/Views/ServicesView.swift | 463 ++++++++++++++++++ MauverseIOS/project.yml | 36 ++ README.md | 19 + 20 files changed, 1829 insertions(+), 60 deletions(-) create mode 100644 MauverseIOS/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon.png create mode 100644 MauverseIOS/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 MauverseIOS/Resources/Assets.xcassets/Contents.json create mode 100644 MauverseIOS/Resources/Info.plist create mode 100644 MauverseIOS/Resources/PrivacyInfo.xcprivacy create mode 100644 MauverseIOS/Sources/Core/APIClient.swift create mode 100644 MauverseIOS/Sources/Core/Models.swift create mode 100644 MauverseIOS/Sources/Core/SessionStore.swift create mode 100644 MauverseIOS/Sources/Design/DesignSystem.swift create mode 100644 MauverseIOS/Sources/MAUverseApp.swift create mode 100644 MauverseIOS/Sources/Views/HomeView.swift create mode 100644 MauverseIOS/Sources/Views/LoginView.swift create mode 100644 MauverseIOS/Sources/Views/NewsView.swift create mode 100644 MauverseIOS/Sources/Views/ProfileView.swift create mode 100644 MauverseIOS/Sources/Views/RootTabView.swift create mode 100644 MauverseIOS/Sources/Views/ScheduleView.swift create mode 100644 MauverseIOS/Sources/Views/ServicesView.swift create mode 100644 MauverseIOS/project.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a5d112b..5fed531 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -177,23 +177,13 @@ 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 - env: - NUGET_PACKAGES: ${{ github.workspace }}/.nuget/packages - defaults: - run: - working-directory: mauverse_mobile + timeout-minutes: 35 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 +192,46 @@ 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 + 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="" \ + 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 +247,39 @@ jobs: exit 1 fi - artifact_dir="bin/Release/net10.0-ios/unsigned-artifact" + artifact_dir="build/unsigned-artifact" payload_dir="$artifact_dir/Payload" mkdir -p "$payload_dir" ditto "$app_path" "$payload_dir/$(basename "$app_path")" ( cd "$artifact_dir" - /usr/bin/zip -qry "../mauverce-ios-arm64-unsigned.ipa" Payload + /usr/bin/zip -qry "../mauverse-native-ios-arm64-unsigned.ipa" Payload ) { echo "commit=${GITHUB_SHA}" - echo "target=net10.0-ios" + echo "target=native-swiftui" echo "runtime=ios-arm64" echo "signed=false" echo "provisioning_profile=false" echo "xcode=$(xcodebuild -version | tr '\n' ' ')" echo "iphoneos_sdk=$(xcrun --sdk iphoneos --show-sdk-version)" - } > "bin/Release/net10.0-ios/build-metadata.txt" + } > "build/build-metadata.txt" ( - cd bin/Release/net10.0-ios - shasum -a 256 mauverce-ios-arm64-unsigned.ipa > checksums.txt + cd build + shasum -a 256 mauverse-native-ios-arm64-unsigned.ipa > checksums.txt ) - name: Upload unsigned iPhone artifact uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - name: mauverce-ios-arm64-release-unsigned + name: mauverse-native-ios-arm64-release-unsigned path: | - mauverse_mobile/bin/Release/net10.0-ios/mauverce-ios-arm64-unsigned.ipa - mauverse_mobile/bin/Release/net10.0-ios/checksums.txt - mauverse_mobile/bin/Release/net10.0-ios/build-metadata.txt + build/mauverse-native-ios-arm64-unsigned.ipa + build/checksums.txt + build/build-metadata.txt if-no-files-found: error retention-days: 14 compression-level: 0 diff --git a/MauverseIOS/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon.png b/MauverseIOS/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon.png new file mode 100644 index 0000000000000000000000000000000000000000..f5e3fe5ec04cec31947180ffdacdee94e5186b30 GIT binary patch literal 85571 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 z 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 literal 0 HcmV?d00001 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..06cdd33 --- /dev/null +++ b/MauverseIOS/Resources/Info.plist @@ -0,0 +1,32 @@ + + + + + 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) + 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..a8270ae --- /dev/null +++ b/MauverseIOS/Sources/Core/APIClient.swift @@ -0,0 +1,97 @@ +import Foundation + +enum APIError: LocalizedError { + case invalidResponse + case server(String) + + var errorDescription: String? { + switch self { + case .invalidResponse: "Сервер вернул некорректный ответ" + case .server(let message): message + } + } +} + +struct EmptyBody: Encodable {} + +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) + } + + func post( + _ path: String, + body: Body, + user: UserDTO? = nil + ) async throws -> Response { + var request = URLRequest(url: baseURL.appendingPathComponent(path)) + request.httpMethod = "POST" + request.httpBody = try JSONEncoder().encode(body) + addHeaders(to: &request, user: user) + return try await execute(request) + } + + 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) async throws -> Response { + let (data, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse else { throw APIError.invalidResponse } + guard (200..<300).contains(http.statusCode) else { + let detail = (try? decoder.decode(APIMessage.self, from: data)) + throw APIError.server(detail?.detail ?? detail?.error ?? "Ошибка сервера: \(http.statusCode)") + } + do { + return try decoder.decode(Response.self, from: data) + } catch { + throw APIError.server("Не удалось прочитать ответ сервера") + } + } + + private var decoder: JSONDecoder { + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + return decoder + } +} + +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 } +struct ScheduleRequest: Encodable { + let startDate: String + let endDate: String + let groupId: Int? + let subgroupId: Int? +} + diff --git a/MauverseIOS/Sources/Core/Models.swift b/MauverseIOS/Sources/Core/Models.swift new file mode 100644 index 0000000..f8a6431 --- /dev/null +++ b/MauverseIOS/Sources/Core/Models.swift @@ -0,0 +1,147 @@ +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: Int? + var subGroupId: Int? + 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 ?? username ?? "Студент" + return value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "Студент" : value + } +} + +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 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: Codable, Identifiable, Hashable { + var name: String? + var id: Int? +} + +struct Telephone: Codable, 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 ?? "")" } +} + +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 ?? "")" } +} + +struct Semester: Codable, Identifiable, Hashable { + var semesterNumber: Int? + var semester: String? + var semesterSubtitle: String? + var debts: [Debt]? + var id: String { "\(semesterNumber ?? 0)-\(semester ?? "")" } +} + +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 { + switch self { + case .all: "Все" + case .departments: "Подразделения" + case .sport: "Спорт" + case .students: "Студентам" + case .science: "Наука" + case .international: "Международное" + case .events: "События" + case .other: "Другое" + } + } +} + diff --git a/MauverseIOS/Sources/Core/SessionStore.swift b/MauverseIOS/Sources/Core/SessionStore.swift new file mode 100644 index 0000000..e447087 --- /dev/null +++ b/MauverseIOS/Sources/Core/SessionStore.swift @@ -0,0 +1,57 @@ +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" + + init() { + if let data = UserDefaults.standard.data(forKey: storageKey) { + user = try? JSONDecoder().decode(UserDTO.self, from: data) + } + } + + func signIn(username: String, password: String) async { + guard !username.isEmpty, !password.isEmpty else { + errorMessage = "Введите логин и пароль" + return + } + isBusy = true + errorMessage = nil + defer { isBusy = false } + do { + let authenticated: UserDTO = try await APIClient.shared.post( + "auth", + body: LoginRequest(username: username, password: password) + ) + if let error = authenticated.error ?? authenticated.detail { + throw APIError.server(error) + } + user = authenticated + persist() + } 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 + UserDefaults.standard.removeObject(forKey: storageKey) + } + + private func persist() { + guard let user, let data = try? JSONEncoder().encode(user) else { return } + UserDefaults.standard.set(data, forKey: storageKey) + } +} diff --git a/MauverseIOS/Sources/Design/DesignSystem.swift b/MauverseIOS/Sources/Design/DesignSystem.swift new file mode 100644 index 0000000..0e42fc1 --- /dev/null +++ b/MauverseIOS/Sources/Design/DesignSystem.swift @@ -0,0 +1,116 @@ +import SwiftUI + +enum MauTheme { + static let blue = Color(red: 0.16, green: 0.43, blue: 0.95) + static let ink = Color(red: 0.08, green: 0.10, blue: 0.16) + static let muted = Color(red: 0.42, green: 0.46, blue: 0.54) + static let canvas = Color(red: 0.965, green: 0.97, blue: 0.985) + static let lavender = Color(red: 0.88, green: 0.90, blue: 1.0) +} + +struct MauBackground: View { + var body: some View { + ZStack { + MauTheme.canvas + Circle() + .fill(Color.blue.opacity(0.12)) + .frame(width: 360, height: 360) + .blur(radius: 40) + .offset(x: 170, y: -330) + Circle() + .fill(Color.purple.opacity(0.10)) + .frame(width: 330, height: 330) + .blur(radius: 55) + .offset(x: -180, y: 360) + } + .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) + } +} + +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 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..33e859e --- /dev/null +++ b/MauverseIOS/Sources/MAUverseApp.swift @@ -0,0 +1,21 @@ +import SwiftUI + +@main +struct MAUverseApp: App { + @StateObject private var session = SessionStore() + + var body: some Scene { + WindowGroup { + Group { + if session.user == nil { + LoginView() + } else { + RootTabView() + } + } + .environmentObject(session) + .preferredColorScheme(.light) + } + } +} + diff --git a/MauverseIOS/Sources/Views/HomeView.swift b/MauverseIOS/Sources/Views/HomeView.swift new file mode 100644 index 0000000..7fe36ac --- /dev/null +++ b/MauverseIOS/Sources/Views/HomeView.swift @@ -0,0 +1,138 @@ +import SwiftUI + +struct HomeView: View { + @EnvironmentObject private var session: SessionStore + + private var firstName: String { + session.user?.firstName?.split(separator: " ").first.map(String.init) + ?? session.user?.displayName.split(separator: " ").first.map(String.init) + ?? "Студент" + } + + var body: some View { + ZStack { + MauBackground() + ScrollView { + VStack(alignment: .leading, spacing: 22) { + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 5) { + Text(greeting) + .font(.subheadline) + .foregroundStyle(MauTheme.muted) + Text(firstName) + .font(.system(size: 36, weight: .bold, design: .rounded)) + } + Spacer() + NavigationLink(destination: ProfileView()) { + Image(systemName: "person.crop.circle.fill") + .font(.system(size: 40)) + .symbolRenderingMode(.hierarchical) + .foregroundStyle(MauTheme.blue) + } + } + + HStack(spacing: 12) { + Image(systemName: "checkmark.icloud.fill") + .foregroundStyle(.green) + VStack(alignment: .leading, spacing: 3) { + Text("Данные сохранены").font(.subheadline.weight(.semibold)) + Text("Профиль доступен после перезапуска") + .font(.caption) + .foregroundStyle(MauTheme.muted) + } + } + .padding(17) + .frame(maxWidth: .infinity, alignment: .leading) + .mauGlass(radius: 20) + + if session.user?.groupName?.isEmpty != false { + NavigationLink(destination: ProfileView()) { + HStack(spacing: 14) { + IconTile(systemName: "exclamationmark.triangle.fill", color: .orange) + VStack(alignment: .leading, spacing: 4) { + Text("Укажите учебную группу").font(.headline) + Text("Она нужна для загрузки расписания") + .font(.caption) + .foregroundStyle(MauTheme.muted) + } + Spacer() + Image(systemName: "chevron.right") + } + .padding(18) + } + .buttonStyle(.plain) + .mauGlass() + } + + Text("Быстрый доступ") + .font(.title3.bold()) + + HStack(spacing: 12) { + QuickCard(title: "Расписание", icon: "calendar", color: MauTheme.blue) { + ScheduleView() + } + QuickCard(title: "Сервисы", icon: "square.grid.2x2", color: .purple) { + ServicesView() + } + QuickCard(title: "Новости", icon: "newspaper", color: .orange) { + NewsView() + } + } + + NavigationLink(destination: NewsView()) { + HStack(spacing: 16) { + VStack(alignment: .leading, spacing: 7) { + Text("Будьте в курсе").font(.title3.bold()) + Text("Свежие новости и события МАУ") + .font(.subheadline) + .foregroundStyle(MauTheme.muted) + } + Spacer() + Image(systemName: "arrow.up.right") + .font(.title3.bold()) + .foregroundStyle(MauTheme.blue) + } + .padding(22) + } + .buttonStyle(.plain) + .mauGlass() + } + .padding(20) + .padding(.bottom, 12) + } + } + .toolbar(.hidden, for: .navigationBar) + } + + private var greeting: String { + let hour = Calendar.current.component(.hour, from: Date()) + switch hour { + case 5..<12: "Доброе утро," + case 12..<18: "Добрый день," + default: "Добрый вечер," + } + } +} + +private struct QuickCard: View { + let title: String + let icon: String + let color: Color + @ViewBuilder let destination: () -> Destination + + var body: some View { + NavigationLink(destination: destination()) { + VStack(alignment: .leading, spacing: 15) { + IconTile(systemName: icon, color: color) + Text(title) + .font(.caption.weight(.semibold)) + .foregroundStyle(MauTheme.ink) + .lineLimit(1) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(14) + } + .buttonStyle(.plain) + .mauGlass(radius: 20) + } +} diff --git a/MauverseIOS/Sources/Views/LoginView.swift b/MauverseIOS/Sources/Views/LoginView.swift new file mode 100644 index 0000000..b98fb4b --- /dev/null +++ b/MauverseIOS/Sources/Views/LoginView.swift @@ -0,0 +1,96 @@ +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: 70) + VStack(spacing: 14) { + ZStack { + RoundedRectangle(cornerRadius: 28, style: .continuous) + .fill(MauTheme.blue.gradient) + Image(systemName: "sparkles") + .font(.system(size: 44, weight: .medium)) + .foregroundStyle(.white) + } + .frame(width: 92, height: 92) + .shadow(color: MauTheme.blue.opacity(0.28), radius: 28, y: 14) + + Text("MAUverse") + .font(.system(size: 38, 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(Color.white.opacity(0.68), in: RoundedRectangle(cornerRadius: 18)) + + SecureField("Пароль", text: $password) + .textContentType(.password) + .focused($focused, equals: .password) + .submitLabel(.go) + .onSubmit { signIn() } + .padding(18) + .background(Color.white.opacity(0.68), 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.blue.gradient, in: RoundedRectangle(cornerRadius: 18)) + .disabled(session.isBusy) + } + .padding(20) + .mauGlass(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..b146c6a --- /dev/null +++ b/MauverseIOS/Sources/Views/NewsView.swift @@ -0,0 +1,137 @@ +import SwiftUI +import SafariServices + +@MainActor +final class NewsViewModel: ObservableObject { + @Published var items: [NewsItem] = [] + @Published var filter: NewsFilter = .all + @Published var isLoading = false + @Published var error: String? + + func load() async { + isLoading = true + error = nil + defer { isLoading = false } + do { + items = try await APIClient.shared.get( + "news", + query: [URLQueryItem(name: "news_type", value: "\(filter.rawValue)")] + ) + } catch { + self.error = error.localizedDescription + } + } +} + +struct NewsView: View { + @StateObject private var model = NewsViewModel() + + var body: some View { + ZStack { + MauBackground() + ScrollView { + VStack(alignment: .leading, spacing: 18) { + Text("Новости").font(.system(size: 34, weight: .bold, design: .rounded)) + + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 9) { + ForEach(NewsFilter.allCases) { filter in + Button(filter.title) { + model.filter = filter + Task { await model.load() } + } + .font(.subheadline.weight(.semibold)) + .foregroundStyle(model.filter == filter ? .white : MauTheme.ink) + .padding(.horizontal, 15) + .padding(.vertical, 10) + .background(model.filter == filter ? AnyShapeStyle(MauTheme.blue.gradient) : AnyShapeStyle(Color.white.opacity(0.7)), + in: Capsule()) + .buttonStyle(.plain) + } + } + } + + if model.isLoading { + LoadingOverlay(title: "Загружаем новости").frame(maxWidth: .infinity) + } else if let error = model.error { + EmptyState(icon: "wifi.exclamationmark", title: "Нет соединения", message: error) + } else if model.items.isEmpty { + EmptyState(icon: "newspaper", title: "Новостей пока нет", message: "Попробуйте выбрать другую категорию") + } else { + LazyVStack(spacing: 16) { + ForEach(model.items) { NewsCard(item: $0) } + } + } + } + .padding(20) + .padding(.bottom, 12) + } + .refreshable { await model.load() } + } + .navigationBarTitleDisplayMode(.inline) + .task { if model.items.isEmpty { await model.load() } } + } +} + +private struct NewsCard: View { + let item: NewsItem + + var body: some View { + Button { + guard let value = item.link, let url = URL(string: value) else { return } + UIApplication.shared.open(url) + } label: { + 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 { + LinearGradient(colors: [MauTheme.lavender, .white], startPoint: .topLeading, endPoint: .bottomTrailing) + .overlay(Image(systemName: "photo").font(.largeTitle).foregroundStyle(MauTheme.blue)) + } + } + .frame(height: 180) + .clipped() + } + VStack(alignment: .leading, spacing: 9) { + if let date = item.publish { + Text(date).font(.caption).foregroundStyle(MauTheme.blue) + } + Text(item.title ?? "Новость") + .font(.headline) + .multilineTextAlignment(.leading) + .foregroundStyle(MauTheme.ink) + if let description = item.description { + Text(description.strippingHTML) + .font(.subheadline) + .foregroundStyle(MauTheme.muted) + .lineLimit(3) + .multilineTextAlignment(.leading) + } + } + .padding(18) + } + .background(Color.white.opacity(0.68)) + .clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous)) + } + .buttonStyle(.plain) + .mauGlass(radius: 24) + } +} + +private extension String { + var strippingHTML: String { + replacingOccurrences(of: "<[^>]+>", with: "", options: .regularExpression) + .replacingOccurrences(of: " ", with: " ") + .replacingOccurrences(of: "&", with: "&") + } +} + +struct SafariView: UIViewControllerRepresentable { + let url: URL + func makeUIViewController(context: Context) -> SFSafariViewController { + SFSafariViewController(url: url) + } + func updateUIViewController(_ uiViewController: SFSafariViewController, context: Context) {} +} diff --git a/MauverseIOS/Sources/Views/ProfileView.swift b/MauverseIOS/Sources/Views/ProfileView.swift new file mode 100644 index 0000000..a9fb187 --- /dev/null +++ b/MauverseIOS/Sources/Views/ProfileView.swift @@ -0,0 +1,185 @@ +import SwiftUI + +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: 12) { + Image(systemName: "person.crop.circle.fill") + .font(.system(size: 86)) + .symbolRenderingMode(.hierarchical) + .foregroundStyle(MauTheme.blue) + Text(session.user?.displayName ?? "Пользователь") + .font(.title2.bold()) + .multilineTextAlignment(.center) + Text(session.user?.username ?? "") + .font(.subheadline) + .foregroundStyle(MauTheme.muted) + } + + 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) + .mauGlass() + + Button { showingEditor = true } label: { + Label("Изменить учебные данные", systemImage: "pencil") + .fontWeight(.semibold) + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + } + .buttonStyle(.plain) + .foregroundStyle(MauTheme.blue) + .mauGlass(radius: 19) + + NavigationLink(destination: SettingsView()) { + HStack { + Label("Настройки", systemImage: "gearshape.fill") + Spacer() + Image(systemName: "chevron.right") + } + .padding(17) + } + .buttonStyle(.plain) + .foregroundStyle(MauTheme.ink) + .mauGlass(radius: 19) + + Button(role: .destructive) { showingLogout = true } label: { + Label("Выйти", systemImage: "rectangle.portrait.and.arrow.right") + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + } + .buttonStyle(.plain) + .mauGlass(radius: 19) + + Text("MAUverse 1.8.5 (20)") + .font(.caption) + .foregroundStyle(MauTheme.muted) + } + .padding(20) + .padding(.bottom, 12) + } + } + .navigationTitle("Профиль") + .navigationBarTitleDisplayMode(.inline) + .sheet(isPresented: $showingEditor) { ProfileEditor() } + .confirmationDialog("Выйти из аккаунта?", isPresented: $showingLogout, titleVisibility: .visible) { + Button("Выйти", role: .destructive) { session.signOut() } + } + } +} + +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 = "" + + var body: some View { + NavigationStack { + Form { + Section("Расписание") { + TextField("Учебная группа", text: $group) + .textInputAutocapitalization(.characters) + } + 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("Сохранить") { + session.update { + $0.groupName = group.trimmingCharacters(in: .whitespacesAndNewlines) + $0.creditBook = creditBook.trimmingCharacters(in: .whitespacesAndNewlines) + } + dismiss() + } + } + } + .onAppear { + group = session.user?.groupName ?? "" + creditBook = session.user?.creditBook ?? "" + } + } + } +} + +private struct SettingsView: View { + @State private var cleared = false + + var body: some View { + ZStack { + MauBackground() + ScrollView { + VStack(spacing: 15) { + VStack(alignment: .leading, spacing: 13) { + Label("Кэш приложения", systemImage: "internaldrive.fill").font(.headline) + Text("Изображения новостей и веб-страниц очищаются системой автоматически.") + .font(.subheadline).foregroundStyle(MauTheme.muted) + Button(cleared ? "Готово" : "Очистить временные данные") { + URLCache.shared.removeAllCachedResponses() + cleared = true + } + .buttonStyle(.borderedProminent) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(20) + .mauGlass() + + VStack(alignment: .leading, spacing: 12) { + Label("О приложении", systemImage: "info.circle.fill").font(.headline) + Text("Нативное приложение Мурманского арктического университета.") + .font(.subheadline).foregroundStyle(MauTheme.muted) + Text("Версия 1.8.5 • сборка 20").font(.caption) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(20) + .mauGlass() + } + .padding(20) + } + } + .navigationTitle("Настройки") + } +} 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..0cc507c --- /dev/null +++ b/MauverseIOS/Sources/Views/ScheduleView.swift @@ -0,0 +1,190 @@ +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? + + let dates: [Date] = (0..<14).compactMap { + Calendar.current.date(byAdding: .day, value: $0, to: Calendar.current.startOfDay(for: Date())) + } + + func load(user: UserDTO?) async { + guard let user else { return } + isLoading = true + error = nil + defer { isLoading = false } + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy-MM-dd" + do { + let response: [ScheduleItem] = try await APIClient.shared.post( + "get_schedule", + body: ScheduleRequest( + startDate: formatter.string(from: dates.first ?? Date()), + endDate: formatter.string(from: dates.last ?? Date()), + groupId: user.groupId, + subgroupId: user.subGroupId + ), + user: user + ) + items = response + } catch { + self.error = error.localizedDescription + } + } + + 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: 34, weight: .bold, design: .rounded)) + Text("Занятия на ближайшие две недели") + .font(.subheadline) + .foregroundStyle(MauTheme.muted) + } + Spacer() + Button { Task { await model.load(user: session.user) } } label: { + Image(systemName: "arrow.clockwise") + .font(.headline) + .frame(width: 44, height: 44) + } + .buttonStyle(.plain) + .mauGlass(radius: 16) + } + + 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) + .mauGlass(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 { + LoadingOverlay(title: "Загружаем расписание") + .frame(maxWidth: .infinity) + } else if let error = model.error { + EmptyState(icon: "wifi.exclamationmark", title: "Не удалось загрузить", message: error) + } 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, 12) + } + .refreshable { await model.load(user: session.user) } + } + .navigationBarTitleDisplayMode(.inline) + .task { if model.items.isEmpty { await model.load(user: session.user) } } + } +} + +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.blue.gradient) : AnyShapeStyle(Color.white.opacity(0.72)), + in: RoundedRectangle(cornerRadius: 19, style: .continuous)) + } +} + +private struct LessonCard: View { + let item: ScheduleItem + + 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) + + RoundedRectangle(cornerRadius: 2) + .fill(MauTheme.blue) + .frame(width: 4, height: 72) + + VStack(alignment: .leading, spacing: 6) { + Text(item.name ?? "Занятие") + .font(.headline) + 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) + .mauGlass(radius: 22) + } +} + diff --git a/MauverseIOS/Sources/Views/ServicesView.swift b/MauverseIOS/Sources/Views/ServicesView.swift new file mode 100644 index 0000000..b7b283b --- /dev/null +++ b/MauverseIOS/Sources/Views/ServicesView.swift @@ -0,0 +1,463 @@ +import SwiftUI + +enum MauService: String, CaseIterable, Identifiable { + case eios, forms, messenger, campus, debts, digital, teachers, departments + var id: String { rawValue } + + var title: String { + switch self { + case .eios: "ЭИОС" + case .forms: "Онлайн-формы" + case .messenger: "Мессенджер ЭИОС" + case .campus: "Навигатор по корпусам" + case .debts: "Учебные задолженности" + case .digital: "Цифровые сервисы МАУ" + case .teachers: "Контакты преподавателей" + case .departments: "Подразделения и телефоны" + } + } + + var icon: String { + 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 { + switch self { + case .eios, .teachers: MauTheme.blue + case .forms, .departments: .purple + case .messenger, .digital: .teal + case .campus: .orange + case .debts: .red + } + } +} + +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) { + Text("Сервисы").font(.system(size: 34, weight: .bold, design: .rounded)) + Text("Полезные инструменты для учёбы и жизни в университете") + .font(.subheadline) + .foregroundStyle(MauTheme.muted) + + LazyVGrid(columns: columns, spacing: 13) { + ForEach(MauService.allCases) { service in + NavigationLink(destination: ServiceDestination(service: service)) { + VStack(alignment: .leading, spacing: 18) { + IconTile(systemName: service.icon, color: service.color) + Text(service.title) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(MauTheme.ink) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, minHeight: 38, alignment: .topLeading) + } + .padding(17) + .frame(maxWidth: .infinity, minHeight: 132, alignment: .topLeading) + } + .buttonStyle(.plain) + .mauGlass(radius: 23) + } + } + } + .padding(20) + .padding(.bottom, 12) + } + } + .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://www.mauniver.ru/services/student/") + 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) { + SafariView(url: destination) + .ignoresSafeArea() + .navigationTitle(title) + .navigationBarTitleDisplayMode(.inline) + } else { + EmptyState(icon: "link.badge.plus", title: "Ссылка недоступна", message: url) + } + } +} + +private struct FormsView: View { + private let forms = [ + ("Справка об обучении", "doc.badge.plus"), + ("Выписка об успеваемости", "list.clipboard.fill"), + ("Обращение в деканат", "envelope.fill") + ] + + var body: some View { + ZStack { + MauBackground() + ScrollView { + VStack(spacing: 13) { + ForEach(forms, id: \.0) { form in + HStack(spacing: 14) { + IconTile(systemName: form.1) + Text(form.0).font(.headline) + Spacer() + Image(systemName: "chevron.right").foregroundStyle(MauTheme.muted) + } + .padding(17) + .mauGlass(radius: 22) + } + Text("Отправка формы откроется после выбора типа документа в ЭИОС.") + .font(.footnote) + .foregroundStyle(MauTheme.muted) + .padding() + } + .padding(20) + } + } + .navigationTitle("Онлайн-формы") + .navigationBarTitleDisplayMode(.large) + } +} + +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("Корпуса") + } +} + +private struct CampusSection: View { + let title: String + let campuses: [Campus] + var body: some View { + Section(title) { + ForEach(campuses) { campus in + Button { + let query = "Мурманск, \(campus.address)" + let encoded = query.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? query + if let url = URL(string: "https://2gis.ru/murmansk/search/\(encoded)") { + UIApplication.shared.open(url) + } + } label: { + 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) + } + } + } + } + } +} + +@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, !creditBook.isEmpty else { + error = "Укажите номер зачётной книжки в профиле" + return + } + isLoading = true + error = nil + defer { isLoading = false } + do { + semesters = try await APIClient.shared.post( + "get_semesters", + body: DebtRequest(creditBook: creditBook), + user: user + ) + } 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 { + EmptyState(icon: "exclamationmark.circle", title: "Нет данных", message: error) + } 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) + } + } + .navigationTitle("Задолженности") + .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? + + func search(_ text: String, user: UserDTO?) async { + guard !text.trimmingCharacters(in: .whitespaces).isEmpty else { return } + isLoading = true + error = nil + defer { isLoading = false } + do { + teachers = try await APIClient.shared.post("get_teachers", body: TeacherRequest(name: text), user: user) + } catch { 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) + Button { Task { await model.search(query, user: session.user) } } label: { + Image(systemName: "magnifyingglass").font(.headline) + } + } + .padding(16) + .mauGlass(radius: 20) + + if model.isLoading { LoadingOverlay(title: "Ищем") } + else if let error = model.error { EmptyState(icon: "wifi.exclamationmark", title: "Ошибка", message: error) } + 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 + ) + } + } + } + } + } + .padding(20) + } + .navigationTitle("Преподаватели") + } +} + +@MainActor +private final class DepartmentsModel: ObservableObject { + @Published var departments: [Department] = [] + @Published var contacts: [Telephone] = [] + @Published var error: String? + + func load(user: UserDTO?) async { + do { departments = try await APIClient.shared.get("get_depts_json", user: user) } + catch { self.error = error.localizedDescription } + } + + func contacts(for department: Department, user: UserDTO?) async { + do { + contacts = try await APIClient.shared.post( + "get_contacts_json", + body: DepartmentRequest(departmentId: department.id ?? 0, name: department.name ?? ""), + user: user + ) + } catch { 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() + 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) + } + } + } + .scrollContentBackground(.hidden) + } + .navigationTitle("Подразделения") + .task { await model.load(user: session.user) } + .sheet(item: $selected) { department in + NavigationStack { + ScrollView { + LazyVStack(spacing: 12) { + if let error = model.error { + EmptyState(icon: "wifi.exclamationmark", title: "Ошибка", message: error) + } + ForEach(model.contacts) { contact in + ContactCard( + title: contact.person ?? contact.title ?? "Контакт", + subtitle: [contact.building2, contact.room].compactMap { $0 }.joined(separator: ", "), + phone: contact.phone, + 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? + 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, !phone.isEmpty { + Link(destination: URL(string: "tel:\(phone.filter { $0.isNumber || $0 == "+" })")!) { + Label(phone, systemImage: "phone.fill") + } + } + if let email, let url = URL(string: "mailto:\(email)") { + Link(destination: url) { Image(systemName: "envelope.fill") } + } + } + .font(.subheadline) + .foregroundStyle(MauTheme.blue) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(17) + .mauGlass(radius: 22) + } +} + diff --git a/MauverseIOS/project.yml b/MauverseIOS/project.yml new file mode 100644 index 0000000..9b1a569 --- /dev/null +++ b/MauverseIOS/project.yml @@ -0,0 +1,36 @@ +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.8.5 + CURRENT_PROJECT_VERSION: 20 + TARGETED_DEVICE_FAMILY: "1" + SUPPORTS_MACCATALYST: NO + scheme: + gatherCoverageData: false diff --git a/README.md b/README.md index a85ca7b..b915256 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,24 @@ # MAUverce +## Native iPhone app + +The native SwiftUI implementation lives in `MauverseIOS`. It targets iPhone, +uses the system Liquid Glass appearance on iOS 26, and falls back to native +materials on iOS 18–25. + +Generate the Xcode project and build it locally: + +```bash +brew install xcodegen +cd MauverseIOS +xcodegen generate +open MAUverse.xcodeproj +``` + +The `ios-release-unsigned` GitHub Actions job builds with Xcode 26.5 and +explicitly disables code signing. Its artifact contains an unsigned IPA that +can be signed with your own Apple certificate and provisioning profile. + MAUverce is a student mobile application for Murmansk Arctic University. The repository contains a .NET 9 MAUI client and a FastAPI gateway. From 466cd374fde01d1485975651702496c8ab924b94 Mon Sep 17 00:00:00 2001 From: D3C0Y Date: Wed, 29 Jul 2026 03:01:29 +0300 Subject: [PATCH 02/14] Fix Swift computed property returns --- MauverseIOS/Sources/Core/APIClient.swift | 3 +-- MauverseIOS/Sources/Core/Models.swift | 3 +-- MauverseIOS/Sources/Views/HomeView.swift | 2 +- MauverseIOS/Sources/Views/ServicesView.swift | 7 +++---- 4 files changed, 6 insertions(+), 9 deletions(-) diff --git a/MauverseIOS/Sources/Core/APIClient.swift b/MauverseIOS/Sources/Core/APIClient.swift index a8270ae..0f3e8c1 100644 --- a/MauverseIOS/Sources/Core/APIClient.swift +++ b/MauverseIOS/Sources/Core/APIClient.swift @@ -5,7 +5,7 @@ enum APIError: LocalizedError { case server(String) var errorDescription: String? { - switch self { + return switch self { case .invalidResponse: "Сервер вернул некорректный ответ" case .server(let message): message } @@ -94,4 +94,3 @@ struct ScheduleRequest: Encodable { let groupId: Int? let subgroupId: Int? } - diff --git a/MauverseIOS/Sources/Core/Models.swift b/MauverseIOS/Sources/Core/Models.swift index f8a6431..647ad8e 100644 --- a/MauverseIOS/Sources/Core/Models.swift +++ b/MauverseIOS/Sources/Core/Models.swift @@ -132,7 +132,7 @@ enum NewsFilter: Int, CaseIterable, Identifiable { case all = 0, departments, sport, students, science, international, events, other var id: Int { rawValue } var title: String { - switch self { + return switch self { case .all: "Все" case .departments: "Подразделения" case .sport: "Спорт" @@ -144,4 +144,3 @@ enum NewsFilter: Int, CaseIterable, Identifiable { } } } - diff --git a/MauverseIOS/Sources/Views/HomeView.swift b/MauverseIOS/Sources/Views/HomeView.swift index 7fe36ac..2f7204c 100644 --- a/MauverseIOS/Sources/Views/HomeView.swift +++ b/MauverseIOS/Sources/Views/HomeView.swift @@ -106,7 +106,7 @@ struct HomeView: View { private var greeting: String { let hour = Calendar.current.component(.hour, from: Date()) - switch hour { + return switch hour { case 5..<12: "Доброе утро," case 12..<18: "Добрый день," default: "Добрый вечер," diff --git a/MauverseIOS/Sources/Views/ServicesView.swift b/MauverseIOS/Sources/Views/ServicesView.swift index b7b283b..b4ee7fb 100644 --- a/MauverseIOS/Sources/Views/ServicesView.swift +++ b/MauverseIOS/Sources/Views/ServicesView.swift @@ -5,7 +5,7 @@ enum MauService: String, CaseIterable, Identifiable { var id: String { rawValue } var title: String { - switch self { + return switch self { case .eios: "ЭИОС" case .forms: "Онлайн-формы" case .messenger: "Мессенджер ЭИОС" @@ -18,7 +18,7 @@ enum MauService: String, CaseIterable, Identifiable { } var icon: String { - switch self { + return switch self { case .eios: "graduationcap.fill" case .forms: "doc.text.fill" case .messenger: "message.fill" @@ -31,7 +31,7 @@ enum MauService: String, CaseIterable, Identifiable { } var color: Color { - switch self { + return switch self { case .eios, .teachers: MauTheme.blue case .forms, .departments: .purple case .messenger, .digital: .teal @@ -460,4 +460,3 @@ private struct ContactCard: View { .mauGlass(radius: 22) } } - From cd696bd4a0ebff01188c595618ce25be872485fd Mon Sep 17 00:00:00 2001 From: D3C0Y Date: Wed, 29 Jul 2026 03:17:55 +0300 Subject: [PATCH 03/14] Update schedule API and add persistent themes --- .github/workflows/ci.yml | 4 + MauverseIOS/Resources/Info.plist | 2 + MauverseIOS/Sources/Core/APIClient.swift | 90 ++++++++++++++++++ MauverseIOS/Sources/Core/KeychainStore.swift | 51 +++++++++++ MauverseIOS/Sources/Core/Models.swift | 91 +++++++++++++++++++ MauverseIOS/Sources/Core/SessionStore.swift | 11 ++- MauverseIOS/Sources/Design/DesignSystem.swift | 30 +++++- MauverseIOS/Sources/MAUverseApp.swift | 4 +- MauverseIOS/Sources/Views/LoginView.swift | 5 +- MauverseIOS/Sources/Views/NewsView.swift | 4 +- MauverseIOS/Sources/Views/ProfileView.swift | 64 +++++++++++-- MauverseIOS/Sources/Views/ScheduleView.swift | 49 ++++++---- MauverseIOS/Sources/Views/ServicesView.swift | 13 ++- MauverseIOS/project.yml | 5 +- 14 files changed, 381 insertions(+), 42 deletions(-) create mode 100644 MauverseIOS/Sources/Core/KeychainStore.swift diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5fed531..ef59993 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -180,6 +180,8 @@ jobs: name: Native SwiftUI iPhone Release (unsigned) runs-on: macos-26 timeout-minutes: 35 + env: + MAUVERSE_SCHEDULE_TOKEN: ${{ secrets.MAUVERSE_SCHEDULE_TOKEN }} steps: - name: Check out sources uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 @@ -212,6 +214,7 @@ jobs: shell: bash run: | set -euo pipefail + test -n "$MAUVERSE_SCHEDULE_TOKEN" xcodebuild \ -project MauverseIOS/MAUverse.xcodeproj \ -scheme MAUverse \ @@ -223,6 +226,7 @@ jobs: CODE_SIGNING_REQUIRED=NO \ CODE_SIGN_IDENTITY="" \ DEVELOPMENT_TEAM="" \ + MAUVERSE_SCHEDULE_TOKEN="$MAUVERSE_SCHEDULE_TOKEN" \ clean build - name: Verify and package unsigned application diff --git a/MauverseIOS/Resources/Info.plist b/MauverseIOS/Resources/Info.plist index 06cdd33..06197e4 100644 --- a/MauverseIOS/Resources/Info.plist +++ b/MauverseIOS/Resources/Info.plist @@ -20,6 +20,8 @@ $(MARKETING_VERSION) CFBundleVersion $(CURRENT_PROJECT_VERSION) + MAUverseScheduleToken + $(MAUVERSE_SCHEDULE_TOKEN) UILaunchScreen UISupportedInterfaceOrientations diff --git a/MauverseIOS/Sources/Core/APIClient.swift b/MauverseIOS/Sources/Core/APIClient.swift index 0f3e8c1..54b25dd 100644 --- a/MauverseIOS/Sources/Core/APIClient.swift +++ b/MauverseIOS/Sources/Core/APIClient.swift @@ -83,6 +83,96 @@ final class APIClient { } } +final class ScheduleAPIClient { + static let shared = ScheduleAPIClient() + private let baseURL = URL(string: "https://api-schedule.mauniver.ru/")! + private let session: URLSession + + 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 response: ScheduleResponse = try await get( + "groups/\(escaped(uid))/schedule/\(start)/\(end)" + ) + return response.timetable.map(\.appItem) + } + + func groups() async throws -> [ScheduleGroup] { + 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) + } + return result.sorted { + $0.group.localizedStandardCompare($1.group) == .orderedAscending + } + } + + 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 + } + + 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, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse else { throw APIError.invalidResponse } + guard (200..<300).contains(http.statusCode) else { + let message: String + switch http.statusCode { + case 401, 403: message = "Токен API расписания недействителен" + case 404: message = "Данные расписания не найдены" + case 422: message = "Сервер не принял параметры запроса" + default: message = "Ошибка API расписания: \(http.statusCode)" + } + throw APIError.server(message) + } + do { + let decoder = JSONDecoder() + return try decoder.decode(Response.self, from: data) + } catch { + throw APIError.server("Новый API вернул неизвестный формат данных") + } + } + + private func escaped(_ value: String) -> String { + value.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? value + } +} + struct LoginRequest: Encodable { let username: String; let password: String } struct GroupRequest: Encodable { let groupName: String } struct DepartmentRequest: Encodable { let departmentId: Int; let name: String } 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 index 647ad8e..39e82ca 100644 --- a/MauverseIOS/Sources/Core/Models.swift +++ b/MauverseIOS/Sources/Core/Models.swift @@ -15,6 +15,7 @@ struct UserDTO: Codable, Equatable { var creditBook: String? var groupId: Int? var subGroupId: Int? + var scheduleGroupUID: String? var groupName: String? var speciality: String? var token: String? @@ -44,6 +45,96 @@ struct ScheduleItem: Codable, Identifiable, Hashable { } } +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: times.first, + endTime: times.count > 1 ? times[1] : nil, + date: date, + externalId: nil + ) + } +} + +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: Decodable, 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? diff --git a/MauverseIOS/Sources/Core/SessionStore.swift b/MauverseIOS/Sources/Core/SessionStore.swift index e447087..ccac1ea 100644 --- a/MauverseIOS/Sources/Core/SessionStore.swift +++ b/MauverseIOS/Sources/Core/SessionStore.swift @@ -10,8 +10,14 @@ final class SessionStore: ObservableObject { private let storageKey = "mauverse.native.session" init() { - if let data = UserDefaults.standard.data(forKey: storageKey) { + 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) + } } } @@ -47,11 +53,12 @@ final class SessionStore: ObservableObject { func signOut() { user = nil + KeychainStore.delete(account: storageKey) UserDefaults.standard.removeObject(forKey: storageKey) } private func persist() { guard let user, let data = try? JSONEncoder().encode(user) else { return } - UserDefaults.standard.set(data, forKey: storageKey) + KeychainStore.write(data, account: storageKey) } } diff --git a/MauverseIOS/Sources/Design/DesignSystem.swift b/MauverseIOS/Sources/Design/DesignSystem.swift index 0e42fc1..52dedab 100644 --- a/MauverseIOS/Sources/Design/DesignSystem.swift +++ b/MauverseIOS/Sources/Design/DesignSystem.swift @@ -1,10 +1,33 @@ 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.16, green: 0.43, blue: 0.95) - static let ink = Color(red: 0.08, green: 0.10, blue: 0.16) - static let muted = Color(red: 0.42, green: 0.46, blue: 0.54) - static let canvas = Color(red: 0.965, green: 0.97, blue: 0.985) + 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) } @@ -113,4 +136,3 @@ struct EmptyState: View { .mauGlass() } } - diff --git a/MauverseIOS/Sources/MAUverseApp.swift b/MauverseIOS/Sources/MAUverseApp.swift index 33e859e..a1b4742 100644 --- a/MauverseIOS/Sources/MAUverseApp.swift +++ b/MauverseIOS/Sources/MAUverseApp.swift @@ -3,6 +3,7 @@ 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 { @@ -14,8 +15,7 @@ struct MAUverseApp: App { } } .environmentObject(session) - .preferredColorScheme(.light) + .preferredColorScheme(AppTheme(rawValue: appearance)?.colorScheme) } } } - diff --git a/MauverseIOS/Sources/Views/LoginView.swift b/MauverseIOS/Sources/Views/LoginView.swift index b98fb4b..a0c54c4 100644 --- a/MauverseIOS/Sources/Views/LoginView.swift +++ b/MauverseIOS/Sources/Views/LoginView.swift @@ -42,7 +42,7 @@ struct LoginView: View { .submitLabel(.next) .onSubmit { focused = .password } .padding(18) - .background(Color.white.opacity(0.68), in: RoundedRectangle(cornerRadius: 18)) + .background(MauTheme.card.opacity(0.78), in: RoundedRectangle(cornerRadius: 18)) SecureField("Пароль", text: $password) .textContentType(.password) @@ -50,7 +50,7 @@ struct LoginView: View { .submitLabel(.go) .onSubmit { signIn() } .padding(18) - .background(Color.white.opacity(0.68), in: RoundedRectangle(cornerRadius: 18)) + .background(MauTheme.card.opacity(0.78), in: RoundedRectangle(cornerRadius: 18)) if let error = session.errorMessage { Text(error) @@ -93,4 +93,3 @@ struct LoginView: View { Task { await session.signIn(username: username, password: password) } } } - diff --git a/MauverseIOS/Sources/Views/NewsView.swift b/MauverseIOS/Sources/Views/NewsView.swift index b146c6a..192b38d 100644 --- a/MauverseIOS/Sources/Views/NewsView.swift +++ b/MauverseIOS/Sources/Views/NewsView.swift @@ -44,7 +44,7 @@ struct NewsView: View { .foregroundStyle(model.filter == filter ? .white : MauTheme.ink) .padding(.horizontal, 15) .padding(.vertical, 10) - .background(model.filter == filter ? AnyShapeStyle(MauTheme.blue.gradient) : AnyShapeStyle(Color.white.opacity(0.7)), + .background(model.filter == filter ? AnyShapeStyle(MauTheme.blue.gradient) : AnyShapeStyle(MauTheme.card.opacity(0.78)), in: Capsule()) .buttonStyle(.plain) } @@ -112,7 +112,7 @@ private struct NewsCard: View { } .padding(18) } - .background(Color.white.opacity(0.68)) + .background(MauTheme.card.opacity(0.72)) .clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous)) } .buttonStyle(.plain) diff --git a/MauverseIOS/Sources/Views/ProfileView.swift b/MauverseIOS/Sources/Views/ProfileView.swift index a9fb187..dcda380 100644 --- a/MauverseIOS/Sources/Views/ProfileView.swift +++ b/MauverseIOS/Sources/Views/ProfileView.swift @@ -66,7 +66,7 @@ struct ProfileView: View { .buttonStyle(.plain) .mauGlass(radius: 19) - Text("MAUverse 1.8.5 (20)") + Text("MAUverse 1.8.6 (21)") .font(.caption) .foregroundStyle(MauTheme.muted) } @@ -103,6 +103,8 @@ private struct ProfileEditor: View { @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 { @@ -110,6 +112,11 @@ private struct ProfileEditor: View { Section("Расписание") { TextField("Учебная группа", text: $group) .textInputAutocapitalization(.characters) + if let errorMessage { + Text(errorMessage) + .font(.footnote) + .foregroundStyle(.red) + } } Section("Задолженности") { TextField("Номер зачётной книжки", text: $creditBook) @@ -128,13 +135,8 @@ private struct ProfileEditor: View { Button("Отмена") { dismiss() } } ToolbarItem(placement: .confirmationAction) { - Button("Сохранить") { - session.update { - $0.groupName = group.trimmingCharacters(in: .whitespacesAndNewlines) - $0.creditBook = creditBook.trimmingCharacters(in: .whitespacesAndNewlines) - } - dismiss() - } + Button(isSaving ? "Проверяем…" : "Сохранить") { save() } + .disabled(isSaving) } } .onAppear { @@ -143,16 +145,60 @@ private struct ProfileEditor: View { } } } + + 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?.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) + .mauGlass() + VStack(alignment: .leading, spacing: 13) { Label("Кэш приложения", systemImage: "internaldrive.fill").font(.headline) Text("Изображения новостей и веб-страниц очищаются системой автоматически.") @@ -171,7 +217,7 @@ private struct SettingsView: View { Label("О приложении", systemImage: "info.circle.fill").font(.headline) Text("Нативное приложение Мурманского арктического университета.") .font(.subheadline).foregroundStyle(MauTheme.muted) - Text("Версия 1.8.5 • сборка 20").font(.caption) + Text("Версия 1.8.6 • сборка 21").font(.caption) } .frame(maxWidth: .infinity, alignment: .leading) .padding(20) diff --git a/MauverseIOS/Sources/Views/ScheduleView.swift b/MauverseIOS/Sources/Views/ScheduleView.swift index 0cc507c..af34608 100644 --- a/MauverseIOS/Sources/Views/ScheduleView.swift +++ b/MauverseIOS/Sources/Views/ScheduleView.swift @@ -11,8 +11,8 @@ final class ScheduleViewModel: ObservableObject { Calendar.current.date(byAdding: .day, value: $0, to: Calendar.current.startOfDay(for: Date())) } - func load(user: UserDTO?) async { - guard let user else { return } + func load(user: UserDTO?) async -> ScheduleGroup? { + guard let user else { return nil } isLoading = true error = nil defer { isLoading = false } @@ -20,19 +20,24 @@ final class ScheduleViewModel: ObservableObject { formatter.locale = Locale(identifier: "en_US_POSIX") formatter.dateFormat = "yyyy-MM-dd" do { - let response: [ScheduleItem] = try await APIClient.shared.post( - "get_schedule", - body: ScheduleRequest( - startDate: formatter.string(from: dates.first ?? Date()), - endDate: formatter.string(from: dates.last ?? Date()), - groupId: user.groupId, - subgroupId: user.subGroupId - ), - user: user + 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("Выберите существующую учебную группу в профиле") + } + items = try await ScheduleAPIClient.shared.schedule( + uid: uid, + start: formatter.string(from: dates.first ?? Date()), + end: formatter.string(from: dates.last ?? Date()) ) - items = response + return resolvedGroup } catch { self.error = error.localizedDescription + return nil } } @@ -70,7 +75,7 @@ struct ScheduleView: View { .foregroundStyle(MauTheme.muted) } Spacer() - Button { Task { await model.load(user: session.user) } } label: { + Button { Task { await reload() } } label: { Image(systemName: "arrow.clockwise") .font(.headline) .frame(width: 44, height: 44) @@ -122,10 +127,21 @@ struct ScheduleView: View { .padding(20) .padding(.bottom, 12) } - .refreshable { await model.load(user: session.user) } + .refreshable { await reload() } } .navigationBarTitleDisplayMode(.inline) - .task { if model.items.isEmpty { await model.load(user: session.user) } } + .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 = group.groupId + $0.groupName = group.group + $0.speciality = group.speciality + } + } } } @@ -142,7 +158,7 @@ private struct DateChip: View { } .foregroundStyle(selected ? .white : MauTheme.ink) .frame(width: 58, height: 68) - .background(selected ? AnyShapeStyle(MauTheme.blue.gradient) : AnyShapeStyle(Color.white.opacity(0.72)), + .background(selected ? AnyShapeStyle(MauTheme.blue.gradient) : AnyShapeStyle(MauTheme.card.opacity(0.82)), in: RoundedRectangle(cornerRadius: 19, style: .continuous)) } } @@ -187,4 +203,3 @@ private struct LessonCard: View { .mauGlass(radius: 22) } } - diff --git a/MauverseIOS/Sources/Views/ServicesView.swift b/MauverseIOS/Sources/Views/ServicesView.swift index b4ee7fb..049900c 100644 --- a/MauverseIOS/Sources/Views/ServicesView.swift +++ b/MauverseIOS/Sources/Views/ServicesView.swift @@ -311,7 +311,18 @@ private final class TeachersModel: ObservableObject { error = nil defer { isLoading = false } do { - teachers = try await APIClient.shared.post("get_teachers", body: TeacherRequest(name: text), user: user) + teachers = try await ScheduleAPIClient.shared.teachers(matching: text).map { + Teacher( + id: $0.teacherId, + teacherId: $0.teacherId, + name: $0.teacher, + fullName: $0.teacher, + email: nil, + phone: nil, + post: "Преподаватель МАУ", + extras: $0.uid + ) + } } catch { self.error = error.localizedDescription } } } diff --git a/MauverseIOS/project.yml b/MauverseIOS/project.yml index 9b1a569..8190332 100644 --- a/MauverseIOS/project.yml +++ b/MauverseIOS/project.yml @@ -28,8 +28,9 @@ targets: INFOPLIST_FILE: Resources/Info.plist GENERATE_INFOPLIST_FILE: NO ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon - MARKETING_VERSION: 1.8.5 - CURRENT_PROJECT_VERSION: 20 + MARKETING_VERSION: 1.8.6 + CURRENT_PROJECT_VERSION: 21 + MAUVERSE_SCHEDULE_TOKEN: "" TARGETED_DEVICE_FAMILY: "1" SUPPORTS_MACCATALYST: NO scheme: From cfc0be5960014edd9f6ee84548d3895d637b9c8d Mon Sep 17 00:00:00 2001 From: D3C0Y Date: Wed, 29 Jul 2026 03:25:06 +0300 Subject: [PATCH 04/14] Load news from official public RSS feeds --- MauverseIOS/Sources/Core/APIClient.swift | 8 + .../Sources/Core/OfficialNewsService.swift | 139 ++++++++++++++++++ MauverseIOS/Sources/Core/SessionStore.swift | 17 +++ MauverseIOS/Sources/Views/NewsView.swift | 7 +- MauverseIOS/Sources/Views/ProfileView.swift | 4 +- MauverseIOS/project.yml | 4 +- 6 files changed, 170 insertions(+), 9 deletions(-) create mode 100644 MauverseIOS/Sources/Core/OfficialNewsService.swift diff --git a/MauverseIOS/Sources/Core/APIClient.swift b/MauverseIOS/Sources/Core/APIClient.swift index 54b25dd..bb68929 100644 --- a/MauverseIOS/Sources/Core/APIClient.swift +++ b/MauverseIOS/Sources/Core/APIClient.swift @@ -12,6 +12,10 @@ enum APIError: LocalizedError { } } +extension Notification.Name { + static let mauverseSessionExpired = Notification.Name("mauverse.session.expired") +} + struct EmptyBody: Encodable {} final class APIClient { @@ -65,6 +69,10 @@ final class APIClient { private func execute(_ request: URLRequest) async throws -> Response { let (data, response) = try await session.data(for: request) guard let http = response as? HTTPURLResponse else { throw APIError.invalidResponse } + if http.statusCode == 401 || http.statusCode == 403 { + NotificationCenter.default.post(name: .mauverseSessionExpired, object: nil) + throw APIError.server("Сессия истекла. Войдите в аккаунт повторно") + } guard (200..<300).contains(http.statusCode) else { let detail = (try? decoder.decode(APIMessage.self, from: data)) throw APIError.server(detail?.detail ?? detail?.error ?? "Ошибка сервера: \(http.statusCode)") diff --git a/MauverseIOS/Sources/Core/OfficialNewsService.swift b/MauverseIOS/Sources/Core/OfficialNewsService.swift new file mode 100644 index 0000000..7adf3eb --- /dev/null +++ b/MauverseIOS/Sources/Core/OfficialNewsService.swift @@ -0,0 +1,139 @@ +import Foundation +import FoundationXML + +final class OfficialNewsService { + static let shared = OfficialNewsService() + + private init() {} + + func load(filter: NewsFilter) async throws -> [NewsItem] { + 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 + } + var request = URLRequest(url: url) + request.timeoutInterval = 30 + request.setValue("MAUverse/1.8.7 (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) + } +} + +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 = 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 = value + case "description": current?.description = value.cleanedRSS + 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 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) + } +} diff --git a/MauverseIOS/Sources/Core/SessionStore.swift b/MauverseIOS/Sources/Core/SessionStore.swift index ccac1ea..5a1df55 100644 --- a/MauverseIOS/Sources/Core/SessionStore.swift +++ b/MauverseIOS/Sources/Core/SessionStore.swift @@ -8,6 +8,7 @@ final class SessionStore: ObservableObject { @Published var errorMessage: String? private let storageKey = "mauverse.native.session" + private var sessionObserver: NSObjectProtocol? init() { if let data = KeychainStore.read(account: storageKey) { @@ -19,6 +20,22 @@ final class SessionStore: ObservableObject { 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 = "Сессия истекла. Войдите в аккаунт повторно" + } + } + } + + deinit { + if let sessionObserver { + NotificationCenter.default.removeObserver(sessionObserver) + } } func signIn(username: String, password: String) async { diff --git a/MauverseIOS/Sources/Views/NewsView.swift b/MauverseIOS/Sources/Views/NewsView.swift index 192b38d..f7f1c24 100644 --- a/MauverseIOS/Sources/Views/NewsView.swift +++ b/MauverseIOS/Sources/Views/NewsView.swift @@ -13,10 +13,7 @@ final class NewsViewModel: ObservableObject { error = nil defer { isLoading = false } do { - items = try await APIClient.shared.get( - "news", - query: [URLQueryItem(name: "news_type", value: "\(filter.rawValue)")] - ) + items = try await OfficialNewsService.shared.load(filter: filter) } catch { self.error = error.localizedDescription } @@ -54,7 +51,7 @@ struct NewsView: View { if model.isLoading { LoadingOverlay(title: "Загружаем новости").frame(maxWidth: .infinity) } else if let error = model.error { - EmptyState(icon: "wifi.exclamationmark", title: "Нет соединения", message: error) + EmptyState(icon: "wifi.exclamationmark", title: "Новости недоступны", message: error) } else if model.items.isEmpty { EmptyState(icon: "newspaper", title: "Новостей пока нет", message: "Попробуйте выбрать другую категорию") } else { diff --git a/MauverseIOS/Sources/Views/ProfileView.swift b/MauverseIOS/Sources/Views/ProfileView.swift index dcda380..d9039b7 100644 --- a/MauverseIOS/Sources/Views/ProfileView.swift +++ b/MauverseIOS/Sources/Views/ProfileView.swift @@ -66,7 +66,7 @@ struct ProfileView: View { .buttonStyle(.plain) .mauGlass(radius: 19) - Text("MAUverse 1.8.6 (21)") + Text("MAUverse 1.8.7 (22)") .font(.caption) .foregroundStyle(MauTheme.muted) } @@ -217,7 +217,7 @@ private struct SettingsView: View { Label("О приложении", systemImage: "info.circle.fill").font(.headline) Text("Нативное приложение Мурманского арктического университета.") .font(.subheadline).foregroundStyle(MauTheme.muted) - Text("Версия 1.8.6 • сборка 21").font(.caption) + Text("Версия 1.8.7 • сборка 22").font(.caption) } .frame(maxWidth: .infinity, alignment: .leading) .padding(20) diff --git a/MauverseIOS/project.yml b/MauverseIOS/project.yml index 8190332..c61c6e4 100644 --- a/MauverseIOS/project.yml +++ b/MauverseIOS/project.yml @@ -28,8 +28,8 @@ targets: INFOPLIST_FILE: Resources/Info.plist GENERATE_INFOPLIST_FILE: NO ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon - MARKETING_VERSION: 1.8.6 - CURRENT_PROJECT_VERSION: 21 + MARKETING_VERSION: 1.8.7 + CURRENT_PROJECT_VERSION: 22 MAUVERSE_SCHEDULE_TOKEN: "" TARGETED_DEVICE_FAMILY: "1" SUPPORTS_MACCATALYST: NO From 62be39e5ee9ddde55bed9aeb1b96e2edc85d2723 Mon Sep 17 00:00:00 2001 From: D3C0Y Date: Wed, 29 Jul 2026 03:26:07 +0300 Subject: [PATCH 05/14] Use iOS Foundation XML parser --- MauverseIOS/Sources/Core/OfficialNewsService.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/MauverseIOS/Sources/Core/OfficialNewsService.swift b/MauverseIOS/Sources/Core/OfficialNewsService.swift index 7adf3eb..ea43f3f 100644 --- a/MauverseIOS/Sources/Core/OfficialNewsService.swift +++ b/MauverseIOS/Sources/Core/OfficialNewsService.swift @@ -1,5 +1,4 @@ import Foundation -import FoundationXML final class OfficialNewsService { static let shared = OfficialNewsService() From 15dd59a525d69901782fc8839924db8354a2432d Mon Sep 17 00:00:00 2001 From: D3C0Y Date: Wed, 29 Jul 2026 16:03:53 +0300 Subject: [PATCH 06/14] Add polished in-app browser for all web content --- .../Sources/Views/InAppBrowserView.swift | 302 ++++++++++++++++++ MauverseIOS/Sources/Views/NewsView.swift | 29 +- MauverseIOS/Sources/Views/ProfileView.swift | 4 +- MauverseIOS/Sources/Views/ServicesView.swift | 37 ++- MauverseIOS/project.yml | 4 +- 5 files changed, 337 insertions(+), 39 deletions(-) create mode 100644 MauverseIOS/Sources/Views/InAppBrowserView.swift diff --git a/MauverseIOS/Sources/Views/InAppBrowserView.swift b/MauverseIOS/Sources/Views/InAppBrowserView.swift new file mode 100644 index 0000000..2053a7c --- /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.8.8" + 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/NewsView.swift b/MauverseIOS/Sources/Views/NewsView.swift index f7f1c24..84c8c6c 100644 --- a/MauverseIOS/Sources/Views/NewsView.swift +++ b/MauverseIOS/Sources/Views/NewsView.swift @@ -1,5 +1,4 @@ import SwiftUI -import SafariServices @MainActor final class NewsViewModel: ObservableObject { @@ -73,11 +72,20 @@ struct NewsView: View { private struct NewsCard: View { let item: NewsItem + @ViewBuilder var body: some View { - Button { - guard let value = item.link, let url = URL(string: value) else { return } - UIApplication.shared.open(url) - } label: { + 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 { VStack(alignment: .leading, spacing: 0) { if let source = item.image, let url = URL(string: source) { AsyncImage(url: url) { phase in @@ -111,9 +119,6 @@ private struct NewsCard: View { } .background(MauTheme.card.opacity(0.72)) .clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous)) - } - .buttonStyle(.plain) - .mauGlass(radius: 24) } } @@ -124,11 +129,3 @@ private extension String { .replacingOccurrences(of: "&", with: "&") } } - -struct SafariView: UIViewControllerRepresentable { - let url: URL - func makeUIViewController(context: Context) -> SFSafariViewController { - SFSafariViewController(url: url) - } - func updateUIViewController(_ uiViewController: SFSafariViewController, context: Context) {} -} diff --git a/MauverseIOS/Sources/Views/ProfileView.swift b/MauverseIOS/Sources/Views/ProfileView.swift index d9039b7..3c94a9b 100644 --- a/MauverseIOS/Sources/Views/ProfileView.swift +++ b/MauverseIOS/Sources/Views/ProfileView.swift @@ -66,7 +66,7 @@ struct ProfileView: View { .buttonStyle(.plain) .mauGlass(radius: 19) - Text("MAUverse 1.8.7 (22)") + Text("MAUverse 1.8.8 (23)") .font(.caption) .foregroundStyle(MauTheme.muted) } @@ -217,7 +217,7 @@ private struct SettingsView: View { Label("О приложении", systemImage: "info.circle.fill").font(.headline) Text("Нативное приложение Мурманского арктического университета.") .font(.subheadline).foregroundStyle(MauTheme.muted) - Text("Версия 1.8.7 • сборка 22").font(.caption) + Text("Версия 1.8.8 • сборка 23").font(.caption) } .frame(maxWidth: .infinity, alignment: .leading) .padding(20) diff --git a/MauverseIOS/Sources/Views/ServicesView.swift b/MauverseIOS/Sources/Views/ServicesView.swift index 049900c..5c719aa 100644 --- a/MauverseIOS/Sources/Views/ServicesView.swift +++ b/MauverseIOS/Sources/Views/ServicesView.swift @@ -113,10 +113,7 @@ private struct WebServiceView: View { var body: some View { if let destination = URL(string: url) { - SafariView(url: destination) - .ignoresSafeArea() - .navigationTitle(title) - .navigationBarTitleDisplayMode(.inline) + InAppBrowserView(url: destination, title: title) } else { EmptyState(icon: "link.badge.plus", title: "Ссылка недоступна", message: url) } @@ -206,26 +203,28 @@ private struct CampusSection: View { var body: some View { Section(title) { ForEach(campuses) { campus in - Button { - let query = "Мурманск, \(campus.address)" - let encoded = query.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? query - if let url = URL(string: "https://2gis.ru/murmansk/search/\(encoded)") { - UIApplication.shared.open(url) - } - } label: { - 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) + 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 diff --git a/MauverseIOS/project.yml b/MauverseIOS/project.yml index c61c6e4..5954cc5 100644 --- a/MauverseIOS/project.yml +++ b/MauverseIOS/project.yml @@ -28,8 +28,8 @@ targets: INFOPLIST_FILE: Resources/Info.plist GENERATE_INFOPLIST_FILE: NO ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon - MARKETING_VERSION: 1.8.7 - CURRENT_PROJECT_VERSION: 22 + MARKETING_VERSION: 1.8.8 + CURRENT_PROJECT_VERSION: 23 MAUVERSE_SCHEDULE_TOKEN: "" TARGETED_DEVICE_FAMILY: "1" SUPPORTS_MACCATALYST: NO From 6c46051d61170aa8d9a2334b75ca51d5bd43bea4 Mon Sep 17 00:00:00 2001 From: D3C0Y Date: Wed, 29 Jul 2026 16:13:43 +0300 Subject: [PATCH 07/14] Apply premium Arctic design system --- .../Sources/Core/OfficialNewsService.swift | 2 +- MauverseIOS/Sources/Design/DesignSystem.swift | 114 +++++- MauverseIOS/Sources/Views/HomeView.swift | 350 +++++++++++++----- .../Sources/Views/InAppBrowserView.swift | 2 +- MauverseIOS/Sources/Views/LoginView.swift | 25 +- MauverseIOS/Sources/Views/NewsView.swift | 147 ++++++-- MauverseIOS/Sources/Views/ProfileView.swift | 109 ++++-- MauverseIOS/Sources/Views/ScheduleView.swift | 83 ++++- MauverseIOS/Sources/Views/ServicesView.swift | 82 +++- MauverseIOS/project.yml | 4 +- 10 files changed, 708 insertions(+), 210 deletions(-) diff --git a/MauverseIOS/Sources/Core/OfficialNewsService.swift b/MauverseIOS/Sources/Core/OfficialNewsService.swift index ea43f3f..85a3165 100644 --- a/MauverseIOS/Sources/Core/OfficialNewsService.swift +++ b/MauverseIOS/Sources/Core/OfficialNewsService.swift @@ -23,7 +23,7 @@ final class OfficialNewsService { } var request = URLRequest(url: url) request.timeoutInterval = 30 - request.setValue("MAUverse/1.8.7 (iOS)", forHTTPHeaderField: "User-Agent") + request.setValue("MAUverse/1.9.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) diff --git a/MauverseIOS/Sources/Design/DesignSystem.swift b/MauverseIOS/Sources/Design/DesignSystem.swift index 52dedab..95042a5 100644 --- a/MauverseIOS/Sources/Design/DesignSystem.swift +++ b/MauverseIOS/Sources/Design/DesignSystem.swift @@ -23,28 +23,61 @@ enum AppTheme: String, CaseIterable, Identifiable { } enum MauTheme { - static let blue = Color(red: 0.16, green: 0.43, blue: 0.95) + 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(Color.blue.opacity(0.12)) - .frame(width: 360, height: 360) - .blur(radius: 40) - .offset(x: 170, y: -330) + .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(Color.purple.opacity(0.10)) - .frame(width: 330, height: 330) - .blur(radius: 55) - .offset(x: -180, y: 360) + .fill(MauTheme.violet.opacity(colorScheme == .dark ? 0.15 : 0.075)) + .frame(width: 390, height: 390) + .blur(radius: 80) + .offset(x: -190, y: 390) } .ignoresSafeArea() } @@ -86,6 +119,14 @@ extension View { } .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 { @@ -101,6 +142,61 @@ struct IconTile: View { } } +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 diff --git a/MauverseIOS/Sources/Views/HomeView.swift b/MauverseIOS/Sources/Views/HomeView.swift index 2f7204c..655b854 100644 --- a/MauverseIOS/Sources/Views/HomeView.swift +++ b/MauverseIOS/Sources/Views/HomeView.swift @@ -1,7 +1,47 @@ import SwiftUI +@MainActor +final class HomeViewModel: ObservableObject { + @Published var nextLesson: ScheduleItem? + @Published var featuredNews: NewsItem? + @Published var isLoading = false + + func load(user: UserDTO?) async { + isLoading = true + defer { isLoading = false } + + async let newsTask = try? OfficialNewsService.shared.load(filter: .all) + if let user { + 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 { + 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 + if let lessons = try? await ScheduleAPIClient.shared.schedule( + uid: uid, + start: formatter.string(from: start), + end: formatter.string(from: end) + ) { + nextLesson = lessons.sorted(by: Self.isEarlier).first + } + } + } + featuredNews = await newsTask?.first + } + + private static func isEarlier(_ lhs: ScheduleItem, _ rhs: ScheduleItem) -> Bool { + "\(lhs.date ?? "") \(lhs.startTime ?? "")" < "\(rhs.date ?? "") \(rhs.startTime ?? "")" + } +} + struct HomeView: View { @EnvironmentObject private var session: SessionStore + @StateObject private var model = HomeViewModel() private var firstName: String { session.user?.firstName?.split(separator: " ").first.map(String.init) @@ -13,95 +53,112 @@ struct HomeView: View { ZStack { MauBackground() ScrollView { - VStack(alignment: .leading, spacing: 22) { - HStack(alignment: .top) { - VStack(alignment: .leading, spacing: 5) { - Text(greeting) - .font(.subheadline) - .foregroundStyle(MauTheme.muted) - Text(firstName) - .font(.system(size: 36, weight: .bold, design: .rounded)) - } - Spacer() - NavigationLink(destination: ProfileView()) { - Image(systemName: "person.crop.circle.fill") - .font(.system(size: 40)) - .symbolRenderingMode(.hierarchical) - .foregroundStyle(MauTheme.blue) - } - } - - HStack(spacing: 12) { - Image(systemName: "checkmark.icloud.fill") - .foregroundStyle(.green) - VStack(alignment: .leading, spacing: 3) { - Text("Данные сохранены").font(.subheadline.weight(.semibold)) - Text("Профиль доступен после перезапуска") - .font(.caption) - .foregroundStyle(MauTheme.muted) - } - } - .padding(17) - .frame(maxWidth: .infinity, alignment: .leading) - .mauGlass(radius: 20) + VStack(alignment: .leading, spacing: MauSpacing.lg) { + header if session.user?.groupName?.isEmpty != false { - NavigationLink(destination: ProfileView()) { - HStack(spacing: 14) { - IconTile(systemName: "exclamationmark.triangle.fill", color: .orange) - VStack(alignment: .leading, spacing: 4) { - Text("Укажите учебную группу").font(.headline) - Text("Она нужна для загрузки расписания") - .font(.caption) - .foregroundStyle(MauTheme.muted) - } - Spacer() - Image(systemName: "chevron.right") - } - .padding(18) - } - .buttonStyle(.plain) - .mauGlass() + missingGroupCard + } else if model.isLoading { + lessonSkeleton + } else { + NextLessonCard(item: model.nextLesson, group: session.user?.groupName) } - Text("Быстрый доступ") - .font(.title3.bold()) - - HStack(spacing: 12) { - QuickCard(title: "Расписание", icon: "calendar", color: MauTheme.blue) { - ScheduleView() - } - QuickCard(title: "Сервисы", icon: "square.grid.2x2", color: .purple) { - ServicesView() - } - QuickCard(title: "Новости", icon: "newspaper", color: .orange) { - NewsView() - } - } + MauSectionHeader(title: "Быстрый доступ") + quickActions - NavigationLink(destination: NewsView()) { - HStack(spacing: 16) { - VStack(alignment: .leading, spacing: 7) { - Text("Будьте в курсе").font(.title3.bold()) - Text("Свежие новости и события МАУ") - .font(.subheadline) - .foregroundStyle(MauTheme.muted) - } - Spacer() - Image(systemName: "arrow.up.right") - .font(.title3.bold()) - .foregroundStyle(MauTheme.blue) - } - .padding(22) + if let news = model.featuredNews { + MauSectionHeader(title: "Главное в МАУ", action: "Все новости") + FeaturedNewsCard(item: news) } - .buttonStyle(.plain) - .mauGlass() } - .padding(20) - .padding(.bottom, 12) + .padding(.horizontal, 20) + .padding(.top, 14) + .padding(.bottom, 24) } + .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 { @@ -114,8 +171,70 @@ struct HomeView: View { } } -private struct QuickCard: View { +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 @@ -123,16 +242,71 @@ private struct QuickCard: View { var body: some View { NavigationLink(destination: destination()) { VStack(alignment: .leading, spacing: 15) { - IconTile(systemName: icon, color: color) - Text(title) - .font(.caption.weight(.semibold)) - .foregroundStyle(MauTheme.ink) - .lineLimit(1) + 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) + } } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(14) + .foregroundStyle(MauTheme.ink) + .frame(maxWidth: .infinity, minHeight: 105, alignment: .topLeading) + .padding(16) } .buttonStyle(.plain) - .mauGlass(radius: 20) + .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() + } 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(height: 250) + .clipShape(RoundedRectangle(cornerRadius: MauRadius.hero, style: .continuous)) } } diff --git a/MauverseIOS/Sources/Views/InAppBrowserView.swift b/MauverseIOS/Sources/Views/InAppBrowserView.swift index 2053a7c..2caaf3c 100644 --- a/MauverseIOS/Sources/Views/InAppBrowserView.swift +++ b/MauverseIOS/Sources/Views/InAppBrowserView.swift @@ -23,7 +23,7 @@ final class InAppBrowserController: NSObject, ObservableObject, WKNavigationDele let configuration = WKWebViewConfiguration() configuration.websiteDataStore = .default() - configuration.applicationNameForUserAgent = "MAUverse/1.8.8" + configuration.applicationNameForUserAgent = "MAUverse/1.9.0" configuration.defaultWebpagePreferences.allowsContentJavaScript = true configuration.allowsInlineMediaPlayback = true webView = WKWebView(frame: .zero, configuration: configuration) diff --git a/MauverseIOS/Sources/Views/LoginView.swift b/MauverseIOS/Sources/Views/LoginView.swift index a0c54c4..4bb6b3a 100644 --- a/MauverseIOS/Sources/Views/LoginView.swift +++ b/MauverseIOS/Sources/Views/LoginView.swift @@ -13,21 +13,25 @@ struct LoginView: View { MauBackground() ScrollView { VStack(spacing: 26) { - Spacer(minLength: 70) + Spacer(minLength: 54) VStack(spacing: 14) { ZStack { - RoundedRectangle(cornerRadius: 28, style: .continuous) - .fill(MauTheme.blue.gradient) - Image(systemName: "sparkles") - .font(.system(size: 44, weight: .medium)) + RoundedRectangle(cornerRadius: 30, style: .continuous) + .fill(MauTheme.heroGradient) + Image(systemName: "snowflake") + .font(.system(size: 45, weight: .medium)) .foregroundStyle(.white) } - .frame(width: 92, height: 92) + .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: 38, weight: .bold, design: .rounded)) - Text("Вся университетская жизнь в одном месте") + .font(.system(size: 40, weight: .bold, design: .rounded)) + Text("Цифровая среда Арктического университета") .font(.subheadline) .foregroundStyle(MauTheme.muted) .multilineTextAlignment(.center) @@ -70,11 +74,12 @@ struct LoginView: View { } .buttonStyle(.plain) .foregroundStyle(.white) - .background(MauTheme.blue.gradient, in: RoundedRectangle(cornerRadius: 18)) + .background(MauTheme.heroGradient, in: RoundedRectangle(cornerRadius: 18)) + .shadow(color: MauTheme.blue.opacity(0.22), radius: 14, y: 7) .disabled(session.isBusy) } .padding(20) - .mauGlass(radius: 28) + .mauSurface(radius: 28) Text("Используйте данные от электронной информационно-образовательной среды МАУ") .font(.caption) diff --git a/MauverseIOS/Sources/Views/NewsView.swift b/MauverseIOS/Sources/Views/NewsView.swift index 84c8c6c..2b5e286 100644 --- a/MauverseIOS/Sources/Views/NewsView.swift +++ b/MauverseIOS/Sources/Views/NewsView.swift @@ -27,7 +27,13 @@ struct NewsView: View { MauBackground() ScrollView { VStack(alignment: .leading, spacing: 18) { - Text("Новости").font(.system(size: 34, weight: .bold, design: .rounded)) + 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) { @@ -48,14 +54,22 @@ struct NewsView: View { } if model.isLoading { - LoadingOverlay(title: "Загружаем новости").frame(maxWidth: .infinity) + VStack(spacing: 16) { + SkeletonCard() + SkeletonCard() + } } else if let error = model.error { EmptyState(icon: "wifi.exclamationmark", title: "Новости недоступны", message: error) } else if model.items.isEmpty { EmptyState(icon: "newspaper", title: "Новостей пока нет", message: "Попробуйте выбрать другую категорию") } else { - LazyVStack(spacing: 16) { - ForEach(model.items) { NewsCard(item: $0) } + 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) + } } } } @@ -71,6 +85,7 @@ struct NewsView: View { private struct NewsCard: View { let item: NewsItem + let category: String @ViewBuilder var body: some View { @@ -86,39 +101,111 @@ private struct NewsCard: View { } 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 { - LinearGradient(colors: [MauTheme.lavender, .white], startPoint: .topLeading, endPoint: .bottomTrailing) - .overlay(Image(systemName: "photo").font(.largeTitle).foregroundStyle(MauTheme.blue)) - } + 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(.subheadline.bold()) + .multilineTextAlignment(.leading) + .foregroundStyle(MauTheme.ink) + .lineLimit(4) + 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 { + ZStack(alignment: .bottomLeading) { + 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 } - .frame(height: 180) - .clipped() } - VStack(alignment: .leading, spacing: 9) { + } else { + MauTheme.heroGradient + } + + LinearGradient( + colors: [.clear, .black.opacity(0.2), .black.opacity(0.92)], + startPoint: .top, + endPoint: .bottom + ) + + VStack(alignment: .leading, spacing: 9) { + HStack { + Text(category.uppercased()) + .font(.caption2.bold()) + .tracking(0.8) + .foregroundStyle(MauTheme.cyan) + Spacer() if let date = item.publish { - Text(date).font(.caption).foregroundStyle(MauTheme.blue) + Text(date) + .font(.caption2) + .foregroundStyle(.white.opacity(0.7)) } - Text(item.title ?? "Новость") - .font(.headline) + } + Text(item.title ?? "Новости университета") + .font(.system(size: 23, weight: .bold, design: .rounded)) + .foregroundStyle(.white) + .multilineTextAlignment(.leading) + .lineLimit(4) + if let description = item.description { + Text(description.strippingHTML) + .font(.subheadline) + .foregroundStyle(.white.opacity(0.75)) + .lineLimit(2) .multilineTextAlignment(.leading) - .foregroundStyle(MauTheme.ink) - if let description = item.description { - Text(description.strippingHTML) - .font(.subheadline) - .foregroundStyle(MauTheme.muted) - .lineLimit(3) - .multilineTextAlignment(.leading) - } } - .padding(18) } - .background(MauTheme.card.opacity(0.72)) - .clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous)) + .padding(20) + } + .frame(height: 330) + .clipShape(RoundedRectangle(cornerRadius: MauRadius.hero, style: .continuous)) + .shadow(color: .black.opacity(0.12), radius: 18, y: 10) } } diff --git a/MauverseIOS/Sources/Views/ProfileView.swift b/MauverseIOS/Sources/Views/ProfileView.swift index 3c94a9b..695d4dc 100644 --- a/MauverseIOS/Sources/Views/ProfileView.swift +++ b/MauverseIOS/Sources/Views/ProfileView.swift @@ -10,18 +10,38 @@ struct ProfileView: View { MauBackground() ScrollView { VStack(spacing: 22) { - VStack(spacing: 12) { - Image(systemName: "person.crop.circle.fill") - .font(.system(size: 86)) - .symbolRenderingMode(.hierarchical) - .foregroundStyle(MauTheme.blue) + 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(.title2.bold()) + .font(.system(size: 25, weight: .bold, design: .rounded)) .multilineTextAlignment(.center) Text(session.user?.username ?? "") .font(.subheadline) - .foregroundStyle(MauTheme.muted) + .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") @@ -34,39 +54,25 @@ struct ProfileView: View { ProfileRow(label: "Зачётная книжка", value: session.user?.creditBook) } .padding(20) - .mauGlass() + .mauSurface() - Button { showingEditor = true } label: { - Label("Изменить учебные данные", systemImage: "pencil") - .fontWeight(.semibold) - .frame(maxWidth: .infinity) - .padding(.vertical, 16) - } - .buttonStyle(.plain) - .foregroundStyle(MauTheme.blue) - .mauGlass(radius: 19) - - NavigationLink(destination: SettingsView()) { - HStack { - Label("Настройки", systemImage: "gearshape.fill") - Spacer() - Image(systemName: "chevron.right") + 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) } - .padding(17) - } - .buttonStyle(.plain) - .foregroundStyle(MauTheme.ink) - .mauGlass(radius: 19) - - Button(role: .destructive) { showingLogout = true } label: { - Label("Выйти", systemImage: "rectangle.portrait.and.arrow.right") - .frame(maxWidth: .infinity) - .padding(.vertical, 16) } .buttonStyle(.plain) - .mauGlass(radius: 19) + .mauSurface() - Text("MAUverse 1.8.8 (23)") + Text("MAUverse 1.9.0 (24)") .font(.caption) .foregroundStyle(MauTheme.muted) } @@ -83,6 +89,31 @@ struct ProfileView: View { } } +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? @@ -197,7 +228,7 @@ private struct SettingsView: View { } .frame(maxWidth: .infinity, alignment: .leading) .padding(20) - .mauGlass() + .mauSurface() VStack(alignment: .leading, spacing: 13) { Label("Кэш приложения", systemImage: "internaldrive.fill").font(.headline) @@ -211,17 +242,17 @@ private struct SettingsView: View { } .frame(maxWidth: .infinity, alignment: .leading) .padding(20) - .mauGlass() + .mauSurface() VStack(alignment: .leading, spacing: 12) { Label("О приложении", systemImage: "info.circle.fill").font(.headline) Text("Нативное приложение Мурманского арктического университета.") .font(.subheadline).foregroundStyle(MauTheme.muted) - Text("Версия 1.8.8 • сборка 23").font(.caption) + Text("Версия 1.9.0 • сборка 24").font(.caption) } .frame(maxWidth: .infinity, alignment: .leading) .padding(20) - .mauGlass() + .mauSurface() } .padding(20) } diff --git a/MauverseIOS/Sources/Views/ScheduleView.swift b/MauverseIOS/Sources/Views/ScheduleView.swift index af34608..a734f52 100644 --- a/MauverseIOS/Sources/Views/ScheduleView.swift +++ b/MauverseIOS/Sources/Views/ScheduleView.swift @@ -69,8 +69,14 @@ struct ScheduleView: View { HStack(alignment: .top) { VStack(alignment: .leading, spacing: 6) { Text("Расписание") - .font(.system(size: 34, weight: .bold, design: .rounded)) - 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) } @@ -97,7 +103,7 @@ struct ScheduleView: View { Spacer() } .padding(17) - .mauGlass(radius: 22) + .mauSurface(radius: 22) ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 10) { @@ -110,8 +116,14 @@ struct ScheduleView: View { } if model.isLoading { - LoadingOverlay(title: "Загружаем расписание") - .frame(maxWidth: .infinity) + 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 { EmptyState(icon: "wifi.exclamationmark", title: "Не удалось загрузить", message: error) } else if model.items(for: model.selectedDate).isEmpty { @@ -158,13 +170,19 @@ private struct DateChip: View { } .foregroundStyle(selected ? .white : MauTheme.ink) .frame(width: 58, height: 68) - .background(selected ? AnyShapeStyle(MauTheme.blue.gradient) : AnyShapeStyle(MauTheme.card.opacity(0.82)), + .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) { @@ -174,13 +192,29 @@ private struct LessonCard: View { } .frame(width: 52) - RoundedRectangle(cornerRadius: 2) - .fill(MauTheme.blue) - .frame(width: 4, height: 72) + 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) { - Text(item.name ?? "Занятие") - .font(.headline) + 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) @@ -200,6 +234,31 @@ private struct LessonCard: View { Spacer() } .padding(17) - .mauGlass(radius: 22) + .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 index 5c719aa..af98b0c 100644 --- a/MauverseIOS/Sources/Views/ServicesView.swift +++ b/MauverseIOS/Sources/Views/ServicesView.swift @@ -39,6 +39,34 @@ enum MauService: String, CaseIterable, Identifiable { 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 { @@ -49,27 +77,45 @@ struct ServicesView: View { MauBackground() ScrollView { VStack(alignment: .leading, spacing: 20) { - Text("Сервисы").font(.system(size: 34, weight: .bold, design: .rounded)) - Text("Полезные инструменты для учёбы и жизни в университете") - .font(.subheadline) - .foregroundStyle(MauTheme.muted) + VStack(alignment: .leading, spacing: 6) { + Text("Сервисы").font(.system(size: 36, weight: .bold, design: .rounded)) + Text("Учёба и университет — в одном месте") + .font(.subheadline) + .foregroundStyle(MauTheme.muted) + } - LazyVGrid(columns: columns, spacing: 13) { - ForEach(MauService.allCases) { service in - NavigationLink(destination: ServiceDestination(service: service)) { - VStack(alignment: .leading, spacing: 18) { - IconTile(systemName: service.icon, color: service.color) - Text(service.title) - .font(.subheadline.weight(.semibold)) - .foregroundStyle(MauTheme.ink) - .multilineTextAlignment(.leading) - .frame(maxWidth: .infinity, minHeight: 38, alignment: .topLeading) + 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) } - .padding(17) - .frame(maxWidth: .infinity, minHeight: 132, alignment: .topLeading) + .buttonStyle(.plain) + .mauSurface(radius: 23) } - .buttonStyle(.plain) - .mauGlass(radius: 23) } } } diff --git a/MauverseIOS/project.yml b/MauverseIOS/project.yml index 5954cc5..835af12 100644 --- a/MauverseIOS/project.yml +++ b/MauverseIOS/project.yml @@ -28,8 +28,8 @@ targets: INFOPLIST_FILE: Resources/Info.plist GENERATE_INFOPLIST_FILE: NO ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon - MARKETING_VERSION: 1.8.8 - CURRENT_PROJECT_VERSION: 23 + MARKETING_VERSION: 1.9.0 + CURRENT_PROJECT_VERSION: 24 MAUVERSE_SCHEDULE_TOKEN: "" TARGETED_DEVICE_FAMILY: "1" SUPPORTS_MACCATALYST: NO From 36aff3f58ccff990c84f7173582253847cb5a1a2 Mon Sep 17 00:00:00 2001 From: D3C0Y Date: Wed, 29 Jul 2026 16:21:37 +0300 Subject: [PATCH 08/14] Fix news layout actions and refresh reliability --- .../Sources/Core/OfficialNewsService.swift | 21 +++++++- MauverseIOS/Sources/Views/HomeView.swift | 23 ++++++++- .../Sources/Views/InAppBrowserView.swift | 2 +- MauverseIOS/Sources/Views/NewsView.swift | 49 ++++++++++++++----- MauverseIOS/Sources/Views/ProfileView.swift | 4 +- MauverseIOS/project.yml | 4 +- 6 files changed, 83 insertions(+), 20 deletions(-) diff --git a/MauverseIOS/Sources/Core/OfficialNewsService.swift b/MauverseIOS/Sources/Core/OfficialNewsService.swift index 85a3165..36d7aec 100644 --- a/MauverseIOS/Sources/Core/OfficialNewsService.swift +++ b/MauverseIOS/Sources/Core/OfficialNewsService.swift @@ -21,9 +21,17 @@ final class OfficialNewsService { guard let url = URL(string: "https://mauniver.ru/\(path)") else { throw APIError.invalidResponse } - var request = URLRequest(url: url) + do { + return try await fetch(url: url, cachePolicy: .reloadRevalidatingCacheData) + } catch let error as URLError where Self.isTransient(error.code) { + return try await fetch(url: url, cachePolicy: .returnCacheDataElseLoad) + } + } + + 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.9.0 (iOS)", forHTTPHeaderField: "User-Agent") + request.setValue("MAUverse/1.9.1 (iOS)", forHTTPHeaderField: "User-Agent") request.setValue("application/rss+xml, application/xml, text/xml", forHTTPHeaderField: "Accept") let (data, response) = try await URLSession.shared.data(for: request) @@ -37,6 +45,15 @@ final class OfficialNewsService { let parser = RSSNewsParser() return try parser.parse(data) } + + private static func isTransient(_ code: URLError.Code) -> Bool { + return switch code { + case .timedOut, .networkConnectionLost, .cannotConnectToHost, .dnsLookupFailed: + true + default: + false + } + } } private final class RSSNewsParser: NSObject, XMLParserDelegate { diff --git a/MauverseIOS/Sources/Views/HomeView.swift b/MauverseIOS/Sources/Views/HomeView.swift index 655b854..3391c42 100644 --- a/MauverseIOS/Sources/Views/HomeView.swift +++ b/MauverseIOS/Sources/Views/HomeView.swift @@ -68,7 +68,21 @@ struct HomeView: View { quickActions if let news = model.featuredNews { - MauSectionHeader(title: "Главное в МАУ", action: "Все новости") + 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) } } @@ -285,7 +299,11 @@ private struct FeaturedNewsCard: View { ZStack(alignment: .bottomLeading) { AsyncImage(url: URL(string: item.image ?? "")) { phase in if case .success(let image) = phase { - image.resizable().scaledToFill() + image + .resizable() + .scaledToFill() + .frame(maxWidth: .infinity, maxHeight: .infinity) + .clipped() } else { MauTheme.heroGradient } @@ -306,6 +324,7 @@ private struct FeaturedNewsCard: View { } .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 index 2caaf3c..4c0bce3 100644 --- a/MauverseIOS/Sources/Views/InAppBrowserView.swift +++ b/MauverseIOS/Sources/Views/InAppBrowserView.swift @@ -23,7 +23,7 @@ final class InAppBrowserController: NSObject, ObservableObject, WKNavigationDele let configuration = WKWebViewConfiguration() configuration.websiteDataStore = .default() - configuration.applicationNameForUserAgent = "MAUverse/1.9.0" + configuration.applicationNameForUserAgent = "MAUverse/1.9.1" configuration.defaultWebpagePreferences.allowsContentJavaScript = true configuration.allowsInlineMediaPlayback = true webView = WKWebView(frame: .zero, configuration: configuration) diff --git a/MauverseIOS/Sources/Views/NewsView.swift b/MauverseIOS/Sources/Views/NewsView.swift index 2b5e286..9166637 100644 --- a/MauverseIOS/Sources/Views/NewsView.swift +++ b/MauverseIOS/Sources/Views/NewsView.swift @@ -6,16 +6,36 @@ final class NewsViewModel: ObservableObject { @Published var filter: NewsFilter = .all @Published var isLoading = false @Published var error: String? + private var activeRequest = UUID() func load() async { - isLoading = true + let request = UUID() + activeRequest = request + let requestedFilter = filter + isLoading = items.isEmpty error = nil - defer { isLoading = false } do { - items = try await OfficialNewsService.shared.load(filter: filter) + let loaded = try await OfficialNewsService.shared.load(filter: requestedFilter) + guard activeRequest == request, filter == requestedFilter else { return } + items = loaded } catch { - self.error = error.localizedDescription + 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 } } @@ -42,7 +62,7 @@ struct NewsView: View { model.filter = filter Task { await model.load() } } - .font(.subheadline.weight(.semibold)) + .font(.system(size: 14, weight: .semibold)) .foregroundStyle(model.filter == filter ? .white : MauTheme.ink) .padding(.horizontal, 15) .padding(.vertical, 10) @@ -120,10 +140,11 @@ private struct NewsCard: View { .tracking(0.7) .foregroundStyle(MauTheme.blue) Text(item.title ?? "Новость") - .font(.subheadline.bold()) + .font(.system(size: 15, weight: .semibold)) .multilineTextAlignment(.leading) .foregroundStyle(MauTheme.ink) - .lineLimit(4) + .lineLimit(3) + .minimumScaleFactor(0.9) Spacer(minLength: 0) if let date = item.publish { Text(date) @@ -160,7 +181,11 @@ private struct HeroNewsCard: View { if let source = item.image, let url = URL(string: source) { AsyncImage(url: url) { phase in if case .success(let image) = phase { - image.resizable().scaledToFill() + image + .resizable() + .scaledToFill() + .frame(maxWidth: .infinity, maxHeight: .infinity) + .clipped() } else { MauTheme.heroGradient } @@ -189,13 +214,14 @@ private struct HeroNewsCard: View { } } Text(item.title ?? "Новости университета") - .font(.system(size: 23, weight: .bold, design: .rounded)) + .font(.system(size: 20, weight: .bold, design: .rounded)) .foregroundStyle(.white) .multilineTextAlignment(.leading) - .lineLimit(4) + .lineLimit(3) + .minimumScaleFactor(0.86) if let description = item.description { Text(description.strippingHTML) - .font(.subheadline) + .font(.system(size: 13, weight: .regular)) .foregroundStyle(.white.opacity(0.75)) .lineLimit(2) .multilineTextAlignment(.leading) @@ -203,6 +229,7 @@ private struct HeroNewsCard: View { } .padding(20) } + .frame(maxWidth: .infinity) .frame(height: 330) .clipShape(RoundedRectangle(cornerRadius: MauRadius.hero, style: .continuous)) .shadow(color: .black.opacity(0.12), radius: 18, y: 10) diff --git a/MauverseIOS/Sources/Views/ProfileView.swift b/MauverseIOS/Sources/Views/ProfileView.swift index 695d4dc..5195708 100644 --- a/MauverseIOS/Sources/Views/ProfileView.swift +++ b/MauverseIOS/Sources/Views/ProfileView.swift @@ -72,7 +72,7 @@ struct ProfileView: View { .buttonStyle(.plain) .mauSurface() - Text("MAUverse 1.9.0 (24)") + Text("MAUverse 1.9.1 (25)") .font(.caption) .foregroundStyle(MauTheme.muted) } @@ -248,7 +248,7 @@ private struct SettingsView: View { Label("О приложении", systemImage: "info.circle.fill").font(.headline) Text("Нативное приложение Мурманского арктического университета.") .font(.subheadline).foregroundStyle(MauTheme.muted) - Text("Версия 1.9.0 • сборка 24").font(.caption) + Text("Версия 1.9.1 • сборка 25").font(.caption) } .frame(maxWidth: .infinity, alignment: .leading) .padding(20) diff --git a/MauverseIOS/project.yml b/MauverseIOS/project.yml index 835af12..ad8a282 100644 --- a/MauverseIOS/project.yml +++ b/MauverseIOS/project.yml @@ -28,8 +28,8 @@ targets: INFOPLIST_FILE: Resources/Info.plist GENERATE_INFOPLIST_FILE: NO ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon - MARKETING_VERSION: 1.9.0 - CURRENT_PROJECT_VERSION: 24 + MARKETING_VERSION: 1.9.1 + CURRENT_PROJECT_VERSION: 25 MAUVERSE_SCHEDULE_TOKEN: "" TARGETED_DEVICE_FAMILY: "1" SUPPORTS_MACCATALYST: NO From 33a02dba67d4435f1adb8060da5b7f300722c4dd Mon Sep 17 00:00:00 2001 From: D3C0Y Date: Wed, 29 Jul 2026 16:31:01 +0300 Subject: [PATCH 09/14] Harden news layout and polish remaining UX --- .../Sources/Core/OfficialNewsService.swift | 21 +++++++-- MauverseIOS/Sources/Views/HomeView.swift | 14 +++++- .../Sources/Views/InAppBrowserView.swift | 2 +- MauverseIOS/Sources/Views/NewsView.swift | 47 ++++++++++++------- MauverseIOS/Sources/Views/ProfileView.swift | 26 ++++++++-- MauverseIOS/Sources/Views/ScheduleView.swift | 2 +- MauverseIOS/Sources/Views/ServicesView.swift | 2 +- MauverseIOS/project.yml | 4 +- 8 files changed, 85 insertions(+), 33 deletions(-) diff --git a/MauverseIOS/Sources/Core/OfficialNewsService.swift b/MauverseIOS/Sources/Core/OfficialNewsService.swift index 36d7aec..3e173e0 100644 --- a/MauverseIOS/Sources/Core/OfficialNewsService.swift +++ b/MauverseIOS/Sources/Core/OfficialNewsService.swift @@ -1,7 +1,8 @@ import Foundation -final class OfficialNewsService { +actor OfficialNewsService { static let shared = OfficialNewsService() + private var cache: [NewsFilter: [NewsItem]] = [:] private init() {} @@ -22,16 +23,26 @@ final class OfficialNewsService { throw APIError.invalidResponse } do { - return try await fetch(url: url, cachePolicy: .reloadRevalidatingCacheData) - } catch let error as URLError where Self.isTransient(error.code) { - return try await fetch(url: url, cachePolicy: .returnCacheDataElseLoad) + 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 } + return loaded + } catch { + if let cached = cache[filter], !cached.isEmpty { + return cached + } + throw error } } 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.9.1 (iOS)", forHTTPHeaderField: "User-Agent") + request.setValue("MAUverse/1.9.2 (iOS)", forHTTPHeaderField: "User-Agent") request.setValue("application/rss+xml, application/xml, text/xml", forHTTPHeaderField: "Accept") let (data, response) = try await URLSession.shared.data(for: request) diff --git a/MauverseIOS/Sources/Views/HomeView.swift b/MauverseIOS/Sources/Views/HomeView.swift index 3391c42..9d2b607 100644 --- a/MauverseIOS/Sources/Views/HomeView.swift +++ b/MauverseIOS/Sources/Views/HomeView.swift @@ -27,7 +27,10 @@ final class HomeViewModel: ObservableObject { start: formatter.string(from: start), end: formatter.string(from: end) ) { - nextLesson = lessons.sorted(by: Self.isEarlier).first + nextLesson = lessons + .filter { Self.lessonDate($0) >= Date().addingTimeInterval(-60 * 15) } + .sorted(by: Self.isEarlier) + .first } } } @@ -37,6 +40,13 @@ final class HomeViewModel: ObservableObject { private static func isEarlier(_ lhs: ScheduleItem, _ rhs: ScheduleItem) -> Bool { "\(lhs.date ?? "") \(lhs.startTime ?? "")" < "\(rhs.date ?? "") \(rhs.startTime ?? "")" } + + 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 { @@ -88,7 +98,7 @@ struct HomeView: View { } .padding(.horizontal, 20) .padding(.top, 14) - .padding(.bottom, 24) + .padding(.bottom, 96) } .refreshable { await model.load(user: session.user) } } diff --git a/MauverseIOS/Sources/Views/InAppBrowserView.swift b/MauverseIOS/Sources/Views/InAppBrowserView.swift index 4c0bce3..fc7faf1 100644 --- a/MauverseIOS/Sources/Views/InAppBrowserView.swift +++ b/MauverseIOS/Sources/Views/InAppBrowserView.swift @@ -23,7 +23,7 @@ final class InAppBrowserController: NSObject, ObservableObject, WKNavigationDele let configuration = WKWebViewConfiguration() configuration.websiteDataStore = .default() - configuration.applicationNameForUserAgent = "MAUverse/1.9.1" + configuration.applicationNameForUserAgent = "MAUverse/1.9.2" configuration.defaultWebpagePreferences.allowsContentJavaScript = true configuration.allowsInlineMediaPlayback = true webView = WKWebView(frame: .zero, configuration: configuration) diff --git a/MauverseIOS/Sources/Views/NewsView.swift b/MauverseIOS/Sources/Views/NewsView.swift index 9166637..ea92d30 100644 --- a/MauverseIOS/Sources/Views/NewsView.swift +++ b/MauverseIOS/Sources/Views/NewsView.swift @@ -7,17 +7,22 @@ final class NewsViewModel: ObservableObject { @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 { @@ -94,7 +99,7 @@ struct NewsView: View { } } .padding(20) - .padding(.bottom, 12) + .padding(.bottom, 96) } .refreshable { await model.load() } } @@ -177,52 +182,54 @@ private struct HeroNewsCard: View { } private var content: some View { - ZStack(alignment: .bottomLeading) { + 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() - .frame(maxWidth: .infinity, maxHeight: .infinity) - .clipped() } 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) } - LinearGradient( - colors: [.clear, .black.opacity(0.2), .black.opacity(0.92)], - startPoint: .top, - endPoint: .bottom - ) - VStack(alignment: .leading, spacing: 9) { HStack { Text(category.uppercased()) .font(.caption2.bold()) .tracking(0.8) - .foregroundStyle(MauTheme.cyan) + .foregroundStyle(MauTheme.blue) Spacer() if let date = item.publish { Text(date) .font(.caption2) - .foregroundStyle(.white.opacity(0.7)) + .foregroundStyle(MauTheme.muted) + .lineLimit(1) } } Text(item.title ?? "Новости университета") - .font(.system(size: 20, weight: .bold, design: .rounded)) - .foregroundStyle(.white) + .font(.system(size: 19, weight: .bold, design: .rounded)) + .foregroundStyle(MauTheme.ink) .multilineTextAlignment(.leading) - .lineLimit(3) - .minimumScaleFactor(0.86) + .lineLimit(4) + .fixedSize(horizontal: false, vertical: true) if let description = item.description { Text(description.strippingHTML) .font(.system(size: 13, weight: .regular)) - .foregroundStyle(.white.opacity(0.75)) + .foregroundStyle(MauTheme.muted) .lineLimit(2) .multilineTextAlignment(.leading) } @@ -230,8 +237,12 @@ private struct HeroNewsCard: View { .padding(20) } .frame(maxWidth: .infinity) - .frame(height: 330) + .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) } } diff --git a/MauverseIOS/Sources/Views/ProfileView.swift b/MauverseIOS/Sources/Views/ProfileView.swift index 5195708..ae75820 100644 --- a/MauverseIOS/Sources/Views/ProfileView.swift +++ b/MauverseIOS/Sources/Views/ProfileView.swift @@ -72,12 +72,12 @@ struct ProfileView: View { .buttonStyle(.plain) .mauSurface() - Text("MAUverse 1.9.1 (25)") + Text("MAUverse 1.9.2 (26)") .font(.caption) .foregroundStyle(MauTheme.muted) } .padding(20) - .padding(.bottom, 12) + .padding(.bottom, 96) } } .navigationTitle("Профиль") @@ -248,7 +248,27 @@ private struct SettingsView: View { Label("О приложении", systemImage: "info.circle.fill").font(.headline) Text("Нативное приложение Мурманского арктического университета.") .font(.subheadline).foregroundStyle(MauTheme.muted) - Text("Версия 1.9.1 • сборка 25").font(.caption) + 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.9.2 • сборка 26").font(.caption) } .frame(maxWidth: .infinity, alignment: .leading) .padding(20) diff --git a/MauverseIOS/Sources/Views/ScheduleView.swift b/MauverseIOS/Sources/Views/ScheduleView.swift index a734f52..a2f69d6 100644 --- a/MauverseIOS/Sources/Views/ScheduleView.swift +++ b/MauverseIOS/Sources/Views/ScheduleView.swift @@ -137,7 +137,7 @@ struct ScheduleView: View { } } .padding(20) - .padding(.bottom, 12) + .padding(.bottom, 96) } .refreshable { await reload() } } diff --git a/MauverseIOS/Sources/Views/ServicesView.swift b/MauverseIOS/Sources/Views/ServicesView.swift index af98b0c..507048c 100644 --- a/MauverseIOS/Sources/Views/ServicesView.swift +++ b/MauverseIOS/Sources/Views/ServicesView.swift @@ -120,7 +120,7 @@ struct ServicesView: View { } } .padding(20) - .padding(.bottom, 12) + .padding(.bottom, 96) } } .navigationBarTitleDisplayMode(.inline) diff --git a/MauverseIOS/project.yml b/MauverseIOS/project.yml index ad8a282..8f64509 100644 --- a/MauverseIOS/project.yml +++ b/MauverseIOS/project.yml @@ -28,8 +28,8 @@ targets: INFOPLIST_FILE: Resources/Info.plist GENERATE_INFOPLIST_FILE: NO ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon - MARKETING_VERSION: 1.9.1 - CURRENT_PROJECT_VERSION: 25 + MARKETING_VERSION: 1.9.2 + CURRENT_PROJECT_VERSION: 26 MAUVERSE_SCHEDULE_TOKEN: "" TARGETED_DEVICE_FAMILY: "1" SUPPORTS_MACCATALYST: NO From c90b8f074970d9ee9d5843f45360972c19a7b898 Mon Sep 17 00:00:00 2001 From: D3C0Y Date: Wed, 29 Jul 2026 23:33:39 +0300 Subject: [PATCH 10/14] Complete native data flows and harden API handling --- MauverseIOS/Sources/Core/APIClient.swift | 238 ++++++++- MauverseIOS/Sources/Core/Models.swift | 243 ++++++++- .../Sources/Core/OfficialNewsService.swift | 69 ++- MauverseIOS/Sources/Core/SessionStore.swift | 19 +- MauverseIOS/Sources/Views/HomeView.swift | 87 +++- .../Sources/Views/InAppBrowserView.swift | 2 +- MauverseIOS/Sources/Views/NewsView.swift | 6 +- MauverseIOS/Sources/Views/ProfileView.swift | 29 +- MauverseIOS/Sources/Views/ScheduleView.swift | 27 +- MauverseIOS/Sources/Views/ServicesView.swift | 490 ++++++++++++++++-- MauverseIOS/project.yml | 4 +- 11 files changed, 1103 insertions(+), 111 deletions(-) diff --git a/MauverseIOS/Sources/Core/APIClient.swift b/MauverseIOS/Sources/Core/APIClient.swift index bb68929..55c08b5 100644 --- a/MauverseIOS/Sources/Core/APIClient.swift +++ b/MauverseIOS/Sources/Core/APIClient.swift @@ -3,11 +3,13 @@ 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 } } } @@ -40,19 +42,22 @@ final class APIClient { var request = URLRequest(url: components.url!) request.httpMethod = "GET" addHeaders(to: &request, user: user) - return try await execute(request) + return try await execute(request, retryOnTransient: true) } func post( _ path: String, body: Body, - user: UserDTO? = nil + user: UserDTO? = nil, + retryOnTransient: Bool = false ) async throws -> Response { var request = URLRequest(url: baseURL.appendingPathComponent(path)) request.httpMethod = "POST" - request.httpBody = try JSONEncoder().encode(body) + let encoder = JSONEncoder() + encoder.keyEncodingStrategy = .convertToSnakeCase + request.httpBody = try encoder.encode(body) addHeaders(to: &request, user: user) - return try await execute(request) + return try await execute(request, retryOnTransient: retryOnTransient) } private func addHeaders(to request: inout URLRequest, user: UserDTO?) { @@ -66,24 +71,100 @@ final class APIClient { } } - private func execute(_ request: URLRequest) async throws -> Response { - let (data, response) = try await session.data(for: request) + private func execute( + _ request: URLRequest, + retryOnTransient: Bool + ) async throws -> Response { + let data: Data + let response: URLResponse + do { + (data, response) = try await data(for: 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 || http.statusCode == 403 { - NotificationCenter.default.post(name: .mauverseSessionExpired, object: nil) - throw APIError.server("Сессия истекла. Войдите в аккаунт повторно") + 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 { - let detail = (try? decoder.decode(APIMessage.self, from: data)) - throw APIError.server(detail?.detail ?? detail?.error ?? "Ошибка сервера: \(http.statusCode)") + 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("Не удалось прочитать ответ сервера") + throw APIError.server("Сервис вернул данные в новом формате. Обновите приложение") } } + private func data( + for 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 @@ -91,10 +172,15 @@ final class APIClient { } } -final class ScheduleAPIClient { +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 @@ -104,13 +190,37 @@ final class ScheduleAPIClient { } func schedule(uid: String, start: String, end: String) async throws -> [ScheduleItem] { - let response: ScheduleResponse = try await get( - "groups/\(escaped(uid))/schedule/\(start)/\(end)" - ) - return response.timetable.map(\.appItem) + 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 { @@ -119,9 +229,15 @@ final class ScheduleAPIClient { ) result.append(contentsOf: response.groups) } - return result.sorted { + 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? { @@ -137,6 +253,17 @@ final class ScheduleAPIClient { 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] = [] @@ -156,15 +283,19 @@ final class ScheduleAPIClient { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") request.setValue("application/json", forHTTPHeaderField: "Accept") - let (data, response) = try await session.data(for: request) + 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: String - switch http.statusCode { - case 401, 403: message = "Токен API расписания недействителен" - case 404: message = "Данные расписания не найдены" - case 422: message = "Сервер не принял параметры запроса" - default: message = "Ошибка API расписания: \(http.statusCode)" + let message = switch http.statusCode { + case 401, 403: "Токен API расписания недействителен" + case 404: "Данные расписания не найдены" + default: APIClient.serverMessage(status: http.statusCode, data: data) } throw APIError.server(message) } @@ -172,8 +303,46 @@ final class ScheduleAPIClient { let decoder = JSONDecoder() return try decoder.decode(Response.self, from: data) } catch { - throw APIError.server("Новый API вернул неизвестный формат данных") + 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 { @@ -181,11 +350,26 @@ final class ScheduleAPIClient { } } +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 } +struct DebtRequest: Encodable { + let creditBook: String + var semesterNumber: Int? = nil +} struct ScheduleRequest: Encodable { let startDate: String let endDate: String diff --git a/MauverseIOS/Sources/Core/Models.swift b/MauverseIOS/Sources/Core/Models.swift index 39e82ca..93a9b00 100644 --- a/MauverseIOS/Sources/Core/Models.swift +++ b/MauverseIOS/Sources/Core/Models.swift @@ -13,8 +13,8 @@ struct UserDTO: Codable, Equatable { var fullName: String? var role: Int? var creditBook: String? - var groupId: Int? - var subGroupId: Int? + var groupId: String? + var subGroupId: String? var scheduleGroupUID: String? var groupName: String? var speciality: String? @@ -27,6 +27,117 @@ struct UserDTO: Codable, Equatable { let value = fullName ?? firstName ?? username ?? "Студент" return value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "Студент" : value } + + 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 { @@ -74,12 +185,22 @@ struct ScheduleAPIItem: Decodable { teacher: teacher, room: room, pairType: type, - startTime: times.first, - endTime: times.count > 1 ? times[1] : nil, + 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 { @@ -103,7 +224,7 @@ struct ScheduleGroupsResponse: Decodable { let groups: [ScheduleGroup] } -struct ScheduleGroup: Decodable, Identifiable, Hashable { +struct ScheduleGroup: Codable, Identifiable, Hashable { let groupId: Int let group: String let speciality: String? @@ -166,12 +287,24 @@ struct SubGroupDTO: Codable { var subGroups: [SubGroupItem]? } -struct Department: Codable, Identifiable, Hashable { +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: Codable, Identifiable, Hashable { +struct Telephone: Decodable, Identifiable, Hashable { var title: String? var person: String? var phone: String? @@ -181,6 +314,28 @@ struct Telephone: Codable, Identifiable, Hashable { 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 { @@ -191,6 +346,15 @@ struct Debt: Codable, Identifiable, Hashable { 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 { @@ -199,6 +363,49 @@ struct Semester: Codable, Identifiable, Hashable { 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 Teacher: Codable, Identifiable, Hashable { @@ -235,3 +442,25 @@ enum NewsFilter: Int, CaseIterable, Identifiable { } } } + +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 index 3e173e0..59aa61d 100644 --- a/MauverseIOS/Sources/Core/OfficialNewsService.swift +++ b/MauverseIOS/Sources/Core/OfficialNewsService.swift @@ -3,10 +3,14 @@ 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/" @@ -29,7 +33,10 @@ actor OfficialNewsService { } catch let error as URLError where Self.isTransient(error.code) { loaded = try await fetch(url: url, cachePolicy: .returnCacheDataElseLoad) } - if !loaded.isEmpty { cache[filter] = loaded } + if !loaded.isEmpty { + cache[filter] = loaded + persist(loaded, for: filter) + } return loaded } catch { if let cached = cache[filter], !cached.isEmpty { @@ -39,10 +46,17 @@ actor OfficialNewsService { } } + 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.9.2 (iOS)", forHTTPHeaderField: "User-Agent") + request.setValue("MAUverse/1.10.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) @@ -55,6 +69,8 @@ actor OfficialNewsService { 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 { @@ -65,6 +81,17 @@ actor OfficialNewsService { 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 { @@ -103,7 +130,10 @@ private final class RSSNewsParser: NSObject, XMLParserDelegate { } else if elementName == "enclosure", let url = attributeDict["url"], attributeDict["type"]?.hasPrefix("image/") != false { - current?.image = url + current?.image = Self.absoluteURL(url) + } else if (elementName == "media:content" || elementName == "media:thumbnail"), + let url = attributeDict["url"] { + current?.image = Self.absoluteURL(url) } } @@ -121,8 +151,12 @@ private final class RSSNewsParser: NSObject, XMLParserDelegate { let value = text.trimmingCharacters(in: .whitespacesAndNewlines) switch elementName { case "title": current?.title = value.cleanedRSS - case "link": current?.link = value - case "description": current?.description = 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) } @@ -149,6 +183,24 @@ private final class RSSNewsParser: NSObject, XMLParserDelegate { .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 { @@ -164,3 +216,10 @@ private extension String { .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 index 5a1df55..1e9d6b9 100644 --- a/MauverseIOS/Sources/Core/SessionStore.swift +++ b/MauverseIOS/Sources/Core/SessionStore.swift @@ -39,7 +39,8 @@ final class SessionStore: ObservableObject { } func signIn(username: String, password: String) async { - guard !username.isEmpty, !password.isEmpty else { + let normalizedUsername = username.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalizedUsername.isEmpty, !password.isEmpty else { errorMessage = "Введите логин и пароль" return } @@ -47,12 +48,20 @@ final class SessionStore: ObservableObject { errorMessage = nil defer { isBusy = false } do { - let authenticated: UserDTO = try await APIClient.shared.post( + var authenticated: UserDTO = try await APIClient.shared.post( "auth", - body: LoginRequest(username: username, password: password) + body: LoginRequest(username: normalizedUsername, password: password) ) - if let error = authenticated.error ?? authenticated.detail { - throw APIError.server(error) + 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() diff --git a/MauverseIOS/Sources/Views/HomeView.swift b/MauverseIOS/Sources/Views/HomeView.swift index 9d2b607..e653983 100644 --- a/MauverseIOS/Sources/Views/HomeView.swift +++ b/MauverseIOS/Sources/Views/HomeView.swift @@ -5,40 +5,61 @@ final class HomeViewModel: ObservableObject { @Published var nextLesson: ScheduleItem? @Published var featuredNews: NewsItem? @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 - defer { isLoading = false } + lessonError = nil + newsError = nil + defer { + if activeRequest == request { isLoading = false } + } - async let newsTask = try? OfficialNewsService.shared.load(filter: .all) + let newsTask = Task { try await OfficialNewsService.shared.load(filter: .all) } if let user { - 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 { - 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 - if let lessons = try? await ScheduleAPIClient.shared.schedule( + 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 } } - featuredNews = await newsTask?.first + do { + let loadedNews = try await newsTask.value.first + guard activeRequest == request else { return } + featuredNews = loadedNews + } catch { + guard activeRequest == request else { return } + newsError = error.localizedDescription + } } private static func isEarlier(_ lhs: ScheduleItem, _ rhs: ScheduleItem) -> Bool { - "\(lhs.date ?? "") \(lhs.startTime ?? "")" < "\(rhs.date ?? "") \(rhs.startTime ?? "")" + lessonDate(lhs) < lessonDate(rhs) } private static func lessonDate(_ item: ScheduleItem) -> Date { @@ -70,6 +91,12 @@ struct HomeView: View { 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) } @@ -94,6 +121,12 @@ struct HomeView: View { } } FeaturedNewsCard(item: news) + } else if let error = model.newsError { + HomeDataErrorCard( + title: "Новости не обновились", + message: error, + icon: "newspaper" + ) } } .padding(.horizontal, 20) @@ -195,6 +228,28 @@ struct HomeView: View { } } +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? diff --git a/MauverseIOS/Sources/Views/InAppBrowserView.swift b/MauverseIOS/Sources/Views/InAppBrowserView.swift index fc7faf1..31ae656 100644 --- a/MauverseIOS/Sources/Views/InAppBrowserView.swift +++ b/MauverseIOS/Sources/Views/InAppBrowserView.swift @@ -23,7 +23,7 @@ final class InAppBrowserController: NSObject, ObservableObject, WKNavigationDele let configuration = WKWebViewConfiguration() configuration.websiteDataStore = .default() - configuration.applicationNameForUserAgent = "MAUverse/1.9.2" + configuration.applicationNameForUserAgent = "MAUverse/1.10.0" configuration.defaultWebpagePreferences.allowsContentJavaScript = true configuration.allowsInlineMediaPlayback = true webView = WKWebView(frame: .zero, configuration: configuration) diff --git a/MauverseIOS/Sources/Views/NewsView.swift b/MauverseIOS/Sources/Views/NewsView.swift index ea92d30..52f0f37 100644 --- a/MauverseIOS/Sources/Views/NewsView.swift +++ b/MauverseIOS/Sources/Views/NewsView.swift @@ -84,7 +84,11 @@ struct NewsView: View { SkeletonCard() } } else if let error = model.error { - EmptyState(icon: "wifi.exclamationmark", title: "Новости недоступны", message: 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 { diff --git a/MauverseIOS/Sources/Views/ProfileView.swift b/MauverseIOS/Sources/Views/ProfileView.swift index ae75820..7e40e86 100644 --- a/MauverseIOS/Sources/Views/ProfileView.swift +++ b/MauverseIOS/Sources/Views/ProfileView.swift @@ -1,4 +1,5 @@ import SwiftUI +import WebKit struct ProfileView: View { @EnvironmentObject private var session: SessionStore @@ -72,7 +73,7 @@ struct ProfileView: View { .buttonStyle(.plain) .mauSurface() - Text("MAUverse 1.9.2 (26)") + Text("MAUverse 1.10.0 (27)") .font(.caption) .foregroundStyle(MauTheme.muted) } @@ -191,7 +192,7 @@ private struct ProfileEditor: View { } session.update { $0.groupName = resolved?.group ?? normalized - $0.groupId = resolved?.groupId + $0.groupId = resolved.map { String($0.groupId) } $0.scheduleGroupUID = resolved?.uid $0.speciality = resolved?.speciality ?? $0.speciality $0.creditBook = creditBook.trimmingCharacters(in: .whitespacesAndNewlines) @@ -235,8 +236,7 @@ private struct SettingsView: View { Text("Изображения новостей и веб-страниц очищаются системой автоматически.") .font(.subheadline).foregroundStyle(MauTheme.muted) Button(cleared ? "Готово" : "Очистить временные данные") { - URLCache.shared.removeAllCachedResponses() - cleared = true + clearCaches() } .buttonStyle(.borderedProminent) } @@ -268,15 +268,34 @@ private struct SettingsView: View { .foregroundStyle(MauTheme.blue) } } - Text("Версия 1.9.2 • сборка 26").font(.caption) + Text("Версия 1.10.0 • сборка 27").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/ScheduleView.swift b/MauverseIOS/Sources/Views/ScheduleView.swift index a2f69d6..d882a29 100644 --- a/MauverseIOS/Sources/Views/ScheduleView.swift +++ b/MauverseIOS/Sources/Views/ScheduleView.swift @@ -6,16 +6,25 @@ final class ScheduleViewModel: ObservableObject { @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? { - guard let user else { return nil } + let request = UUID() + activeRequest = request + guard let user else { + error = "Необходимо войти в аккаунт" + items = [] + return nil + } isLoading = true error = nil - defer { isLoading = false } + defer { + if activeRequest == request { isLoading = false } + } let formatter = DateFormatter() formatter.locale = Locale(identifier: "en_US_POSIX") formatter.dateFormat = "yyyy-MM-dd" @@ -29,13 +38,16 @@ final class ScheduleViewModel: ObservableObject { guard let uid, !uid.isEmpty else { throw APIError.server("Выберите существующую учебную группу в профиле") } - items = try await ScheduleAPIClient.shared.schedule( + 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 } @@ -88,6 +100,7 @@ struct ScheduleView: View { } .buttonStyle(.plain) .mauGlass(radius: 16) + .disabled(model.isLoading) } HStack(spacing: 12) { @@ -125,7 +138,11 @@ struct ScheduleView: View { } } } else if let error = model.error { - EmptyState(icon: "wifi.exclamationmark", title: "Не удалось загрузить", message: 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 { @@ -149,7 +166,7 @@ struct ScheduleView: View { if let group = await model.load(user: session.user) { session.update { $0.scheduleGroupUID = group.uid - $0.groupId = group.groupId + $0.groupId = String(group.groupId) $0.groupName = group.group $0.speciality = group.speciality } diff --git a/MauverseIOS/Sources/Views/ServicesView.swift b/MauverseIOS/Sources/Views/ServicesView.swift index 507048c..f838af1 100644 --- a/MauverseIOS/Sources/Views/ServicesView.swift +++ b/MauverseIOS/Sources/Views/ServicesView.swift @@ -168,9 +168,54 @@ private struct WebServiceView: View { private struct FormsView: View { private let forms = [ - ("Справка об обучении", "doc.badge.plus"), - ("Выписка об успеваемости", "list.clipboard.fill"), - ("Обращение в деканат", "envelope.fill") + OnlineFormLink( + title: "Справка об обучении", + subtitle: "Отправить заявку внутри приложения", + icon: "doc.badge.plus", + url: nil + ), + 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: "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/" + ) ] var body: some View { @@ -178,17 +223,26 @@ private struct FormsView: View { MauBackground() ScrollView { VStack(spacing: 13) { - ForEach(forms, id: \.0) { form in - HStack(spacing: 14) { - IconTile(systemName: form.1) - Text(form.0).font(.headline) - Spacer() - Image(systemName: "chevron.right").foregroundStyle(MauTheme.muted) + 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) } - .padding(17) + .buttonStyle(.plain) .mauGlass(radius: 22) } - Text("Отправка формы откроется после выбора типа документа в ЭИОС.") + Text("Все страницы открываются во внутреннем браузере MAUverse.") .font(.footnote) .foregroundStyle(MauTheme.muted) .padding() @@ -198,6 +252,232 @@ private struct FormsView: View { } .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 { + CertificateRequestView() + } + } +} + +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 } } @@ -240,6 +520,7 @@ private struct CampusView: View { .scrollContentBackground(.hidden) } .navigationTitle("Корпуса") + .toolbar(.hidden, for: .tabBar) } } @@ -280,19 +561,41 @@ private final class DebtsModel: ObservableObject { @Published var error: String? func load(user: UserDTO?) async { - guard let user, let creditBook = user.creditBook, !creditBook.isEmpty else { + guard let user, + let creditBook = user.creditBook?.trimmed, + !creditBook.isEmpty else { error = "Укажите номер зачётной книжки в профиле" + semesters = [] return } isLoading = true error = nil defer { isLoading = false } do { - semesters = try await APIClient.shared.post( + let response: SemesterResponse = try await APIClient.shared.post( "get_semesters", body: DebtRequest(creditBook: creditBook), - user: user + 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 } } } @@ -309,7 +612,11 @@ private struct DebtsView: View { if model.isLoading { LoadingOverlay(title: "Проверяем задолженности") } else if let error = model.error { - EmptyState(icon: "exclamationmark.circle", title: "Нет данных", message: 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 { @@ -337,9 +644,12 @@ private struct DebtsView: View { } } .padding(20) + .padding(.bottom, 30) } + .refreshable { await model.load(user: session.user) } } .navigationTitle("Задолженности") + .toolbar(.hidden, for: .tabBar) .task { await model.load(user: session.user) } } } @@ -349,14 +659,24 @@ 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 { - guard !text.trimmingCharacters(in: .whitespaces).isEmpty else { return } + let request = UUID() + activeRequest = request + let query = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard query.count >= 2 else { + teachers = [] + error = "Введите минимум две буквы фамилии" + return + } isLoading = true error = nil - defer { isLoading = false } + defer { + if activeRequest == request { isLoading = false } + } do { - teachers = try await ScheduleAPIClient.shared.teachers(matching: text).map { + let loaded = try await ScheduleAPIClient.shared.teachers(matching: query).map { Teacher( id: $0.teacherId, teacherId: $0.teacherId, @@ -368,7 +688,12 @@ private final class TeachersModel: ObservableObject { extras: $0.uid ) } - } catch { self.error = error.localizedDescription } + guard activeRequest == request else { return } + teachers = loaded + } catch { + guard activeRequest == request else { return } + self.error = error.localizedDescription + } } } @@ -384,15 +709,27 @@ private struct TeacherContactsView: View { 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) { @@ -406,11 +743,13 @@ private struct TeacherContactsView: View { } } } + .scrollDismissesKeyboard(.interactively) } } .padding(20) } .navigationTitle("Преподаватели") + .toolbar(.hidden, for: .tabBar) } } @@ -418,21 +757,44 @@ private struct TeacherContactsView: View { 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 { - do { departments = try await APIClient.shared.get("get_depts_json", user: user) } + 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 { - contacts = try await APIClient.shared.post( + let loaded: [Telephone] = try await APIClient.shared.post( "get_contacts_json", body: DepartmentRequest(departmentId: department.id ?? 0, name: department.name ?? ""), - user: user + user: user, + retryOnTransient: true ) - } catch { self.error = error.localizedDescription } + guard activeContactsRequest == request else { return } + contacts = loaded + } catch { + guard activeContactsRequest == request else { return } + self.error = error.localizedDescription + } } } @@ -444,34 +806,67 @@ private struct DepartmentsView: View { var body: some View { ZStack { MauBackground() - 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) + 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) } - .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 let error = model.error { + 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 ) } @@ -490,6 +885,7 @@ private struct ContactCard: View { let title: String let subtitle: String? let phone: String? + var phone2: String? = nil let email: String? var body: some View { @@ -499,12 +895,17 @@ private struct ContactCard: View { Text(subtitle).font(.caption).foregroundStyle(MauTheme.muted) } HStack { - if let phone, !phone.isEmpty { - Link(destination: URL(string: "tel:\(phone.filter { $0.isNumber || $0 == "+" })")!) { + if let phone, let destination = phoneURL(phone) { + Link(destination: destination) { Label(phone, systemImage: "phone.fill") } } - if let email, let url = URL(string: "mailto:\(email)") { + 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") } } } @@ -515,4 +916,19 @@ private struct ContactCard: View { .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 index 8f64509..0580268 100644 --- a/MauverseIOS/project.yml +++ b/MauverseIOS/project.yml @@ -28,8 +28,8 @@ targets: INFOPLIST_FILE: Resources/Info.plist GENERATE_INFOPLIST_FILE: NO ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon - MARKETING_VERSION: 1.9.2 - CURRENT_PROJECT_VERSION: 26 + MARKETING_VERSION: 1.10.0 + CURRENT_PROJECT_VERSION: 27 MAUVERSE_SCHEDULE_TOKEN: "" TARGETED_DEVICE_FAMILY: "1" SUPPORTS_MACCATALYST: NO From 36bfeb0931b1b19036d19def63dbb6dba551f2a4 Mon Sep 17 00:00:00 2001 From: D3C0Y Date: Wed, 29 Jul 2026 23:35:30 +0300 Subject: [PATCH 11/14] Fix API request helper name collision --- MauverseIOS/Sources/Core/APIClient.swift | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/MauverseIOS/Sources/Core/APIClient.swift b/MauverseIOS/Sources/Core/APIClient.swift index 55c08b5..bc906e2 100644 --- a/MauverseIOS/Sources/Core/APIClient.swift +++ b/MauverseIOS/Sources/Core/APIClient.swift @@ -78,7 +78,10 @@ final class APIClient { let data: Data let response: URLResponse do { - (data, response) = try await data(for: request, retryOnTransient: retryOnTransient) + (data, response) = try await performRequest( + request, + retryOnTransient: retryOnTransient + ) } catch let error as URLError { throw APIError.network(Self.networkMessage(for: error)) } @@ -100,8 +103,8 @@ final class APIClient { } } - private func data( - for request: URLRequest, + private func performRequest( + _ request: URLRequest, retryOnTransient: Bool ) async throws -> (Data, URLResponse) { let attempts = retryOnTransient ? 2 : 1 From 4b1e79d9b86b7e7a0586c763b37028538ecb056b Mon Sep 17 00:00:00 2001 From: D3C0Y Date: Thu, 30 Jul 2026 15:41:09 +0300 Subject: [PATCH 12/14] Deliver cross-platform MAUverse 1.11 feature parity --- .github/workflows/ci.yml | 4 + MauverseIOS/Sources/Core/APIClient.swift | 13 +- MauverseIOS/Sources/Core/Models.swift | 68 +++- .../Sources/Core/OfficialNewsService.swift | 2 +- MauverseIOS/Sources/Core/SessionStore.swift | 31 ++ MauverseIOS/Sources/Views/HomeView.swift | 72 +++- .../Sources/Views/InAppBrowserView.swift | 2 +- MauverseIOS/Sources/Views/ProfileView.swift | 4 +- MauverseIOS/Sources/Views/ServicesView.swift | 74 +++- MauverseIOS/project.yml | 4 +- mauverse_mobile.Tests/UserGreetingTests.cs | 31 ++ .../mauverse_mobile.Tests.csproj | 2 + mauverse_mobile/DTOModels/UserDTO.cs | 33 +- mauverse_mobile/DTOModels/UserInfoDTO.cs | 14 +- mauverse_mobile/Pages/NewsPage.xaml | 15 +- mauverse_mobile/Pages/OnlineFormsPage.xaml | 2 +- .../Utils/API/Interaface/IParserRequests.cs | 7 +- mauverse_mobile/Utils/API/ParserRequests.cs | 37 +- mauverse_mobile/Utils/API/ScheduleRequests.cs | 359 +++++++++++++----- .../Services/BrowserDestinationRegistry.cs | 8 +- mauverse_mobile/Utils/UserGreeting.cs | 29 ++ mauverse_mobile/ViewModel/MainViewModel.cs | 16 +- .../ViewModel/News/NewsViewModel.cs | 96 +++-- .../Services/OnlineFormsViewModel.cs | 68 +++- .../ViewModel/SettingsViewModel.cs | 2 +- mauverse_mobile/mau.csproj | 19 +- 26 files changed, 802 insertions(+), 210 deletions(-) create mode 100644 mauverse_mobile.Tests/UserGreetingTests.cs create mode 100644 mauverse_mobile/Utils/UserGreeting.cs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ef59993..ad85413 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -104,6 +104,7 @@ jobs: timeout-minutes: 45 env: NUGET_PACKAGES: ${{ github.workspace }}/.nuget/packages + MAUVERSE_SCHEDULE_TOKEN: ${{ secrets.MAUVERSE_SCHEDULE_TOKEN }} defaults: run: working-directory: mauverse_mobile @@ -144,6 +145,8 @@ jobs: run: dotnet test ../mauverse_mobile.Tests/mauverse_mobile.Tests.csproj --configuration Release --no-restore --warnaserror - name: Build Release configuration + env: + MAUVERSE_SCHEDULE_TOKEN: ${{ secrets.MAUVERSE_SCHEDULE_TOKEN }} run: >- dotnet build mau.csproj --configuration Release @@ -152,6 +155,7 @@ jobs: --no-restore --warnaserror -p:ContinuousIntegrationBuild=true + -p:MauverseScheduleToken="$MAUVERSE_SCHEDULE_TOKEN" -maxcpucount:1 - name: Create unsigned artifact checksums diff --git a/MauverseIOS/Sources/Core/APIClient.swift b/MauverseIOS/Sources/Core/APIClient.swift index bc906e2..b3eefd6 100644 --- a/MauverseIOS/Sources/Core/APIClient.swift +++ b/MauverseIOS/Sources/Core/APIClient.swift @@ -18,8 +18,6 @@ extension Notification.Name { static let mauverseSessionExpired = Notification.Name("mauverse.session.expired") } -struct EmptyBody: Encodable {} - final class APIClient { static let shared = APIClient() private let baseURL = URL(string: "https://app.mauniver.ru/dev/mauverse/")! @@ -60,6 +58,17 @@ final class APIClient { 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") diff --git a/MauverseIOS/Sources/Core/Models.swift b/MauverseIOS/Sources/Core/Models.swift index 93a9b00..98a071c 100644 --- a/MauverseIOS/Sources/Core/Models.swift +++ b/MauverseIOS/Sources/Core/Models.swift @@ -24,10 +24,29 @@ struct UserDTO: Codable, Equatable { var detail: String? var displayName: String { - let value = fullName ?? firstName ?? username ?? "Студент" + 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 @@ -408,6 +427,53 @@ struct SuccessResponse: Codable { 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? diff --git a/MauverseIOS/Sources/Core/OfficialNewsService.swift b/MauverseIOS/Sources/Core/OfficialNewsService.swift index 59aa61d..2cbbc0a 100644 --- a/MauverseIOS/Sources/Core/OfficialNewsService.swift +++ b/MauverseIOS/Sources/Core/OfficialNewsService.swift @@ -56,7 +56,7 @@ actor OfficialNewsService { private func fetch(url: URL, cachePolicy: URLRequest.CachePolicy) async throws -> [NewsItem] { var request = URLRequest(url: url, cachePolicy: cachePolicy, timeoutInterval: 30) request.timeoutInterval = 30 - request.setValue("MAUverse/1.10.0 (iOS)", forHTTPHeaderField: "User-Agent") + 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) diff --git a/MauverseIOS/Sources/Core/SessionStore.swift b/MauverseIOS/Sources/Core/SessionStore.swift index 1e9d6b9..38bf393 100644 --- a/MauverseIOS/Sources/Core/SessionStore.swift +++ b/MauverseIOS/Sources/Core/SessionStore.swift @@ -30,6 +30,9 @@ final class SessionStore: ObservableObject { self?.errorMessage = "Сессия истекла. Войдите в аккаунт повторно" } } + if user != nil { + Task { await refreshProfile() } + } } deinit { @@ -65,6 +68,7 @@ final class SessionStore: ObservableObject { } user = authenticated persist() + await refreshProfile() } catch { errorMessage = error.localizedDescription } @@ -83,6 +87,33 @@ final class SessionStore: ObservableObject { 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/Views/HomeView.swift b/MauverseIOS/Sources/Views/HomeView.swift index e653983..3135dad 100644 --- a/MauverseIOS/Sources/Views/HomeView.swift +++ b/MauverseIOS/Sources/Views/HomeView.swift @@ -4,6 +4,7 @@ import SwiftUI 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? @@ -20,6 +21,15 @@ final class HomeViewModel: ObservableObject { } 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 @@ -56,6 +66,9 @@ final class HomeViewModel: ObservableObject { 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 { @@ -75,9 +88,7 @@ struct HomeView: View { @StateObject private var model = HomeViewModel() private var firstName: String { - session.user?.firstName?.split(separator: " ").first.map(String.init) - ?? session.user?.displayName.split(separator: " ").first.map(String.init) - ?? "Студент" + session.user?.greetingName ?? "Студент" } var body: some View { @@ -104,6 +115,17 @@ struct HomeView: View { 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("Главное в МАУ") @@ -228,6 +250,50 @@ struct HomeView: View { } } +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 diff --git a/MauverseIOS/Sources/Views/InAppBrowserView.swift b/MauverseIOS/Sources/Views/InAppBrowserView.swift index 31ae656..b5c0fd5 100644 --- a/MauverseIOS/Sources/Views/InAppBrowserView.swift +++ b/MauverseIOS/Sources/Views/InAppBrowserView.swift @@ -23,7 +23,7 @@ final class InAppBrowserController: NSObject, ObservableObject, WKNavigationDele let configuration = WKWebViewConfiguration() configuration.websiteDataStore = .default() - configuration.applicationNameForUserAgent = "MAUverse/1.10.0" + configuration.applicationNameForUserAgent = "MAUverse/1.11.0" configuration.defaultWebpagePreferences.allowsContentJavaScript = true configuration.allowsInlineMediaPlayback = true webView = WKWebView(frame: .zero, configuration: configuration) diff --git a/MauverseIOS/Sources/Views/ProfileView.swift b/MauverseIOS/Sources/Views/ProfileView.swift index 7e40e86..a053ff4 100644 --- a/MauverseIOS/Sources/Views/ProfileView.swift +++ b/MauverseIOS/Sources/Views/ProfileView.swift @@ -73,7 +73,7 @@ struct ProfileView: View { .buttonStyle(.plain) .mauSurface() - Text("MAUverse 1.10.0 (27)") + Text("MAUverse 1.11.0 (28)") .font(.caption) .foregroundStyle(MauTheme.muted) } @@ -268,7 +268,7 @@ private struct SettingsView: View { .foregroundStyle(MauTheme.blue) } } - Text("Версия 1.10.0 • сборка 27").font(.caption) + Text("Версия 1.11.0 • сборка 28").font(.caption) } .frame(maxWidth: .infinity, alignment: .leading) .padding(20) diff --git a/MauverseIOS/Sources/Views/ServicesView.swift b/MauverseIOS/Sources/Views/ServicesView.swift index f838af1..48a5488 100644 --- a/MauverseIOS/Sources/Views/ServicesView.swift +++ b/MauverseIOS/Sources/Views/ServicesView.swift @@ -136,7 +136,7 @@ private struct ServiceDestination: View { case .eios: WebServiceView(title: service.title, url: "https://eios.mauniver.ru/moodle/") case .digital: - WebServiceView(title: service.title, url: "https://www.mauniver.ru/services/student/") + 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: @@ -170,9 +170,9 @@ private struct FormsView: View { private let forms = [ OnlineFormLink( title: "Справка об обучении", - subtitle: "Отправить заявку внутри приложения", + subtitle: "Обычная, гербовая или электронная", icon: "doc.badge.plus", - url: nil + url: "https://mauniver.ru/services/student/" ), OnlineFormLink( title: "Справка для перевода", @@ -192,6 +192,12 @@ private struct FormsView: View { 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: "Для предоставления работодателю", @@ -215,6 +221,66 @@ private struct FormsView: View { 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/" ) ] @@ -260,7 +326,7 @@ private struct FormsView: View { if let value = form.url, let url = URL(string: value) { InAppBrowserView(url: url, title: form.title) } else { - CertificateRequestView() + EmptyState(icon: "link.badge.plus", title: "Ссылка недоступна", message: form.title) } } } diff --git a/MauverseIOS/project.yml b/MauverseIOS/project.yml index 0580268..1ef2077 100644 --- a/MauverseIOS/project.yml +++ b/MauverseIOS/project.yml @@ -28,8 +28,8 @@ targets: INFOPLIST_FILE: Resources/Info.plist GENERATE_INFOPLIST_FILE: NO ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon - MARKETING_VERSION: 1.10.0 - CURRENT_PROJECT_VERSION: 27 + MARKETING_VERSION: 1.11.0 + CURRENT_PROJECT_VERSION: 28 MAUVERSE_SCHEDULE_TOKEN: "" TARGETED_DEVICE_FAMILY: "1" SUPPORTS_MACCATALYST: NO diff --git a/mauverse_mobile.Tests/UserGreetingTests.cs b/mauverse_mobile.Tests/UserGreetingTests.cs new file mode 100644 index 0000000..91df786 --- /dev/null +++ b/mauverse_mobile.Tests/UserGreetingTests.cs @@ -0,0 +1,31 @@ +using mau.Utils; +using Xunit; + +namespace Mauverse.Mobile.Tests; + +public sealed class UserGreetingTests +{ + [Fact] + public void UsesFirstNameInsteadOfLogin() + { + Assert.Equal("Иван", UserGreeting.ResolveFirstName("Иван", "Иванов Иван Иванович", "student01")); + } + + [Fact] + public void IgnoresLoginAccidentallyReturnedAsFirstName() + { + Assert.Equal("Иван", UserGreeting.ResolveFirstName("student01", "Иванов Иван Иванович", "student01")); + } + + [Fact] + public void UsesNeutralFallbackWhenOnlyLoginIsAvailable() + { + Assert.Equal("Студент", UserGreeting.ResolveFirstName("student01", "", "student01")); + } + + [Fact] + public void UsesFirstPartForMoodleTwoPartDisplayName() + { + Assert.Equal("Иван", UserGreeting.ResolveFirstName("", "Иван Иванов", "student01")); + } +} diff --git a/mauverse_mobile.Tests/mauverse_mobile.Tests.csproj b/mauverse_mobile.Tests/mauverse_mobile.Tests.csproj index a16ddfd..a456e4b 100644 --- a/mauverse_mobile.Tests/mauverse_mobile.Tests.csproj +++ b/mauverse_mobile.Tests/mauverse_mobile.Tests.csproj @@ -25,6 +25,8 @@ Link="Production\BrowserDestinationRegistry.cs" /> + diff --git a/mauverse_mobile/DTOModels/UserDTO.cs b/mauverse_mobile/DTOModels/UserDTO.cs index 6c1598a..0f9cd23 100644 --- a/mauverse_mobile/DTOModels/UserDTO.cs +++ b/mauverse_mobile/DTOModels/UserDTO.cs @@ -3,21 +3,30 @@ namespace mau.DTOModels; -public sealed class UserDTO : BaseDTO -{ - public int UserId { get; set; } - public string Username { get; set; } = string.Empty; - public string FirstName { get; set; } = string.Empty; - public string FullName { get; set; } = string.Empty; +public sealed class UserDTO : BaseDTO +{ + [JsonPropertyName("userid")] + public int UserId { get; set; } + public string Username { get; set; } = string.Empty; + + [JsonPropertyName("firstname")] + public string FirstName { get; set; } = string.Empty; + + [JsonPropertyName("fullname")] + public string FullName { get; set; } = string.Empty; [JsonPropertyName("roleid")] public UserRole Role { get; set; } - public string CreditBook { get; set; } = string.Empty; - public string GroupId { get; set; } = string.Empty; - public string SubGroupId { get; set; } = string.Empty; - public string GroupName { get; set; } = string.Empty; + public string CreditBook { get; set; } = string.Empty; + + public string GroupId { get; set; } = string.Empty; + + public string SubGroupId { get; set; } = string.Empty; + + [JsonPropertyName("groupname")] + public string GroupName { get; set; } = string.Empty; public string Speciality { get; set; } = string.Empty; public string Token { get; set; } = string.Empty; - public string PrivateToken { get; set; } = string.Empty; -} + public string PrivateToken { get; set; } = string.Empty; +} diff --git a/mauverse_mobile/DTOModels/UserInfoDTO.cs b/mauverse_mobile/DTOModels/UserInfoDTO.cs index e994a17..002e597 100644 --- a/mauverse_mobile/DTOModels/UserInfoDTO.cs +++ b/mauverse_mobile/DTOModels/UserInfoDTO.cs @@ -1,14 +1,18 @@ -using mau.Models; -using mau.Utils; +using mau.Models; +using mau.Utils; +using System.Text.Json.Serialization; namespace mau.DTOModels; public sealed class UserInfoDTO : BaseDTO { - public string GroupName { get; set; } = string.Empty; - public UserRole RoleId { get; set; } = UserRole.All; + [JsonPropertyName("groupname")] + public string GroupName { get; set; } = string.Empty; + + [JsonPropertyName("roleid")] + public UserRole RoleId { get; set; } = UserRole.All; public string Username { get; set; } = string.Empty; - public string GroupId { get; set; } = string.Empty; + public string GroupId { get; set; } = string.Empty; public string Speciality { get; set; } = string.Empty; public IEnumerable SubGroups { get; set; } = []; } diff --git a/mauverse_mobile/Pages/NewsPage.xaml b/mauverse_mobile/Pages/NewsPage.xaml index a144ff6..5409b8a 100644 --- a/mauverse_mobile/Pages/NewsPage.xaml +++ b/mauverse_mobile/Pages/NewsPage.xaml @@ -81,12 +81,15 @@ VerticalTextAlignment="Center" /> - + + + diff --git a/mauverse_mobile/Pages/OnlineFormsPage.xaml b/mauverse_mobile/Pages/OnlineFormsPage.xaml index be8b83b..82439bc 100644 --- a/mauverse_mobile/Pages/OnlineFormsPage.xaml +++ b/mauverse_mobile/Pages/OnlineFormsPage.xaml @@ -84,7 +84,7 @@ -