diff --git a/AIGrammar.xcodeproj/project.pbxproj b/AIGrammar.xcodeproj/project.pbxproj index 20a9f26..bf5e179 100644 --- a/AIGrammar.xcodeproj/project.pbxproj +++ b/AIGrammar.xcodeproj/project.pbxproj @@ -26,6 +26,7 @@ 550B85A22C2BC624008834E5 /* InitAPP.swift in Sources */ = {isa = PBXBuildFile; fileRef = 550B85A12C2BC623008834E5 /* InitAPP.swift */; }; 551C8C342C79946700B1A88C /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 551C8C332C79946700B1A88C /* GoogleService-Info.plist */; }; 555027392C81C0ED00A05441 /* QCloudTTS.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 555027382C81C0ED00A05441 /* QCloudTTS.xcframework */; }; + 5550273C2C8322F800A05441 /* PushHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5550273B2C8322F800A05441 /* PushHandler.swift */; }; 5586E0882C80AD2D00026733 /* TTSManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5586E0872C80AD2D00026733 /* TTSManager.swift */; }; 559E6D7C2C34EAE700C971B9 /* IapManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 559E6D7B2C34EAE700C971B9 /* IapManager.swift */; }; 559E6D7E2C35355200C971B9 /* LogManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 559E6D7D2C35355200C971B9 /* LogManager.swift */; }; @@ -91,6 +92,8 @@ 550B85A12C2BC623008834E5 /* InitAPP.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InitAPP.swift; sourceTree = ""; }; 551C8C332C79946700B1A88C /* GoogleService-Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; }; 555027382C81C0ED00A05441 /* QCloudTTS.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = QCloudTTS.xcframework; sourceTree = ""; }; + 5550273A2C8311CB00A05441 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + 5550273B2C8322F800A05441 /* PushHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushHandler.swift; sourceTree = ""; }; 5586E0832C8092C400026733 /* AIGrammar-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "AIGrammar-Bridging-Header.h"; sourceTree = ""; }; 5586E0872C80AD2D00026733 /* TTSManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TTSManager.swift; sourceTree = ""; }; 559E6D7B2C34EAE700C971B9 /* IapManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IapManager.swift; sourceTree = ""; }; @@ -183,6 +186,7 @@ 5500A38E2BB3C7E80065A1D3 /* AIGrammar */ = { isa = PBXGroup; children = ( + 5550273A2C8311CB00A05441 /* Info.plist */, 5586E0842C8093DF00026733 /* third-party */, 5586E0832C8092C400026733 /* AIGrammar-Bridging-Header.h */, 55BC47472C3A380C00120A7D /* CommView */, @@ -249,6 +253,7 @@ 559E6D7D2C35355200C971B9 /* LogManager.swift */, 55E4E8F42C60CFFC00988503 /* CommFunc.swift */, 5586E0872C80AD2D00026733 /* TTSManager.swift */, + 5550273B2C8322F800A05441 /* PushHandler.swift */, ); path = lib; sourceTree = ""; @@ -559,6 +564,7 @@ 5500A3C42BB40AC40065A1D3 /* WordsView.swift in Sources */, 5509CEF12BB54DD10056C5C2 /* Config.swift in Sources */, 55BB127B2BBD653100D2BEA4 /* ShareSheet.swift in Sources */, + 5550273C2C8322F800A05441 /* PushHandler.swift in Sources */, 5500A3C82BB40ADE0065A1D3 /* SettingsView.swift in Sources */, 55E4E8F52C60CFFC00988503 /* CommFunc.swift in Sources */, 55DAC6552BBA959500BDD4C8 /* ResultView.swift in Sources */, @@ -750,6 +756,7 @@ "$(PROJECT_DIR)/AIGrammar/third-party/ios-arm64_i386_x86_64-simulator", ); GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = AIGrammar/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = EasyGrammar; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.education"; INFOPLIST_KEY_NSCameraUsageDescription = "Camera access is required to capture text for grammar and spelling correction."; @@ -873,6 +880,7 @@ "$(PROJECT_DIR)/AIGrammar/third-party/ios-arm64_i386_x86_64-simulator", ); GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = AIGrammar/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = EasyGrammar; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.education"; INFOPLIST_KEY_NSCameraUsageDescription = "Camera access is required to capture text for grammar and spelling correction."; diff --git a/AIGrammar/AIGrammar.entitlements b/AIGrammar/AIGrammar.entitlements index 0c67376..903def2 100644 --- a/AIGrammar/AIGrammar.entitlements +++ b/AIGrammar/AIGrammar.entitlements @@ -1,5 +1,8 @@ - + + aps-environment + development + diff --git a/AIGrammar/AIGrammarApp.swift b/AIGrammar/AIGrammarApp.swift index a5ce7f5..efb4057 100644 --- a/AIGrammar/AIGrammarApp.swift +++ b/AIGrammar/AIGrammarApp.swift @@ -8,15 +8,24 @@ import SwiftUI import TrustDecision +import Combine import Firebase import FirebaseAnalytics import FirebaseCrashlytics @main struct AIGrammarApp: App { - let persistenceController = PersistenceController.shared + // 获取 AppDelegate 的实例 + @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate + let persistenceController = PersistenceController.shared + @StateObject private var appState = NofifyState() + @State private var urlToOpen: String? + init() { + // 将当前实例传递给 AppDelegate + appDelegate.app = self + // 初始化部分 setupLogging() _ = InitApp.shared @@ -33,12 +42,68 @@ struct AIGrammarApp: App { var body: some Scene { WindowGroup { - AllTabView() + AllTabView(selectedTab: $appState.selectedTab, showPromotion: $appState.showPromotion, promotionMode: $appState.promotionMode) .environment(\.managedObjectContext, persistenceController.container.viewContext) .environmentObject(IAPManager()) // 这里添加 IAPManager .environmentObject(globalEnvironment) // 这里添加 IAPManager + .environmentObject(appState) .preferredColorScheme(.light) // 强制整个应用使用亮色模式 + .sheet(isPresented: $appState.showPromotion) { + switch appState.promotionMode { + case .halfScreen: + PromotionView() // 半屏模式 + .presentationDetents([.medium]) + case .fullScreen: + PromotionView() // 全屏模式 + .edgesIgnoringSafeArea(.all) + default: + EmptyView() + } + } + .onChange(of: urlToOpen) { url in + if let url = url, let link = URL(string: url) { + UIApplication.shared.open(link) + } + } + .onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in + handlePushNotification() + } + } + } + + // 这个是在app外打开链接,实际上不应该用到。 + func openURL(urlString: String) { + urlToOpen = urlString + } + + func handlePushNotification() { + // 确保在主线程中更新视图 + DispatchQueue.main.async { + let pushInfo = globalEnvironment.pushSettings + // 切换到对应的tab + if pushInfo.gotoTab >= 0 && pushInfo.gotoTab < 4 { + self.appState.selectedTab = pushInfo.gotoTab + }else { + logger.info("invalid goto Tab: \(pushInfo.gotoTab)") + } + // 是否显示本地页面 + if pushInfo.showPage && pushInfo.page != "" { + self.appState.showPromotion = true + switch pushInfo.showMode { + case "halfScreen" : + self.appState.promotionMode = PromotionDisplayType.halfScreen + case "fullScreen" : + self.appState.promotionMode = PromotionDisplayType.fullScreen + default: + break + } + } + // 是否显示远程页面 + if pushInfo.showPage && pushInfo.openURL != "" { + urlToOpen = pushInfo.openURL + } + + logger.info("Push Values. gotoTab: \(pushInfo.gotoTab), showPage: \(pushInfo.showPage), page: \(pushInfo.page), showMode: \(pushInfo.showMode), url: \(pushInfo.openURL)") } } - } diff --git a/AIGrammar/AllTabView.swift b/AIGrammar/AllTabView.swift index 5b56cdd..799e9ee 100644 --- a/AIGrammar/AllTabView.swift +++ b/AIGrammar/AllTabView.swift @@ -9,36 +9,58 @@ import SwiftUI struct AllTabView: View { + // 当前正在展示的tab + @Binding var selectedTab: Int + @Binding var showPromotion: Bool + @Binding var promotionMode: PromotionDisplayType + var body: some View { - TabView { + TabView(selection: $selectedTab) { GrammarCheckView() .tabItem { Image(systemName: "book.fill") Text("Grammar Check") } + .tag(0) WordsView() .tabItem { Image(systemName: "text.bubble") Text("Words") } + .tag(1) TranslateView() .tabItem { Image(systemName: "globe") Text("Translate") } + .tag(2) SettingsView() .tabItem { Image(systemName: "gear") Text("Settings") } + .tag(3) } } } +struct AllTabView_Preview: View{ + @State private var selectedTab = 2 + @State private var showPromotion = false + @State private var promotionMode: PromotionDisplayType = .halfScreen + @State private var urlToOpen: String? + + var body: some View { + VStack { + AllTabView(selectedTab: $selectedTab, showPromotion: $showPromotion, promotionMode: $promotionMode) + } + } + +} #Preview { - AllTabView() + AllTabView_Preview() } diff --git a/AIGrammar/ViewModel/Config.swift b/AIGrammar/ViewModel/Config.swift index 40bc208..9c3e477 100644 --- a/AIGrammar/ViewModel/Config.swift +++ b/AIGrammar/ViewModel/Config.swift @@ -83,7 +83,16 @@ class GlobalEnvironment: ObservableObject { logger.info("baseHost: \(self.baseHost)") // 以后定义SandBox的功能,主要是商品列表的区分。 } - + // 使用结构体组织相关的设置 + @Published var pushSettings: PushInfo = PushInfo() + struct PushInfo{ + var gotoTab: Int = 0 + var showPage: Bool = false + var page: String = "" + var showMode: String = "" + var openURL: String = "" + var appAtFront : Bool = false + } } // 全局实例 diff --git a/AIGrammar/lib/PushHandler.swift b/AIGrammar/lib/PushHandler.swift new file mode 100644 index 0000000..b2bcb75 --- /dev/null +++ b/AIGrammar/lib/PushHandler.swift @@ -0,0 +1,118 @@ +// +// PushHandler.swift +// AIGrammar +// +// Created by oscar on 2024/8/31. +// + +import UIKit +import SwiftUI +import UserNotifications + +enum PromotionDisplayType { + case halfScreen + case fullScreen + case urlLink(String) // URL 链接,可以传递 URL 字符串 +} + +class NofifyState: ObservableObject { + @Published var selectedTab: Int = 0 + @Published var showPromotion: Bool = false + @Published var promotionMode: PromotionDisplayType = .halfScreen +} + +class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate { + // 保存 AIGrammarApp 的实例 + var app: AIGrammarApp? + + var window: UIWindow? + var rootView: ContentView? + + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + + // 设置 UNUserNotificationCenter 的 delegate + UNUserNotificationCenter.current().delegate = self + + // 注册推送通知 + registerForPushNotifications() + return true + } + + func registerForPushNotifications() { + UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in + logger.info("Permission granted: \(granted)") + guard granted else { return } + self.getNotificationSettings() + } + } + + func getNotificationSettings() { + UNUserNotificationCenter.current().getNotificationSettings { settings in + logger.info("Notification settings: \(settings)") + guard settings.authorizationStatus == .authorized else { return } + DispatchQueue.main.async { + UIApplication.shared.registerForRemoteNotifications() + } + } + } + + func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { + let tokenParts = deviceToken.map { data in String(format: "%02.2hhx", data) } + let token = tokenParts.joined() + logger.info("Device Token: \(token)") + // 你可以在此处将 token 发送到你的服务器 + } + + func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) { + logger.error("Failed to register: \(error)") + } + + /* + // 处理前台接收推送通知 + func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { + logger.info("get push msg in willPresent") + globalEnvironment.pushSettings.appAtFront = true + //completionHandler([.sound, .banner]) + } + */ + + // 处理用户响应推送通知 + func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { + logger.info("get push msg in didReceive") + let userInfo = response.notification.request.content.userInfo + + // 解析相关的参数,保存到全局变量中。等用户唤起主界面后执行 + globalEnvironment.pushSettings.gotoTab = extractValue(from: userInfo, key: "gotoTab", defaultValue: 0, logMessage: "gotoTab not found or invalid in userInfo") + globalEnvironment.pushSettings.showPage = extractValue(from: userInfo, key: "showPage", defaultValue: 0, logMessage: "showPage not found or invalid in userInfo") != 0 + globalEnvironment.pushSettings.page = extractValue(from: userInfo, key: "page", defaultValue: "", logMessage: "page not found or invalid in userInfo") + globalEnvironment.pushSettings.showMode = extractValue(from: userInfo, key: "showMode", defaultValue: "", logMessage: "showMode not found or invalid in userInfo") + globalEnvironment.pushSettings.openURL = extractValue(from: userInfo, key: "url", defaultValue: "", logMessage: "url not found or invalid in userInfo") + + // 如果推送过来的时候,已经在前台了,需要触发一下 + if globalEnvironment.pushSettings.appAtFront { + DispatchQueue.main.async { + self.app?.handlePushNotification() + } + globalEnvironment.pushSettings.appAtFront = false + } + completionHandler() + } + + // 解析 userInfo 的通用函数 + func extractValue(from userInfo: [AnyHashable: Any], key: String, defaultValue: T, logMessage: String) -> T { + if let value = userInfo[key] as? T { + return value + } else { + logger.info(logMessage) + return defaultValue + } + } + +} + + +struct PromotionView: View { + var body: some View { + Text("This is the Promotion View") + } +}