diff --git a/android/app/capacitor.build.gradle b/android/app/capacitor.build.gradle index 62cae968d..34d1d88c9 100644 --- a/android/app/capacitor.build.gradle +++ b/android/app/capacitor.build.gradle @@ -9,6 +9,8 @@ android { apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle" dependencies { + implementation project(':capacitor-keyboard') + implementation project(':capacitor-status-bar') implementation project(':capacitor-app') implementation project(':capacitor-filesystem') implementation project(':capacitor-local-notifications') diff --git a/android/capacitor.settings.gradle b/android/capacitor.settings.gradle index 368c09131..0ae566fe2 100644 --- a/android/capacitor.settings.gradle +++ b/android/capacitor.settings.gradle @@ -2,6 +2,12 @@ include ':capacitor-android' project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor') +include ':capacitor-keyboard' +project(':capacitor-keyboard').projectDir = new File('../node_modules/@capacitor/keyboard/android') + +include ':capacitor-status-bar' +project(':capacitor-status-bar').projectDir = new File('../node_modules/@capacitor/status-bar/android') + include ':capacitor-app' project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android') diff --git a/capacitor.config.ts b/capacitor.config.ts index 9f030fbeb..a9e90721b 100644 --- a/capacitor.config.ts +++ b/capacitor.config.ts @@ -2,15 +2,34 @@ import type { CapacitorConfig } from '@capacitor/cli'; const config: CapacitorConfig = { appId: 'com.superproductivity.superproductivity', - appName: 'super-productivity', + appName: 'Super Productivity', webDir: 'dist/browser', plugins: { CapacitorHttp: { enabled: true, }, LocalNotifications: { + // Android-specific: small icon for notification smallIcon: 'ic_stat_sp', }, + Keyboard: { + // Resize the web view when keyboard appears (iOS) + resize: 'body', + // Style keyboard accessory bar + resizeOnFullScreen: true, + }, + StatusBar: { + // Status bar overlays webview (iOS) + overlaysWebView: false, + }, + }, + ios: { + // Content inset for safe areas (notch, home indicator) + contentInset: 'automatic', + // Allow inline media playback + allowsLinkPreview: true, + // Scroll behavior + scrollEnabled: true, }, }; diff --git a/ios/.gitignore b/ios/.gitignore new file mode 100644 index 000000000..f47029973 --- /dev/null +++ b/ios/.gitignore @@ -0,0 +1,13 @@ +App/build +App/Pods +App/output +App/App/public +DerivedData +xcuserdata + +# Cordova plugins for Capacitor +capacitor-cordova-ios-plugins + +# Generated Config files +App/App/capacitor.config.json +App/App/config.xml diff --git a/ios/App/App.xcodeproj/project.pbxproj b/ios/App/App.xcodeproj/project.pbxproj new file mode 100644 index 000000000..f9e7370f2 --- /dev/null +++ b/ios/App/App.xcodeproj/project.pbxproj @@ -0,0 +1,408 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 48; + objects = { + +/* Begin PBXBuildFile section */ + 2FAD9763203C412B000D30F8 /* config.xml in Resources */ = {isa = PBXBuildFile; fileRef = 2FAD9762203C412B000D30F8 /* config.xml */; }; + 50379B232058CBB4000EE86E /* capacitor.config.json in Resources */ = {isa = PBXBuildFile; fileRef = 50379B222058CBB4000EE86E /* capacitor.config.json */; }; + 504EC3081FED79650016851F /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504EC3071FED79650016851F /* AppDelegate.swift */; }; + 504EC30D1FED79650016851F /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30B1FED79650016851F /* Main.storyboard */; }; + 504EC30F1FED79650016851F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30E1FED79650016851F /* Assets.xcassets */; }; + 504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; }; + 50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; }; + A084ECDBA7D38E1E42DFC39D /* Pods_App.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AF277DCFFFF123FFC6DF26C7 /* Pods_App.framework */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 2FAD9762203C412B000D30F8 /* config.xml */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = config.xml; sourceTree = ""; }; + 50379B222058CBB4000EE86E /* capacitor.config.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = capacitor.config.json; sourceTree = ""; }; + 504EC3041FED79650016851F /* App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = App.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 504EC3071FED79650016851F /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 504EC30C1FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 504EC30E1FED79650016851F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 504EC3111FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = ""; }; + AF277DCFFFF123FFC6DF26C7 /* Pods_App.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_App.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + AF51FD2D460BCFE21FA515B2 /* Pods-App.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.release.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.release.xcconfig"; sourceTree = ""; }; + FC68EB0AF532CFC21C3344DD /* Pods-App.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.debug.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.debug.xcconfig"; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 504EC3011FED79650016851F /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + A084ECDBA7D38E1E42DFC39D /* Pods_App.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 27E2DDA53C4D2A4D1A88CE4A /* Frameworks */ = { + isa = PBXGroup; + children = ( + AF277DCFFFF123FFC6DF26C7 /* Pods_App.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; + 504EC2FB1FED79650016851F = { + isa = PBXGroup; + children = ( + 504EC3061FED79650016851F /* App */, + 504EC3051FED79650016851F /* Products */, + 7F8756D8B27F46E3366F6CEA /* Pods */, + 27E2DDA53C4D2A4D1A88CE4A /* Frameworks */, + ); + sourceTree = ""; + }; + 504EC3051FED79650016851F /* Products */ = { + isa = PBXGroup; + children = ( + 504EC3041FED79650016851F /* App.app */, + ); + name = Products; + sourceTree = ""; + }; + 504EC3061FED79650016851F /* App */ = { + isa = PBXGroup; + children = ( + 50379B222058CBB4000EE86E /* capacitor.config.json */, + 504EC3071FED79650016851F /* AppDelegate.swift */, + 504EC30B1FED79650016851F /* Main.storyboard */, + 504EC30E1FED79650016851F /* Assets.xcassets */, + 504EC3101FED79650016851F /* LaunchScreen.storyboard */, + 504EC3131FED79650016851F /* Info.plist */, + 2FAD9762203C412B000D30F8 /* config.xml */, + 50B271D01FEDC1A000F3C39B /* public */, + ); + path = App; + sourceTree = ""; + }; + 7F8756D8B27F46E3366F6CEA /* Pods */ = { + isa = PBXGroup; + children = ( + FC68EB0AF532CFC21C3344DD /* Pods-App.debug.xcconfig */, + AF51FD2D460BCFE21FA515B2 /* Pods-App.release.xcconfig */, + ); + name = Pods; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 504EC3031FED79650016851F /* App */ = { + isa = PBXNativeTarget; + buildConfigurationList = 504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "App" */; + buildPhases = ( + 6634F4EFEBD30273BCE97C65 /* [CP] Check Pods Manifest.lock */, + 504EC3001FED79650016851F /* Sources */, + 504EC3011FED79650016851F /* Frameworks */, + 504EC3021FED79650016851F /* Resources */, + 9592DBEFFC6D2A0C8D5DEB22 /* [CP] Embed Pods Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = App; + productName = App; + productReference = 504EC3041FED79650016851F /* App.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 504EC2FC1FED79650016851F /* Project object */ = { + isa = PBXProject; + attributes = { + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 0920; + TargetAttributes = { + 504EC3031FED79650016851F = { + CreatedOnToolsVersion = 9.2; + LastSwiftMigration = 1100; + ProvisioningStyle = Automatic; + }; + }; + }; + buildConfigurationList = 504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */; + compatibilityVersion = "Xcode 8.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 504EC2FB1FED79650016851F; + packageReferences = ( + ); + productRefGroup = 504EC3051FED79650016851F /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 504EC3031FED79650016851F /* App */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 504EC3021FED79650016851F /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */, + 50B271D11FEDC1A000F3C39B /* public in Resources */, + 504EC30F1FED79650016851F /* Assets.xcassets in Resources */, + 50379B232058CBB4000EE86E /* capacitor.config.json in Resources */, + 504EC30D1FED79650016851F /* Main.storyboard in Resources */, + 2FAD9763203C412B000D30F8 /* config.xml in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 6634F4EFEBD30273BCE97C65 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-App-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 9592DBEFFC6D2A0C8D5DEB22 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "[CP] Embed Pods Frameworks"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-App/Pods-App-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 504EC3001FED79650016851F /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 504EC3081FED79650016851F /* AppDelegate.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXVariantGroup section */ + 504EC30B1FED79650016851F /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 504EC30C1FED79650016851F /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 504EC3101FED79650016851F /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 504EC3111FED79650016851F /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 504EC3141FED79650016851F /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_IDENTITY = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 504EC3151FED79650016851F /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_IDENTITY = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 504EC3171FED79650016851F /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = FC68EB0AF532CFC21C3344DD /* Pods-App.debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + INFOPLIST_FILE = App/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + MARKETING_VERSION = 1.0; + OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\""; + PRODUCT_BUNDLE_IDENTIFIER = com.superproductivity.superproductivity; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 504EC3181FED79650016851F /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = AF51FD2D460BCFE21FA515B2 /* Pods-App.release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + INFOPLIST_FILE = App/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.superproductivity.superproductivity; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = ""; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 504EC3141FED79650016851F /* Debug */, + 504EC3151FED79650016851F /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "App" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 504EC3171FED79650016851F /* Debug */, + 504EC3181FED79650016851F /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 504EC2FC1FED79650016851F /* Project object */; +} diff --git a/ios/App/App.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/App/App.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 000000000..18d981003 --- /dev/null +++ b/ios/App/App.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/ios/App/App/AppDelegate.swift b/ios/App/App/AppDelegate.swift new file mode 100644 index 000000000..c3cd83b5c --- /dev/null +++ b/ios/App/App/AppDelegate.swift @@ -0,0 +1,49 @@ +import UIKit +import Capacitor + +@UIApplicationMain +class AppDelegate: UIResponder, UIApplicationDelegate { + + var window: UIWindow? + + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + // Override point for customization after application launch. + return true + } + + func applicationWillResignActive(_ application: UIApplication) { + // Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state. + // Use this method to pause ongoing tasks, disable timers, and invalidate graphics rendering callbacks. Games should use this method to pause the game. + } + + func applicationDidEnterBackground(_ application: UIApplication) { + // Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later. + // If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits. + } + + func applicationWillEnterForeground(_ application: UIApplication) { + // Called as part of the transition from the background to the active state; here you can undo many of the changes made on entering the background. + } + + func applicationDidBecomeActive(_ application: UIApplication) { + // Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface. + } + + func applicationWillTerminate(_ application: UIApplication) { + // Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:. + } + + func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool { + // Called when the app was launched with a url. Feel free to add additional processing here, + // but if you want the App API to support tracking app url opens, make sure to keep this call + return ApplicationDelegateProxy.shared.application(app, open: url, options: options) + } + + func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool { + // Called when the app was launched with an activity, including Universal Links. + // Feel free to add additional processing here, but if you want the App API to support + // tracking app url opens, make sure to keep this call + return ApplicationDelegateProxy.shared.application(application, continue: userActivity, restorationHandler: restorationHandler) + } + +} diff --git a/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png b/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png new file mode 100644 index 000000000..cc4b54330 Binary files /dev/null and b/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png differ diff --git a/ios/App/App/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/App/App/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 000000000..00b5bd341 --- /dev/null +++ b/ios/App/App/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images": [ + { + "filename": "AppIcon-512@2x.png", + "idiom": "universal", + "platform": "ios", + "size": "1024x1024" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/ios/App/App/Assets.xcassets/Contents.json b/ios/App/App/Assets.xcassets/Contents.json new file mode 100644 index 000000000..97a8662eb --- /dev/null +++ b/ios/App/App/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info": { + "version": 1, + "author": "xcode" + } +} diff --git a/ios/App/App/Assets.xcassets/Splash.imageset/Contents.json b/ios/App/App/Assets.xcassets/Splash.imageset/Contents.json new file mode 100644 index 000000000..b78149276 --- /dev/null +++ b/ios/App/App/Assets.xcassets/Splash.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images": [ + { + "idiom": "universal", + "filename": "splash-2732x2732-2.png", + "scale": "1x" + }, + { + "idiom": "universal", + "filename": "splash-2732x2732-1.png", + "scale": "2x" + }, + { + "idiom": "universal", + "filename": "splash-2732x2732.png", + "scale": "3x" + } + ], + "info": { + "version": 1, + "author": "xcode" + } +} diff --git a/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-1.png b/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-1.png new file mode 100644 index 000000000..75f885815 Binary files /dev/null and b/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-1.png differ diff --git a/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-2.png b/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-2.png new file mode 100644 index 000000000..75f885815 Binary files /dev/null and b/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-2.png differ diff --git a/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732.png b/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732.png new file mode 100644 index 000000000..75f885815 Binary files /dev/null and b/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732.png differ diff --git a/ios/App/App/Base.lproj/LaunchScreen.storyboard b/ios/App/App/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 000000000..e7ae5d780 --- /dev/null +++ b/ios/App/App/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/App/App/Base.lproj/Main.storyboard b/ios/App/App/Base.lproj/Main.storyboard new file mode 100644 index 000000000..b44df7be8 --- /dev/null +++ b/ios/App/App/Base.lproj/Main.storyboard @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/ios/App/App/Info.plist b/ios/App/App/Info.plist new file mode 100644 index 000000000..b65062ed3 --- /dev/null +++ b/ios/App/App/Info.plist @@ -0,0 +1,56 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + Super Productivity + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UIRequiredDeviceCapabilities + + armv7 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIViewControllerBasedStatusBarAppearance + + NSUserNotificationsUsageDescription + Super Productivity uses notifications to remind you about tasks and scheduled events. + UIBackgroundModes + + fetch + remote-notification + + + diff --git a/ios/App/Podfile b/ios/App/Podfile new file mode 100644 index 000000000..7de83bae0 --- /dev/null +++ b/ios/App/Podfile @@ -0,0 +1,30 @@ +require_relative '../../node_modules/@capacitor/ios/scripts/pods_helpers' + +platform :ios, '14.0' +use_frameworks! + +# workaround to avoid Xcode caching of Pods that requires +# Product -> Clean Build Folder after new Cordova plugins installed +# Requires CocoaPods 1.6 or newer +install! 'cocoapods', :disable_input_output_paths => true + +def capacitor_pods + pod 'Capacitor', :path => '../../node_modules/@capacitor/ios' + pod 'CapacitorCordova', :path => '../../node_modules/@capacitor/ios' + pod 'CapacitorKeyboard', :path => '../../node_modules/@capacitor/keyboard' + pod 'CapacitorStatusBar', :path => '../../node_modules/@capacitor/status-bar' + pod 'CapacitorApp', :path => '../../node_modules/@capacitor/app' + pod 'CapacitorFilesystem', :path => '../../node_modules/@capacitor/filesystem' + pod 'CapacitorLocalNotifications', :path => '../../node_modules/@capacitor/local-notifications' + pod 'CapacitorShare', :path => '../../node_modules/@capacitor/share' + pod 'CapawesomeCapacitorBackgroundTask', :path => '../../node_modules/@capawesome/capacitor-background-task' +end + +target 'App' do + capacitor_pods + # Add your Pods here +end + +post_install do |installer| + assertDeploymentTarget(installer) +end diff --git a/package-lock.json b/package-lock.json index 4958cf6e4..c38536901 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,9 @@ "packages/*" ], "dependencies": { + "@capacitor/ios": "^7.4.4", + "@capacitor/keyboard": "^7.0.4", + "@capacitor/status-bar": "^7.0.4", "electron-dl": "^3.5.2", "electron-localshortcut": "^3.2.1", "electron-log": "^5.4.3", @@ -5826,7 +5829,6 @@ "version": "7.4.3", "resolved": "https://registry.npmjs.org/@capacitor/core/-/core-7.4.3.tgz", "integrity": "sha512-wCWr8fQ9Wxn0466vPg7nMn0tivbNVjNy1yL4GvDSIZuZx7UpU2HeVGNe9QjN/quEd+YLRFeKEBLBw619VqUiNg==", - "dev": true, "license": "MIT", "dependencies": { "tslib": "^2.1.0" @@ -5843,6 +5845,24 @@ "@capacitor/core": ">=7.0.0" } }, + "node_modules/@capacitor/ios": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@capacitor/ios/-/ios-7.4.4.tgz", + "integrity": "sha512-Xp3bGWlSQAwsZGngRMWTdoD2agdMV12Whnm+/xsYPxfQSj+Tksbr7r/8Mso7VWkpnTKO4iMlx762g3PjW+wi4w==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": "^7.4.0" + } + }, + "node_modules/@capacitor/keyboard": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/@capacitor/keyboard/-/keyboard-7.0.4.tgz", + "integrity": "sha512-kKHsuDOC0q9iC1XANhQBK35S+hFKx4EfY9I+SEMPR6RuUAIuXQXYaA3+D0LkdRdHIf3OrlTDznPvXQ5Dg2WrCA==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": ">=7.0.0" + } + }, "node_modules/@capacitor/local-notifications": { "version": "7.0.1", "dev": true, @@ -5861,6 +5881,15 @@ "@capacitor/core": ">=7.0.0" } }, + "node_modules/@capacitor/status-bar": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/@capacitor/status-bar/-/status-bar-7.0.4.tgz", + "integrity": "sha512-2BszlCqIlBZxHLjRyQbumKyuuisutkeJH+5eSKAEJKaDVJcfmAzr2v3MXWsRLrAHJFteLzRXkOlce5msSy28tQ==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": ">=7.0.0" + } + }, "node_modules/@capacitor/synapse": { "version": "1.0.2", "dev": true, diff --git a/package.json b/package.json index 9272e2c1d..62b50bc44 100644 --- a/package.json +++ b/package.json @@ -136,6 +136,9 @@ "@ctrl/tinycolor": "4.1.0" }, "dependencies": { + "@capacitor/ios": "^7.4.4", + "@capacitor/keyboard": "^7.0.4", + "@capacitor/status-bar": "^7.0.4", "electron-dl": "^3.5.2", "electron-localshortcut": "^3.2.1", "electron-log": "^5.4.3", diff --git a/src/app/app.constants.ts b/src/app/app.constants.ts index 1beee0cca..757af8da2 100644 --- a/src/app/app.constants.ts +++ b/src/app/app.constants.ts @@ -46,6 +46,12 @@ export enum BodyClass { isAndroidKeyboardShown = 'isAndroidKeyboardShown', isAndroidKeyboardHidden = 'isAndroidKeyboardHidden', isAddTaskBarOpen = 'isAddTaskBarOpen', + + // iOS-specific classes + isIOS = 'isIOS', + isIPad = 'isIPad', + isNativeMobile = 'isNativeMobile', + isKeyboardVisible = 'isKeyboardVisible', } export enum HelperClasses { diff --git a/src/app/core/notify/notify.service.ts b/src/app/core/notify/notify.service.ts index af9b2a02b..4869b6515 100644 --- a/src/app/core/notify/notify.service.ts +++ b/src/app/core/notify/notify.service.ts @@ -5,10 +5,10 @@ import { IS_ELECTRON } from '../../app.constants'; import { IS_MOBILE } from '../../util/is-mobile'; import { TranslateService } from '@ngx-translate/core'; import { UiHelperService } from '../../features/ui-helper/ui-helper.service'; -import { IS_ANDROID_WEB_VIEW } from '../../util/is-android-web-view'; import { Log } from '../log'; -import { LocalNotifications } from '@capacitor/local-notifications'; import { generateNotificationId } from '../../features/android/android-notification-id.util'; +import { CapacitorNotificationService } from '../platform/capacitor-notification.service'; +import { CapacitorPlatformService } from '../platform/capacitor-platform.service'; @Injectable({ providedIn: 'root', @@ -16,6 +16,8 @@ import { generateNotificationId } from '../../features/android/android-notificat export class NotifyService { private _translateService = inject(TranslateService); private _uiHelperService = inject(UiHelperService); + private _platformService = inject(CapacitorPlatformService); + private _notificationService = inject(CapacitorNotificationService); async notifyDesktop(options: NotifyModel): Promise { if (!IS_MOBILE) { @@ -53,48 +55,29 @@ export class NotifyService { body, }); } - } else if (IS_ANDROID_WEB_VIEW) { + } else if (this._platformService.isNative) { + // Use Capacitor LocalNotifications for iOS and Android try { - // Check permissions - const checkResult = await LocalNotifications.checkPermissions(); - let displayPermissionGranted = checkResult.display === 'granted'; - - // Request permissions if not granted - if (!displayPermissionGranted) { - const requestResult = await LocalNotifications.requestPermissions(); - displayPermissionGranted = requestResult.display === 'granted'; - if (!displayPermissionGranted) { - Log.warn('NotifyService: Notification permission not granted'); - return; - } - } - // Generate a deterministic notification ID from title and body // Use a prefix to distinguish plugin notifications from reminders const notificationKey = `plugin-notification:${title}:${body}`; const notificationId = generateNotificationId(notificationKey); - // Schedule an immediate notification - await LocalNotifications.schedule({ - notifications: [ - { - id: notificationId, - title, - body, - schedule: { - at: new Date(Date.now() + 1000), // Show after 1 second - allowWhileIdle: true, - }, - }, - ], - }); - - Log.log('NotifyService: Android notification scheduled successfully', { + const success = await this._notificationService.schedule({ id: notificationId, title, + body, }); + + if (success) { + Log.log('NotifyService: Mobile notification scheduled successfully', { + id: notificationId, + title, + platform: this._platformService.platform, + }); + } } catch (error) { - Log.err('NotifyService: Failed to show Android notification', error); + Log.err('NotifyService: Failed to show mobile notification', error); } } else if (this._isBasicNotificationSupport()) { const permission = await Notification.requestPermission(); @@ -122,8 +105,14 @@ export class NotifyService { }, options.duration || 10000); return instance; } + } else { + Log.warn('NotifyService: No notification method available', { + platform: this._platformService.platform, + isNative: this._platformService.isNative, + hasServiceWorker: this._isServiceWorkerAvailable(), + hasBasicNotification: this._isBasicNotificationSupport(), + }); } - Log.err('No notifications supported'); return undefined; } diff --git a/src/app/core/platform/capacitor-notification.service.spec.ts b/src/app/core/platform/capacitor-notification.service.spec.ts new file mode 100644 index 000000000..2f4aaca18 --- /dev/null +++ b/src/app/core/platform/capacitor-notification.service.spec.ts @@ -0,0 +1,143 @@ +import { TestBed } from '@angular/core/testing'; +import { CapacitorNotificationService } from './capacitor-notification.service'; +import { CapacitorPlatformService } from './capacitor-platform.service'; + +describe('CapacitorNotificationService', () => { + let service: CapacitorNotificationService; + let platformServiceSpy: jasmine.SpyObj; + + beforeEach(() => { + // Create a spy for CapacitorPlatformService + platformServiceSpy = jasmine.createSpyObj( + 'CapacitorPlatformService', + ['hasCapability', 'isIOS', 'isAndroid'], + { + platform: 'web', + isNative: false, + isMobile: false, + capabilities: { + backgroundTracking: false, + backgroundFocusTimer: false, + localFileSync: false, + homeWidget: false, + scheduledNotifications: false, + webdavSync: true, + shareOut: true, + shareIn: false, + darkMode: true, + }, + }, + ); + + TestBed.configureTestingModule({ + providers: [ + CapacitorNotificationService, + { provide: CapacitorPlatformService, useValue: platformServiceSpy }, + ], + }); + service = TestBed.inject(CapacitorNotificationService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + describe('isAvailable', () => { + it('should return false when scheduledNotifications capability is false', () => { + expect(service.isAvailable).toBe(false); + }); + + it('should return true when scheduledNotifications capability is true', () => { + // Create a new spy with notifications enabled + const nativeServiceSpy = jasmine.createSpyObj( + 'CapacitorPlatformService', + ['hasCapability'], + { + capabilities: { + scheduledNotifications: true, + }, + }, + ); + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + CapacitorNotificationService, + { provide: CapacitorPlatformService, useValue: nativeServiceSpy }, + ], + }); + const nativeService = TestBed.inject(CapacitorNotificationService); + expect(nativeService.isAvailable).toBe(true); + }); + }); + + describe('requestPermissions', () => { + it('should return false when not available', async () => { + const result = await service.requestPermissions(); + expect(result).toBe(false); + }); + }); + + describe('checkPermissions', () => { + it('should return false when not available', async () => { + const result = await service.checkPermissions(); + expect(result).toBe(false); + }); + }); + + describe('ensurePermissions', () => { + it('should return false when not available', async () => { + const result = await service.ensurePermissions(); + expect(result).toBe(false); + }); + }); + + describe('schedule', () => { + it('should return false when not available', async () => { + const result = await service.schedule({ + id: 1, + title: 'Test', + body: 'Test body', + }); + expect(result).toBe(false); + }); + }); + + describe('cancel', () => { + it('should return false when not available', async () => { + const result = await service.cancel(1); + expect(result).toBe(false); + }); + }); + + describe('cancelMultiple', () => { + it('should return false when not available', async () => { + const result = await service.cancelMultiple([1, 2, 3]); + expect(result).toBe(false); + }); + + it('should return false for empty array', async () => { + const result = await service.cancelMultiple([]); + expect(result).toBe(false); + }); + }); + + describe('getPending', () => { + it('should return empty array when not available', async () => { + const result = await service.getPending(); + expect(result).toEqual([]); + }); + }); + + describe('addActionListener', () => { + it('should not throw when not available', async () => { + await expectAsync(service.addActionListener(() => {})).toBeResolved(); + }); + }); + + describe('removeAllListeners', () => { + it('should not throw when not available', async () => { + await expectAsync(service.removeAllListeners()).toBeResolved(); + }); + }); +}); diff --git a/src/app/core/platform/capacitor-notification.service.ts b/src/app/core/platform/capacitor-notification.service.ts new file mode 100644 index 000000000..a7c75367f --- /dev/null +++ b/src/app/core/platform/capacitor-notification.service.ts @@ -0,0 +1,325 @@ +import { inject, Injectable } from '@angular/core'; +import { + ActionPerformed, + LocalNotifications, + ScheduleOptions, +} from '@capacitor/local-notifications'; +import { Log } from '../log'; +import { CapacitorPlatformService } from './capacitor-platform.service'; +import { Subject } from 'rxjs'; + +/** + * Notification action IDs for reminder notifications + */ +export const NOTIFICATION_ACTION = { + SNOOZE: 'snooze', + DONE: 'done', +} as const; + +/** + * Action type ID for reminder notifications with action buttons + */ +export const REMINDER_ACTION_TYPE_ID = 'reminder-actions'; + +export interface ScheduleNotificationOptions { + id: number; + title: string; + body: string; + /** + * When to show the notification. If not provided, shows immediately. + */ + scheduleAt?: Date; + /** + * Extra data to attach to the notification + */ + extra?: Record; + /** + * Whether to allow notification when device is idle (Android) + */ + allowWhileIdle?: boolean; + /** + * Action type ID for notification actions (iOS) + */ + actionTypeId?: string; +} + +export interface NotificationActionEvent { + actionId: string; + notificationId: number; + extra?: Record; +} + +/** + * Service for managing local notifications via Capacitor. + * + * This service provides a unified interface for scheduling and canceling + * local notifications on iOS and Android. + */ +@Injectable({ + providedIn: 'root', +}) +export class CapacitorNotificationService { + private _platformService = inject(CapacitorPlatformService); + private _actionsRegistered = false; + + /** + * Subject that emits when a notification action is performed + */ + readonly action$ = new Subject(); + + /** + * Check if notifications are available on this platform + */ + get isAvailable(): boolean { + return this._platformService.capabilities.scheduledNotifications; + } + + /** + * Register action types for reminder notifications (iOS). + * Call this once during app initialization. + */ + async registerReminderActions(): Promise { + if (!this.isAvailable || this._actionsRegistered) { + return; + } + + try { + // Register action types with Snooze and Done buttons + await LocalNotifications.registerActionTypes({ + types: [ + { + id: REMINDER_ACTION_TYPE_ID, + actions: [ + { + id: NOTIFICATION_ACTION.SNOOZE, + title: 'Snooze', + }, + { + id: NOTIFICATION_ACTION.DONE, + title: 'Done', + destructive: true, + }, + ], + }, + ], + }); + + // Listen for action events + await LocalNotifications.addListener( + 'localNotificationActionPerformed', + (event: ActionPerformed) => { + Log.log('CapacitorNotificationService: Action performed', { + actionId: event.actionId, + notificationId: event.notification.id, + }); + + this.action$.next({ + actionId: event.actionId, + notificationId: event.notification.id, + extra: event.notification.extra, + }); + }, + ); + + this._actionsRegistered = true; + Log.log('CapacitorNotificationService: Reminder actions registered'); + } catch (error) { + Log.err('CapacitorNotificationService: Failed to register actions', error); + } + } + + /** + * Request notification permissions from the user + */ + async requestPermissions(): Promise { + if (!this.isAvailable) { + return false; + } + + try { + const result = await LocalNotifications.requestPermissions(); + return result.display === 'granted'; + } catch (error) { + Log.err('CapacitorNotificationService: Failed to request permissions', error); + return false; + } + } + + /** + * Check current notification permission status + */ + async checkPermissions(): Promise { + if (!this.isAvailable) { + return false; + } + + try { + const result = await LocalNotifications.checkPermissions(); + return result.display === 'granted'; + } catch (error) { + Log.err('CapacitorNotificationService: Failed to check permissions', error); + return false; + } + } + + /** + * Ensure permissions are granted, requesting if necessary + */ + async ensurePermissions(): Promise { + const hasPermission = await this.checkPermissions(); + if (hasPermission) { + return true; + } + return this.requestPermissions(); + } + + /** + * Schedule a local notification + */ + async schedule(options: ScheduleNotificationOptions): Promise { + if (!this.isAvailable) { + Log.warn( + 'CapacitorNotificationService: Notifications not available on this platform', + ); + return false; + } + + const hasPermission = await this.ensurePermissions(); + if (!hasPermission) { + Log.warn('CapacitorNotificationService: Notification permission not granted'); + return false; + } + + try { + const scheduleOptions: ScheduleOptions = { + notifications: [ + { + id: options.id, + title: options.title, + body: options.body, + extra: options.extra, + // Include action type for iOS notification actions + actionTypeId: options.actionTypeId, + schedule: options.scheduleAt + ? { + at: options.scheduleAt, + allowWhileIdle: options.allowWhileIdle ?? true, + } + : { + // Schedule for 1 second from now for immediate notifications + at: new Date(Date.now() + 1000), + allowWhileIdle: options.allowWhileIdle ?? true, + }, + }, + ], + }; + + await LocalNotifications.schedule(scheduleOptions); + + Log.log('CapacitorNotificationService: Notification scheduled', { + id: options.id, + title: options.title, + scheduleAt: options.scheduleAt, + }); + + return true; + } catch (error) { + Log.err('CapacitorNotificationService: Failed to schedule notification', error); + return false; + } + } + + /** + * Cancel a scheduled notification by ID + */ + async cancel(notificationId: number): Promise { + if (!this.isAvailable) { + return false; + } + + try { + await LocalNotifications.cancel({ + notifications: [{ id: notificationId }], + }); + + Log.log('CapacitorNotificationService: Notification cancelled', { + id: notificationId, + }); + + return true; + } catch (error) { + Log.err('CapacitorNotificationService: Failed to cancel notification', error); + return false; + } + } + + /** + * Cancel multiple scheduled notifications + */ + async cancelMultiple(notificationIds: number[]): Promise { + if (!this.isAvailable || notificationIds.length === 0) { + return false; + } + + try { + await LocalNotifications.cancel({ + notifications: notificationIds.map((id) => ({ id })), + }); + + Log.log('CapacitorNotificationService: Notifications cancelled', { + ids: notificationIds, + }); + + return true; + } catch (error) { + Log.err('CapacitorNotificationService: Failed to cancel notifications', error); + return false; + } + } + + /** + * Get all pending notifications + */ + async getPending(): Promise<{ id: number }[]> { + if (!this.isAvailable) { + return []; + } + + try { + const result = await LocalNotifications.getPending(); + return result.notifications; + } catch (error) { + Log.err('CapacitorNotificationService: Failed to get pending notifications', error); + return []; + } + } + + /** + * Register a listener for notification actions + */ + async addActionListener( + callback: (notification: { id: number; extra?: Record }) => void, + ): Promise { + if (!this.isAvailable) { + return; + } + + await LocalNotifications.addListener('localNotificationActionPerformed', (event) => { + callback({ + id: event.notification.id, + extra: event.notification.extra, + }); + }); + } + + /** + * Remove all notification listeners + */ + async removeAllListeners(): Promise { + if (!this.isAvailable) { + return; + } + + await LocalNotifications.removeAllListeners(); + } +} diff --git a/src/app/core/platform/capacitor-platform.service.spec.ts b/src/app/core/platform/capacitor-platform.service.spec.ts new file mode 100644 index 000000000..e390ccab7 --- /dev/null +++ b/src/app/core/platform/capacitor-platform.service.spec.ts @@ -0,0 +1,64 @@ +import { TestBed } from '@angular/core/testing'; +import { CapacitorPlatformService } from './capacitor-platform.service'; + +describe('CapacitorPlatformService', () => { + let service: CapacitorPlatformService; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [CapacitorPlatformService], + }); + service = TestBed.inject(CapacitorPlatformService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should detect platform type', () => { + expect(service.platform).toBeDefined(); + expect(['ios', 'android', 'web', 'electron']).toContain(service.platform); + }); + + it('should have capabilities object', () => { + expect(service.capabilities).toBeDefined(); + expect(typeof service.capabilities.scheduledNotifications).toBe('boolean'); + expect(typeof service.capabilities.webdavSync).toBe('boolean'); + expect(typeof service.capabilities.shareOut).toBe('boolean'); + }); + + it('should have consistent platform checks', () => { + // Only one platform method should return true + const platformChecks = [ + service.isIOS(), + service.isAndroid(), + service.isElectron(), + service.isWeb(), + ]; + const trueCount = platformChecks.filter((x) => x).length; + expect(trueCount).toBe(1); + }); + + it('should check capability via hasCapability method', () => { + expect(service.hasCapability('webdavSync')).toBe(service.capabilities.webdavSync); + expect(service.hasCapability('scheduledNotifications')).toBe( + service.capabilities.scheduledNotifications, + ); + }); + + describe('in web environment', () => { + // These tests run in Karma which is a web browser + it('should detect web platform in test environment', () => { + // In Karma test runner, we're in a web context + expect(service.platform).toBe('web'); + expect(service.isWeb()).toBe(true); + expect(service.isNative).toBe(false); + }); + + it('should have web capabilities', () => { + expect(service.capabilities.backgroundTracking).toBe(false); + expect(service.capabilities.localFileSync).toBe(false); + expect(service.capabilities.webdavSync).toBe(true); + }); + }); +}); diff --git a/src/app/core/platform/capacitor-platform.service.ts b/src/app/core/platform/capacitor-platform.service.ts new file mode 100644 index 000000000..66727988a --- /dev/null +++ b/src/app/core/platform/capacitor-platform.service.ts @@ -0,0 +1,188 @@ +import { Injectable } from '@angular/core'; +import { Capacitor } from '@capacitor/core'; +import { + ANDROID_CAPABILITIES, + ELECTRON_CAPABILITIES, + IOS_CAPABILITIES, + PlatformCapabilities, + PlatformType, + WEB_CAPABILITIES, +} from './platform-capabilities.model'; + +/** + * Service for detecting platform and exposing platform-specific capabilities. + * + * This service provides a unified interface for platform detection and feature + * availability, replacing scattered IS_ANDROID_WEB_VIEW checks throughout the codebase. + * + * Usage: + * ```typescript + * if (this.platformService.capabilities.scheduledNotifications) { + * // Schedule notification using Capacitor plugin + * } + * ``` + */ +@Injectable({ + providedIn: 'root', +}) +export class CapacitorPlatformService { + /** + * The current platform type + */ + readonly platform: PlatformType; + + /** + * Whether running in a native Capacitor context (iOS or Android) + */ + readonly isNative: boolean; + + /** + * Whether running in a mobile context (iOS or Android, native or PWA) + */ + readonly isMobile: boolean; + + /** + * Platform capabilities for conditional feature enabling + */ + readonly capabilities: PlatformCapabilities; + + constructor() { + this.platform = this._detectPlatform(); + // Include legacy Android WebView in isNative check + this.isNative = Capacitor.isNativePlatform() || this._isAndroidWebView(); + this.isMobile = this.platform === 'ios' || this.platform === 'android'; + this.capabilities = this._getCapabilities(); + } + + /** + * Check if a specific capability is available + */ + hasCapability(capability: keyof PlatformCapabilities): boolean { + return this.capabilities[capability]; + } + + /** + * Check if running on iOS + */ + isIOS(): boolean { + return this.platform === 'ios'; + } + + /** + * Check if running on Android + */ + isAndroid(): boolean { + return this.platform === 'android'; + } + + /** + * Check if running in Electron + */ + isElectron(): boolean { + return this.platform === 'electron'; + } + + /** + * Check if running in web browser (not Electron, not native mobile) + */ + isWeb(): boolean { + return this.platform === 'web'; + } + + /** + * Check if running on iPad (native or browser) + */ + isIPad(): boolean { + if (this.platform !== 'ios') { + return false; + } + // Check for iPad identifier in user agent + const userAgent = navigator.userAgent; + if (/iPad/.test(userAgent)) { + return true; + } + // iPad on iOS 13+ reports as Mac with touch support + if (userAgent.includes('Mac') && 'ontouchend' in document) { + return true; + } + return false; + } + + /** + * Detect the current platform + */ + private _detectPlatform(): PlatformType { + // Check for Electron first (it also has navigator) + if (this._isElectron()) { + return 'electron'; + } + + // Check for native Capacitor platforms + if (Capacitor.isNativePlatform()) { + const platform = Capacitor.getPlatform(); + if (platform === 'ios') { + return 'ios'; + } + if (platform === 'android') { + return 'android'; + } + } + + // Check for Android WebView (legacy check for existing Android implementation) + if (this._isAndroidWebView()) { + return 'android'; + } + + // Check for iOS in browser context (iPad, iPhone) + if (this._isIOSBrowser()) { + // Running in iOS browser, not native - treat as web + return 'web'; + } + + return 'web'; + } + + /** + * Get capabilities for the current platform + */ + private _getCapabilities(): PlatformCapabilities { + switch (this.platform) { + case 'ios': + return IOS_CAPABILITIES; + case 'android': + return ANDROID_CAPABILITIES; + case 'electron': + return ELECTRON_CAPABILITIES; + default: + return WEB_CAPABILITIES; + } + } + + /** + * Check if running in Electron + */ + private _isElectron(): boolean { + return navigator.userAgent.toLowerCase().indexOf(' electron/') > -1; + } + + /** + * Check if running in Android WebView (legacy detection) + */ + private _isAndroidWebView(): boolean { + return !!(window as any).SUPAndroid; + } + + /** + * Check if running in iOS browser (not native) + */ + private _isIOSBrowser(): boolean { + // Check user agent for iOS devices + const userAgent = navigator.userAgent; + const isIOSUserAgent = + /iPad|iPhone|iPod/.test(userAgent) || + // iPad on iOS 13+ reports as Mac with touch support + (userAgent.includes('Mac') && 'ontouchend' in document); + + return isIOSUserAgent; + } +} diff --git a/src/app/core/platform/capacitor-reminder.service.spec.ts b/src/app/core/platform/capacitor-reminder.service.spec.ts new file mode 100644 index 000000000..8999cffec --- /dev/null +++ b/src/app/core/platform/capacitor-reminder.service.spec.ts @@ -0,0 +1,185 @@ +import { TestBed } from '@angular/core/testing'; +import { CapacitorReminderService } from './capacitor-reminder.service'; +import { CapacitorPlatformService } from './capacitor-platform.service'; +import { CapacitorNotificationService } from './capacitor-notification.service'; + +describe('CapacitorReminderService', () => { + let service: CapacitorReminderService; + let platformServiceSpy: jasmine.SpyObj; + let notificationServiceSpy: jasmine.SpyObj; + + beforeEach(() => { + // Create spies for dependencies + platformServiceSpy = jasmine.createSpyObj( + 'CapacitorPlatformService', + ['hasCapability', 'isIOS', 'isAndroid'], + { + platform: 'web', + isNative: false, + isMobile: false, + capabilities: { + backgroundTracking: false, + backgroundFocusTimer: false, + localFileSync: false, + homeWidget: false, + scheduledNotifications: false, + webdavSync: true, + shareOut: true, + shareIn: false, + darkMode: true, + }, + }, + ); + + notificationServiceSpy = jasmine.createSpyObj('CapacitorNotificationService', [ + 'ensurePermissions', + 'cancel', + 'cancelMultiple', + ]); + notificationServiceSpy.ensurePermissions.and.returnValue(Promise.resolve(true)); + notificationServiceSpy.cancel.and.returnValue(Promise.resolve(true)); + notificationServiceSpy.cancelMultiple.and.returnValue(Promise.resolve(true)); + + TestBed.configureTestingModule({ + providers: [ + CapacitorReminderService, + { provide: CapacitorPlatformService, useValue: platformServiceSpy }, + { provide: CapacitorNotificationService, useValue: notificationServiceSpy }, + ], + }); + service = TestBed.inject(CapacitorReminderService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + describe('isAvailable', () => { + it('should return false when scheduledNotifications capability is false', () => { + expect(service.isAvailable).toBe(false); + }); + + it('should return true when scheduledNotifications capability is true', () => { + const nativePlatformSpy = jasmine.createSpyObj( + 'CapacitorPlatformService', + ['hasCapability', 'isIOS'], + { + platform: 'ios', + isNative: true, + capabilities: { + scheduledNotifications: true, + }, + }, + ); + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + CapacitorReminderService, + { provide: CapacitorPlatformService, useValue: nativePlatformSpy }, + { provide: CapacitorNotificationService, useValue: notificationServiceSpy }, + ], + }); + const nativeService = TestBed.inject(CapacitorReminderService); + expect(nativeService.isAvailable).toBe(true); + }); + }); + + describe('scheduleReminder', () => { + it('should return false when not available', async () => { + const result = await service.scheduleReminder({ + notificationId: 1, + reminderId: 'task-1', + relatedId: 'task-1', + title: 'Test Reminder', + reminderType: 'TASK', + triggerAtMs: Date.now() + 60000, + }); + expect(result).toBe(false); + }); + }); + + describe('cancelReminder', () => { + it('should return false when not available', async () => { + const result = await service.cancelReminder(1); + expect(result).toBe(false); + }); + }); + + describe('cancelMultipleReminders', () => { + it('should return false when not available', async () => { + const result = await service.cancelMultipleReminders([1, 2, 3]); + expect(result).toBe(false); + }); + + it('should return false for empty array', async () => { + const result = await service.cancelMultipleReminders([]); + expect(result).toBe(false); + }); + }); + + describe('ensurePermissions', () => { + it('should return false when not available', async () => { + const result = await service.ensurePermissions(); + expect(result).toBe(false); + }); + }); + + describe('with native platform', () => { + let nativeService: CapacitorReminderService; + let nativePlatformSpy: jasmine.SpyObj; + + beforeEach(() => { + nativePlatformSpy = jasmine.createSpyObj( + 'CapacitorPlatformService', + ['hasCapability', 'isIOS'], + { + platform: 'ios', + isNative: true, + isMobile: true, + capabilities: { + backgroundTracking: false, + backgroundFocusTimer: false, + localFileSync: false, + homeWidget: false, + scheduledNotifications: true, + webdavSync: true, + shareOut: true, + shareIn: false, + darkMode: true, + }, + }, + ); + nativePlatformSpy.isIOS.and.returnValue(true); + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + CapacitorReminderService, + { provide: CapacitorPlatformService, useValue: nativePlatformSpy }, + { provide: CapacitorNotificationService, useValue: notificationServiceSpy }, + ], + }); + nativeService = TestBed.inject(CapacitorReminderService); + }); + + it('should be available on native platform', () => { + expect(nativeService.isAvailable).toBe(true); + }); + + it('should call notificationService.cancel when cancelling reminder', async () => { + await nativeService.cancelReminder(123); + expect(notificationServiceSpy.cancel).toHaveBeenCalledWith(123); + }); + + it('should call notificationService.cancelMultiple when cancelling multiple reminders', async () => { + await nativeService.cancelMultipleReminders([1, 2, 3]); + expect(notificationServiceSpy.cancelMultiple).toHaveBeenCalledWith([1, 2, 3]); + }); + + it('should call notificationService.ensurePermissions when ensuring permissions', async () => { + await nativeService.ensurePermissions(); + expect(notificationServiceSpy.ensurePermissions).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/app/core/platform/capacitor-reminder.service.ts b/src/app/core/platform/capacitor-reminder.service.ts new file mode 100644 index 000000000..3ddbc7b6c --- /dev/null +++ b/src/app/core/platform/capacitor-reminder.service.ts @@ -0,0 +1,255 @@ +import { inject, Injectable } from '@angular/core'; +import { LocalNotifications } from '@capacitor/local-notifications'; +import { Log } from '../log'; +import { CapacitorPlatformService } from './capacitor-platform.service'; +import { + CapacitorNotificationService, + NotificationActionEvent, + REMINDER_ACTION_TYPE_ID, +} from './capacitor-notification.service'; +import { IS_ANDROID_WEB_VIEW } from '../../util/is-android-web-view'; +import { androidInterface } from '../../features/android/android-interface'; +import { Observable } from 'rxjs'; + +export interface ScheduleReminderOptions { + /** + * Unique notification ID (numeric) + */ + notificationId: number; + /** + * Reminder identifier (string, e.g., task ID) + */ + reminderId: string; + /** + * Related entity ID (string, e.g., task ID) + */ + relatedId: string; + /** + * Notification title + */ + title: string; + /** + * Type of reminder (e.g., 'TASK') + */ + reminderType: string; + /** + * Timestamp when the reminder should trigger (in milliseconds) + */ + triggerAtMs: number; +} + +/** + * Service for scheduling reminders across platforms. + * + * On Android: Uses native AlarmManager via androidInterface for precise timing + * On iOS: Uses Capacitor LocalNotifications for scheduled notifications + */ +@Injectable({ + providedIn: 'root', +}) +export class CapacitorReminderService { + private _platformService = inject(CapacitorPlatformService); + private _notificationService = inject(CapacitorNotificationService); + + /** + * Observable that emits when a notification action is performed (iOS). + * Use this to handle snooze/done button taps from notifications. + */ + get action$(): Observable { + return this._notificationService.action$; + } + + /** + * Check if reminder scheduling is available on this platform + */ + get isAvailable(): boolean { + return this._platformService.capabilities.scheduledNotifications; + } + + /** + * Initialize the reminder service. + * Registers notification action types on iOS. + */ + async initialize(): Promise { + if (this._platformService.isIOS()) { + await this._notificationService.registerReminderActions(); + } + } + + /** + * Schedule a reminder notification + */ + async scheduleReminder(options: ScheduleReminderOptions): Promise { + if (!this.isAvailable) { + Log.warn('CapacitorReminderService: Scheduled notifications not available'); + return false; + } + + const now = Date.now(); + const triggerAt = options.triggerAtMs <= now ? now + 1000 : options.triggerAtMs; + + // On Android, use native AlarmManager for precision + if (IS_ANDROID_WEB_VIEW && androidInterface.scheduleNativeReminder) { + try { + androidInterface.scheduleNativeReminder( + options.notificationId, + options.reminderId, + options.relatedId, + options.title, + options.reminderType, + triggerAt, + ); + Log.log('CapacitorReminderService: Android reminder scheduled', { + notificationId: options.notificationId, + title: options.title, + triggerAt: new Date(triggerAt).toISOString(), + }); + return true; + } catch (error) { + Log.err('CapacitorReminderService: Failed to schedule Android reminder', error); + return false; + } + } + + // On iOS (and Android as fallback), use Capacitor LocalNotifications + if (this._platformService.isNative) { + try { + const hasPermission = await this._notificationService.ensurePermissions(); + if (!hasPermission) { + Log.warn('CapacitorReminderService: Notification permission not granted'); + return false; + } + + await LocalNotifications.schedule({ + notifications: [ + { + id: options.notificationId, + title: options.title, + body: `Reminder: ${options.title}`, + // Include action type for iOS notification actions (Snooze/Done buttons) + actionTypeId: this._platformService.isIOS() + ? REMINDER_ACTION_TYPE_ID + : undefined, + schedule: { + at: new Date(triggerAt), + allowWhileIdle: true, + }, + extra: { + reminderId: options.reminderId, + relatedId: options.relatedId, + reminderType: options.reminderType, + }, + }, + ], + }); + + Log.log('CapacitorReminderService: iOS reminder scheduled', { + notificationId: options.notificationId, + title: options.title, + triggerAt: new Date(triggerAt).toISOString(), + }); + return true; + } catch (error) { + Log.err('CapacitorReminderService: Failed to schedule iOS reminder', error); + return false; + } + } + + Log.warn('CapacitorReminderService: No reminder implementation for platform', { + platform: this._platformService.platform, + }); + return false; + } + + /** + * Cancel a scheduled reminder + */ + async cancelReminder(notificationId: number): Promise { + if (!this.isAvailable) { + return false; + } + + // On Android, use native cancellation + if (IS_ANDROID_WEB_VIEW && androidInterface.cancelNativeReminder) { + try { + androidInterface.cancelNativeReminder(notificationId); + Log.log('CapacitorReminderService: Android reminder cancelled', { + notificationId, + }); + return true; + } catch (error) { + Log.err('CapacitorReminderService: Failed to cancel Android reminder', error); + return false; + } + } + + // On iOS (and Android as fallback), use Capacitor LocalNotifications + if (this._platformService.isNative) { + return this._notificationService.cancel(notificationId); + } + + return false; + } + + /** + * Cancel multiple scheduled reminders + */ + async cancelMultipleReminders(notificationIds: number[]): Promise { + if (!this.isAvailable || notificationIds.length === 0) { + return false; + } + + // On Android, cancel each individually + if (IS_ANDROID_WEB_VIEW && androidInterface.cancelNativeReminder) { + try { + for (const id of notificationIds) { + androidInterface.cancelNativeReminder(id); + } + Log.log('CapacitorReminderService: Android reminders cancelled', { + count: notificationIds.length, + }); + return true; + } catch (error) { + Log.err('CapacitorReminderService: Failed to cancel Android reminders', error); + return false; + } + } + + // On iOS (and Android as fallback), use batch cancellation + if (this._platformService.isNative) { + return this._notificationService.cancelMultiple(notificationIds); + } + + return false; + } + + /** + * Ensure notification permissions are granted. + * Also handles Android 12+ exact alarm permissions. + */ + async ensurePermissions(): Promise { + if (!this.isAvailable) { + return false; + } + + const hasPermission = await this._notificationService.ensurePermissions(); + if (!hasPermission) { + return false; + } + + // On Android 12+, also check exact alarm permission + if (IS_ANDROID_WEB_VIEW) { + try { + const exactAlarmStatus = await LocalNotifications.checkExactNotificationSetting(); + if (exactAlarmStatus?.exact_alarm !== 'granted') { + await LocalNotifications.changeExactNotificationSetting(); + } + } catch (error) { + // Non-fatal - exact alarms may not be available on all devices + Log.warn('CapacitorReminderService: Exact alarm check failed', error); + } + } + + return true; + } +} diff --git a/src/app/core/platform/index.ts b/src/app/core/platform/index.ts new file mode 100644 index 000000000..5afaa8337 --- /dev/null +++ b/src/app/core/platform/index.ts @@ -0,0 +1,4 @@ +export * from './platform-capabilities.model'; +export * from './capacitor-platform.service'; +export * from './capacitor-notification.service'; +export * from './capacitor-reminder.service'; diff --git a/src/app/core/platform/platform-capabilities.model.ts b/src/app/core/platform/platform-capabilities.model.ts new file mode 100644 index 000000000..d9e25a9c2 --- /dev/null +++ b/src/app/core/platform/platform-capabilities.model.ts @@ -0,0 +1,124 @@ +/** + * Platform types supported by the application + */ +export type PlatformType = 'ios' | 'android' | 'web' | 'electron'; + +/** + * Runtime capabilities that vary by platform. + * Used to conditionally enable/disable features in the UI. + */ +export interface PlatformCapabilities { + /** + * Whether the platform supports background time tracking notifications. + * Android only - uses foreground service. + */ + readonly backgroundTracking: boolean; + + /** + * Whether the platform supports background focus mode timer. + * Android only - uses foreground service. + */ + readonly backgroundFocusTimer: boolean; + + /** + * Whether the platform supports local file sync (Storage Access Framework). + * Android only for MVP. + */ + readonly localFileSync: boolean; + + /** + * Whether the platform supports home screen widgets. + * Android only for MVP. + */ + readonly homeWidget: boolean; + + /** + * Whether the platform supports scheduled local notifications. + * iOS and Android via Capacitor LocalNotifications plugin. + */ + readonly scheduledNotifications: boolean; + + /** + * Whether the platform supports WebDAV sync. + * All platforms - uses CapacitorHttp on mobile, fetch on web. + */ + readonly webdavSync: boolean; + + /** + * Whether the platform supports sharing content to other apps. + * iOS and Android via Capacitor Share plugin, Web via Web Share API. + */ + readonly shareOut: boolean; + + /** + * Whether the platform supports receiving shared content from other apps. + * Android only for MVP. iOS requires Share Extension (post-MVP). + */ + readonly shareIn: boolean; + + /** + * Whether the platform supports native dark mode detection. + * All platforms support this. + */ + readonly darkMode: boolean; +} + +/** + * Default capabilities for web browser + */ +export const WEB_CAPABILITIES: PlatformCapabilities = { + backgroundTracking: false, + backgroundFocusTimer: false, + localFileSync: false, + homeWidget: false, + scheduledNotifications: false, // Service worker notifications only + webdavSync: true, + shareOut: true, // Web Share API + shareIn: false, + darkMode: true, +}; + +/** + * Capabilities for Electron desktop app + */ +export const ELECTRON_CAPABILITIES: PlatformCapabilities = { + backgroundTracking: true, // Tray icon + backgroundFocusTimer: true, // Always visible window + localFileSync: true, // Native file system + homeWidget: false, + scheduledNotifications: true, // Native notifications + webdavSync: true, + shareOut: true, + shareIn: false, + darkMode: true, +}; + +/** + * Capabilities for Android via Capacitor + */ +export const ANDROID_CAPABILITIES: PlatformCapabilities = { + backgroundTracking: true, // Foreground service + backgroundFocusTimer: true, // Foreground service + localFileSync: true, // Storage Access Framework + homeWidget: true, // AppWidgetProvider + scheduledNotifications: true, // LocalNotifications plugin + webdavSync: true, // CapacitorHttp + shareOut: true, // Share plugin + shareIn: true, // Intent filter + darkMode: true, +}; + +/** + * Capabilities for iOS via Capacitor (MVP) + */ +export const IOS_CAPABILITIES: PlatformCapabilities = { + backgroundTracking: false, // Not supported in MVP + backgroundFocusTimer: false, // Not supported in MVP + localFileSync: false, // Not supported in MVP + homeWidget: false, // Not supported in MVP + scheduledNotifications: true, // LocalNotifications plugin + webdavSync: true, // CapacitorHttp + shareOut: true, // Share plugin + shareIn: false, // Requires Share Extension (post-MVP) + darkMode: true, +}; diff --git a/src/app/core/share/share-file.util.ts b/src/app/core/share/share-file.util.ts index dd0da6f88..9e8b2d87b 100644 --- a/src/app/core/share/share-file.util.ts +++ b/src/app/core/share/share-file.util.ts @@ -1,6 +1,5 @@ -import { Capacitor } from '@capacitor/core'; import { Directory, Filesystem } from '@capacitor/filesystem'; -import { IS_ANDROID_WEB_VIEW } from '../../util/is-android-web-view'; +import { IS_NATIVE_PLATFORM } from '../../util/is-native-platform'; import { ShareResult } from './share.model'; /** @@ -95,7 +94,7 @@ export const canOpenDownloadResult = (result: ShareResult): boolean => { return true; } - if (Capacitor.isNativePlatform() || IS_ANDROID_WEB_VIEW) { + if (IS_NATIVE_PLATFORM) { return false; } diff --git a/src/app/core/share/share-platform.util.ts b/src/app/core/share/share-platform.util.ts index 41513d4b2..e8d35efb5 100644 --- a/src/app/core/share/share-platform.util.ts +++ b/src/app/core/share/share-platform.util.ts @@ -1,6 +1,5 @@ -import { Capacitor } from '@capacitor/core'; import { Share } from '@capacitor/share'; -import { IS_ANDROID_WEB_VIEW } from '../../util/is-android-web-view'; +import { IS_NATIVE_PLATFORM } from '../../util/is-native-platform'; export type ShareSupport = 'native' | 'web' | 'none'; @@ -12,10 +11,6 @@ export const detectShareSupport = async (): Promise => { return 'native'; } - if (IS_ANDROID_WEB_VIEW) { - return 'native'; - } - if (typeof navigator !== 'undefined' && typeof navigator.share === 'function') { return 'web'; } @@ -31,10 +26,6 @@ export const isSystemShareAvailable = async (): Promise => { return true; } - if (IS_ANDROID_WEB_VIEW) { - return true; - } - if (typeof navigator !== 'undefined' && 'share' in navigator) { return true; } @@ -54,7 +45,7 @@ export const isCapacitorShareAvailable = (): boolean => { * Get Capacitor Share plugin if available. */ export const getCapacitorSharePlugin = (): typeof Share | null => { - if (Capacitor.isNativePlatform() || IS_ANDROID_WEB_VIEW) { + if (IS_NATIVE_PLATFORM) { return Share; } diff --git a/src/app/core/share/share.service.ts b/src/app/core/share/share.service.ts index 0e12f8ffd..4b7cc2407 100644 --- a/src/app/core/share/share.service.ts +++ b/src/app/core/share/share.service.ts @@ -1,8 +1,7 @@ import { Injectable, inject } from '@angular/core'; import { MatDialog } from '@angular/material/dialog'; -import { Capacitor } from '@capacitor/core'; import { Directory, Filesystem } from '@capacitor/filesystem'; -import { IS_ANDROID_WEB_VIEW } from '../../util/is-android-web-view'; +import { IS_NATIVE_PLATFORM } from '../../util/is-native-platform'; import { SnackService } from '../snack/snack.service'; import { ShareCanvasImageParams, @@ -629,7 +628,7 @@ export class ShareService { filename: string; dataUrl: string; }): Promise { - if (base64 && (Capacitor.isNativePlatform() || IS_ANDROID_WEB_VIEW)) { + if (base64 && IS_NATIVE_PLATFORM) { const sanitizedName = ShareFileUtil.sanitizeFilename(filename); const relativePath = `shared-images/${Date.now()}-${sanitizedName}`; try { diff --git a/src/app/core/startup/startup.service.ts b/src/app/core/startup/startup.service.ts index b348c732d..a64cbb230 100644 --- a/src/app/core/startup/startup.service.ts +++ b/src/app/core/startup/startup.service.ts @@ -11,7 +11,6 @@ import { BannerService } from '../banner/banner.service'; import { UiHelperService } from '../../features/ui-helper/ui-helper.service'; import { ChromeExtensionInterfaceService } from '../chrome-extension-interface/chrome-extension-interface.service'; import { ProjectService } from '../../features/project/project.service'; -import { IS_ANDROID_WEB_VIEW } from '../../util/is-android-web-view'; import { IS_ELECTRON } from '../../app.constants'; import { Log } from '../log'; import { T } from '../../t.const'; @@ -32,6 +31,7 @@ import { IPC } from '../../../../electron/shared-with-frontend/ipc-events.const' import { IpcRendererEvent } from 'electron'; import { environment } from '../../../environments/environment'; import { TrackingReminderService } from '../../features/tracking-reminder/tracking-reminder.service'; +import { CapacitorPlatformService } from '../platform/capacitor-platform.service'; const w = window as Window & { productivityTips?: string[][]; randomIndex?: number }; @@ -57,6 +57,7 @@ export class StartupService { private _trackingReminderService = inject(TrackingReminderService); private _opLogStore = inject(OperationLogStoreService); private _store = inject(Store); + private _platformService = inject(CapacitorPlatformService); constructor() { // Initialize electron error handler in an effect @@ -83,7 +84,8 @@ export class StartupService { } async init(): Promise { - if (!IS_ANDROID_WEB_VIEW && !IS_ELECTRON) { + // Skip single instance check for native mobile apps and Electron + if (!this._platformService.isNative && !IS_ELECTRON) { const isSingle = await this._checkIsSingleInstance(); if (!isSingle) { this._showMultiInstanceBlocker(); @@ -146,7 +148,8 @@ export class StartupService { } }); - if (!IS_ANDROID_WEB_VIEW) { + // Chrome extension only works in web browser, not native mobile apps + if (!this._platformService.isNative) { this._chromeExtensionInterfaceService.init(); } } @@ -154,7 +157,8 @@ export class StartupService { private async _initBackups(): Promise { // if completely fresh instance check for local backups - if (IS_ELECTRON || IS_ANDROID_WEB_VIEW) { + // Local backups are available on Electron and native mobile (iOS/Android) + if (IS_ELECTRON || this._platformService.isNative) { const stateCache = await this._opLogStore.loadStateCache(); // If no state cache exists, this is a fresh instance - offer to restore from backup if (!stateCache) { @@ -277,8 +281,8 @@ export class StartupService { if (granted) { Log.log('Persistent store granted'); } - // NOTE: we never execute for android web view, because it is always true - else if (!IS_ANDROID_WEB_VIEW) { + // NOTE: we never show this warning for native mobile apps, because persistence is always granted + else if (!this._platformService.isNative) { const msg = T.GLOBAL_SNACK.PERSISTENCE_DISALLOWED; Log.warn('Persistence not allowed'); this._snackService.open({ msg }); diff --git a/src/app/core/theme/global-theme.service.ts b/src/app/core/theme/global-theme.service.ts index b7d2368aa..d09fd80d4 100644 --- a/src/app/core/theme/global-theme.service.ts +++ b/src/app/core/theme/global-theme.service.ts @@ -30,6 +30,9 @@ import { ChartConfiguration } from 'chart.js'; import { IS_ANDROID_WEB_VIEW } from '../../util/is-android-web-view'; import { androidInterface } from '../../features/android/android-interface'; import { HttpClient } from '@angular/common/http'; +import { CapacitorPlatformService } from '../platform/capacitor-platform.service'; +import { Keyboard } from '@capacitor/keyboard'; +import { StatusBar, Style } from '@capacitor/status-bar'; import { LS } from '../persistence/storage-keys.const'; import { CustomThemeService } from './custom-theme.service'; import { Log } from '../log'; @@ -52,6 +55,7 @@ export class GlobalThemeService { private _imexMetaService = inject(ImexViewService); private _http = inject(HttpClient); private _customThemeService = inject(CustomThemeService); + private _platformService = inject(CapacitorPlatformService); private _environmentInjector = inject(EnvironmentInjector); private _destroyRef = inject(DestroyRef); private _hasInitialized = false; @@ -279,6 +283,22 @@ export class GlobalThemeService { }); } + // Add native mobile platform classes + if (this._platformService.isNative) { + this.document.body.classList.add(BodyClass.isNativeMobile); + + if (this._platformService.isIOS()) { + this.document.body.classList.add(BodyClass.isIOS); + this._initIOSKeyboardHandling(); + this._initIOSStatusBar(); + + // Add iPad-specific class for tablet optimizations + if (this._platformService.isIPad()) { + this.document.body.classList.add(BodyClass.isIPad); + } + } + } + if (IS_ANDROID_WEB_VIEW) { androidInterface.isKeyboardShown$ .pipe(takeUntilDestroyed(this._destroyRef)) @@ -287,11 +307,15 @@ export class GlobalThemeService { this.document.body.classList.remove(BodyClass.isAndroidKeyboardHidden); this.document.body.classList.remove(BodyClass.isAndroidKeyboardShown); + this.document.body.classList.remove(BodyClass.isKeyboardVisible); this.document.body.classList.add( isShown ? BodyClass.isAndroidKeyboardShown : BodyClass.isAndroidKeyboardHidden, ); + if (isShown) { + this.document.body.classList.add(BodyClass.isKeyboardVisible); + } }); } @@ -406,4 +430,40 @@ export class GlobalThemeService { } }); } + + /** + * Initialize iOS keyboard visibility tracking using Capacitor Keyboard plugin. + * Adds/removes CSS classes when keyboard shows/hides. + */ + private _initIOSKeyboardHandling(): void { + Keyboard.addListener('keyboardWillShow', (info) => { + Log.log('iOS keyboard will show', info); + this.document.body.classList.add(BodyClass.isKeyboardVisible); + // Set CSS variable for keyboard height to adjust layout + this.document.documentElement.style.setProperty( + '--keyboard-height', + `${info.keyboardHeight}px`, + ); + }); + + Keyboard.addListener('keyboardWillHide', () => { + Log.log('iOS keyboard will hide'); + this.document.body.classList.remove(BodyClass.isKeyboardVisible); + this.document.documentElement.style.setProperty('--keyboard-height', '0px'); + }); + } + + /** + * Initialize iOS status bar styling. + * Syncs status bar style with app dark/light mode. + */ + private _initIOSStatusBar(): void { + // Set initial status bar style based on current theme + effect(() => { + const isDark = this.isDarkTheme(); + StatusBar.setStyle({ style: isDark ? Style.Dark : Style.Light }).catch((err) => { + Log.warn('Failed to set iOS status bar style', err); + }); + }); + } } diff --git a/src/app/features/android/store/android.effects.ts b/src/app/features/android/store/android.effects.ts index 2b6c82e0c..3433bf315 100644 --- a/src/app/features/android/store/android.effects.ts +++ b/src/app/features/android/store/android.effects.ts @@ -1,8 +1,7 @@ import { inject, Injectable } from '@angular/core'; import { createEffect } from '@ngrx/effects'; import { switchMap, tap } from 'rxjs/operators'; -import { combineLatest, timer } from 'rxjs'; -import { LocalNotifications } from '@capacitor/local-notifications'; +import { timer } from 'rxjs'; import { SnackService } from '../../../core/snack/snack.service'; import { IS_ANDROID_WEB_VIEW } from '../../../util/is-android-web-view'; import { DroidLog } from '../../../core/log'; @@ -12,7 +11,8 @@ import { TaskService } from '../../tasks/task.service'; import { TaskAttachmentService } from '../../tasks/task-attachment/task-attachment.service'; import { Store } from '@ngrx/store'; import { selectAllTasksWithReminder } from '../../tasks/store/task.selectors'; -import { selectReminderConfig } from '../../config/store/global-config.reducer'; +import { CapacitorReminderService } from '../../../core/platform/capacitor-reminder.service'; +import { CapacitorPlatformService } from '../../../core/platform/capacitor-platform.service'; // TODO send message to electron when current task changes here @@ -25,28 +25,29 @@ export class AndroidEffects { private _taskService = inject(TaskService); private _taskAttachmentService = inject(TaskAttachmentService); private _store = inject(Store); + private _reminderService = inject(CapacitorReminderService); + private _platformService = inject(CapacitorPlatformService); // Single-shot guard so we don't spam the user with duplicate warnings. private _hasShownNotificationWarning = false; - private _hasCheckedExactAlarm = false; // Track scheduled reminder IDs to cancel removed ones private _scheduledReminderIds = new Set(); + /** + * Check notification permissions on startup for mobile platforms. + * Shows a warning if permissions are not granted. + */ askPermissionsIfNotGiven$ = - IS_ANDROID_WEB_VIEW && + this._platformService.isNative && createEffect( () => timer(DELAY_PERMISSIONS).pipe( - tap(async (v) => { + tap(async () => { try { - const checkResult = await LocalNotifications.checkPermissions(); - DroidLog.log('AndroidEffects: initial permission check', checkResult); - const displayPermissionGranted = checkResult.display === 'granted'; - if (displayPermissionGranted) { - await this._ensureExactAlarmAccess(); - return; + const hasPermission = await this._reminderService.ensurePermissions(); + DroidLog.log('MobileEffects: initial permission check', { hasPermission }); + if (!hasPermission) { + this._notifyPermissionIssue(); } - // Surface a gentle warning early, but defer the actual permission prompt until we truly need it. - this._notifyPermissionIssue(); } catch (error) { DroidLog.err(error); this._notifyPermissionIssue(error?.toString()); @@ -59,26 +60,22 @@ export class AndroidEffects { ); /** - * Schedule native Android reminders for tasks with remindAt set. + * Schedule reminders for tasks with remindAt set. + * Works on both iOS and Android. * * SYNC-SAFE: This effect is intentionally safe during sync/hydration because: - * - dispatch: false - no store mutations, only native Android API calls + * - dispatch: false - no store mutations, only native API calls * - We WANT notifications scheduled for synced tasks (user-facing functionality) - * - Native AlarmManager calls are idempotent - rescheduling the same reminder is harmless + * - Native scheduling calls are idempotent - rescheduling the same reminder is harmless * - Cancellation of removed reminders correctly handles tasks deleted via sync */ scheduleNotifications$ = - IS_ANDROID_WEB_VIEW && + this._platformService.isNative && createEffect( () => timer(DELAY_SCHEDULE).pipe( - switchMap(() => - combineLatest([ - this._store.select(selectAllTasksWithReminder), - this._store.select(selectReminderConfig), - ]), - ), - tap(async ([tasksWithReminders, reminderConfig]) => { + switchMap(() => this._store.select(selectAllTasksWithReminder)), + tap(async (tasksWithReminders) => { try { const currentReminderIds = new Set( (tasksWithReminders || []).map((t) => t.id), @@ -88,11 +85,11 @@ export class AndroidEffects { for (const previousId of this._scheduledReminderIds) { if (!currentReminderIds.has(previousId)) { const notificationId = generateNotificationId(previousId); - DroidLog.log('AndroidEffects: cancelling removed reminder', { + DroidLog.log('MobileEffects: cancelling removed reminder', { relatedId: previousId, notificationId, }); - androidInterface.cancelNativeReminder?.(notificationId); + await this._reminderService.cancelReminder(notificationId); } } @@ -100,47 +97,38 @@ export class AndroidEffects { this._scheduledReminderIds.clear(); return; } - DroidLog.log('AndroidEffects: scheduling reminders natively', { + + DroidLog.log('MobileEffects: scheduling reminders', { reminderCount: tasksWithReminders.length, + platform: this._platformService.platform, }); - // Check permissions first - const checkResult = await LocalNotifications.checkPermissions(); - let displayPermissionGranted = checkResult.display === 'granted'; - if (!displayPermissionGranted) { - const requestResult = await LocalNotifications.requestPermissions(); - displayPermissionGranted = requestResult.display === 'granted'; - if (!displayPermissionGranted) { - this._notifyPermissionIssue(); - return; - } + // Ensure permissions are granted + const hasPermission = await this._reminderService.ensurePermissions(); + if (!hasPermission) { + this._notifyPermissionIssue(); + return; } - await this._ensureExactAlarmAccess(); - const useAlarmStyle = reminderConfig.useAlarmStyleReminders ?? false; - - // Schedule each reminder using native Android AlarmManager + // Schedule each reminder using the platform-appropriate method for (const task of tasksWithReminders) { const id = generateNotificationId(task.id); - const now = Date.now(); - const scheduleAt = task.remindAt! <= now ? now + 1000 : task.remindAt!; - - androidInterface.scheduleNativeReminder?.( - id, - task.id, - task.id, - task.title, - 'TASK', - scheduleAt, - useAlarmStyle, - ); + await this._reminderService.scheduleReminder({ + notificationId: id, + reminderId: task.id, + relatedId: task.id, + title: task.title, + reminderType: 'TASK', + triggerAtMs: task.remindAt!, + }); } // Update tracked IDs this._scheduledReminderIds = currentReminderIds; - DroidLog.log('AndroidEffects: scheduled native reminders', { + DroidLog.log('MobileEffects: scheduled reminders', { reminderCount: tasksWithReminders.length, + platform: this._platformService.platform, }); } catch (error) { DroidLog.err(error); @@ -219,23 +207,6 @@ export class AndroidEffects { { dispatch: false }, ); - private async _ensureExactAlarmAccess(): Promise { - try { - if (this._hasCheckedExactAlarm) { - return; - } - // Android 12+ gates exact alarms behind a separate toggle; surface the settings screen if needed. - const exactAlarmStatus = await LocalNotifications.checkExactNotificationSetting(); - if (exactAlarmStatus?.exact_alarm !== 'granted') { - DroidLog.log(await LocalNotifications.changeExactNotificationSetting()); - } else { - this._hasCheckedExactAlarm = true; - } - } catch (error) { - DroidLog.warn(error); - } - } - private _notifyPermissionIssue(message?: string): void { if (this._hasShownNotificationWarning) { return; diff --git a/src/app/features/config/form-cfgs/sync-form.const.ts b/src/app/features/config/form-cfgs/sync-form.const.ts index 924a438b4..ba19121f3 100644 --- a/src/app/features/config/form-cfgs/sync-form.const.ts +++ b/src/app/features/config/form-cfgs/sync-form.const.ts @@ -6,6 +6,7 @@ import { IS_ANDROID_WEB_VIEW } from '../../../util/is-android-web-view'; import { IS_ELECTRON } from '../../../app.constants'; import { fileSyncDroid, fileSyncElectron } from '../../../op-log/model/model-config'; import { FormlyFieldConfig } from '@ngx-formly/core'; +import { IS_NATIVE_PLATFORM } from '../../../util/is-native-platform'; /** * Creates form fields for WebDAV-based sync providers. @@ -19,7 +20,8 @@ const createWebdavFormFields = (options: { baseUrlDescription: string; }): FormlyFieldConfig[] => { return [ - ...(!IS_ELECTRON && !IS_ANDROID_WEB_VIEW + // Hide CORS info for Electron and native mobile apps (iOS/Android) since they handle CORS natively + ...(!IS_ELECTRON && !IS_NATIVE_PLATFORM ? [ { type: 'tpl', diff --git a/src/app/features/reminder/reminder.module.ts b/src/app/features/reminder/reminder.module.ts index 4fab9f689..207816937 100644 --- a/src/app/features/reminder/reminder.module.ts +++ b/src/app/features/reminder/reminder.module.ts @@ -3,7 +3,7 @@ import { CommonModule } from '@angular/common'; import { ReminderService } from './reminder.service'; import { MatDialog } from '@angular/material/dialog'; import { IS_ELECTRON } from '../../app.constants'; -import { IS_ANDROID_WEB_VIEW } from '../../util/is-android-web-view'; +import { IS_NATIVE_PLATFORM, IS_IOS_NATIVE } from '../../util/is-native-platform'; import { concatMap, delay, @@ -28,6 +28,15 @@ import { TaskWithReminderData } from '../tasks/task.model'; import { Store } from '@ngrx/store'; import { TaskSharedActions } from '../../root-store/meta/task-shared.actions'; import { GlobalConfigService } from '../config/global-config.service'; +import { CapacitorReminderService } from '../../core/platform/capacitor-reminder.service'; +import { + NOTIFICATION_ACTION, + NotificationActionEvent, +} from '../../core/platform/capacitor-notification.service'; +import { Log } from '../../core/log'; + +const IOS_SNOOZE_MINUTES = 10; +const MINUTES_TO_MILLISECONDS = 60 * 1000; @NgModule({ declarations: [], @@ -44,11 +53,15 @@ export class ReminderModule { private readonly _syncTriggerService = inject(SyncTriggerService); private readonly _store = inject(Store); private readonly _globalConfigService = inject(GlobalConfigService); + private readonly _capacitorReminderService = inject(CapacitorReminderService); constructor() { // Initialize reminder service (runs migration in background) this._reminderService.init(); + // Initialize iOS notification actions + this._initIOSNotificationActions(); + this._syncTriggerService.afterInitialSyncDoneAndDataLoadedInitially$ .pipe( first(), @@ -96,7 +109,7 @@ export class ReminderModule { // app state. The reminder will be cancelled when the task is marked done in the app. // To fully fix: Add onReminderDone$ subject to android-interface.ts and wire it up // in the Kotlin ReminderBroadcastReceiver to call dismissReminderOnly action. - if (IS_ANDROID_WEB_VIEW) { + if (IS_NATIVE_PLATFORM) { return; } @@ -139,7 +152,7 @@ export class ReminderModule { @throttle(60000) private _showNotification(reminders: TaskWithReminderData[]): void { // Skip on Android - we use native notifications with snooze button instead - if (IS_ANDROID_WEB_VIEW) { + if (IS_NATIVE_PLATFORM) { return; } @@ -162,4 +175,71 @@ export class ReminderModule { }) .then(); } + + /** + * Initialize iOS notification action handling. + * Registers action types and subscribes to action events. + */ + private _initIOSNotificationActions(): void { + if (!IS_IOS_NATIVE) { + return; + } + + // Initialize the Capacitor reminder service (registers action types) + this._capacitorReminderService.initialize().then(() => { + Log.log('ReminderModule: iOS notification actions initialized'); + }); + + // Handle notification action events + this._capacitorReminderService.action$.subscribe((event: NotificationActionEvent) => { + this._handleIOSNotificationAction(event); + }); + } + + /** + * Handle iOS notification action (Snooze or Done). + */ + private async _handleIOSNotificationAction( + event: NotificationActionEvent, + ): Promise { + const taskId = event.extra?.['relatedId'] as string | undefined; + if (!taskId) { + Log.warn('ReminderModule: No task ID in notification action', event); + return; + } + + Log.log('ReminderModule: Handling iOS notification action', { + actionId: event.actionId, + taskId, + }); + + if (event.actionId === NOTIFICATION_ACTION.SNOOZE) { + // Snooze: Reschedule the reminder for 10 minutes later + const task = await this._taskService.getByIdOnce$(taskId).toPromise(); + if (task) { + const snoozeMs = IOS_SNOOZE_MINUTES * MINUTES_TO_MILLISECONDS; + const newRemindAt = Date.now() + snoozeMs; + this._store.dispatch( + TaskSharedActions.reScheduleTaskWithTime({ + task, + remindAt: newRemindAt, + dueWithTime: task.dueWithTime ?? newRemindAt, + isMoveToBacklog: false, + }), + ); + Log.log('ReminderModule: Task snoozed via iOS notification', { + taskId, + newRemindAt: new Date(newRemindAt).toISOString(), + }); + } + } else if (event.actionId === NOTIFICATION_ACTION.DONE) { + // Done: Dismiss the reminder only (don't mark task as done) + this._store.dispatch( + TaskSharedActions.dismissReminderOnly({ + id: taskId, + }), + ); + Log.log('ReminderModule: Reminder dismissed via iOS notification', { taskId }); + } + } } diff --git a/src/app/imex/file-imex/file-imex.component.ts b/src/app/imex/file-imex/file-imex.component.ts index 4bdae88e9..3a44bdbb8 100644 --- a/src/app/imex/file-imex/file-imex.component.ts +++ b/src/app/imex/file-imex/file-imex.component.ts @@ -21,7 +21,7 @@ import { MatTooltip } from '@angular/material/tooltip'; import { TranslatePipe } from '@ngx-translate/core'; import { AppDataComplete } from '../../op-log/model/model-config'; import { BackupService } from '../../op-log/backup/backup.service'; -import { IS_ANDROID_WEB_VIEW } from '../../util/is-android-web-view'; +import { IS_NATIVE_PLATFORM } from '../../util/is-native-platform'; import { first } from 'rxjs/operators'; import { ConfirmUrlImportDialogComponent, @@ -211,7 +211,7 @@ export class FileImexComponent implements OnInit { async downloadBackup(): Promise { const data = await this._backupService.loadCompleteBackup(true); const result = await download('super-productivity-backup.json', JSON.stringify(data)); - if ((IS_ANDROID_WEB_VIEW && !result.wasCanceled) || result.isSnap) { + if ((IS_NATIVE_PLATFORM && !result.wasCanceled) || result.isSnap) { this._snackService.open({ type: 'SUCCESS', msg: result.path @@ -225,7 +225,7 @@ export class FileImexComponent implements OnInit { async privacyAppDataDownload(): Promise { const data = await this._backupService.loadCompleteBackup(true); const result = await download('super-productivity-backup.json', privacyExport(data)); - if ((IS_ANDROID_WEB_VIEW && !result.wasCanceled) || result.isSnap) { + if ((IS_NATIVE_PLATFORM && !result.wasCanceled) || result.isSnap) { this._snackService.open({ type: 'SUCCESS', msg: result.path diff --git a/src/app/imex/local-backup/local-backup.service.ts b/src/app/imex/local-backup/local-backup.service.ts index 159ceb4c5..61d4e072f 100644 --- a/src/app/imex/local-backup/local-backup.service.ts +++ b/src/app/imex/local-backup/local-backup.service.ts @@ -15,9 +15,12 @@ import { TranslateService } from '@ngx-translate/core'; import { AppDataComplete } from '../../op-log/model/model-config'; import { SnackService } from '../../core/snack/snack.service'; import { Log } from '../../core/log'; +import { CapacitorPlatformService } from '../../core/platform/capacitor-platform.service'; +import { Directory, Encoding, Filesystem } from '@capacitor/filesystem'; const DEFAULT_BACKUP_INTERVAL = 5 * 60 * 1000; const ANDROID_DB_KEY = 'backup'; +const IOS_BACKUP_FILENAME = 'super-productivity-backup.json'; // const DEFAULT_BACKUP_INTERVAL = 6 * 1000; @@ -31,6 +34,7 @@ export class LocalBackupService { private _backupService = inject(BackupService); private _snackService = inject(SnackService); private _translateService = inject(TranslateService); + private _platformService = inject(CapacitorPlatformService); private _cfg$: Observable = this._configService.cfg$.pipe( map((cfg) => cfg.localBackup), @@ -46,9 +50,16 @@ export class LocalBackupService { } checkBackupAvailable(): Promise { - return IS_ANDROID_WEB_VIEW - ? androidInterface.loadFromDbWrapped(ANDROID_DB_KEY).then((r) => !!r) - : window.ea.checkBackupAvailable(); + if (IS_ANDROID_WEB_VIEW) { + return androidInterface.loadFromDbWrapped(ANDROID_DB_KEY).then((r) => !!r); + } + if (this._platformService.isIOS()) { + return this._checkBackupAvailableIOS(); + } + if (IS_ELECTRON) { + return window.ea.checkBackupAvailable(); + } + return Promise.resolve(false); } loadBackupElectron(backupPath: string): Promise { @@ -59,8 +70,30 @@ export class LocalBackupService { return androidInterface.loadFromDbWrapped(ANDROID_DB_KEY).then((r) => r as string); } + async loadBackupIOS(): Promise { + const result = await Filesystem.readFile({ + path: IOS_BACKUP_FILENAME, + directory: Directory.Data, + encoding: Encoding.UTF8, + }); + return result.data as string; + } + + private async _checkBackupAvailableIOS(): Promise { + try { + const stat = await Filesystem.stat({ + path: IOS_BACKUP_FILENAME, + directory: Directory.Data, + }); + return !!stat; + } catch { + // File doesn't exist + return false; + } + } + async askForFileStoreBackupIfAvailable(): Promise { - if (!IS_ELECTRON && !IS_ANDROID_WEB_VIEW) { + if (!IS_ELECTRON && !IS_ANDROID_WEB_VIEW && !this._platformService.isIOS()) { return; } @@ -93,6 +126,17 @@ export class LocalBackupService { Log.log('lineBreaksReplaced', lineBreaksReplaced); await this._importBackup(lineBreaksReplaced); } + + // iOS + // --- + } else if (this._platformService.isIOS() && backupMeta === true) { + if ( + confirm(this._translateService.instant(T.CONFIRM.RESTORE_FILE_BACKUP_ANDROID)) + ) { + const backupData = await this.loadBackupIOS(); + Log.log('iOS backupData loaded'); + await this._importBackup(backupData); + } } } @@ -106,6 +150,23 @@ export class LocalBackupService { if (IS_ANDROID_WEB_VIEW) { await androidInterface.saveToDbWrapped(ANDROID_DB_KEY, JSON.stringify(data)); } + if (this._platformService.isIOS()) { + await this._backupIOS(data); + } + } + + private async _backupIOS(data: AppDataComplete): Promise { + try { + await Filesystem.writeFile({ + path: IOS_BACKUP_FILENAME, + data: JSON.stringify(data), + directory: Directory.Data, + encoding: Encoding.UTF8, + }); + Log.log('iOS backup saved successfully'); + } catch (error) { + Log.err('Failed to save iOS backup', error); + } } private async _importBackup(backupData: string): Promise { diff --git a/src/app/op-log/sync-providers/file-based/webdav/webdav-http-adapter.ts b/src/app/op-log/sync-providers/file-based/webdav/webdav-http-adapter.ts index 441bd3ea8..f2777495b 100644 --- a/src/app/op-log/sync-providers/file-based/webdav/webdav-http-adapter.ts +++ b/src/app/op-log/sync-providers/file-based/webdav/webdav-http-adapter.ts @@ -9,6 +9,7 @@ import { TooManyRequestsAPIError, } from '../../../core/errors/sync-errors'; import { WebDavHttpStatus } from './webdav.const'; +import { Capacitor } from '@capacitor/core'; // Define and register our WebDAV plugin interface WebDavHttpPluginRequest { @@ -55,23 +56,29 @@ export class WebDavHttpAdapter { 'PROPPATCH', ]; - // Make IS_ANDROID_WEB_VIEW testable by making it a class property + // Make platform checks testable by making them class properties protected get isAndroidWebView(): boolean { return IS_ANDROID_WEB_VIEW; } + protected get isNativePlatform(): boolean { + return Capacitor.isNativePlatform(); + } + async request(options: WebDavHttpRequest): Promise { try { let response: WebDavHttpResponse; - if (this.isAndroidWebView) { + if (this.isNativePlatform) { // Check if this is a WebDAV method const isWebDavMethod = WebDavHttpAdapter.WEBDAV_METHODS.includes( options.method.toUpperCase(), ); - if (isWebDavMethod) { - // Use our custom WebDAV plugin for WebDAV methods + // On Android, use custom WebDavHttp plugin for WebDAV methods (better retry handling) + // On iOS, use CapacitorHttp for all methods (including WebDAV) + if (isWebDavMethod && this.isAndroidWebView) { + // Use our custom WebDAV plugin for WebDAV methods on Android PFLog.log( `${WebDavHttpAdapter.L}.request() using WebDavHttp for ${options.method}`, ); @@ -83,7 +90,12 @@ export class WebDavHttpAdapter { }); response = this._convertWebDavResponse(webdavResponse); } else { - // Use standard CapacitorHttp for regular HTTP methods + // Use standard CapacitorHttp for: + // - All methods on iOS (including WebDAV) + // - Regular HTTP methods on Android + PFLog.log( + `${WebDavHttpAdapter.L}.request() using CapacitorHttp for ${options.method}`, + ); const capacitorResponse = await CapacitorHttp.request({ url: options.url, method: options.method, diff --git a/src/app/util/download.ts b/src/app/util/download.ts index 8ded69019..b98bb93d6 100644 --- a/src/app/util/download.ts +++ b/src/app/util/download.ts @@ -1,6 +1,6 @@ import { Directory, Encoding, Filesystem, WriteFileResult } from '@capacitor/filesystem'; import { Share } from '@capacitor/share'; -import { IS_ANDROID_WEB_VIEW } from './is-android-web-view'; +import { IS_NATIVE_PLATFORM } from './is-native-platform'; import { Log } from '../core/log'; // Type definitions for window.ea are in ../core/window-ea.d.ts @@ -12,7 +12,8 @@ export const download = async ( filename: string, stringData: string, ): Promise<{ isSnap?: boolean; path?: string; wasCanceled?: boolean }> => { - if (IS_ANDROID_WEB_VIEW) { + // Use Capacitor Filesystem + Share for native mobile platforms (Android and iOS) + if (IS_NATIVE_PLATFORM) { try { const fileResult = await Filesystem.writeFile({ path: filename, diff --git a/src/app/util/is-native-platform.ts b/src/app/util/is-native-platform.ts new file mode 100644 index 000000000..9a99395d5 --- /dev/null +++ b/src/app/util/is-native-platform.ts @@ -0,0 +1,19 @@ +import { Capacitor } from '@capacitor/core'; +import { IS_ANDROID_WEB_VIEW } from './is-android-web-view'; + +/** + * Whether running in a native Capacitor context (iOS or Android). + * This can be used in constants that are evaluated at module load time. + */ +export const IS_NATIVE_PLATFORM = Capacitor.isNativePlatform() || IS_ANDROID_WEB_VIEW; + +/** + * Whether running on iOS native (Capacitor). + */ +export const IS_IOS_NATIVE = Capacitor.getPlatform() === 'ios'; + +/** + * Whether running on Android native (Capacitor or WebView). + */ +export const IS_ANDROID_NATIVE = + Capacitor.getPlatform() === 'android' || IS_ANDROID_WEB_VIEW; diff --git a/src/main.ts b/src/main.ts index 36f0e0b5c..b4b67fec0 100644 --- a/src/main.ts +++ b/src/main.ts @@ -13,6 +13,7 @@ import { IS_ELECTRON } from './app/app.constants'; import { DEFAULT_LANGUAGE, LocalesImports } from './app/core/locale.constants'; import { IS_ANDROID_WEB_VIEW } from './app/util/is-android-web-view'; import { androidInterface } from './app/features/android/android-interface'; +import { IS_IOS_NATIVE, IS_NATIVE_PLATFORM } from './app/util/is-native-platform'; // Type definitions for window.ea are in ./app/core/window-ea.d.ts import { App as CapacitorApp } from '@capacitor/app'; import { GlobalErrorHandler } from './app/core/error-handler/global-error-handler.class'; @@ -131,7 +132,7 @@ bootstrapApplication(AppComponent, { ServiceWorkerModule.register('ngsw-worker.js', { enabled: !IS_ELECTRON && - !IS_ANDROID_WEB_VIEW && + !IS_NATIVE_PLATFORM && (environment.production || environment.stage), // Register the ServiceWorker as soon as the application is stable // or after 30 seconds (whichever comes first). @@ -226,14 +227,14 @@ bootstrapApplication(AppComponent, { 'serviceWorker' in navigator && (environment.production || environment.stage) && !IS_ELECTRON && - !IS_ANDROID_WEB_VIEW + !IS_NATIVE_PLATFORM ) { Log.log('Registering Service worker'); return navigator.serviceWorker.register('ngsw-worker.js').catch((err: unknown) => { Log.log('Service Worker Registration Error'); Log.err(err); }); - } else if ('serviceWorker' in navigator && (IS_ELECTRON || IS_ANDROID_WEB_VIEW)) { + } else if ('serviceWorker' in navigator && (IS_ELECTRON || IS_NATIVE_PLATFORM)) { navigator.serviceWorker .getRegistrations() .then((registrations) => { @@ -259,8 +260,10 @@ if (!(environment.production || environment.stage) && IS_ANDROID_WEB_VIEW) { }, 1000); } -// CAPICATOR STUFF +// CAPACITOR STUFF // --------------- + +// Android-specific: Handle back button if (IS_ANDROID_WEB_VIEW) { CapacitorApp.addListener('backButton', ({ canGoBack }) => { if (!canGoBack) { @@ -269,7 +272,10 @@ if (IS_ANDROID_WEB_VIEW) { window.history.back(); } }); +} +// Android: Handle app state changes with background task for sync completion +if (IS_ANDROID_WEB_VIEW) { CapacitorApp.addListener('appStateChange', async ({ isActive }) => { if (isActive) { return; @@ -286,3 +292,23 @@ if (IS_ANDROID_WEB_VIEW) { }); }); } + +// iOS: Handle app state changes (limited background time) +if (IS_IOS_NATIVE) { + CapacitorApp.addListener('appStateChange', async ({ isActive }) => { + if (isActive) { + Log.log('iOS app became active'); + return; + } + // iOS has limited background execution time (~30 seconds) + // Log state change but don't attempt long-running tasks + Log.log('iOS app going to background'); + }); + + // Handle app URL open (for OAuth callbacks, deep links, etc.) + CapacitorApp.addListener('appUrlOpen', (event) => { + Log.log('iOS app URL open', event.url); + // Handle OAuth callbacks or deep links here + // The URL will be passed to the app when opened via custom scheme + }); +} diff --git a/src/styles.scss b/src/styles.scss index 2d7f378cb..110bb60a7 100644 --- a/src/styles.scss +++ b/src/styles.scss @@ -37,3 +37,72 @@ body.isDisableAnimations { animation: none !important; } } + +// =============================== +// iOS Safe Area Handling +// =============================== + +// Native mobile platforms get safe area padding +body.isNativeMobile { + // Apply safe area padding to the viewport + padding-top: var(--safe-area-top); + padding-left: var(--safe-area-left); + padding-right: var(--safe-area-right); + + // Bottom padding only when keyboard is not visible + &:not(.isKeyboardVisible) { + padding-bottom: var(--safe-area-bottom); + } +} + +// iOS-specific adjustments +body.isIOS { + // Prevent overscroll bounce effect + overscroll-behavior: none; + + // Ensure the viewport extends behind safe areas + // This allows us to color the areas behind notch/home indicator + min-height: 100vh; + min-height: -webkit-fill-available; +} + +// Keyboard visible adjustments +body.isKeyboardVisible { + // Remove bottom safe area when keyboard is visible + padding-bottom: 0; + + // Optionally adjust for keyboard height + // Elements can use --keyboard-height to position above keyboard +} + +// =============================== +// iPad-Specific Optimizations +// =============================== + +body.isIPad { + // iPad has more screen real estate - use wider content + --component-max-width: 900px; + + // Larger touch targets are less necessary on iPad + --touch-target-min: 40px; + + // Better use of horizontal space in landscape + @media (min-width: 1024px) { + --component-max-width: 1000px; + } + + // Split-view support: when iPad is in split-view mode, + // the viewport is narrower - detect and adjust + @media (max-width: 500px) { + // In narrow split-view, use phone-like layout + --component-max-width: 100%; + } + + // iPad in landscape with enough space for side-by-side content + @media (min-width: 1024px) and (orientation: landscape) { + // Wider dialogs on iPad landscape + .mat-mdc-dialog-container { + max-width: 700px; + } + } +} diff --git a/src/styles/_css-variables.scss b/src/styles/_css-variables.scss index 5dfd77be0..1f9201d28 100644 --- a/src/styles/_css-variables.scss +++ b/src/styles/_css-variables.scss @@ -6,6 +6,18 @@ // =============================== :root { + // ----------------------------- + // Safe Areas (iOS notch, home indicator) + // Uses env() for native iOS safe areas + // ----------------------------- + --safe-area-top: env(safe-area-inset-top, 0px); + --safe-area-bottom: env(safe-area-inset-bottom, 0px); + --safe-area-left: env(safe-area-inset-left, 0px); + --safe-area-right: env(safe-area-inset-right, 0px); + + // Keyboard height (set dynamically via JS) + --keyboard-height: 0px; + // ----------------------------- // Spacing System // -----------------------------