mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-01-23 02:36:05 +00:00
feat(ios): add iOS platform support via Capacitor
Add iOS MVP with core functionality using Capacitor-first approach: Platform Abstraction Layer: - Add CapacitorPlatformService for unified platform detection - Add PlatformCapabilities model with per-platform feature flags - Add CapacitorNotificationService wrapping LocalNotifications plugin - Add CapacitorReminderService for cross-platform reminder scheduling iOS Support: - Add Capacitor iOS project with proper configuration - Configure Info.plist for notifications and background modes - Add app icon and splash screen assets - Enable CapacitorHttp for WebDAV sync (avoids CORS issues) Refactoring: - Update android.effects.ts to use CapacitorReminderService - Update notify.service.ts to support iOS notifications - Update startup.service.ts with platform-aware initialization - Update sync-form.const.ts to hide CORS info on native platforms - Update webdav-http-adapter.ts to use CapacitorHttp on iOS MVP includes: task management, scheduled notifications, Dropbox sync, WebDAV sync, share-out, dark mode. Excludes: background tracking, widgets, local file sync, share-in (post-MVP features).
This commit is contained in:
parent
9d21fa1b6e
commit
590e1592da
32 changed files with 1942 additions and 109 deletions
|
|
@ -2,16 +2,25 @@ 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',
|
||||
},
|
||||
},
|
||||
ios: {
|
||||
// Content inset for safe areas (notch, home indicator)
|
||||
contentInset: 'automatic',
|
||||
// Allow inline media playback
|
||||
allowsLinkPreview: true,
|
||||
// Scroll behavior
|
||||
scrollEnabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
|
|
|||
13
ios/.gitignore
vendored
Normal file
13
ios/.gitignore
vendored
Normal file
|
|
@ -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
|
||||
408
ios/App/App.xcodeproj/project.pbxproj
Normal file
408
ios/App/App.xcodeproj/project.pbxproj
Normal file
|
|
@ -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 = "<group>"; };
|
||||
50379B222058CBB4000EE86E /* capacitor.config.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = capacitor.config.json; sourceTree = "<group>"; };
|
||||
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 = "<group>"; };
|
||||
504EC30C1FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
|
||||
504EC30E1FED79650016851F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
504EC3111FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
||||
504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = "<group>"; };
|
||||
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 = "<group>"; };
|
||||
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 = "<group>"; };
|
||||
/* 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 = "<group>";
|
||||
};
|
||||
504EC2FB1FED79650016851F = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
504EC3061FED79650016851F /* App */,
|
||||
504EC3051FED79650016851F /* Products */,
|
||||
7F8756D8B27F46E3366F6CEA /* Pods */,
|
||||
27E2DDA53C4D2A4D1A88CE4A /* Frameworks */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
504EC3051FED79650016851F /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
504EC3041FED79650016851F /* App.app */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
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 = "<group>";
|
||||
};
|
||||
7F8756D8B27F46E3366F6CEA /* Pods */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
FC68EB0AF532CFC21C3344DD /* Pods-App.debug.xcconfig */,
|
||||
AF51FD2D460BCFE21FA515B2 /* Pods-App.release.xcconfig */,
|
||||
);
|
||||
name = Pods;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* 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 = "<group>";
|
||||
};
|
||||
504EC3101FED79650016851F /* LaunchScreen.storyboard */ = {
|
||||
isa = PBXVariantGroup;
|
||||
children = (
|
||||
504EC3111FED79650016851F /* Base */,
|
||||
);
|
||||
name = LaunchScreen.storyboard;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* 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 */;
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>IDEDidComputeMac32BitWarning</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
49
ios/App/App/AppDelegate.swift
Normal file
49
ios/App/App/AppDelegate.swift
Normal file
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 110 KiB |
14
ios/App/App/Assets.xcassets/AppIcon.appiconset/Contents.json
Normal file
14
ios/App/App/Assets.xcassets/AppIcon.appiconset/Contents.json
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"images": [
|
||||
{
|
||||
"filename": "AppIcon-512@2x.png",
|
||||
"idiom": "universal",
|
||||
"platform": "ios",
|
||||
"size": "1024x1024"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
6
ios/App/App/Assets.xcassets/Contents.json
Normal file
6
ios/App/App/Assets.xcassets/Contents.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"info": {
|
||||
"version": 1,
|
||||
"author": "xcode"
|
||||
}
|
||||
}
|
||||
23
ios/App/App/Assets.xcassets/Splash.imageset/Contents.json
vendored
Normal file
23
ios/App/App/Assets.xcassets/Splash.imageset/Contents.json
vendored
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
BIN
ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-1.png
vendored
Normal file
BIN
ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-1.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 48 KiB |
BIN
ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-2.png
vendored
Normal file
BIN
ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-2.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 48 KiB |
BIN
ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732.png
vendored
Normal file
BIN
ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 48 KiB |
32
ios/App/App/Base.lproj/LaunchScreen.storyboard
Normal file
32
ios/App/App/Base.lproj/LaunchScreen.storyboard
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="17132" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
|
||||
<device id="retina4_7" orientation="portrait" appearance="light"/>
|
||||
<dependencies>
|
||||
<deployment identifier="iOS"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="17105"/>
|
||||
<capability name="System colors in document resources" minToolsVersion="11.0"/>
|
||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||
</dependencies>
|
||||
<scenes>
|
||||
<!--View Controller-->
|
||||
<scene sceneID="EHf-IW-A2E">
|
||||
<objects>
|
||||
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
|
||||
<imageView key="view" userInteractionEnabled="NO" contentMode="scaleAspectFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="Splash" id="snD-IY-ifK">
|
||||
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
|
||||
<autoresizingMask key="autoresizingMask"/>
|
||||
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
|
||||
</imageView>
|
||||
</viewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="53" y="375"/>
|
||||
</scene>
|
||||
</scenes>
|
||||
<resources>
|
||||
<image name="Splash" width="1366" height="1366"/>
|
||||
<systemColor name="systemBackgroundColor">
|
||||
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
</systemColor>
|
||||
</resources>
|
||||
</document>
|
||||
19
ios/App/App/Base.lproj/Main.storyboard
Normal file
19
ios/App/App/Base.lproj/Main.storyboard
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="14111" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES" initialViewController="BYZ-38-t0r">
|
||||
<device id="retina4_7" orientation="portrait">
|
||||
<adaptation id="fullscreen"/>
|
||||
</device>
|
||||
<dependencies>
|
||||
<deployment identifier="iOS"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="14088"/>
|
||||
</dependencies>
|
||||
<scenes>
|
||||
<!--Bridge View Controller-->
|
||||
<scene sceneID="tne-QT-ifu">
|
||||
<objects>
|
||||
<viewController id="BYZ-38-t0r" customClass="CAPBridgeViewController" customModule="Capacitor" sceneMemberID="viewController"/>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
</scene>
|
||||
</scenes>
|
||||
</document>
|
||||
56
ios/App/App/Info.plist
Normal file
56
ios/App/App/Info.plist
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Super Productivity</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIMainStoryboardFile</key>
|
||||
<string>Main</string>
|
||||
<key>UIRequiredDeviceCapabilities</key>
|
||||
<array>
|
||||
<string>armv7</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||
<true/>
|
||||
<key>NSUserNotificationsUsageDescription</key>
|
||||
<string>Super Productivity uses notifications to remind you about tasks and scheduled events.</string>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>fetch</string>
|
||||
<string>remote-notification</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
28
ios/App/Podfile
Normal file
28
ios/App/Podfile
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
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 '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
|
||||
11
package-lock.json
generated
11
package-lock.json
generated
|
|
@ -12,6 +12,7 @@
|
|||
"packages/*"
|
||||
],
|
||||
"dependencies": {
|
||||
"@capacitor/ios": "^7.4.4",
|
||||
"electron-dl": "^3.5.2",
|
||||
"electron-localshortcut": "^3.2.1",
|
||||
"electron-log": "^5.4.3",
|
||||
|
|
@ -5826,7 +5827,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 +5843,15 @@
|
|||
"@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/local-notifications": {
|
||||
"version": "7.0.1",
|
||||
"dev": true,
|
||||
|
|
|
|||
|
|
@ -136,6 +136,7 @@
|
|||
"@ctrl/tinycolor": "4.1.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@capacitor/ios": "^7.4.4",
|
||||
"electron-dl": "^3.5.2",
|
||||
"electron-localshortcut": "^3.2.1",
|
||||
"electron-log": "^5.4.3",
|
||||
|
|
|
|||
|
|
@ -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<Notification | undefined> {
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
|
|||
143
src/app/core/platform/capacitor-notification.service.spec.ts
Normal file
143
src/app/core/platform/capacitor-notification.service.spec.ts
Normal file
|
|
@ -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<CapacitorPlatformService>;
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
235
src/app/core/platform/capacitor-notification.service.ts
Normal file
235
src/app/core/platform/capacitor-notification.service.ts
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
import { inject, Injectable } from '@angular/core';
|
||||
import { LocalNotifications, ScheduleOptions } from '@capacitor/local-notifications';
|
||||
import { Log } from '../log';
|
||||
import { CapacitorPlatformService } from './capacitor-platform.service';
|
||||
|
||||
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<string, unknown>;
|
||||
/**
|
||||
* Whether to allow notification when device is idle (Android)
|
||||
*/
|
||||
allowWhileIdle?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
|
||||
/**
|
||||
* Check if notifications are available on this platform
|
||||
*/
|
||||
get isAvailable(): boolean {
|
||||
return this._platformService.capabilities.scheduledNotifications;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request notification permissions from the user
|
||||
*/
|
||||
async requestPermissions(): Promise<boolean> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
const hasPermission = await this.checkPermissions();
|
||||
if (hasPermission) {
|
||||
return true;
|
||||
}
|
||||
return this.requestPermissions();
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule a local notification
|
||||
*/
|
||||
async schedule(options: ScheduleNotificationOptions): Promise<boolean> {
|
||||
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,
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
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<string, unknown> }) => void,
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
if (!this.isAvailable) {
|
||||
return;
|
||||
}
|
||||
|
||||
await LocalNotifications.removeAllListeners();
|
||||
}
|
||||
}
|
||||
64
src/app/core/platform/capacitor-platform.service.spec.ts
Normal file
64
src/app/core/platform/capacitor-platform.service.spec.ts
Normal file
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
169
src/app/core/platform/capacitor-platform.service.ts
Normal file
169
src/app/core/platform/capacitor-platform.service.ts
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
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';
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
185
src/app/core/platform/capacitor-reminder.service.spec.ts
Normal file
185
src/app/core/platform/capacitor-reminder.service.spec.ts
Normal file
|
|
@ -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<CapacitorPlatformService>;
|
||||
let notificationServiceSpy: jasmine.SpyObj<CapacitorNotificationService>;
|
||||
|
||||
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<CapacitorPlatformService>;
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
228
src/app/core/platform/capacitor-reminder.service.ts
Normal file
228
src/app/core/platform/capacitor-reminder.service.ts
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
import { inject, Injectable } from '@angular/core';
|
||||
import { LocalNotifications } from '@capacitor/local-notifications';
|
||||
import { Log } from '../log';
|
||||
import { CapacitorPlatformService } from './capacitor-platform.service';
|
||||
import { CapacitorNotificationService } from './capacitor-notification.service';
|
||||
import { IS_ANDROID_WEB_VIEW } from '../../util/is-android-web-view';
|
||||
import { androidInterface } from '../../features/android/android-interface';
|
||||
|
||||
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);
|
||||
|
||||
/**
|
||||
* Check if reminder scheduling is available on this platform
|
||||
*/
|
||||
get isAvailable(): boolean {
|
||||
return this._platformService.capabilities.scheduledNotifications;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule a reminder notification
|
||||
*/
|
||||
async scheduleReminder(options: ScheduleReminderOptions): Promise<boolean> {
|
||||
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}`,
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
4
src/app/core/platform/index.ts
Normal file
4
src/app/core/platform/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export * from './platform-capabilities.model';
|
||||
export * from './capacitor-platform.service';
|
||||
export * from './capacitor-notification.service';
|
||||
export * from './capacitor-reminder.service';
|
||||
124
src/app/core/platform/platform-capabilities.model.ts
Normal file
124
src/app/core/platform/platform-capabilities.model.ts
Normal file
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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';
|
||||
|
|
@ -27,6 +26,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 };
|
||||
|
||||
|
|
@ -51,6 +51,7 @@ export class StartupService {
|
|||
private _projectService = inject(ProjectService);
|
||||
private _trackingReminderService = inject(TrackingReminderService);
|
||||
private _opLogStore = inject(OperationLogStoreService);
|
||||
private _platformService = inject(CapacitorPlatformService);
|
||||
|
||||
constructor() {
|
||||
// Initialize electron error handler in an effect
|
||||
|
|
@ -77,7 +78,8 @@ export class StartupService {
|
|||
}
|
||||
|
||||
async init(): Promise<void> {
|
||||
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();
|
||||
|
|
@ -140,7 +142,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();
|
||||
}
|
||||
}
|
||||
|
|
@ -148,7 +151,8 @@ export class StartupService {
|
|||
|
||||
private async _initBackups(): Promise<void> {
|
||||
// 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) {
|
||||
|
|
@ -258,8 +262,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 });
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import { inject, Injectable } from '@angular/core';
|
|||
import { createEffect } from '@ngrx/effects';
|
||||
import { switchMap, tap } from 'rxjs/operators';
|
||||
import { timer } from 'rxjs';
|
||||
import { LocalNotifications } from '@capacitor/local-notifications';
|
||||
import { SnackService } from '../../../core/snack/snack.service';
|
||||
import { IS_ANDROID_WEB_VIEW } from '../../../util/is-android-web-view';
|
||||
import { DroidLog } from '../../../core/log';
|
||||
|
|
@ -12,6 +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 { 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
|
||||
|
||||
|
|
@ -24,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<string>();
|
||||
|
||||
/**
|
||||
* 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());
|
||||
|
|
@ -58,16 +60,17 @@ 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(
|
||||
|
|
@ -82,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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -94,44 +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();
|
||||
|
||||
// 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,
|
||||
);
|
||||
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);
|
||||
|
|
@ -210,23 +207,6 @@ export class AndroidEffects {
|
|||
{ dispatch: false },
|
||||
);
|
||||
|
||||
private async _ensureExactAlarmAccess(): Promise<void> {
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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<WebDavHttpResponse> {
|
||||
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,
|
||||
|
|
|
|||
19
src/app/util/is-native-platform.ts
Normal file
19
src/app/util/is-native-platform.ts
Normal file
|
|
@ -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;
|
||||
Loading…
Add table
Add a link
Reference in a new issue