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