38 Commits

Author SHA1 Message Date
Matthew
4e25841fe9 fix(test-app): auto-detect Android SDK and allow build without adb
Previously, the build script would skip Android builds entirely if
adb was not in PATH, even though adb is only needed for installing/
launching apps, not for building APKs.

Changes:
- Added find_android_sdk() function that automatically detects SDK
  location via ANDROID_HOME, ANDROID_SDK_ROOT, existing local.properties,
  or common default locations (macOS/Linux)
- Automatically creates/updates android/local.properties with detected
  SDK location
- Removed early exit when adb not found - build now proceeds without adb
- Moved adb check to only when installing/launching apps (--run flags)
- Updated warning messages to clarify adb is only needed for install/launch

This allows developers to build APKs even when Android SDK platform-tools
are not in PATH, improving build script usability.
2026-02-03 00:34:25 -08:00
Matthew
367325452a fix(android): explicitly set component and package for AlarmManager broadcasts
AlarmManager was firing alarms but DailyNotificationReceiver was not
receiving broadcasts. The issue was that Intents created with
Intent(context, Class) constructor were not reliably matched by
AlarmManager when delivering broadcasts.

Solution: Explicitly set ComponentName and package on all Intents used
for AlarmManager broadcasts. This ensures AlarmManager can correctly
match PendingIntents to the registered receiver.

Changes:
- NotifyReceiver.kt: Fixed Intent creation in scheduleNotification(),
  cancelNotification(), isAlarmScheduled(), and idempotence checks
- ReactivationManager.kt: Fixed alarmsExist() to use
  DailyNotificationReceiver with explicit component/package
- DailyNotificationScheduler.java: Fixed Intent creation to explicitly
  set component and package

This fixes the critical bug where alarms fire but receivers are not
triggered, resolving the gap between AlarmManager delivery and receiver
execution.
2026-02-03 00:33:33 -08:00
Matthew
dd55c6b4e1 fix(ios): use wrapper script for pod install in cap:sync:ios
The cap:sync:ios npm script was failing with "pod: command not found"
because npm scripts don't inherit the same PATH environment as shell
scripts, preventing detection of pod installed via rbenv shims.

Added pod-install.sh wrapper script that:
- Detects pod via command -v or rbenv shims ($HOME/.rbenv/shims/pod)
- Executes pod install with the found command
- Matches the pod detection logic used in build-native.sh

Updated cap:sync:ios script to use the wrapper instead of calling
pod install directly, ensuring consistent pod detection across
all build contexts.
2026-01-15 23:05:45 -08:00
Jose Olarte III
2915fe7438 fix(build): add SQLite conflict detection and Command Line Tools verification
Prevents iOS build failures caused by pkgx SQLite linking conflicts and
ensures Xcode Command Line Tools are properly installed.

Problem:
- pkgx installs SQLite built for macOS, causing linker errors when building
  for iOS simulator: "linking in dylib built for 'macOS'"
- Missing Command Line Tools cause build failures without clear error messages

Changes:
- Add check_sqlite_conflicts() function
  - Detects pkgx SQLite installations in ~/.pkgx
  - Warns about macOS dylibs that will cause iOS simulator build failures
  - Checks for system SQLite from Command Line Tools
  - Validates library paths (DYLD_LIBRARY_PATH, LD_LIBRARY_PATH)

- Add check_command_line_tools() function
  - Verifies Xcode Command Line Tools are installed and configured
  - Checks for xcodebuild availability
  - Verifies sqlite3 is available (part of Command Line Tools)
  - Provides clear error messages with installation instructions

- Enhance pkgx detection in iOS build functions
  - Specifically searches for pkgx SQLite dylibs
  - Automatically removes pkgx paths from PATH environment variable
  - Provides detailed warnings about detected conflicts
  - Cleans all problematic environment variables before building

- Integrate checks into environment validation
  - Runs automatically when building for iOS
  - Provides early warnings before build starts
  - Fails fast with clear error messages if tools are missing

This fixes the linker error:
  "ld: building for 'iOS-simulator', but linking in dylib
   (/Users/trent/.pkgx/sqlite.org/v3.44.2/lib/libsqlite3.0.dylib)
   built for 'macOS'"

The build script now:
- Detects pkgx SQLite conflicts before building
- Automatically fixes environment variables
- Verifies Command Line Tools are installed
- Provides clear guidance for manual fixes if needed

Files modified:
- scripts/build-native.sh
2026-01-15 18:34:33 +08:00
Jose Olarte III
5247ebeecb fix(build): add Capacitor sync and auto-fix for Android test app builds
Fixed Android test app build failures by ensuring Capacitor is synced
before building and automatically fixing missing file references.

Changes:
- Add Capacitor Android sync step before building test app
  - Runs `npm run cap:sync:android` to create required project structure
  - Ensures `:capacitor-cordova-android-plugins` project exists before Gradle resolves dependencies
- Add automatic fix for missing cordova.variables.gradle reference
  - Detects when capacitor.build.gradle references non-existent file
  - Automatically comments out problematic `apply from` line
  - Uses platform-agnostic sed command (handles macOS and Linux)
  - Provides clear logging about what was fixed
- Reorganize build flow to match iOS pattern
  - Sync Capacitor first, then navigate to platform directory
  - Apply fixes, then build

This fixes the build error:
  "Could not resolve project :capacitor-cordova-android-plugins"
  "No matching variant of project :capacitor-cordova-android-plugins was found"

The build now completes successfully even when:
- Capacitor hasn't been synced yet
- capacitor.build.gradle references missing files

Files modified:
- scripts/build-native.sh
2026-01-15 15:51:08 +08:00
Jose Olarte III
20b33f6e31 fix(build): handle missing dependencies and Capacitor files during iOS build
Fixed build failures when test app dependencies aren't installed and when
Capacitor files don't exist during initial install.

Changes:
- Use `npx run-p` in test app build script to work without local install
- Add dependency check in build-native.sh before building Vue app
- Make fix-capacitor-plugins.js resilient to missing files during postinstall
  - Gracefully handles missing capacitor.plugins.json on first install
  - Provides clear messaging about when fixes will be applied
  - No longer exits with error when Capacitor hasn't been synced yet

This allows the build to complete successfully even when:
- npm dependencies aren't installed in test app
- Capacitor files don't exist yet (will be created during cap:sync:ios)

Files modified:
- scripts/build-native.sh
- test-apps/daily-notification-test/package.json
- test-apps/daily-notification-test/scripts/fix-capacitor-plugins.js
2026-01-15 15:37:32 +08:00
Jose Olarte III
630fd3de81 fix(ios): resolve SQLite linking conflicts with pkgx
Fixes iOS build failures caused by linker picking up macOS SQLite
libraries from pkgx instead of iOS system SQLite, resulting in
undefined symbol errors for all sqlite3 functions.

Changes:
- Explicitly link system SQLite library (-lsqlite3) in podspec
- Detect and unset pkgx environment variables during iOS builds
- Add warnings to guide users if manual intervention needed

The issue occurs when pkgx (or similar package managers) set
DYLD_LIBRARY_PATH or PKGX_DIR, causing the linker to find macOS
SQLite dylibs at /Users/*/.pkgx/sqlite.org/*/lib/libsqlite3.0.dylib
instead of the iOS system SQLite library.

This fix ensures the iOS build always uses the correct system SQLite
library regardless of environment variable interference.
2026-01-14 18:41:46 +08:00
Jose Olarte III
aaac23111c chore(build): add clean-build script for troubleshooting
Adds a comprehensive clean script that removes all build artifacts,
caches, and dependencies to help reproduce build issues across
different development environments.

The script cleans:
- TypeScript build output (dist/)
- iOS plugin artifacts (Pods, Podfile.lock, build dirs)
- Android plugin artifacts (build dirs, optional .gradle cache)
- Test app artifacts (node_modules, dist/, iOS/Android builds)
- Optional: Xcode DerivedData, Gradle cache, node_modules reinstall

Usage:
  ./scripts/clean-build.sh              # Basic clean
  ./scripts/clean-build.sh --all        # Full clean with reinstall

This is particularly useful when troubleshooting build failures
that may be environment-specific (different Xcode, CocoaPods,
macOS, or Node versions).
2026-01-12 20:50:02 +08:00
Jose Olarte III
d2a1041cc4 feat(ios): add missed rollover recovery for background/inactive app scenarios
Implement enhanced app launch recovery to detect and schedule missed rollover
notifications that occurred while the app was terminated, backgrounded, or
inactive.

Key improvements:
- Detect missed rollovers on app launch by checking for past notifications
  without next scheduled notification
- Add active rollover check when app becomes active (handles inactive app
  scenario where notifications fire silently)
- Calculate forward to future time when next scheduled time is in the past
  (handles delays > rollover interval)
- Enhance duplicate detection to exclude original notification from checks
- Retry rollover if previous attempt failed (rollover time set but no next
  notification exists)

Changes:
- DailyNotificationReactivationManager: Add detectAndProcessMissedRollovers()
  method and performActiveRolloverCheck() for app becoming active
- DailyNotificationReactivationManager: Enhance warm start scenario to check
  for missed rollovers
- DailyNotificationScheduler: Add forward calculation loop when next scheduled
  time is in the past
- DailyNotificationPlugin: Register observer for UIApplication.didBecomeActiveNotification
  to trigger rollover check when app becomes active

Fixes rollover scheduling for:
- App terminated: Rollover now detected and scheduled on next launch
- App inactive/backgrounded: Rollover detected when app becomes active
- Delayed recovery: Handles cases where app reopened after rollover interval
  has passed by calculating forward to next future time

All scenarios now properly schedule rollover notifications regardless of app
state when notification fires.
2026-01-09 20:02:40 +08:00
Jose Olarte III
243cbd08f1 docs(ios): add testing instructions for rollover interval
Add inline comments and documentation explaining how to temporarily
change rollover notification intervals from 24 hours to 2 minutes
for testing purposes. Comments specify exact line numbers and values
to change, making it easy to switch between production and testing
modes without losing context.

Changes:
- Add TESTING section to calculateNextScheduledTime() documentation
- Add inline TESTING comments at three change points:
  * Calendar date addition (24 hours → 2 minutes)
  * Fallback time calculation (24 hours → 2 minutes)
  * Duplicate prevention threshold (1 hour → 1 minute)

All code remains at production settings (24-hour intervals).
2026-01-08 21:25:37 +08:00
Jose Olarte III
7e93cbd771 fix(test-app): convert boolean pending to number in display
The pending field in NotificationStatus is a boolean, but the UI
was displaying it directly, causing "true" to appear instead of
a numeric count when notifications were scheduled.

Added a pendingCount computed property that converts boolean
values to numbers (true → 1, false → 0) while also handling
number types for future compatibility.
2026-01-08 19:00:55 +08:00
Jose Olarte III
6d64f71988 fix(ios): save rollover notification content to storage
Save notification content to storage immediately after scheduling
rollover notification so it can be retrieved when the notification
fires. Without this, processRollover fails to find the content
and cannot schedule the next notification.

The rollover flow creates a new notification with a new ID
(daily_rollover_*) but was only scheduling it with the system,
not saving it to storage. When the notification fired, the
lookup by ID failed because the content wasn't stored.

This matches the pattern used in DailyNotificationScheduleHelper
which saves content before scheduling.
2026-01-08 17:29:46 +08:00
Jose Olarte III
65379aedd6 refactor(test-app): extract reusable StatusList component
Created standardized StatusList component to eliminate duplicate status
display code across views. Standardized styling:
- Flexbox layout with space-between justification
- Border-bottom dividers (removed via :last-child)
- Optional status-based color coding via show-status-colors prop
- Consistent padding (12px 0) and spacing

Migrated HomeView and StatusView to use the new component:
- HomeView: Replaced inline status list, removed ~50 lines of duplicate CSS
- StatusView: Replaced diagnostics info items, removed ~25 lines of duplicate CSS
- Removed unused helper functions (getStatusType, getStatusDescription)
- Fixed TypeScript type assertions for status values
- Added diagnosticsItems computed property in StatusView

Reduces code duplication by ~75 lines and provides single source of truth
for status list styling across the application.
2026-01-07 21:20:49 +08:00
Jose Olarte III
66c7eca33d refactor(test-app): simplify System Status section layout
Replaced StatusCard components with simpler inline list layout:
- Removed card bounding boxes and side padding
- Added horizontal dividers between status items
- Title and value on same line using grid layout (left/right justified)
- Reduced padding and margins for more compact display
- Removed unused StatusCard import
2026-01-07 18:53:16 +08:00
Jose Olarte III
d88978259d fix(ios): correct userInfo scope error in notification delivery handler
Fixed compilation error where userInfo was referenced outside its scope
in handleNotificationDelivery error logging. Changed to use
notification.userInfo directly.
2026-01-07 18:52:45 +08:00
Jose Olarte III
66cbe763fc fix(ios): add diagnostic logging for rollover notification flow
Add comprehensive logging to trace rollover notification handling
from AppDelegate delivery through to next notification scheduling.
This enables diagnosis of why rollover notifications fail to
schedule the next 24-hour notification.

Changes:
- Log observer registration on plugin load
- Log handler entry and data extraction in handleNotificationDelivery
- Log processing steps in processRollover including:
  * Missing scheduler/storage detection
  * Content lookup failures with available IDs
  * ScheduleNextNotification success/failure

These logs will help identify whether the issue is:
- Observer not receiving notifications
- Content missing from storage
- Scheduling logic failing silently
2026-01-07 16:51:40 +08:00
Jose Olarte III
766d56c661 feat(test-app): default notification time to current + 3 minutes
Replace hardcoded '09:00' default with dynamic calculation that sets
the notification time to 3 minutes from now (rounded up to next minute).
This makes it more convenient for users to quickly test notifications
without manually adjusting the time field.
2026-01-06 19:27:05 +08:00
Jose Olarte III
f446362984 fix(ui): make notification countdown update reactively
The "Time Until" field in NotificationsView was not updating when
refresh was clicked or over time because the computed property used
Date.now() directly, which is evaluated once and doesn't trigger
reactive updates.

Changes:
- Add reactive currentTime property that updates every second
- Set up interval in mounted() to keep countdown live
- Clean up interval in beforeUnmount() to prevent memory leaks
- Update timeUntilNext computed to use reactive currentTime
- Update refreshNotifications() to immediately refresh currentTime

The countdown now updates automatically every second and immediately
when the refresh button is clicked, without requiring navigation away
and back to the view.
2026-01-06 17:38:59 +08:00
Jose Olarte III
20f15ebcea fix(ios): post notification delivery event to trigger rollover
The AppDelegate's willPresent method was not posting the
DailyNotificationDelivered notification event that the plugin
observes to trigger rollover scheduling. This caused rollover
notifications (scheduled 24 hours after the current notification)
to never be created, even though the rollover logic was fully
implemented in the plugin.

The fix extracts notification_id and scheduled_time from the
notification's userInfo and posts them via NotificationCenter using
the decoupled pattern. This allows the plugin to detect notification
delivery and automatically schedule the next day's notification.

Rollover now works correctly: when a notification is delivered,
the plugin schedules the next notification for 24 hours later,
and the NotificationsView properly displays the next notification
timestamp.
2026-01-06 17:19:18 +08:00
Jose Olarte III
b230a8e7b5 feat(test-app): add emoji icons to platform and status badges
Add platform-specific emojis (🤖 Android, 🍎 iOS, 🌐 Web) and status
indicators ( Ready, ⚠️ Not Ready,  Unknown) to improve visual
clarity in the home view welcome section.
2026-01-06 16:23:20 +08:00
Jose Olarte III
f97b3bec5b feat(test-apps/daily-notification-test): implement notification status display in NotificationsView
Replace placeholder content with functional notification status viewer that
displays scheduled notifications and rollover information. Enables verification
of both manually scheduled notifications and automatic rollover scheduling
(24-hour recurrence).

Features:
- Display next scheduled notification time with formatted date/time
- Show time until next notification (days, hours, minutes)
- Display pending notification count
- Show last notification delivery time
- Display rollover status (enabled/disabled, last rollover time) when available
- Additional status info (enabled, scheduled, errors)
- Manual refresh button for status updates
- Loading and error states with platform detection

Uses typed plugin wrapper for type safety with fallback to raw plugin access
for rollover fields not in TypeScript interface (iOS-specific fields).
2026-01-05 20:57:15 +08:00
Jose Olarte III
911aabf671 fix(test-app): use Capacitor for platform detection in views
Replace hardcoded platform values and appStore.platform with
Capacitor.getPlatform() for accurate runtime platform detection.

Changes:
- HomeView: Use Capacitor.getPlatform() instead of appStore.platform
- StatusView: Use Capacitor.getPlatform() for initial diagnostics
- diagnostics-export: Replace hardcoded 'Android' with Capacitor detection
- StatusView: Fix timezone to use actual timezone instead of 'Unknown'
- diagnostics-export: Show 'N/A' for API Level on iOS/web (Android-specific)

Fixes platform badge showing "web" on iOS native and diagnostics
showing incorrect platform information.
2026-01-05 20:38:55 +08:00
Jose Olarte III
5ae63e6f6d fix(test-app): constrain JSON diagnostics output width
The Raw Diagnostics pre element was overflowing its container and
causing the entire page to require horizontal scrolling, making field
values in the diagnostics info section inaccessible.

Added min-width: 0 and overflow: hidden to .diagnostics-json container
to allow proper grid constraint propagation. Added max-width: 100%,
width: 100%, and box-sizing: border-box to .json-output to ensure
it respects container bounds while maintaining horizontal scroll
within the pre element itself.
2026-01-05 20:21:48 +08:00
Jose Olarte III
edc4082f72 feat(ios): implement testAlarm method and fix plugin discovery
Add testAlarm() method to iOS plugin for quick notification testing.
Fix plugin method discovery by registering testAlarm in CAPBridgedPlugin
pluginMethods array. Add force-load code in AppDelegate to ensure plugin
is discovered by Capacitor's objc_getClassList scan.

Changes:
- Add testAlarm() implementation in DailyNotificationPlugin.swift
- Register testAlarm in pluginMethods array (required for Capacitor discovery)
- Add force-load code in test app AppDelegate (matches working ios-test-app)
- Add UNUserNotificationCenterDelegate to show notifications in foreground
- Add test notification button to ScheduleView with immediate feedback
- Add debug logging for method discovery and plugin loading

Fixes issue where testAlarm was implemented but returned "UNIMPLEMENTED"
because it wasn't registered in the pluginMethods array. Also ensures
plugin class is loaded before Capacitor's discovery phase.
2025-12-31 17:25:52 +08:00
Jose Olarte III
c8919480d9 fix(test-app): conditionally call getExactAlarmStatus on Android only
getExactAlarmStatus() is an Android-only API and was causing
UNIMPLEMENTED errors on iOS. Now only calls the method on Android
platforms, with safe defaults for iOS.

- Check platform before calling getExactAlarmStatus()
- Use default values { enabled: false, supported: false } on iOS
- Add error handling for Android call failures
- Make exact alarm status logging conditional on Android
2025-12-31 14:27:08 +08:00
Jose Olarte III
2d353c877c feat(test-app): add dedicated Request Permissions view
Create a new RequestPermissionsView that provides a dedicated interface
for checking and requesting notification permissions, matching the
functionality found in the iOS test app.

The view includes:
- Status display with color-coded states (requesting/granted/error)
- Permission status grid showing notifications, exact alarm, and
  background refresh status
- Request Permissions button with same functionality as iOS test app
- Platform-specific settings access buttons
- Automatic status refresh on mount

Updated HomeView to navigate to the new view instead of calling
permission request function directly, providing better UX with
dedicated screen for permission management.
2025-12-31 14:20:49 +08:00
Jose Olarte III
2f0d733b10 feat(test-app): add back navigation and improve mobile layout
- Add back buttons to all sub-views (Schedule, Status, Notifications, History, Logs, Settings, UserZero, About)
- Fix router navigation by importing router instance directly (resolves TypeScript errors with vue-facing-decorator)
- Update back button styling: flex layout with page title, arrow-only label
- Fix "Check Status" action to navigate to StatusView instead of checking status inline
- Remove horizontal padding on mobile views (max-width 768px) for edge-to-edge layout
- Simplify badge styling in HomeView (remove padding and border-radius)
2025-12-31 14:04:42 +08:00
Jose Olarte III
a7d33e2d37 feat(build): add iOS support to build-native.sh
Adds iOS platform support to the unified build script, enabling
building of test-apps/daily-notification-test for iOS alongside
existing Android support.

Changes:
- Add build_plugin_for_test_app_ios() to build iOS test app
- Add build_ios() function for iOS platform handling
- Make environment checks conditional based on target platform
- Add get_pod_command() helper to handle CocoaPods via rbenv
- Update main() to accept --platform ios and include iOS in "all"

This aligns the script with BUILDING.md documentation (lines 71, 75)
which implied iOS support was already available. The iOS build
process mirrors Android: creates plugin symlink, builds Vue app,
syncs Capacitor iOS (handles Podfile fixes), and builds with
xcodebuild for simulator.

Platform-specific environment checks allow iOS-only builds without
requiring Android toolchain, and vice versa.
2025-12-31 13:11:08 +08:00
Jose Olarte III
83ec604a4b fix(build): resolve Android build failures with Java 21 and Capacitor context
Fixed two build issues preventing Android plugin compilation:

1. Build script now builds from test app context instead of standalone
   - Capacitor Android is only available as a project dependency, not from Maven
   - Plugin must be built within a Capacitor app's Android project
   - Changed build_plugin_for_test_app() to build from test app's android/
     directory where Capacitor is available as :capacitor-android project

2. Added JVM arguments for Java 17+ KAPT compatibility
   - Java 21's module system blocks KAPT from accessing internal compiler classes
   - Added --add-opens flags to both org.gradle.jvmargs and kotlin.daemon.jvmargs
   - Kotlin compiler daemon runs separately and needs its own configuration
   - Applied to both plugin and test app gradle.properties files

These changes allow the plugin to build successfully with Java 21 and ensure
it's built in the correct context where Capacitor dependencies are available.
2025-12-31 12:53:02 +08:00
Jose Olarte III
8b116db095 refactor(test-app): reset default margins and padding in HTML
Add inline style resets to html, body, and #app elements to
eliminate browser default margins and padding. This ensures consistent layout
baseline across browsers and complements the centralized padding
management in App.vue.
2025-12-31 10:30:03 +08:00
Jose Olarte III
76c05e3690 refactor(test-app): centralize padding in root container
Remove individual padding declarations from view components and
set padding to 0 on App.vue root container. This consolidates
padding management in one place for easier maintenance and
consistent spacing control.
2025-12-31 10:17:22 +08:00
Jose Olarte III
f19ff4c127 Merge branch 'master' into ios-2 2025-12-31 09:56:16 +08:00
Jose Olarte III
9565191101 Fix iOS build errors and test app setup
- Fix async/await usage in background fetch handler
- Fix Core Data metadata access errors
- Replace SQLITE_TRANSIENT with nil for Swift compatibility
- Fix PermissionStatus interface and type casts in test app
- Add iOS setup documentation to BUILDING.md
- Update iOS sync workflow to handle Podfile regeneration

Resolves all iOS compilation errors and improves test app setup process.
2025-12-30 12:35:10 +08:00
Matthew Raymer
f83e799254 Merge branch 'ios-2' 2025-12-30 03:03:09 +00:00
Matthew Raymer
36e15633be feat: add comprehensive logging to test app UI refresh and polling
- Add detailed [UI Refresh] prefix logging to loadPluginStatus()
- Log plugin availability checks, status calls, and UI updates
- Add detailed [Poll] prefix logging to checkNotificationDelivery()
- Log status check results, notification delivery detection, and time comparisons
- Log rollover detection with old/new timestamp details
- Log periodic refresh triggers and initialization of tracking variables
- Include structured object logging for debugging UI refresh behavior

This enables debugging of UI auto-refresh mechanism and visibility
into JavaScript console logs in captured logcat output during test runs.
2025-12-30 02:56:56 +00:00
Matthew Raymer
dced4b49e1 feat: add comprehensive logging for UI refresh and capture JS console logs
- Add detailed logging to loadPluginStatus() with [UI Refresh] prefix
- Add detailed logging to checkNotificationDelivery() with [Poll] prefix
- Log status check results, change detection, and refresh triggers
- Log nextNotificationTime comparisons to debug rollover detection
- Include Capacitor/Console in logcat capture pattern to capture JS logs
- Log notification delivery detection and time calculations
- Log when rollover is detected and UI refresh is triggered

