Compare commits

...

26 Commits

Author SHA1 Message Date
Matthew Raymer
80d5199259 chore: synchronize package 2025-11-11 01:04:20 +00:00
Matthew Raymer
816c7a6582 Fix daily notification plugin integration and bundling
- Change from dynamic to static imports for @timesafari/daily-notification-plugin
- Remove plugin from external dependencies in vite.config.capacitor.mts to ensure proper bundling
- Add debug logging to DailyNotificationSection for troubleshooting
- Update package-lock.json with latest dependencies
2025-11-10 04:23:45 +00:00
Matthew Raymer
9ea9f4969e Merge branch 'master' into integrate-notification-plugin 2025-11-10 02:55:07 +00:00
5050156beb fix: a type, plus add the type-check to the mobile build scripts 2025-11-08 08:31:42 -07:00
d265a9f78c chore: bump version and add "-beta" 2025-11-06 08:56:33 -07:00
f848de15f1 chore: bump version to 1.1.2 build 47 (for fix to seed backup) 2025-11-06 08:54:11 -07:00
ebaf2dedf0 Merge pull request 'fix: database connection error causing navigation redirect on iOS/Android' (#220) from fix-sqlite-connection-error-mobile into master
Reviewed-on: #220
2025-11-06 09:52:31 -05:00
Jose Olarte III
749204f96b fix: database connection error causing navigation redirect on iOS/Android
Handle "Connection already exists" error when initializing SQLite database
on Capacitor platforms. The native connection can persist across app
restarts while the JavaScript connection Map is empty, causing a mismatch.

When createConnection fails with "already exists":
- Check if connection exists in JavaScript Map and retrieve it if present
- If not in Map, close the native connection and recreate to sync both sides
- Handle "already open" errors gracefully when opening existing connections

This fixes the issue where clicking "Backup Identifier Seed" would redirect
to StartView instead of SeedBackupView due to database initialization
failures in the router navigation guard.

Fixes navigation issue on both iOS and Android platforms.
2025-11-06 21:38:51 +08:00
Matthew Raymer
831532739c feat: implement 72-hour JWT token refresh for daily notification plugin
- Add accessTokenForBackground() with 72-hour default expiration
  - Supports offline-first prefetch operations
  - Balances security with offline capability

- Implement proactive token refresh strategy
  - Refresh on component mount (DailyNotificationSection)
  - Refresh on app resume (Capacitor 'resume' event)
  - Refresh when notifications are enabled
  - Automatic refresh without user interaction

- Update CapacitorPlatformService.configureNativeFetcher()
  - Automatically retrieves activeDid from database
  - Generates 72-hour JWT tokens for background operations
  - Includes starred plans in configuration

- Add BroadcastReceivers to AndroidManifest.xml
  - DailyNotificationReceiver for scheduled notifications
  - BootReceiver for rescheduling after device reboot

- Add comprehensive documentation
  - JSDoc comments for all token-related functions
  - Inline comments explaining refresh strategy
  - Documentation section on authentication & token management

Benefits:
- No app wake-up required (refresh when app already open)
- Works offline (72-hour validity supports extended periods)
- Automatic (no user interaction required)
- Graceful degradation (uses cached content if refresh fails)
2025-11-06 12:44:06 +00:00
Matthew Raymer
5f17f6cb4e feat(notifications): integrate daily notification plugin into AccountViewView
- Add notification methods to PlatformService interface
- Implement notification support in CapacitorPlatformService with plugin integration
- Add stub implementations in WebPlatformService and ElectronPlatformService
- Add nativeNotificationTime, nativeNotificationTitle, and nativeNotificationMessage fields to Settings interface
- Create DailyNotificationSection component for AccountViewView integration
- Add Android manifest permissions (POST_NOTIFICATIONS, SCHEDULE_EXACT_ALARM, RECEIVE_BOOT_COMPLETED)
- Register daily-notification-plugin in capacitor.plugins.json
- Integrate DailyNotificationSection into AccountViewView

Features:
- Platform capability detection (hides on unsupported platforms)
- Permission request flow with fallback to settings
- Schedule/cancel notifications
- Time editing with HTML5 time input
- Settings persistence
- Plugin state synchronization on app load

NOTE: Currently storing notification schedule in SQLite database, but plugin
was designed to store schedule internally. Consider migrating to plugin's
internal storage to avoid database initialization issues.
2025-11-05 10:37:02 +00:00
Matthew Raymer
5def44c349 fix(db): remove inline comments from migration 006 SQL
Migration 006 was failing during database initialization because the SQLite
plugin splits SQL statements on semicolons, and inline comments after
semicolons were being treated as separate statements. When the last statement
was comment-only (e.g., '-- Notification body text'), it caused an error.

Fixed by removing all inline comments from the migration SQL. The comments
are already documented in the TypeScript code, so they're not needed in the
SQL itself.

NOTE: We're experiencing database initialization problems with storing
notification schedule data. The daily notification plugin was originally
designed to store the schedule internally, which would be a better approach
than storing it in our SQLite database. We should consider migrating to
using the plugin's internal storage instead of adding these columns to the
settings table.
2025-11-05 10:36:00 +00:00
1053bb6e4c Merge pull request 'bulk-members-dialog-refactor' (#218) from bulk-members-dialog-refactor into master
Reviewed-on: #218
2025-11-05 03:34:27 -05:00
88f46787e5 Merge pull request 'entity-selection-list-component' (#216) from entity-selection-list-component into master
Reviewed-on: #216
2025-11-05 03:25:35 -05:00
Matthew Raymer
45eff4a9ac docs: add plugin state sync, time update logic, and component extraction
- Update initialization to sync with plugin state on mount (checks for pre-existing schedules)
- Add updateNotificationTime() method to update schedule when time changes (cancel old, schedule new)
- Extract DailyNotificationSection into dedicated component using vue-facing-decorator
- Update component architecture to show DailyNotificationSection.vue structure
- Update Phase 2 tasks to reflect component creation and AccountViewView integration
- Add acceptance criteria for plugin state sync and time update functionality
- Update verification checklist with new requirements
2025-11-05 06:34:53 +00:00
Matthew Raymer
ae5f1a33a7 docs: add checkboxes to all actionable items in integration plan
- Add checkboxes to Phase 1, 2, 3 task sub-items
- Add checkboxes to Milestone success criteria
- Add checkboxes to Testing Strategy test items
- Add checkboxes to Risk Mitigation mitigation items
- Add checkboxes to Next Steps
- All actionable items now have checkboxes for tracking progress
2025-11-05 06:02:34 +00:00
Matthew Raymer
95ac1afcd2 docs: remove independent views, focus on AccountViewView integration only
- Remove ScheduleView, NotificationsView, NotificationHistoryView, NotificationSettingsView
- Remove router routes for independent views
- Remove Pinia store (not needed - state managed locally)
- Remove HomeView diagnostics integration
- Remove native fetcher configuration integration
- Keep only AccountViewView integration with optional supporting components
- Update all phases to focus on AccountViewView only
- Update milestones and testing strategy
- Update dependencies to remove router/pinia references
- Clarify supporting components are optional and only if AccountViewView exceeds length limits
2025-11-05 04:41:27 +00:00
Matthew Raymer
49c62b2b69 Merge branch 'integrate-notification-plugin' of ssh://173.199.124.46:222/trent_larson/crowd-funder-for-time-pwa into integrate-notification-plugin 2025-11-05 04:18:25 +00:00
Matthew Raymer
7ae3b241dd docs: add explicit requirement for components to hide on unsupported platforms
- Add CRITICAL REQUIREMENT in Platform Detection Strategy section
- Add Component Visibility Requirements listing all components
- Add Component Visibility Requirements section in Implementation Notes
- Include required pattern with code examples for component hiding
- Add verification checklist for component hiding
- Update Phase 2 tasks to require platform support checks
- Update Phase 3 tasks to require hiding for all notification views
- Add Risk 6 for components visible on unsupported platforms
- Update acceptance criteria to verify component hiding
- Update success criteria to verify hiding on Web/Electron platforms
2025-11-05 04:04:18 +00:00
Matthew Raymer
ced8248436 docs: merge AccountViewView integration strategy into main plan
- Consolidate AccountViewView integration strategy into unified plan
- Add comprehensive AccountViewView Integration Strategy section
- Include UI component design, data flow, and implementation decisions
- Remove separate strategy document to follow meta_feature_planning structure
- Update Phase 2 to include AccountViewView integration tasks
2025-11-05 04:01:50 +00:00
Matthew Raymer
70059e5a31 Merge branch 'master' into integrate-notification-plugin 2025-11-05 02:18:12 +00:00
Jose Olarte III
c06ffec466 refactor: combine member processing methods in BulkMembersDialog
Consolidate organizerAdmitAndAddWithVisibility() and
memberAddContactWithVisibility() into a single unified method
processSelectedMembers() that handles both organizer and member
modes based on the isOrganizer prop.

- Remove redundant handleMainAction() wrapper method
- Update template to call processSelectedMembers directly
- Reduce code duplication by ~30% (140 lines → 98 lines)
- Maintain identical functionality for both modes

This simplifies the component structure and makes the processing
logic easier to maintain.
2025-11-04 18:39:45 +08:00
Jose Olarte III
8b199ec76c refactor: remove redundant dialogType prop from BulkMembersDialog
Remove dialogType prop and consolidate to use only isOrganizer prop.

- Remove dialogType prop from BulkMembersDialog component
- Replace all dialogType checks with isOrganizer boolean checks
- Add comments clarifying isOrganizer true/false meanings
- Remove dialog-type prop binding from MembersList component

This simplifies the component API while maintaining the same functionality.
2025-11-04 17:57:38 +08:00
Matthew Raymer
602fe394fa Merge branch 'master' into integrate-notification-plugin 2025-11-04 03:52:52 +00:00
Matthew Raymer
1f858fa1ce build: configure daily notification plugin for Capacitor Android
- Add plugin project to capacitor.build.gradle
- Register plugin in capacitor.plugins.json
- Include plugin project in capacitor.settings.gradle
2025-11-03 10:04:31 +00:00
Matthew Raymer
f9446f529b chore: add @timesafari/daily-notification-plugin dependency
- Add daily-notification-plugin as local file dependency
- Update package-lock.json with plugin dependency tree
2025-11-03 10:04:30 +00:00
Matthew Raymer
d576920810 docs: add daily notification plugin integration planning documents
- Add comprehensive integration plan following meta_feature_planning workflow
- Add AccountViewView integration strategy with PlatformService approach
- Document architecture decisions: PlatformService interface integration
- Remove web/push notification references
- Document implementation phases and acceptance criteria
2025-11-03 10:03:08 +00:00
24 changed files with 3048 additions and 161 deletions

View File

@@ -1161,7 +1161,7 @@ If you need to build manually or want to understand the individual steps:
##### 1. Bump the version in package.json for `MARKETING_VERSION`, then `grep CURRENT_PROJECT_VERSION ios/App/App.xcodeproj/project.pbxproj` and add 1 for the numbered version;
```bash
cd ios/App && xcrun agvtool new-version 46 && perl -p -i -e "s/MARKETING_VERSION = .*;/MARKETING_VERSION = 1.1.1;/g" App.xcodeproj/project.pbxproj && cd -
cd ios/App && xcrun agvtool new-version 47 && perl -p -i -e "s/MARKETING_VERSION = .*;/MARKETING_VERSION = 1.1.2;/g" App.xcodeproj/project.pbxproj && cd -
# Unfortunately this edits Info.plist directly.
#xcrun agvtool new-marketing-version 0.4.5
```
@@ -1197,7 +1197,8 @@ If you need to build manually or want to understand the individual steps:
- It can take 15 minutes for the build to show up in the list of builds.
- You'll probably have to "Manage" something about encryption, disallowed in France.
- Then "Save" and "Add to Review" and "Resubmit to App Review".
- Eventually it'll be "Ready for Distribution" which means
- Eventually it'll be "Ready for Distribution" which means it's live
- When finished, bump package.json version
### Android Build
@@ -1315,11 +1316,11 @@ The recommended way to build for Android is using the automated build script:
#### Android Manual Build Process
##### 1. Bump the version in package.json, then here: android/app/build.gradle
##### 1. Bump the version in package.json, then update these versions & run:
```bash
perl -p -i -e 's/versionCode .*/versionCode 46/g' android/app/build.gradle
perl -p -i -e 's/versionName .*/versionName "1.1.1"/g' android/app/build.gradle
perl -p -i -e 's/versionCode .*/versionCode 47/g' android/app/build.gradle
perl -p -i -e 's/versionName .*/versionName "1.1.2"/g' android/app/build.gradle
```
##### 2. Build
@@ -1379,6 +1380,8 @@ At play.google.com/console:
- Note that if you add testers, you have to go to "Publishing Overview" and send
those changes or your (closed) testers won't see it.
- When finished, bump package.json version
### Capacitor Operations
```bash

View File

@@ -6,6 +6,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.1.2] - 2025.11.06
### Fixed
- Bad page when user follows prompt to backup seed
## [1.1.1] - 2025.11.03
### Added

View File

@@ -31,8 +31,8 @@ android {
applicationId "app.timesafari.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 46
versionName "1.1.1"
versionCode 47
versionName "1.1.2"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.

View File

@@ -18,6 +18,7 @@ dependencies {
implementation project(':capacitor-share')
implementation project(':capacitor-status-bar')
implementation project(':capawesome-capacitor-file-picker')
implementation project(':timesafari-daily-notification-plugin')
}

View File

@@ -36,6 +36,30 @@
android:grantUriPermissions="true">
<meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/file_paths" />
</provider>
<!-- Daily Notification Plugin Receivers -->
<receiver
android:name="com.timesafari.dailynotification.DailyNotificationReceiver"
android:enabled="true"
android:exported="false">
<intent-filter>
<action android:name="com.timesafari.daily.NOTIFICATION" />
</intent-filter>
</receiver>
<receiver
android:name="com.timesafari.dailynotification.BootReceiver"
android:directBootAware="true"
android:enabled="true"
android:exported="true">
<intent-filter android:priority="1000">
<!-- Delivered very early after reboot (before unlock) -->
<action android:name="android.intent.action.LOCKED_BOOT_COMPLETED" />
<!-- Delivered after the user unlocks / credential-encrypted storage is available -->
<action android:name="android.intent.action.BOOT_COMPLETED" />
<!-- Delivered after app update; great for rescheduling alarms without reboot -->
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
</intent-filter>
</receiver>
</application>
<!-- Permissions -->
@@ -45,4 +69,15 @@
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera" android:required="true" />
<!-- Notification permissions -->
<!-- POST_NOTIFICATIONS required for Android 13+ (API 33+) -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- SCHEDULE_EXACT_ALARM required for Android 12+ (API 31+) to schedule exact alarms -->
<!-- Note: On Android 12+, users can grant/deny this permission -->
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
<!-- RECEIVE_BOOT_COMPLETED needed to reschedule notifications after device reboot -->
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
</manifest>

View File

@@ -34,5 +34,9 @@
{
"pkg": "@capawesome/capacitor-file-picker",
"classpath": "io.capawesome.capacitorjs.plugins.filepicker.FilePickerPlugin"
},
{
"pkg": "@timesafari/daily-notification-plugin",
"classpath": "com.timesafari.dailynotification.DailyNotificationPlugin"
}
]

View File

@@ -28,3 +28,6 @@ project(':capacitor-status-bar').projectDir = new File('../node_modules/@capacit
include ':capawesome-capacitor-file-picker'
project(':capawesome-capacitor-file-picker').projectDir = new File('../node_modules/@capawesome/capacitor-file-picker/android')
include ':timesafari-daily-notification-plugin'
project(':timesafari-daily-notification-plugin').projectDir = new File('../node_modules/@timesafari/daily-notification-plugin/android')

File diff suppressed because it is too large Load Diff

View File

@@ -403,7 +403,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 46;
CURRENT_PROJECT_VERSION = 47;
DEVELOPMENT_TEAM = GM3FS5JQPH;
ENABLE_APP_SANDBOX = NO;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
@@ -413,7 +413,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.1.1;
MARKETING_VERSION = 1.1.2;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -430,7 +430,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 46;
CURRENT_PROJECT_VERSION = 47;
DEVELOPMENT_TEAM = GM3FS5JQPH;
ENABLE_APP_SANDBOX = NO;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
@@ -440,7 +440,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.1.1;
MARKETING_VERSION = 1.1.2;
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";

46
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "timesafari",
"version": "1.1.2-beta",
"version": "1.1.3-beta",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "timesafari",
"version": "1.1.2-beta",
"version": "1.1.3-beta",
"dependencies": {
"@capacitor-community/electron": "^5.0.1",
"@capacitor-community/sqlite": "6.0.2",
@@ -37,6 +37,7 @@
"@pvermeer/dexie-encrypted-addon": "^3.0.0",
"@simplewebauthn/browser": "^10.0.0",
"@simplewebauthn/server": "^10.0.0",
"@timesafari/daily-notification-plugin": "file:../daily-notification-plugin",
"@tweenjs/tween.js": "^21.1.1",
"@types/qrcode": "^1.5.5",
"@veramo/core": "^5.6.0",
@@ -149,6 +150,43 @@
"vite": "^5.2.0"
}
},
"../daily-notification-plugin": {
"name": "@timesafari/daily-notification-plugin",
"version": "1.0.8",
"license": "MIT",
"workspaces": [
"packages/*"
],
"dependencies": {
"@capacitor/core": "^6.2.1"
},
"devDependencies": {
"@capacitor/android": "^6.2.1",
"@capacitor/cli": "^6.2.1",
"@capacitor/ios": "^6.2.1",
"@types/jest": "^29.5.0",
"@types/jsdom": "^21.1.7",
"@types/node": "^20.19.0",
"@typescript-eslint/eslint-plugin": "^5.57.0",
"@typescript-eslint/parser": "^5.57.0",
"eslint": "^8.37.0",
"jest": "^29.5.0",
"jest-environment-jsdom": "^30.0.5",
"jsdom": "^26.1.0",
"markdownlint-cli2": "^0.18.1",
"prettier": "^2.8.7",
"rimraf": "^4.4.0",
"rollup": "^3.20.0",
"rollup-plugin-typescript2": "^0.31.0",
"standard-version": "^9.5.0",
"ts-jest": "^29.1.0",
"typescript": "~5.2.0",
"vite": "^7.1.9"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@0no-co/graphql.web": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@0no-co/graphql.web/-/graphql.web-1.2.0.tgz",
@@ -9605,6 +9643,10 @@
"node": ">=10"
}
},
"node_modules/@timesafari/daily-notification-plugin": {
"resolved": "../daily-notification-plugin",
"link": true
},
"node_modules/@tootallnate/once": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "timesafari",
"version": "1.1.2-beta",
"version": "1.1.3-beta",
"description": "Time Safari Application",
"author": {
"name": "Time Safari Team"
@@ -166,6 +166,7 @@
"@pvermeer/dexie-encrypted-addon": "^3.0.0",
"@simplewebauthn/browser": "^10.0.0",
"@simplewebauthn/server": "^10.0.0",
"@timesafari/daily-notification-plugin": "file:../daily-notification-plugin",
"@tweenjs/tween.js": "^21.1.1",
"@types/qrcode": "^1.5.5",
"@veramo/core": "^5.6.0",

View File

@@ -436,7 +436,21 @@ fi
log_info "Cleaning dist directory..."
clean_build_artifacts "dist"
# Step 4: Build Capacitor version with mode
# Step 4: Run TypeScript type checking for test and production builds
if [ "$BUILD_MODE" = "production" ] || [ "$BUILD_MODE" = "test" ]; then
log_info "Running TypeScript type checking for $BUILD_MODE mode..."
if ! measure_time npm run type-check; then
log_error "TypeScript type checking failed for $BUILD_MODE mode!"
exit 2
fi
log_success "TypeScript type checking completed for $BUILD_MODE mode"
else
log_debug "Skipping TypeScript type checking for development mode"
fi
# Step 5: Build Capacitor version with mode
if [ "$BUILD_MODE" = "development" ]; then
safe_execute "Building Capacitor version (development)" "npm run build:capacitor" || exit 3
elif [ "$BUILD_MODE" = "test" ]; then
@@ -445,23 +459,23 @@ elif [ "$BUILD_MODE" = "production" ]; then
safe_execute "Building Capacitor version (production)" "npm run build:capacitor -- --mode production" || exit 3
fi
# Step 5: Clean Gradle build
# Step 6: Clean Gradle build
safe_execute "Cleaning Gradle build" "cd android && ./gradlew clean && cd .." || exit 4
# Step 6: Build based on type
# Step 7: Build based on type
if [ "$BUILD_TYPE" = "debug" ]; then
safe_execute "Assembling debug build" "cd android && ./gradlew assembleDebug && cd .." || exit 5
elif [ "$BUILD_TYPE" = "release" ]; then
safe_execute "Assembling release build" "cd android && ./gradlew assembleRelease && cd .." || exit 5
fi
# Step 7: Sync with Capacitor
# Step 8: Sync with Capacitor
safe_execute "Syncing with Capacitor" "npx cap sync android" || exit 6
# Step 8: Generate assets
# Step 9: Generate assets
safe_execute "Generating assets" "npx capacitor-assets generate --android" || exit 7
# Step 9: Build APK/AAB if requested
# Step 10: Build APK/AAB if requested
if [ "$BUILD_APK" = true ]; then
if [ "$BUILD_TYPE" = "debug" ]; then
safe_execute "Building debug APK" "cd android && ./gradlew assembleDebug && cd .." || exit 5
@@ -474,7 +488,7 @@ if [ "$BUILD_AAB" = true ]; then
safe_execute "Building AAB" "cd android && ./gradlew bundleRelease && cd .." || exit 5
fi
# Step 10: Auto-run app if requested
# Step 11: Auto-run app if requested
if [ "$AUTO_RUN" = true ]; then
log_step "Auto-running Android app..."
safe_execute "Launching app" "npx cap run android" || {
@@ -485,7 +499,7 @@ if [ "$AUTO_RUN" = true ]; then
log_success "Android app launched successfully!"
fi
# Step 11: Open Android Studio if requested
# Step 12: Open Android Studio if requested
if [ "$OPEN_STUDIO" = true ]; then
safe_execute "Opening Android Studio" "npx cap open android" || exit 8
fi

View File

@@ -381,7 +381,21 @@ safe_execute "Cleaning iOS build" "clean_ios_build" || exit 1
log_info "Cleaning dist directory..."
clean_build_artifacts "dist"
# Step 4: Build Capacitor version with mode
# Step 4: Run TypeScript type checking for test and production builds
if [ "$BUILD_MODE" = "production" ] || [ "$BUILD_MODE" = "test" ]; then
log_info "Running TypeScript type checking for $BUILD_MODE mode..."
if ! measure_time npm run type-check; then
log_error "TypeScript type checking failed for $BUILD_MODE mode!"
exit 2
fi
log_success "TypeScript type checking completed for $BUILD_MODE mode"
else
log_debug "Skipping TypeScript type checking for development mode"
fi
# Step 5: Build Capacitor version with mode
if [ "$BUILD_MODE" = "development" ]; then
safe_execute "Building Capacitor version (development)" "npm run build:capacitor" || exit 3
elif [ "$BUILD_MODE" = "test" ]; then
@@ -390,16 +404,16 @@ elif [ "$BUILD_MODE" = "production" ]; then
safe_execute "Building Capacitor version (production)" "npm run build:capacitor -- --mode production" || exit 3
fi
# Step 5: Sync with Capacitor
# Step 6: Sync with Capacitor
safe_execute "Syncing with Capacitor" "npx cap sync ios" || exit 6
# Step 6: Generate assets
# Step 7: Generate assets
safe_execute "Generating assets" "npx capacitor-assets generate --ios" || exit 7
# Step 7: Build iOS app
# Step 8: Build iOS app
safe_execute "Building iOS app" "build_ios_app" || exit 5
# Step 8: Build IPA/App if requested
# Step 9: Build IPA/App if requested
if [ "$BUILD_IPA" = true ]; then
log_info "Building IPA package..."
cd ios/App
@@ -426,12 +440,12 @@ if [ "$BUILD_APP" = true ]; then
log_success "App bundle built successfully"
fi
# Step 9: Auto-run app if requested
# Step 10: Auto-run app if requested
if [ "$AUTO_RUN" = true ]; then
safe_execute "Auto-running iOS app" "auto_run_ios_app" || exit 9
fi
# Step 10: Open Xcode if requested
# Step 11: Open Xcode if requested
if [ "$OPEN_STUDIO" = true ]; then
safe_execute "Opening Xcode" "npx cap open ios" || exit 8
fi

View File

@@ -111,7 +111,7 @@
? 'bg-blue-600 text-white cursor-pointer'
: 'bg-slate-400 text-slate-200 cursor-not-allowed',
]"
@click="handleMainAction"
@click="processSelectedMembers"
>
{{ buttonText }}
</button>
@@ -145,7 +145,7 @@ import { Contact } from "@/db/tables/contacts";
export default class BulkMembersDialog extends Vue {
@Prop({ default: "" }) activeDid!: string;
@Prop({ default: "" }) apiServer!: string;
@Prop({ required: true }) dialogType!: "admit" | "visibility";
// isOrganizer: true = organizer mode (admit members), false = member mode (set visibility)
@Prop({ required: true }) isOrganizer!: boolean;
// Vue notification system
@@ -252,15 +252,7 @@ export default class BulkMembersDialog extends Vue {
return this.selectedMembers.includes(memberDid);
}
async handleMainAction() {
if (this.dialogType === "admit") {
await this.organizerAdmitAndAddWithVisibility();
} else {
await this.memberAddContactWithVisibility();
}
}
async organizerAdmitAndAddWithVisibility() {
async processSelectedMembers() {
try {
const selectedMembers: MemberData[] = this.membersData.filter((member) =>
this.selectedMembers.includes(member.did),
@@ -275,16 +267,20 @@ export default class BulkMembersDialog extends Vue {
for (const member of selectedMembers) {
try {
// First, admit the member
// Organizer mode: admit and register the member first
if (this.isOrganizer) {
await this.admitMember(member);
// Register them
await this.registerMember(member);
admittedCount++;
}
// If they're not a contact yet, add them as a contact
if (!member.isContact) {
await this.addAsContact(member, true);
// Organizer mode: set isRegistered to true, member mode: undefined
await this.addAsContact(
member,
this.isOrganizer ? true : undefined,
);
contactAddedCount++;
}
@@ -299,6 +295,7 @@ export default class BulkMembersDialog extends Vue {
}
// Show success notification
if (this.isOrganizer) {
if (admittedCount > 0) {
this.$notify(
{
@@ -307,7 +304,7 @@ export default class BulkMembersDialog extends Vue {
title: "Members Admitted Successfully",
text: `${admittedCount} member${admittedCount === 1 ? "" : "s"} admitted and registered${contactAddedCount === 0 ? "" : admittedCount === contactAddedCount ? " and" : `, ${contactAddedCount}`}${contactAddedCount === 0 ? "" : ` added as contact${contactAddedCount === 1 ? "" : "s"}`}.`,
},
10000,
5000,
);
}
if (errors > 0) {
@@ -321,66 +318,28 @@ export default class BulkMembersDialog extends Vue {
5000,
);
}
this.close(notSelectedMembers.map((member) => member.did));
} catch (error) {
// eslint-disable-next-line no-console
console.error("Error admitting members:", error);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Some errors occurred. Work with members individually below.",
},
5000,
);
}
}
async memberAddContactWithVisibility() {
try {
const selectedMembers: MemberData[] = this.membersData.filter((member) =>
this.selectedMembers.includes(member.did),
);
const notSelectedMembers: MemberData[] = this.membersData.filter(
(member) => !this.selectedMembers.includes(member.did),
);
let contactsAddedCount = 0;
for (const member of selectedMembers) {
try {
// If they're not a contact yet, add them as a contact first
if (!member.isContact) {
await this.addAsContact(member, undefined);
contactsAddedCount++;
}
// Set their seesMe to true
await this.updateContactVisibility(member.did, true);
} catch (error) {
// eslint-disable-next-line no-console
console.error(`Error processing member ${member.did}:`, error);
// Continue with other members even if one fails
}
}
// Show success notification
} else {
// Member mode: show contacts added notification
if (contactAddedCount > 0) {
this.$notify(
{
group: "alert",
type: "success",
title: "Contacts Added Successfully",
text: `${contactsAddedCount} member${contactsAddedCount === 1 ? "" : "s"} added as contact${contactsAddedCount === 1 ? "" : "s"}.`,
text: `${contactAddedCount} member${contactAddedCount === 1 ? "" : "s"} added as contact${contactAddedCount === 1 ? "" : "s"}.`,
},
5000,
);
}
}
this.close(notSelectedMembers.map((member) => member.did));
} catch (error) {
// eslint-disable-next-line no-console
console.error("Error adding contacts:", error);
console.error(
`Error ${this.isOrganizer ? "admitting members" : "adding contacts"}:`,
error,
);
this.$notify(
{
group: "alert",
@@ -487,8 +446,8 @@ export default class BulkMembersDialog extends Vue {
}
showContactInfo() {
const message =
this.dialogType === "admit"
// isOrganizer: true = admit mode, false = visibility mode
const message = this.isOrganizer
? "This user is already your contact, but they are not yet admitted to the meeting."
: "This user is already your contact, but your activities are not visible to them yet.";

View File

@@ -223,7 +223,6 @@
ref="bulkMembersDialog"
:active-did="activeDid"
:api-server="apiServer"
:dialog-type="isOrganizer ? 'admit' : 'visibility'"
:is-organizer="isOrganizer"
@close="closeBulkMembersDialogCallback"
/>

View File

@@ -0,0 +1,781 @@
<template>
<section
v-if="notificationsSupported"
id="sectionDailyNotifications"
class="bg-slate-100 rounded-md overflow-hidden px-4 py-4 mt-8 mb-8"
aria-labelledby="dailyNotificationsHeading"
>
<h2 id="dailyNotificationsHeading" class="mb-2 font-bold">
Daily Notifications
<button
class="text-slate-400 fa-fw cursor-pointer"
aria-label="Learn more about native notifications"
@click.stop="showNativeNotificationInfo"
>
<font-awesome icon="circle-question" aria-hidden="true" />
</button>
</h2>
<div class="flex items-center justify-between">
<div>Daily Notification</div>
<!-- Toggle switch -->
<div
class="relative ml-2 cursor-pointer"
role="switch"
:aria-checked="nativeNotificationEnabled"
:aria-label="
nativeNotificationEnabled
? 'Disable daily notifications'
: 'Enable daily notifications'
"
tabindex="0"
@click="toggleNativeNotification"
>
<!-- input -->
<input
:checked="nativeNotificationEnabled"
type="checkbox"
class="sr-only"
tabindex="-1"
readonly
/>
<!-- line -->
<div
class="block bg-slate-500 w-14 h-8 rounded-full transition"
:class="{
'bg-blue-600': nativeNotificationEnabled,
}"
></div>
<!-- dot -->
<div
class="dot absolute left-1 top-1 bg-slate-400 w-6 h-6 rounded-full transition"
:class="{
'left-7 bg-white': nativeNotificationEnabled,
}"
></div>
</div>
</div>
<!-- Show "Open Settings" button when permissions are denied -->
<div
v-if="
notificationsSupported &&
notificationStatus &&
notificationStatus.permissions.notifications === 'denied'
"
class="mt-2"
>
<button
class="w-full px-3 py-2 bg-blue-600 text-white rounded text-sm font-medium"
@click="openNotificationSettings"
>
Open Settings
</button>
<p class="text-xs text-slate-500 mt-1 text-center">
Enable notifications in Settings > App info > Notifications
</p>
</div>
<!-- Time input section - show when enabled OR when no time is set -->
<div
v-if="nativeNotificationEnabled || !nativeNotificationTimeStorage"
class="mt-2"
>
<div
v-if="nativeNotificationEnabled"
class="flex items-center justify-between mb-2"
>
<span
>Scheduled for:
<span v-if="nativeNotificationTime">{{
nativeNotificationTime
}}</span>
<span v-else class="text-slate-500">Not set</span></span
>
<button
class="text-blue-500 text-sm"
@click="editNativeNotificationTime"
>
{{ showTimeEdit ? "Cancel" : "Edit Time" }}
</button>
</div>
<!-- Time input (shown when editing or when no time is set) -->
<div v-if="showTimeEdit || !nativeNotificationTimeStorage" class="mt-2">
<label class="block text-sm text-slate-600 mb-1">
Notification Time
</label>
<div class="flex items-center gap-2">
<input
v-model="nativeNotificationTimeStorage"
type="time"
class="rounded border border-slate-400 px-2 py-2"
@change="onTimeChange"
/>
<button
v-if="showTimeEdit || nativeNotificationTimeStorage"
class="px-3 py-2 bg-blue-600 text-white rounded"
@click="saveTimeChange"
>
Save
</button>
</div>
<p
v-if="!nativeNotificationTimeStorage"
class="text-xs text-slate-500 mt-1"
>
Set a time before enabling notifications
</p>
</div>
</div>
<!-- Loading state -->
<div v-if="loading" class="mt-2 text-sm text-slate-500">Loading...</div>
</section>
</template>
<script lang="ts">
/**
* DailyNotificationSection Component
*
* A self-contained component for managing daily notification scheduling
* in AccountViewView. This component handles platform detection, permission
* requests, scheduling, and state management for daily notifications.
*
* Features:
* - Platform capability detection (hides on unsupported platforms)
* - Permission request flow
* - Schedule/cancel notifications
* - Time editing with HTML5 time input
* - Settings persistence
* - Plugin state synchronization
*
* @author Generated for TimeSafari Daily Notification Integration
* @component
*/
import { Component, Vue } from "vue-facing-decorator";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
import { logger } from "@/utils/logger";
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
import type {
NotificationStatus,
PermissionStatus,
} from "@/services/PlatformService";
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
import type { NotificationIface } from "@/constants/app";
/**
* Convert 24-hour time format ("09:00") to 12-hour display format ("9:00 AM")
*/
function formatTimeForDisplay(time24: string): string {
if (!time24) return "";
const [hours, minutes] = time24.split(":");
const hourNum = parseInt(hours);
const isPM = hourNum >= 12;
const displayHour =
hourNum === 0 ? 12 : hourNum > 12 ? hourNum - 12 : hourNum;
return `${displayHour}:${minutes} ${isPM ? "PM" : "AM"}`;
}
@Component({
name: "DailyNotificationSection",
mixins: [PlatformServiceMixin],
})
export default class DailyNotificationSection extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
// Component state
notificationsSupported: boolean = false;
nativeNotificationEnabled: boolean = false;
nativeNotificationTime: string = ""; // Display format: "9:00 AM"
nativeNotificationTimeStorage: string = ""; // Plugin format: "09:00"
nativeNotificationTitle: string = "Daily Update";
nativeNotificationMessage: string = "Your daily notification is ready!";
showTimeEdit: boolean = false;
loading: boolean = false;
notificationStatus: NotificationStatus | null = null;
// Notify helpers
private notify!: ReturnType<typeof createNotifyHelpers>;
async created(): Promise<void> {
this.notify = createNotifyHelpers(this.$notify);
}
/**
* Initialize component state on mount
* Checks platform support and syncs with plugin state
*
* **Token Refresh on Mount:**
* - Refreshes native fetcher configuration to ensure plugin has valid token
* - This handles cases where app was closed for extended periods
* - Token refresh happens automatically without user interaction
*
* **App Resume Listener:**
* - Listens for Capacitor 'resume' event to refresh token when app comes to foreground
* - Ensures plugin always has fresh token for background prefetch operations
* - Cleaned up in `beforeDestroy()` lifecycle hook
*/
async mounted(): Promise<void> {
await this.initializeState();
// Refresh native fetcher configuration on mount
// This ensures plugin has valid token even if app was closed for extended periods
await this.refreshNativeFetcherConfig();
// Listen for app resume events to refresh token when app comes to foreground
// This is part of the proactive token refresh strategy
document.addEventListener("resume", this.handleAppResume);
}
/**
* Cleanup on component destroy
*/
beforeDestroy(): void {
document.removeEventListener("resume", this.handleAppResume);
}
/**
* Handle app resume event - refresh native fetcher configuration
*
* This method is called when the app comes to foreground (via Capacitor 'resume' event).
* It proactively refreshes the JWT token to ensure the plugin has valid authentication
* for background prefetch operations.
*
* **Why refresh on resume?**
* - Tokens expire after 72 hours
* - App may have been closed for extended periods
* - Refreshing ensures plugin has valid token for next prefetch cycle
* - No user interaction required - happens automatically
*
* @see {@link refreshNativeFetcherConfig} For implementation details
*/
async handleAppResume(): Promise<void> {
logger.debug(
"[DailyNotificationSection] App resumed, refreshing native fetcher config",
);
await this.refreshNativeFetcherConfig();
}
/**
* Refresh native fetcher configuration with fresh JWT token
*
* This method ensures the daily notification plugin has a valid authentication token
* for background prefetch operations. It's called proactively to prevent token expiration
* issues during offline periods.
*
* **Refresh Triggers:**
* - Component mount (when notification settings page loads)
* - App resume (when app comes to foreground)
* - Notification enabled (when user enables daily notifications)
*
* **Token Refresh Strategy (Hybrid Approach):**
* - Tokens are valid for 72 hours (see `accessTokenForBackground`)
* - Tokens are refreshed proactively when app is already open
* - If token expires while offline, plugin uses cached content
* - Next time app opens, token is automatically refreshed
*
* **Why This Approach?**
* - No app wake-up required (tokens refresh when app is already open)
* - Works offline (72-hour validity supports extended offline periods)
* - Automatic (no user interaction required)
* - Includes starred plans (fetcher receives user's starred plans for prefetch)
* - Graceful degradation (if refresh fails, cached content still works)
*
* **Error Handling:**
* - Errors are logged but not shown to user (background operation)
* - Returns early if notifications not supported or disabled
* - Returns early if API server not configured
* - Failures don't interrupt user experience
*
* @returns Promise that resolves when refresh completes (or fails silently)
*
* @example
* ```typescript
* // Called automatically on mount/resume
* await this.refreshNativeFetcherConfig();
* ```
*
* @see {@link CapacitorPlatformService.configureNativeFetcher} For token generation
* @see {@link accessTokenForBackground} For 72-hour token generation
*/
async refreshNativeFetcherConfig(): Promise<void> {
try {
const platformService = PlatformServiceFactory.getInstance();
// Early return: Only refresh if notifications are supported and enabled
// This prevents unnecessary work when notifications aren't being used
if (!this.notificationsSupported || !this.nativeNotificationEnabled) {
return;
}
// Get settings for API server and starred plans
// API server tells plugin where to fetch content from
// Starred plans tell plugin which plans to prefetch
const settings = await this.$accountSettings();
const apiServer = settings.apiServer || "";
if (!apiServer) {
logger.warn(
"[DailyNotificationSection] No API server configured, skipping native fetcher refresh",
);
return;
}
// Get starred plans from settings
// These are passed to the plugin so it knows which plans to prefetch
const starredPlanHandleIds = settings.starredPlanHandleIds || [];
// Configure native fetcher with fresh token
// The jwt parameter is ignored - configureNativeFetcher generates it automatically
// This ensures we always have a fresh token with current expiration time
await platformService.configureNativeFetcher({
apiServer,
jwt: "", // Will be generated automatically by configureNativeFetcher
starredPlanHandleIds,
});
logger.info(
"[DailyNotificationSection] Native fetcher configuration refreshed",
);
} catch (error) {
// Don't show error to user - this is a background operation
// Failures are logged for debugging but don't interrupt user experience
// If refresh fails, plugin will use existing token (if still valid) or cached content
logger.error(
"[DailyNotificationSection] Failed to refresh native fetcher config:",
error,
);
}
}
/**
* Initialize component state
* Checks platform support and syncs with plugin state
*/
async initializeState(): Promise<void> {
try {
this.loading = true;
const platformService = PlatformServiceFactory.getInstance();
logger.debug(
"[DailyNotificationSection] Checking notification support...",
);
// Check if notifications are supported on this platform
// This also verifies plugin availability (returns null if plugin unavailable)
const status = await platformService.getDailyNotificationStatus();
if (status === null) {
// Notifications not supported or plugin unavailable - don't initialize
this.notificationsSupported = false;
logger.warn(
"[DailyNotificationSection] Notifications not supported or plugin unavailable - section will be hidden",
);
return;
}
logger.debug(
"[DailyNotificationSection] Notifications supported, status:",
status,
);
this.notificationsSupported = true;
this.notificationStatus = status;
// Plugin state is the source of truth
if (status.isScheduled && status.scheduledTime) {
// Plugin has a scheduled notification - sync UI to match
this.nativeNotificationEnabled = true;
this.nativeNotificationTimeStorage = status.scheduledTime;
this.nativeNotificationTime = formatTimeForDisplay(
status.scheduledTime,
);
} else {
// No plugin schedule - UI defaults to disabled
this.nativeNotificationEnabled = false;
this.nativeNotificationTimeStorage = "";
this.nativeNotificationTime = "";
}
} catch (error) {
logger.error("[DailyNotificationSection] Failed to initialize:", error);
this.notificationsSupported = false;
} finally {
this.loading = false;
}
}
/**
* Toggle notification on/off
*/
async toggleNativeNotification(): Promise<void> {
// Prevent multiple simultaneous toggles
if (this.loading) {
logger.warn(
"[DailyNotificationSection] Toggle ignored - operation in progress",
);
return;
}
logger.info(
`[DailyNotificationSection] Toggling notification: ${this.nativeNotificationEnabled} -> ${!this.nativeNotificationEnabled}`,
);
if (this.nativeNotificationEnabled) {
await this.disableNativeNotification();
} else {
await this.enableNativeNotification();
}
}
/**
* Enable daily notification
*/
async enableNativeNotification(): Promise<void> {
try {
this.loading = true;
const platformService = PlatformServiceFactory.getInstance();
// Check if we have a time set
if (!this.nativeNotificationTimeStorage) {
this.notify.error(
"Please set a notification time first",
TIMEOUTS.SHORT,
);
this.loading = false;
return;
}
// Check permissions first - this also verifies plugin availability
let permissions: PermissionStatus | null;
try {
permissions = await platformService.checkNotificationPermissions();
logger.info(
`[DailyNotificationSection] Permission check result:`,
permissions,
);
} catch (error) {
// Plugin may not be available or there's an error
logger.error(
"[DailyNotificationSection] Failed to check permissions (plugin may be unavailable):",
error,
);
this.notify.error(
"Unable to check notification permissions. The notification plugin may not be installed.",
TIMEOUTS.LONG,
);
this.nativeNotificationEnabled = false;
return;
}
if (permissions === null) {
// Platform doesn't support notifications or plugin unavailable
logger.warn(
"[DailyNotificationSection] Notifications not supported or plugin unavailable",
);
this.notify.error(
"Notifications are not supported on this platform or the plugin is not installed.",
TIMEOUTS.SHORT,
);
this.nativeNotificationEnabled = false;
return;
}
logger.info(
`[DailyNotificationSection] Permission state: ${permissions.notifications}`,
);
// If permissions are explicitly denied, don't try to request again
// (this prevents the plugin crash when handling denied permissions)
// Android won't show the dialog again if permissions are permanently denied
if (permissions.notifications === "denied") {
logger.warn(
"[DailyNotificationSection] Permissions already denied, directing user to settings",
);
this.notify.error(
"Notification permissions were denied. Tap 'Open Settings' to enable them.",
TIMEOUTS.LONG,
);
this.nativeNotificationEnabled = false;
return;
}
// Only request if permissions are in "prompt" state (not denied, not granted)
// This ensures we only call requestPermissions when Android will actually show a dialog
if (permissions.notifications === "prompt") {
logger.info(
"[DailyNotificationSection] Permission state is 'prompt', requesting permissions...",
);
try {
const result = await platformService.requestNotificationPermissions();
logger.info(
`[DailyNotificationSection] Permission request result:`,
result,
);
if (result === null) {
// Plugin unavailable or request failed
logger.error(
"[DailyNotificationSection] Permission request returned null",
);
this.notify.error(
"Unable to request notification permissions. The plugin may not be available.",
TIMEOUTS.LONG,
);
this.nativeNotificationEnabled = false;
return;
}
if (!result.notifications) {
// Permission request was denied
logger.warn(
"[DailyNotificationSection] Permission request denied by user",
);
this.notify.error(
"Notification permissions are required. Tap 'Open Settings' to enable them.",
TIMEOUTS.LONG,
);
this.nativeNotificationEnabled = false;
return;
}
// Permissions granted - continue
logger.info(
"[DailyNotificationSection] Permissions granted successfully",
);
} catch (error) {
// Handle permission request errors (including plugin crashes)
logger.error(
"[DailyNotificationSection] Permission request failed:",
error,
);
this.notify.error(
"Unable to request notification permissions. Tap 'Open Settings' to enable them.",
TIMEOUTS.LONG,
);
this.nativeNotificationEnabled = false;
return;
}
} else if (permissions.notifications !== "granted") {
// Unexpected state - shouldn't happen, but handle gracefully
logger.warn(
`[DailyNotificationSection] Unexpected permission state: ${permissions.notifications}`,
);
this.notify.error(
"Unable to determine notification permission status. Tap 'Open Settings' to check.",
TIMEOUTS.LONG,
);
this.nativeNotificationEnabled = false;
return;
} else {
logger.info("[DailyNotificationSection] Permissions already granted");
}
// Permissions are granted - continue with scheduling
// Schedule notification via PlatformService
await platformService.scheduleDailyNotification({
time: this.nativeNotificationTimeStorage, // "09:00" in local time
title: this.nativeNotificationTitle,
body: this.nativeNotificationMessage,
sound: true,
priority: "high",
});
// Update UI state
this.nativeNotificationEnabled = true;
// Refresh native fetcher configuration with fresh token
// This ensures plugin has valid authentication when notifications are first enabled
// Token will be valid for 72 hours, supporting offline prefetch operations
await this.refreshNativeFetcherConfig();
this.notify.success(
"Daily notification scheduled successfully",
TIMEOUTS.SHORT,
);
} catch (error) {
logger.error(
"[DailyNotificationSection] Failed to enable notification:",
error,
);
this.notify.error(
"Failed to schedule notification. Please try again.",
TIMEOUTS.LONG,
);
this.nativeNotificationEnabled = false;
} finally {
this.loading = false;
}
}
/**
* Disable daily notification
*/
async disableNativeNotification(): Promise<void> {
try {
this.loading = true;
const platformService = PlatformServiceFactory.getInstance();
// Cancel notification via PlatformService
await platformService.cancelDailyNotification();
// Update UI state
this.nativeNotificationEnabled = false;
this.nativeNotificationTime = "";
this.nativeNotificationTimeStorage = "";
this.showTimeEdit = false;
this.notify.success("Daily notification disabled", TIMEOUTS.SHORT);
} catch (error) {
logger.error(
"[DailyNotificationSection] Failed to disable notification:",
error,
);
this.notify.error(
"Failed to disable notification. Please try again.",
TIMEOUTS.LONG,
);
} finally {
this.loading = false;
}
}
/**
* Show/hide time edit input
*/
editNativeNotificationTime(): void {
this.showTimeEdit = !this.showTimeEdit;
}
/**
* Handle time input change
*/
onTimeChange(): void {
// Time is already in nativeNotificationTimeStorage via v-model
// Just update display format
if (this.nativeNotificationTimeStorage) {
this.nativeNotificationTime = formatTimeForDisplay(
this.nativeNotificationTimeStorage,
);
}
}
/**
* Save time change and update notification schedule
*/
async saveTimeChange(): Promise<void> {
if (!this.nativeNotificationTimeStorage) {
this.notify.error("Please select a time", TIMEOUTS.SHORT);
return;
}
// Update display format
this.nativeNotificationTime = formatTimeForDisplay(
this.nativeNotificationTimeStorage,
);
// If notification is enabled, update the schedule
if (this.nativeNotificationEnabled) {
await this.updateNotificationTime(this.nativeNotificationTimeStorage);
} else {
// Just update local state (time preference stored in component)
this.showTimeEdit = false;
this.notify.success("Notification time saved", TIMEOUTS.SHORT);
}
}
/**
* Update notification time
* If notification is enabled, immediately updates the schedule
*/
async updateNotificationTime(newTime: string): Promise<void> {
// newTime is in "HH:mm" format from HTML5 time input
if (!this.nativeNotificationEnabled) {
// If notification is disabled, just update local state
this.nativeNotificationTimeStorage = newTime;
this.nativeNotificationTime = formatTimeForDisplay(newTime);
this.showTimeEdit = false;
return;
}
// Notification is enabled - update the schedule
try {
this.loading = true;
const platformService = PlatformServiceFactory.getInstance();
// 1. Cancel existing notification
await platformService.cancelDailyNotification();
// 2. Schedule with new time
await platformService.scheduleDailyNotification({
time: newTime, // "09:00" in local time
title: this.nativeNotificationTitle,
body: this.nativeNotificationMessage,
sound: true,
priority: "high",
});
// 3. Update local state
this.nativeNotificationTimeStorage = newTime;
this.nativeNotificationTime = formatTimeForDisplay(newTime);
this.notify.success(
"Notification time updated successfully",
TIMEOUTS.SHORT,
);
this.showTimeEdit = false;
} catch (error) {
logger.error(
"[DailyNotificationSection] Failed to update notification time:",
error,
);
this.notify.error(
"Failed to update notification time. Please try again.",
TIMEOUTS.LONG,
);
} finally {
this.loading = false;
}
}
/**
* Show info dialog about native notifications
*/
showNativeNotificationInfo(): void {
// TODO: Implement info dialog or navigate to help page
this.notify.info(
"Daily notifications use your device's native notification system. They work even when the app is closed.",
TIMEOUTS.STANDARD,
);
}
/**
* Open app notification settings
*/
async openNotificationSettings(): Promise<void> {
try {
const platformService = PlatformServiceFactory.getInstance();
const result = await platformService.openAppNotificationSettings();
if (result === null) {
this.notify.error(
"Unable to open settings. Please go to Settings > Apps > TimeSafari > Notifications manually.",
TIMEOUTS.LONG,
);
} else {
this.notify.success("Opening notification settings...", TIMEOUTS.SHORT);
}
} catch (error) {
logger.error(
"[DailyNotificationSection] Failed to open notification settings:",
error,
);
this.notify.error(
"Unable to open settings. Please go to Settings > Apps > TimeSafari > Notifications manually.",
TIMEOUTS.LONG,
);
}
}
}
</script>
<style scoped>
.dot {
transition: left 0.2s ease;
}
</style>

View File

@@ -104,6 +104,71 @@ export const accessToken = async (did?: string) => {
}
};
/**
* Generate a longer-lived access token for background operations
*
* This function creates JWT tokens with extended validity (default 72 hours) for use
* in background prefetch operations. The longer expiration period allows the daily
* notification plugin to work offline for extended periods without requiring the app
* to be in the foreground to refresh tokens.
*
* **Token Refresh Strategy (Hybrid Approach):**
* - Tokens are valid for 72 hours (configurable)
* - Tokens are refreshed proactively when:
* - App comes to foreground (via Capacitor 'resume' event)
* - Component mounts (DailyNotificationSection)
* - Notifications are enabled
* - If token expires while offline, plugin uses cached content
* - Next time app opens, token is automatically refreshed
*
* **Why 72 Hours?**
* - Balances security (read-only prefetch operations) with offline capability
* - Reduces need for app to wake itself for token refresh
* - Allows plugin to work offline for extended periods (e.g., weekend trips)
* - Longer than typical prefetch windows (5 minutes before notification)
*
* **Security Considerations:**
* - Tokens are used only for read-only prefetch operations
* - Tokens are stored securely in plugin's Room database
* - Tokens are refreshed proactively to minimize exposure window
* - No private keys are exposed to native code
*
* @param {string} did - User DID (Decentralized Identifier) for token issuer
* @param {number} expirationMinutes - Optional expiration in minutes (defaults to 72 hours = 4320 minutes)
* @return {Promise<string>} JWT token with extended validity, or empty string if no DID provided
*
* @example
* ```typescript
* // Generate token with default 72-hour expiration
* const token = await accessTokenForBackground("did:ethr:0x...");
*
* // Generate token with custom expiration (24 hours)
* const token24h = await accessTokenForBackground("did:ethr:0x...", 24 * 60);
* ```
*
* @see {@link accessToken} For short-lived tokens (1 minute) for regular API requests
* @see {@link createEndorserJwtForDid} For JWT creation implementation
*/
export const accessTokenForBackground = async (
did?: string,
expirationMinutes?: number,
): Promise<string> => {
if (!did) {
return "";
}
// Use provided expiration or default to 72 hours (4320 minutes)
// This allows background prefetch operations to work offline for extended periods
const expirationSeconds = expirationMinutes
? expirationMinutes * 60
: 72 * 60 * 60; // Default 72 hours
const nowEpoch = Math.floor(Date.now() / 1000);
const endEpoch = nowEpoch + expirationSeconds;
const tokenPayload = { exp: endEpoch, iat: nowEpoch, iss: did };
return createEndorserJwtForDid(did, tokenPayload);
};
/**
* Extract JWT from various URL formats
* @param jwtUrlText The URL containing the JWT

View File

@@ -1686,7 +1686,10 @@ export async function register(
"Registration thrown error:",
errorMessage || JSON.stringify(err),
);
return { error: errorMessage || "Got a server error when registering." };
return {
error:
(errorMessage as string) || "Got a server error when registering.",
};
}
return { error: "Got a server error when registering." };
}

View File

@@ -32,6 +32,68 @@ export interface PlatformCapabilities {
isNativeApp: boolean;
}
/**
* Permission status for notifications
*/
export interface PermissionStatus {
/** Notification permission status */
notifications: "granted" | "denied" | "prompt";
/** Exact alarms permission status (Android only) */
exactAlarms?: "granted" | "denied" | "prompt";
}
/**
* Result of permission request
*/
export interface PermissionResult {
/** Whether notification permission was granted */
notifications: boolean;
/** Whether exact alarms permission was granted (Android only) */
exactAlarms?: boolean;
}
/**
* Status of scheduled daily notifications
*/
export interface NotificationStatus {
/** Whether a notification is currently scheduled */
isScheduled: boolean;
/** Scheduled time in "HH:mm" format (24-hour) */
scheduledTime?: string;
/** Last time the notification was triggered (ISO string) */
lastTriggered?: string;
/** Current permission status */
permissions: PermissionStatus;
}
/**
* Options for scheduling a daily notification
*/
export interface ScheduleOptions {
/** Time in "HH:mm" format (24-hour) in local time */
time: string;
/** Notification title */
title: string;
/** Notification body text */
body: string;
/** Whether to play sound (default: true) */
sound?: boolean;
/** Notification priority */
priority?: "high" | "normal" | "low";
}
/**
* Configuration for native fetcher background operations
*/
export interface NativeFetcherConfig {
/** API server URL */
apiServer: string;
/** JWT token for authentication */
jwt: string;
/** Array of starred plan handle IDs */
starredPlanHandleIds: string[];
}
/**
* Platform-agnostic interface for handling platform-specific operations.
* Provides a common API for file system operations, camera interactions,
@@ -209,6 +271,58 @@ export interface PlatformService {
*/
retrieveSettingsForActiveAccount(): Promise<Record<string, unknown> | null>;
// Daily notification operations
/**
* Get the status of scheduled daily notifications
* @returns Promise resolving to notification status, or null if not supported
*/
getDailyNotificationStatus(): Promise<NotificationStatus | null>;
/**
* Check notification permissions
* @returns Promise resolving to permission status, or null if not supported
*/
checkNotificationPermissions(): Promise<PermissionStatus | null>;
/**
* Request notification permissions
* @returns Promise resolving to permission result, or null if not supported
*/
requestNotificationPermissions(): Promise<PermissionResult | null>;
/**
* Schedule a daily notification
* @param options - Notification scheduling options
* @returns Promise that resolves when scheduled, or rejects if not supported
*/
scheduleDailyNotification(options: ScheduleOptions): Promise<void>;
/**
* Cancel scheduled daily notification
* @returns Promise that resolves when cancelled, or rejects if not supported
*/
cancelDailyNotification(): Promise<void>;
/**
* Configure native fetcher for background operations
* @param config - Native fetcher configuration
* @returns Promise that resolves when configured, or null if not supported
*/
configureNativeFetcher(config: NativeFetcherConfig): Promise<void | null>;
/**
* Update starred plans for background fetcher
* @param plans - Starred plan IDs
* @returns Promise that resolves when updated, or null if not supported
*/
updateStarredPlans(plans: { planIds: string[] }): Promise<void | null>;
/**
* Open the app's notification settings in the system settings
* @returns Promise that resolves when the settings page is opened, or null if not supported
*/
openAppNotificationSettings(): Promise<void | null>;
// --- PWA/Web-only methods (optional, only implemented on web) ---
/**
* Registers the service worker for PWA support (web only)

View File

@@ -13,6 +13,7 @@ import {
CapacitorSQLite,
DBSQLiteValues,
} from "@capacitor-community/sqlite";
import { DailyNotification } from "@timesafari/daily-notification-plugin";
import { runMigrations } from "@/db-sql/migration";
import { QueryExecResult } from "@/interfaces/database";
@@ -20,6 +21,11 @@ import {
ImageResult,
PlatformService,
PlatformCapabilities,
NotificationStatus,
PermissionStatus,
PermissionResult,
ScheduleOptions,
NativeFetcherConfig,
} from "../PlatformService";
import { logger } from "../../utils/logger";
import { BaseDatabaseService } from "./BaseDatabaseService";
@@ -91,7 +97,8 @@ export class CapacitorPlatformService
}
try {
// Create/Open database
// Try to create/Open database connection
try {
this.db = await this.sqlite.createConnection(
this.dbName,
false,
@@ -99,8 +106,83 @@ export class CapacitorPlatformService
1,
false,
);
} catch (createError: unknown) {
// If connection already exists, try to retrieve it or handle gracefully
const errorMessage =
createError instanceof Error
? createError.message
: String(createError);
const errorObj =
typeof createError === "object" && createError !== null
? (createError as { errorMessage?: string; message?: string })
: {};
const fullErrorMessage =
errorObj.errorMessage || errorObj.message || errorMessage;
if (fullErrorMessage.includes("already exists")) {
logger.debug(
"[CapacitorPlatformService] Connection already exists on native side, attempting to retrieve",
);
// Check if connection exists in JavaScript Map
const isConnResult = await this.sqlite.isConnection(
this.dbName,
false,
);
if (isConnResult.result) {
// Connection exists in Map, retrieve it
this.db = await this.sqlite.retrieveConnection(this.dbName, false);
logger.debug(
"[CapacitorPlatformService] Successfully retrieved existing connection from Map",
);
} else {
// Connection exists on native side but not in JavaScript Map
// This can happen when the app is restarted but native connections persist
// Try to close the native connection first, then create a new one
logger.debug(
"[CapacitorPlatformService] Connection exists natively but not in Map, closing and recreating",
);
try {
await this.sqlite.closeConnection(this.dbName, false);
} catch (closeError) {
// Ignore close errors - connection might not be properly tracked
logger.debug(
"[CapacitorPlatformService] Error closing connection (may be expected):",
closeError,
);
}
// Now try to create the connection again
this.db = await this.sqlite.createConnection(
this.dbName,
false,
"no-encryption",
1,
false,
);
logger.debug(
"[CapacitorPlatformService] Successfully created connection after cleanup",
);
}
} else {
// Re-throw if it's a different error
throw createError;
}
}
// Open the connection if it's not already open
try {
await this.db.open();
} catch (openError: unknown) {
const openErrorMessage =
openError instanceof Error ? openError.message : String(openError);
// If already open, that's fine - continue
if (!openErrorMessage.includes("already open")) {
throw openError;
}
logger.debug(
"[CapacitorPlatformService] Database connection already open",
);
}
// Set journal mode to WAL for better performance
// await this.db.execute("PRAGMA journal_mode=WAL;");
@@ -1333,6 +1415,426 @@ export class CapacitorPlatformService
// --- PWA/Web-only methods (no-op for Capacitor) ---
public registerServiceWorker(): void {}
// Daily notification operations
/**
* Get the status of scheduled daily notifications
* @see PlatformService.getDailyNotificationStatus
*/
async getDailyNotificationStatus(): Promise<NotificationStatus | null> {
try {
logger.debug(
"[CapacitorPlatformService] Getting daily notification status...",
);
const pluginStatus = await DailyNotification.getNotificationStatus();
// Get permissions separately
const permissions = await DailyNotification.checkPermissions();
// Map plugin PermissionState to our PermissionStatus format
const notificationsPermission = permissions.notifications;
let notifications: "granted" | "denied" | "prompt";
if (notificationsPermission === "granted") {
notifications = "granted";
} else if (notificationsPermission === "denied") {
notifications = "denied";
} else {
notifications = "prompt";
}
// Handle lastNotificationTime which can be a Promise<number>
let lastTriggered: string | undefined;
const lastNotificationTime = pluginStatus.lastNotificationTime;
if (lastNotificationTime) {
const timeValue = await Promise.resolve(lastNotificationTime);
if (typeof timeValue === "number") {
lastTriggered = new Date(timeValue).toISOString();
}
}
return {
isScheduled: pluginStatus.isScheduled ?? false,
scheduledTime: pluginStatus.settings?.time,
lastTriggered,
permissions: {
notifications,
exactAlarms: undefined, // Plugin doesn't expose this in status
},
};
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
logger.error(
"[CapacitorPlatformService] Failed to get notification status:",
errorMessage,
error,
);
logger.warn(
"[CapacitorPlatformService] Daily notification section will be hidden - plugin may not be installed or available",
);
return null;
}
}
/**
* Check notification permissions
* @see PlatformService.checkNotificationPermissions
*/
async checkNotificationPermissions(): Promise<PermissionStatus | null> {
try {
const permissions = await DailyNotification.checkPermissions();
// Log the raw permission state for debugging
logger.info(
`[CapacitorPlatformService] Raw permission state from plugin:`,
permissions,
);
// Map plugin PermissionState to our PermissionStatus format
const notificationsPermission = permissions.notifications;
let notifications: "granted" | "denied" | "prompt";
// Handle all possible PermissionState values
if (notificationsPermission === "granted") {
notifications = "granted";
} else if (
notificationsPermission === "denied" ||
notificationsPermission === "ephemeral"
) {
notifications = "denied";
} else {
// Treat "prompt", "prompt-with-rationale", "unknown", "provisional" as "prompt"
// This allows Android to show the permission dialog
notifications = "prompt";
}
logger.info(
`[CapacitorPlatformService] Mapped permission state: ${notifications} (from ${notificationsPermission})`,
);
return {
notifications,
exactAlarms: undefined, // Plugin doesn't expose this directly
};
} catch (error) {
logger.error(
"[CapacitorPlatformService] Failed to check permissions:",
error,
);
return null;
}
}
/**
* Request notification permissions
* @see PlatformService.requestNotificationPermissions
*/
async requestNotificationPermissions(): Promise<PermissionResult | null> {
try {
logger.info(
`[CapacitorPlatformService] Requesting notification permissions...`,
);
const result = await DailyNotification.requestPermissions();
logger.info(
`[CapacitorPlatformService] Permission request result:`,
result,
);
// Map plugin PermissionState to boolean
const notificationsGranted = result.notifications === "granted";
logger.info(
`[CapacitorPlatformService] Mapped permission result: ${notificationsGranted} (from ${result.notifications})`,
);
return {
notifications: notificationsGranted,
exactAlarms: undefined, // Plugin doesn't expose this directly
};
} catch (error) {
logger.error(
"[CapacitorPlatformService] Failed to request permissions:",
error,
);
return null;
}
}
/**
* Schedule a daily notification
* @see PlatformService.scheduleDailyNotification
*/
async scheduleDailyNotification(options: ScheduleOptions): Promise<void> {
try {
await DailyNotification.scheduleDailyNotification({
time: options.time,
title: options.title,
body: options.body,
sound: options.sound ?? true,
priority: options.priority ?? "high",
});
logger.info(
`[CapacitorPlatformService] Scheduled daily notification for ${options.time}`,
);
} catch (error) {
logger.error(
"[CapacitorPlatformService] Failed to schedule notification:",
error,
);
throw error;
}
}
/**
* Cancel scheduled daily notification
* @see PlatformService.cancelDailyNotification
*/
async cancelDailyNotification(): Promise<void> {
try {
await DailyNotification.cancelAllNotifications();
logger.info("[CapacitorPlatformService] Cancelled daily notification");
} catch (error) {
logger.error(
"[CapacitorPlatformService] Failed to cancel notification:",
error,
);
throw error;
}
}
/**
* Configure native fetcher for background operations
*
* This method configures the daily notification plugin's native content fetcher
* with authentication credentials for background prefetch operations. It automatically
* retrieves the active DID from the database and generates a fresh JWT token with
* 72-hour expiration.
*
* **Authentication Flow:**
* 1. Retrieves active DID from `active_identity` table (single source of truth)
* 2. Generates JWT token with 72-hour expiration using `accessTokenForBackground()`
* 3. Configures plugin with API server URL, active DID, and JWT token
* 4. Plugin stores token in its Room database for background workers
*
* **Token Management:**
* - Tokens are valid for 72 hours (4320 minutes)
* - Tokens are refreshed proactively when app comes to foreground
* - If token expires while offline, plugin uses cached content
* - Token refresh happens automatically via `DailyNotificationSection.refreshNativeFetcherConfig()`
*
* **Offline-First Design:**
* - 72-hour validity supports extended offline periods
* - Plugin can prefetch content when online and use cached content when offline
* - No app wake-up required for token refresh (happens when app is already open)
*
* **Error Handling:**
* - Returns `null` if active DID not found (no user logged in)
* - Returns `null` if JWT generation fails
* - Logs errors but doesn't throw (allows graceful degradation)
*
* @param config - Native fetcher configuration
* @param config.apiServer - API server URL (optional, uses default if not provided)
* @param config.jwt - JWT token (ignored, generated automatically)
* @param config.starredPlanHandleIds - Array of starred plan handle IDs for prefetch
* @returns Promise that resolves when configured, or `null` if configuration failed
*
* @example
* ```typescript
* await platformService.configureNativeFetcher({
* apiServer: "https://api.endorser.ch",
* jwt: "", // Generated automatically
* starredPlanHandleIds: ["plan-123", "plan-456"]
* });
* ```
*
* @see {@link accessTokenForBackground} For JWT token generation
* @see {@link DailyNotificationSection.refreshNativeFetcherConfig} For proactive token refresh
* @see PlatformService.configureNativeFetcher
*/
async configureNativeFetcher(
config: NativeFetcherConfig,
): Promise<void | null> {
try {
// Step 1: Get activeDid from database (single source of truth)
// This ensures we're using the correct user identity for authentication
const activeIdentity = await this.getActiveIdentity();
const activeDid = activeIdentity.activeDid;
if (!activeDid) {
logger.warn(
"[CapacitorPlatformService] No activeDid found, cannot configure native fetcher",
);
return null;
}
// Step 2: Generate JWT token for background operations
// Use 72-hour expiration for offline-first prefetch operations
// This allows the plugin to work offline for extended periods
const { accessTokenForBackground } = await import(
"../../libs/crypto/index"
);
// Use 72 hours (4320 minutes) for background prefetch tokens
// This is longer than passkey expiration to support offline scenarios
const expirationMinutes = 72 * 60; // 72 hours
const jwtToken = await accessTokenForBackground(
activeDid,
expirationMinutes,
);
if (!jwtToken) {
logger.error("[CapacitorPlatformService] Failed to generate JWT token");
return null;
}
// Step 3: Get API server from config or use default
// This ensures the plugin knows where to fetch content from
const apiServer =
config.apiServer ||
(await import("../../constants/app")).DEFAULT_ENDORSER_API_SERVER;
// Step 4: Configure plugin with credentials
// Plugin stores these in its Room database for background workers
await DailyNotification.configureNativeFetcher({
apiBaseUrl: apiServer,
activeDid,
jwtToken,
});
logger.info("[CapacitorPlatformService] Configured native fetcher", {
activeDid,
apiServer,
tokenExpirationHours: 72,
tokenExpirationMinutes: expirationMinutes,
});
} catch (error) {
logger.error(
"[CapacitorPlatformService] Failed to configure native fetcher:",
error,
);
return null;
}
}
/**
* Update starred plans for background fetcher
* @see PlatformService.updateStarredPlans
*/
async updateStarredPlans(plans: { planIds: string[] }): Promise<void | null> {
try {
await DailyNotification.updateStarredPlans({
planIds: plans.planIds,
});
logger.info(
`[CapacitorPlatformService] Updated starred plans: ${plans.planIds.length} plans`,
);
} catch (error) {
logger.error(
"[CapacitorPlatformService] Failed to update starred plans:",
error,
);
return null;
}
}
/**
* Open the app's notification settings in the system settings
* @see PlatformService.openAppNotificationSettings
*/
async openAppNotificationSettings(): Promise<void | null> {
try {
const platform = Capacitor.getPlatform();
if (platform === "android") {
// Android: Open app details settings page
// From there, users can navigate to "Notifications" section
// This is more reliable than trying to open notification settings directly
const packageName = "app.timesafari.app"; // Full application ID from build.gradle
// Use APPLICATION_DETAILS_SETTINGS which opens the app's settings page
// Users can then navigate to "Notifications" section
// Try multiple URL formats to ensure compatibility
const intentUrl1 = `intent:#Intent;action=android.settings.APPLICATION_DETAILS_SETTINGS;data=package:${packageName};end`;
const intentUrl2 = `intent://settings/app_detail?package=${packageName}#Intent;scheme=android-app;end`;
logger.info(
`[CapacitorPlatformService] Opening Android app settings for ${packageName}`,
);
// Log current permission state before opening settings
try {
const currentPerms = await this.checkNotificationPermissions();
logger.info(
`[CapacitorPlatformService] Current permission state before opening settings:`,
currentPerms,
);
} catch (e) {
logger.warn(
`[CapacitorPlatformService] Could not check permissions before opening settings:`,
e,
);
}
// Try multiple approaches to ensure it works
try {
// Method 1: Direct window.location.href (most reliable)
window.location.href = intentUrl1;
// Method 2: Fallback with window.open
setTimeout(() => {
try {
window.open(intentUrl1, "_blank");
} catch (e) {
logger.warn(
"[CapacitorPlatformService] window.open fallback failed:",
e,
);
}
}, 100);
// Method 3: Alternative format
setTimeout(() => {
try {
window.location.href = intentUrl2;
} catch (e) {
logger.warn(
"[CapacitorPlatformService] Alternative format failed:",
e,
);
}
}, 200);
} catch (e) {
logger.error(
"[CapacitorPlatformService] Failed to open intent URL:",
e,
);
}
} else if (platform === "ios") {
// iOS: Use app settings URL scheme
const settingsUrl = `app-settings:`;
window.location.href = settingsUrl;
logger.info("[CapacitorPlatformService] Opening iOS app settings");
} else {
logger.warn(
`[CapacitorPlatformService] Cannot open settings on platform: ${platform}`,
);
return null;
}
} catch (error) {
logger.error(
"[CapacitorPlatformService] Failed to open app notification settings:",
error,
);
return null;
}
}
// Database utility methods - inherited from BaseDatabaseService
// generateInsertStatement, updateDefaultSettings, updateActiveDid,
// getActiveIdentity, insertNewDidIntoSettings, updateDidSpecificSettings,

