三 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>
461 行
17 KiB
Swift
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
|