Merge branch 'ios-2'

This commit is contained in:
Jose Olarte III
2026-01-22 14:43:52 +08:00
32 changed files with 3351 additions and 341 deletions

View File

@@ -44,9 +44,11 @@ npx cap run android
## Prerequisites
### Required Software
- **Android Studio** (latest stable version)
- **Android Studio** (latest stable version) - for Android development
- **Java 11+** (for Kotlin compilation)
- **Android SDK** with API level 21+
- **Xcode** (latest stable version) - for iOS development (macOS only)
- **Xcode Command Line Tools** - required for iOS builds (includes `xcodebuild`, `sqlite3`, etc.)
- **Node.js** 16+ (for TypeScript compilation)
- **npm** or **yarn** (for dependency management)
@@ -54,11 +56,35 @@ npx cap run android
- **Gradle Wrapper** (included in project)
- **Kotlin** (configured in build.gradle)
- **TypeScript** (for plugin interface)
- **CocoaPods** - for iOS dependency management
### iOS-Specific Prerequisites
**Xcode Command Line Tools** are required for iOS builds. The build script will verify these are installed:
```bash
# Install Xcode Command Line Tools (if not already installed)
xcode-select --install
```
**Verification:**
```bash
# Check if Command Line Tools are configured
xcode-select -p
# Verify xcodebuild is available
xcodebuild -version
# Verify sqlite3 is available (part of Command Line Tools)
sqlite3 --version
```
**Note:** The build script automatically checks for Command Line Tools and will fail with clear error messages if they're missing.
### System Requirements
- **RAM**: 4GB minimum, 8GB recommended
- **Storage**: 2GB free space
- **OS**: Windows 10+, macOS 10.14+, or Linux
- **OS**: Windows 10+, macOS 10.14+, or Linux (iOS development requires macOS)
## Build Methods
@@ -297,6 +323,8 @@ android/build/reports/tests/test/index.html
### iOS Native Build Process
**Prerequisites:** Ensure Xcode Command Line Tools are installed (see [Prerequisites](#prerequisites) section). The build script will verify this automatically.
#### 1. Navigate to iOS Directory
```bash
cd ios
@@ -307,6 +335,12 @@ cd ios
pod install
```
**Note:** If you encounter issues with `pod install`, ensure Xcode Command Line Tools are properly configured:
```bash
xcode-select --install # Install if missing
xcode-select -p # Verify installation path
```
#### 3. Build Commands
```bash
# Build using Xcode command line
@@ -782,6 +816,13 @@ The project includes several automated build scripts in the `scripts/` directory
./scripts/build-native.sh --platform ios
./scripts/build-native.sh --verbose
# Clean build (removes all build artifacts and caches)
./scripts/clean-build.sh
./scripts/clean-build.sh --all # Also cleans caches and reinstalls dependencies
./scripts/clean-build.sh --clean-gradle-cache # Clean Gradle cache
./scripts/clean-build.sh --clean-derived-data # Clean Xcode DerivedData
./scripts/clean-build.sh --reinstall-node # Reinstall node_modules
# TimeSafari-specific builds
node scripts/build-timesafari.js
@@ -948,6 +989,28 @@ adb logcat | grep DailyNotification
## Troubleshooting
### Clean Build (First Step for Many Issues)
If you encounter persistent build issues, try a clean build first:
```bash
# Clean all build artifacts (recommended first step)
./scripts/clean-build.sh
# Clean everything including caches (for stubborn issues)
./scripts/clean-build.sh --all
# Then rebuild
./scripts/build-native.sh --platform all
```
**When to use clean-build:**
- Build errors that don't make sense
- Dependency conflicts
- Stale build artifacts
- After switching branches
- After updating dependencies
### Common Issues
#### Gradle Sync Failures
@@ -1019,6 +1082,39 @@ File → Project Structure → SDK Location
# Solution: Check Kotlin version in build.gradle
```
#### iOS Build Issues
```bash
# Problem: "Xcode Command Line Tools not configured"
# Error: xcode-select -p fails or xcodebuild not found
# Solution: Install Command Line Tools
xcode-select --install
# Verify installation
xcode-select -p
xcodebuild -version
sqlite3 --version
# Problem: "sqlite3 not found" or linker errors with SQLite
# Solution: Ensure Command Line Tools are properly installed
# The build script checks for this automatically, but if you see linker errors:
xcode-select --install
# Problem: pkgx SQLite conflicts with iOS builds
# Error: Linker errors about libsqlite3.dylib
# Solution: The build script automatically handles this by unsetting problematic
# environment variables. If issues persist:
unset PKGX_DIR DYLD_LIBRARY_PATH LD_LIBRARY_PATH
./scripts/build-native.sh --platform ios
# Problem: "pod install" fails
# Solution: Ensure Command Line Tools are installed
xcode-select --install
# Then reinstall CocoaPods dependencies
cd ios
pod deintegrate
pod install
```
#### Capacitor Integration Issues
```bash
# Problem: Plugin not found in Capacitor app

View File

@@ -1,76 +1,46 @@
refactor(android): Complete plugin refactoring and safety fixes (Batches 0-7)
docs(building): update BUILDING.md with iOS prerequisites and clean-build script
Comprehensive refactoring to make DailyNotificationPlugin a thin adapter,
eliminate duplicated logic, remove unsafe operations, and harden security.
Updates BUILDING.md to reflect recent changes in build-native.sh, especially
the Xcode Command Line Tools prerequisite check and the clean-build script.
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:
- BUILDING.md didn't mention Xcode Command Line Tools prerequisite
(recently added to build-native.sh)
- clean-build.sh script exists but wasn't documented
- iOS build troubleshooting lacked Command Line Tools guidance
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 Xcode Command Line Tools to Prerequisites section
- Document installation command (xcode-select --install)
- Include verification steps (xcode-select -p, xcodebuild -version)
- Note that build script automatically checks for these tools
- Explain that sqlite3 is part of Command Line Tools
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
- Document clean-build.sh script in Build Scripts section
- Basic usage: ./scripts/clean-build.sh
- All options: --all, --clean-gradle-cache, --clean-derived-data,
--reinstall-node
- Explain when to use clean builds
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 iOS Native Build Process section
- Add prerequisite note about Command Line Tools
- Include troubleshooting commands for pod install issues
- Reference prerequisites section for details
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)
- Add comprehensive troubleshooting sections
- Clean Build section at start of Troubleshooting
- Recommends clean-build as first step for many issues
- Lists when to use clean builds
- iOS Build Issues section
- Command Line Tools configuration errors
- SQLite/linker issues and pkgx conflicts
- CocoaPods installation problems
- All with clear solutions and commands
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
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 documentation now accurately reflects:
- Xcode Command Line Tools as required iOS prerequisite
- clean-build.sh as available build tool
- Complete iOS troubleshooting workflow
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
- BUILDING.md

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

@@ -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

@@ -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
@@ -1120,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
*
@@ -1253,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)
@@ -1270,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
*
@@ -1277,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(
@@ -1294,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
}
/**
@@ -1943,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,17 +130,40 @@ check_environment() {
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
log_error "Java version 11 or higher is required"
exit 1
# 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
log_error "Java version 11 or higher is required"
exit 1
fi
# Check for Android SDK
if [ -z "$ANDROID_HOME" ]; then
log_error "ANDROID_HOME environment variable is not set"
exit 1
fi
fi
# Check for Android SDK
if [ -z "$ANDROID_HOME" ]; then
log_error "ANDROID_HOME environment variable is not set"
exit 1
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
}
@@ -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

@@ -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

@@ -8,14 +8,14 @@
},
"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 copy ios && node scripts/fix-capacitor-plugins.js && cd ios/App && pod install && cd ../..",
"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": {

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);
}
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

@@ -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">
<h1>This is an about page</h1>
<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">
<h1 class="page-title">📋 History</h1>
<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,33 +82,25 @@
<!-- 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>
<ActionCard
title="Plugin Diagnostics"
description="Check plugin loading and availability"
button-text="Run Diagnostics"
@click="runPluginDiagnostics"
/>
<ActionCard
title="View Console Logs"
description="Open browser console for detailed logs"
button-text="Open Console"
@click="openConsole"
/>
<div class="action-grid">
<ActionCard
title="Plugin Diagnostics"
description="Check plugin loading and availability"
button-text="Run Diagnostics"
@click="runPluginDiagnostics"
/>
<ActionCard
title="View Console Logs"
description="Open browser console for detailed logs"
button-text="Open Console"
@click="openConsole"
/>
</div>
</div>
</div>
</div>
@@ -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)
console.log('📊 Exact alarm status:', exactAlarmStatus)
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()
@@ -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">
<h1 class="page-title">📋 System Logs</h1>
<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">
<h1 class="page-title">🔔 Notifications</h1>
<p class="page-subtitle">Manage scheduled notifications</p>
<div class="header-title-row">
<button class="back-button" @click="goBack" aria-label="Go back to home">
</button>
<h1 class="page-title">🔔 Notifications</h1>
</div>
<p class="page-subtitle">View scheduled notifications and rollover status</p>
</div>
<div class="placeholder-content">
<p>Notifications management coming soon...</p>
<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">
<h1 class="page-title">📅 Schedule Notification</h1>
<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">
<h1 class="page-title"> Settings</h1>
<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">
<h1 class="page-title">📊 Status Matrix</h1>
<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">
<h1 class="page-title">User Zero Stars Testing</h1>
<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>