View File

@@ -22,6 +22,13 @@
import { CapacitorPlatformService } from "./CapacitorPlatformService";
import { logger } from "../../utils/logger";
import {
NotificationStatus,
PermissionStatus,
PermissionResult,
ScheduleOptions,
NativeFetcherConfig,
} from "../PlatformService";
/**
* Electron-specific platform service implementation.
@@ -166,4 +173,88 @@ export class ElectronPlatformService extends CapacitorPlatformService {
// --- PWA/Web-only methods (no-op for Electron) ---
public registerServiceWorker(): void {}
// Daily notification operations
// Override CapacitorPlatformService methods to return null/throw errors
// since Electron doesn't support native daily notifications
/**
* Get the status of scheduled daily notifications
* @see PlatformService.getDailyNotificationStatus
* @returns null - notifications not supported on Electron platform
*/
async getDailyNotificationStatus(): Promise<NotificationStatus | null> {
return null;
}
/**
* Check notification permissions
* @see PlatformService.checkNotificationPermissions
* @returns null - notifications not supported on Electron platform
*/
async checkNotificationPermissions(): Promise<PermissionStatus | null> {
return null;
}
/**
* Request notification permissions
* @see PlatformService.requestNotificationPermissions
* @returns null - notifications not supported on Electron platform
*/
async requestNotificationPermissions(): Promise<PermissionResult | null> {
return null;
}
/**
* Schedule a daily notification
* @see PlatformService.scheduleDailyNotification
* @throws Error - notifications not supported on Electron platform
*/
async scheduleDailyNotification(_options: ScheduleOptions): Promise<void> {
throw new Error(
"Daily notifications are not supported on Electron platform",
);
}
/**
* Cancel scheduled daily notification
* @see PlatformService.cancelDailyNotification
* @throws Error - notifications not supported on Electron platform
*/
async cancelDailyNotification(): Promise<void> {
throw new Error(
"Daily notifications are not supported on Electron platform",
);
}
/**
* Configure native fetcher for background operations
* @see PlatformService.configureNativeFetcher
* @returns null - native fetcher not supported on Electron platform
*/
async configureNativeFetcher(
_config: NativeFetcherConfig,
): Promise<void | null> {
return null;
}
/**
* Update starred plans for background fetcher
* @see PlatformService.updateStarredPlans
* @returns null - native fetcher not supported on Electron platform
*/
async updateStarredPlans(_plans: {
planIds: string[];
}): Promise<void | null> {
return null;
}
/**
* Open the app's notification settings in the system settings
* @see PlatformService.openAppNotificationSettings
* @returns null - not supported on Electron platform
*/
async openAppNotificationSettings(): Promise<void | null> {
return null;
}
}

