From 5cef8759e3f8fa24c367c6bea37e3969310cadb4 Mon Sep 17 00:00:00 2001 From: D3C0Y Date: Mon, 3 Aug 2026 18:52:44 +0000 Subject: [PATCH] iOS 1.12.4: production Liquid Glass materials and motion Rebuild DesignSystem around mauGlass (iOS 26 glassEffect + a11y fallbacks), thin-glass mauSurface, MauGlassStack, MauMotion, and press feedback. Convert Home/Schedule/News/Services/Login/Campus to glass, fix NewsCard opaque-under-glass, animate chips and tab selection, clean calendar HTML, document the contract. Co-authored-by: D3C0Y --- .../Sources/Core/OfficialNewsService.swift | 2 +- MauverseIOS/Sources/Design/DesignSystem.swift | 183 +++++++++++++----- .../Sources/Views/CampusNavigatorView.swift | 3 +- .../Sources/Views/EventsCalendarView.swift | 4 +- MauverseIOS/Sources/Views/HomeView.swift | 56 +++--- .../Sources/Views/InAppBrowserView.swift | 4 +- MauverseIOS/Sources/Views/LoginView.swift | 12 +- MauverseIOS/Sources/Views/NewsView.swift | 64 +++--- MauverseIOS/Sources/Views/ProfileView.swift | 4 +- MauverseIOS/Sources/Views/RootTabView.swift | 2 + MauverseIOS/Sources/Views/ScheduleView.swift | 43 ++-- MauverseIOS/Sources/Views/ServicesView.swift | 4 +- .../Views/UniversityContactsView.swift | 3 + MauverseIOS/project.yml | 4 +- docs/IOS_LIQUID_GLASS.md | 94 ++++----- mauverse_mobile/mau.csproj | 4 +- 16 files changed, 293 insertions(+), 193 deletions(-) diff --git a/MauverseIOS/Sources/Core/OfficialNewsService.swift b/MauverseIOS/Sources/Core/OfficialNewsService.swift index 4b7c422..6db3490 100644 --- a/MauverseIOS/Sources/Core/OfficialNewsService.swift +++ b/MauverseIOS/Sources/Core/OfficialNewsService.swift @@ -58,7 +58,7 @@ actor OfficialNewsService { private func fetch(url: URL, cachePolicy: URLRequest.CachePolicy) async throws -> [NewsItem] { var request = URLRequest(url: url, cachePolicy: cachePolicy, timeoutInterval: 30) request.timeoutInterval = 30 - request.setValue("MAUverse/1.12.3 (iOS)", forHTTPHeaderField: "User-Agent") + request.setValue("MAUverse/1.12.4 (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 95042a5..872b56d 100644 --- a/MauverseIOS/Sources/Design/DesignSystem.swift +++ b/MauverseIOS/Sources/Design/DesignSystem.swift @@ -7,14 +7,14 @@ enum AppTheme: String, CaseIterable, Identifiable { var id: String { rawValue } var title: String { - return switch self { + switch self { case .system: "Как на iPhone" case .light: "Светлая" case .dark: "Тёмная" } } var colorScheme: ColorScheme? { - return switch self { + switch self { case .system: nil case .light: .light case .dark: .dark @@ -25,17 +25,16 @@ enum AppTheme: String, CaseIterable, Identifiable { enum MauTheme { static let blue = Color(red: 0.09, green: 0.46, blue: 0.97) static let cyan = Color(red: 0.29, green: 0.78, blue: 1.0) - static let violet = Color(red: 0.47, green: 0.40, blue: 1.0) + static let violet = Color(red: 0.38, green: 0.42, blue: 0.92) 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], + colors: [blue, Color(red: 0.08, green: 0.27, blue: 0.68), cyan.opacity(0.85)], startPoint: .topLeading, endPoint: .bottomTrailing ) @@ -55,55 +54,138 @@ enum MauRadius { static let hero: CGFloat = 30 } +enum MauMotion { + static let snappy = Animation.snappy(duration: 0.28, extraBounce: 0.05) + static let soft = Animation.spring(response: 0.42, dampingFraction: 0.86) + static let press = Animation.spring(response: 0.22, dampingFraction: 0.72) + static let orb = Animation.easeInOut(duration: 9.5).repeatForever(autoreverses: true) + static let pulse = Animation.easeInOut(duration: 1.05).repeatForever(autoreverses: true) +} + +/// Atmospheric canvas with drifting glass orbs (respects Reduce Motion). struct MauBackground: View { @Environment(\.colorScheme) private var colorScheme + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @State private var drift = false 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)], + ? [MauTheme.navy.opacity(0.97), .black, MauTheme.blue.opacity(0.12)] + : [MauTheme.cyan.opacity(0.07), MauTheme.canvas, MauTheme.blue.opacity(0.05)], startPoint: .topTrailing, endPoint: .bottomLeading ) Circle() - .fill(MauTheme.cyan.opacity(colorScheme == .dark ? 0.13 : 0.10)) - .frame(width: 420, height: 420) - .blur(radius: 70) - .offset(x: 190, y: -350) + .fill(MauTheme.cyan.opacity(colorScheme == .dark ? 0.16 : 0.12)) + .frame(width: 440, height: 440) + .blur(radius: 78) + .offset(x: drift ? 175 : 205, y: drift ? -330 : -360) Circle() - .fill(MauTheme.violet.opacity(colorScheme == .dark ? 0.15 : 0.075)) - .frame(width: 390, height: 390) - .blur(radius: 80) - .offset(x: -190, y: 390) + .fill(MauTheme.blue.opacity(colorScheme == .dark ? 0.18 : 0.09)) + .frame(width: 400, height: 400) + .blur(radius: 88) + .offset(x: drift ? -175 : -210, y: drift ? 360 : 410) + Circle() + .fill(MauTheme.violet.opacity(colorScheme == .dark ? 0.10 : 0.05)) + .frame(width: 280, height: 280) + .blur(radius: 70) + .offset(x: drift ? 40 : -20, y: drift ? 120 : 160) } .ignoresSafeArea() + .onAppear { + guard !reduceMotion else { return } + withAnimation(MauMotion.orb) { drift = true } + } } } +enum MauGlassStyle { + case regular + case thin + case interactive +} + private struct MauGlassModifier: ViewModifier { let radius: CGFloat + let style: MauGlassStyle + + @Environment(\.colorScheme) private var colorScheme + @Environment(\.accessibilityReduceTransparency) private var reduceTransparency @ViewBuilder func body(content: Content) -> some View { - if #available(iOS 26.0, *) { - content.glassEffect(.regular, in: RoundedRectangle(cornerRadius: radius, style: .continuous)) + if reduceTransparency { + content + .background(MauTheme.card.opacity(colorScheme == .dark ? 0.94 : 0.96), + in: RoundedRectangle(cornerRadius: radius, style: .continuous)) + .overlay { stroke } + } else if #available(iOS 26.0, *) { + glass26(content) + .overlay { stroke.opacity(0.55) } } else { content - .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: radius, style: .continuous)) - .overlay { - RoundedRectangle(cornerRadius: radius, style: .continuous) - .stroke(Color.white.opacity(0.75), lineWidth: 1) - } + .background(fallbackMaterial, in: RoundedRectangle(cornerRadius: radius, style: .continuous)) + .overlay { stroke } } } + + @available(iOS 26.0, *) + @ViewBuilder + private func glass26(_ content: Content) -> some View { + let shape = RoundedRectangle(cornerRadius: radius, style: .continuous) + // Keep to stable glassEffect APIs shipped with Xcode 26.5. + content.glassEffect(.regular, in: shape) + } + + private var fallbackMaterial: Material { + switch style { + case .thin: .thinMaterial + case .regular, .interactive: .ultraThinMaterial + } + } + + private var stroke: some View { + RoundedRectangle(cornerRadius: radius, style: .continuous) + .strokeBorder( + LinearGradient( + colors: colorScheme == .dark + ? [Color.white.opacity(0.28), Color.white.opacity(0.06)] + : [Color.white.opacity(0.85), Color.white.opacity(0.25)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ), + lineWidth: 0.9 + ) + } +} + +private struct MauPressStyle: ButtonStyle { + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .scaleEffect(configuration.isPressed && !reduceMotion ? 0.965 : 1) + .opacity(configuration.isPressed ? 0.92 : 1) + .animation(MauMotion.press, value: configuration.isPressed) + } } extension View { - func mauGlass(radius: CGFloat = 24) -> some View { - modifier(MauGlassModifier(radius: radius)) + /// Primary Liquid Glass surface (cards, sheets, chrome). + func mauGlass(radius: CGFloat = MauRadius.card, style: MauGlassStyle = .regular) -> some View { + modifier(MauGlassModifier(radius: radius, style: style)) + } + + /// Secondary translucent surface — prefers glass over opaque fills. + func mauSurface(radius: CGFloat = MauRadius.card) -> some View { + mauGlass(radius: radius, style: .thin) + } + + func mauPressable() -> some View { + buttonStyle(MauPressStyle()) } func pageTitle(_ title: String, subtitle: String? = nil) -> some View { @@ -119,14 +201,6 @@ 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 { @@ -139,6 +213,7 @@ struct IconTile: View { .foregroundStyle(.white) .frame(width: 44, height: 44) .background(color.gradient, in: RoundedRectangle(cornerRadius: 14, style: .continuous)) + .shadow(color: color.opacity(0.28), radius: 8, y: 3) } } @@ -172,28 +247,35 @@ struct MauStatusPill: View { .foregroundStyle(color) .padding(.horizontal, 11) .padding(.vertical, 7) - .background(color.opacity(0.12), in: Capsule()) + .background(color.opacity(0.14), in: Capsule()) + .overlay { + Capsule().strokeBorder(color.opacity(0.22), lineWidth: 0.6) + } } } struct SkeletonCard: View { + @Environment(\.accessibilityReduceMotion) private var reduceMotion @State private var pulse = false + var height: CGFloat = 150 var body: some View { VStack(alignment: .leading, spacing: 13) { - RoundedRectangle(cornerRadius: 14) - .frame(height: 150) - RoundedRectangle(cornerRadius: 6) + RoundedRectangle(cornerRadius: 14, style: .continuous) + .frame(height: height) + RoundedRectangle(cornerRadius: 6, style: .continuous) .frame(height: 18) .padding(.trailing, 40) - RoundedRectangle(cornerRadius: 6) + RoundedRectangle(cornerRadius: 6, style: .continuous) .frame(width: 150, height: 12) } - .foregroundStyle(MauTheme.muted.opacity(pulse ? 0.12 : 0.23)) + .foregroundStyle(MauTheme.muted.opacity(pulse ? 0.10 : 0.22)) .padding(16) - .mauSurface() - .animation(.easeInOut(duration: 0.9).repeatForever(autoreverses: true), value: pulse) - .onAppear { pulse = true } + .mauGlass(radius: MauRadius.card, style: .thin) + .onAppear { + guard !reduceMotion else { pulse = true; return } + withAnimation(MauMotion.pulse) { pulse = true } + } } } @@ -207,7 +289,7 @@ struct LoadingOverlay: View { } .padding(.horizontal, 18) .padding(.vertical, 14) - .mauGlass(radius: 18) + .mauGlass(radius: MauRadius.compact, style: .interactive) } } @@ -219,7 +301,7 @@ struct EmptyState: View { var body: some View { VStack(spacing: 12) { Image(systemName: icon) - .font(.system(size: 35)) + .font(.system(size: 35, weight: .medium)) .foregroundStyle(MauTheme.blue) Text(title).font(.headline) Text(message) @@ -229,6 +311,19 @@ struct EmptyState: View { } .frame(maxWidth: .infinity) .padding(28) - .mauGlass() + .mauGlass(radius: MauRadius.card) + } +} + +/// Groups sibling glass views so iOS 26 can morph materials together. +struct MauGlassStack: View { + @ViewBuilder var content: () -> Content + + var body: some View { + if #available(iOS 26.0, *) { + GlassEffectContainer { content() } + } else { + content() + } } } diff --git a/MauverseIOS/Sources/Views/CampusNavigatorView.swift b/MauverseIOS/Sources/Views/CampusNavigatorView.swift index f18fdb6..e26a053 100644 --- a/MauverseIOS/Sources/Views/CampusNavigatorView.swift +++ b/MauverseIOS/Sources/Views/CampusNavigatorView.swift @@ -34,7 +34,8 @@ struct CampusNavigatorView: View { } TextField("Поиск корпуса или адреса", text: $searchText) - .textFieldStyle(.roundedBorder) + .padding(14) + .mauGlass(radius: MauRadius.compact, style: .thin) if let url = URL(string: UniversityPortalURLs.campusNavigatorSite) { NavigationLink { diff --git a/MauverseIOS/Sources/Views/EventsCalendarView.swift b/MauverseIOS/Sources/Views/EventsCalendarView.swift index d5ebdcc..d798f31 100644 --- a/MauverseIOS/Sources/Views/EventsCalendarView.swift +++ b/MauverseIOS/Sources/Views/EventsCalendarView.swift @@ -109,7 +109,7 @@ struct EventsCalendarView: View { .foregroundStyle(MauTheme.blue) } if let description = item.description, !description.isEmpty { - Text(description) + Text(HTMLTextCleaning.cleanRSS(description)) .font(.caption) .foregroundStyle(MauTheme.muted) .lineLimit(3) @@ -118,7 +118,7 @@ struct EventsCalendarView: View { } .frame(maxWidth: .infinity, alignment: .leading) .padding(16) - .mauSurface(radius: 18) + .mauGlass(radius: MauRadius.compact) } .buttonStyle(.plain) } diff --git a/MauverseIOS/Sources/Views/HomeView.swift b/MauverseIOS/Sources/Views/HomeView.swift index 4a7380f..01443e1 100644 --- a/MauverseIOS/Sources/Views/HomeView.swift +++ b/MauverseIOS/Sources/Views/HomeView.swift @@ -220,37 +220,37 @@ struct HomeView: View { } private var lessonSkeleton: some View { - RoundedRectangle(cornerRadius: MauRadius.hero) - .fill(MauTheme.blue.opacity(0.14)) + SkeletonCard(height: 120) .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) { - selectedTab = 1 - } - PremiumQuickCard(title: "Сервисы", subtitle: "Все инструменты МАУ", icon: "square.grid.2x2.fill", color: MauTheme.violet) { - selectedTab = 2 - } - PremiumQuickCard(title: "Новости", subtitle: "События университета", icon: "newspaper.fill", color: .orange) { - selectedTab = 3 - } - NavigationLink { - InAppBrowserView(url: URL(string: "https://eios.mauniver.ru/moodle/")!, title: "Учебный портал") - } label: { - PremiumQuickCardContent( - title: "Учёба", - subtitle: "Учебный портал", - icon: "graduationcap.fill", - color: .teal - ) + MauGlassStack { + LazyVGrid( + columns: [GridItem(.flexible(), spacing: 12), GridItem(.flexible(), spacing: 12)], + spacing: 12 + ) { + PremiumQuickCard(title: "Расписание", subtitle: "Занятия и аудитории", icon: "calendar", color: MauTheme.blue) { + withAnimation(MauMotion.soft) { selectedTab = 1 } + } + PremiumQuickCard(title: "Сервисы", subtitle: "Все инструменты МАУ", icon: "square.grid.2x2.fill", color: MauTheme.violet) { + withAnimation(MauMotion.soft) { selectedTab = 2 } + } + PremiumQuickCard(title: "Новости", subtitle: "События университета", icon: "newspaper.fill", color: .orange) { + withAnimation(MauMotion.soft) { selectedTab = 3 } + } + NavigationLink { + InAppBrowserView(url: URL(string: "https://eios.mauniver.ru/moodle/")!, title: "Учебный портал") + } label: { + PremiumQuickCardContent( + title: "Учёба", + subtitle: "Учебный портал", + icon: "graduationcap.fill", + color: .teal + ) + } + .mauPressable() } - .buttonStyle(.plain) } } @@ -410,7 +410,7 @@ private struct PremiumQuickCard: View { Button(action: action) { PremiumQuickCardContent(title: title, subtitle: subtitle, icon: icon, color: color) } - .buttonStyle(.plain) + .mauPressable() } } @@ -440,7 +440,7 @@ private struct PremiumQuickCardContent: View { .foregroundStyle(MauTheme.ink) .frame(maxWidth: .infinity, minHeight: 105, alignment: .topLeading) .padding(16) - .mauSurface(radius: 22) + .mauGlass(radius: MauRadius.card, style: .interactive) } } diff --git a/MauverseIOS/Sources/Views/InAppBrowserView.swift b/MauverseIOS/Sources/Views/InAppBrowserView.swift index 8eee4fc..7d1a1df 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.12.3" + configuration.applicationNameForUserAgent = "MAUverse/1.12.4" configuration.defaultWebpagePreferences.allowsContentJavaScript = true configuration.allowsInlineMediaPlayback = true webView = WKWebView(frame: .zero, configuration: configuration) @@ -224,7 +224,7 @@ private struct BrowserLocationBar: View { } .padding(.horizontal, 16) .padding(.vertical, 9) - .background(.bar) + .background(.ultraThinMaterial) if browser.isLoading { ProgressView(value: browser.progress) diff --git a/MauverseIOS/Sources/Views/LoginView.swift b/MauverseIOS/Sources/Views/LoginView.swift index ec7badc..b5457f7 100644 --- a/MauverseIOS/Sources/Views/LoginView.swift +++ b/MauverseIOS/Sources/Views/LoginView.swift @@ -46,7 +46,7 @@ struct LoginView: View { .submitLabel(.next) .onSubmit { focused = .password } .padding(18) - .background(MauTheme.card.opacity(0.78), in: RoundedRectangle(cornerRadius: 18)) + .mauGlass(radius: MauRadius.compact, style: .thin) SecureField("Пароль", text: $password) .textContentType(.password) @@ -54,13 +54,14 @@ struct LoginView: View { .submitLabel(.go) .onSubmit { signIn() } .padding(18) - .background(MauTheme.card.opacity(0.78), in: RoundedRectangle(cornerRadius: 18)) + .mauGlass(radius: MauRadius.compact, style: .thin) if let error = session.errorMessage { Text(error) .font(.footnote) .foregroundStyle(.red) .frame(maxWidth: .infinity, alignment: .leading) + .transition(.opacity.combined(with: .move(edge: .top))) } Button(action: signIn) { @@ -72,14 +73,15 @@ struct LoginView: View { .frame(maxWidth: .infinity) .padding(.vertical, 17) } - .buttonStyle(.plain) + .mauPressable() .foregroundStyle(.white) - .background(MauTheme.heroGradient, in: RoundedRectangle(cornerRadius: 18)) + .background(MauTheme.heroGradient, in: RoundedRectangle(cornerRadius: 18, style: .continuous)) .shadow(color: MauTheme.blue.opacity(0.22), radius: 14, y: 7) .disabled(session.isBusy) } .padding(20) - .mauSurface(radius: 28) + .mauGlass(radius: 28) + .animation(MauMotion.soft, value: session.errorMessage) Text("Используйте данные от электронной информационно-образовательной среды МАУ") .font(.caption) diff --git a/MauverseIOS/Sources/Views/NewsView.swift b/MauverseIOS/Sources/Views/NewsView.swift index e4bb48e..7554d6d 100644 --- a/MauverseIOS/Sources/Views/NewsView.swift +++ b/MauverseIOS/Sources/Views/NewsView.swift @@ -63,17 +63,26 @@ struct NewsView: View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 9) { ForEach(NewsFilter.allCases) { filter in - Button(filter.title) { - model.filter = filter + Button { + withAnimation(MauMotion.snappy) { + model.filter = filter + } Task { await model.load() } + } label: { + Text(filter.title) + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(model.filter == filter ? .white : MauTheme.ink) + .padding(.horizontal, 15) + .padding(.vertical, 10) + .background { + if model.filter == filter { + Capsule().fill(MauTheme.heroGradient) + } + } + .mauGlass(radius: 22, style: .thin) } - .font(.system(size: 14, weight: .semibold)) - .foregroundStyle(model.filter == filter ? .white : MauTheme.ink) - .padding(.horizontal, 15) - .padding(.vertical, 10) - .background(model.filter == filter ? AnyShapeStyle(MauTheme.blue.gradient) : AnyShapeStyle(MauTheme.card.opacity(0.78)), - in: Capsule()) - .buttonStyle(.plain) + .mauPressable() + .sensoryFeedback(.selection, trigger: model.filter) } } } @@ -118,15 +127,17 @@ private struct NewsCard: View { @ViewBuilder var body: some View { - if let value = item.link, let url = URL(string: value) { - NavigationLink(destination: InAppBrowserView(url: url, title: item.title ?? "Новости МАУ")) { + Group { + if let value = item.link, let url = URL(string: value) { + NavigationLink(destination: InAppBrowserView(url: url, title: item.title ?? "Новости МАУ")) { + content + } + .buttonStyle(.plain) + } else { content } - .buttonStyle(.plain) - .mauGlass(radius: 24) - } else { - content.mauGlass(radius: 24) } + .mauGlass(radius: MauRadius.card, style: .regular) } private var content: some View { @@ -135,6 +146,7 @@ private struct NewsCard: View { AsyncImage(url: url) { phase in if case .success(let image) = phase { image.resizable().scaledToFill() + .transition(.opacity) } else { MauTheme.heroGradient .overlay(Image(systemName: "photo").foregroundStyle(.white.opacity(0.7))) @@ -164,7 +176,6 @@ private struct NewsCard: View { .frame(maxWidth: .infinity, minHeight: 106, alignment: .topLeading) .padding(16) } - .background(MauTheme.card.opacity(0.74)) .clipShape(RoundedRectangle(cornerRadius: MauRadius.card, style: .continuous)) } } @@ -175,14 +186,18 @@ private struct HeroNewsCard: View { @ViewBuilder var body: some View { - if let value = item.link, let url = URL(string: value) { - NavigationLink(destination: InAppBrowserView(url: url, title: item.title ?? "Новости МАУ")) { + Group { + if let value = item.link, let url = URL(string: value) { + NavigationLink(destination: InAppBrowserView(url: url, title: item.title ?? "Новости МАУ")) { + content + } + .buttonStyle(.plain) + } else { content } - .buttonStyle(.plain) - } else { - content } + .mauGlass(radius: MauRadius.hero) + .shadow(color: MauTheme.blue.opacity(0.12), radius: 18, y: 10) } private var content: some View { @@ -193,6 +208,7 @@ private struct HeroNewsCard: View { image .resizable() .scaledToFill() + .transition(.opacity) } else { MauTheme.heroGradient .overlay { @@ -241,13 +257,7 @@ private struct HeroNewsCard: View { .padding(20) } .frame(maxWidth: .infinity) - .background(MauTheme.card.opacity(0.82)) .clipShape(RoundedRectangle(cornerRadius: MauRadius.hero, style: .continuous)) - .overlay { - RoundedRectangle(cornerRadius: MauRadius.hero, style: .continuous) - .stroke(Color.primary.opacity(0.07), lineWidth: 0.75) - } - .shadow(color: .black.opacity(0.12), radius: 18, y: 10) } } diff --git a/MauverseIOS/Sources/Views/ProfileView.swift b/MauverseIOS/Sources/Views/ProfileView.swift index 58f0de2..9cd908f 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.12.3 (32)") + Text("MAUverse 1.12.4 (33)") .font(.caption) .foregroundStyle(MauTheme.muted) } @@ -280,7 +280,7 @@ private struct SettingsView: View { .foregroundStyle(MauTheme.blue) } } - Text("Версия 1.12.3 • сборка 32").font(.caption) + Text("Версия 1.12.4 • сборка 33").font(.caption) } .frame(maxWidth: .infinity, alignment: .leading) .padding(20) diff --git a/MauverseIOS/Sources/Views/RootTabView.swift b/MauverseIOS/Sources/Views/RootTabView.swift index d60c0cd..f955be9 100644 --- a/MauverseIOS/Sources/Views/RootTabView.swift +++ b/MauverseIOS/Sources/Views/RootTabView.swift @@ -22,5 +22,7 @@ struct RootTabView: View { .tag(4) } .tint(MauTheme.blue) + .sensoryFeedback(.selection, trigger: selectedTab) + .animation(MauMotion.soft, value: selectedTab) } } diff --git a/MauverseIOS/Sources/Views/ScheduleView.swift b/MauverseIOS/Sources/Views/ScheduleView.swift index 41ac7a9..8352731 100644 --- a/MauverseIOS/Sources/Views/ScheduleView.swift +++ b/MauverseIOS/Sources/Views/ScheduleView.swift @@ -224,12 +224,10 @@ struct ScheduleView: View { if model.isLoading { VStack(spacing: 12) { ForEach(0..<3, id: \.self) { _ in - RoundedRectangle(cornerRadius: 22) - .fill(MauTheme.card.opacity(0.75)) - .frame(height: 112) - .overlay { ProgressView().opacity(0.45) } + SkeletonCard(height: 56) } } + .transition(.opacity) } else if let error = model.error { VStack(spacing: 12) { EmptyState(icon: "wifi.exclamationmark", title: "Не удалось загрузить", message: error) @@ -238,12 +236,18 @@ struct ScheduleView: View { } } else if model.items(for: model.selectedDate).isEmpty { EmptyState(icon: "cup.and.saucer.fill", title: "Занятий нет", message: "На выбранную дату расписание пустое") + .transition(.opacity.combined(with: .move(edge: .bottom))) } else { LazyVStack(spacing: 12) { ForEach(model.items(for: model.selectedDate), id: \.stableID) { item in LessonCard(item: item) + .transition(.asymmetric( + insertion: .opacity.combined(with: .move(edge: .trailing)), + removal: .opacity + )) } } + .animation(MauMotion.soft, value: model.selectedDate) } } .padding(20) @@ -280,13 +284,15 @@ private struct DateChip: View { } .foregroundStyle(selected ? .white : MauTheme.ink) .frame(width: 58, height: 68) - .background(selected ? AnyShapeStyle(MauTheme.heroGradient) : AnyShapeStyle(MauTheme.card.opacity(0.82)), - in: RoundedRectangle(cornerRadius: 19, style: .continuous)) - .overlay { - RoundedRectangle(cornerRadius: 19, style: .continuous) - .stroke(selected ? Color.white.opacity(0.22) : Color.primary.opacity(0.06), lineWidth: 0.75) + .background { + if selected { + RoundedRectangle(cornerRadius: 19, style: .continuous) + .fill(MauTheme.heroGradient) + } } + .mauGlass(radius: 19, style: selected ? .regular : .thin) .shadow(color: selected ? MauTheme.blue.opacity(0.22) : .clear, radius: 10, y: 5) + .animation(MauMotion.snappy, value: selected) } } @@ -296,19 +302,24 @@ private struct FilterChip: View { let action: () -> Void var body: some View { - Button(action: action) { + Button { + withAnimation(MauMotion.snappy) { action() } + } label: { Text(title) .font(.caption.weight(.semibold)) .foregroundStyle(selected ? .white : MauTheme.ink) .lineLimit(1) .padding(.horizontal, 12) .padding(.vertical, 8) - .background( - selected ? AnyShapeStyle(MauTheme.blue.gradient) : AnyShapeStyle(MauTheme.card.opacity(0.85)), - in: Capsule() - ) + .background { + if selected { + Capsule().fill(MauTheme.blue.gradient) + } + } + .mauGlass(radius: 20, style: .thin) } - .buttonStyle(.plain) + .mauPressable() + .sensoryFeedback(.selection, trigger: selected) } } @@ -366,7 +377,7 @@ private struct LessonCard: View { Spacer() } .padding(17) - .mauSurface(radius: 22) + .mauGlass(radius: MauRadius.card, style: .interactive) } } diff --git a/MauverseIOS/Sources/Views/ServicesView.swift b/MauverseIOS/Sources/Views/ServicesView.swift index 0b22923..c5545c5 100644 --- a/MauverseIOS/Sources/Views/ServicesView.swift +++ b/MauverseIOS/Sources/Views/ServicesView.swift @@ -135,8 +135,8 @@ struct ServicesView: View { .padding(16) .frame(maxWidth: .infinity, minHeight: 135, alignment: .topLeading) } - .buttonStyle(.plain) - .mauSurface(radius: 23) + .mauPressable() + .mauGlass(radius: MauRadius.card, style: .interactive) } } } diff --git a/MauverseIOS/Sources/Views/UniversityContactsView.swift b/MauverseIOS/Sources/Views/UniversityContactsView.swift index ae9c0ef..874f9c0 100644 --- a/MauverseIOS/Sources/Views/UniversityContactsView.swift +++ b/MauverseIOS/Sources/Views/UniversityContactsView.swift @@ -59,6 +59,9 @@ struct UniversityContactsView: View { .padding(.horizontal, 14) .padding(.vertical, 10) .background(.ultraThinMaterial, in: Capsule()) + .overlay { + Capsule().strokeBorder(Color.white.opacity(0.25), lineWidth: 0.6) + } .padding(.bottom, 24) .transition(.move(edge: .bottom).combined(with: .opacity)) } diff --git a/MauverseIOS/project.yml b/MauverseIOS/project.yml index e7460e2..fb2c6ad 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.12.3 - CURRENT_PROJECT_VERSION: 32 + MARKETING_VERSION: 1.12.4 + CURRENT_PROJECT_VERSION: 33 MAUVERSE_SCHEDULE_TOKEN: "" MAUVERSE_API_BASE_URL: "https://app.mauniver.ru/dev/mauverse/" TARGETED_DEVICE_FAMILY: "1" diff --git a/docs/IOS_LIQUID_GLASS.md b/docs/IOS_LIQUID_GLASS.md index a7b52fc..6ebc49d 100644 --- a/docs/IOS_LIQUID_GLASS.md +++ b/docs/IOS_LIQUID_GLASS.md @@ -1,71 +1,47 @@ -# Native iPhone app (SwiftUI) +# iOS Liquid Glass — production pass (1.12.4) -The shipping iPhone application is the native SwiftUI client in `MauverseIOS`. -Android remains the .NET MAUI client in `mauverse_mobile`. Both talk to the same -MAUverse API and share the same bundle id `com.pmi4freal.mauverse3`. +Built: 2026-08-03 +Client: `MauverseIOS` (SwiftUI), deployment iOS 18+, glass API gated iOS 26+ -## Rendering +## Intent -- iOS 26 and newer use the system Liquid Glass appearance for navigation and - translucent surfaces. -- iOS 18–25 use native SwiftUI / UIKit materials. -- Reduce Transparency, Increase Contrast, and Reduce Motion remain controlled - by iOS. +Ship a coherent Liquid Glass campus app: translucent materials everywhere cards live, intentional motion, no matte-on-glass bugs, Reduce Transparency / Reduce Motion respected. -## Build and run +## Contract -Requires a Mac with Xcode 26.5 (or newer matching `project.yml`) and -[XcodeGen](https://github.com/yonaskolb/XcodeGen): +| API | Meaning | +| --- | --- | +| `mauGlass(radius:style:)` | Primary glass. iOS 26 → `glassEffect(.regular)`; older → materials; Reduce Transparency → solid card | +| `mauSurface` | Alias of **thin glass** (not opaque fill) | +| `MauGlassStack` | `GlassEffectContainer` on iOS 26 for sibling morph | +| `mauPressable()` | Scale/opacity press spring | +| `MauMotion` | `snappy` / `soft` / `press` / `orb` / `pulse` | +| `MauBackground` | Canvas + drifting cyan/navy orbs (orb motion off if Reduce Motion) | -```bash -brew install xcodegen -cd MauverseIOS -xcodegen generate --spec project.yml -open MAUverse.xcodeproj -``` +Styles: `.regular` (cards), `.thin` (secondary / fields), `.interactive` (tappable tiles). -Set `MAUVERSE_SCHEDULE_TOKEN` in the Xcode build settings or scheme environment -when calling the schedule API. A physical iPhone is recommended for final -material, accessibility, dark-mode, and performance checks. +## What changed in 1.12.4 -For App Store distribution, configure the team, provisioning profile, and -release signing on the Mac. Do not publish an unsigned or Debug-signed archive. +- Redesigned `DesignSystem.swift` (materials, a11y, motion tokens, press, glass stack, pulsing glass skeletons). +- Home: glass quick actions in `MauGlassStack`, press, skeleton, soft tab switches. +- Schedule: glass date/filter chips + lesson cards, skeleton loaders, list transitions. +- News: fixed double-layer card (opaque under glass); hero glass; animated filter chips. +- Services: interactive glass tiles. +- Login: glass form + fields + error appear. +- Campus search glass; events HTML cleaned; contacts toast stroked. +- RootTab: selection haptic + soft animation. +- Brand gradient: less purple, more Arctic cyan/navy. -## Unsigned GitHub CI artifact +## Smoke (device, before production) -The `ios-release-unsigned` job (display name -`Native SwiftUI iPhone Release (unsigned)`) publishes -`mauverse-native-ios-arm64-release-unsigned`. It contains: +1. iOS 26: glass on Home / Schedule / News / Services; tab bar system glass. +2. Dark mode + Reduce Transparency (solids, no broken strokes). +3. Reduce Motion (no orb drift / press scale still subtle). +4. Login → Home → each tab; news filters; schedule day chips. +5. Dynamic Type large; VoiceOver on quick cards. -- `mauverse-native-ios-arm64-unsigned.ipa`; -- `checksums.txt`; -- `build-metadata.txt`. +## Follow-ups (post-ship if needed) -The IPA is only an unsigned container for later signing. The job does not import -an Apple certificate or provisioning profile and fails if the application -contains `_CodeSignature` or `embedded.mobileprovision`. Sign the extracted -application with your own matching certificate, entitlements, and provisioning -profile, then recreate the IPA. - -## Portal parity with Android - -`MauverseIOS` mirrors the Android portal surfaces from mauniver.ru: - -- Guides: Студенту, Абитуриенту, Наука, International -- Digital services hub (ЭИОС, library, webmail, dorm IT, Intra, PROMT) -- Events calendar from `/press/calendar/rss/` -- News filters Абитуриент and Календарь -- Contacts and payment requisites -- Campus navigator with branches, transport tips, route and panorama -- Settings links to official site, personal-data policy, and sveden -- Schedule teacher/room chips (mutually exclusive) - -## Required smoke checks - -1. Login, logout, and restored session. -2. All five tabs: Home, Schedule, Services, News, and Profile. -3. Service routes, portal guides, calendar, contacts, in-app browser, messenger, and forms. -4. Light and dark appearance. -5. iOS Reduce Transparency and Increase Contrast. -6. Dynamic Type and VoiceOver labels. -7. iPhone portrait and landscape layouts. +- Custom glass Form for certificate / profile editor. +- Departments off system `List`. +- Matched-geometry date chip pill. diff --git a/mauverse_mobile/mau.csproj b/mauverse_mobile/mau.csproj index 1721572..cf2a0ea 100644 --- a/mauverse_mobile/mau.csproj +++ b/mauverse_mobile/mau.csproj @@ -16,8 +16,8 @@ MAUverse com.pmi4freal.mauverse3 - 1.12.3 - 32 + 1.12.4 + 33 https://app.mauniver.ru/dev/mauverse/ None