This enables debugging of UI auto-refresh mechanism and visibility
into JavaScript console logs in captured logcat output.
2025-12-29 10:22:36 +00:00
Jose Olarte III
7725f19387 Merge branch 'ios-2' 2025-12-19 10:54:18 +08:00
76b3fa8199 doc: add notes from overall discussions 2025-12-17 09:23:55 -07:00
46 changed files with 3830 additions and 471 deletions

View File

@@ -361,12 +361,16 @@ npm install
# Build Vue 3 app
npm run build
# Add Capacitor
npm install @capacitor/android
# Add Capacitor platforms
npm install @capacitor/android @capacitor/ios
# Sync with Capacitor
npx cap sync android
# For iOS: Use the npm script (handles Podfile fixes automatically)
npm run cap:sync:ios
# This runs: cap copy ios + fix Podfile + pod install
# Run on Android device/emulator
npx cap run android
@@ -374,6 +378,149 @@ npx cap run android
npx cap run ios
```
**iOS Setup (Vue 3 Test App)**
The iOS setup requires additional steps to configure the plugin correctly:
**1. Install Dependencies**
```bash
cd test-apps/daily-notification-test
npm install
```
**2. Build Vue App**
```bash
npm run build
```
**3. Add iOS Platform (if not already added)**
```bash
npx cap add ios
```
**4. Fix Podfile Configuration**
**Critical**: Capacitor's `npx cap sync ios` regenerates the Podfile with incorrect plugin references (`TimesafariDailyNotificationPlugin` instead of `DailyNotificationPlugin`).
**Solution**: Use the npm script `npm run cap:sync:ios` which:
1. Copies assets without running pod install (`npx cap copy ios`)
2. Automatically fixes the Podfile
3. Then runs `pod install` with the corrected Podfile
```bash
# Use the npm script (recommended)
npm run cap:sync:ios
# Or manually fix after copy
npx cap copy ios
node scripts/fix-capacitor-plugins.js
cd ios/App && pod install && cd ../..
```
The fix script will:
- Change `TimesafariDailyNotificationPlugin``DailyNotificationPlugin`
- Fix the path from `'../../../..'``'../../node_modules/@timesafari/daily-notification-plugin/ios'`
**5. Install CocoaPods Dependencies**
After the Podfile is fixed, install the iOS dependencies:
```bash
cd ios/App
pod install
cd ../..
```
**Expected Podfile Configuration:**
The Podfile should reference the plugin like this:
```ruby
def capacitor_pods
pod 'Capacitor', :path => '../../node_modules/@capacitor/ios'
pod 'CapacitorCordova', :path => '../../node_modules/@capacitor/ios'
pod 'DailyNotificationPlugin', :path => '../../node_modules/@timesafari/daily-notification-plugin/ios'
end
```
**Important Notes:**
- The pod name must be `DailyNotificationPlugin` (not `TimesafariDailyNotificationPlugin`)
- The path must point to `../../node_modules/@timesafari/daily-notification-plugin/ios`
- The plugin must be installed in `node_modules` via `npm install` (it's installed as a local file dependency)
**6. Sync and Build**
**Important**: `npx cap sync ios` tries to run `pod install` automatically, but it will fail because the Podfile has incorrect plugin references. Use the npm script instead:
```bash
# Option 1: Use the npm script (recommended - handles everything)
npm run cap:sync:ios
# This script:
# 1. Copies web assets (npx cap copy ios)
# 2. Fixes the Podfile (node scripts/fix-capacitor-plugins.js)
# 3. Installs pods (cd ios/App && pod install)
# Option 2: Manual steps (if you need more control)
npx cap copy ios # Copy assets without pod install
node scripts/fix-capacitor-plugins.js # Fix Podfile
cd ios/App && pod install && cd ../.. # Install pods
# Open in Xcode
npx cap open ios
```
**Why this approach?**
- `npx cap sync ios` regenerates the Podfile with wrong references, then tries to run `pod install` which fails
- `npx cap copy ios` only copies files, allowing us to fix the Podfile before `pod install`
- The npm script automates the entire workflow correctly
**Troubleshooting iOS Setup:**
**Error: `[!] No podspec found for 'TimesafariDailyNotificationPlugin'`**
This means the Podfile has the wrong pod name or path. Solutions:
1. **Run the fix script:**
```bash
node scripts/fix-capacitor-plugins.js
```
2. **Manually fix the Podfile:**
- Open `ios/App/Podfile`
- Change `TimesafariDailyNotificationPlugin` to `DailyNotificationPlugin`
- Change path from `'../../../..'` to `'../../node_modules/@timesafari/daily-notification-plugin/ios'`
3. **Verify plugin is installed:**
```bash
ls -la node_modules/@timesafari/daily-notification-plugin/ios/DailyNotificationPlugin.podspec
```
4. **Reinstall dependencies if needed:**
```bash
rm -rf node_modules package-lock.json
npm install
```
**Error: `pod install` fails**
1. **Update CocoaPods:**
```bash
sudo gem install cocoapods
```
2. **Clean CocoaPods cache:**
```bash
cd ios/App
rm -rf Pods Podfile.lock
pod install --repo-update
```
3. **Verify Xcode Command Line Tools:**
```bash
xcode-select --install
```
**Test App Features:**
- Interactive plugin testing interface
@@ -390,8 +537,13 @@ test-apps/daily-notification-test/
│ ├── components/ # Reusable UI components
│ └── stores/ # Pinia state management
├── android/ # Android Capacitor app
├── ios/ # iOS Capacitor app
│ └── App/
│ ├── Podfile # CocoaPods dependencies
│ └── App.xcworkspace # Xcode workspace
├── docs/ # Test app documentation
└── scripts/ # Test app build scripts
│ └── fix-capacitor-plugins.js # Auto-fixes Podfile
```
#### Android Test Apps

View File

@@ -1,76 +1,47 @@
refactor(android): Complete plugin refactoring and safety fixes (Batches 0-7)
fix(build): add SQLite conflict detection and Command Line Tools verification
Comprehensive refactoring to make DailyNotificationPlugin a thin adapter,
eliminate duplicated logic, remove unsafe operations, and harden security.
Prevents iOS build failures caused by pkgx SQLite linking conflicts and
ensures Xcode Command Line Tools are properly installed.
Batch 0 - Constants Centralization:
- Created DailyNotificationConstants.kt to eliminate magic numbers and duplicates
- Centralized: PERMISSION_REQUEST_CODE, channel constants, intent actions/extras,
SharedPreferences keys, WorkManager tags, notification IDs
- Replaced duplicates across Plugin, PermissionManager, ChannelManager, Scheduler
Problem:
- pkgx installs SQLite built for macOS, causing linker errors when building
for iOS simulator: "linking in dylib built for 'macOS'"
- Missing Command Line Tools cause build failures without clear error messages
Batch 1 - Permission Flow Unification:
- Created PermissionStatus.kt data class for unified permission reporting
- Added PermissionManager.getPermissionStatus() as single source of truth
- Implemented PendingPermissionRequest tracking for reliable resume resolution
- Replaced method-name-based resume logic with token-based tracking
- Plugin now delegates all permission checks to PermissionManager
Changes:
- Add check_sqlite_conflicts() function
- Detects pkgx SQLite installations in ~/.pkgx
- Warns about macOS dylibs that will cause iOS simulator build failures
- Checks for system SQLite from Command Line Tools
- Validates library paths (DYLD_LIBRARY_PATH, LD_LIBRARY_PATH)
Batch 2 - Notification Status Checker Hardening:
- Modified NotificationStatusChecker to always check OS-level notification
enablement via NotificationManagerCompat.areNotificationsEnabled()
- Added getReadinessReport() method providing comprehensive status with issues
and actionable guidance
- Plugin checkStatus() now uses readiness report
- Add check_command_line_tools() function
- Verifies Xcode Command Line Tools are installed and configured
- Checks for xcodebuild availability
- Verifies sqlite3 is available (part of Command Line Tools)
- Provides clear error messages with installation instructions
Batch 3 - Cancel Semantics Safety:
- Removed unsafe brute-force cancellation loop (was trying request codes 0-100)
- Cancellation now only targets alarms proven to exist in database
- Prevents accidental cancellation of other alarms and false confidence
- Enhance pkgx detection in iOS build functions
- Specifically searches for pkgx SQLite dylibs
- Automatically removes pkgx paths from PATH environment variable
- Provides detailed warnings about detected conflicts
- Cleans all problematic environment variables before building
Batch 4 - Legacy Scheduler Removal:
- Removed unused legacy scheduleExactAlarm() method (48 lines)
- All scheduling now goes through modern paths:
1. exactAlarmManager.scheduleAlarm() (if available)
2. pendingIntentManager.scheduleExactAlarm() (modern path)
3. pendingIntentManager.scheduleWindowedAlarm() (fallback)
- Integrate checks into environment validation
- Runs automatically when building for iOS
- Provides early warnings before build starts
- Fails fast with clear error messages if tools are missing
Batch 5 - Input Contract Tightening:
- Enforced single input shape for updateStarredPlans: { planIds: string[] }
- Added validation: rejects non-array, non-string elements, empty strings
- Legacy support: single string normalized to array (with warning)
- Clear error messages for contract violations
This fixes the linker error:
"ld: building for 'iOS-simulator', but linking in dylib
(/Users/trent/.pkgx/sqlite.org/v3.44.2/lib/libsqlite3.0.dylib)
built for 'macOS'"
Batch 6 - Token Storage Security:
- Added explicit opt-in for JWT token persistence (persistToken: true)
- Default behavior: tokens NOT persisted (secure default)
- Security warnings logged when persistence is enabled
- Documents unencrypted storage risk
Batch 7 - Plugin Thinning:
- Moved getExactAlarmStatus() to PermissionManager.getExactAlarmStatus()
- Moved canRequestExactAlarmPermission() to PermissionManager
- Removed direct AlarmManager access in cancelAllNotifications()
- Delegated scheduleUserNotification/scheduleDualNotification permission
handling to PermissionManager.requestExactAlarmPermission()
- Removed unused imports: AlarmManager, PendingIntent, PowerManager,
NotificationManagerCompat
Result:
- Plugin is now a thin adapter delegating to services
- No duplicated permission logic
- No unsafe cancellation operations
- No legacy scheduler paths
- Secure token storage defaults
- Clear input contracts
- Comprehensive status reporting
The build script now:
- Detects pkgx SQLite conflicts before building
- Automatically fixes environment variables
- Verifies Command Line Tools are installed
- Provides clear guidance for manual fixes if needed
Files modified:
- DailyNotificationConstants.kt (new)
- PermissionStatus.kt (new)
- DailyNotificationPlugin.kt (thinned, ~500 lines refactored)
- PermissionManager.java (enhanced with status methods)
- NotificationStatusChecker.java (hardened)
- DailyNotificationScheduler.java (legacy removed)
Refs: Cursor directive Batches 0-7
- scripts/build-native.sh

View File

@@ -38,6 +38,15 @@ The plugin has been optimized for **native-first deployment** with the following
## Implementation Status
### **Overview**
Dec 17
- test-apps
- android has been seen to work
- ios is being developed (Jose)
- after ios, will work on daily-notification-test (that includes Vue)
- need to test with real data in the API
### ✅ **Phase 2 Complete - Production Ready**
| Component | Status | Implementation |

View File

@@ -22,7 +22,32 @@ org.gradle.caching=true
org.gradle.parallel=true
# Increase memory for Gradle daemon
org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m
# Java 17+ requires --add-opens flags for KAPT to access internal compiler classes
org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m \
--add-opens=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED \
--add-opens=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED \
--add-opens=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED \
--add-opens=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED \
--add-opens=jdk.compiler/com.sun.tools.javac.jvm=ALL-UNNAMED \
--add-opens=jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED \
--add-opens=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED \
--add-opens=jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED \
--add-opens=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED \
--add-opens=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED
# Kotlin compiler daemon JVM arguments (required for KAPT with Java 17+)
# The Kotlin daemon runs separately and needs the same --add-opens flags
kotlin.daemon.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m \
--add-opens=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED \
--add-opens=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED \
--add-opens=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED \
--add-opens=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED \
--add-opens=jdk.compiler/com.sun.tools.javac.jvm=ALL-UNNAMED \
--add-opens=jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED \
--add-opens=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED \
--add-opens=jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED \
--add-opens=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED \
--add-opens=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED
# Enable configuration cache
org.gradle.configuration-cache=true

View File

@@ -12,6 +12,7 @@ package com.timesafari.dailynotification;
import android.app.AlarmManager;
import android.app.PendingIntent;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.os.Build;
@@ -154,9 +155,15 @@ public class DailyNotificationScheduler {
cancelNotification(duplicateId);
}
// Create intent for the notification
Intent intent = new Intent(context, DailyNotificationReceiver.class);
intent.setAction(com.timesafari.dailynotification.DailyNotificationConstants.ACTION_NOTIFICATION);
// CRITICAL FIX: Explicitly set component and package for AlarmManager broadcasts
// AlarmManager requires explicit component matching when delivering broadcasts
ComponentName receiverComponent = new ComponentName(
context.getPackageName(),
"com.timesafari.dailynotification.DailyNotificationReceiver"
);
Intent intent = new Intent(com.timesafari.dailynotification.DailyNotificationConstants.ACTION_NOTIFICATION);
intent.setComponent(receiverComponent);
intent.setPackage(context.getPackageName());
intent.putExtra(com.timesafari.dailynotification.DailyNotificationConstants.EXTRA_NOTIFICATION_ID, content.getId());
// Check if this is a static reminder

View File