View File

@@ -2,6 +2,11 @@ import {
ImageResult,
PlatformService,
PlatformCapabilities,
NotificationStatus,
PermissionStatus,
PermissionResult,
ScheduleOptions,
NativeFetcherConfig,
} from "../PlatformService";
import { logger } from "../../utils/logger";
import { QueryExecResult } from "@/interfaces/database";
@@ -677,4 +682,81 @@ export class WebPlatformService
// generateInsertStatement, updateDefaultSettings, updateActiveDid,
// getActiveIdentity, insertNewDidIntoSettings, updateDidSpecificSettings,
// retrieveSettingsForActiveAccount are all inherited from BaseDatabaseService
// Daily notification operations
/**
* Get the status of scheduled daily notifications
* @see PlatformService.getDailyNotificationStatus
* @returns null - notifications not supported on web platform
*/
async getDailyNotificationStatus(): Promise<NotificationStatus | null> {
return null;
}
/**
* Check notification permissions
* @see PlatformService.checkNotificationPermissions
* @returns null - notifications not supported on web platform
*/
async checkNotificationPermissions(): Promise<PermissionStatus | null> {
return null;
}
/**
* Request notification permissions
* @see PlatformService.requestNotificationPermissions
* @returns null - notifications not supported on web platform
*/
async requestNotificationPermissions(): Promise<PermissionResult | null> {
return null;
}
/**
* Schedule a daily notification
* @see PlatformService.scheduleDailyNotification
* @throws Error - notifications not supported on web platform
*/
async scheduleDailyNotification(_options: ScheduleOptions): Promise<void> {
throw new Error("Daily notifications are not supported on web platform");
}
/**
* Cancel scheduled daily notification
* @see PlatformService.cancelDailyNotification
* @throws Error - notifications not supported on web platform
*/
async cancelDailyNotification(): Promise<void> {
throw new Error("Daily notifications are not supported on web platform");
}
/**
* Configure native fetcher for background operations
* @see PlatformService.configureNativeFetcher
* @returns null - native fetcher not supported on web platform
*/
async configureNativeFetcher(
_config: NativeFetcherConfig,
): Promise<void | null> {
return null;
}
/**
* Update starred plans for background fetcher
* @see PlatformService.updateStarredPlans
* @returns null - native fetcher not supported on web platform
*/
async updateStarredPlans(_plans: {
planIds: string[];
}): Promise<void | null> {
return null;
}
/**
* Open the app's notification settings in the system settings
* @see PlatformService.openAppNotificationSettings
* @returns null - not supported on web platform
*/
async openAppNotificationSettings(): Promise<void | null> {
return null;
}
}

