From 0eccd0fdf36c92c13d8bcba4266fcf4e6bae1481 Mon Sep 17 00:00:00 2001 From: D3C0Y Date: Mon, 3 Aug 2026 18:35:43 +0000 Subject: [PATCH] UX audit 1.12.3: dumb-user bug/UX fixes + Android signing setup Android: fix StateContainer CanStateChange, news filter/error copy, HTML decode in news details, subgroup cancel path, teacher tap-to-search, schedule tab rename, friendlier login labels, certificate DOB, null guards. iOS: stop using Moodle group id as schedule UID, wire native certificate form, configurable API base URL, tab-switching quick actions, retry cards, ru date chips, softer jargon, phone/mailto contact fixes. Docs: UX_AUDIT_1.12.3 schemes + ANDROID_SIGNING engineering keystore flow. Co-authored-by: D3C0Y --- .gitignore | 1 + MauverseIOS/Resources/Info.plist | 2 + MauverseIOS/Sources/Core/APIClient.swift | 8 +- MauverseIOS/Sources/Core/Models.swift | 1 - .../Sources/Core/OfficialNewsService.swift | 2 +- MauverseIOS/Sources/Core/SessionStore.swift | 20 +- MauverseIOS/Sources/Views/HomeView.swift | 119 ++++-- .../Sources/Views/InAppBrowserView.swift | 2 +- MauverseIOS/Sources/Views/LoginView.swift | 2 +- MauverseIOS/Sources/Views/NewsView.swift | 8 +- MauverseIOS/Sources/Views/ProfileView.swift | 6 +- MauverseIOS/Sources/Views/RootTabView.swift | 12 +- MauverseIOS/Sources/Views/ScheduleView.swift | 34 +- MauverseIOS/Sources/Views/ServicesView.swift | 46 ++- .../Views/UniversityContactsView.swift | 36 +- MauverseIOS/project.yml | 5 +- docs/ANDROID_SIGNING.md | 34 ++ docs/UX_AUDIT_1.12.3.md | 78 ++++ mauverse_mobile/AppShell.xaml | 2 +- .../Directory.Build.signing.props.example | 10 + mauverse_mobile/Pages/LoginPage.xaml | 8 +- mauverse_mobile/Pages/NewsPage.xaml | 168 ++++---- mauverse_mobile/Pages/SchedulePage.xaml | 368 +++++++++--------- mauverse_mobile/ViewModel/BaseViewModel.cs | 2 +- .../ViewModel/Dialogs/DetailsNewsViewModel.cs | 15 +- mauverse_mobile/ViewModel/LoginViewModel.cs | 6 +- .../ViewModel/News/NewsViewModel.cs | 8 +- mauverse_mobile/ViewModel/ProfileViewModel.cs | 22 +- .../Schedules/BaseScheduleViewModel.cs | 7 +- .../ViewModel/Schedules/ScheduleViewModel.cs | 4 +- .../Services/CertificateRequestViewModel.cs | 6 +- .../ViewModel/Services/InfoViewModel.cs | 3 +- .../Services/UniversityGuideViewModel.cs | 2 +- mauverse_mobile/mau.csproj | 6 +- 34 files changed, 662 insertions(+), 391 deletions(-) create mode 100644 docs/ANDROID_SIGNING.md create mode 100644 docs/UX_AUDIT_1.12.3.md create mode 100644 mauverse_mobile/Directory.Build.signing.props.example diff --git a/.gitignore b/.gitignore index 37df0d1..1657f90 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,7 @@ *.p12 *.pfx *.mobileprovision +**/Directory.Build.signing.props # Generated artifacts .artifacts/ diff --git a/MauverseIOS/Resources/Info.plist b/MauverseIOS/Resources/Info.plist index 06197e4..a36e11d 100644 --- a/MauverseIOS/Resources/Info.plist +++ b/MauverseIOS/Resources/Info.plist @@ -22,6 +22,8 @@ $(CURRENT_PROJECT_VERSION) MAUverseScheduleToken $(MAUVERSE_SCHEDULE_TOKEN) + MAUVERSE_API_BASE_URL + $(MAUVERSE_API_BASE_URL) UILaunchScreen UISupportedInterfaceOrientations diff --git a/MauverseIOS/Sources/Core/APIClient.swift b/MauverseIOS/Sources/Core/APIClient.swift index b3eefd6..d497aef 100644 --- a/MauverseIOS/Sources/Core/APIClient.swift +++ b/MauverseIOS/Sources/Core/APIClient.swift @@ -20,10 +20,16 @@ extension Notification.Name { final class APIClient { static let shared = APIClient() - private let baseURL = URL(string: "https://app.mauniver.ru/dev/mauverse/")! + private let baseURL: URL private let session: URLSession private init() { + if let configured = Bundle.main.object(forInfoDictionaryKey: "MAUVERSE_API_BASE_URL") as? String, + let url = URL(string: configured), !configured.isEmpty { + baseURL = url + } else { + baseURL = URL(string: "https://app.mauniver.ru/dev/mauverse/")! + } let configuration = URLSessionConfiguration.default configuration.timeoutIntervalForRequest = 30 configuration.waitsForConnectivity = true diff --git a/MauverseIOS/Sources/Core/Models.swift b/MauverseIOS/Sources/Core/Models.swift index 1aeaacc..e10b185 100644 --- a/MauverseIOS/Sources/Core/Models.swift +++ b/MauverseIOS/Sources/Core/Models.swift @@ -128,7 +128,6 @@ struct UserDTO: Codable, Equatable { 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) diff --git a/MauverseIOS/Sources/Core/OfficialNewsService.swift b/MauverseIOS/Sources/Core/OfficialNewsService.swift index 5f09a61..4b7c422 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.2 (iOS)", forHTTPHeaderField: "User-Agent") + request.setValue("MAUverse/1.12.3 (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 38bf393..7bd1a64 100644 --- a/MauverseIOS/Sources/Core/SessionStore.swift +++ b/MauverseIOS/Sources/Core/SessionStore.swift @@ -20,6 +20,10 @@ final class SessionStore: ObservableObject { UserDefaults.standard.removeObject(forKey: storageKey) } } + if var current = user { + Self.clearMistakenScheduleUID(¤t) + user = current + } sessionObserver = NotificationCenter.default.addObserver( forName: .mauverseSessionExpired, object: nil, @@ -63,9 +67,7 @@ final class SessionStore: ObservableObject { ) } authenticated.username = authenticated.username ?? normalizedUsername - if authenticated.scheduleGroupUID?.isEmpty != false { - authenticated.scheduleGroupUID = authenticated.groupId - } + Self.clearMistakenScheduleUID(&authenticated) user = authenticated persist() await refreshProfile() @@ -83,6 +85,7 @@ final class SessionStore: ObservableObject { func signOut() { user = nil + errorMessage = nil KeychainStore.delete(account: storageKey) UserDefaults.standard.removeObject(forKey: storageKey) } @@ -118,4 +121,15 @@ final class SessionStore: ObservableObject { guard let user, let data = try? JSONEncoder().encode(user) else { return } KeychainStore.write(data, account: storageKey) } + + /// Moodle numeric group ids were previously copied into scheduleGroupUID and broke lookups. + private static func clearMistakenScheduleUID(_ user: inout UserDTO) { + guard let uid = user.scheduleGroupUID, !uid.isEmpty else { + user.scheduleGroupUID = nil + return + } + if let groupId = user.groupId, uid == groupId { + user.scheduleGroupUID = nil + } + } } diff --git a/MauverseIOS/Sources/Views/HomeView.swift b/MauverseIOS/Sources/Views/HomeView.swift index 3135dad..4a7380f 100644 --- a/MauverseIOS/Sources/Views/HomeView.swift +++ b/MauverseIOS/Sources/Views/HomeView.swift @@ -85,6 +85,7 @@ final class HomeViewModel: ObservableObject { struct HomeView: View { @EnvironmentObject private var session: SessionStore + @Binding var selectedTab: Int @StateObject private var model = HomeViewModel() private var firstName: String { @@ -106,17 +107,21 @@ struct HomeView: View { HomeDataErrorCard( title: "Расписание недоступно", message: error, - icon: "calendar.badge.exclamationmark" + icon: "calendar.badge.exclamationmark", + retry: { Task { await model.load(user: session.user) } } ) } else { - NextLessonCard(item: model.nextLesson, group: session.user?.groupName) + Button { selectedTab = 1 } label: { + NextLessonCard(item: model.nextLesson, group: session.user?.groupName) + } + .buttonStyle(.plain) } MauSectionHeader(title: "Быстрый доступ") quickActions if !model.notifications.isEmpty { - MauSectionHeader(title: "Обновления ЭИОС") + MauSectionHeader(title: "Уведомления портала") ScrollView(.horizontal, showsIndicators: false) { LazyHStack(spacing: 12) { ForEach(model.notifications) { notification in @@ -132,7 +137,7 @@ struct HomeView: View { .font(.title3.bold()) .foregroundStyle(MauTheme.ink) Spacer() - NavigationLink(destination: NewsView()) { + Button { selectedTab = 3 } label: { HStack(spacing: 4) { Text("Все новости") Image(systemName: "chevron.right") @@ -147,7 +152,8 @@ struct HomeView: View { HomeDataErrorCard( title: "Новости не обновились", message: error, - icon: "newspaper" + icon: "newspaper", + retry: { Task { await model.load(user: session.user) } } ) } } @@ -171,7 +177,7 @@ struct HomeView: View { .font(.system(size: 38, weight: .bold, design: .rounded)) .foregroundStyle(MauTheme.ink) HStack(spacing: 8) { - MauStatusPill(title: "Сессия защищена", icon: "lock.fill") + MauStatusPill(title: "Вход сохранён", icon: "lock.fill") if let group = session.user?.groupName, !group.isEmpty { Text(group) .font(.caption.weight(.semibold)) @@ -181,7 +187,7 @@ struct HomeView: View { } } Spacer() - NavigationLink(destination: ProfileView()) { + Button { selectedTab = 4 } label: { ZStack { Circle().fill(MauTheme.blue.opacity(0.13)) Image(systemName: "person.crop.circle.fill") @@ -226,17 +232,25 @@ struct HomeView: View { spacing: 12 ) { PremiumQuickCard(title: "Расписание", subtitle: "Занятия и аудитории", icon: "calendar", color: MauTheme.blue) { - ScheduleView() + selectedTab = 1 } PremiumQuickCard(title: "Сервисы", subtitle: "Все инструменты МАУ", icon: "square.grid.2x2.fill", color: MauTheme.violet) { - ServicesView() + selectedTab = 2 } PremiumQuickCard(title: "Новости", subtitle: "События университета", icon: "newspaper.fill", color: .orange) { - NewsView() + selectedTab = 3 } - PremiumQuickCard(title: "ЭИОС", subtitle: "Учебный портал", icon: "graduationcap.fill", color: .teal) { - InAppBrowserView(url: URL(string: "https://eios.mauniver.ru/moodle/")!, title: "ЭИОС") + NavigationLink { + InAppBrowserView(url: URL(string: "https://eios.mauniver.ru/moodle/")!, title: "Учебный портал") + } label: { + PremiumQuickCardContent( + title: "Учёба", + subtitle: "Учебный портал", + icon: "graduationcap.fill", + color: .teal + ) } + .buttonStyle(.plain) } } @@ -298,18 +312,26 @@ private struct HomeDataErrorCard: View { let title: String let message: String let icon: String + var retry: (() -> Void)? 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) + VStack(alignment: .leading, spacing: 12) { + 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(3) + } + Spacer() + } + if let retry { + Button("Повторить", action: retry) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(MauTheme.blue) } - Spacer() } .padding(17) .mauSurface(radius: 22) @@ -377,36 +399,47 @@ private struct NextLessonCard: View { } } -private struct PremiumQuickCard: View { +private struct PremiumQuickCard: View { let title: String let subtitle: String let icon: String let color: Color - @ViewBuilder let destination: () -> Destination + let action: () -> Void var body: some View { - NavigationLink(destination: destination()) { - VStack(alignment: .leading, spacing: 15) { - HStack { - IconTile(systemName: icon, color: color) - Spacer() - Image(systemName: "arrow.up.right") - .font(.caption.bold()) - .foregroundStyle(MauTheme.muted) - } - VStack(alignment: .leading, spacing: 3) { - Text(title).font(.headline) - Text(subtitle) - .font(.caption) - .foregroundStyle(MauTheme.muted) - .lineLimit(1) - } - } - .foregroundStyle(MauTheme.ink) - .frame(maxWidth: .infinity, minHeight: 105, alignment: .topLeading) - .padding(16) + Button(action: action) { + PremiumQuickCardContent(title: title, subtitle: subtitle, icon: icon, color: color) } .buttonStyle(.plain) + } +} + +private struct PremiumQuickCardContent: View { + let title: String + let subtitle: String + let icon: String + let color: Color + + var body: some View { + VStack(alignment: .leading, spacing: 15) { + HStack { + IconTile(systemName: icon, color: color) + Spacer() + Image(systemName: "arrow.up.right") + .font(.caption.bold()) + .foregroundStyle(MauTheme.muted) + } + VStack(alignment: .leading, spacing: 3) { + Text(title).font(.headline) + Text(subtitle) + .font(.caption) + .foregroundStyle(MauTheme.muted) + .lineLimit(1) + } + } + .foregroundStyle(MauTheme.ink) + .frame(maxWidth: .infinity, minHeight: 105, alignment: .topLeading) + .padding(16) .mauSurface(radius: 22) } } diff --git a/MauverseIOS/Sources/Views/InAppBrowserView.swift b/MauverseIOS/Sources/Views/InAppBrowserView.swift index 7ffb94d..8eee4fc 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.2" + configuration.applicationNameForUserAgent = "MAUverse/1.12.3" 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 4bb6b3a..ec7badc 100644 --- a/MauverseIOS/Sources/Views/LoginView.swift +++ b/MauverseIOS/Sources/Views/LoginView.swift @@ -38,7 +38,7 @@ struct LoginView: View { } VStack(spacing: 16) { - TextField("Логин ЭИОС", text: $username) + TextField("Логин учебного портала", text: $username) .textContentType(.username) .textInputAutocapitalization(.never) .autocorrectionDisabled() diff --git a/MauverseIOS/Sources/Views/NewsView.swift b/MauverseIOS/Sources/Views/NewsView.swift index 52f0f37..e4bb48e 100644 --- a/MauverseIOS/Sources/Views/NewsView.swift +++ b/MauverseIOS/Sources/Views/NewsView.swift @@ -231,7 +231,7 @@ private struct HeroNewsCard: View { .lineLimit(4) .fixedSize(horizontal: false, vertical: true) if let description = item.description { - Text(description.strippingHTML) + Text(description.cleanedForDisplay) .font(.system(size: 13, weight: .regular)) .foregroundStyle(MauTheme.muted) .lineLimit(2) @@ -252,9 +252,7 @@ private struct HeroNewsCard: View { } private extension String { - var strippingHTML: String { - replacingOccurrences(of: "<[^>]+>", with: "", options: .regularExpression) - .replacingOccurrences(of: " ", with: " ") - .replacingOccurrences(of: "&", with: "&") + var cleanedForDisplay: String { + HTMLTextCleaning.cleanRSS(self) } } diff --git a/MauverseIOS/Sources/Views/ProfileView.swift b/MauverseIOS/Sources/Views/ProfileView.swift index 573831f..58f0de2 100644 --- a/MauverseIOS/Sources/Views/ProfileView.swift +++ b/MauverseIOS/Sources/Views/ProfileView.swift @@ -28,7 +28,7 @@ struct ProfileView: View { .font(.subheadline) .foregroundStyle(.white.opacity(0.72)) HStack(spacing: 8) { - Label("Сессия в Keychain", systemImage: "lock.fill") + Label("Вход сохранён", systemImage: "lock.fill") if let group = session.user?.groupName, !group.isEmpty { Text("•") Text(group) @@ -73,7 +73,7 @@ struct ProfileView: View { .buttonStyle(.plain) .mauSurface() - Text("MAUverse 1.12.2 (31)") + Text("MAUverse 1.12.3 (32)") .font(.caption) .foregroundStyle(MauTheme.muted) } @@ -280,7 +280,7 @@ private struct SettingsView: View { .foregroundStyle(MauTheme.blue) } } - Text("Версия 1.12.2 • сборка 31").font(.caption) + Text("Версия 1.12.3 • сборка 32").font(.caption) } .frame(maxWidth: .infinity, alignment: .leading) .padding(20) diff --git a/MauverseIOS/Sources/Views/RootTabView.swift b/MauverseIOS/Sources/Views/RootTabView.swift index ad02a7e..d60c0cd 100644 --- a/MauverseIOS/Sources/Views/RootTabView.swift +++ b/MauverseIOS/Sources/Views/RootTabView.swift @@ -1,20 +1,26 @@ import SwiftUI struct RootTabView: View { + @State private var selectedTab = 0 + var body: some View { - TabView { - NavigationStack { HomeView() } + TabView(selection: $selectedTab) { + NavigationStack { HomeView(selectedTab: $selectedTab) } .tabItem { Label("Главная", systemImage: "house.fill") } + .tag(0) NavigationStack { ScheduleView() } .tabItem { Label("Расписание", systemImage: "calendar") } + .tag(1) NavigationStack { ServicesView() } .tabItem { Label("Сервисы", systemImage: "square.grid.2x2.fill") } + .tag(2) NavigationStack { NewsView() } .tabItem { Label("Новости", systemImage: "newspaper.fill") } + .tag(3) NavigationStack { ProfileView() } .tabItem { Label("Профиль", systemImage: "person.crop.circle.fill") } + .tag(4) } .tint(MauTheme.blue) } } - diff --git a/MauverseIOS/Sources/Views/ScheduleView.swift b/MauverseIOS/Sources/Views/ScheduleView.swift index f75a31f..41ac7a9 100644 --- a/MauverseIOS/Sources/Views/ScheduleView.swift +++ b/MauverseIOS/Sources/Views/ScheduleView.swift @@ -145,20 +145,28 @@ struct ScheduleView: View { .disabled(model.isLoading) } - HStack(spacing: 12) { - IconTile(systemName: "person.3.fill") - VStack(alignment: .leading, spacing: 3) { - Text(session.user?.groupName ?? "Группа не указана") - .font(.headline) - Text(session.user?.speciality ?? "Изменить можно в профиле") - .font(.caption) + NavigationLink { + ProfileView() + } label: { + 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() + Image(systemName: "chevron.right") + .font(.caption.bold()) .foregroundStyle(MauTheme.muted) - .lineLimit(1) } - Spacer() + .padding(17) + .mauSurface(radius: 22) } - .padding(17) - .mauSurface(radius: 22) + .buttonStyle(.plain) ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 10) { @@ -265,9 +273,9 @@ private struct DateChip: View { var body: some View { VStack(spacing: 5) { - Text(date.formatted(.dateTime.weekday(.abbreviated)).uppercased()) + Text(date.formatted(.dateTime.weekday(.abbreviated).locale(Locale(identifier: "ru_RU"))).uppercased()) .font(.caption2.weight(.bold)) - Text(date.formatted(.dateTime.day())) + Text(date.formatted(.dateTime.day().locale(Locale(identifier: "ru_RU")))) .font(.title3.bold()) } .foregroundStyle(selected ? .white : MauTheme.ink) diff --git a/MauverseIOS/Sources/Views/ServicesView.swift b/MauverseIOS/Sources/Views/ServicesView.swift index c310453..0b22923 100644 --- a/MauverseIOS/Sources/Views/ServicesView.swift +++ b/MauverseIOS/Sources/Views/ServicesView.swift @@ -225,7 +225,8 @@ private struct FormsView: View { title: "Справка об обучении", subtitle: "Обычная, гербовая или электронная", icon: "doc.badge.plus", - url: "https://mauniver.ru/services/student/" + url: nil, + isNative: true ), OnlineFormLink( title: "Справка для перевода", @@ -376,7 +377,9 @@ private struct FormsView: View { @ViewBuilder private func destination(for form: OnlineFormLink) -> some View { - if let value = form.url, let url = URL(string: value) { + if form.isNative { + CertificateRequestView() + } else if let value = form.url, let url = URL(string: value) { InAppBrowserView(url: url, title: form.title) } else { EmptyState(icon: "link.badge.plus", title: "Ссылка недоступна", message: form.title) @@ -389,6 +392,7 @@ private struct OnlineFormLink: Identifiable { let subtitle: String let icon: String let url: String? + var isNative: Bool = false var id: String { title } } @@ -705,6 +709,7 @@ private final class TeachersModel: ObservableObject { @Published var teachers: [Teacher] = [] @Published var isLoading = false @Published var error: String? + @Published var hint: String? private var activeRequest = UUID() func search(_ text: String, user: UserDTO?) async { @@ -713,11 +718,13 @@ private final class TeachersModel: ObservableObject { let query = text.trimmingCharacters(in: .whitespacesAndNewlines) guard query.count >= 2 else { teachers = [] - error = "Введите минимум две буквы фамилии" + error = nil + hint = query.isEmpty ? nil : "Введите минимум две буквы фамилии" return } isLoading = true error = nil + hint = nil defer { if activeRequest == request { isLoading = false } } @@ -766,7 +773,12 @@ private struct TeacherContactsView: View { .mauGlass(radius: 20) if model.isLoading { LoadingOverlay(title: "Ищем") } - else if let error = model.error { EmptyState(icon: "wifi.exclamationmark", title: "Ошибка", message: error) } + else if let error = model.error { + EmptyState(icon: "wifi.exclamationmark", title: "Не удалось найти", message: error) + } + else if let hint = model.hint { + EmptyState(icon: "character.cursor.ibeam", title: "Продолжите ввод", message: hint) + } else if query.isEmpty { EmptyState( icon: "person.text.rectangle", @@ -941,16 +953,8 @@ private struct ContactCard: View { Text(subtitle).font(.caption).foregroundStyle(MauTheme.muted) } HStack { - if let phone, let destination = phoneURL(phone) { - Link(destination: destination) { - Label(phone, systemImage: "phone.fill") - } - } - if let phone2, let destination = phoneURL(phone2) { - Link(destination: destination) { - Label(phone2, systemImage: "phone.fill") - } - } + phoneLabel(phone) + phoneLabel(phone2) if let email, let url = URL(string: "mailto:\(email.trimmed)") { Link(destination: url) { Image(systemName: "envelope.fill") } } @@ -963,6 +967,20 @@ private struct ContactCard: View { .mauGlass(radius: 22) } + @ViewBuilder + private func phoneLabel(_ value: String?) -> some View { + if let value, !value.trimmed.isEmpty { + if let destination = phoneURL(value) { + Link(destination: destination) { + Label(value, systemImage: "phone.fill") + } + } else { + Label(value, systemImage: "phone") + .foregroundStyle(MauTheme.muted) + } + } + } + private func phoneURL(_ value: String) -> URL? { PhoneNumberFormatting.telURL(from: value) } diff --git a/MauverseIOS/Sources/Views/UniversityContactsView.swift b/MauverseIOS/Sources/Views/UniversityContactsView.swift index a5d1c55..ae9c0ef 100644 --- a/MauverseIOS/Sources/Views/UniversityContactsView.swift +++ b/MauverseIOS/Sources/Views/UniversityContactsView.swift @@ -77,12 +77,18 @@ struct UniversityContactsView: View { .fixedSize(horizontal: false, vertical: true) HStack(spacing: 10) { - if let phone = block.phone, let url = phoneURL(phone) { - Link(destination: url) { - Label("Позвонить", systemImage: "phone.fill") + if let phone = block.phone { + if let url = phoneURL(phone) { + Link(destination: url) { + Label("Позвонить", systemImage: "phone.fill") + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + } else { + Text(phone) + .font(.caption.weight(.semibold)) + .foregroundStyle(MauTheme.muted) } - .buttonStyle(.borderedProminent) - .controlSize(.small) } Button { copyText(block.details, label: "Скопировано") @@ -94,13 +100,21 @@ struct UniversityContactsView: View { } if let email = block.email { - Button { - copyText(email, label: "E-mail скопирован") - } label: { - Label(email, systemImage: "envelope") - .font(.caption.weight(.semibold)) + HStack(spacing: 10) { + if let mailURL = URL(string: "mailto:\(email)") { + Link(destination: mailURL) { + Label(email, systemImage: "envelope.fill") + .font(.caption.weight(.semibold)) + } + } + Button { + copyText(email, label: "E-mail скопирован") + } label: { + Image(systemName: "doc.on.doc") + .font(.caption.weight(.semibold)) + } + .buttonStyle(.plain) } - .buttonStyle(.plain) .foregroundStyle(MauTheme.blue) } } diff --git a/MauverseIOS/project.yml b/MauverseIOS/project.yml index 403007f..e7460e2 100644 --- a/MauverseIOS/project.yml +++ b/MauverseIOS/project.yml @@ -28,9 +28,10 @@ targets: INFOPLIST_FILE: Resources/Info.plist GENERATE_INFOPLIST_FILE: NO ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon - MARKETING_VERSION: 1.12.2 - CURRENT_PROJECT_VERSION: 31 + MARKETING_VERSION: 1.12.3 + CURRENT_PROJECT_VERSION: 32 MAUVERSE_SCHEDULE_TOKEN: "" + MAUVERSE_API_BASE_URL: "https://app.mauniver.ru/dev/mauverse/" TARGETED_DEVICE_FAMILY: "1" SUPPORTS_MACCATALYST: NO scheme: diff --git a/docs/ANDROID_SIGNING.md b/docs/ANDROID_SIGNING.md new file mode 100644 index 0000000..36d9f50 --- /dev/null +++ b/docs/ANDROID_SIGNING.md @@ -0,0 +1,34 @@ +# Android signing (engineering) + +Production Play keystores stay out of the repository (see `docs/CI_AND_RELEASE.md`). +For local sideload / engineering Release builds: + +1. Generate a keystore (already done under `.artifacts/signing/` in cloud agents; regenerate locally if needed): + +```bash +keytool -genkeypair -v \ + -keystore .artifacts/signing/mauverse-engineering.keystore \ + -alias mauverse \ + -keyalg RSA -keysize 2048 -validity 10000 \ + -storepass mauverse-eng \ + -keypass mauverse-eng \ + -dname "CN=MAUverse Engineering, OU=MAUverse, O=decoder-dev, L=Murmansk, ST=Murmansk, C=RU" +``` + +2. Copy `mauverse_mobile/Directory.Build.signing.props.example` → `mauverse_mobile/Directory.Build.signing.props` (gitignored). + +3. Build: + +```bash +cd mauverse_mobile +dotnet build mau.csproj -c Release -f net10.0-android -p:AndroidPackageFormats=aab +``` + +4. Verify signer is not missing: + +```bash +jarsigner -verify -verbose -certs \ + bin/Release/net10.0-android/android-arm64/com.pmi4freal.mauverse3-Signed.aab | head +``` + +Engineering cert `CN=MAUverse Engineering` is for sideload only — not for Play Store. diff --git a/docs/UX_AUDIT_1.12.3.md b/docs/UX_AUDIT_1.12.3.md new file mode 100644 index 0000000..6b2a867 --- /dev/null +++ b/docs/UX_AUDIT_1.12.3.md @@ -0,0 +1,78 @@ +# UX / bug audit — dumbest-user schemes (1.12.3) + +Built: 2026-08-03 +Clients: Android MAUI (`mauverse_mobile`) + iOS SwiftUI (`MauverseIOS`) + +Perspective: a confused freshman who skips text, taps the biggest control, and assumes every blue thing calls or opens something. + +--- + +## Convenient (keep) + +- Five clear tabs; Russian snackbars; offline schedule/news caches. +- Login field validation; logout wipe confirmation. +- Campus route/panorama; contacts call/copy; Murmansk phone + extension dialing. +- iOS Keychain session; news HTML entity cleaning on RSS path. +- In-app browser chrome (back/forward/reload). + +--- + +## Scheme A — Bugs (fix) + +| ID | Issue | Platform | Fix | +| --- | --- | --- | --- | +| B1 | `CanStateChange` defaults false → Loading/Empty never shows | Android | Init `CanStateChange = true` in `BaseViewModel` | +| B2 | News Appearing always reloads Default; chip desync | Android | `LoadData` uses `SelectedButton?.FilterType` | +| B3 | News/events network fail shown as “empty category” | Android | Distinct error empty copy / snackbar + state | +| B4 | Login subgroup dismiss saves empty subgroup | Android | Require subgroup or send to Profile setup | +| B5 | `CurrentUser` null races after logout | Android | Null-guards on Profile/Schedule | +| B6 | News HTML entities in detail popup | Android | HtmlDecode/strip before bind | +| B7 | Moodle `groupId` treated as schedule UID | iOS | Stop copying; always resolve via `findGroup` | +| B8 | Native certificate form never linked | iOS | Wire `CertificateRequestView` like Android `IsNative` | +| B9 | Hardcoded `/dev/mauverse/` API base | iOS | Build-configurable base URL (parity with Android) | +| B10 | Teacher search needs «Найти» after suggestion tap | Android | Auto-run search on teacher select | +| B11 | Android CA1822 / Release already fixed | — | done in 1.12.2 | + +--- + +## Scheme B — Inconvenient (improve) + +| ID | Pain | Platform | Fix | +| --- | --- | --- | --- | +| U1 | Tab «Пары» vs page «Расписание» | Android | Rename tab to «Расписание» | +| U2 | Legend «Нет занятий» always looks like today empty | Android | Reword caption | +| U3 | Teacher short query shown as wifi «Ошибка» | iOS | Hint style, not error icon | +| U4 | Home pushes duplicate tab roots | iOS | Switch selected tab instead of NavigationLink push | +| U5 | Schedule group row not tappable | iOS | Link to Profile editor | +| U6 | Soften Keychain / ЭИОС jargon | Both | Friendlier labels | +| U7 | Certificate DOB prefilled 01.01.2000 | Android | Empty until chosen | +| U8 | International guide English subtitle | Android | Russian description | +| U9 | Date chips English locale | iOS | `ru_RU` | +| U10 | Home error without Retry | iOS | Retry button | +| U11 | Phone label not obvious call CTA | Android | Keep Primary color + ensure tap works | +| U12 | Unparseable phone hidden | iOS | Show number + «Не удалось набрать» path | + +--- + +## Scheme C — Android signing (end) + +| Step | Action | +| --- | --- | +| C1 | Generate local engineering upload keystore (gitignored) | +| C2 | Add optional signing props via `Directory.Build.signing.props` (gitignored) + documented template | +| C3 | Document `keytool` + `dotnet build` with signing props | +| C4 | Produce signed AAB/APK and verify signer ≠ missing; engineering cert OK for sideload | + +Production Play keystore stays out of repo (existing policy). + +--- + +## Implementation status (2026-08-03) + +Implemented on branch `cursor/ux-audit-fixes-010d` for release **1.12.3**: + +- Android: B1–B6, B10, U1–U2, U6–U8 (+ profile/schedule null guards) +- iOS: B7–B9, U3–U6, U9–U10, U12, B8 (+ mailto on contacts) +- Signing: C1–C4 docs + engineering keystore path + optional `Directory.Build.signing.props` + +Deferred (follow-up): soft refresh without LoadingPage, telephone search, teacher schedule role parity, settings cache confirm. diff --git a/mauverse_mobile/AppShell.xaml b/mauverse_mobile/AppShell.xaml index d4099ca..4b649b3 100644 --- a/mauverse_mobile/AppShell.xaml +++ b/mauverse_mobile/AppShell.xaml @@ -24,7 +24,7 @@ Icon="home_icon" ContentTemplate="{DataTemplate local:MainPage}" Route="MainPage" /> - diff --git a/mauverse_mobile/Directory.Build.signing.props.example b/mauverse_mobile/Directory.Build.signing.props.example new file mode 100644 index 0000000..771f2b7 --- /dev/null +++ b/mauverse_mobile/Directory.Build.signing.props.example @@ -0,0 +1,10 @@ + + + + true + $(MSBuildThisFileDirectory)../.artifacts/signing/mauverse-engineering.keystore + mauverse-eng + mauverse + mauverse-eng + + diff --git a/mauverse_mobile/Pages/LoginPage.xaml b/mauverse_mobile/Pages/LoginPage.xaml index 4f30349..965dd47 100644 --- a/mauverse_mobile/Pages/LoginPage.xaml +++ b/mauverse_mobile/Pages/LoginPage.xaml @@ -77,12 +77,12 @@ TextColor="{DynamicResource TextSecondary}" />