# Plugin Feedback: Implement scheduleDualNotification on iOS **Target repo:** daily-notification-plugin (iOS native layer) **Purpose:** Document for implementing or fixing `scheduleDualNotification` on iOS so the consuming app (TimeSafari / crowd-funder) can enable “New Activity” notifications. **Consuming app doc:** `doc/notification-new-activity-lay-of-the-land.md` --- ## Troubleshooting: `UNIMPLEMENTED` on iOS (Capacitor 6) If **`configureNativeFetcher`** (or other DailyNotification methods) work but **`scheduleDualNotification`** still fails with **`{"code":"UNIMPLEMENTED"}`** and you **do not** see a native log line like `To Native -> DailyNotification scheduleDualNotification`, the failure is often **not** missing Swift code—it is **Capacitor’s JavaScript layer** rejecting the call because the method is **not listed** in `window.Capacitor.PluginHeaders` for `DailyNotification`. Those headers are built at runtime from the **compiled** plugin’s `pluginMethods` list (`CAPBridgedPlugin`). **Fix in the consuming app (usual cause: stale Pods / binary):** 1. Ensure `node_modules/@timesafari/daily-notification-plugin` includes `scheduleDualNotification` in `DailyNotificationPlugin.swift`’s `pluginMethods` (v2.1.0+). 2. From the project root: `npx cap sync ios` 3. `cd ios/App && pod install` (or delete `Pods` + `Podfile.lock` and `pod install` if upgrading the plugin). 4. Xcode: **Product → Clean Build Folder**, then rebuild and run on device/simulator. **Verify:** Safari → Develop → attach to the app WebView → Console: inspect `window.Capacitor.PluginHeaders` and confirm the `DailyNotification` entry’s `methods` array includes `{ name: "scheduleDualNotification", ... }`. If a full clean rebuild still doesn't fix it, clear Xcode's **system** DerivedData (quit Xcode, run `rm -rf ~/Library/Developer/Xcode/DerivedData/*TimeSafari*`, reopen and rebuild). On launch the app logs `[Capacitor] DNP PluginHeaders methods: [...]`; if that list omits `scheduleDualNotification`, the native binary is still stale. If the method **is** present in headers but scheduling still fails, debug the Swift implementation (reject message, BG tasks, etc.). ### Misleading `UNIMPLEMENTED` before `scheduleDualNotification` Capacitor’s `registerPlugin` proxy returns a **callable stub for every property name**. So `if (DailyNotification?.updateStarredPlans)` is **always truthy** even when iOS does not expose `updateStarredPlans` in `pluginMethods`. Calling that stub throws **`UNIMPLEMENTED`** in JS **before** any `To Native -> DailyNotification scheduleDualNotification` line appears—so logs look like “dual schedule is unimplemented” when the real failure was **`updateStarredPlans`**. **Consuming-app fix:** treat `updateStarredPlans` as optional: catch `UNIMPLEMENTED` and continue, or only call after verifying the method name exists on `PluginHeaders` for `DailyNotification`. If the plugin adds `updateStarredPlans` natively later, starred-plan filtering will start working without app changes. --- ## Current behavior - The **consuming app** calls `DailyNotification.scheduleDualNotification({ config })` from TypeScript when the user turns on “New Activity Notification” and picks a time (native iOS). - On **iOS**, the plugin rejects with **`code: "UNIMPLEMENTED"`** (observed in Xcode: `[AccountViewView] scheduleNewActivityDualNotification failed: {"code":"UNIMPLEMENTED"}`). - On **Android**, the same call is expected to work (dual schedule: content fetch + user notification). The app has already: - Called `configureNativeFetcher({ apiBaseUrl, activeDid, jwtToken })` so the plugin can use the native fetcher for API-driven content. - Called `updateStarredPlans({ planIds })` so the fetcher knows which plans to query. - Built a `config` object that matches the plugin’s `DualScheduleConfiguration` (see below). So the missing piece on iOS is a **working implementation** of `scheduleDualNotification` that accepts this config and schedules the dual flow (content fetch at one time, user notification at a later time). --- ## Call from the consuming app ```ts await DailyNotification.scheduleDualNotification({ config }); ``` `config` is built by the app’s `buildDualScheduleConfig({ notifyTime })` and has the following shape. --- ## Config shape the app sends The app sends a single `config` object that matches the plugin’s `DualScheduleConfiguration` (see `definitions.ts`). Example for `notifyTime: "18:30"` (6:30 PM): ```json { "contentFetch": { "enabled": true, "schedule": "25 18 * * *", "callbacks": {} }, "userNotification": { "enabled": true, "schedule": "30 18 * * *", "title": "New Activity", "body": "Check your starred projects and offers for updates.", "sound": true, "priority": "normal" }, "relationship": { "autoLink": true, "contentTimeout": 300000, "fallbackBehavior": "show_default" } } ``` - **Cron format:** `"minute hour * * *"` (daily at that local time). - **contentFetch.schedule:** 5 minutes **before** the user’s chosen time (e.g. 18:25 for notify at 18:30). - **userNotification.schedule:** The user’s chosen time (e.g. 18:30). - **contentFetch.callbacks:** The app sends `{}`; the actual fetch is done by the **native fetcher** (already configured via `configureNativeFetcher`). The plugin should run the content-fetch job at the contentFetch cron and use the native fetcher to get content; at userNotification time it should show a notification using that content or the fallback title/body. - **relationship.contentTimeout:** Milliseconds to wait for content before showing the notification (app uses 5 minutes = 300000). - **relationship.fallbackBehavior:** `"show_default"` means if content isn’t ready in time, show the notification with the default title/body from `userNotification`. The app does **not** send `contentFetch.url` or `contentFetch.timesafariConfig`; it relies on the native fetcher and `configureNativeFetcher` / `updateStarredPlans` for API behavior. --- ## Expected plugin behavior (iOS) 1. **Accept** the `config` argument (object with `contentFetch`, `userNotification`, and optional `relationship`). 2. **Parse** the cron expressions for `contentFetch.schedule` and `userNotification.schedule` (e.g. using a shared cron parser or the same approach as Android). 3. **Schedule** two things: - **Content fetch:** At the time given by `contentFetch.schedule`, run the **native notification content fetcher** (the one configured via `configureNativeFetcher`). Store the result in the plugin’s cache (or equivalent) for use when the user notification fires. - **User notification:** At the time given by `userNotification.schedule`, show a local notification. Use cached content from the fetch if available and within `relationship.contentTimeout`; otherwise use `userNotification.title` and `userNotification.body` (per `relationship.fallbackBehavior: "show_default"`). 4. **Do not** reject with `UNIMPLEMENTED`; resolve the promise once scheduling has succeeded (or reject with a descriptive error if scheduling fails). 5. **cancelDualSchedule()** should cancel both the content-fetch schedule and the user-notification schedule so the user can turn off New Activity from the app. Alignment with **Android** (if implemented there) is desirable: same config shape, same semantics (prefetch then notify, fallback to default title/body). The plugin’s **definitions.ts** already defines `DualScheduleConfiguration`, `ContentFetchConfig`, `UserNotificationConfig`, and the `scheduleDualNotification` / `cancelDualSchedule` API. --- ## Where to look in the plugin (iOS) - **Plugin entry:** `ios/Plugin/DailyNotificationPlugin.swift` (or equivalent)—find the handler for `scheduleDualNotification` (e.g. method that receives `call.getObject("config")`). - **Android reference:** `android/` implementation of `scheduleDualNotification` and how it schedules WorkManager/alarms for content fetch and for the user notification. - **Definitions:** `src/definitions.ts` — `DualScheduleConfiguration`, `scheduleDualNotification`, `cancelDualSchedule`. - **Native fetcher:** The app configures the native fetcher before calling `scheduleDualNotification`; the iOS plugin should invoke that same fetcher when the content-fetch job runs (BGAppRefreshTask or equivalent), not a URL from the config. --- ## Acceptance criteria - [ ] On iOS, calling `DailyNotification.scheduleDualNotification({ config })` with the config shape above **does not** reject with `code: "UNIMPLEMENTED"`. - [ ] The content-fetch job is scheduled at `contentFetch.schedule` and uses the configured native fetcher to fetch content. - [ ] The user notification is scheduled at `userNotification.schedule` and shows with API-derived content when available, or with `userNotification.title` / `userNotification.body` as fallback. - [ ] Calling `DailyNotification.cancelDualSchedule()` cancels both schedules on iOS. - [ ] Behavior is consistent with Android where applicable (same config, same lifecycle). --- ## Relationship to consuming app The consuming app will continue to call: 1. `configureNativeFetcher(...)` on startup and when enabling New Activity. 2. `updateStarredPlans({ planIds })` when enabling or when Account view loads with New Activity on. 3. `scheduleDualNotification({ config })` when the user turns on New Activity and picks a time. 4. `cancelDualSchedule()` when the user turns off New Activity. No change to the app’s config shape or call order is planned; the fix is entirely on the plugin iOS side to implement or correct `scheduleDualNotification` (and ensure `cancelDualSchedule` clears the dual schedule).