View File

@@ -161,6 +161,9 @@
</section>
<PushNotificationPermission ref="pushNotificationPermission" />
<!-- Daily Notifications (Native) -->
<DailyNotificationSection />
<!-- User Profile -->
<section
v-if="isRegistered"
@@ -790,6 +793,7 @@ import IdentitySection from "@/components/IdentitySection.vue";
import RegistrationNotice from "@/components/RegistrationNotice.vue";
import LocationSearchSection from "@/components/LocationSearchSection.vue";
import UsageLimitsSection from "@/components/UsageLimitsSection.vue";
import DailyNotificationSection from "@/components/notifications/DailyNotificationSection.vue";
import {
AppString,
DEFAULT_IMAGE_API_SERVER,
@@ -858,6 +862,7 @@ interface UserNameDialogRef {
RegistrationNotice,
LocationSearchSection,
UsageLimitsSection,
DailyNotificationSection,
},
mixins: [PlatformServiceMixin],
})

View File

@@ -1,4 +1,21 @@
import { defineConfig } from "vite";
import { createBuildConfig } from "./vite.config.common.mts";
export default defineConfig(async () => createBuildConfig('capacitor'));
export default defineConfig(async () => {
const baseConfig = await createBuildConfig('capacitor');
return {
...baseConfig,
build: {
...baseConfig.build,
rollupOptions: {
...baseConfig.build?.rollupOptions,
// Note: @timesafari/daily-notification-plugin is NOT externalized
// because it needs to be bundled for dynamic imports to work in Capacitor WebView
output: {
...baseConfig.build?.rollupOptions?.output,
}
}
}
};
});