YwxMobileApp-iOS/Shared/AppHomeView.swift
徐勤民 8b2796709b feat: YwxMobileApp-iOS 初始提交
三 Flavor(Shijiaobao / Clinical / Yiwangxin)iOS 项目

架构
- Shared/   全部共用层:AppHomeView、WebMessageHandler(JSBridge)、DevUrlSettings
- *Flavor/  各 flavor 入口:AppConfig、*App(@main)、AppDelegate(Yiwangxin 含 Push)
- project.yml + xcodegen 管理项目结构

SDK 集成
- 引用 Gitea SPM 包(branch: main,SNAPSHOT 开发模式)
- sdk.properties 追踪当前 SDK_REF,scripts/use-sdk.sh 支持 main/local/x.y.z 一键切换
- 含各 flavor xuqm/*.xuqmconfig 配置文件

JSBridge 协议
- window.webkit.messageHandlers.SZYX_APP.postMessage(json)
- 回调 window.SZYX_YWX_WebViewBridge.onMessage(json)
- 实现 setTitle/getDeviceInfo/login/setUserInfo/getAppVersion/checkUpdate
         setStorage/getStorage/removeStorage/clearStorage/setOrientation/apiError

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 11:29:49 +08:00

461 行
17 KiB
Swift

import SwiftUI
import XuqmCoreSDK
import XuqmWebViewSDK
import XuqmUpdateSDK
import XuqmBugCollectSDK
// MARK: - Root view with splash overlay
struct AppRootView: View {
@State private var showWelcome = true
var body: some View {
ZStack {
AppHomeView(onContentReady: { showWelcome = false })
if showWelcome {
WelcomeScreen()
.transition(.opacity)
.animation(.easeOut(duration: 0.3), value: showWelcome)
}
}
}
}
// MARK: - Welcome / splash screen
struct WelcomeScreen: View {
var body: some View {
Color(AppConfig.splashBackgroundColor)
.overlay(
VStack(spacing: 16) {
Image(AppConfig.splashImageName)
.resizable()
.scaledToFit()
.frame(maxWidth: 200)
}
)
.ignoresSafeArea()
}
}
// MARK: - Main home screen
struct AppHomeView: View {
let onContentReady: () -> Void
@State private var updateInfo: AppUpdateInfo?
@State private var updateBusy = false
@State private var showDevSettings = false
@State private var canGoBack = false
@State private var statusBarColor = UIColor.white
@State private var navTitle = ""
@State private var navTextColor = UIColor.black
@State private var navBgColor = UIColor.white
@State private var navGradient: [UIColor]?
@State private var navGradientAngle: Float = 0
@State private var navBgImage: String?
@State private var pageError: String?
@State private var loadProgress: Int = 0
@State private var ignoreVersion = false
@State private var showNotifDialog = false
var body: some View {
VStack(spacing: 0) {
if AppConfig.enableNavBar {
navBar
if loadProgress > 0 && loadProgress < 99 {
ProgressView(value: Double(loadProgress), total: 100)
.progressViewStyle(.linear)
.tint(Color(hex: "#2196F3"))
.frame(height: 3)
}
}
ZStack {
XWebViewView(config: XWebViewConfig(
url: DevUrlSettings.effectiveStartUrl ?? AppConfig.startUrl,
title: AppConfig.appName,
hideToolbar: true,
injectedJavaScript: webViewErrorInterceptorJS,
jsBridgeName: "SZYX_APP",
onMessage: { data in
Task { @MainActor in
handleWebMessage(
data: data,
onStatusBarColorChanged: { color in statusBarColor = color },
onNavBarChanged: { title, textColor, bgColor, gradient, angle, image in
if AppConfig.enableNavBar {
navTitle = title
if let c = textColor { navTextColor = c }
navBgImage = image
if image != nil {
navGradient = nil
} else if let g = gradient {
navGradient = g
navGradientAngle = angle
if let c = bgColor { navBgColor = c }
} else {
navGradient = nil
if let c = bgColor { navBgColor = c }
}
}
},
onCheckUpdateRequested: { Task { await checkUpdate(explicit: true) } },
onOrientationChanged: { applyOrientation($0) }
)
}
},
onPageError: { desc in
pageError = desc
onContentReady()
BugCollectSDK.shared.captureError(
WebViewLoadError(description: desc),
tags: ["context": "webView"]
)
},
onProgressChanged: { pct in
loadProgress = pct
if pct >= 100 { onContentReady() }
},
onNavigationChanged: { back, _ in canGoBack = back }
))
if let error = pageError {
pageErrorOverlay(error)
}
}
}
.background(Color(statusBarColor))
.ignoresSafeArea(edges: .bottom)
.overlay(alignment: .top) {
// Match status-bar tint
Color(statusBarColor)
.frame(height: UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.first?.statusBarManager?.statusBarFrame.height ?? 44)
.ignoresSafeArea(edges: .top)
}
// Update dialog
.overlay {
if let info = updateInfo, info.needsUpdate {
updateOverlay(info: info)
}
}
// Notification permission prompt (yiwangxin only)
.alert("开启通知权限", isPresented: $showNotifDialog) {
Button("允许") { requestNotificationPermission() }
Button("暂不", role: .cancel) {}
} message: {
Text("需要通知权限才能接收消息推送,是否允许?")
}
.task { await startupChecks() }
#if DEBUG
.overlay(alignment: .bottomTrailing) {
Button {
showDevSettings = true
} label: {
Text("DEV")
.font(.caption2.bold())
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Color.orange.opacity(0.85))
.foregroundStyle(.white)
.clipShape(Capsule())
}
.padding(.trailing, 12)
.padding(.bottom, 32)
}
.sheet(isPresented: $showDevSettings) {
DevUrlSettingsSheet()
}
#endif
}
// MARK: - Navigation bar
@ViewBuilder
private var navBar: some View {
ZStack {
// Background: gradient > image > solid color
if let gradient = navGradient {
LinearGradient(
colors: gradient.map { Color($0) },
startPoint: gradientStart(navGradientAngle),
endPoint: gradientEnd(navGradientAngle)
)
} else if let imageName = navBgImage {
AsyncImage(url: URL(string: imageName)) { img in
img.resizable().scaledToFill()
} placeholder: {
Color(navBgColor)
}
} else {
Color(navBgColor)
}
HStack(spacing: 0) {
if canGoBack {
Button {
XWebViewBridge.shared.currentController()?.goBack()
} label: {
Image(systemName: "chevron.left")
.foregroundColor(Color(navTextColor))
.frame(width: 44, height: 44)
}
}
Spacer()
Text(navTitle.isEmpty ? AppConfig.appName : navTitle)
.font(.headline)
.foregroundColor(Color(navTextColor))
Spacer()
if canGoBack {
Color.clear.frame(width: 44, height: 44)
}
}
.padding(.horizontal, 4)
}
.frame(height: 44)
.padding(.top, safeAreaTop)
}
// MARK: - Page error
private func pageErrorOverlay(_ error: String) -> some View {
VStack(spacing: 16) {
Text("页面加载失败")
.font(.headline)
Text(error)
.font(.caption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 24)
Button("点击重试") {
pageError = nil
XWebViewBridge.shared.currentController()?.reload()
}
.buttonStyle(.borderedProminent)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.white)
}
// MARK: - Update overlay
private func updateOverlay(info: AppUpdateInfo) -> some View {
ZStack {
Color.black.opacity(0.45).ignoresSafeArea()
VStack(alignment: .leading, spacing: 14) {
Text("发现新版本")
.font(.title3.weight(.semibold))
Text("版本:\(info.versionName ?? String(info.versionCode ?? 0))")
.font(.subheadline.weight(.medium))
if let log = info.changeLog, !log.isEmpty {
ScrollView {
Text(log)
.frame(maxWidth: .infinity, alignment: .leading)
.foregroundStyle(.secondary)
}
.frame(maxHeight: 160)
}
if info.forceUpdate {
Text("当前版本必须更新后才能继续使用")
.foregroundStyle(.red)
.font(.subheadline.weight(.medium))
}
if !info.forceUpdate {
Toggle("不再提示此版本", isOn: $ignoreVersion)
.labelsHidden()
Text("不再提示此版本").font(.subheadline)
}
if updateBusy {
ProgressView()
}
HStack(spacing: 12) {
if !info.forceUpdate {
Button("暂不更新") {
if ignoreVersion, let vc = info.versionCode {
UpdateSDK.shared.ignoreVersion(vc)
}
updateInfo = nil
}
.buttonStyle(.bordered)
}
Button(updateButtonLabel(info: info)) {
openUpdate(info: info)
}
.buttonStyle(.borderedProminent)
.disabled(updateBusy)
}
}
.padding(20)
.frame(maxWidth: 420)
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 20, style: .continuous))
.padding(.horizontal, 24)
}
}
// MARK: - Startup checks
private func startupChecks() async {
#if PUSH_ENABLED
checkNotificationPermission()
#endif
await checkUpdate(explicit: false)
}
private func checkUpdate(explicit: Bool) async {
do {
let info = try await UpdateSDK.shared.checkAppUpdate(bypassIgnore: explicit)
if info.needsUpdate { updateInfo = info }
else if explicit {
await MainActor.run {
showToast("当前已是最新版本")
}
}
} catch {
BugCollectSDK.shared.captureError(error, tags: ["context": "checkAppUpdate"])
}
}
private func openUpdate(info: AppUpdateInfo) {
if let storeUrl = info.appStoreUrl, !storeUrl.isEmpty {
UpdateSDK.shared.openAppStore(url: storeUrl)
if !info.forceUpdate { updateInfo = nil }
} else if let marketUrl = info.marketUrl, !marketUrl.isEmpty {
UpdateSDK.shared.openAppStore(url: marketUrl)
if !info.forceUpdate { updateInfo = nil }
}
}
private func updateButtonLabel(info: AppUpdateInfo) -> String {
let hasStore = (info.appStoreUrl?.isEmpty == false) || (info.marketUrl?.isEmpty == false)
return hasStore ? "前往商店" : "更新"
}
// MARK: - Orientation
private func applyOrientation(_ orientation: String) {
guard let scene = UIApplication.shared.connectedScenes
.compactMap({ $0 as? UIWindowScene }).first else { return }
let mask: UIInterfaceOrientationMask = switch orientation {
case "portrait": .portrait
case "landscape": .landscape
case "landscapeLeft": .landscapeLeft
case "landscapeRight": .landscapeRight
case "auto": .all
default: .all
}
scene.requestGeometryUpdate(.iOS(interfaceOrientations: mask))
}
// MARK: - Notification permission
private func checkNotificationPermission() {
Task {
let center = UNUserNotificationCenter.current()
let settings = await center.notificationSettings()
if settings.authorizationStatus == .notDetermined {
await MainActor.run { showNotifDialog = true }
}
}
}
private func requestNotificationPermission() {
Task {
try? await UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound])
await UIApplication.shared.registerForRemoteNotifications()
}
}
// MARK: - Helpers
private var safeAreaTop: CGFloat {
UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.first?.windows.first?.safeAreaInsets.top ?? 44
}
private func gradientStart(_ angle: Float) -> UnitPoint {
let rad = Double(angle) * .pi / 180
return UnitPoint(x: 0.5 - sin(rad) * 0.5, y: 0.5 - cos(rad) * 0.5)
}
private func gradientEnd(_ angle: Float) -> UnitPoint {
let rad = Double(angle) * .pi / 180
return UnitPoint(x: 0.5 + sin(rad) * 0.5, y: 0.5 + cos(rad) * 0.5)
}
private func showToast(_ message: String) {
// Simple alert-style toast via a brief UIAlertController presentation
guard let scene = UIApplication.shared.connectedScenes.compactMap({ $0 as? UIWindowScene }).first,
let vc = scene.windows.first?.rootViewController else { return }
let alert = UIAlertController(title: nil, message: message, preferredStyle: .alert)
vc.present(alert, animated: true)
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { alert.dismiss(animated: true) }
}
}
// MARK: - WebView error interceptor JS (mirrors Android's WEBVIEW_ERROR_INTERCEPTOR_JS)
private let webViewErrorInterceptorJS = """
(function() {
var bridge = window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.SZYX_APP;
if (!bridge) return;
function report(url, method, status, statusText, body) {
try {
bridge.postMessage(JSON.stringify({
model: 'apiError',
data: { url: url, method: method, status: status, statusText: statusText || '', response: (body || '').substring(0, 500) }
}));
} catch(e) {}
}
var origFetch = window.fetch;
window.fetch = function() {
var url = arguments[0] && (typeof arguments[0] === 'string' ? arguments[0] : arguments[0].url) || '';
var method = (arguments[1] && arguments[1].method) || 'GET';
return origFetch.apply(this, arguments).then(function(resp) {
if (!resp.ok) {
resp.clone().text().then(function(body) { report(url, method, resp.status, resp.statusText, body); });
}
return resp;
});
};
var origOpen = XMLHttpRequest.prototype.open;
var origSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function(m, u) { this._m = m; this._u = u; return origOpen.apply(this, arguments); };
XMLHttpRequest.prototype.send = function() {
var xhr = this;
xhr.addEventListener('load', function() {
if (xhr.status >= 400) report(xhr._u, xhr._m, xhr.status, xhr.statusText, xhr.responseText);
});
xhr.addEventListener('error', function() { report(xhr._u, xhr._m, 0, 'Network Error', ''); });
return origSend.apply(this, arguments);
};
})();
"""
// MARK: - Supporting error type
private struct WebViewLoadError: Error {
let description: String
var localizedDescription: String { description }
}
// MARK: - Color hex init
private extension Color {
init(hex: String) {
if let c = parseHexColor(hex) {
self = Color(c)
} else {
self = .clear
}
}
}
// MARK: - UserNotifications import (only needed at call sites above)
import UserNotifications