@@ -6,6 +6,7 @@ import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.os.Build
@@ -146,8 +147,15 @@ class NotifyReceiver : BroadcastReceiver() {
// This prevents duplicate alarms when multiple scheduling paths race
// Strategy: Check both by scheduleId (stable) and by trigger time (catches different scheduleIds for same time)
val requestCode = getRequestCode(stableScheduleId)
val checkIntent = Intent(context, com.timesafari.dailynotification.DailyNotificationReceiver::class.java).apply {
action = "com.timesafari.daily.NOTIFICATION"
// CRITICAL FIX: Explicitly set component and package for AlarmManager broadcasts
// AlarmManager requires explicit component matching when delivering broadcasts
val receiverComponent = ComponentName(
context.packageName,
"com.timesafari.dailynotification.DailyNotificationReceiver"
)
val checkIntent = Intent("com.timesafari.daily.NOTIFICATION").apply {
setComponent(receiverComponent)
setPackage(context.packageName)
}
// Check 1: Same scheduleId (stable requestCode) - most reliable
@@ -269,12 +277,21 @@ class NotifyReceiver : BroadcastReceiver() {
Log.w(TAG, "Failed to store notification content in database, continuing with alarm scheduling", e)
}
// FIX: Use DailyNotificationReceiver (registered in manifest) instead of NotifyReceiver
// FIX: Set action to match manifest registration
val intent = Intent(context, com.timesafari.dailynotification.DailyNotificationReceiver::class.java).apply {
action = "com.timesafari.daily.NOTIFICATION" // Must match manifest intent-filter action
putExtra("notification_id", notificationId) // DailyNotificationReceiver expects this extra
putExtra("schedule_id", stableScheduleId) // Add stable scheduleId for tracking
// CRITICAL FIX: Explicitly set component and package for AlarmManager broadcasts
// AlarmManager requires explicit component matching when delivering broadcasts.
// Using Intent(context, Class) constructor may not work reliably with AlarmManager
// on all Android versions, especially when the app is in certain states.
// Solution: Create Intent with action, then explicitly set component and package.
val intent = Intent("com.timesafari.daily.NOTIFICATION").apply {
// Explicitly set component to ensure AlarmManager can match it to the receiver
setComponent(receiverComponent)
// Explicitly set package to ensure it matches the app's package (not plugin's)
setPackage(context.packageName)
// Must match manifest intent-filter action
// DailyNotificationReceiver expects this extra
putExtra("notification_id", notificationId)
// Add stable scheduleId for tracking
putExtra("schedule_id", stableScheduleId)
// Also preserve original extras for backward compatibility if needed
putExtra("title", config.title)
putExtra("body", config.body)
@@ -282,7 +299,8 @@ class NotifyReceiver : BroadcastReceiver() {
putExtra("vibration", config.vibration ?: true)
putExtra("priority", config.priority ?: "normal")
putExtra("is_static_reminder", isStaticReminder)
putExtra("trigger_time", triggerAtMillis) // Store trigger time for debugging
// Store trigger time for debugging
putExtra("trigger_time", triggerAtMillis)
if (reminderId != null) {
putExtra("reminder_id", reminderId)
}
@@ -407,9 +425,14 @@ class NotifyReceiver : BroadcastReceiver() {
*/
fun cancelNotification(context: Context, scheduleId: String? = null, triggerAtMillis: Long? = null) {
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
// FIX: Use DailyNotificationReceiver to match what was scheduled
val intent = Intent(context, com.timesafari.dailynotification.DailyNotificationReceiver::class.java).apply {
action = "com.timesafari.daily.NOTIFICATION"
// CRITICAL FIX: Use same Intent format as scheduling (explicit component and package)
val receiverComponent = ComponentName(
context.packageName,
"com.timesafari.dailynotification.DailyNotificationReceiver"
)
val intent = Intent("com.timesafari.daily.NOTIFICATION").apply {
setComponent(receiverComponent)
setPackage(context.packageName)
}
val requestCode = when {
scheduleId != null -> getRequestCode(scheduleId)
@@ -438,9 +461,14 @@ class NotifyReceiver : BroadcastReceiver() {
* @return true if alarm is scheduled, false otherwise
*/
fun isAlarmScheduled(context: Context, scheduleId: String? = null, triggerAtMillis: Long? = null): Boolean {
// FIX: Use DailyNotificationReceiver to match what was scheduled
val intent = Intent(context, com.timesafari.dailynotification.DailyNotificationReceiver::class.java).apply {
action = "com.timesafari.daily.NOTIFICATION"
// CRITICAL FIX: Use same Intent format as scheduling (explicit component and package)
val receiverComponent = ComponentName(
context.packageName,
"com.timesafari.dailynotification.DailyNotificationReceiver"
)
val intent = Intent("com.timesafari.daily.NOTIFICATION").apply {
setComponent(receiverComponent)
setPackage(context.packageName)
}
val requestCode = when {
scheduleId != null -> getRequestCode(scheduleId)

View File

@@ -1,6 +1,7 @@
package com.timesafari.dailynotification
import android.app.PendingIntent
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
@@ -442,8 +443,14 @@ class ReactivationManager(private val context: Context) {
return try {
// Check if any PendingIntent for our receiver exists
// This is more reliable than nextAlarmClock
val intent = Intent(context, NotifyReceiver::class.java).apply {
action = "com.timesafari.daily.NOTIFICATION"
// CRITICAL FIX: Use DailyNotificationReceiver with explicit component/package
val receiverComponent = ComponentName(
context.packageName,
"com.timesafari.dailynotification.DailyNotificationReceiver"
)
val intent = Intent("com.timesafari.daily.NOTIFICATION").apply {
setComponent(receiverComponent)
setPackage(context.packageName)
}
val pendingIntent = PendingIntent.getBroadcast(
context,

View File

@@ -11,7 +11,13 @@ Pod::Spec.new do |s|
s.dependency 'Capacitor', '>= 5.0.0'
s.dependency 'CapacitorCordova', '>= 5.0.0'
s.swift_version = '5.1'
s.xcconfig = { 'GCC_PREPROCESSOR_DEFINITIONS' => '$(inherited) COCOAPODS=1' }
# Explicitly link against system SQLite library to avoid conflicts with
# macOS SQLite libraries (e.g., from pkgx or other package managers that
# may set DYLD_LIBRARY_PATH or similar environment variables)
s.xcconfig = {
'GCC_PREPROCESSOR_DEFINITIONS' => '$(inherited) COCOAPODS=1',
'OTHER_LDFLAGS' => '$(inherited) -lsqlite3'
}
s.deprecated = false
# Set to false so Capacitor can discover the plugin
# Capacitor iOS does not scan static frameworks for plugin discovery

View File

@@ -263,12 +263,12 @@ class DailyNotificationDatabase {
return
}
sqlite3_bind_text(stmt, 1, (content.id as NSString).utf8String, -1, SQLITE_TRANSIENT)
sqlite3_bind_text(stmt, 2, (json as NSString).utf8String, -1, SQLITE_TRANSIENT)
sqlite3_bind_text(stmt, 1, (content.id as NSString).utf8String, -1, nil)
sqlite3_bind_text(stmt, 2, (json as NSString).utf8String, -1, nil)
sqlite3_bind_int64(stmt, 3, sqlite3_int64(content.fetchedAt))
if let etag = content.etag {
sqlite3_bind_text(stmt, 4, (etag as NSString).utf8String, -1, SQLITE_TRANSIENT)
sqlite3_bind_text(stmt, 4, (etag as NSString).utf8String, -1, nil)
} else {
sqlite3_bind_null(stmt, 4)
}
@@ -310,7 +310,7 @@ class DailyNotificationDatabase {
return
}
sqlite3_bind_text(stmt, 1, (id as NSString).utf8String, -1, SQLITE_TRANSIENT)
sqlite3_bind_text(stmt, 1, (id as NSString).utf8String, -1, nil)
if sqlite3_step(stmt) != SQLITE_DONE {
print("\(Self.TAG): deleteNotificationContent step failed: \(String(cString: sqlite3_errmsg(db)))")

View File

@@ -261,17 +261,8 @@ class PersistenceController {
description?.shouldMigrateStoreAutomatically = true
description?.shouldInferMappingModelAutomatically = true
// Set initial schema version metadata (for new stores)
if !inMemory {
var metadata = description?.metadata ?? [:]
if metadata["schema_version"] == nil {
metadata["schema_version"] = PersistenceController.SCHEMA_VERSION
description?.metadata = metadata
}
}
var loadError: Error? = nil
tempContainer?.loadPersistentStores { description, error in
tempContainer?.loadPersistentStores { storeDescription, error in
if let error = error as NSError? {
loadError = error
print("DNP-PLUGIN: CoreData store load error: \(error.localizedDescription)")
@@ -281,7 +272,19 @@ class PersistenceController {
}
} else {
print("DNP-PLUGIN: CoreData store loaded successfully")
print("DNP-PLUGIN: Store URL: \(description.url?.absoluteString ?? "unknown")")
print("DNP-PLUGIN: Store URL: \(storeDescription.url?.absoluteString ?? "unknown")")
// Set initial schema version metadata (for new stores)
// Metadata must be set using the coordinator after the store is loaded
if !inMemory,
let coordinator = tempContainer?.persistentStoreCoordinator,
let store = coordinator.persistentStores.first,
let metadata = store.metadata,
metadata["schema_version"] == nil {
var newMetadata = metadata
newMetadata["schema_version"] = PersistenceController.SCHEMA_VERSION
coordinator.setMetadata(newMetadata, for: store)
}
}
}
@@ -373,7 +376,13 @@ class PersistenceController {
return
}
let currentVersion = store.metadata["schema_version"] as? Int ?? 1
// store.metadata is optional, so we need to unwrap it
guard let metadata = store.metadata else {
print("DNP-PLUGIN: Store metadata is nil, using default schema version")
return
}
let currentVersion = metadata["schema_version"] as? Int ?? 1
let expectedVersion = PersistenceController.SCHEMA_VERSION
if currentVersion != expectedVersion {
@@ -381,9 +390,13 @@ class PersistenceController {
print("DNP-PLUGIN: CoreData auto-migration will handle schema changes")
// Update metadata for future reference (does not trigger migration)
var metadata = store.metadata
metadata["schema_version"] = expectedVersion
// Use the coordinator to set metadata
if let coordinator = container?.persistentStoreCoordinator {
var newMetadata = metadata
newMetadata["schema_version"] = expectedVersion
coordinator.setMetadata(newMetadata, for: store)
// Note: Metadata persists on next store save
}
} else {
print("DNP-PLUGIN: Schema version verified: \(currentVersion)")
}

View File

@@ -7,10 +7,12 @@
//
import Foundation
import UIKit
import Capacitor
import UserNotifications
import BackgroundTasks
import CoreData
import ObjectiveC
/**
* iOS implementation of Daily Notification Plugin
@@ -80,8 +82,50 @@ public class DailyNotificationPlugin: CAPPlugin {
object: nil
)
NSLog("DNP-ROLLOVER: Observer registered for DailyNotificationDelivered notification")
print("DNP-ROLLOVER: Observer registered for DailyNotificationDelivered notification")
// Register for app becoming active to check for missed rollovers
NotificationCenter.default.addObserver(
self,
selector: #selector(handleAppBecameActive(_:)),
name: UIApplication.didBecomeActiveNotification,
object: nil
)
NSLog("DNP-ROLLOVER: Observer registered for app becoming active")
print("DNP-ROLLOVER: Observer registered for app becoming active")
NSLog("DNP-DEBUG: DailyNotificationPlugin.load() completed - initialization done")
print("DNP-PLUGIN: Daily Notification Plugin loaded on iOS")
// Debug: Log all available @objc methods for Capacitor discovery
let methods = getObjCMethods()
NSLog("DNP-DEBUG: Available @objc methods: \(methods.joined(separator: ", "))")
print("DNP-DEBUG: Available @objc methods: \(methods.joined(separator: ", "))")
}
/**
* Debug helper: Get all @objc methods for this class
*/
private func getObjCMethods() -> [String] {
var methods: [String] = []
var methodCount: UInt32 = 0
let methodList = class_copyMethodList(type(of: self), &methodCount)
for i in 0..<Int(methodCount) {
if let method = methodList?[i] {
let selector = method_getName(method)
let methodName = NSStringFromSelector(selector)
// Filter for methods that look like plugin methods (take CAPPluginCall)
if methodName.contains(":") && !methodName.hasPrefix("_") {
methods.append(methodName)
}
}
}
free(methodList)
return methods.sorted()
}
// MARK: - Configuration Methods
@@ -419,6 +463,9 @@ public class DailyNotificationPlugin: CAPPlugin {
// Phase 3: Check for JWT-signed fetcher configuration
// If native fetcher is configured, use it; otherwise fall back to dummy content
let nativeFetcherConfig = UserDefaults.standard.string(forKey: "native_fetcher_config")
// Save content to storage via state actor (thread-safe)
Task {
let content: NotificationContent
if let configJson = nativeFetcherConfig,
@@ -468,9 +515,6 @@ public class DailyNotificationPlugin: CAPPlugin {
etag: nil
)
}
// Save content to storage via state actor (thread-safe)
Task {
do {
// Use the content (either from JWT fetcher or dummy)
if #available(iOS 13.0, *) {
@@ -513,12 +557,17 @@ public class DailyNotificationPlugin: CAPPlugin {
// Phase 3.3: Schedule next background task
// Calculate next fetch time based on notification schedule
if let nextScheduledTime = self.getNextScheduledNotificationTime() {
self.scheduleBackgroundFetch(scheduledTime: nextScheduledTime)
if let scheduler = self.scheduler {
let nextScheduledTime = await scheduler.getNextNotificationTime()
if let nextTime = nextScheduledTime {
self.scheduleBackgroundFetch(scheduledTime: nextTime)
print("DNP-FETCH: Next background fetch scheduled")
} else {
print("DNP-FETCH: No future notifications found, skipping next task schedule")
}
} else {
print("DNP-FETCH: Scheduler not available, skipping next task schedule")
}
guard !taskCompleted else { return }
task.setTaskCompleted(success: true)
@@ -575,10 +624,13 @@ public class DailyNotificationPlugin: CAPPlugin {
// Phase 3.3: Schedule next background task if needed
// For notify task, schedule next occurrence if applicable
if let nextScheduledTime = self.getNextScheduledNotificationTime() {
if let scheduler = self.scheduler {
let nextScheduledTime = await scheduler.getNextNotificationTime()
if let nextTime = nextScheduledTime {
// Calculate next notify task time (if applicable)
// Note: Notify tasks are typically scheduled less frequently than fetch tasks
print("DNP-NOTIFY: Next notification scheduled at \(nextScheduledTime)")
print("DNP-NOTIFY: Next notification scheduled at \(nextTime)")
}
}
guard !taskCompleted else { return }
@@ -1091,7 +1143,7 @@ public class DailyNotificationPlugin: CAPPlugin {
scheduler: scheduler,
storage: self.storage,
stateActor: await self.stateActor,
scheduleBackgroundFetch: { [weak self] scheduledTime in
scheduleBackgroundFetch: { [weak self] (scheduledTime: Int64) -> Void in
self?.scheduleBackgroundFetch(scheduledTime: scheduledTime)
}
)
@@ -1112,6 +1164,129 @@ public class DailyNotificationPlugin: CAPPlugin {
}
}
/**
* Test method: Schedule an alarm to fire in a few seconds
* Useful for verifying alarm delivery works correctly
*
* @param call Plugin call with optional secondsFromNow (default: 5)
* @returns Object with scheduled (boolean), secondsFromNow (number), and triggerAtMillis (number)
*/
@objc func testAlarm(_ call: CAPPluginCall) {
NSLog("DNP-DEBUG: testAlarm() method CALLED - method is being invoked!")
print("DNP-DEBUG: testAlarm() method CALLED - method is being invoked!")
print("DNP-DEBUG: testAlarm call data: \(call.jsObjectRepresentation)")
guard let scheduler = scheduler else {
NSLog("DNP-DEBUG: testAlarm() - scheduler is nil, rejecting")
print("DNP-DEBUG: testAlarm() - scheduler is nil, rejecting")
let error = DailyNotificationErrorCodes.createErrorResponse(
code: DailyNotificationErrorCodes.PLUGIN_NOT_INITIALIZED,
message: "Plugin not initialized"
)
let errorMessage = error["message"] as? String ?? "Unknown error"
let errorCode = error["error"] as? String ?? "unknown_error"
call.reject(errorMessage, errorCode)
return
}
// Get secondsFromNow parameter (default: 5)
let secondsFromNow = call.getInt("secondsFromNow") ?? 5
// Ensure minimum of 1 second (iOS requirement)
let validSeconds = max(1, secondsFromNow)
Task {
do {
// Check permissions first
let permissionStatus = await notificationCenter.notificationSettings()
if permissionStatus.authorizationStatus != .authorized && permissionStatus.authorizationStatus != .provisional {
let error = DailyNotificationErrorCodes.createErrorResponse(
code: DailyNotificationErrorCodes.NOTIFICATIONS_DENIED,
message: "Notification permissions not granted"
)
let errorMessage = error["message"] as? String ?? "Unknown error"
let errorCode = error["error"] as? String ?? "unknown_error"
call.reject(errorMessage, errorCode)
return
}
// Create test notification content
let notificationContent = UNMutableNotificationContent()
notificationContent.title = "Test Notification"
notificationContent.body = "This is a test notification scheduled \(validSeconds) seconds from now"
notificationContent.sound = .default
notificationContent.categoryIdentifier = "DAILY_NOTIFICATION"
notificationContent.userInfo = [
"notification_id": "test_\(Date().timeIntervalSince1970)",
"is_test": true
]
// Create time interval trigger (fires in X seconds)
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: TimeInterval(validSeconds), repeats: false)
// Create notification request with unique ID
let notificationId = "test_alarm_\(Date().timeIntervalSince1970)"
let request = UNNotificationRequest(
identifier: notificationId,
content: notificationContent,
trigger: trigger
)
// Schedule notification
try await notificationCenter.add(request)
// Calculate trigger time in milliseconds
let triggerAtMillis = Int64((Date().timeIntervalSince1970 + Double(validSeconds)) * 1000)
let result: [String: Any] = [
"scheduled": true,
"secondsFromNow": validSeconds,
"triggerAtMillis": triggerAtMillis
]
print("DNP-PLUGIN: Test alarm scheduled for \(validSeconds) seconds from now (triggerAtMillis=\(triggerAtMillis))")
NSLog("DNP-DEBUG: testAlarm() - Successfully scheduled, resolving with result: \(result)")
DispatchQueue.main.async {
NSLog("DNP-DEBUG: testAlarm() - Resolving call with result")
call.resolve(result)
}
} catch {
NSLog("DNP-DEBUG: testAlarm() - Error caught: \(error)")
print("DNP-PLUGIN: Error scheduling test alarm: \(error)")
let errorResponse = DailyNotificationErrorCodes.createErrorResponse(
code: DailyNotificationErrorCodes.SCHEDULING_FAILED,
message: "Failed to schedule test alarm: \(error.localizedDescription)"
)
let errorMessage = errorResponse["message"] as? String ?? "Unknown error"
let errorCode = errorResponse["error"] as? String ?? "unknown_error"
NSLog("DNP-DEBUG: testAlarm() - Rejecting with error: \(errorMessage) (\(errorCode))")
DispatchQueue.main.async {
call.reject(errorMessage, errorCode)
}
}
}
}
/**
* Debug method: List all available plugin methods
* Useful for verifying Capacitor method discovery
*
* @param call Plugin call
*/
@objc func listAvailableMethods(_ call: CAPPluginCall) {
let methods = getObjCMethods()
let result: [String: Any] = [
"methods": methods,
"count": methods.count,
"testAlarmFound": methods.contains("testAlarm:")
]
NSLog("DNP-DEBUG: listAvailableMethods() - Found \(methods.count) methods")
NSLog("DNP-DEBUG: testAlarm: found: \(methods.contains("testAlarm:"))")
call.resolve(result)
}
/**
* Get the last notification that was delivered
*
@@ -1245,13 +1420,22 @@ public class DailyNotificationPlugin: CAPPlugin {
* @param notification NSNotification with userInfo containing notification_id and scheduled_time
*/
@objc private func handleNotificationDelivery(_ notification: Notification) {
NSLog("DNP-ROLLOVER: handleNotificationDelivery called")
print("DNP-ROLLOVER: handleNotificationDelivery called")
// Extract notification data from userInfo
guard let userInfo = notification.userInfo,
let notificationId = userInfo["notification_id"] as? String,
let scheduledTime = userInfo["scheduled_time"] as? Int64 else {
NSLog("DNP-ROLLOVER: ERROR handleNotificationDelivery missing required data userInfo=%@", notification.userInfo ?? "nil")
print("DNP-ROLLOVER: ERROR handleNotificationDelivery missing required data userInfo=\(notification.userInfo ?? [:])")
return
}
let scheduledTimeStr = formatTime(scheduledTime)
NSLog("DNP-ROLLOVER: handleNotificationDelivery processing id=%@ scheduled_time=%@", notificationId, scheduledTimeStr)
print("DNP-ROLLOVER: handleNotificationDelivery processing id=\(notificationId) scheduled_time=\(scheduledTimeStr)")
// Track notify execution
let currentTime = Int64(Date().timeIntervalSince1970 * 1000)
storage?.saveLastNotifyExecution(timestamp: currentTime)
@@ -1262,6 +1446,23 @@ public class DailyNotificationPlugin: CAPPlugin {
}
}
/**
* Handle app becoming active (foreground)
*
* This is called when the app becomes active to check for missed rollovers
* that occurred while the app was backgrounded.
*
* @param notification NSNotification for app becoming active
*/
@objc private func handleAppBecameActive(_ notification: Notification) {
NSLog("DNP-ROLLOVER: handleAppBecameActive called")
print("DNP-ROLLOVER: handleAppBecameActive called")
// Perform lightweight rollover check when app becomes active
// This handles cases where notifications fired while app was backgrounded
reactivationManager?.performActiveRolloverCheck()
}
/**
* Process rollover for delivered notification
*
@@ -1269,15 +1470,33 @@ public class DailyNotificationPlugin: CAPPlugin {
* @param scheduledTime Scheduled time of delivered notification
*/
private func processRollover(notificationId: String, scheduledTime: Int64) async {
let scheduledTimeStr = formatTime(scheduledTime)
NSLog("DNP-ROLLOVER: processRollover START id=%@ scheduled_time=%@", notificationId, scheduledTimeStr)
print("DNP-ROLLOVER: processRollover START id=\(notificationId) scheduled_time=\(scheduledTimeStr)")
guard let scheduler = scheduler, let storage = storage else {
NSLog("DNP-ROLLOVER: ERROR processRollover missing scheduler or storage scheduler=%@ storage=%@",
scheduler != nil ? "present" : "nil", storage != nil ? "present" : "nil")
print("DNP-ROLLOVER: ERROR processRollover missing scheduler or storage scheduler=\(scheduler != nil ? "present" : "nil") storage=\(storage != nil ? "present" : "nil")")
return
}
// Get the notification content that was delivered
guard let content = storage.getNotificationContent(id: notificationId) else {
NSLog("DNP-ROLLOVER: ERROR processRollover content not found in storage id=%@", notificationId)
print("DNP-ROLLOVER: ERROR processRollover content not found in storage id=\(notificationId)")
// Log available notification IDs for debugging
let allNotifications = storage.getAllNotifications()
let availableIds = allNotifications.map { $0.id }.joined(separator: ", ")
NSLog("DNP-ROLLOVER: Available notification IDs in storage: [%@]", availableIds)
print("DNP-ROLLOVER: Available notification IDs in storage: [\(availableIds)]")
return
}
NSLog("DNP-ROLLOVER: processRollover found content id=%@ calling scheduleNextNotification", notificationId)
print("DNP-ROLLOVER: processRollover found content id=\(notificationId) calling scheduleNextNotification")
// Delegate to scheduler to schedule next notification (glue logic - will be moved to service)
// Note: fetcher parameter is unused - scheduler uses fetchScheduler instead (already implemented)
let scheduled = await scheduler.scheduleNextNotification(
@@ -1286,8 +1505,15 @@ public class DailyNotificationPlugin: CAPPlugin {
fetcher: nil // Unused - fetchScheduler handles prefetch scheduling
)
if scheduled {
NSLog("DNP-ROLLOVER: processRollover SUCCESS id=%@ next notification scheduled", notificationId)
print("DNP-ROLLOVER: processRollover SUCCESS id=\(notificationId) next notification scheduled")
} else {
NSLog("DNP-ROLLOVER: processRollover FAILED id=%@ scheduleNextNotification returned false", notificationId)
print("DNP-ROLLOVER: processRollover FAILED id=\(notificationId) scheduleNextNotification returned false")
}
// Rollover processing is non-fatal - recovery will handle on next launch if needed
_ = scheduled
}
/**
@@ -1532,8 +1758,8 @@ public class DailyNotificationPlugin: CAPPlugin {
// User must check in Settings app
// Delegate storage access to storage service
let lastFetchExecution = storage?.getLastSuccessfulRun() ?? NSNull()
let lastNotifyExecution = storage?.getLastNotifyExecution() ?? NSNull()
let lastFetchExecution: Any = storage?.getLastSuccessfulRun() ?? NSNull()
let lastNotifyExecution: Any = storage?.getLastNotifyExecution() ?? NSNull()
let result: [String: Any] = [
"fetchTaskRegistered": true, // Assumed registered if setupBackgroundTasks() was called
@@ -1630,6 +1856,7 @@ public class DailyNotificationPlugin: CAPPlugin {
// Get channelId from call (optional, for API parity with Android)
// iOS doesn't have per-channel control, so check app-wide notification authorization
let channelId = call.getString("channelId") ?? "default"
Task {
// Delegate to scheduler for permission status check
let status = await scheduler.checkPermissionStatus()
@@ -1677,9 +1904,6 @@ public class DailyNotificationPlugin: CAPPlugin {
}
}
}
call.reject("Invalid settings URL")
}
}
/**
* Update notification settings
@@ -1937,6 +2161,7 @@ public class DailyNotificationPlugin: CAPPlugin {
methods.append(CAPPluginMethod(name: "configure", returnType: CAPPluginReturnPromise))
methods.append(CAPPluginMethod(name: "configureNativeFetcher", returnType: CAPPluginReturnPromise))
methods.append(CAPPluginMethod(name: "scheduleDailyNotification", returnType: CAPPluginReturnPromise))
methods.append(CAPPluginMethod(name: "testAlarm", returnType: CAPPluginReturnPromise))
methods.append(CAPPluginMethod(name: "getLastNotification", returnType: CAPPluginReturnPromise))
methods.append(CAPPluginMethod(name: "cancelAllNotifications", returnType: CAPPluginReturnPromise))
methods.append(CAPPluginMethod(name: "getNotificationStatus", returnType: CAPPluginReturnPromise))

View File

@@ -94,6 +94,35 @@ class DailyNotificationReactivationManager {
// MARK: - Recovery Execution
/**
* Perform lightweight rollover check when app becomes active
*
* This is called when the app becomes active (foreground) to check for
* missed rollovers that occurred while the app was backgrounded.
*
* This is a lightweight check that only:
* 1. Checks for delivered notifications and triggers rollover
* 2. Detects and processes missed rollovers
*
* It does NOT perform full recovery (missed notification marking, rescheduling, etc.)
* Full recovery only happens on app launch.
*
* This handles the "inactive app" scenario where notifications fire while
* the app is backgrounded and rollover doesn't happen.
*/
func performActiveRolloverCheck() {
Task {
NSLog("\(Self.TAG): Performing active rollover check (app became active)")
// Check for delivered notifications and trigger rollover
await checkAndProcessDeliveredNotifications()
// Check for missed rollovers (notifications that should have rolled over)
let rolloverResult = await detectAndProcessMissedRollovers()
NSLog("\(Self.TAG): Active rollover check completed: processed=\(rolloverResult.processedCount), failed=\(rolloverResult.failedCount), checked=\(rolloverResult.totalChecked)")
}
}
/**
* Perform recovery on app launch
*
@@ -171,7 +200,22 @@ class DailyNotificationReactivationManager {
self.updateLastLaunchTime()
return
case .warmStart:
NSLog("\(Self.TAG): Warm start detected - no recovery needed")
NSLog("\(Self.TAG): Warm start detected - checking for missed rollovers")
// Even in warm start, we need to check for missed rollovers
// This handles cases where notifications fired while app was backgrounded
let warmStartTime = Date()
// Check for delivered notifications and trigger rollover
await self.checkAndProcessDeliveredNotifications()
// Check for missed rollovers (notifications that should have rolled over)
let rolloverResult = await self.detectAndProcessMissedRollovers()
NSLog("\(Self.TAG): Warm start rollover check: processed=\(rolloverResult.processedCount), failed=\(rolloverResult.failedCount), checked=\(rolloverResult.totalChecked)")
let warmEndTime = Date()
let duration = warmEndTime.timeIntervalSince(warmStartTime) * 1000 // ms
NSLog("\(Self.TAG): Warm start rollover check completed: duration=%.0fms", duration)
self.updateLastLaunchTime()
return
case .coldStart:
@@ -398,6 +442,11 @@ class DailyNotificationReactivationManager {
// This handles notifications that were delivered while app was not running
await checkAndProcessDeliveredNotifications()
// Step 4.6: Check for missed rollovers (notifications that should have rolled over)
// This handles notifications that fired but rollover didn't happen (app was terminated)
let rolloverResult = await detectAndProcessMissedRollovers()
NSLog("\(Self.TAG): Missed rollover recovery: processed=\(rolloverResult.processedCount), failed=\(rolloverResult.failedCount), checked=\(rolloverResult.totalChecked)")
// Record recovery in history
let result = RecoveryResult(
missedCount: missedCount,
@@ -734,6 +783,11 @@ class DailyNotificationReactivationManager {
let verificationResult = try await verifyFutureNotifications()
NSLog("\(Self.TAG): Final verification: found=\(verificationResult.notificationsFound), missing=\(verificationResult.notificationsMissing)")
// Step 8: Check for missed rollovers (notifications that should have rolled over)
// This handles notifications that fired but rollover didn't happen (app was terminated)
let rolloverResult = await detectAndProcessMissedRollovers()
NSLog("\(Self.TAG): Missed rollover recovery: processed=\(rolloverResult.processedCount), failed=\(rolloverResult.failedCount), checked=\(rolloverResult.totalChecked)")
// Record recovery in history
let result = RecoveryResult(
missedCount: missedCount,
@@ -1084,6 +1138,185 @@ class DailyNotificationReactivationManager {
print("DNP-ROLLOVER: RECOVERY_COMPLETE processed=\(processedCount) skipped=\(skippedCount)")
}
/**
* Detect and process missed rollovers on app launch
*
* This method identifies notifications that should have rolled over but didn't,
* and schedules the next notification(s) for them.
*
* Detection Logic:
* 1. Find notifications where scheduledTime < currentTime (should have fired)
* 2. Check if next notification exists (in storage or pending)
* 3. Check if rollover was already processed (via lastRolloverTime)
* 4. If no next notification and rollover not processed, schedule it
*
* This handles cases where:
* - Notification fired while app was terminated
* - Notification was dismissed before app launched
* - Rollover didn't happen because app wasn't active
*
* Error Handling:
* - Individual notification errors are caught and counted
* - Partial results returned if some operations fail
* - All errors logged but don't stop recovery process
*
* @return RolloverRecoveryResult with counts of processed rollovers
*/
private func detectAndProcessMissedRollovers() async -> RolloverRecoveryResult {
NSLog("DNP-ROLLOVER: MISSED_ROLLOVER_CHECK_START")
print("DNP-ROLLOVER: MISSED_ROLLOVER_CHECK_START")
let currentTime = Int64(Date().timeIntervalSince1970 * 1000)
let currentTimeStr = formatTime(currentTime)
// Step 1: Get all notifications from storage
let allNotifications: [NotificationContent]
do {
allNotifications = storage.getAllNotifications()
} catch {
// Non-fatal: Log error and return empty result
NSLog("\(Self.TAG): Error getting notifications from storage (non-fatal): \(error.localizedDescription)")
return RolloverRecoveryResult(processedCount: 0, failedCount: 0, totalChecked: 0)
}
// Step 2: Get pending notifications from system
let pendingRequests: [UNNotificationRequest]
do {
pendingRequests = try await notificationCenter.pendingNotificationRequests()
} catch {
// Non-fatal: Log error and continue with empty pending list
NSLog("\(Self.TAG): Error getting pending notifications (non-fatal): \(error.localizedDescription)")
return RolloverRecoveryResult(processedCount: 0, failedCount: 0, totalChecked: allNotifications.count)
}
let pendingIds = Set(pendingRequests.map { $0.identifier })
// Step 3: Find notifications that should have rolled over
var missedRollovers: [NotificationContent] = []
for notification in allNotifications {
// Check if notification should have fired (scheduledTime < currentTime)
if notification.scheduledTime >= currentTime {
continue // Future notification, skip
}
// Check if rollover was already processed
// Only skip if rollover was processed AND next notification exists
// This handles cases where rollover was attempted but failed
let lastRolloverTime = await storage.getLastRolloverTime(for: notification.id)
// Calculate next scheduled time first to check if it exists
var nextScheduledTime = scheduler.calculateNextScheduledTime(notification.scheduledTime)
// If next scheduled time is in the past, keep calculating forward until we get a future time
// This handles cases where the notification fired more than 2 minutes ago
while nextScheduledTime < currentTime {
let nextTimeStr = formatTime(nextScheduledTime)
NSLog("DNP-ROLLOVER: MISSED_ROLLOVER_NEXT_IN_PAST id=\(notification.id) next_time=\(nextTimeStr) current_time=\(currentTimeStr), calculating forward")
print("DNP-ROLLOVER: MISSED_ROLLOVER_NEXT_IN_PAST id=\(notification.id) next_time=\(nextTimeStr) current_time=\(currentTimeStr), calculating forward")
nextScheduledTime = scheduler.calculateNextScheduledTime(nextScheduledTime)
}
// Check if next notification actually exists
let toleranceMs: Int64 = 60 * 1000 // 1 minute tolerance
var nextNotificationExists = false
// Quick check in storage (exclude original)
for existing in allNotifications {
if existing.id == notification.id {
continue
}
if abs(existing.scheduledTime - nextScheduledTime) <= toleranceMs {
nextNotificationExists = true
break
}
}
// Quick check in pending
if !nextNotificationExists {
for pending in pendingRequests {
if pending.identifier == notification.id {
continue
}
if let trigger = pending.trigger as? UNCalendarNotificationTrigger,
let nextDate = trigger.nextTriggerDate() {
let pendingTime = Int64(nextDate.timeIntervalSince1970 * 1000)
if abs(pendingTime - nextScheduledTime) <= toleranceMs {
nextNotificationExists = true
break
}
}
}
}
// If rollover was processed AND next notification exists, skip
// Otherwise, process it (either rollover wasn't attempted, or it failed)
if let lastTime = lastRolloverTime, lastTime >= notification.scheduledTime, nextNotificationExists {
let lastTimeStr = formatTime(lastTime)
NSLog("DNP-ROLLOVER: MISSED_ROLLOVER_SKIP id=\(notification.id) already_processed last_rollover=\(lastTimeStr) next_exists=true")
print("DNP-ROLLOVER: MISSED_ROLLOVER_SKIP id=\(notification.id) already_processed last_rollover=\(lastTimeStr) next_exists=true")
continue // Already processed and next notification exists
}
// If rollover was attempted but next notification doesn't exist, log and continue processing
if let lastTime = lastRolloverTime, lastTime >= notification.scheduledTime, !nextNotificationExists {
let lastTimeStr = formatTime(lastTime)
NSLog("DNP-ROLLOVER: MISSED_ROLLOVER_RETRY id=\(notification.id) rollover_attempted=\(lastTimeStr) but_next_missing, will_retry")
print("DNP-ROLLOVER: MISSED_ROLLOVER_RETRY id=\(notification.id) rollover_attempted=\(lastTimeStr) but_next_missing, will_retry")
// Continue to process - rollover was attempted but failed
}
// Re-check if next notification exists (we already calculated nextScheduledTime above)
// This is the final check before adding to missed rollovers list
if !nextNotificationExists {
let nextTimeStr = formatTime(nextScheduledTime)
NSLog("DNP-ROLLOVER: MISSED_ROLLOVER_DETECTED id=\(notification.id) scheduled_time=\(formatTime(notification.scheduledTime)) next_time=\(nextTimeStr)")
print("DNP-ROLLOVER: MISSED_ROLLOVER_DETECTED id=\(notification.id) scheduled_time=\(formatTime(notification.scheduledTime)) next_time=\(nextTimeStr)")
missedRollovers.append(notification)
}
}
NSLog("DNP-ROLLOVER: MISSED_ROLLOVER_FOUND count=\(missedRollovers.count) current_time=\(currentTimeStr)")
print("DNP-ROLLOVER: MISSED_ROLLOVER_FOUND count=\(missedRollovers.count) current_time=\(currentTimeStr)")
// Step 4: Process missed rollovers
var processedCount = 0
var failedCount = 0
for notification in missedRollovers {
let scheduledTimeStr = formatTime(notification.scheduledTime)
NSLog("DNP-ROLLOVER: MISSED_ROLLOVER_PROCESS id=\(notification.id) scheduled_time=\(scheduledTimeStr)")
print("DNP-ROLLOVER: MISSED_ROLLOVER_PROCESS id=\(notification.id) scheduled_time=\(scheduledTimeStr)")
// Schedule next notification using existing rollover logic
// Note: fetcher parameter is unused - scheduler uses fetchScheduler instead
let scheduled = await scheduler.scheduleNextNotification(
notification,
storage: storage,
fetcher: nil // Unused - fetchScheduler handles prefetch scheduling
)
if scheduled {
processedCount += 1
NSLog("DNP-ROLLOVER: MISSED_ROLLOVER_SUCCESS id=\(notification.id)")
print("DNP-ROLLOVER: MISSED_ROLLOVER_SUCCESS id=\(notification.id)")
} else {
failedCount += 1
NSLog("DNP-ROLLOVER: MISSED_ROLLOVER_FAILED id=\(notification.id)")
print("DNP-ROLLOVER: MISSED_ROLLOVER_FAILED id=\(notification.id)")
}
}
NSLog("DNP-ROLLOVER: MISSED_ROLLOVER_COMPLETE processed=\(processedCount) failed=\(failedCount) total_checked=\(allNotifications.count)")
print("DNP-ROLLOVER: MISSED_ROLLOVER_COMPLETE processed=\(processedCount) failed=\(failedCount) total_checked=\(allNotifications.count)")
return RolloverRecoveryResult(
processedCount: processedCount,
failedCount: failedCount,
totalChecked: allNotifications.count
)
}
/**
* Format time for logging
*
@@ -1132,6 +1365,15 @@ struct VerificationResult {
let missingIds: [String]
}
/**
* Rollover recovery result
*/
struct RolloverRecoveryResult {
let processedCount: Int
let failedCount: Int
let totalChecked: Int
}
/**
* Reactivation errors
*/

View File

@@ -389,6 +389,10 @@ class DailyNotificationScheduler {
*
* @param currentScheduledTime Current scheduled time in milliseconds
* @return Next scheduled time in milliseconds (24 hours later)
*
* TESTING: To test with shorter intervals (e.g., 2 minutes), change:
* - Line ~404: `.hour, value: 24` `.minute, value: 2`
* - Line ~407: `(24 * 60 * 60 * 1000)` `(2 * 60 * 1000)`
*/
func calculateNextScheduledTime(_ currentScheduledTime: Int64) -> Int64 {
let calendar = Calendar.current
@@ -396,8 +400,10 @@ class DailyNotificationScheduler {
let currentTimeStr = formatTime(currentScheduledTime)
// Add 24 hours (handles DST transitions automatically)
// TESTING: Change `.hour, value: 24` to `.minute, value: 2` for 2-minute testing
guard let nextDate = calendar.date(byAdding: .hour, value: 24, to: currentDate) else {
// Fallback to simple 24-hour addition if calendar calculation fails
// TESTING: Change `(24 * 60 * 60 * 1000)` to `(2 * 60 * 1000)` for 2-minute testing
let fallbackTime = currentScheduledTime + (24 * 60 * 60 * 1000)
let fallbackTimeStr = formatTime(fallbackTime)
NSLog("DNP-ROLLOVER: DST_CALC_FAILED current=\(currentTimeStr) using_fallback=\(fallbackTimeStr)")
@@ -456,6 +462,7 @@ class DailyNotificationScheduler {
let lastRolloverTime = await storage.getLastRolloverTime(for: content.id)
// If rollover was processed recently (< 1 hour ago), skip
// TESTING: Change `(60 * 60 * 1000)` to `(60 * 1000)` for 1-minute threshold when testing with 2-minute intervals
if let lastTime = lastRolloverTime,
(currentTime - lastTime) < (60 * 60 * 1000) {
let lastTimeStr = formatTime(lastTime)
@@ -467,7 +474,17 @@ class DailyNotificationScheduler {
}
// Calculate next occurrence using DST-safe calculation
let nextScheduledTime = calculateNextScheduledTime(content.scheduledTime)
var nextScheduledTime = calculateNextScheduledTime(content.scheduledTime)
// If next scheduled time is in the past, keep calculating forward until we get a future time
// This handles cases where the notification fired more than 2 minutes ago
while nextScheduledTime < currentTime {
let nextTimeStr = formatTime(nextScheduledTime)
NSLog("DNP-ROLLOVER: NEXT_IN_PAST id=\(content.id) next_time=\(nextTimeStr) current_time=\(currentTimeStr), calculating forward")
print("DNP-ROLLOVER: NEXT_IN_PAST id=\(content.id) next_time=\(nextTimeStr) current_time=\(currentTimeStr), calculating forward")
nextScheduledTime = calculateNextScheduledTime(nextScheduledTime)
}
let nextScheduledTimeStr = formatTime(nextScheduledTime)
let hoursUntilNext = Double(nextScheduledTime - currentTime) / 1000.0 / 60.0 / 60.0
@@ -538,6 +555,12 @@ class DailyNotificationScheduler {
let scheduled = await scheduleNotification(nextContent)
if scheduled {
// Save notification content to storage so it can be retrieved when rollover fires
// This is critical: without saving, processRollover won't find the content
storage?.saveNotificationContent(nextContent)
NSLog("DNP-ROLLOVER: SAVED id=\(content.id) next_id=\(nextId) content saved to storage")
print("DNP-ROLLOVER: SAVED id=\(content.id) next_id=\(nextId) content saved to storage")
// Verify the notification was actually scheduled
let pendingCount = await getPendingNotificationCount()
let isScheduled = await isNotificationScheduled(id: nextId)

View File

@@ -1,29 +0,0 @@
PODS:
- Capacitor (6.2.1):
- CapacitorCordova
- CapacitorCordova (6.2.1)
- DailyNotificationPlugin (1.0.0):
- Capacitor (>= 5.0.0)
- CapacitorCordova (>= 5.0.0)
DEPENDENCIES:
- "Capacitor (from `../node_modules/@capacitor/ios`)"
- "CapacitorCordova (from `../node_modules/@capacitor/ios`)"
- DailyNotificationPlugin (from `.`)
EXTERNAL SOURCES:
Capacitor:
:path: "../node_modules/@capacitor/ios"
CapacitorCordova:
:path: "../node_modules/@capacitor/ios"
DailyNotificationPlugin:
:path: "."
SPEC CHECKSUMS:
Capacitor: c95400d761e376be9da6be5a05f226c0e865cebf
CapacitorCordova: 8d93e14982f440181be7304aa9559ca631d77fff
DailyNotificationPlugin: bb72fde9eab3704a4e70af3c868a789da0650ddc
PODFILE CHECKSUM: ac8c229d24347f6f83e67e6b95458e0b81e68f7c
COCOAPODS: 1.16.2

View File

@@ -25,22 +25,103 @@ log_error() {
# Validation functions
check_command() {
if ! command -v $1 &> /dev/null; then
# Try rbenv shims for pod command
if [ "$1" = "pod" ] && [ -f "$HOME/.rbenv/shims/pod" ]; then
log_info "Found pod in rbenv shims"
return 0
fi
log_error "$1 is not installed. Please install it first."
exit 1
fi
}
check_environment() {
# Check for required tools
check_command "node"
check_command "npm"
check_command "java"
# Check for Gradle Wrapper instead of system gradle
if [ ! -f "android/gradlew" ]; then
log_error "Gradle wrapper not found at android/gradlew"
# Get pod command (handles rbenv)
get_pod_command() {
if command -v pod &> /dev/null; then
echo "pod"
elif [ -f "$HOME/.rbenv/shims/pod" ]; then
echo "$HOME/.rbenv/shims/pod"
else
log_error "CocoaPods (pod) not found. Please install CocoaPods first."
exit 1
fi
}
check_sqlite_conflicts() {
local PLATFORM=$1
if [ "$PLATFORM" != "ios" ] && [ "$PLATFORM" != "all" ]; then
return 0
fi
# Check for pkgx SQLite that might interfere with iOS builds
if [ -d "$HOME/.pkgx" ]; then
# Look for pkgx SQLite installations
PKGX_SQLITE=$(find "$HOME/.pkgx" -name "libsqlite3*.dylib" 2>/dev/null | head -1)
if [ -n "$PKGX_SQLITE" ]; then
log_warn "⚠️ pkgx SQLite detected: $PKGX_SQLITE"
log_warn " This may cause iOS build failures (linking macOS dylib for iOS simulator)"
log_warn " The build script will unset problematic environment variables"
# Check if library paths point to pkgx
if [ -n "$DYLD_LIBRARY_PATH" ] && echo "$DYLD_LIBRARY_PATH" | grep -q "\.pkgx"; then
log_warn " DYLD_LIBRARY_PATH contains pkgx paths - will be unset for build"
fi
if [ -n "$LD_LIBRARY_PATH" ] && echo "$LD_LIBRARY_PATH" | grep -q "\.pkgx"; then
log_warn " LD_LIBRARY_PATH contains pkgx paths - will be unset for build"
fi
fi
fi
# Check for system SQLite (from Command Line Tools)
if command -v sqlite3 &> /dev/null; then
SQLITE_PATH=$(which sqlite3)
if [ "$SQLITE_PATH" = "/usr/bin/sqlite3" ]; then
log_info "✅ System SQLite found (from Command Line Tools): $SQLITE_PATH"
else
log_warn "⚠️ SQLite found at non-standard location: $SQLITE_PATH"
log_warn " Ensure this is compatible with iOS builds"
fi
else
log_warn "⚠️ sqlite3 command not found - Command Line Tools may not be installed"
log_warn " Install with: xcode-select --install"
fi
}
check_command_line_tools() {
if [ "$(uname)" != "Darwin" ]; then
return 0
fi
# Check if xcode-select points to a valid developer directory
if ! xcode-select -p &> /dev/null; then
log_error "Xcode Command Line Tools not configured"
log_error " Run: xcode-select --install"
exit 1
fi
# Check if xcodebuild is available
if ! command -v xcodebuild &> /dev/null; then
log_error "xcodebuild not found - Command Line Tools may be incomplete"
log_error " Run: xcode-select --install"
exit 1
fi
# Verify sqlite3 is available (part of Command Line Tools)
if ! command -v sqlite3 &> /dev/null; then
log_warn "⚠️ sqlite3 not found - Command Line Tools may be incomplete"
log_warn " This may cause iOS build issues"
log_warn " Run: xcode-select --install"
fi
}
check_environment() {
local PLATFORM=$1
# Check for required tools (always needed)
check_command "node"
check_command "npm"
# Check Node.js version
NODE_VERSION=$(node -v | cut -d. -f1 | tr -d 'v')
@@ -49,6 +130,16 @@ check_environment() {
exit 1
fi
# Platform-specific checks
if [ "$PLATFORM" = "android" ] || [ "$PLATFORM" = "all" ]; then
check_command "java"
# Check for Gradle Wrapper instead of system gradle
if [ ! -f "android/gradlew" ]; then
log_error "Gradle wrapper not found at android/gradlew"
exit 1
fi
# Check Java version
JAVA_VERSION=$(java -version 2>&1 | head -n 1 | cut -d'"' -f2 | cut -d. -f1)
if [ "$JAVA_VERSION" -lt 11 ]; then
@@ -61,6 +152,19 @@ check_environment() {
log_error "ANDROID_HOME environment variable is not set"
exit 1
fi
fi
if [ "$PLATFORM" = "ios" ] || [ "$PLATFORM" = "all" ]; then
# Check Command Line Tools
check_command_line_tools
# Check for SQLite conflicts
check_sqlite_conflicts "$PLATFORM"
# iOS-specific checks are done in build_ios() function
# to avoid failing if iOS tools aren't available when building Android only
:
fi
}
# Build functions
@@ -76,30 +180,8 @@ build_typescript() {
build_plugin_for_test_app() {
log_info "Building plugin for test app..."
# Build the plugin AAR
log_info "Building plugin AAR..."
cd android || exit 1
if ! ./gradlew :plugin:clean :plugin:assembleDebug; then
log_error "Plugin build failed"
exit 1
fi
AAR_FILE="plugin/build/outputs/aar/plugin-debug.aar"
if [ ! -f "$AAR_FILE" ]; then
log_error "AAR file not found at $AAR_FILE"
exit 1
fi
log_info "Plugin AAR built: $AAR_FILE"
# Remove any stale AAR from test app's libs directory
if [ -f "../test-apps/daily-notification-test/android/app/libs/plugin-debug.aar" ]; then
log_info "Removing stale AAR from test app libs..."
rm "../test-apps/daily-notification-test/android/app/libs/plugin-debug.aar"
fi
cd ..
# Ensure symlink is in place
# Ensure symlink is in place FIRST (before building)
# The test app expects the plugin at node_modules/@timesafari/daily-notification-plugin
SYMLINK="test-apps/daily-notification-test/node_modules/@timesafari/daily-notification-plugin"
if [ ! -L "$SYMLINK" ] || [ ! -e "$SYMLINK" ]; then
log_info "Creating symlink to plugin..."
@@ -108,27 +190,213 @@ build_plugin_for_test_app() {
ln -sf "../../../" "$SYMLINK"
fi
# Build test app
log_info "Building test app..."
cd test-apps/daily-notification-test/android || exit 1
# Navigate to test app directory
cd test-apps/daily-notification-test || exit 1
if ! ./gradlew clean assembleDebug; then
log_error "Test app build failed"
# Sync Capacitor Android (creates necessary project structure)
log_info "Syncing Capacitor Android..."
if ! npm run cap:sync:android; then
log_error "Capacitor Android sync failed"
exit 1
fi
# Navigate to android directory
cd android || exit 1
# Fix capacitor.build.gradle if it references missing cordova.variables.gradle
# This file is auto-generated by Capacitor and may reference files that don't exist
if [ -f "app/capacitor.build.gradle" ]; then
if grep -q "^apply from: \"../capacitor-cordova-android-plugins/cordova.variables.gradle\"" "app/capacitor.build.gradle"; then
# Check if the referenced file actually exists
if [ ! -f "capacitor-cordova-android-plugins/cordova.variables.gradle" ]; then
log_info "🔧 Applying fix to capacitor.build.gradle..."
log_info " Reason: Referenced cordova.variables.gradle doesn't exist"
log_info " Fix: Commenting out problematic 'apply from' line"
# Apply the fix by commenting out the problematic line
# Handle macOS vs Linux sed differences
if [[ "$OSTYPE" == "darwin"* ]]; then
# macOS sed requires empty string after -i
# Use a simpler approach: just comment out the line
sed -i '' 's|^apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"|// &|' "app/capacitor.build.gradle"
else
# Linux sed
sed -i 's|^apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"|// &|' "app/capacitor.build.gradle"
fi
log_info "✅ Fix applied successfully"
log_warn "⚠️ Note: This fix will be lost if you run 'npx cap sync' or 'npx cap update'"
fi
fi
fi
# Build the plugin and test app from within the test app's Android project context
# This is necessary because Capacitor Android is only available as a project dependency
# The plugin will be built automatically as a dependency of the app
log_info "Building plugin and test app from test app context..."
# Build test app (this will automatically build the plugin as a dependency)
if ! ./gradlew clean assembleDebug; then
log_error "Build failed"
exit 1
fi
# Verify plugin was built (optional check)
PLUGIN_AAR="$(find . -name "*.aar" -path "*/timesafari-daily-notification-plugin/*" 2>/dev/null | head -1)"
if [ -n "$PLUGIN_AAR" ]; then
log_info "Plugin AAR built: $PLUGIN_AAR"
fi
# Verify APK was built
APK_FILE="app/build/outputs/apk/debug/app-debug.apk"
if [ ! -f "$APK_FILE" ]; then
log_error "APK file not found at $APK_FILE"
exit 1
fi
log_info "Test app build successful: $APK_FILE"
log_info "Build successful: $APK_FILE"
log_info "Install with: adb install -r $APK_FILE"
cd ../../..
}
build_plugin_for_test_app_ios() {
log_info "Building iOS test app..."
# Fix pkgx interference with SQLite linking
# pkgx installs SQLite built for macOS, which causes linker errors when building for iOS simulator
if [ -n "$PKGX_DIR" ] || [ -d "$HOME/.pkgx" ]; then
log_warn "⚠️ pkgx detected - fixing SQLite linking conflicts..."
# Check for pkgx SQLite specifically
PKGX_SQLITE=$(find "$HOME/.pkgx" -name "libsqlite3*.dylib" 2>/dev/null | head -1)
if [ -n "$PKGX_SQLITE" ]; then
log_warn " Found pkgx SQLite: $PKGX_SQLITE"
log_warn " This is built for macOS and will cause iOS simulator build failures"
fi
log_warn " Temporarily unsetting PKGX_DIR and library paths for build..."
unset PKGX_DIR
unset DYLD_LIBRARY_PATH
unset LD_LIBRARY_PATH
# Also unset any pkgx-related paths that might be in PATH
export PATH=$(echo "$PATH" | tr ':' '\n' | grep -v "\.pkgx" | tr '\n' ':' | sed 's/:$//')
fi
# Ensure symlink is in place FIRST (before building)
# The test app expects the plugin at node_modules/@timesafari/daily-notification-plugin
SYMLINK="test-apps/daily-notification-test/node_modules/@timesafari/daily-notification-plugin"
if [ ! -L "$SYMLINK" ] || [ ! -e "$SYMLINK" ]; then
log_info "Creating symlink to plugin..."
mkdir -p "$(dirname "$SYMLINK")"
rm -f "$SYMLINK"
ln -sf "../../../" "$SYMLINK"
fi
# Navigate to test app directory
cd test-apps/daily-notification-test || exit 1
# Install dependencies if node_modules doesn't exist or is missing key packages
if [ ! -d "node_modules" ] || [ ! -f "node_modules/.bin/run-p" ]; then
log_info "Installing test app dependencies..."
if ! npm install; then
log_error "Failed to install test app dependencies"
exit 1
fi
fi
# Build Vue app
log_info "Building Vue app..."
if ! npm run build; then
log_error "Vue app build failed"
exit 1
fi
# Sync Capacitor iOS (handles Podfile fixes and pod install)
log_info "Syncing Capacitor iOS..."
if ! npm run cap:sync:ios; then
log_error "Capacitor iOS sync failed"
exit 1
fi
# Build with xcodebuild
cd ios/App || exit 1
# Find workspace
if [ -d "App.xcworkspace" ]; then
WORKSPACE="App.xcworkspace"
SCHEME="App"
elif [ -d "App.xcodeproj" ]; then
PROJECT="App.xcodeproj"
SCHEME="App"
else
log_error "No Xcode workspace or project found in ios/App"
log_info "Expected: App.xcworkspace or App.xcodeproj"
exit 1
fi
# Auto-detect available iPhone simulator
log_info "Detecting available iPhone simulator..."
SIMULATOR_LINE=$(xcrun simctl list devices available 2>&1 | grep -i "iPhone" | head -1)
if [ -n "$SIMULATOR_LINE" ]; then
# Extract device ID (UUID in parentheses)
SIMULATOR_ID=$(echo "$SIMULATOR_LINE" | sed -E 's/.*\(([A-F0-9-]+)\).*/\1/')
# Extract device name (everything before the first parenthesis)
SIMULATOR_NAME=$(echo "$SIMULATOR_LINE" | sed -E 's/^[[:space:]]*([^(]+).*/\1/' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
if [ -n "$SIMULATOR_ID" ] && [ "$SIMULATOR_ID" != "Shutdown" ] && [ "$SIMULATOR_ID" != "Booted" ]; then
DESTINATION="platform=iOS Simulator,id=$SIMULATOR_ID"
log_info "Building for iOS Simulator ($SIMULATOR_NAME, ID: $SIMULATOR_ID)..."
elif [ -n "$SIMULATOR_NAME" ]; then
DESTINATION="platform=iOS Simulator,name=$SIMULATOR_NAME"
log_info "Building for iOS Simulator ($SIMULATOR_NAME)..."
else
DESTINATION="platform=iOS Simulator,name=iPhone 14"
log_warn "Using default simulator destination: iPhone 14"
fi
else
DESTINATION="platform=iOS Simulator,name=iPhone 14"
log_warn "No iPhone simulators found, using default: iPhone 14"
fi
# Build for simulator
log_info "Building iOS app for simulator..."
if [ -n "$WORKSPACE" ]; then
if ! xcodebuild build \
-workspace "$WORKSPACE" \
-scheme "$SCHEME" \
-configuration Debug \
-sdk iphonesimulator \
-destination "$DESTINATION" \
CODE_SIGN_IDENTITY="" \
CODE_SIGNING_REQUIRED=NO \
CODE_SIGNING_ALLOWED=NO; then
log_error "iOS build failed"
exit 1
fi
else
if ! xcodebuild build \
-project "$PROJECT" \
-scheme "$SCHEME" \
-configuration Debug \
-sdk iphonesimulator \
-destination "$DESTINATION" \
CODE_SIGN_IDENTITY="" \
CODE_SIGNING_REQUIRED=NO \
CODE_SIGNING_ALLOWED=NO; then
log_error "iOS build failed"
exit 1
fi
fi
log_info "iOS build successful!"
log_info "App built in DerivedData. To run: xcrun simctl boot <simulator-id> && xcrun simctl install booted <app-path>"
cd ../../..
}
build_android() {
log_info "Building Android..."
@@ -237,6 +505,75 @@ build_android() {
cd ..
}
build_ios() {
log_info "Building iOS..."
# Check iOS-specific requirements
check_command "xcodebuild"
check_command "pod"
# Check for Xcode
if ! xcodebuild -version &> /dev/null; then
log_error "Xcode is not installed or not properly configured"
exit 1
fi
# Check for SQLite conflicts (already done in check_environment, but remind here)
if [ -d "$HOME/.pkgx" ]; then
PKGX_SQLITE=$(find "$HOME/.pkgx" -name "libsqlite3*.dylib" 2>/dev/null | head -1)
if [ -n "$PKGX_SQLITE" ]; then
log_warn "⚠️ pkgx SQLite detected - build script will handle this automatically"
log_warn " If you still see linker errors, manually run:"
log_warn " unset PKGX_DIR DYLD_LIBRARY_PATH LD_LIBRARY_PATH && ./scripts/build-native.sh --platform ios"
fi
fi
# Detect project type
if [ -d "test-apps/daily-notification-test" ]; then
log_info "Detected test app. Building plugin and test app together..."
build_plugin_for_test_app_ios
return 0
fi
# For plugin-only builds, navigate to iOS directory
cd ios || exit 1
log_warn "This appears to be a plugin development project"
log_warn "iOS plugin source code has been built successfully"
log_warn "To test the plugin, use it in a Capacitor app instead"
# Install CocoaPods dependencies if Podfile exists
if [ -f "Podfile" ]; then
log_info "Installing CocoaPods dependencies..."
POD_CMD=$(get_pod_command)
if ! $POD_CMD install; then
log_error "CocoaPods installation failed"
exit 1
fi
fi
# Build plugin framework (if workspace exists)
if [ -d "DailyNotificationPlugin.xcworkspace" ]; then
log_info "Building iOS plugin framework..."
if ! xcodebuild build \
-workspace DailyNotificationPlugin.xcworkspace \
-scheme DailyNotificationPlugin \
-configuration Release \
-sdk iphoneos \
CODE_SIGN_IDENTITY="" \
CODE_SIGNING_REQUIRED=NO \
CODE_SIGNING_ALLOWED=NO; then
log_error "iOS plugin build failed"
exit 1
fi
log_info "iOS plugin build successful"
else
log_warn "No Xcode workspace found - plugin may need to be built in a host app"
fi
cd ..
}
# Main build process
main() {
log_info "Starting build process..."
@@ -256,22 +593,26 @@ main() {
esac
done
# Check environment
check_environment
# Build TypeScript
# Build TypeScript (needed for all platforms)
build_typescript
# Check environment (after parsing platform so we can check conditionally)
check_environment "$BUILD_PLATFORM"
# Build based on platform
case $BUILD_PLATFORM in
"android")
build_android
;;
"ios")
build_ios
;;
"all")
build_android
build_ios
;;
*)
log_error "Invalid platform: $BUILD_PLATFORM. Use 'android' or 'all'"
log_error "Invalid platform: $BUILD_PLATFORM. Use 'android', 'ios', or 'all'"
exit 1
;;
esac

271
scripts/clean-build.sh Executable file
View File

@@ -0,0 +1,271 @@
#!/bin/bash
# Exit on error
set -e
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Logging functions
log_info() {
echo -e "${GREEN}[INFO]${NC} $1"
}
log_warn() {
echo -e "${YELLOW}[WARN]${NC} $1"
}
log_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
log_step() {
echo -e "${BLUE}[STEP]${NC} $1"
}
# Parse command line arguments
REINSTALL_NODE_MODULES=false
CLEAN_GRADLE_CACHE=false
CLEAN_DERIVED_DATA=false
while [[ $# -gt 0 ]]; do
case $1 in
--reinstall-node)
REINSTALL_NODE_MODULES=true
shift
;;
--clean-gradle-cache)
CLEAN_GRADLE_CACHE=true
shift
;;
--clean-derived-data)
CLEAN_DERIVED_DATA=true
shift
;;
--all)
REINSTALL_NODE_MODULES=true
CLEAN_GRADLE_CACHE=true
CLEAN_DERIVED_DATA=true
shift
;;
*)
log_error "Unknown option: $1"
echo "Usage: $0 [--reinstall-node] [--clean-gradle-cache] [--clean-derived-data] [--all]"
exit 1
;;
esac
done
log_info "Starting clean build process..."
log_warn "This will remove all build artifacts and caches"
# Step 1: Clean TypeScript build output
log_step "1. Cleaning TypeScript build output..."
npm run clean 2>/dev/null || log_warn "npm run clean failed (may not exist)"
rm -rf dist/
log_info "✓ TypeScript build output cleaned"
# Step 2: Clean iOS plugin build artifacts
log_step "2. Cleaning iOS plugin build artifacts..."
if [ -d "ios" ]; then
cd ios
# Remove Pods and Podfile.lock
if [ -d "Pods" ]; then
rm -rf Pods/
log_info " ✓ Removed Pods/"
fi
if [ -f "Podfile.lock" ]; then
rm -f Podfile.lock
log_info " ✓ Removed Podfile.lock"
fi
# Remove Xcode build artifacts
if [ -d "DailyNotificationPlugin.xcworkspace/xcuserdata" ]; then
rm -rf DailyNotificationPlugin.xcworkspace/xcuserdata/
log_info " ✓ Removed workspace user data"
fi
if [ -d "DailyNotificationPlugin.xcodeproj/xcuserdata" ]; then
rm -rf DailyNotificationPlugin.xcodeproj/xcuserdata/
log_info " ✓ Removed project user data"
fi
# Remove build directories
find . -type d -name "build" -exec rm -rf {} + 2>/dev/null || true
find . -type d -name "DerivedData" -exec rm -rf {} + 2>/dev/null || true
cd ..
log_info "✓ iOS plugin cleaned"
fi
# Step 3: Clean Android plugin build artifacts
log_step "3. Cleaning Android plugin build artifacts..."
if [ -d "android" ]; then
cd android
# Remove build directories
if [ -d "build" ]; then
rm -rf build/
log_info " ✓ Removed build/"
fi
if [ -d "app/build" ]; then
rm -rf app/build/
log_info " ✓ Removed app/build/"
fi
if [ -d ".gradle" ]; then
if [ "$CLEAN_GRADLE_CACHE" = true ]; then
rm -rf .gradle/
log_info " ✓ Removed .gradle/ (cache cleaned)"
else
log_info " .gradle/ preserved (use --clean-gradle-cache to remove)"
fi
fi
cd ..
log_info "✓ Android plugin cleaned"
fi
# Step 4: Clean test app build artifacts
log_step "4. Cleaning test app build artifacts..."
if [ -d "test-apps/daily-notification-test" ]; then
cd test-apps/daily-notification-test
# Remove symlink
if [ -L "node_modules/@timesafari/daily-notification-plugin" ]; then
rm -f node_modules/@timesafari/daily-notification-plugin
log_info " ✓ Removed plugin symlink"
fi
# Remove node_modules (if requested)
if [ "$REINSTALL_NODE_MODULES" = true ]; then
if [ -d "node_modules" ]; then
# Use find to handle permission issues on macOS
find node_modules -delete 2>/dev/null || rm -rf node_modules/ 2>/dev/null || {
log_warn " ⚠ Some files in node_modules/ could not be deleted (permission issues)"
log_warn " You may need to manually remove node_modules/ or use sudo"
}
log_info " ✓ Removed node_modules/ (will reinstall)"
fi
fi
# Remove build output
if [ -d "dist" ]; then
rm -rf dist/
log_info " ✓ Removed dist/"
fi
# Clean iOS test app
if [ -d "ios" ]; then
cd ios/App
# Remove Pods and Podfile.lock
if [ -d "Pods" ]; then
rm -rf Pods/
log_info " ✓ Removed iOS test app Pods/"
fi
if [ -f "Podfile.lock" ]; then
rm -f Podfile.lock
log_info " ✓ Removed iOS test app Podfile.lock"
fi
# Remove Xcode build artifacts
if [ -d "App.xcworkspace/xcuserdata" ]; then
rm -rf App.xcworkspace/xcuserdata/
fi
if [ -d "App.xcodeproj/xcuserdata" ]; then
rm -rf App.xcodeproj/xcuserdata/
fi
# Remove build directories
find . -type d -name "build" -exec rm -rf {} + 2>/dev/null || true
cd ../..
fi
# Clean Android test app
if [ -d "android" ]; then
cd android
# Remove build directories
if [ -d "build" ]; then
rm -rf build/
log_info " ✓ Removed Android test app build/"
fi
if [ -d "app/build" ]; then
rm -rf app/build/
log_info " ✓ Removed Android test app app/build/"
fi
if [ -d "capacitor-cordova-android-plugins/build" ]; then
rm -rf capacitor-cordova-android-plugins/build/
log_info " ✓ Removed Android test app plugin build/"
fi
if [ -d ".gradle" ] && [ "$CLEAN_GRADLE_CACHE" = true ]; then
rm -rf .gradle/
log_info " ✓ Removed Android test app .gradle/ (cache cleaned)"
fi
cd ..
fi
cd ../..
log_info "✓ Test app cleaned"
fi
# Step 5: Clean Xcode DerivedData (if requested)
if [ "$CLEAN_DERIVED_DATA" = true ]; then
log_step "5. Cleaning Xcode DerivedData..."
DERIVED_DATA_DIR="$HOME/Library/Developer/Xcode/DerivedData"
if [ -d "$DERIVED_DATA_DIR" ]; then
# Find and remove project-specific DerivedData
find "$DERIVED_DATA_DIR" -maxdepth 1 -type d \( -name "*DailyNotification*" -o -name "*daily-notification*" \) -exec rm -rf {} + 2>/dev/null || true
log_info "✓ Xcode DerivedData cleaned (project-specific)"
log_warn " To clean all DerivedData, manually delete: $DERIVED_DATA_DIR"
else
log_warn " DerivedData directory not found: $DERIVED_DATA_DIR"
fi
fi
# Step 6: Reinstall node_modules (if requested)
if [ "$REINSTALL_NODE_MODULES" = true ]; then
log_step "6. Reinstalling node_modules..."
# Root node_modules
if [ -d "node_modules" ]; then
find node_modules -delete 2>/dev/null || rm -rf node_modules/ 2>/dev/null || {
log_warn " ⚠ Some files in root node_modules/ could not be deleted (permission issues)"
log_warn " You may need to manually remove node_modules/ or use sudo"
}
fi
log_info " Installing root dependencies..."
npm install
# Test app node_modules
if [ -d "test-apps/daily-notification-test" ]; then
cd test-apps/daily-notification-test
log_info " Installing test app dependencies..."
npm install
cd ../..
fi
log_info "✓ Dependencies reinstalled"
fi
log_info ""
log_info "════════════════════════════════════════════════════════════"
log_info "Clean build complete! 🎉"
log_info "════════════════════════════════════════════════════════════"
log_info ""
log_info "Next steps:"
log_info " 1. Run: ./scripts/build-native.sh --platform all"
log_info " 2. Or run: ./scripts/build-native.sh --platform ios"
log_info " 3. Or run: ./scripts/build-native.sh --platform android"
log_info ""
log_info "Options used:"
[ "$REINSTALL_NODE_MODULES" = true ] && log_info " ✓ Reinstalled node_modules"
[ "$CLEAN_GRADLE_CACHE" = true ] && log_info " ✓ Cleaned Gradle cache"
[ "$CLEAN_DERIVED_DATA" = true ] && log_info " ✓ Cleaned Xcode DerivedData"
log_info ""

View File

@@ -4,6 +4,8 @@
Both test apps are configured to **automatically build the plugin** as part of their build process. The plugin is included as a Gradle project dependency, so Gradle handles building it automatically.
Note that Test App 1 `android-test-app` is used most frequently.
---
## Test App 1: `android-test-app` (Standalone Android)
@@ -59,7 +61,7 @@ avdmanager list avd
# Run one
emulator -avd AVD_NAME
# Check that one is running
# Simply see that one is running
adb devices
# Now install on the emulator

View File

@@ -182,32 +182,52 @@
}
function loadPluginStatus() {
console.log('loadPluginStatus called');
console.log('[UI Refresh] loadPluginStatus called');
const pluginStatusContent = document.getElementById('pluginStatusContent');
const statusCard = document.getElementById('statusCard');
try {
if (!window.DailyNotification) {
console.warn('[UI Refresh] DailyNotification plugin not available');
pluginStatusContent.innerHTML = '❌ DailyNotification plugin not available';
statusCard.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background
return;
}
console.log('[UI Refresh] Calling getNotificationStatus()...');
window.DailyNotification.getNotificationStatus()
.then(result => {
console.log('[UI Refresh] getNotificationStatus result:', {
nextNotificationTime: result.nextNotificationTime,
isEnabled: result.isEnabled,
pending: result.pending,
lastNotificationTime: result.lastNotificationTime
});
const nextTime = formatDateTimeNormalized(result.nextNotificationTime);
const hasSchedules = result.isEnabled || (result.pending && result.pending > 0);
const statusIcon = hasSchedules ? '✅' : '⏸️';
console.log('[UI Refresh] Updating UI:', {
nextTime: nextTime,
hasSchedules: hasSchedules,
statusIcon: statusIcon
});
pluginStatusContent.innerHTML = `${statusIcon} Active Schedules: ${hasSchedules ? 'Yes' : 'No'}<br>
📅 Next Notification: ${nextTime}<br>
⏳ Pending: ${result.pending || 0}`;
statusCard.style.background = hasSchedules ?
'rgba(0, 255, 0, 0.15)' : 'rgba(255, 255, 255, 0.1)'; // Green if active, light gray if none
console.log('[UI Refresh] UI updated successfully');
})
.catch(error => {
console.error('[UI Refresh] getNotificationStatus failed:', error);
pluginStatusContent.innerHTML = `⚠️ Status check failed: ${error.message}`;
statusCard.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background
});
} catch (error) {
console.error('[UI Refresh] loadPluginStatus exception:', error);
pluginStatusContent.innerHTML = `⚠️ Status check failed: ${error.message}`;
statusCard.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background
}
@@ -444,18 +464,36 @@
// Check for notification delivery and status updates periodically
function checkNotificationDelivery() {
if (!window.DailyNotification) return;
if (!window.DailyNotification) {
console.log('[Poll] DailyNotification not available, skipping check');
return;
}
console.log('[Poll] checkNotificationDelivery called');
window.DailyNotification.getNotificationStatus()
.then(result => {
console.log('[Poll] Status check result:', {
nextNotificationTime: result.nextNotificationTime,
lastNotificationTime: result.lastNotificationTime,
lastKnownNextNotificationTime: lastKnownNextNotificationTime
});
// Check for notification delivery
if (result.lastNotificationTime) {
const lastTime = new Date(result.lastNotificationTime);
const now = new Date();
const timeDiff = now - lastTime;
console.log('[Poll] Notification delivery check:', {
lastTime: lastTime.toISOString(),
now: now.toISOString(),
timeDiff: timeDiff,
withinWindow: timeDiff > 0 && timeDiff < 120000
});
// If notification was received in the last 2 minutes, show indicator
if (timeDiff > 0 && timeDiff < 120000) {
console.log('[Poll] Notification received recently, showing indicator');
const indicator = document.getElementById('notificationReceivedIndicator');
const timeSpan = document.getElementById('notificationReceivedTime');
@@ -471,7 +509,9 @@
}, 30000);
// Force immediate refresh when notification is received (rollover may have occurred)
console.log('[Poll] Scheduling UI refresh in 1 second (waiting for rollover)...');
setTimeout(() => {
console.log('[Poll] Triggering UI refresh after notification received');
loadPluginStatus();
}, 1000); // Wait 1 second for rollover to complete
}
@@ -480,20 +520,37 @@
// Detect if nextNotificationTime changed (rollover occurred)
const currentNextTime = result.nextNotificationTime;
console.log('[Poll] Comparing nextNotificationTime:', {
current: currentNextTime,
lastKnown: lastKnownNextNotificationTime,
changed: currentNextTime && currentNextTime !== lastKnownNextNotificationTime
});
if (currentNextTime && currentNextTime !== lastKnownNextNotificationTime) {
if (lastKnownNextNotificationTime !== null) {
console.log('Next notification time changed - rollover detected!');
console.log('[Poll] ⚠️ Next notification time changed - rollover detected!', {
old: lastKnownNextNotificationTime,
new: currentNextTime,
oldDate: new Date(lastKnownNextNotificationTime).toISOString(),
newDate: new Date(currentNextTime).toISOString()
});
// Force immediate refresh
loadPluginStatus();
} else {
console.log('[Poll] Initializing lastKnownNextNotificationTime:', currentNextTime);
}
lastKnownNextNotificationTime = currentNextTime;
} else {
console.log('[Poll] No change detected in nextNotificationTime');
}
// Auto-refresh plugin status periodically to show updated next notification time after rollover
// This ensures the UI updates when the plugin reschedules the notification
console.log('[Poll] Triggering periodic UI refresh');
loadPluginStatus();
})
.catch(error => {
console.error('[Poll] Status check failed:', error);
// Silently fail - this is just for visual feedback
});
}

View File

@@ -799,7 +799,8 @@ main() {
# Capture post-rollover state
capture_alarms "test0_after_rollover"
# Look for rollover logs: DN|RESCHEDULE, DN|DISPLAY, DN|RECEIVE, ROLLOVER_ON_FIRE, etc.
capture_logcat "test0_after_rollover" "DN|RESCHEDULE|DN|DISPLAY|DN|RECEIVE|ROLLOVER|DNP-SCHEDULE" 200
# Also capture JavaScript console logs (Capacitor/Console) for UI debugging
capture_logcat "test0_after_rollover" "DN|RESCHEDULE|DN|DISPLAY|DN|RECEIVE|ROLLOVER|DNP-SCHEDULE|Capacitor/Console" 200
capture_screenshot "test0_after_rollover"
step_pass "p1_t0_s4" "Post-rollover evidence captured"

View File

@@ -9,7 +9,32 @@
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx1536m
# Java 17+ requires --add-opens flags for KAPT to access internal compiler classes
org.gradle.jvmargs=-Xmx1536m \
--add-opens=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED \
--add-opens=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED \
--add-opens=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED \
--add-opens=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED \
--add-opens=jdk.compiler/com.sun.tools.javac.jvm=ALL-UNNAMED \
--add-opens=jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED \
--add-opens=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED \
--add-opens=jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED \
--add-opens=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED \
--add-opens=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED
# Kotlin compiler daemon JVM arguments (required for KAPT with Java 17+)
# The Kotlin daemon runs separately and needs the same --add-opens flags
kotlin.daemon.jvmargs=-Xmx1536m \
--add-opens=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED \
--add-opens=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED \
--add-opens=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED \
--add-opens=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED \
--add-opens=jdk.compiler/com.sun.tools.javac.jvm=ALL-UNNAMED \
--add-opens=jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED \
--add-opens=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED \
--add-opens=jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED \
--add-opens=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED \
--add-opens=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit

View File

@@ -1,13 +1,13 @@
<!DOCTYPE html>
<html lang="">
<html lang="en" style="margin: 0; padding: 0;">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vite App</title>
</head>
<body>
<div id="app"></div>
<body style="margin: 0; padding: 0;">
<div id="app" style="margin: 0; padding: 0;"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View File

@@ -1,12 +1,52 @@
import UIKit
import Capacitor
import BackgroundTasks
import DailyNotificationPlugin
import ObjectiveC
import UserNotifications
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterDelegate {
var window: UIWindow?
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
NSLog("DNP-DEBUG: AppDelegate.application(_:didFinishLaunchingWithOptions:) called")
// CRITICAL: Force-load the plugin framework before Capacitor initializes
// objc_getClassList may not include classes from frameworks that haven't been loaded yet
// Even though NSClassFromString can find the class, Capacitor's discovery uses objc_getClassList
// which only includes loaded classes. We need to ensure the framework is loaded.
NSLog("DNP-DEBUG: Force-loading DailyNotificationPlugin framework...")
_ = DailyNotificationPlugin.self // Force class load
NSLog("DNP-DEBUG: DailyNotificationPlugin class reference created - framework should be loaded")
// Verify class is now in objc_getClassList
let classCount = objc_getClassList(nil, 0)
let classes = UnsafeMutablePointer<AnyClass?>.allocate(capacity: Int(classCount))
defer { classes.deallocate() }
let releasingClasses = AutoreleasingUnsafeMutablePointer<AnyClass>(classes)
let numClasses = objc_getClassList(releasingClasses, Int32(classCount))
var foundInClassList = false
for i in 0..<Int(numClasses) {
if let aClass = classes[i] {
let className = NSStringFromClass(aClass)
if className == "DailyNotificationPlugin" {
foundInClassList = true
NSLog("DNP-DEBUG: ✅ DailyNotificationPlugin found in objc_getClassList")
break
}
}
}
if !foundInClassList {
NSLog("DNP-DEBUG: ❌ DailyNotificationPlugin NOT found in objc_getClassList (this is the problem!)")
}
// Set notification center delegate to show notifications in foreground
UNUserNotificationCenter.current().delegate = self
NSLog("DNP-DEBUG: UNUserNotificationCenter delegate set to AppDelegate")
// Override point for customization after application launch.
return true
}
@@ -27,6 +67,72 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
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.
// Re-set delegate when app becomes active (in case Capacitor resets it)
UNUserNotificationCenter.current().delegate = self
NSLog("DNP-DEBUG: UNUserNotificationCenter delegate re-set in applicationDidBecomeActive")
}
// MARK: - UNUserNotificationCenterDelegate
/**
* Show notifications even when app is in foreground
* This is required for test app to see notifications during testing
*/
func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
NSLog("DNP-DEBUG: ✅ userNotificationCenter willPresent called!")
NSLog("DNP-DEBUG: Notification received in foreground: %@", notification.request.identifier)
NSLog("DNP-DEBUG: Notification title: %@", notification.request.content.title)
NSLog("DNP-DEBUG: Notification body: %@", notification.request.content.body)
// Extract notification info from userInfo for rollover
let userInfo = notification.request.content.userInfo
if let notificationId = userInfo["notification_id"] as? String,
let scheduledTime = userInfo["scheduled_time"] as? Int64 {
// Format scheduled time for logging
let scheduledDate = Date(timeIntervalSince1970: Double(scheduledTime) / 1000.0)
let formatter = DateFormatter()
formatter.dateStyle = .medium
formatter.timeStyle = .short
let scheduledTimeStr = formatter.string(from: scheduledDate)
NSLog("DNP-ROLLOVER: APPDELGATE_DETECTED id=%@ scheduled_time=%@", notificationId, scheduledTimeStr)
NSLog("DNP-DEBUG: Posted rollover notification for id=%@", notificationId)
// Post notification to trigger rollover (decoupled pattern)
NotificationCenter.default.post(
name: NSNotification.Name("DailyNotificationDelivered"),
object: nil,
userInfo: [
"notification_id": notificationId,
"scheduled_time": scheduledTime
]
)
} else {
NSLog("DNP-ROLLOVER: APPDELGATE_MISSING_DATA id=%@ userInfo=%@", notification.request.identifier, userInfo)
}
// Show notification with banner, sound, and badge
// Use .banner for iOS 14+, fallback to .alert for iOS 13
if #available(iOS 14.0, *) {
completionHandler([.banner, .sound, .badge])
} else {
completionHandler([.alert, .sound, .badge])
}
NSLog("DNP-DEBUG: ✅ Completion handler called with presentation options")
}
/**
* Handle notification tap/interaction
*/
func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
NSLog("DNP-DEBUG: Notification tapped: %@", response.notification.request.identifier)
NSLog("DNP-DEBUG: Action identifier: %@", response.actionIdentifier)
// Handle notification tap if needed
completionHandler()
}
func applicationWillTerminate(_ application: UIApplication) {

View File

@@ -1,4 +1,4 @@
require_relative '../../../../node_modules/@capacitor/ios/scripts/pods_helpers'
require_relative '../../node_modules/@capacitor/ios/scripts/pods_helpers'
platform :ios, '13.0'
use_frameworks!
@@ -9,8 +9,8 @@ use_frameworks!
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 'Capacitor', :path => '../../node_modules/@capacitor/ios'
pod 'CapacitorCordova', :path => '../../node_modules/@capacitor/ios'
pod 'DailyNotificationPlugin', :path => '../../node_modules/@timesafari/daily-notification-plugin/ios'
end

View File

@@ -12,6 +12,7 @@
"@capacitor/android": "^6.2.1",
"@capacitor/cli": "^6.2.1",
"@capacitor/core": "^6.2.1",
"@capacitor/ios": "^6.2.1",
"@timesafari/daily-notification-plugin": "file:../../",
"date-fns": "^4.1.0",
"did-jwt": "^7.4.7",
@@ -117,6 +118,7 @@
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.3",
@@ -416,6 +418,7 @@
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.4.tgz",
"integrity": "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/types": "^7.28.4"
},
@@ -634,10 +637,20 @@
"resolved": "https://registry.npmjs.org/@capacitor/core/-/core-6.2.1.tgz",
"integrity": "sha512-urZwxa7hVE/BnA18oCFAdizXPse6fCKanQyEqpmz6cBJ2vObwMpyJDG5jBeoSsgocS9+Ax+9vb4ducWJn0y2qQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"tslib": "^2.1.0"
}
},
"node_modules/@capacitor/ios": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/@capacitor/ios/-/ios-6.2.1.tgz",
"integrity": "sha512-tbMlQdQjxe1wyaBvYVU1yTojKJjgluZQsJkALuJxv/6F8QTw5b6vd7X785O/O7cMpIAZfUWo/vtAHzFkRV+kXw==",
"license": "MIT",
"peerDependencies": {
"@capacitor/core": "^6.2.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.25.11",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.11.tgz",
@@ -2065,6 +2078,7 @@
"integrity": "sha512-6JSSaBZmsKvEkbRUkf7Zj7dru/8ZCrJxAqArcLaVMee5907JdtEbKGsZ7zNiIm/UAkpGUkaSMZEXShnN2D1HZA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.46.1",
"@typescript-eslint/types": "8.46.1",
@@ -2663,6 +2677,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -2911,6 +2926,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.9",
"caniuse-lite": "^1.0.30001746",
@@ -3373,6 +3389,7 @@
"integrity": "sha512-XyLmROnACWqSxiGYArdef1fItQd47weqB7iwtfr9JHwRrqIXZdcFMvvEcL9xHCmL0SNsOvF0c42lWyM1U5dgig==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -3434,6 +3451,7 @@
"integrity": "sha512-K6tP0dW8FJVZLQxa2S7LcE1lLw3X8VvB3t887Q6CLrFVxHYBXGANbXvwNzYIu6Ughx1bSJ5BDT0YB3ybPT39lw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.4.0",
"natural-compare": "^1.4.0",
@@ -4293,6 +4311,7 @@
"integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"jiti": "lib/jiti-cli.mjs"
}
@@ -5721,6 +5740,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -5798,6 +5818,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -6031,6 +6052,7 @@
"integrity": "sha512-CmuvUBzVJ/e3HGxhg6cYk88NGgTnBoOo7ogtfJJ0fefUWAxN/WDSUa50o+oVBxuIhO8FoEZW0j2eW7sfjs5EtA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",
@@ -6301,6 +6323,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -6320,6 +6343,7 @@
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.22.tgz",
"integrity": "sha512-toaZjQ3a/G/mYaLSbV+QsQhIdMo9x5rrqIpYRObsJ6T/J+RyCSFwN2LHNVH9v8uIcljDNa3QzPVdv3Y6b9hAJQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-dom": "3.5.22",
"@vue/compiler-sfc": "3.5.22",

View File

@@ -8,21 +8,21 @@
},
"scripts": {
"dev": "vite",
"build": "run-p type-check \"build-only {@}\" --",
"build": "npx run-p type-check \"build-only {@}\" --",
"preview": "vite preview",
"build-only": "vite build",
"type-check": "vue-tsc --build",
"lint": "eslint . --fix",
"cap:sync": "npx cap sync && node scripts/fix-capacitor-plugins.js",
"cap:sync:android": "npx cap sync android && node scripts/fix-capacitor-plugins.js",
"cap:sync:ios": "npx cap sync ios",
"cap:sync:ios": "npx cap copy ios && node scripts/fix-capacitor-plugins.js && cd ios/App && ../../scripts/pod-install.sh && cd ../..",
"postinstall": "node scripts/fix-capacitor-plugins.js"
},
"dependencies": {
"@capacitor/android": "^6.2.1",
"@capacitor/ios": "^6.2.1",
"@capacitor/cli": "^6.2.1",
"@capacitor/core": "^6.2.1",
"@capacitor/ios": "^6.2.1",
"@timesafari/daily-notification-plugin": "file:../../",
"date-fns": "^4.1.0",
"did-jwt": "^7.4.7",

View File

@@ -108,7 +108,8 @@ check_requirements() {
# Check Android requirements if building Android
if [ "$BUILD_ALL" = true ] || [ "$BUILD_ANDROID" = true ]; then
if ! command -v adb &> /dev/null; then
log_warn "Android SDK not found (adb not in PATH). Android build will be skipped."
log_warn "Android SDK tools not found (adb not in PATH)."
log_warn "APK can still be built, but install/launch requires adb."
else
log_info "✅ Android SDK: $(adb version | head -1)"
fi
@@ -238,18 +239,88 @@ if [ "$BUILD_ALL" = true ] || [ "$BUILD_IOS" = true ]; then
fi
fi
# Find Android SDK location
find_android_sdk() {
local android_dir=""
local local_props="$PROJECT_DIR/android/local.properties"
# Check environment variables first
if [ -n "$ANDROID_HOME" ] && [ -d "$ANDROID_HOME" ]; then
android_dir="$ANDROID_HOME"
log_info "Found Android SDK via ANDROID_HOME: $android_dir"
elif [ -n "$ANDROID_SDK_ROOT" ] && [ -d "$ANDROID_SDK_ROOT" ]; then
android_dir="$ANDROID_SDK_ROOT"
log_info "Found Android SDK via ANDROID_SDK_ROOT: $android_dir"
fi
# Check existing local.properties
if [ -z "$android_dir" ] && [ -f "$local_props" ]; then
# Temporarily disable exit on error for grep (may not find match)
set +e
sdk_line=$(grep "^sdk.dir=" "$local_props" 2>/dev/null)
set -e
if [ -n "$sdk_line" ]; then
android_dir=$(echo "$sdk_line" | cut -d'=' -f2 | sed 's|\\\\|/|g' | sed "s|^~|$HOME|")
if [ -n "$android_dir" ] && [ -d "$android_dir" ]; then
log_info "Found Android SDK in local.properties: $android_dir"
else
android_dir=""
fi
fi
fi
# Try common locations
if [ -z "$android_dir" ]; then
# macOS default location
if [ -d "$HOME/Library/Android/sdk" ]; then
android_dir="$HOME/Library/Android/sdk"
log_info "Found Android SDK in default macOS location: $android_dir"
# Linux default location
elif [ -d "$HOME/Android/Sdk" ]; then
android_dir="$HOME/Android/Sdk"
log_info "Found Android SDK in default Linux location: $android_dir"
fi
fi
# Create/update local.properties if SDK found
if [ -n "$android_dir" ]; then
# Normalize path (convert to forward slashes, expand ~)
android_dir=$(echo "$android_dir" | sed 's|\\\\|/|g' | sed "s|^~|$HOME|")
# Create local.properties with SDK location
mkdir -p "$(dirname "$local_props")"
echo "## This file is automatically generated by build script" > "$local_props"
echo "## Location: $android_dir" >> "$local_props"
echo "sdk.dir=$android_dir" >> "$local_props"
log_info "✅ Configured Android SDK in local.properties"
return 0
else
log_error "Android SDK not found!"
log_error "Please set one of the following:"
log_error " 1. ANDROID_HOME environment variable"
log_error " 2. ANDROID_SDK_ROOT environment variable"
log_error " 3. Create android/local.properties with: sdk.dir=/path/to/android/sdk"
log_error ""
log_error "Common SDK locations:"
log_error " macOS: ~/Library/Android/sdk"
log_error " Linux: ~/Android/Sdk"
return 1
fi
}
# Android build
if [ "$BUILD_ALL" = true ] || [ "$BUILD_ANDROID" = true ]; then
log_step "Building Android app..."
# Check for Android SDK
if ! command -v adb &> /dev/null; then
log_warn "adb not found. Android SDK may not be installed."
log_warn "Skipping Android build. Install Android SDK to build Android."
else
# Ensure Android SDK is configured
if ! find_android_sdk; then
log_error "Cannot build Android app without SDK location"
exit 1
fi
cd "$PROJECT_DIR/android"
# Build APK
# Build APK (Gradle doesn't require adb for building)
if ./gradlew :app:assembleDebug; then
log_info "Android APK built successfully"
@@ -258,8 +329,15 @@ if [ "$BUILD_ALL" = true ] || [ "$BUILD_ANDROID" = true ]; then
if [ -f "$APK_PATH" ]; then
log_info "APK location: $APK_PATH"
# Run on emulator if requested
# Run on emulator if requested (requires adb)
if [ "$RUN_ALL" = true ] || [ "$RUN_ANDROID" = true ]; then
# Check for Android SDK tools (adb)
if ! command -v adb &> /dev/null; then
log_warn "adb not found in PATH. Cannot install/launch app."
log_warn "APK built successfully, but install/launch requires Android SDK."
log_info "To install manually: adb install -r $APK_PATH"
log_info "Or add Android SDK platform-tools to your PATH."
else
log_step "Installing and launching Android app..."
# Check for running emulator
@@ -283,6 +361,7 @@ if [ "$BUILD_ALL" = true ] || [ "$BUILD_ANDROID" = true ]; then
fi
fi
fi
fi
else
log_error "APK not found at expected location: $APK_PATH"
fi
@@ -293,7 +372,6 @@ if [ "$BUILD_ALL" = true ] || [ "$BUILD_ANDROID" = true ]; then
cd "$PROJECT_DIR"
fi
fi
# iOS build
if [ "$BUILD_ALL" = true ] || [ "$BUILD_IOS" = true ]; then

View File

@@ -35,13 +35,17 @@ const PLUGIN_ENTRY = {
function fixCapacitorPlugins() {
console.log('🔧 Fixing capacitor.plugins.json...');
// Check if the file exists - if not, it means Capacitor hasn't been synced yet
if (!fs.existsSync(PLUGINS_JSON_PATH)) {
console.log(' capacitor.plugins.json not found (Capacitor not synced yet - will be fixed after cap sync)');
return;
}
try {
// Read current content
let plugins = [];
if (fs.existsSync(PLUGINS_JSON_PATH)) {
const content = fs.readFileSync(PLUGINS_JSON_PATH, 'utf8');
plugins = JSON.parse(content);
}
// Check if our plugin is already there
const hasPlugin = plugins.some(p => p.name === PLUGIN_ENTRY.name);
@@ -56,7 +60,8 @@ function fixCapacitorPlugins() {
} catch (error) {
console.error('❌ Error fixing capacitor.plugins.json:', error.message);
process.exit(1);
// Don't exit - this might be a first-time install
console.log(' This is normal on first install. Run "npm run cap:sync" to generate Capacitor files.');
}
}
@@ -207,8 +212,9 @@ function fixAll() {
fixCapacitorSettingsGradle();
fixPodfile();
console.log('\n✅ All fixes applied successfully!');
console.log('💡 These fixes will persist until the next "npx cap sync"');
console.log('\n✅ Fix script completed!');
console.log('💡 Note: Some fixes may be skipped if Capacitor files don\'t exist yet.');
console.log('💡 Run "npm run cap:sync" to generate Capacitor files, then this script will apply fixes.');
}
// Run if called directly

View File

@@ -0,0 +1,25 @@
#!/bin/bash
#
# Wrapper script to find and run pod install
# Handles rbenv shims and standard CocoaPods installations
#
# @author Matthew Raymer
set -e
# Get pod command (handles rbenv)
get_pod_command() {
if command -v pod &> /dev/null; then
echo "pod"
elif [ -f "$HOME/.rbenv/shims/pod" ]; then
echo "$HOME/.rbenv/shims/pod"
else
echo "pod" # Let it fail with standard error message
fi
}
# Find pod command
POD_CMD=$(get_pod_command)
# Run pod install
exec "$POD_CMD" install "$@"

View File

@@ -63,6 +63,7 @@ export default toNative(App)
min-height: 100vh;
display: flex;
flex-direction: column;
padding: 0;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}

View File

@@ -0,0 +1,102 @@
<!--
/**
* Status List Component - Standardized Label/Value List Display
*
* Reusable component for displaying status items in a consistent format
* with optional status-based color coding
*
* @author Matthew Raymer
* @version 1.0.0
*/
-->
<template>
<div class="status-list">
<div
v-for="item in items"
:key="item.label"
class="status-item"
:class="showStatusColors && item.status ? `status-${item.status}` : ''"
>
<div class="status-row">
<span class="status-label">{{ item.label }}</span>
<span
class="status-value"
:class="showStatusColors && item.status ? `value-${item.status}` : ''"
>
{{ item.value }}
</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
interface StatusListItem {
label: string
value: string
status?: 'success' | 'warning' | 'error' | 'info'
}
interface Props {
items: StatusListItem[]
showStatusColors?: boolean
}
defineProps<Props>()
</script>
<style scoped>
.status-list {
display: flex;
flex-direction: column;
}
.status-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.15);
}
.status-item:last-child {
border-bottom: none;
}
.status-row {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
}
.status-label {
font-size: 14px;
font-weight: 500;
color: rgba(255, 255, 255, 0.9);
}
.status-value {
font-size: 14px;
font-weight: 600;
color: white;
}
.value-success {
color: #4caf50;
}
.value-warning {
color: #ff9800;
}
.value-error {
color: #f44336;
}
.value-info {
color: #2196f3;
}
</style>

View File

@@ -40,6 +40,9 @@ export interface ScheduleResponse {
export interface PermissionStatus {
notifications: 'granted' | 'denied'
notificationsEnabled: boolean
exactAlarmEnabled?: boolean
wakeLockEnabled?: boolean
allPermissionsGranted?: boolean
}
export interface NotificationStatus {

View File

@@ -8,6 +8,7 @@
* @version 1.0.0
*/
import { Capacitor } from '@capacitor/core'
import {
type PermissionStatus,
type NotificationStatus,
@@ -119,10 +120,17 @@ export class DiagnosticsExporter {
// Calculate performance metrics
const loadTime = performance.now() - startTime
// Detect platform using Capacitor
const platform = Capacitor.getPlatform()
const platformDisplayName = platform.charAt(0).toUpperCase() + platform.slice(1)
// API Level is Android-specific, show N/A for iOS/web
const apiLevel = platform === 'android' ? 'Unknown' : 'N/A'
return {
appVersion: '1.0.0', // TODO: Get from app info
platform: 'Android', // TODO: Detect platform
apiLevel: 'Unknown', // TODO: Get from device info
platform: platformDisplayName,
apiLevel: apiLevel, // TODO: Get from device info for Android
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
lastUpdated: new Date().toLocaleString(),
postNotificationsGranted: permissions.notifications === 'granted',

View File

@@ -77,6 +77,15 @@ const router = createRouter({
requiresAuth: false
}
},
{
path: '/request-permissions',
name: 'RequestPermissions',
component: (): Promise<typeof import('../views/RequestPermissionsView.vue')> => import('../views/RequestPermissionsView.vue'),
meta: {
title: 'Request Permissions',
requiresAuth: false
}
},
{
path: '/about',
name: 'About',

View File

@@ -1,10 +1,61 @@
<template>
<div class="about">
<div class="view-header">
<div class="header-title-row">
<button class="back-button" @click="goBack" aria-label="Go back to home">
</button>
<h1>This is an about page</h1>
</div>
</div>
</div>
</template>
<style>
<script setup lang="ts">
import { useRouter } from 'vue-router'
const router = useRouter()
function goBack() {
router.push('/')
}
</script>
<style scoped>
.view-header {
text-align: center;
}
.header-title-row {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
margin-bottom: 8px;
}
.back-button {
background: rgba(255, 255, 255, 0.2);
border: 1px solid rgba(255, 255, 255, 0.3);
color: white;
padding: 8px 12px;
border-radius: 8px;
cursor: pointer;
font-size: 18px;
font-weight: 500;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
min-width: 40px;
height: 40px;
}
.back-button:hover {
background: rgba(255, 255, 255, 0.3);
transform: translateX(-2px);
}
@media (min-width: 1024px) {
.about {
min-height: 100vh;
@@ -12,4 +63,17 @@
align-items: center;
}
}
/* Mobile responsiveness */
@media (max-width: 768px) {
.header-title-row {
gap: 8px;
}
.back-button {
min-width: 36px;
height: 36px;
font-size: 16px;
}
}
</style>

View File

@@ -1,7 +1,12 @@
<template>
<div class="history-view">
<div class="view-header">
<div class="header-title-row">
<button class="back-button" @click="goBack" aria-label="Go back to home">
</button>
<h1 class="page-title">📋 History</h1>
</div>
<p class="page-subtitle">Notification history and activity</p>
</div>
<div class="placeholder-content">
@@ -12,15 +17,19 @@
<script lang="ts">
import { Vue, Component, toNative } from 'vue-facing-decorator'
import router from '../router'
@Component
class HistoryView extends Vue {}
class HistoryView extends Vue {
goBack() {
router.push('/')
}
}
export default toNative(HistoryView)
</script>
<style scoped>
.history-view {
padding: 20px;
max-width: 1200px;
margin: 0 auto;
}
@@ -30,6 +39,36 @@ export default toNative(HistoryView)
margin-bottom: 30px;
}
.header-title-row {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
margin-bottom: 8px;
}
.back-button {
background: rgba(255, 255, 255, 0.2);
border: 1px solid rgba(255, 255, 255, 0.3);
color: white;
padding: 8px 12px;
border-radius: 8px;
cursor: pointer;
font-size: 18px;
font-weight: 500;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
min-width: 40px;
height: 40px;
}
.back-button:hover {
background: rgba(255, 255, 255, 0.3);
transform: translateX(-2px);
}
.page-title {
margin: 0 0 8px 0;
font-size: 28px;
@@ -52,4 +91,17 @@ export default toNative(HistoryView)
color: rgba(255, 255, 255, 0.8);
backdrop-filter: blur(10px);
}
/* Mobile responsiveness */
@media (max-width: 768px) {
.header-title-row {
gap: 8px;
}
.back-button {
min-width: 36px;
height: 36px;
font-size: 16px;
}
}
</style>

View File

@@ -21,10 +21,10 @@
</p>
<div class="platform-info">
<span class="platform-badge" :class="platformClass">
{{ platformName }}
{{ platformEmoji }} {{ platformName }}
</span>
<span class="status-badge" :class="statusClass">
{{ statusText }}
{{ statusEmoji }} {{ statusText }}
</span>
</div>
</div>
@@ -44,15 +44,13 @@
icon="📊"
title="Check Status"
description="View notification system status"
@click="checkSystemStatus"
:loading="isCheckingStatus"
@click="navigateToStatus"
/>
<ActionCard
icon="🔐"
title="Request Permissions"
description="Check and request notification permissions"
@click="checkAndRequestPermissions"
:loading="isRequestingPermissions"
@click="navigateToRequestPermissions"
/>
<ActionCard
icon="🔔"
@@ -84,21 +82,12 @@
<!-- System Status -->
<div class="system-status">
<h2 class="section-title">System Status</h2>
<div class="status-grid">
<StatusCard
v-for="item in systemStatus"
:key="item.label"
:title="item.label"
:status="getStatusType(item.status)"
:value="item.value"
:description="getStatusDescription(item.label)"
@refresh="refreshSystemStatus"
/>
</div>
<StatusList :items="systemStatus" :show-status-colors="true" />
<!-- Diagnostic Actions -->
<div class="section">
<h2 class="section-title">🔧 Diagnostics</h2>
<div class="action-grid">
<ActionCard
title="Plugin Diagnostics"
description="Check plugin loading and availability"
@@ -114,6 +103,7 @@
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
@@ -121,7 +111,7 @@ import { computed, ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useAppStore } from '@/stores/app'
import ActionCard from '@/components/cards/ActionCard.vue'
import StatusCard from '@/components/cards/StatusCard.vue'
import StatusList from '@/components/StatusList.vue'
import { Capacitor } from '@capacitor/core'
import { DailyNotification } from '@timesafari/daily-notification-plugin'
import { TEST_USER_ZERO_CONFIG, generateEndorserJWT } from '@/config/test-user-zero'
@@ -136,11 +126,25 @@ const isRequestingPermissions = ref(false)
const nativeFetcherConfigured = ref(false)
const platformName = computed(() => {
const platform = appStore.platform
const platform = Capacitor.getPlatform()
return platform.charAt(0).toUpperCase() + platform.slice(1)
})
const platformClass = computed(() => `platform-${appStore.platform}`)
const platformEmoji = computed(() => {
const platform = Capacitor.getPlatform()
switch (platform) {
case 'android':
return '🤖'
case 'ios':
return '🍎'
case 'web':
return '🌐'
default:
return '📱'
}
})
const platformClass = computed(() => `platform-${Capacitor.getPlatform()}`)
const statusClass = computed(() => {
const status = appStore.notificationStatus
@@ -156,21 +160,31 @@ const statusText = computed(() => {
return 'Not Ready'
})
const statusEmoji = computed(() => {
const status = appStore.notificationStatus
if (!status) return '❓'
if (status.canScheduleNow) return '✅'
return '⚠️'
})
const systemStatus = computed(() => {
const status = appStore.notificationStatus
if (!status) {
return [
{ label: 'Platform', value: platformName.value, status: 'info' },
{ label: 'Plugin', value: 'Not Available', status: 'error' }
{ label: 'Platform', value: platformName.value, status: 'info' as const },
{ label: 'Plugin', value: 'Not Available', status: 'error' as const }
]
}
const permissionsStatus: 'success' | 'warning' = status.postNotificationsGranted ? 'success' : 'warning'
const canScheduleStatus: 'success' | 'warning' = status.canScheduleNow ? 'success' : 'warning'
return [
{ label: 'Platform', value: platformName.value, status: 'info' },
{ label: 'Plugin', value: 'Available', status: 'success' },
{ label: 'Permissions', value: status.postNotificationsGranted ? 'Granted' : 'Not Granted', status: status.postNotificationsGranted ? 'success' : 'warning' },
{ label: 'Can Schedule', value: status.canScheduleNow ? 'Yes' : 'No', status: status.canScheduleNow ? 'success' : 'warning' },
{ label: 'Next Scheduled', value: status.nextScheduledAt ? new Date(status.nextScheduledAt).toLocaleTimeString() : 'None', status: 'info' }
{ label: 'Platform', value: platformName.value, status: 'info' as const },
{ label: 'Plugin', value: 'Available', status: 'success' as const },
{ label: 'Permissions', value: status.postNotificationsGranted ? 'Granted' : 'Not Granted', status: permissionsStatus },
{ label: 'Can Schedule', value: status.canScheduleNow ? 'Yes' : 'No', status: canScheduleStatus },
{ label: 'Next Scheduled', value: status.nextScheduledAt ? new Date(status.nextScheduledAt).toLocaleTimeString() : 'None', status: 'info' as const }
]
})
@@ -199,6 +213,16 @@ const navigateToSettings = (): void => {
router.push('/settings')
}
const navigateToStatus = (): void => {
console.log('🔄 CLICK: Navigate to Status')
router.push('/status')
}
const navigateToRequestPermissions = (): void => {
console.log('🔄 CLICK: Navigate to Request Permissions')
router.push('/request-permissions')
}
const checkSystemStatus = async (): Promise<void> => {
console.log('🔄 CLICK: Check System Status')
isCheckingStatus.value = true
@@ -227,7 +251,17 @@ const checkSystemStatus = async (): Promise<void> => {
const status = await plugin.getNotificationStatus()
// Use checkPermissionStatus() which is the correct method name for iOS
const permissions = await plugin.checkPermissionStatus()
const exactAlarmStatus = await plugin.getExactAlarmStatus()
// getExactAlarmStatus() is Android-only - only call on Android
let exactAlarmStatus = { enabled: false, supported: false }
if (platform === 'android') {
try {
exactAlarmStatus = await plugin.getExactAlarmStatus()
} catch (exactAlarmError) {
console.warn('⚠️ getExactAlarmStatus() failed (non-critical):', exactAlarmError)
// Use defaults if call fails
}
}
console.log('📊 Plugin status object:', status)
console.log('📊 Status values:')
@@ -244,7 +278,9 @@ const checkSystemStatus = async (): Promise<void> => {
console.log(' - exactAlarmEnabled:', permissions.exactAlarmEnabled)
console.log(' - wakeLockEnabled:', permissions.wakeLockEnabled)
console.log(' - allPermissionsGranted:', permissions.allPermissionsGranted)
if (platform === 'android') {
console.log('📊 Exact alarm status:', exactAlarmStatus)
}
// Map plugin response to app store format
// checkPermissionStatus() returns PermissionStatusResult with boolean flags
@@ -325,35 +361,6 @@ const checkSystemStatus = async (): Promise<void> => {
}
}
const getStatusType = (status: string): 'success' | 'warning' | 'error' | 'info' => {
switch (status) {
case 'success':
case 'warning':
case 'error':
case 'info':
return status
default:
return 'info'
}
}
const getStatusDescription = (label: string): string => {
switch (label) {
case 'Platform':
return 'Current platform information'
case 'Plugin':
return 'DailyNotification plugin availability'
case 'Permissions':
return 'Notification permission status'
case 'Can Schedule':
return 'Ready to schedule notifications'
case 'Next Scheduled':
return 'Next scheduled notification time'
default:
return 'System status information'
}
}
const refreshSystemStatus = async (): Promise<void> => {
console.log('🔄 CLICK: Refresh System Status')
await checkSystemStatus()
@@ -402,7 +409,7 @@ const checkAndRequestPermissions = async (): Promise<void> => {
// Request permissions - this will show the iOS system dialog
// Try requestNotificationPermissions first (iOS), fallback to requestPermissions
if (typeof (plugin as any).requestNotificationPermissions === 'function') {
await (plugin as { requestNotificationPermissions: () => Promise<void> }).requestNotificationPermissions()
await (plugin as { requestNotificationPermissions: () => Promise<any> }).requestNotificationPermissions()
} else if (typeof (plugin as any).requestPermissions === 'function') {
await (plugin as { requestPermissions: () => Promise<any> }).requestPermissions()
} else {
@@ -623,7 +630,6 @@ onMounted(async () => {
<style scoped>
.home-view {
padding: 20px;
max-width: 1200px;
margin: 0 auto;
}
@@ -654,8 +660,6 @@ onMounted(async () => {
}
.platform-badge {
padding: 6px 12px;
border-radius: 16px;
font-size: 12px;
font-weight: 500;
text-transform: uppercase;
@@ -663,8 +667,6 @@ onMounted(async () => {
}
.status-badge {
padding: 6px 12px;
border-radius: 16px;
font-size: 12px;
font-weight: 500;
}
@@ -688,17 +690,18 @@ onMounted(async () => {
}
.system-status {
margin-bottom: 40px;
margin-bottom: 32px;
}
.section {
margin-bottom: 30px;
margin-bottom: 24px;
margin-top: 32px;
}
/* Mobile responsiveness */
@media (max-width: 768px) {
.home-view {
padding: 16px;
padding: 16px 0;
}
.welcome-title {
@@ -708,10 +711,5 @@ onMounted(async () => {
.action-grid {
grid-template-columns: 1fr;
}
.platform-info {
flex-direction: column;
align-items: center;
}
}
</style>

View File

@@ -12,7 +12,12 @@
<template>
<div class="logs-view">
<div class="view-header">
<div class="header-title-row">
<button class="back-button" @click="goBack" aria-label="Go back to home">
</button>
<h1 class="page-title">📋 System Logs</h1>
</div>
<p class="page-subtitle">View and copy DailyNotification plugin logs</p>
</div>
@@ -73,6 +78,7 @@
<script lang="ts">
import { Vue, Component, toNative } from 'vue-facing-decorator'
import router from '../router'
type LogLine = { ts: number; msg: string; level: string }
@Component
@@ -82,6 +88,10 @@ class LogsView extends Vue {
logs: LogLine[] = []
lastUpdated: number | null = null
goBack() {
router.push('/')
}
get hasLogs() { return this.logs.length > 0 }
formatTimestamp(ts: number) { return new Date(ts).toLocaleString() }
@@ -121,7 +131,6 @@ export default toNative(LogsView)
<style scoped>
.logs-view {
padding: 20px;
max-width: 1200px;
margin: 0 auto;
}
@@ -131,6 +140,36 @@ export default toNative(LogsView)
margin-bottom: 30px;
}
.header-title-row {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
margin-bottom: 8px;
}
.back-button {
background: rgba(255, 255, 255, 0.2);
border: 1px solid rgba(255, 255, 255, 0.3);
color: white;
padding: 8px 12px;
border-radius: 8px;
cursor: pointer;
font-size: 18px;
font-weight: 500;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
min-width: 40px;
height: 40px;
}
.back-button:hover {
background: rgba(255, 255, 255, 0.3);
transform: translateX(-2px);
}
.page-title {
margin: 0 0 8px 0;
font-size: 28px;
@@ -279,7 +318,7 @@ export default toNative(LogsView)
/* Mobile responsiveness */
@media (max-width: 768px) {
.logs-view {
padding: 16px;
padding: 16px 0;
}
.logs-controls {
@@ -302,5 +341,15 @@ export default toNative(LogsView)
.log-timestamp {
font-size: 11px;
}
.header-title-row {
gap: 8px;
}
.back-button {
min-width: 36px;
height: 36px;
font-size: 16px;
}
}
</style>

View File

@@ -21,7 +21,6 @@ export default toNative(NotFoundView)
<style scoped>
.not-found-view {
padding: 20px;
max-width: 1200px;
margin: 0 auto;
}

View File

@@ -1,26 +1,313 @@
<template>
<div class="notifications-view">
<div class="view-header">
<div class="header-title-row">
<button class="back-button" @click="goBack" aria-label="Go back to home">
</button>
<h1 class="page-title">🔔 Notifications</h1>
<p class="page-subtitle">Manage scheduled notifications</p>
</div>
<div class="placeholder-content">
<p>Notifications management coming soon...</p>
<p class="page-subtitle">View scheduled notifications and rollover status</p>
</div>
<div class="notifications-content">
<div class="actions-bar">
<button
class="action-button refresh"
@click="refreshNotifications"
:disabled="isLoading"
>
🔄 {{ isLoading ? 'Refreshing...' : 'Refresh' }}
</button>
</div>
<div v-if="errorMessage" class="error-message">
{{ errorMessage }}
</div>
<div v-if="!isLoading && notificationStatus" class="status-cards">
<!-- Main Status Card -->
<div class="status-card main">
<div class="card-header">
<h2>📅 Scheduled Notification</h2>
<span class="status-badge" :class="statusClass">
{{ statusText }}
</span>
</div>
<div class="card-content">
<div v-if="nextNotificationTime" class="info-row">
<span class="label">Next Notification:</span>
<span class="value highlight">{{ formattedNextTime }}</span>
</div>
<div v-else class="info-row">
<span class="label">Next Notification:</span>
<span class="value">None scheduled</span>
</div>
<div v-if="timeUntilNext" class="info-row">
<span class="label">Time Until:</span>
<span class="value">{{ timeUntilNext }}</span>
</div>
<div class="info-row">
<span class="label">Pending Count:</span>
<span class="value">{{ pendingCount }}</span>
</div>
<div v-if="notificationStatus.lastNotificationTime" class="info-row">
<span class="label">Last Notification:</span>
<span class="value">{{ formattedLastTime }}</span>
</div>
</div>
</div>
<!-- Rollover Status Card -->
<div v-if="rolloverInfo" class="status-card rollover">
<div class="card-header">
<h2>🔄 Rollover Status</h2>
<span class="status-badge" :class="rolloverInfo.enabled ? 'success' : 'warning'">
{{ rolloverInfo.enabled ? 'Active' : 'Inactive' }}
</span>
</div>
<div class="card-content">
<div v-if="rolloverInfo.lastRolloverTime" class="info-row">
<span class="label">Last Rollover:</span>
<span class="value">{{ formattedRolloverTime }}</span>
</div>
<div v-else class="info-row">
<span class="label">Last Rollover:</span>
<span class="value">Never</span>
</div>
<div class="info-row">
<span class="label">Rollover Enabled:</span>
<span class="value">{{ rolloverInfo.enabled ? 'Yes' : 'No' }}</span>
</div>
</div>
</div>
<!-- Additional Info Card -->
<div class="status-card info">
<div class="card-header">
<h2> Additional Information</h2>
</div>
<div class="card-content">
<div class="info-row">
<span class="label">Notifications Enabled:</span>
<span class="value">{{ notificationStatus.isEnabled ? 'Yes' : 'No' }}</span>
</div>
<div class="info-row">
<span class="label">Is Scheduled:</span>
<span class="value">{{ notificationStatus.isScheduled ? 'Yes' : 'No' }}</span>
</div>
<div v-if="notificationStatus.error" class="info-row error">
<span class="label">Error:</span>
<span class="value">{{ notificationStatus.error }}</span>
</div>
</div>
</div>
</div>
<div v-else-if="!isLoading && !notificationStatus" class="empty-state">
<p>No notification status available. Try refreshing.</p>
</div>
<div v-if="isLoading" class="loading-state">
<p>Loading notification status...</p>
</div>
</div>
</div>
</template>
<script lang="ts">
import { Vue, Component, toNative } from 'vue-facing-decorator'
import { Capacitor } from '@capacitor/core'
import router from '../router'
import { createTypedPlugin } from '../lib/typed-plugin'
import type { NotificationStatus } from '../lib/bridge'
interface RolloverInfo {
enabled: boolean
lastRolloverTime?: number
}
@Component
class NotificationsView extends Vue {}
class NotificationsView extends Vue {
isLoading = false
errorMessage = ''
notificationStatus: NotificationStatus | null = null
rolloverInfo: RolloverInfo | null = null
currentTime = Date.now()
private timeUpdateInterval: number | null = null
async mounted() {
await this.refreshNotifications()
// Update current time every second to keep countdown reactive
this.timeUpdateInterval = window.setInterval(() => {
this.currentTime = Date.now()
}, 1000)
}
beforeUnmount() {
if (this.timeUpdateInterval !== null) {
clearInterval(this.timeUpdateInterval)
this.timeUpdateInterval = null
}
}
goBack() {
router.push('/')
}
async refreshNotifications() {
this.isLoading = true
this.errorMessage = ''
// Update current time immediately to refresh countdown
this.currentTime = Date.now()
try {
const platform = Capacitor.getPlatform()
const isNative = platform !== 'web'
if (!isNative) {
this.errorMessage = 'Notification status is only available on native platforms (iOS/Android)'
this.isLoading = false
return
}
const typedPlugin = await createTypedPlugin()
if (!typedPlugin) {
throw new Error('Plugin not available')
}
// Get notification status
const status = await typedPlugin.getNotificationStatus()
this.notificationStatus = status
// Try to get rollover info (may not be in type definition but could be in response)
// The iOS plugin returns rolloverEnabled and lastRolloverTime
try {
const { DailyNotification } = await import('@timesafari/daily-notification-plugin')
const rawStatus = await DailyNotification.getNotificationStatus() as any
if (rawStatus.rolloverEnabled !== undefined || rawStatus.lastRolloverTime !== undefined) {
this.rolloverInfo = {
enabled: rawStatus.rolloverEnabled ?? false,
lastRolloverTime: rawStatus.lastRolloverTime
}
}
} catch (rolloverError) {
// Rollover info not available, that's okay
console.log('Rollover info not available:', rolloverError)
}
} catch (error: any) {
console.error('Failed to fetch notification status:', error)
this.errorMessage = error.message || 'Failed to load notification status'
} finally {
this.isLoading = false
}
}
get statusClass(): string {
if (!this.notificationStatus) return 'info'
if (this.notificationStatus.isScheduled && this.nextNotificationTime) return 'success'
if (this.notificationStatus.error) return 'error'
return 'warning'
}
get statusText(): string {
if (!this.notificationStatus) return 'Unknown'
if (this.notificationStatus.isScheduled && this.nextNotificationTime) return 'Scheduled'
if (this.notificationStatus.error) return 'Error'
return 'Not Scheduled'
}
get nextNotificationTime(): number | null {
if (!this.notificationStatus?.nextNotificationTime) return null
const time = this.notificationStatus.nextNotificationTime
return typeof time === 'number' ? time : null
}
get formattedNextTime(): string {
if (!this.nextNotificationTime) return 'N/A'
const date = new Date(this.nextNotificationTime)
return date.toLocaleString(undefined, {
weekday: 'short',
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})
}
get timeUntilNext(): string | null {
if (!this.nextNotificationTime) return null
const diff = this.nextNotificationTime - this.currentTime
if (diff < 0) return 'Past due'
const days = Math.floor(diff / (1000 * 60 * 60 * 24))
const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60))
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60))
const parts: string[] = []
if (days > 0) parts.push(`${days} day${days !== 1 ? 's' : ''}`)
if (hours > 0) parts.push(`${hours} hour${hours !== 1 ? 's' : ''}`)
if (minutes > 0 || parts.length === 0) parts.push(`${minutes} minute${minutes !== 1 ? 's' : ''}`)
return parts.join(', ')
}
get formattedLastTime(): string {
if (!this.notificationStatus?.lastNotificationTime) return 'N/A'
const date = new Date(this.notificationStatus.lastNotificationTime)
return date.toLocaleString(undefined, {
weekday: 'short',
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})
}
get formattedRolloverTime(): string {
if (!this.rolloverInfo?.lastRolloverTime) return 'N/A'
const date = new Date(this.rolloverInfo.lastRolloverTime)
return date.toLocaleString(undefined, {
weekday: 'short',
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})
}
get pendingCount(): number {
if (!this.notificationStatus) return 0
const pending = this.notificationStatus.pending
// Handle both boolean and number types
if (typeof pending === 'boolean') {
return pending ? 1 : 0
}
if (typeof pending === 'number') {
return pending
}
return 0
}
}
export default toNative(NotificationsView)
</script>
<style scoped>
.notifications-view {
padding: 20px;
max-width: 1200px;
margin: 0 auto;
}
@@ -30,6 +317,36 @@ export default toNative(NotificationsView)
margin-bottom: 30px;
}
.header-title-row {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
margin-bottom: 8px;
}
.back-button {
background: rgba(255, 255, 255, 0.2);
border: 1px solid rgba(255, 255, 255, 0.3);
color: white;
padding: 8px 12px;
border-radius: 8px;
cursor: pointer;
font-size: 18px;
font-weight: 500;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
min-width: 40px;
height: 40px;
}
.back-button:hover {
background: rgba(255, 255, 255, 0.3);
transform: translateX(-2px);
}
.page-title {
margin: 0 0 8px 0;
font-size: 28px;
@@ -43,7 +360,162 @@ export default toNative(NotificationsView)
color: rgba(255, 255, 255, 0.8);
}
.placeholder-content {
.notifications-content {
padding: 0 16px;
}
.actions-bar {
margin-bottom: 24px;
display: flex;
justify-content: flex-end;
}
.action-button {
padding: 12px 24px;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.action-button.refresh {
background: #1976d2;
color: white;
}
.action-button.refresh:hover:not(:disabled) {
background: #1565c0;
}
.action-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.error-message {
background: rgba(244, 67, 54, 0.2);
border: 1px solid rgba(244, 67, 54, 0.4);
border-radius: 8px;
padding: 16px;
margin-bottom: 24px;
color: #e57373;
font-size: 14px;
}
.status-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 24px;
margin-bottom: 24px;
}
.status-card {
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 12px;
padding: 24px;
backdrop-filter: blur(10px);
}
.status-card.main {
grid-column: 1 / -1;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 16px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.card-header h2 {
margin: 0;
font-size: 20px;
font-weight: 600;
color: white;
}
.status-badge {
padding: 6px 12px;
border-radius: 16px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
}
.status-badge.success {
background: rgba(76, 175, 80, 0.3);
color: #81c784;
border: 1px solid rgba(76, 175, 80, 0.5);
}
.status-badge.warning {
background: rgba(255, 152, 0, 0.3);
color: #ffb74d;
border: 1px solid rgba(255, 152, 0, 0.5);
}
.status-badge.error {
background: rgba(244, 67, 54, 0.3);
color: #e57373;
border: 1px solid rgba(244, 67, 54, 0.5);
}
.status-badge.info {
background: rgba(33, 150, 243, 0.3);
color: #64b5f6;
border: 1px solid rgba(33, 150, 243, 0.5);
}
.card-content {
display: flex;
flex-direction: column;
gap: 16px;
}
.info-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 12px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.info-row:last-child {
border-bottom: none;
}
.info-row.error {
color: #e57373;
}
.info-row .label {
font-size: 14px;
color: rgba(255, 255, 255, 0.7);
font-weight: 500;
flex: 0 0 40%;
}
.info-row .value {
font-size: 14px;
color: white;
text-align: right;
flex: 1;
word-break: break-word;
}
.info-row .value.highlight {
color: #81c784;
font-weight: 600;
font-size: 15px;
}
.empty-state,
.loading-state {
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 12px;
@@ -52,4 +524,38 @@ export default toNative(NotificationsView)
color: rgba(255, 255, 255, 0.8);
backdrop-filter: blur(10px);
}
/* Mobile responsiveness */
@media (max-width: 768px) {
.header-title-row {
gap: 8px;
}
.back-button {
min-width: 36px;
height: 36px;
font-size: 16px;
}
.status-cards {
grid-template-columns: 1fr;
}
.status-card.main {
grid-column: 1;
}
.info-row {
flex-direction: column;
gap: 8px;
}
.info-row .label {
flex: 1;
}
.info-row .value {
text-align: left;
}
}
</style>

View File

@@ -0,0 +1,576 @@
<!--
/**
* Request Permissions View - Permission Management Interface
*
* Platform-neutral permissions view with status display and request functionality
*
* @author Matthew Raymer
* @version 1.0.0
*/
-->
<template>
<div class="request-permissions-view">
<div class="view-header">
<div class="header-title-row">
<button class="back-button" @click="goBack" aria-label="Go back to home">
</button>
<h1 class="page-title">🔐 Request Permissions</h1>
</div>
<p class="page-subtitle">Check and request notification permissions</p>
</div>
<!-- Status Display -->
<div class="status-section">
<div class="status-card" :class="statusCardClass">
<div class="status-content">
<div class="status-icon">{{ statusIcon }}</div>
<div class="status-text">{{ statusText }}</div>
<div v-if="statusDetails" class="status-details">{{ statusDetails }}</div>
</div>
</div>
</div>
<!-- Permission Details -->
<div class="permissions-section">
<h2 class="section-title">Permission Status</h2>
<div class="permissions-grid">
<div class="permission-item">
<span class="permission-label">🔔 Notifications:</span>
<span class="permission-value" :class="notificationStatusClass">
{{ notificationStatusText }}
</span>
</div>
<div v-if="exactAlarmStatusText" class="permission-item">
<span class="permission-label"> Exact Alarm:</span>
<span class="permission-value" :class="exactAlarmStatusClass">
{{ exactAlarmStatusText }}
</span>
</div>
<div v-if="backgroundRefreshStatusText" class="permission-item">
<span class="permission-label">🔄 Background Refresh:</span>
<span class="permission-value" :class="backgroundRefreshStatusClass">
{{ backgroundRefreshStatusText }}
</span>
</div>
</div>
</div>
<!-- Action Button -->
<div class="action-section">
<button
class="action-button primary"
@click="requestPermissions"
:disabled="isRequesting"
>
{{ isRequesting ? 'Requesting...' : 'Request Permissions' }}
</button>
<button
class="action-button secondary"
@click="refreshStatus"
:disabled="isRefreshing"
>
{{ isRefreshing ? 'Refreshing...' : 'Refresh Status' }}
</button>
</div>
<!-- Additional Actions -->
<div class="additional-actions">
<button
class="action-button tertiary"
@click="openNotificationSettings"
>
Open Notification Settings
</button>
<button
v-if="showBackgroundRefreshButton"
class="action-button tertiary"
@click="openBackgroundRefreshSettings"
>
Open Background Refresh Settings
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { Capacitor } from '@capacitor/core'
import { DailyNotification } from '@timesafari/daily-notification-plugin'
const router = useRouter()
const isRequesting = ref(false)
const isRefreshing = ref(false)
const statusText = ref('Ready to check permissions...')
const statusDetails = ref('')
const notificationGranted = ref(false)
const exactAlarmGranted = ref(false)
const backgroundRefreshEnabled = ref(false)
const showBackgroundRefreshButton = ref(false)
const statusCardClass = computed(() => {
if (isRequesting.value || isRefreshing.value) return 'status-yellow'
if (notificationGranted.value) return 'status-green'
return 'status-gray'
})
const statusIcon = computed(() => {
if (isRequesting.value || isRefreshing.value) return '⏳'
if (notificationGranted.value) return '✅'
return '⚠️'
})
const notificationStatusClass = computed(() => {
return notificationGranted.value ? 'status-success' : 'status-error'
})
const notificationStatusText = computed(() => {
return notificationGranted.value ? 'Granted' : 'Not Granted'
})
const exactAlarmStatusClass = computed(() => {
return exactAlarmGranted.value ? 'status-success' : 'status-warning'
})
const exactAlarmStatusText = computed(() => {
if (!exactAlarmGranted.value && Capacitor.getPlatform() === 'android') {
return 'Not Granted'
}
return null
})
const backgroundRefreshStatusClass = computed(() => {
return backgroundRefreshEnabled.value ? 'status-success' : 'status-warning'
})
const backgroundRefreshStatusText = computed(() => {
if (Capacitor.getPlatform() === 'ios') {
return backgroundRefreshEnabled.value ? 'Enabled' : 'Not Enabled'
}
return null
})
const checkStatus = async (): Promise<void> => {
isRefreshing.value = true
statusText.value = 'Checking permissions...'
statusDetails.value = ''
try {
if (!Capacitor.isNativePlatform()) {
statusText.value = 'Web platform - permissions not available'
statusDetails.value = 'This feature requires a native platform (iOS or Android)'
notificationGranted.value = false
return
}
const plugin = DailyNotification
if (!plugin) {
statusText.value = 'Plugin not available'
statusDetails.value = 'DailyNotification plugin could not be loaded'
notificationGranted.value = false
return
}
// Check permission status
const permissionStatus = await plugin.checkPermissionStatus()
notificationGranted.value = permissionStatus.notificationsEnabled ?? false
// Check exact alarm status (Android only)
if (Capacitor.getPlatform() === 'android') {
try {
const exactAlarmStatus = await plugin.getExactAlarmStatus()
exactAlarmGranted.value = exactAlarmStatus.enabled ?? false
} catch (error) {
console.warn('Could not check exact alarm status:', error)
}
}
// Check background refresh status (iOS only)
if (Capacitor.getPlatform() === 'ios') {
try {
if (typeof (plugin as any).getBackgroundTaskStatus === 'function') {
const backgroundStatus = await (plugin as any).getBackgroundTaskStatus()
// Check if tasks are registered (either fetchTaskRegistered or notifyTaskRegistered)
const tasksRegistered = backgroundStatus.tasksRegistered ??
backgroundStatus.fetchTaskRegistered ??
backgroundStatus.notifyTaskRegistered ??
false
backgroundRefreshEnabled.value = tasksRegistered
showBackgroundRefreshButton.value = true
}
} catch (error) {
console.warn('Could not check background refresh status:', error)
}
}
if (notificationGranted.value) {
statusText.value = 'Permission request completed! Check your device settings if needed.'
statusDetails.value = 'Notifications are enabled and ready to use.'
} else {
statusText.value = 'Permissions not granted'
statusDetails.value = 'Tap "Request Permissions" to enable notifications.'
}
} catch (error) {
console.error('❌ Failed to check permissions:', error)
statusText.value = `Status check failed: ${(error as Error).message}`
statusDetails.value = 'Please try again or check the console for details.'
notificationGranted.value = false
} finally {
isRefreshing.value = false
}
}
const requestPermissions = async (): Promise<void> => {
if (isRequesting.value) {
console.log('⏳ Permission request already in progress')
return
}
isRequesting.value = true
statusText.value = 'Requesting permissions...'
statusDetails.value = ''
try {
if (!Capacitor.isNativePlatform()) {
statusText.value = 'Web platform - permissions not available'
statusDetails.value = 'This feature requires a native platform (iOS or Android)'
return
}
const plugin = DailyNotification
if (!plugin) {
statusText.value = 'Plugin not available'
statusDetails.value = 'DailyNotification plugin could not be loaded'
return
}
// Request permissions - try iOS-specific method first, fallback to generic
if (typeof (plugin as any).requestNotificationPermissions === 'function') {
await (plugin as { requestNotificationPermissions: () => Promise<any> }).requestNotificationPermissions()
} else if (typeof (plugin as any).requestPermissions === 'function') {
await (plugin as { requestPermissions: () => Promise<any> }).requestPermissions()
} else {
throw new Error('Permission request method not available')
}
statusText.value = 'Permission request completed! Check your device settings if needed.'
statusDetails.value = 'Refreshing status...'
// Wait a moment for system to update, then refresh status
await new Promise(resolve => setTimeout(resolve, 1000))
await checkStatus()
} catch (error) {
console.error('❌ Permission request failed:', error)
statusText.value = `Permission request failed: ${(error as Error).message}`
statusDetails.value = 'Please check your device settings or try again.'
} finally {
isRequesting.value = false
}
}
const refreshStatus = async (): Promise<void> => {
await checkStatus()
}
const openNotificationSettings = async (): Promise<void> => {
try {
const plugin = DailyNotification
if (!plugin) {
alert('Plugin not available')
return
}
if (typeof (plugin as any).openNotificationSettings === 'function') {
await (plugin as any).openNotificationSettings()
} else {
alert('Open notification settings not available on this platform')
}
} catch (error) {
console.error('❌ Failed to open notification settings:', error)
alert(`Failed to open settings: ${(error as Error).message}`)
}
}
const openBackgroundRefreshSettings = async (): Promise<void> => {
try {
const plugin = DailyNotification
if (!plugin) {
alert('Plugin not available')
return
}
if (typeof (plugin as any).openBackgroundAppRefreshSettings === 'function') {
await (plugin as any).openBackgroundAppRefreshSettings()
} else {
alert('Open background refresh settings not available on this platform')
}
} catch (error) {
console.error('❌ Failed to open background refresh settings:', error)
alert(`Failed to open settings: ${(error as Error).message}`)
}
}
const goBack = (): void => {
router.push('/')
}
// Check status when component mounts
onMounted(async () => {
await checkStatus()
})
</script>
<style scoped>
.request-permissions-view {
max-width: 600px;
margin: 0 auto;
}
.view-header {
text-align: center;
margin-bottom: 30px;
}
.header-title-row {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
margin-bottom: 8px;
}
.back-button {
background: rgba(255, 255, 255, 0.2);
border: 1px solid rgba(255, 255, 255, 0.3);
color: white;
padding: 8px 12px;
border-radius: 8px;
cursor: pointer;
font-size: 18px;
font-weight: 500;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
min-width: 40px;
height: 40px;
}
.back-button:hover {
background: rgba(255, 255, 255, 0.3);
transform: translateX(-2px);
}
.page-title {
margin: 0 0 8px 0;
font-size: 28px;
font-weight: 700;
color: white;
}
.page-subtitle {
margin: 0;
font-size: 16px;
color: rgba(255, 255, 255, 0.8);
}
/* Status Section */
.status-section {
margin-bottom: 24px;
}
.status-card {
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 12px;
padding: 24px;
backdrop-filter: blur(10px);
text-align: center;
}
.status-card.status-yellow {
background: rgba(255, 255, 0, 0.3);
border-color: rgba(255, 255, 0, 0.5);
}
.status-card.status-green {
background: rgba(0, 255, 0, 0.3);
border-color: rgba(0, 255, 0, 0.5);
}
.status-card.status-gray {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.2);
}
.status-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.status-icon {
font-size: 48px;
}
.status-text {
font-size: 18px;
font-weight: 600;
color: white;
}
.status-details {
font-size: 14px;
color: rgba(255, 255, 255, 0.8);
margin-top: 4px;
}
/* Permissions Section */
.permissions-section {
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 12px;
padding: 24px;
margin-bottom: 24px;
backdrop-filter: blur(10px);
}
.section-title {
margin: 0 0 16px 0;
font-size: 20px;
font-weight: 600;
color: white;
}
.permissions-grid {
display: flex;
flex-direction: column;
gap: 12px;
}
.permission-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px;
background: rgba(255, 255, 255, 0.05);
border-radius: 8px;
}
.permission-label {
font-size: 14px;
font-weight: 500;
color: rgba(255, 255, 255, 0.9);
}
.permission-value {
font-size: 14px;
font-weight: 600;
padding: 4px 12px;
border-radius: 12px;
}
.permission-value.status-success {
background: rgba(34, 197, 94, 0.3);
color: #86efac;
}
.permission-value.status-error {
background: rgba(239, 68, 68, 0.3);
color: #fca5a5;
}
.permission-value.status-warning {
background: rgba(251, 191, 36, 0.3);
color: #fde047;
}
/* Action Section */
.action-section {
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 16px;
}
.action-button {
width: 100%;
padding: 14px;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.action-button.primary {
background: #1976d2;
color: white;
}
.action-button.primary:hover:not(:disabled) {
background: #1565c0;
}
.action-button.secondary {
background: rgba(255, 255, 255, 0.2);
border: 1px solid rgba(255, 255, 255, 0.3);
color: white;
}
.action-button.secondary:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.3);
}
.action-button.tertiary {
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.3);
color: white;
}
.action-button.tertiary:hover {
background: rgba(255, 255, 255, 0.1);
}
.action-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.additional-actions {
display: flex;
flex-direction: column;
gap: 8px;
}
/* Mobile responsiveness */
@media (max-width: 768px) {
.request-permissions-view {
padding: 16px 0;
}
.status-card,
.permissions-section {
padding: 20px;
}
.header-title-row {
gap: 8px;
}
.back-button {
min-width: 36px;
height: 36px;
font-size: 16px;
}
}
</style>

View File

@@ -10,7 +10,12 @@
<template>
<div class="schedule-view">
<div class="view-header">
<div class="header-title-row">
<button class="back-button" @click="goBack" aria-label="Go back to home">
</button>
<h1 class="page-title">📅 Schedule Notification</h1>
</div>
<p class="page-subtitle">Schedule a new daily notification</p>
</div>
@@ -34,6 +39,13 @@
<button class="action-button primary" @click="scheduleNotification" :disabled="isScheduling">
{{ isScheduling ? 'Scheduling...' : 'Schedule Notification' }}
</button>
<button class="action-button secondary" @click="testNotification" :disabled="isTesting">
{{ isTesting ? 'Testing...' : '🧪 Test Notification (5 seconds)' }}
</button>
</div>
<div v-if="scheduleFeedback" class="feedback-message" :class="scheduleFeedback.type">
{{ scheduleFeedback.message }}
</div>
</div>
</div>
@@ -41,16 +53,45 @@
<script lang="ts">
import { Vue, Component, toNative } from 'vue-facing-decorator'
import router from '../router'
@Component
class ScheduleView extends Vue {
scheduleTime = '09:00'
scheduleTime = this.getDefaultScheduleTime()
notificationTitle = 'Daily Update'
notificationMessage = 'Your daily notification is ready!'
isScheduling = false
isTesting = false
scheduleFeedback: { type: 'success' | 'error' | 'info', message: string } | null = null
/**
* Calculate default schedule time as current time + 3 minutes (rounded up)
* @returns Time string in HH:MM format
*/
getDefaultScheduleTime(): string {
const now = new Date()
// Round up to next minute if there are any seconds
if (now.getSeconds() > 0 || now.getMilliseconds() > 0) {
now.setMinutes(now.getMinutes() + 1)
now.setSeconds(0)
now.setMilliseconds(0)
}
// Add 3 minutes
now.setMinutes(now.getMinutes() + 3)
// Format as HH:MM
const hours = now.getHours().toString().padStart(2, '0')
const minutes = now.getMinutes().toString().padStart(2, '0')
return `${hours}:${minutes}`
}
goBack() {
router.push('/')
}
async scheduleNotification() {
this.isScheduling = true
this.scheduleFeedback = null
try {
console.log('🔄 Starting notification scheduling...')
@@ -60,6 +101,17 @@ class ScheduleView extends Vue {
console.log('✅ Plugin loaded:', plugin)
// Calculate when the notification will fire
const [hours, minutes] = this.scheduleTime.split(':').map(Number)
const now = new Date()
const scheduledTime = new Date()
scheduledTime.setHours(hours, minutes, 0, 0)
// If the time has passed today, schedule for tomorrow
if (scheduledTime <= now) {
scheduledTime.setDate(scheduledTime.getDate() + 1)
}
const options = {
time: this.scheduleTime,
title: this.notificationTitle,
@@ -74,8 +126,22 @@ class ScheduleView extends Vue {
console.log('✅ Notification scheduled successfully!')
// Show success feedback to user
alert('Notification scheduled successfully!')
// Show success feedback with timing info
const timeUntil = scheduledTime.getTime() - now.getTime()
const hoursUntil = Math.floor(timeUntil / (1000 * 60 * 60))
const minutesUntil = Math.floor((timeUntil % (1000 * 60 * 60)) / (1000 * 60))
let timeMessage = ''
if (hoursUntil > 0) {
timeMessage = `Notification will appear in ${hoursUntil} hour${hoursUntil > 1 ? 's' : ''} and ${minutesUntil} minute${minutesUntil !== 1 ? 's' : ''} (at ${scheduledTime.toLocaleTimeString()})`
} else {
timeMessage = `Notification will appear in ${minutesUntil} minute${minutesUntil !== 1 ? 's' : ''} (at ${scheduledTime.toLocaleTimeString()})`
}
this.scheduleFeedback = {
type: 'success',
message: `✅ Notification scheduled successfully! ${timeMessage}. Use "Test Notification" to see one immediately.`
}
} catch (error) {
console.error('❌ Failed to schedule notification:', error)
@@ -85,19 +151,65 @@ class ScheduleView extends Vue {
stack: error.stack
})
// Show error feedback to user
alert(`Failed to schedule notification: ${error.message}`)
this.scheduleFeedback = {
type: 'error',
message: `❌ Failed to schedule notification: ${error.message}`
}
} finally {
this.isScheduling = false
}
}
async testNotification() {
this.isTesting = true
this.scheduleFeedback = null
try {
console.log('🧪 Testing notification (will fire in 5 seconds)...')
// Import and use the real plugin
const { DailyNotification } = await import('@timesafari/daily-notification-plugin')
const plugin = DailyNotification
console.log('✅ Plugin loaded:', plugin)
// Use testAlarm to schedule a notification that fires in 5 seconds
const result = await plugin.testAlarm({ secondsFromNow: 5 })
console.log('✅ Test notification scheduled:', result)
this.scheduleFeedback = {
type: 'success',
message: '✅ Test notification scheduled! It will appear in 5 seconds. Look for it at the top of your screen.'
}
// Clear feedback after 10 seconds
setTimeout(() => {
this.scheduleFeedback = null
}, 10000)
} catch (error) {
console.error('❌ Failed to schedule test notification:', error)
console.error('❌ Error details:', {
name: error.name,
message: error.message,
stack: error.stack
})
this.scheduleFeedback = {
type: 'error',
message: `❌ Failed to schedule test notification: ${error.message}`
}
} finally {
this.isTesting = false
}
}
}
export default toNative(ScheduleView)
</script>
<style scoped>
.schedule-view {
padding: 20px;
max-width: 600px;
margin: 0 auto;
}
@@ -107,6 +219,36 @@ export default toNative(ScheduleView)
margin-bottom: 30px;
}
.header-title-row {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
margin-bottom: 8px;
}
.back-button {
background: rgba(255, 255, 255, 0.2);
border: 1px solid rgba(255, 255, 255, 0.3);
color: white;
padding: 8px 12px;
border-radius: 8px;
cursor: pointer;
font-size: 18px;
font-weight: 500;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
min-width: 40px;
height: 40px;
}
.back-button:hover {
background: rgba(255, 255, 255, 0.3);
transform: translateX(-2px);
}
.page-title {
margin: 0 0 8px 0;
font-size: 28px;
@@ -190,14 +332,60 @@ export default toNative(ScheduleView)
cursor: not-allowed;
}
.action-button.secondary {
background: rgba(255, 255, 255, 0.2);
color: white;
margin-top: 12px;
}
.action-button.secondary:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.3);
}
.feedback-message {
margin-top: 16px;
padding: 12px;
border-radius: 8px;
font-size: 14px;
line-height: 1.5;
}
.feedback-message.success {
background: rgba(76, 175, 80, 0.2);
border: 1px solid rgba(76, 175, 80, 0.4);
color: #81c784;
}
.feedback-message.error {
background: rgba(244, 67, 54, 0.2);
border: 1px solid rgba(244, 67, 54, 0.4);
color: #e57373;
}
.feedback-message.info {
background: rgba(33, 150, 243, 0.2);
border: 1px solid rgba(33, 150, 243, 0.4);
color: #64b5f6;
}
/* Mobile responsiveness */
@media (max-width: 768px) {
.schedule-view {
padding: 16px;
padding: 16px 0;
}
.schedule-form {
padding: 20px;
}
.header-title-row {
gap: 8px;
}
.back-button {
min-width: 36px;
height: 36px;
font-size: 16px;
}
}
</style>

View File

@@ -1,7 +1,12 @@
<template>
<div class="settings-view">
<div class="view-header">
<div class="header-title-row">
<button class="back-button" @click="goBack" aria-label="Go back to home">
</button>
<h1 class="page-title"> Settings</h1>
</div>
<p class="page-subtitle">Configure app preferences</p>
</div>
<div class="placeholder-content">
@@ -12,15 +17,19 @@
<script lang="ts">
import { Vue, Component, toNative } from 'vue-facing-decorator'
import router from '../router'
@Component
class SettingsView extends Vue {}
class SettingsView extends Vue {
goBack() {
router.push('/')
}
}
export default toNative(SettingsView)
</script>
<style scoped>
.settings-view {
padding: 20px;
max-width: 1200px;
margin: 0 auto;
}
@@ -30,6 +39,36 @@ export default toNative(SettingsView)
margin-bottom: 30px;
}
.header-title-row {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
margin-bottom: 8px;
}
.back-button {
background: rgba(255, 255, 255, 0.2);
border: 1px solid rgba(255, 255, 255, 0.3);
color: white;
padding: 8px 12px;
border-radius: 8px;
cursor: pointer;
font-size: 18px;
font-weight: 500;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
min-width: 40px;
height: 40px;
}
.back-button:hover {
background: rgba(255, 255, 255, 0.3);
transform: translateX(-2px);
}
.page-title {
margin: 0 0 8px 0;
font-size: 28px;
@@ -52,4 +91,17 @@ export default toNative(SettingsView)
color: rgba(255, 255, 255, 0.8);
backdrop-filter: blur(10px);
}
/* Mobile responsiveness */
@media (max-width: 768px) {
.header-title-row {
gap: 8px;
}
.back-button {
min-width: 36px;
height: 36px;
font-size: 16px;
}
}
</style>

View File

@@ -1,7 +1,12 @@
<template>
<div class="status-view">
<div class="view-header">
<div class="header-title-row">
<button class="back-button" @click="goBack" aria-label="Go back to home">
</button>
<h1 class="page-title">📊 Status Matrix</h1>
</div>
<p class="page-subtitle">Comprehensive system status and diagnostics</p>
</div>
@@ -44,28 +49,7 @@
<div class="diagnostics-section">
<h2>System Diagnostics</h2>
<div class="diagnostics-content">
<div class="diagnostics-info">
<div class="info-item">
<span class="label">App Version:</span>
<span class="value">{{ diagnostics.appVersion }}</span>
</div>
<div class="info-item">
<span class="label">Platform:</span>
<span class="value">{{ diagnostics.platform }}</span>
</div>
<div class="info-item">
<span class="label">API Level:</span>
<span class="value">{{ diagnostics.apiLevel }}</span>
</div>
<div class="info-item">
<span class="label">Timezone:</span>
<span class="value">{{ diagnostics.timezone }}</span>
</div>
<div class="info-item">
<span class="label">Last Updated:</span>
<span class="value">{{ diagnostics.lastUpdated }}</span>
</div>
</div>
<StatusList :items="diagnosticsItems" />
<div class="diagnostics-json">
<h3>Raw Diagnostics (JSON)</h3>
@@ -87,7 +71,10 @@
<script lang="ts">
import { Vue, Component, toNative } from 'vue-facing-decorator'
import { Capacitor } from '@capacitor/core'
import router from '../router'
import StatusCard from '../components/cards/StatusCard.vue'
import StatusList from '../components/StatusList.vue'
import { createTypedPlugin } from '../lib/typed-plugin'
import { collectDiagnostics, copyDiagnosticsToClipboard, type ComprehensiveDiagnostics } from '../lib/diagnostics-export'
@@ -108,17 +95,25 @@ type Diagnostics = ComprehensiveDiagnostics
@Component({
components: {
StatusCard
StatusCard,
StatusList
}
})
class StatusView extends Vue {
isRefreshing = false
errorMessage = ''
goBack() {
router.push('/')
}
diagnostics: Diagnostics = {
appVersion: '1.0.0',
platform: 'Android',
apiLevel: 'Unknown',
timezone: 'Unknown',
platform: (() => {
const platform = Capacitor.getPlatform()
return platform.charAt(0).toUpperCase() + platform.slice(1)
})(),
apiLevel: Capacitor.getPlatform() === 'android' ? 'Unknown' : 'N/A',
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
lastUpdated: 'Never',
postNotificationsGranted: false,
exactAlarmGranted: false,
@@ -225,6 +220,16 @@ class StatusView extends Vue {
return JSON.stringify(this.diagnostics, null, 2)
}
get diagnosticsItems() {
return [
{ label: 'App Version:', value: this.diagnostics.appVersion },
{ label: 'Platform:', value: this.diagnostics.platform },
{ label: 'API Level:', value: this.diagnostics.apiLevel },
{ label: 'Timezone:', value: this.diagnostics.timezone },
{ label: 'Last Updated:', value: this.diagnostics.lastUpdated }
]
}
async mounted() {
await this.refreshStatus()
}
@@ -336,7 +341,6 @@ export default toNative(StatusView)
<style scoped>
.status-view {
padding: 20px;
max-width: 1200px;
margin: 0 auto;
}
@@ -346,6 +350,36 @@ export default toNative(StatusView)
margin-bottom: 30px;
}
.header-title-row {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
margin-bottom: 8px;
}
.back-button {
background: rgba(255, 255, 255, 0.2);
border: 1px solid rgba(255, 255, 255, 0.3);
color: white;
padding: 8px 12px;
border-radius: 8px;
cursor: pointer;
font-size: 18px;
font-weight: 500;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
min-width: 40px;
height: 40px;
}
.back-button:hover {
background: rgba(255, 255, 255, 0.3);
transform: translateX(-2px);
}
.page-title {
margin: 0 0 8px 0;
font-size: 28px;
@@ -453,32 +487,10 @@ export default toNative(StatusView)
gap: 24px;
}
.diagnostics-info {
display: flex;
flex-direction: column;
gap: 12px;
}
.info-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.info-item:last-child {
border-bottom: none;
}
.info-item .label {
color: rgba(255, 255, 255, 0.8);
font-weight: 500;
}
.info-item .value {
color: white;
font-weight: 600;
.diagnostics-json {
min-width: 0;
overflow: hidden;
}
.diagnostics-json h3 {
@@ -498,8 +510,12 @@ export default toNative(StatusView)
font-size: 12px;
line-height: 1.4;
overflow-x: auto;
max-height: 300px;
overflow-y: auto;
max-height: 300px;
max-width: 100%;
width: 100%;
box-sizing: border-box;
white-space: pre;
}
/* Error Section */
@@ -534,7 +550,7 @@ export default toNative(StatusView)
/* Responsive Design */
@media (max-width: 768px) {
.status-view {
padding: 16px;
padding: 16px 0;
}
.matrix-header {
@@ -574,5 +590,15 @@ export default toNative(StatusView)
.action-button {
justify-content: center;
}
.header-title-row {
gap: 8px;
}
.back-button {
min-width: 36px;
height: 36px;
font-size: 16px;
}
}
</style>

View File

@@ -1,7 +1,12 @@
<template>
<div class="user-zero-view">
<div class="view-header">
<div class="header-title-row">
<button class="back-button" @click="goBack" aria-label="Go back to home">
</button>
<h1 class="page-title">User Zero Stars Testing</h1>
</div>
<p class="page-subtitle">Test starred projects querying with TimeSafari User Zero</p>
</div>
@@ -103,8 +108,11 @@
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { TEST_USER_ZERO_CONFIG, TestUserZeroAPI } from '../config/test-user-zero'
const router = useRouter()
// Reactive state
const config = reactive(TEST_USER_ZERO_CONFIG)
const isTesting = ref(false)
@@ -272,11 +280,17 @@ function toggleMockMode() {
function clearError() {
errorMessage.value = ''
}
/**
* Navigate back to home
*/
function goBack() {
router.push('/')
}
</script>
<style scoped>
.user-zero-view {
padding: 20px;
max-width: 800px;
margin: 0 auto;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
@@ -289,6 +303,36 @@ function clearError() {
margin-bottom: 30px;
}
.header-title-row {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
margin-bottom: 8px;
}
.back-button {
background: rgba(255, 255, 255, 0.2);
border: 1px solid rgba(255, 255, 255, 0.3);
color: white;
padding: 8px 12px;
border-radius: 8px;
cursor: pointer;
font-size: 18px;
font-weight: 500;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
min-width: 40px;
height: 40px;
}
.back-button:hover {
background: rgba(255, 255, 255, 0.3);
transform: translateX(-2px);
}
.page-title {
margin: 0 0 8px 0;
font-size: 28px;
@@ -462,7 +506,7 @@ function clearError() {
@media (max-width: 768px) {
.user-zero-view {
padding: 16px;
padding: 16px 0;
}
.config-grid {
@@ -476,5 +520,15 @@ function clearError() {
.test-button {
width: 100%;
}
.header-title-row {
gap: 8px;
}
.back-button {
min-width: 36px;
height: 36px;
font-size: 16px;
}
}
</style>

View File

@@ -182,32 +182,52 @@
}
function loadPluginStatus() {
console.log('loadPluginStatus called');
console.log('[UI Refresh] loadPluginStatus called');
const pluginStatusContent = document.getElementById('pluginStatusContent');
const statusCard = document.getElementById('statusCard');
try {
if (!window.DailyNotification) {
console.warn('[UI Refresh] DailyNotification plugin not available');
pluginStatusContent.innerHTML = '❌ DailyNotification plugin not available';
statusCard.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background
return;
}
console.log('[UI Refresh] Calling getNotificationStatus()...');
window.DailyNotification.getNotificationStatus()
.then(result => {
console.log('[UI Refresh] getNotificationStatus result:', {
nextNotificationTime: result.nextNotificationTime,
isEnabled: result.isEnabled,
pending: result.pending,
lastNotificationTime: result.lastNotificationTime
});
const nextTime = formatDateTimeNormalized(result.nextNotificationTime);
const hasSchedules = result.isEnabled || (result.pending && result.pending > 0);
const statusIcon = hasSchedules ? '✅' : '⏸️';
console.log('[UI Refresh] Updating UI:', {
nextTime: nextTime,
hasSchedules: hasSchedules,
statusIcon: statusIcon
});
pluginStatusContent.innerHTML = `${statusIcon} Active Schedules: ${hasSchedules ? 'Yes' : 'No'}<br>
📅 Next Notification: ${nextTime}<br>
⏳ Pending: ${result.pending || 0}`;
statusCard.style.background = hasSchedules ?
'rgba(0, 255, 0, 0.15)' : 'rgba(255, 255, 255, 0.1)'; // Green if active, light gray if none
console.log('[UI Refresh] UI updated successfully');
})
.catch(error => {
console.error('[UI Refresh] getNotificationStatus failed:', error);
pluginStatusContent.innerHTML = `⚠️ Status check failed: ${error.message}`;
statusCard.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background
});
} catch (error) {
console.error('[UI Refresh] loadPluginStatus exception:', error);
pluginStatusContent.innerHTML = `⚠️ Status check failed: ${error.message}`;
statusCard.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background
}
@@ -444,18 +464,36 @@
// Check for notification delivery and status updates periodically
function checkNotificationDelivery() {
if (!window.DailyNotification) return;
if (!window.DailyNotification) {
console.log('[Poll] DailyNotification not available, skipping check');
return;
}
console.log('[Poll] checkNotificationDelivery called');
window.DailyNotification.getNotificationStatus()
.then(result => {
console.log('[Poll] Status check result:', {
nextNotificationTime: result.nextNotificationTime,
lastNotificationTime: result.lastNotificationTime,
lastKnownNextNotificationTime: lastKnownNextNotificationTime
});
// Check for notification delivery
if (result.lastNotificationTime) {
const lastTime = new Date(result.lastNotificationTime);
const now = new Date();
const timeDiff = now - lastTime;
console.log('[Poll] Notification delivery check:', {
lastTime: lastTime.toISOString(),
now: now.toISOString(),
timeDiff: timeDiff,
withinWindow: timeDiff > 0 && timeDiff < 120000
});
// If notification was received in the last 2 minutes, show indicator
if (timeDiff > 0 && timeDiff < 120000) {
console.log('[Poll] Notification received recently, showing indicator');
const indicator = document.getElementById('notificationReceivedIndicator');
const timeSpan = document.getElementById('notificationReceivedTime');
@@ -471,7 +509,9 @@
}, 30000);
// Force immediate refresh when notification is received (rollover may have occurred)
console.log('[Poll] Scheduling UI refresh in 1 second (waiting for rollover)...');
setTimeout(() => {
console.log('[Poll] Triggering UI refresh after notification received');
loadPluginStatus();
}, 1000); // Wait 1 second for rollover to complete
}
@@ -480,20 +520,37 @@
// Detect if nextNotificationTime changed (rollover occurred)
const currentNextTime = result.nextNotificationTime;
console.log('[Poll] Comparing nextNotificationTime:', {
current: currentNextTime,
lastKnown: lastKnownNextNotificationTime,
changed: currentNextTime && currentNextTime !== lastKnownNextNotificationTime
});
if (currentNextTime && currentNextTime !== lastKnownNextNotificationTime) {
if (lastKnownNextNotificationTime !== null) {
console.log('Next notification time changed - rollover detected!');
console.log('[Poll] ⚠️ Next notification time changed - rollover detected!', {
old: lastKnownNextNotificationTime,
new: currentNextTime,
oldDate: new Date(lastKnownNextNotificationTime).toISOString(),
newDate: new Date(currentNextTime).toISOString()
});
// Force immediate refresh
loadPluginStatus();
} else {
console.log('[Poll] Initializing lastKnownNextNotificationTime:', currentNextTime);
}
lastKnownNextNotificationTime = currentNextTime;
} else {
console.log('[Poll] No change detected in nextNotificationTime');
}
// Auto-refresh plugin status periodically to show updated next notification time after rollover
// This ensures the UI updates when the plugin reschedules the notification
console.log('[Poll] Triggering periodic UI refresh');
loadPluginStatus();
})
.catch(error => {
console.error('[Poll] Status check failed:', error);
// Silently fail - this is just for visual feedback
});
}