Compare commits
38 Commits
a85f8b2f52
...
android-fi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e25841fe9 | ||
|
|
367325452a | ||
|
|
dd55c6b4e1 | ||
|
|
2915fe7438 | ||
|
|
5247ebeecb | ||
|
|
20b33f6e31 | ||
|
|
630fd3de81 | ||
|
|
aaac23111c | ||
|
|
d2a1041cc4 | ||
|
|
243cbd08f1 | ||
|
|
7e93cbd771 | ||
|
|
6d64f71988 | ||
|
|
65379aedd6 | ||
|
|
66c7eca33d | ||
|
|
d88978259d | ||
|
|
66cbe763fc | ||
|
|
766d56c661 | ||
|
|
f446362984 | ||
|
|
20f15ebcea | ||
|
|
b230a8e7b5 | ||
|
|
f97b3bec5b | ||
|
|
911aabf671 | ||
|
|
5ae63e6f6d | ||
|
|
edc4082f72 | ||
|
|
c8919480d9 | ||
|
|
2d353c877c | ||
|
|
2f0d733b10 | ||
|
|
a7d33e2d37 | ||
|
|
83ec604a4b | ||
|
|
8b116db095 | ||
|
|
76c05e3690 | ||
|
|
f19ff4c127 | ||
|
|
9565191101 | ||
|
|
f83e799254 | ||
|
|
36e15633be | ||
|
|
dced4b49e1 | ||
|
|
7725f19387 | ||
| 76b3fa8199 |
156
BUILDING.md
156
BUILDING.md
@@ -361,12 +361,16 @@ npm install
|
||||
# Build Vue 3 app
|
||||
npm run build
|
||||
|
||||
# Add Capacitor
|
||||
npm install @capacitor/android
|
||||
# Add Capacitor platforms
|
||||
npm install @capacitor/android @capacitor/ios
|
||||
|
||||
# Sync with Capacitor
|
||||
npx cap sync android
|
||||
|
||||
# For iOS: Use the npm script (handles Podfile fixes automatically)
|
||||
npm run cap:sync:ios
|
||||
# This runs: cap copy ios + fix Podfile + pod install
|
||||
|
||||
# Run on Android device/emulator
|
||||
npx cap run android
|
||||
|
||||
@@ -374,6 +378,149 @@ npx cap run android
|
||||
npx cap run ios
|
||||
```
|
||||
|
||||
**iOS Setup (Vue 3 Test App)**
|
||||
|
||||
The iOS setup requires additional steps to configure the plugin correctly:
|
||||
|
||||
**1. Install Dependencies**
|
||||
```bash
|
||||
cd test-apps/daily-notification-test
|
||||
npm install
|
||||
```
|
||||
|
||||
**2. Build Vue App**
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
**3. Add iOS Platform (if not already added)**
|
||||
```bash
|
||||
npx cap add ios
|
||||
```
|
||||
|
||||
**4. Fix Podfile Configuration**
|
||||
|
||||
**Critical**: Capacitor's `npx cap sync ios` regenerates the Podfile with incorrect plugin references (`TimesafariDailyNotificationPlugin` instead of `DailyNotificationPlugin`).
|
||||
|
||||
**Solution**: Use the npm script `npm run cap:sync:ios` which:
|
||||
1. Copies assets without running pod install (`npx cap copy ios`)
|
||||
2. Automatically fixes the Podfile
|
||||
3. Then runs `pod install` with the corrected Podfile
|
||||
|
||||
```bash
|
||||
# Use the npm script (recommended)
|
||||
npm run cap:sync:ios
|
||||
|
||||
# Or manually fix after copy
|
||||
npx cap copy ios
|
||||
node scripts/fix-capacitor-plugins.js
|
||||
cd ios/App && pod install && cd ../..
|
||||
```
|
||||
|
||||
The fix script will:
|
||||
- Change `TimesafariDailyNotificationPlugin` → `DailyNotificationPlugin`
|
||||
- Fix the path from `'../../../..'` → `'../../node_modules/@timesafari/daily-notification-plugin/ios'`
|
||||
|
||||
**5. Install CocoaPods Dependencies**
|
||||
|
||||
After the Podfile is fixed, install the iOS dependencies:
|
||||
|
||||
```bash
|
||||
cd ios/App
|
||||
pod install
|
||||
cd ../..
|
||||
```
|
||||
|
||||
**Expected Podfile Configuration:**
|
||||
|
||||
The Podfile should reference the plugin like this:
|
||||
|
||||
```ruby
|
||||
def capacitor_pods
|
||||
pod 'Capacitor', :path => '../../node_modules/@capacitor/ios'
|
||||
pod 'CapacitorCordova', :path => '../../node_modules/@capacitor/ios'
|
||||
pod 'DailyNotificationPlugin', :path => '../../node_modules/@timesafari/daily-notification-plugin/ios'
|
||||
end
|
||||
```
|
||||
|
||||
**Important Notes:**
|
||||
- The pod name must be `DailyNotificationPlugin` (not `TimesafariDailyNotificationPlugin`)
|
||||
- The path must point to `../../node_modules/@timesafari/daily-notification-plugin/ios`
|
||||
- The plugin must be installed in `node_modules` via `npm install` (it's installed as a local file dependency)
|
||||
|
||||
**6. Sync and Build**
|
||||
|
||||
**Important**: `npx cap sync ios` tries to run `pod install` automatically, but it will fail because the Podfile has incorrect plugin references. Use the npm script instead:
|
||||
|
||||
```bash
|
||||
# Option 1: Use the npm script (recommended - handles everything)
|
||||
npm run cap:sync:ios
|
||||
|
||||
# This script:
|
||||
# 1. Copies web assets (npx cap copy ios)
|
||||
# 2. Fixes the Podfile (node scripts/fix-capacitor-plugins.js)
|
||||
# 3. Installs pods (cd ios/App && pod install)
|
||||
|
||||
# Option 2: Manual steps (if you need more control)
|
||||
npx cap copy ios # Copy assets without pod install
|
||||
node scripts/fix-capacitor-plugins.js # Fix Podfile
|
||||
cd ios/App && pod install && cd ../.. # Install pods
|
||||
|
||||
# Open in Xcode
|
||||
npx cap open ios
|
||||
```
|
||||
|
||||
**Why this approach?**
|
||||
- `npx cap sync ios` regenerates the Podfile with wrong references, then tries to run `pod install` which fails
|
||||
- `npx cap copy ios` only copies files, allowing us to fix the Podfile before `pod install`
|
||||
- The npm script automates the entire workflow correctly
|
||||
|
||||
**Troubleshooting iOS Setup:**
|
||||
|
||||
**Error: `[!] No podspec found for 'TimesafariDailyNotificationPlugin'`**
|
||||
|
||||
This means the Podfile has the wrong pod name or path. Solutions:
|
||||
|
||||
1. **Run the fix script:**
|
||||
```bash
|
||||
node scripts/fix-capacitor-plugins.js
|
||||
```
|
||||
|
||||
2. **Manually fix the Podfile:**
|
||||
- Open `ios/App/Podfile`
|
||||
- Change `TimesafariDailyNotificationPlugin` to `DailyNotificationPlugin`
|
||||
- Change path from `'../../../..'` to `'../../node_modules/@timesafari/daily-notification-plugin/ios'`
|
||||
|
||||
3. **Verify plugin is installed:**
|
||||
```bash
|
||||
ls -la node_modules/@timesafari/daily-notification-plugin/ios/DailyNotificationPlugin.podspec
|
||||
```
|
||||
|
||||
4. **Reinstall dependencies if needed:**
|
||||
```bash
|
||||
rm -rf node_modules package-lock.json
|
||||
npm install
|
||||
```
|
||||
|
||||
**Error: `pod install` fails**
|
||||
|
||||
1. **Update CocoaPods:**
|
||||
```bash
|
||||
sudo gem install cocoapods
|
||||
```
|
||||
|
||||
2. **Clean CocoaPods cache:**
|
||||
```bash
|
||||
cd ios/App
|
||||
rm -rf Pods Podfile.lock
|
||||
pod install --repo-update
|
||||
```
|
||||
|
||||
3. **Verify Xcode Command Line Tools:**
|
||||
```bash
|
||||
xcode-select --install
|
||||
```
|
||||
|
||||
**Test App Features:**
|
||||
|
||||
- Interactive plugin testing interface
|
||||
@@ -390,8 +537,13 @@ test-apps/daily-notification-test/
|
||||
│ ├── components/ # Reusable UI components
|
||||
│ └── stores/ # Pinia state management
|
||||
├── android/ # Android Capacitor app
|
||||
├── ios/ # iOS Capacitor app
|
||||
│ └── App/
|
||||
│ ├── Podfile # CocoaPods dependencies
|
||||
│ └── App.xcworkspace # Xcode workspace
|
||||
├── docs/ # Test app documentation
|
||||
└── scripts/ # Test app build scripts
|
||||
│ └── fix-capacitor-plugins.js # Auto-fixes Podfile
|
||||
```
|
||||
|
||||
#### Android Test Apps
|
||||
|
||||
@@ -1,76 +1,47 @@
|
||||
refactor(android): Complete plugin refactoring and safety fixes (Batches 0-7)
|
||||
fix(build): add SQLite conflict detection and Command Line Tools verification
|
||||
|
||||
Comprehensive refactoring to make DailyNotificationPlugin a thin adapter,
|
||||
eliminate duplicated logic, remove unsafe operations, and harden security.
|
||||
Prevents iOS build failures caused by pkgx SQLite linking conflicts and
|
||||
ensures Xcode Command Line Tools are properly installed.
|
||||
|
||||
Batch 0 - Constants Centralization:
|
||||
- Created DailyNotificationConstants.kt to eliminate magic numbers and duplicates
|
||||
- Centralized: PERMISSION_REQUEST_CODE, channel constants, intent actions/extras,
|
||||
SharedPreferences keys, WorkManager tags, notification IDs
|
||||
- Replaced duplicates across Plugin, PermissionManager, ChannelManager, Scheduler
|
||||
Problem:
|
||||
- pkgx installs SQLite built for macOS, causing linker errors when building
|
||||
for iOS simulator: "linking in dylib built for 'macOS'"
|
||||
- Missing Command Line Tools cause build failures without clear error messages
|
||||
|
||||
Batch 1 - Permission Flow Unification:
|
||||
- Created PermissionStatus.kt data class for unified permission reporting
|
||||
- Added PermissionManager.getPermissionStatus() as single source of truth
|
||||
- Implemented PendingPermissionRequest tracking for reliable resume resolution
|
||||
- Replaced method-name-based resume logic with token-based tracking
|
||||
- Plugin now delegates all permission checks to PermissionManager
|
||||
Changes:
|
||||
- Add check_sqlite_conflicts() function
|
||||
- Detects pkgx SQLite installations in ~/.pkgx
|
||||
- Warns about macOS dylibs that will cause iOS simulator build failures
|
||||
- Checks for system SQLite from Command Line Tools
|
||||
- Validates library paths (DYLD_LIBRARY_PATH, LD_LIBRARY_PATH)
|
||||
|
||||
- Add check_command_line_tools() function
|
||||
- Verifies Xcode Command Line Tools are installed and configured
|
||||
- Checks for xcodebuild availability
|
||||
- Verifies sqlite3 is available (part of Command Line Tools)
|
||||
- Provides clear error messages with installation instructions
|
||||
|
||||
Batch 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
|
||||
- Enhance pkgx detection in iOS build functions
|
||||
- Specifically searches for pkgx SQLite dylibs
|
||||
- Automatically removes pkgx paths from PATH environment variable
|
||||
- Provides detailed warnings about detected conflicts
|
||||
- Cleans all problematic environment variables before building
|
||||
|
||||
Batch 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
|
||||
- Integrate checks into environment validation
|
||||
- Runs automatically when building for iOS
|
||||
- Provides early warnings before build starts
|
||||
- Fails fast with clear error messages if tools are missing
|
||||
|
||||
Batch 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)
|
||||
This fixes the linker error:
|
||||
"ld: building for 'iOS-simulator', but linking in dylib
|
||||
(/Users/trent/.pkgx/sqlite.org/v3.44.2/lib/libsqlite3.0.dylib)
|
||||
built for 'macOS'"
|
||||
|
||||
Batch 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 build script now:
|
||||
- Detects pkgx SQLite conflicts before building
|
||||
- Automatically fixes environment variables
|
||||
- Verifies Command Line Tools are installed
|
||||
- Provides clear guidance for manual fixes if needed
|
||||
|
||||
Files modified:
|
||||
- DailyNotificationConstants.kt (new)
|
||||
- PermissionStatus.kt (new)
|
||||
- DailyNotificationPlugin.kt (thinned, ~500 lines refactored)
|
||||
- PermissionManager.java (enhanced with status methods)
|
||||
- NotificationStatusChecker.java (hardened)
|
||||
- DailyNotificationScheduler.java (legacy removed)
|
||||
|
||||
Refs: Cursor directive Batches 0-7
|
||||
- scripts/build-native.sh
|
||||
|
||||
@@ -38,6 +38,15 @@ The plugin has been optimized for **native-first deployment** with the following
|
||||
|
||||
## Implementation Status
|
||||
|
||||
### **Overview**
|
||||
|
||||
Dec 17
|
||||
- test-apps
|
||||
- android has been seen to work
|
||||
- ios is being developed (Jose)
|
||||
- after ios, will work on daily-notification-test (that includes Vue)
|
||||
- need to test with real data in the API
|
||||
|
||||
### ✅ **Phase 2 Complete - Production Ready**
|
||||
|
||||
| Component | Status | Implementation |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -12,6 +12,7 @@ package com.timesafari.dailynotification;
|
||||
|
||||
import android.app.AlarmManager;
|
||||
import android.app.PendingIntent;
|
||||
import android.content.ComponentName;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.os.Build;
|
||||
@@ -154,9 +155,15 @@ public class DailyNotificationScheduler {
|
||||
cancelNotification(duplicateId);
|
||||
}
|
||||
|
||||
// Create intent for the notification
|
||||
Intent intent = new Intent(context, DailyNotificationReceiver.class);
|
||||
intent.setAction(com.timesafari.dailynotification.DailyNotificationConstants.ACTION_NOTIFICATION);
|
||||
// CRITICAL FIX: Explicitly set component and package for AlarmManager broadcasts
|
||||
// AlarmManager requires explicit component matching when delivering broadcasts
|
||||
ComponentName receiverComponent = new ComponentName(
|
||||
context.getPackageName(),
|
||||
"com.timesafari.dailynotification.DailyNotificationReceiver"
|
||||
);
|
||||
Intent intent = new Intent(com.timesafari.dailynotification.DailyNotificationConstants.ACTION_NOTIFICATION);
|
||||
intent.setComponent(receiverComponent);
|
||||
intent.setPackage(context.getPackageName());
|
||||
intent.putExtra(com.timesafari.dailynotification.DailyNotificationConstants.EXTRA_NOTIFICATION_ID, content.getId());
|
||||
|
||||
// Check if this is a static reminder
|
||||
|
||||
@@ -6,6 +6,7 @@ import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
@@ -146,8 +147,15 @@ class NotifyReceiver : BroadcastReceiver() {
|
||||
// This prevents duplicate alarms when multiple scheduling paths race
|
||||
// Strategy: Check both by scheduleId (stable) and by trigger time (catches different scheduleIds for same time)
|
||||
val requestCode = getRequestCode(stableScheduleId)
|
||||
val checkIntent = Intent(context, com.timesafari.dailynotification.DailyNotificationReceiver::class.java).apply {
|
||||
action = "com.timesafari.daily.NOTIFICATION"
|
||||
// CRITICAL FIX: Explicitly set component and package for AlarmManager broadcasts
|
||||
// AlarmManager requires explicit component matching when delivering broadcasts
|
||||
val receiverComponent = ComponentName(
|
||||
context.packageName,
|
||||
"com.timesafari.dailynotification.DailyNotificationReceiver"
|
||||
)
|
||||
val checkIntent = Intent("com.timesafari.daily.NOTIFICATION").apply {
|
||||
setComponent(receiverComponent)
|
||||
setPackage(context.packageName)
|
||||
}
|
||||
|
||||
// Check 1: Same scheduleId (stable requestCode) - most reliable
|
||||
@@ -269,12 +277,21 @@ class NotifyReceiver : BroadcastReceiver() {
|
||||
Log.w(TAG, "Failed to store notification content in database, continuing with alarm scheduling", e)
|
||||
}
|
||||
|
||||
// FIX: Use DailyNotificationReceiver (registered in manifest) instead of NotifyReceiver
|
||||
// FIX: Set action to match manifest registration
|
||||
val intent = Intent(context, com.timesafari.dailynotification.DailyNotificationReceiver::class.java).apply {
|
||||
action = "com.timesafari.daily.NOTIFICATION" // Must match manifest intent-filter action
|
||||
putExtra("notification_id", notificationId) // DailyNotificationReceiver expects this extra
|
||||
putExtra("schedule_id", stableScheduleId) // Add stable scheduleId for tracking
|
||||
// CRITICAL FIX: Explicitly set component and package for AlarmManager broadcasts
|
||||
// AlarmManager requires explicit component matching when delivering broadcasts.
|
||||
// Using Intent(context, Class) constructor may not work reliably with AlarmManager
|
||||
// on all Android versions, especially when the app is in certain states.
|
||||
// Solution: Create Intent with action, then explicitly set component and package.
|
||||
val intent = Intent("com.timesafari.daily.NOTIFICATION").apply {
|
||||
// Explicitly set component to ensure AlarmManager can match it to the receiver
|
||||
setComponent(receiverComponent)
|
||||
// Explicitly set package to ensure it matches the app's package (not plugin's)
|
||||
setPackage(context.packageName)
|
||||
// Must match manifest intent-filter action
|
||||
// DailyNotificationReceiver expects this extra
|
||||
putExtra("notification_id", notificationId)
|
||||
// Add stable scheduleId for tracking
|
||||
putExtra("schedule_id", stableScheduleId)
|
||||
// Also preserve original extras for backward compatibility if needed
|
||||
putExtra("title", config.title)
|
||||
putExtra("body", config.body)
|
||||
@@ -282,7 +299,8 @@ class NotifyReceiver : BroadcastReceiver() {
|
||||
putExtra("vibration", config.vibration ?: true)
|
||||
putExtra("priority", config.priority ?: "normal")
|
||||
putExtra("is_static_reminder", isStaticReminder)
|
||||
putExtra("trigger_time", triggerAtMillis) // Store trigger time for debugging
|
||||
// Store trigger time for debugging
|
||||
putExtra("trigger_time", triggerAtMillis)
|
||||
if (reminderId != null) {
|
||||
putExtra("reminder_id", reminderId)
|
||||
}
|
||||
@@ -407,9 +425,14 @@ class NotifyReceiver : BroadcastReceiver() {
|
||||
*/
|
||||
fun cancelNotification(context: Context, scheduleId: String? = null, triggerAtMillis: Long? = null) {
|
||||
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
||||
// FIX: Use DailyNotificationReceiver to match what was scheduled
|
||||
val intent = Intent(context, com.timesafari.dailynotification.DailyNotificationReceiver::class.java).apply {
|
||||
action = "com.timesafari.daily.NOTIFICATION"
|
||||
// CRITICAL FIX: Use same Intent format as scheduling (explicit component and package)
|
||||
val receiverComponent = ComponentName(
|
||||
context.packageName,
|
||||
"com.timesafari.dailynotification.DailyNotificationReceiver"
|
||||
)
|
||||
val intent = Intent("com.timesafari.daily.NOTIFICATION").apply {
|
||||
setComponent(receiverComponent)
|
||||
setPackage(context.packageName)
|
||||
}
|
||||
val requestCode = when {
|
||||
scheduleId != null -> getRequestCode(scheduleId)
|
||||
@@ -438,9 +461,14 @@ class NotifyReceiver : BroadcastReceiver() {
|
||||
* @return true if alarm is scheduled, false otherwise
|
||||
*/
|
||||
fun isAlarmScheduled(context: Context, scheduleId: String? = null, triggerAtMillis: Long? = null): Boolean {
|
||||
// FIX: Use DailyNotificationReceiver to match what was scheduled
|
||||
val intent = Intent(context, com.timesafari.dailynotification.DailyNotificationReceiver::class.java).apply {
|
||||
action = "com.timesafari.daily.NOTIFICATION"
|
||||
// CRITICAL FIX: Use same Intent format as scheduling (explicit component and package)
|
||||
val receiverComponent = ComponentName(
|
||||
context.packageName,
|
||||
"com.timesafari.dailynotification.DailyNotificationReceiver"
|
||||
)
|
||||
val intent = Intent("com.timesafari.daily.NOTIFICATION").apply {
|
||||
setComponent(receiverComponent)
|
||||
setPackage(context.packageName)
|
||||
}
|
||||
val requestCode = when {
|
||||
scheduleId != null -> getRequestCode(scheduleId)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.timesafari.dailynotification
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.SharedPreferences
|
||||
@@ -442,8 +443,14 @@ class ReactivationManager(private val context: Context) {
|
||||
return try {
|
||||
// Check if any PendingIntent for our receiver exists
|
||||
// This is more reliable than nextAlarmClock
|
||||
val intent = Intent(context, NotifyReceiver::class.java).apply {
|
||||
action = "com.timesafari.daily.NOTIFICATION"
|
||||
// CRITICAL FIX: Use DailyNotificationReceiver with explicit component/package
|
||||
val receiverComponent = ComponentName(
|
||||
context.packageName,
|
||||
"com.timesafari.dailynotification.DailyNotificationReceiver"
|
||||
)
|
||||
val intent = Intent("com.timesafari.daily.NOTIFICATION").apply {
|
||||
setComponent(receiverComponent)
|
||||
setPackage(context.packageName)
|
||||
}
|
||||
val pendingIntent = PendingIntent.getBroadcast(
|
||||
context,
|
||||
|
||||
@@ -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
|
||||
|
||||
Binary file not shown.
@@ -263,12 +263,12 @@ class DailyNotificationDatabase {
|
||||
return
|
||||
}
|
||||
|
||||
sqlite3_bind_text(stmt, 1, (content.id as NSString).utf8String, -1, SQLITE_TRANSIENT)
|
||||
sqlite3_bind_text(stmt, 2, (json as NSString).utf8String, -1, SQLITE_TRANSIENT)
|
||||
sqlite3_bind_text(stmt, 1, (content.id as NSString).utf8String, -1, nil)
|
||||
sqlite3_bind_text(stmt, 2, (json as NSString).utf8String, -1, nil)
|
||||
sqlite3_bind_int64(stmt, 3, sqlite3_int64(content.fetchedAt))
|
||||
|
||||
if let etag = content.etag {
|
||||
sqlite3_bind_text(stmt, 4, (etag as NSString).utf8String, -1, SQLITE_TRANSIENT)
|
||||
sqlite3_bind_text(stmt, 4, (etag as NSString).utf8String, -1, nil)
|
||||
} else {
|
||||
sqlite3_bind_null(stmt, 4)
|
||||
}
|
||||
@@ -310,7 +310,7 @@ class DailyNotificationDatabase {
|
||||
return
|
||||
}
|
||||
|
||||
sqlite3_bind_text(stmt, 1, (id as NSString).utf8String, -1, SQLITE_TRANSIENT)
|
||||
sqlite3_bind_text(stmt, 1, (id as NSString).utf8String, -1, nil)
|
||||
|
||||
if sqlite3_step(stmt) != SQLITE_DONE {
|
||||
print("\(Self.TAG): deleteNotificationContent step failed: \(String(cString: sqlite3_errmsg(db)))")
|
||||
|
||||
@@ -261,17 +261,8 @@ class PersistenceController {
|
||||
description?.shouldMigrateStoreAutomatically = true
|
||||
description?.shouldInferMappingModelAutomatically = true
|
||||
|
||||
// Set initial schema version metadata (for new stores)
|
||||
if !inMemory {
|
||||
var metadata = description?.metadata ?? [:]
|
||||
if metadata["schema_version"] == nil {
|
||||
metadata["schema_version"] = PersistenceController.SCHEMA_VERSION
|
||||
description?.metadata = metadata
|
||||
}
|
||||
}
|
||||
|
||||
var loadError: Error? = nil
|
||||
tempContainer?.loadPersistentStores { description, error in
|
||||
tempContainer?.loadPersistentStores { storeDescription, error in
|
||||
if let error = error as NSError? {
|
||||
loadError = error
|
||||
print("DNP-PLUGIN: CoreData store load error: \(error.localizedDescription)")
|
||||
@@ -281,7 +272,19 @@ class PersistenceController {
|
||||
}
|
||||
} else {
|
||||
print("DNP-PLUGIN: CoreData store loaded successfully")
|
||||
print("DNP-PLUGIN: Store URL: \(description.url?.absoluteString ?? "unknown")")
|
||||
print("DNP-PLUGIN: Store URL: \(storeDescription.url?.absoluteString ?? "unknown")")
|
||||
|
||||
// Set initial schema version metadata (for new stores)
|
||||
// Metadata must be set using the coordinator after the store is loaded
|
||||
if !inMemory,
|
||||
let coordinator = tempContainer?.persistentStoreCoordinator,
|
||||
let store = coordinator.persistentStores.first,
|
||||
let metadata = store.metadata,
|
||||
metadata["schema_version"] == nil {
|
||||
var newMetadata = metadata
|
||||
newMetadata["schema_version"] = PersistenceController.SCHEMA_VERSION
|
||||
coordinator.setMetadata(newMetadata, for: store)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -373,7 +376,13 @@ class PersistenceController {
|
||||
return
|
||||
}
|
||||
|
||||
let currentVersion = store.metadata["schema_version"] as? Int ?? 1
|
||||
// store.metadata is optional, so we need to unwrap it
|
||||
guard let metadata = store.metadata else {
|
||||
print("DNP-PLUGIN: Store metadata is nil, using default schema version")
|
||||
return
|
||||
}
|
||||
|
||||
let currentVersion = metadata["schema_version"] as? Int ?? 1
|
||||
let expectedVersion = PersistenceController.SCHEMA_VERSION
|
||||
|
||||
if currentVersion != expectedVersion {
|
||||
@@ -381,9 +390,13 @@ class PersistenceController {
|
||||
print("DNP-PLUGIN: CoreData auto-migration will handle schema changes")
|
||||
|
||||
// Update metadata for future reference (does not trigger migration)
|
||||
var metadata = store.metadata
|
||||
metadata["schema_version"] = expectedVersion
|
||||
// Note: Metadata persists on next store save
|
||||
// Use the coordinator to set metadata
|
||||
if let coordinator = container?.persistentStoreCoordinator {
|
||||
var newMetadata = metadata
|
||||
newMetadata["schema_version"] = expectedVersion
|
||||
coordinator.setMetadata(newMetadata, for: store)
|
||||
// Note: Metadata persists on next store save
|
||||
}
|
||||
} else {
|
||||
print("DNP-PLUGIN: Schema version verified: \(currentVersion)")
|
||||
}
|
||||
|
||||
@@ -7,10 +7,12 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
import Capacitor
|
||||
import UserNotifications
|
||||
import BackgroundTasks
|
||||
import CoreData
|
||||
import ObjectiveC
|
||||
|
||||
/**
|
||||
* iOS implementation of Daily Notification Plugin
|
||||
@@ -80,8 +82,50 @@ public class DailyNotificationPlugin: CAPPlugin {
|
||||
object: nil
|
||||
)
|
||||
|
||||
NSLog("DNP-ROLLOVER: Observer registered for DailyNotificationDelivered notification")
|
||||
print("DNP-ROLLOVER: Observer registered for DailyNotificationDelivered notification")
|
||||
|
||||
// Register for app becoming active to check for missed rollovers
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(handleAppBecameActive(_:)),
|
||||
name: UIApplication.didBecomeActiveNotification,
|
||||
object: nil
|
||||
)
|
||||
|
||||
NSLog("DNP-ROLLOVER: Observer registered for app becoming active")
|
||||
print("DNP-ROLLOVER: Observer registered for app becoming active")
|
||||
|
||||
NSLog("DNP-DEBUG: DailyNotificationPlugin.load() completed - initialization done")
|
||||
print("DNP-PLUGIN: Daily Notification Plugin loaded on iOS")
|
||||
|
||||
// Debug: Log all available @objc methods for Capacitor discovery
|
||||
let methods = getObjCMethods()
|
||||
NSLog("DNP-DEBUG: Available @objc methods: \(methods.joined(separator: ", "))")
|
||||
print("DNP-DEBUG: Available @objc methods: \(methods.joined(separator: ", "))")
|
||||
}
|
||||
|
||||
/**
|
||||
* Debug helper: Get all @objc methods for this class
|
||||
*/
|
||||
private func getObjCMethods() -> [String] {
|
||||
var methods: [String] = []
|
||||
var methodCount: UInt32 = 0
|
||||
let methodList = class_copyMethodList(type(of: self), &methodCount)
|
||||
|
||||
for i in 0..<Int(methodCount) {
|
||||
if let method = methodList?[i] {
|
||||
let selector = method_getName(method)
|
||||
let methodName = NSStringFromSelector(selector)
|
||||
// Filter for methods that look like plugin methods (take CAPPluginCall)
|
||||
if methodName.contains(":") && !methodName.hasPrefix("_") {
|
||||
methods.append(methodName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
free(methodList)
|
||||
return methods.sorted()
|
||||
}
|
||||
|
||||
// MARK: - Configuration Methods
|
||||
@@ -419,58 +463,58 @@ public class DailyNotificationPlugin: CAPPlugin {
|
||||
// Phase 3: Check for JWT-signed fetcher configuration
|
||||
// If native fetcher is configured, use it; otherwise fall back to dummy content
|
||||
let nativeFetcherConfig = UserDefaults.standard.string(forKey: "native_fetcher_config")
|
||||
let content: NotificationContent
|
||||
|
||||
if let configJson = nativeFetcherConfig,
|
||||
let configData = configJson.data(using: .utf8),
|
||||
let config = try? JSONSerialization.jsonObject(with: configData) as? [String: Any],
|
||||
let apiBaseUrl = config["apiBaseUrl"] as? String,
|
||||
let activeDid = config["activeDid"] as? String,
|
||||
let jwtToken = config["jwtToken"] as? String {
|
||||
// Phase 3: JWT-signed fetcher is configured - attempt HTTP fetch
|
||||
print("DNP-FETCH: Using JWT-signed fetcher (apiBaseUrl=\(apiBaseUrl), activeDid=\(activeDid.prefix(30))...)")
|
||||
// Save content to storage via state actor (thread-safe)
|
||||
Task {
|
||||
let content: NotificationContent
|
||||
|
||||
// Attempt to fetch content from TimeSafari API
|
||||
// Note: This is a minimal implementation - can be extended with full API client
|
||||
do {
|
||||
let fetchedContent = try await fetchContentFromAPI(
|
||||
apiBaseUrl: apiBaseUrl,
|
||||
activeDid: activeDid,
|
||||
jwtToken: jwtToken
|
||||
)
|
||||
content = fetchedContent
|
||||
print("DNP-FETCH: Successfully fetched content from API")
|
||||
} catch {
|
||||
// Fallback to dummy content on fetch failure
|
||||
print("DNP-FETCH: API fetch failed (\(error.localizedDescription)), using fallback content")
|
||||
if let configJson = nativeFetcherConfig,
|
||||
let configData = configJson.data(using: .utf8),
|
||||
let config = try? JSONSerialization.jsonObject(with: configData) as? [String: Any],
|
||||
let apiBaseUrl = config["apiBaseUrl"] as? String,
|
||||
let activeDid = config["activeDid"] as? String,
|
||||
let jwtToken = config["jwtToken"] as? String {
|
||||
// Phase 3: JWT-signed fetcher is configured - attempt HTTP fetch
|
||||
print("DNP-FETCH: Using JWT-signed fetcher (apiBaseUrl=\(apiBaseUrl), activeDid=\(activeDid.prefix(30))...)")
|
||||
|
||||
// Attempt to fetch content from TimeSafari API
|
||||
// Note: This is a minimal implementation - can be extended with full API client
|
||||
do {
|
||||
let fetchedContent = try await fetchContentFromAPI(
|
||||
apiBaseUrl: apiBaseUrl,
|
||||
activeDid: activeDid,
|
||||
jwtToken: jwtToken
|
||||
)
|
||||
content = fetchedContent
|
||||
print("DNP-FETCH: Successfully fetched content from API")
|
||||
} catch {
|
||||
// Fallback to dummy content on fetch failure
|
||||
print("DNP-FETCH: API fetch failed (\(error.localizedDescription)), using fallback content")
|
||||
content = NotificationContent(
|
||||
id: "fallback_\(Date().timeIntervalSince1970)",
|
||||
title: "Daily Update",
|
||||
body: "Your daily notification is ready",
|
||||
scheduledTime: Int64(Date().timeIntervalSince1970 * 1000) + (5 * 60 * 1000),
|
||||
fetchedAt: Int64(Date().timeIntervalSince1970 * 1000),
|
||||
url: nil,
|
||||
payload: ["fetchError": error.localizedDescription],
|
||||
etag: nil
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// Fallback: Dummy content fetch (no network)
|
||||
print("DNP-FETCH: Using dummy content (native fetcher not configured)")
|
||||
content = NotificationContent(
|
||||
id: "fallback_\(Date().timeIntervalSince1970)",
|
||||
id: "dummy_\(Date().timeIntervalSince1970)",
|
||||
title: "Daily Update",
|
||||
body: "Your daily notification is ready",
|
||||
scheduledTime: Int64(Date().timeIntervalSince1970 * 1000) + (5 * 60 * 1000),
|
||||
fetchedAt: Int64(Date().timeIntervalSince1970 * 1000),
|
||||
url: nil,
|
||||
payload: ["fetchError": error.localizedDescription],
|
||||
payload: nil,
|
||||
etag: nil
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// Fallback: Dummy content fetch (no network)
|
||||
print("DNP-FETCH: Using dummy content (native fetcher not configured)")
|
||||
content = NotificationContent(
|
||||
id: "dummy_\(Date().timeIntervalSince1970)",
|
||||
title: "Daily Update",
|
||||
body: "Your daily notification is ready",
|
||||
scheduledTime: Int64(Date().timeIntervalSince1970 * 1000) + (5 * 60 * 1000),
|
||||
fetchedAt: Int64(Date().timeIntervalSince1970 * 1000),
|
||||
url: nil,
|
||||
payload: nil,
|
||||
etag: nil
|
||||
)
|
||||
}
|
||||
|
||||
// Save content to storage via state actor (thread-safe)
|
||||
Task {
|
||||
do {
|
||||
// Use the content (either from JWT fetcher or dummy)
|
||||
if #available(iOS 13.0, *) {
|
||||
@@ -513,11 +557,16 @@ public class DailyNotificationPlugin: CAPPlugin {
|
||||
|
||||
// Phase 3.3: Schedule next background task
|
||||
// Calculate next fetch time based on notification schedule
|
||||
if let nextScheduledTime = self.getNextScheduledNotificationTime() {
|
||||
self.scheduleBackgroundFetch(scheduledTime: nextScheduledTime)
|
||||
print("DNP-FETCH: Next background fetch scheduled")
|
||||
if let scheduler = self.scheduler {
|
||||
let nextScheduledTime = await scheduler.getNextNotificationTime()
|
||||
if let nextTime = nextScheduledTime {
|
||||
self.scheduleBackgroundFetch(scheduledTime: nextTime)
|
||||
print("DNP-FETCH: Next background fetch scheduled")
|
||||
} else {
|
||||
print("DNP-FETCH: No future notifications found, skipping next task schedule")
|
||||
}
|
||||
} else {
|
||||
print("DNP-FETCH: No future notifications found, skipping next task schedule")
|
||||
print("DNP-FETCH: Scheduler not available, skipping next task schedule")
|
||||
}
|
||||
|
||||
guard !taskCompleted else { return }
|
||||
@@ -575,10 +624,13 @@ public class DailyNotificationPlugin: CAPPlugin {
|
||||
|
||||
// Phase 3.3: Schedule next background task if needed
|
||||
// For notify task, schedule next occurrence if applicable
|
||||
if let nextScheduledTime = self.getNextScheduledNotificationTime() {
|
||||
// Calculate next notify task time (if applicable)
|
||||
// Note: Notify tasks are typically scheduled less frequently than fetch tasks
|
||||
print("DNP-NOTIFY: Next notification scheduled at \(nextScheduledTime)")
|
||||
if let scheduler = self.scheduler {
|
||||
let nextScheduledTime = await scheduler.getNextNotificationTime()
|
||||
if let nextTime = nextScheduledTime {
|
||||
// Calculate next notify task time (if applicable)
|
||||
// Note: Notify tasks are typically scheduled less frequently than fetch tasks
|
||||
print("DNP-NOTIFY: Next notification scheduled at \(nextTime)")
|
||||
}
|
||||
}
|
||||
|
||||
guard !taskCompleted else { return }
|
||||
@@ -1091,7 +1143,7 @@ public class DailyNotificationPlugin: CAPPlugin {
|
||||
scheduler: scheduler,
|
||||
storage: self.storage,
|
||||
stateActor: await self.stateActor,
|
||||
scheduleBackgroundFetch: { [weak self] scheduledTime in
|
||||
scheduleBackgroundFetch: { [weak self] (scheduledTime: Int64) -> Void in
|
||||
self?.scheduleBackgroundFetch(scheduledTime: scheduledTime)
|
||||
}
|
||||
)
|
||||
@@ -1112,6 +1164,129 @@ public class DailyNotificationPlugin: CAPPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test method: Schedule an alarm to fire in a few seconds
|
||||
* Useful for verifying alarm delivery works correctly
|
||||
*
|
||||
* @param call Plugin call with optional secondsFromNow (default: 5)
|
||||
* @returns Object with scheduled (boolean), secondsFromNow (number), and triggerAtMillis (number)
|
||||
*/
|
||||
@objc func testAlarm(_ call: CAPPluginCall) {
|
||||
NSLog("DNP-DEBUG: testAlarm() method CALLED - method is being invoked!")
|
||||
print("DNP-DEBUG: testAlarm() method CALLED - method is being invoked!")
|
||||
print("DNP-DEBUG: testAlarm call data: \(call.jsObjectRepresentation)")
|
||||
|
||||
guard let scheduler = scheduler else {
|
||||
NSLog("DNP-DEBUG: testAlarm() - scheduler is nil, rejecting")
|
||||
print("DNP-DEBUG: testAlarm() - scheduler is nil, rejecting")
|
||||
let error = DailyNotificationErrorCodes.createErrorResponse(
|
||||
code: DailyNotificationErrorCodes.PLUGIN_NOT_INITIALIZED,
|
||||
message: "Plugin not initialized"
|
||||
)
|
||||
let errorMessage = error["message"] as? String ?? "Unknown error"
|
||||
let errorCode = error["error"] as? String ?? "unknown_error"
|
||||
call.reject(errorMessage, errorCode)
|
||||
return
|
||||
}
|
||||
|
||||
// Get secondsFromNow parameter (default: 5)
|
||||
let secondsFromNow = call.getInt("secondsFromNow") ?? 5
|
||||
|
||||
// Ensure minimum of 1 second (iOS requirement)
|
||||
let validSeconds = max(1, secondsFromNow)
|
||||
|
||||
Task {
|
||||
do {
|
||||
// Check permissions first
|
||||
let permissionStatus = await notificationCenter.notificationSettings()
|
||||
if permissionStatus.authorizationStatus != .authorized && permissionStatus.authorizationStatus != .provisional {
|
||||
let error = DailyNotificationErrorCodes.createErrorResponse(
|
||||
code: DailyNotificationErrorCodes.NOTIFICATIONS_DENIED,
|
||||
message: "Notification permissions not granted"
|
||||
)
|
||||
let errorMessage = error["message"] as? String ?? "Unknown error"
|
||||
let errorCode = error["error"] as? String ?? "unknown_error"
|
||||
call.reject(errorMessage, errorCode)
|
||||
return
|
||||
}
|
||||
|
||||
// Create test notification content
|
||||
let notificationContent = UNMutableNotificationContent()
|
||||
notificationContent.title = "Test Notification"
|
||||
notificationContent.body = "This is a test notification scheduled \(validSeconds) seconds from now"
|
||||
notificationContent.sound = .default
|
||||
notificationContent.categoryIdentifier = "DAILY_NOTIFICATION"
|
||||
notificationContent.userInfo = [
|
||||
"notification_id": "test_\(Date().timeIntervalSince1970)",
|
||||
"is_test": true
|
||||
]
|
||||
|
||||
// Create time interval trigger (fires in X seconds)
|
||||
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: TimeInterval(validSeconds), repeats: false)
|
||||
|
||||
// Create notification request with unique ID
|
||||
let notificationId = "test_alarm_\(Date().timeIntervalSince1970)"
|
||||
let request = UNNotificationRequest(
|
||||
identifier: notificationId,
|
||||
content: notificationContent,
|
||||
trigger: trigger
|
||||
)
|
||||
|
||||
// Schedule notification
|
||||
try await notificationCenter.add(request)
|
||||
|
||||
// Calculate trigger time in milliseconds
|
||||
let triggerAtMillis = Int64((Date().timeIntervalSince1970 + Double(validSeconds)) * 1000)
|
||||
|
||||
let result: [String: Any] = [
|
||||
"scheduled": true,
|
||||
"secondsFromNow": validSeconds,
|
||||
"triggerAtMillis": triggerAtMillis
|
||||
]
|
||||
|
||||
print("DNP-PLUGIN: Test alarm scheduled for \(validSeconds) seconds from now (triggerAtMillis=\(triggerAtMillis))")
|
||||
NSLog("DNP-DEBUG: testAlarm() - Successfully scheduled, resolving with result: \(result)")
|
||||
|
||||
DispatchQueue.main.async {
|
||||
NSLog("DNP-DEBUG: testAlarm() - Resolving call with result")
|
||||
call.resolve(result)
|
||||
}
|
||||
|
||||
} catch {
|
||||
NSLog("DNP-DEBUG: testAlarm() - Error caught: \(error)")
|
||||
print("DNP-PLUGIN: Error scheduling test alarm: \(error)")
|
||||
let errorResponse = DailyNotificationErrorCodes.createErrorResponse(
|
||||
code: DailyNotificationErrorCodes.SCHEDULING_FAILED,
|
||||
message: "Failed to schedule test alarm: \(error.localizedDescription)"
|
||||
)
|
||||
let errorMessage = errorResponse["message"] as? String ?? "Unknown error"
|
||||
let errorCode = errorResponse["error"] as? String ?? "unknown_error"
|
||||
NSLog("DNP-DEBUG: testAlarm() - Rejecting with error: \(errorMessage) (\(errorCode))")
|
||||
DispatchQueue.main.async {
|
||||
call.reject(errorMessage, errorCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Debug method: List all available plugin methods
|
||||
* Useful for verifying Capacitor method discovery
|
||||
*
|
||||
* @param call Plugin call
|
||||
*/
|
||||
@objc func listAvailableMethods(_ call: CAPPluginCall) {
|
||||
let methods = getObjCMethods()
|
||||
let result: [String: Any] = [
|
||||
"methods": methods,
|
||||
"count": methods.count,
|
||||
"testAlarmFound": methods.contains("testAlarm:")
|
||||
]
|
||||
NSLog("DNP-DEBUG: listAvailableMethods() - Found \(methods.count) methods")
|
||||
NSLog("DNP-DEBUG: testAlarm: found: \(methods.contains("testAlarm:"))")
|
||||
call.resolve(result)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the last notification that was delivered
|
||||
*
|
||||
@@ -1245,13 +1420,22 @@ public class DailyNotificationPlugin: CAPPlugin {
|
||||
* @param notification NSNotification with userInfo containing notification_id and scheduled_time
|
||||
*/
|
||||
@objc private func handleNotificationDelivery(_ notification: Notification) {
|
||||
NSLog("DNP-ROLLOVER: handleNotificationDelivery called")
|
||||
print("DNP-ROLLOVER: handleNotificationDelivery called")
|
||||
|
||||
// Extract notification data from userInfo
|
||||
guard let userInfo = notification.userInfo,
|
||||
let notificationId = userInfo["notification_id"] as? String,
|
||||
let scheduledTime = userInfo["scheduled_time"] as? Int64 else {
|
||||
NSLog("DNP-ROLLOVER: ERROR handleNotificationDelivery missing required data userInfo=%@", notification.userInfo ?? "nil")
|
||||
print("DNP-ROLLOVER: ERROR handleNotificationDelivery missing required data userInfo=\(notification.userInfo ?? [:])")
|
||||
return
|
||||
}
|
||||
|
||||
let scheduledTimeStr = formatTime(scheduledTime)
|
||||
NSLog("DNP-ROLLOVER: handleNotificationDelivery processing id=%@ scheduled_time=%@", notificationId, scheduledTimeStr)
|
||||
print("DNP-ROLLOVER: handleNotificationDelivery processing id=\(notificationId) scheduled_time=\(scheduledTimeStr)")
|
||||
|
||||
// Track notify execution
|
||||
let currentTime = Int64(Date().timeIntervalSince1970 * 1000)
|
||||
storage?.saveLastNotifyExecution(timestamp: currentTime)
|
||||
@@ -1262,6 +1446,23 @@ public class DailyNotificationPlugin: CAPPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle app becoming active (foreground)
|
||||
*
|
||||
* This is called when the app becomes active to check for missed rollovers
|
||||
* that occurred while the app was backgrounded.
|
||||
*
|
||||
* @param notification NSNotification for app becoming active
|
||||
*/
|
||||
@objc private func handleAppBecameActive(_ notification: Notification) {
|
||||
NSLog("DNP-ROLLOVER: handleAppBecameActive called")
|
||||
print("DNP-ROLLOVER: handleAppBecameActive called")
|
||||
|
||||
// Perform lightweight rollover check when app becomes active
|
||||
// This handles cases where notifications fired while app was backgrounded
|
||||
reactivationManager?.performActiveRolloverCheck()
|
||||
}
|
||||
|
||||
/**
|
||||
* Process rollover for delivered notification
|
||||
*
|
||||
@@ -1269,15 +1470,33 @@ public class DailyNotificationPlugin: CAPPlugin {
|
||||
* @param scheduledTime Scheduled time of delivered notification
|
||||
*/
|
||||
private func processRollover(notificationId: String, scheduledTime: Int64) async {
|
||||
let scheduledTimeStr = formatTime(scheduledTime)
|
||||
NSLog("DNP-ROLLOVER: processRollover START id=%@ scheduled_time=%@", notificationId, scheduledTimeStr)
|
||||
print("DNP-ROLLOVER: processRollover START id=\(notificationId) scheduled_time=\(scheduledTimeStr)")
|
||||
|
||||
guard let scheduler = scheduler, let storage = storage else {
|
||||
NSLog("DNP-ROLLOVER: ERROR processRollover missing scheduler or storage scheduler=%@ storage=%@",
|
||||
scheduler != nil ? "present" : "nil", storage != nil ? "present" : "nil")
|
||||
print("DNP-ROLLOVER: ERROR processRollover missing scheduler or storage scheduler=\(scheduler != nil ? "present" : "nil") storage=\(storage != nil ? "present" : "nil")")
|
||||
return
|
||||
}
|
||||
|
||||
// Get the notification content that was delivered
|
||||
guard let content = storage.getNotificationContent(id: notificationId) else {
|
||||
NSLog("DNP-ROLLOVER: ERROR processRollover content not found in storage id=%@", notificationId)
|
||||
print("DNP-ROLLOVER: ERROR processRollover content not found in storage id=\(notificationId)")
|
||||
|
||||
// Log available notification IDs for debugging
|
||||
let allNotifications = storage.getAllNotifications()
|
||||
let availableIds = allNotifications.map { $0.id }.joined(separator: ", ")
|
||||
NSLog("DNP-ROLLOVER: Available notification IDs in storage: [%@]", availableIds)
|
||||
print("DNP-ROLLOVER: Available notification IDs in storage: [\(availableIds)]")
|
||||
return
|
||||
}
|
||||
|
||||
NSLog("DNP-ROLLOVER: processRollover found content id=%@ calling scheduleNextNotification", notificationId)
|
||||
print("DNP-ROLLOVER: processRollover found content id=\(notificationId) calling scheduleNextNotification")
|
||||
|
||||
// Delegate to scheduler to schedule next notification (glue logic - will be moved to service)
|
||||
// Note: fetcher parameter is unused - scheduler uses fetchScheduler instead (already implemented)
|
||||
let scheduled = await scheduler.scheduleNextNotification(
|
||||
@@ -1286,8 +1505,15 @@ public class DailyNotificationPlugin: CAPPlugin {
|
||||
fetcher: nil // Unused - fetchScheduler handles prefetch scheduling
|
||||
)
|
||||
|
||||
if scheduled {
|
||||
NSLog("DNP-ROLLOVER: processRollover SUCCESS id=%@ next notification scheduled", notificationId)
|
||||
print("DNP-ROLLOVER: processRollover SUCCESS id=\(notificationId) next notification scheduled")
|
||||
} else {
|
||||
NSLog("DNP-ROLLOVER: processRollover FAILED id=%@ scheduleNextNotification returned false", notificationId)
|
||||
print("DNP-ROLLOVER: processRollover FAILED id=\(notificationId) scheduleNextNotification returned false")
|
||||
}
|
||||
|
||||
// Rollover processing is non-fatal - recovery will handle on next launch if needed
|
||||
_ = scheduled
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1532,8 +1758,8 @@ public class DailyNotificationPlugin: CAPPlugin {
|
||||
// User must check in Settings app
|
||||
|
||||
// Delegate storage access to storage service
|
||||
let lastFetchExecution = storage?.getLastSuccessfulRun() ?? NSNull()
|
||||
let lastNotifyExecution = storage?.getLastNotifyExecution() ?? NSNull()
|
||||
let lastFetchExecution: Any = storage?.getLastSuccessfulRun() ?? NSNull()
|
||||
let lastNotifyExecution: Any = storage?.getLastNotifyExecution() ?? NSNull()
|
||||
|
||||
let result: [String: Any] = [
|
||||
"fetchTaskRegistered": true, // Assumed registered if setupBackgroundTasks() was called
|
||||
@@ -1630,6 +1856,7 @@ public class DailyNotificationPlugin: CAPPlugin {
|
||||
|
||||
// Get channelId from call (optional, for API parity with Android)
|
||||
// iOS doesn't have per-channel control, so check app-wide notification authorization
|
||||
let channelId = call.getString("channelId") ?? "default"
|
||||
Task {
|
||||
// Delegate to scheduler for permission status check
|
||||
let status = await scheduler.checkPermissionStatus()
|
||||
@@ -1677,9 +1904,6 @@ public class DailyNotificationPlugin: CAPPlugin {
|
||||
}
|
||||
}
|
||||
}
|
||||
call.reject("Invalid settings URL")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update notification settings
|
||||
@@ -1937,6 +2161,7 @@ public class DailyNotificationPlugin: CAPPlugin {
|
||||
methods.append(CAPPluginMethod(name: "configure", returnType: CAPPluginReturnPromise))
|
||||
methods.append(CAPPluginMethod(name: "configureNativeFetcher", returnType: CAPPluginReturnPromise))
|
||||
methods.append(CAPPluginMethod(name: "scheduleDailyNotification", returnType: CAPPluginReturnPromise))
|
||||
methods.append(CAPPluginMethod(name: "testAlarm", returnType: CAPPluginReturnPromise))
|
||||
methods.append(CAPPluginMethod(name: "getLastNotification", returnType: CAPPluginReturnPromise))
|
||||
methods.append(CAPPluginMethod(name: "cancelAllNotifications", returnType: CAPPluginReturnPromise))
|
||||
methods.append(CAPPluginMethod(name: "getNotificationStatus", returnType: CAPPluginReturnPromise))
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
fi
|
||||
# Platform-specific checks
|
||||
if [ "$PLATFORM" = "android" ] || [ "$PLATFORM" = "all" ]; then
|
||||
check_command "java"
|
||||
|
||||
# Check for Gradle Wrapper instead of system gradle
|
||||
if [ ! -f "android/gradlew" ]; then
|
||||
log_error "Gradle wrapper not found at android/gradlew"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check for Android SDK
|
||||
if [ -z "$ANDROID_HOME" ]; then
|
||||
log_error "ANDROID_HOME environment variable is not set"
|
||||
exit 1
|
||||
# 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
|
||||
|
||||
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
271
scripts/clean-build.sh
Executable 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 ""
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
Both test apps are configured to **automatically build the plugin** as part of their build process. The plugin is included as a Gradle project dependency, so Gradle handles building it automatically.
|
||||
|
||||
Note that Test App 1 `android-test-app` is used most frequently.
|
||||
|
||||
---
|
||||
|
||||
## Test App 1: `android-test-app` (Standalone Android)
|
||||
@@ -59,7 +61,7 @@ avdmanager list avd
|
||||
# Run one
|
||||
emulator -avd AVD_NAME
|
||||
|
||||
# Check that one is running
|
||||
# Simply see that one is running
|
||||
adb devices
|
||||
|
||||
# Now install on the emulator
|
||||
|
||||
@@ -182,32 +182,52 @@
|
||||
}
|
||||
|
||||
function loadPluginStatus() {
|
||||
console.log('loadPluginStatus called');
|
||||
console.log('[UI Refresh] loadPluginStatus called');
|
||||
const pluginStatusContent = document.getElementById('pluginStatusContent');
|
||||
const statusCard = document.getElementById('statusCard');
|
||||
|
||||
try {
|
||||
if (!window.DailyNotification) {
|
||||
console.warn('[UI Refresh] DailyNotification plugin not available');
|
||||
pluginStatusContent.innerHTML = '❌ DailyNotification plugin not available';
|
||||
statusCard.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background
|
||||
return;
|
||||
}
|
||||
console.log('[UI Refresh] Calling getNotificationStatus()...');
|
||||
window.DailyNotification.getNotificationStatus()
|
||||
.then(result => {
|
||||
console.log('[UI Refresh] getNotificationStatus result:', {
|
||||
nextNotificationTime: result.nextNotificationTime,
|
||||
isEnabled: result.isEnabled,
|
||||
pending: result.pending,
|
||||
lastNotificationTime: result.lastNotificationTime
|
||||
});
|
||||
|
||||
const nextTime = formatDateTimeNormalized(result.nextNotificationTime);
|
||||
const hasSchedules = result.isEnabled || (result.pending && result.pending > 0);
|
||||
const statusIcon = hasSchedules ? '✅' : '⏸️';
|
||||
|
||||
console.log('[UI Refresh] Updating UI:', {
|
||||
nextTime: nextTime,
|
||||
hasSchedules: hasSchedules,
|
||||
statusIcon: statusIcon
|
||||
});
|
||||
|
||||
pluginStatusContent.innerHTML = `${statusIcon} Active Schedules: ${hasSchedules ? 'Yes' : 'No'}<br>
|
||||
📅 Next Notification: ${nextTime}<br>
|
||||
⏳ Pending: ${result.pending || 0}`;
|
||||
statusCard.style.background = hasSchedules ?
|
||||
'rgba(0, 255, 0, 0.15)' : 'rgba(255, 255, 255, 0.1)'; // Green if active, light gray if none
|
||||
|
||||
console.log('[UI Refresh] UI updated successfully');
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('[UI Refresh] getNotificationStatus failed:', error);
|
||||
pluginStatusContent.innerHTML = `⚠️ Status check failed: ${error.message}`;
|
||||
statusCard.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[UI Refresh] loadPluginStatus exception:', error);
|
||||
pluginStatusContent.innerHTML = `⚠️ Status check failed: ${error.message}`;
|
||||
statusCard.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background
|
||||
}
|
||||
@@ -444,18 +464,36 @@
|
||||
|
||||
// Check for notification delivery and status updates periodically
|
||||
function checkNotificationDelivery() {
|
||||
if (!window.DailyNotification) return;
|
||||
if (!window.DailyNotification) {
|
||||
console.log('[Poll] DailyNotification not available, skipping check');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[Poll] checkNotificationDelivery called');
|
||||
window.DailyNotification.getNotificationStatus()
|
||||
.then(result => {
|
||||
console.log('[Poll] Status check result:', {
|
||||
nextNotificationTime: result.nextNotificationTime,
|
||||
lastNotificationTime: result.lastNotificationTime,
|
||||
lastKnownNextNotificationTime: lastKnownNextNotificationTime
|
||||
});
|
||||
|
||||
// Check for notification delivery
|
||||
if (result.lastNotificationTime) {
|
||||
const lastTime = new Date(result.lastNotificationTime);
|
||||
const now = new Date();
|
||||
const timeDiff = now - lastTime;
|
||||
|
||||
console.log('[Poll] Notification delivery check:', {
|
||||
lastTime: lastTime.toISOString(),
|
||||
now: now.toISOString(),
|
||||
timeDiff: timeDiff,
|
||||
withinWindow: timeDiff > 0 && timeDiff < 120000
|
||||
});
|
||||
|
||||
// If notification was received in the last 2 minutes, show indicator
|
||||
if (timeDiff > 0 && timeDiff < 120000) {
|
||||
console.log('[Poll] Notification received recently, showing indicator');
|
||||
const indicator = document.getElementById('notificationReceivedIndicator');
|
||||
const timeSpan = document.getElementById('notificationReceivedTime');
|
||||
|
||||
@@ -471,7 +509,9 @@
|
||||
}, 30000);
|
||||
|
||||
// Force immediate refresh when notification is received (rollover may have occurred)
|
||||
console.log('[Poll] Scheduling UI refresh in 1 second (waiting for rollover)...');
|
||||
setTimeout(() => {
|
||||
console.log('[Poll] Triggering UI refresh after notification received');
|
||||
loadPluginStatus();
|
||||
}, 1000); // Wait 1 second for rollover to complete
|
||||
}
|
||||
@@ -480,20 +520,37 @@
|
||||
|
||||
// Detect if nextNotificationTime changed (rollover occurred)
|
||||
const currentNextTime = result.nextNotificationTime;
|
||||
console.log('[Poll] Comparing nextNotificationTime:', {
|
||||
current: currentNextTime,
|
||||
lastKnown: lastKnownNextNotificationTime,
|
||||
changed: currentNextTime && currentNextTime !== lastKnownNextNotificationTime
|
||||
});
|
||||
|
||||
if (currentNextTime && currentNextTime !== lastKnownNextNotificationTime) {
|
||||
if (lastKnownNextNotificationTime !== null) {
|
||||
console.log('Next notification time changed - rollover detected!');
|
||||
console.log('[Poll] ⚠️ Next notification time changed - rollover detected!', {
|
||||
old: lastKnownNextNotificationTime,
|
||||
new: currentNextTime,
|
||||
oldDate: new Date(lastKnownNextNotificationTime).toISOString(),
|
||||
newDate: new Date(currentNextTime).toISOString()
|
||||
});
|
||||
// Force immediate refresh
|
||||
loadPluginStatus();
|
||||
} else {
|
||||
console.log('[Poll] Initializing lastKnownNextNotificationTime:', currentNextTime);
|
||||
}
|
||||
lastKnownNextNotificationTime = currentNextTime;
|
||||
} else {
|
||||
console.log('[Poll] No change detected in nextNotificationTime');
|
||||
}
|
||||
|
||||
// Auto-refresh plugin status periodically to show updated next notification time after rollover
|
||||
// This ensures the UI updates when the plugin reschedules the notification
|
||||
console.log('[Poll] Triggering periodic UI refresh');
|
||||
loadPluginStatus();
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('[Poll] Status check failed:', error);
|
||||
// Silently fail - this is just for visual feedback
|
||||
});
|
||||
}
|
||||
|
||||
@@ -799,7 +799,8 @@ main() {
|
||||
# Capture post-rollover state
|
||||
capture_alarms "test0_after_rollover"
|
||||
# Look for rollover logs: DN|RESCHEDULE, DN|DISPLAY, DN|RECEIVE, ROLLOVER_ON_FIRE, etc.
|
||||
capture_logcat "test0_after_rollover" "DN|RESCHEDULE|DN|DISPLAY|DN|RECEIVE|ROLLOVER|DNP-SCHEDULE" 200
|
||||
# Also capture JavaScript console logs (Capacitor/Console) for UI debugging
|
||||
capture_logcat "test0_after_rollover" "DN|RESCHEDULE|DN|DISPLAY|DN|RECEIVE|ROLLOVER|DNP-SCHEDULE|Capacitor/Console" 200
|
||||
capture_screenshot "test0_after_rollover"
|
||||
|
||||
step_pass "p1_t0_s4" "Post-rollover evidence captured"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
require_relative '../../../../node_modules/@capacitor/ios/scripts/pods_helpers'
|
||||
require_relative '../../node_modules/@capacitor/ios/scripts/pods_helpers'
|
||||
|
||||
platform :ios, '13.0'
|
||||
use_frameworks!
|
||||
@@ -9,8 +9,8 @@ use_frameworks!
|
||||
install! 'cocoapods', :disable_input_output_paths => true
|
||||
|
||||
def capacitor_pods
|
||||
pod 'Capacitor', :path => '../../../../node_modules/@capacitor/ios'
|
||||
pod 'CapacitorCordova', :path => '../../../../node_modules/@capacitor/ios'
|
||||
pod 'Capacitor', :path => '../../node_modules/@capacitor/ios'
|
||||
pod 'CapacitorCordova', :path => '../../node_modules/@capacitor/ios'
|
||||
pod 'DailyNotificationPlugin', :path => '../../node_modules/@timesafari/daily-notification-plugin/ios'
|
||||
end
|
||||
|
||||
|
||||
24
test-apps/daily-notification-test/package-lock.json
generated
24
test-apps/daily-notification-test/package-lock.json
generated
@@ -12,6 +12,7 @@
|
||||
"@capacitor/android": "^6.2.1",
|
||||
"@capacitor/cli": "^6.2.1",
|
||||
"@capacitor/core": "^6.2.1",
|
||||
"@capacitor/ios": "^6.2.1",
|
||||
"@timesafari/daily-notification-plugin": "file:../../",
|
||||
"date-fns": "^4.1.0",
|
||||
"did-jwt": "^7.4.7",
|
||||
@@ -117,6 +118,7 @@
|
||||
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
"@babel/generator": "^7.28.3",
|
||||
@@ -416,6 +418,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.4.tgz",
|
||||
"integrity": "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.28.4"
|
||||
},
|
||||
@@ -634,10 +637,20 @@
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/core/-/core-6.2.1.tgz",
|
||||
"integrity": "sha512-urZwxa7hVE/BnA18oCFAdizXPse6fCKanQyEqpmz6cBJ2vObwMpyJDG5jBeoSsgocS9+Ax+9vb4ducWJn0y2qQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@capacitor/ios": {
|
||||
"version": "6.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/ios/-/ios-6.2.1.tgz",
|
||||
"integrity": "sha512-tbMlQdQjxe1wyaBvYVU1yTojKJjgluZQsJkALuJxv/6F8QTw5b6vd7X785O/O7cMpIAZfUWo/vtAHzFkRV+kXw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@capacitor/core": "^6.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.25.11",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.11.tgz",
|
||||
@@ -2065,6 +2078,7 @@
|
||||
"integrity": "sha512-6JSSaBZmsKvEkbRUkf7Zj7dru/8ZCrJxAqArcLaVMee5907JdtEbKGsZ7zNiIm/UAkpGUkaSMZEXShnN2D1HZA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.46.1",
|
||||
"@typescript-eslint/types": "8.46.1",
|
||||
@@ -2663,6 +2677,7 @@
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -2911,6 +2926,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.8.9",
|
||||
"caniuse-lite": "^1.0.30001746",
|
||||
@@ -3373,6 +3389,7 @@
|
||||
"integrity": "sha512-XyLmROnACWqSxiGYArdef1fItQd47weqB7iwtfr9JHwRrqIXZdcFMvvEcL9xHCmL0SNsOvF0c42lWyM1U5dgig==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -3434,6 +3451,7 @@
|
||||
"integrity": "sha512-K6tP0dW8FJVZLQxa2S7LcE1lLw3X8VvB3t887Q6CLrFVxHYBXGANbXvwNzYIu6Ughx1bSJ5BDT0YB3ybPT39lw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.4.0",
|
||||
"natural-compare": "^1.4.0",
|
||||
@@ -4293,6 +4311,7 @@
|
||||
"integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"jiti": "lib/jiti-cli.mjs"
|
||||
}
|
||||
@@ -5721,6 +5740,7 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -5798,6 +5818,7 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -6031,6 +6052,7 @@
|
||||
"integrity": "sha512-CmuvUBzVJ/e3HGxhg6cYk88NGgTnBoOo7ogtfJJ0fefUWAxN/WDSUa50o+oVBxuIhO8FoEZW0j2eW7sfjs5EtA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -6301,6 +6323,7 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -6320,6 +6343,7 @@
|
||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.22.tgz",
|
||||
"integrity": "sha512-toaZjQ3a/G/mYaLSbV+QsQhIdMo9x5rrqIpYRObsJ6T/J+RyCSFwN2LHNVH9v8uIcljDNa3QzPVdv3Y6b9hAJQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vue/compiler-dom": "3.5.22",
|
||||
"@vue/compiler-sfc": "3.5.22",
|
||||
|
||||
@@ -8,21 +8,21 @@
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "run-p type-check \"build-only {@}\" --",
|
||||
"build": "npx run-p type-check \"build-only {@}\" --",
|
||||
"preview": "vite preview",
|
||||
"build-only": "vite build",
|
||||
"type-check": "vue-tsc --build",
|
||||
"lint": "eslint . --fix",
|
||||
"cap:sync": "npx cap sync && node scripts/fix-capacitor-plugins.js",
|
||||
"cap:sync:android": "npx cap sync android && node scripts/fix-capacitor-plugins.js",
|
||||
"cap:sync:ios": "npx cap sync ios",
|
||||
"cap:sync:ios": "npx cap copy ios && node scripts/fix-capacitor-plugins.js && cd ios/App && ../../scripts/pod-install.sh && cd ../..",
|
||||
"postinstall": "node scripts/fix-capacitor-plugins.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@capacitor/android": "^6.2.1",
|
||||
"@capacitor/ios": "^6.2.1",
|
||||
"@capacitor/cli": "^6.2.1",
|
||||
"@capacitor/core": "^6.2.1",
|
||||
"@capacitor/ios": "^6.2.1",
|
||||
"@timesafari/daily-notification-plugin": "file:../../",
|
||||
"date-fns": "^4.1.0",
|
||||
"did-jwt": "^7.4.7",
|
||||
|
||||
@@ -108,7 +108,8 @@ check_requirements() {
|
||||
# Check Android requirements if building Android
|
||||
if [ "$BUILD_ALL" = true ] || [ "$BUILD_ANDROID" = true ]; then
|
||||
if ! command -v adb &> /dev/null; then
|
||||
log_warn "Android SDK not found (adb not in PATH). Android build will be skipped."
|
||||
log_warn "Android SDK tools not found (adb not in PATH)."
|
||||
log_warn "APK can still be built, but install/launch requires adb."
|
||||
else
|
||||
log_info "✅ Android SDK: $(adb version | head -1)"
|
||||
fi
|
||||
@@ -238,28 +239,105 @@ if [ "$BUILD_ALL" = true ] || [ "$BUILD_IOS" = true ]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
# Find Android SDK location
|
||||
find_android_sdk() {
|
||||
local android_dir=""
|
||||
local local_props="$PROJECT_DIR/android/local.properties"
|
||||
|
||||
# Check environment variables first
|
||||
if [ -n "$ANDROID_HOME" ] && [ -d "$ANDROID_HOME" ]; then
|
||||
android_dir="$ANDROID_HOME"
|
||||
log_info "Found Android SDK via ANDROID_HOME: $android_dir"
|
||||
elif [ -n "$ANDROID_SDK_ROOT" ] && [ -d "$ANDROID_SDK_ROOT" ]; then
|
||||
android_dir="$ANDROID_SDK_ROOT"
|
||||
log_info "Found Android SDK via ANDROID_SDK_ROOT: $android_dir"
|
||||
fi
|
||||
|
||||
# Check existing local.properties
|
||||
if [ -z "$android_dir" ] && [ -f "$local_props" ]; then
|
||||
# Temporarily disable exit on error for grep (may not find match)
|
||||
set +e
|
||||
sdk_line=$(grep "^sdk.dir=" "$local_props" 2>/dev/null)
|
||||
set -e
|
||||
if [ -n "$sdk_line" ]; then
|
||||
android_dir=$(echo "$sdk_line" | cut -d'=' -f2 | sed 's|\\\\|/|g' | sed "s|^~|$HOME|")
|
||||
if [ -n "$android_dir" ] && [ -d "$android_dir" ]; then
|
||||
log_info "Found Android SDK in local.properties: $android_dir"
|
||||
else
|
||||
android_dir=""
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Try common locations
|
||||
if [ -z "$android_dir" ]; then
|
||||
# macOS default location
|
||||
if [ -d "$HOME/Library/Android/sdk" ]; then
|
||||
android_dir="$HOME/Library/Android/sdk"
|
||||
log_info "Found Android SDK in default macOS location: $android_dir"
|
||||
# Linux default location
|
||||
elif [ -d "$HOME/Android/Sdk" ]; then
|
||||
android_dir="$HOME/Android/Sdk"
|
||||
log_info "Found Android SDK in default Linux location: $android_dir"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Create/update local.properties if SDK found
|
||||
if [ -n "$android_dir" ]; then
|
||||
# Normalize path (convert to forward slashes, expand ~)
|
||||
android_dir=$(echo "$android_dir" | sed 's|\\\\|/|g' | sed "s|^~|$HOME|")
|
||||
|
||||
# Create local.properties with SDK location
|
||||
mkdir -p "$(dirname "$local_props")"
|
||||
echo "## This file is automatically generated by build script" > "$local_props"
|
||||
echo "## Location: $android_dir" >> "$local_props"
|
||||
echo "sdk.dir=$android_dir" >> "$local_props"
|
||||
log_info "✅ Configured Android SDK in local.properties"
|
||||
return 0
|
||||
else
|
||||
log_error "Android SDK not found!"
|
||||
log_error "Please set one of the following:"
|
||||
log_error " 1. ANDROID_HOME environment variable"
|
||||
log_error " 2. ANDROID_SDK_ROOT environment variable"
|
||||
log_error " 3. Create android/local.properties with: sdk.dir=/path/to/android/sdk"
|
||||
log_error ""
|
||||
log_error "Common SDK locations:"
|
||||
log_error " macOS: ~/Library/Android/sdk"
|
||||
log_error " Linux: ~/Android/Sdk"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Android build
|
||||
if [ "$BUILD_ALL" = true ] || [ "$BUILD_ANDROID" = true ]; then
|
||||
log_step "Building Android app..."
|
||||
|
||||
# Check for Android SDK
|
||||
if ! command -v adb &> /dev/null; then
|
||||
log_warn "adb not found. Android SDK may not be installed."
|
||||
log_warn "Skipping Android build. Install Android SDK to build Android."
|
||||
else
|
||||
cd "$PROJECT_DIR/android"
|
||||
# Ensure Android SDK is configured
|
||||
if ! find_android_sdk; then
|
||||
log_error "Cannot build Android app without SDK location"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd "$PROJECT_DIR/android"
|
||||
|
||||
# Build APK (Gradle doesn't require adb for building)
|
||||
if ./gradlew :app:assembleDebug; then
|
||||
log_info "Android APK built successfully"
|
||||
|
||||
# Build APK
|
||||
if ./gradlew :app:assembleDebug; then
|
||||
log_info "Android APK built successfully"
|
||||
APK_PATH="$PROJECT_DIR/android/app/build/outputs/apk/debug/app-debug.apk"
|
||||
|
||||
if [ -f "$APK_PATH" ]; then
|
||||
log_info "APK location: $APK_PATH"
|
||||
|
||||
APK_PATH="$PROJECT_DIR/android/app/build/outputs/apk/debug/app-debug.apk"
|
||||
|
||||
if [ -f "$APK_PATH" ]; then
|
||||
log_info "APK location: $APK_PATH"
|
||||
|
||||
# Run on emulator if requested
|
||||
if [ "$RUN_ALL" = true ] || [ "$RUN_ANDROID" = true ]; then
|
||||
# Run on emulator if requested (requires adb)
|
||||
if [ "$RUN_ALL" = true ] || [ "$RUN_ANDROID" = true ]; then
|
||||
# Check for Android SDK tools (adb)
|
||||
if ! command -v adb &> /dev/null; then
|
||||
log_warn "adb not found in PATH. Cannot install/launch app."
|
||||
log_warn "APK built successfully, but install/launch requires Android SDK."
|
||||
log_info "To install manually: adb install -r $APK_PATH"
|
||||
log_info "Or add Android SDK platform-tools to your PATH."
|
||||
else
|
||||
log_step "Installing and launching Android app..."
|
||||
|
||||
# Check for running emulator
|
||||
@@ -283,16 +361,16 @@ if [ "$BUILD_ALL" = true ] || [ "$BUILD_ANDROID" = true ]; then
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
else
|
||||
log_error "APK not found at expected location: $APK_PATH"
|
||||
fi
|
||||
else
|
||||
log_error "Android build failed"
|
||||
exit 1
|
||||
log_error "APK not found at expected location: $APK_PATH"
|
||||
fi
|
||||
|
||||
cd "$PROJECT_DIR"
|
||||
else
|
||||
log_error "Android build failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd "$PROJECT_DIR"
|
||||
fi
|
||||
|
||||
# iOS build
|
||||
|
||||
@@ -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
|
||||
|
||||
25
test-apps/daily-notification-test/scripts/pod-install.sh
Executable file
25
test-apps/daily-notification-test/scripts/pod-install.sh
Executable 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 "$@"
|
||||
@@ -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%);
|
||||
}
|
||||
|
||||
|
||||
102
test-apps/daily-notification-test/src/components/StatusList.vue
Normal file
102
test-apps/daily-notification-test/src/components/StatusList.vue
Normal 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>
|
||||
|
||||
@@ -40,6 +40,9 @@ export interface ScheduleResponse {
|
||||
export interface PermissionStatus {
|
||||
notifications: 'granted' | 'denied'
|
||||
notificationsEnabled: boolean
|
||||
exactAlarmEnabled?: boolean
|
||||
wakeLockEnabled?: boolean
|
||||
allPermissionsGranted?: boolean
|
||||
}
|
||||
|
||||
export interface NotificationStatus {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
@@ -402,7 +409,7 @@ const checkAndRequestPermissions = async (): Promise<void> => {
|
||||
// Request permissions - this will show the iOS system dialog
|
||||
// Try requestNotificationPermissions first (iOS), fallback to requestPermissions
|
||||
if (typeof (plugin as any).requestNotificationPermissions === 'function') {
|
||||
await (plugin as { requestNotificationPermissions: () => Promise<void> }).requestNotificationPermissions()
|
||||
await (plugin as { requestNotificationPermissions: () => Promise<any> }).requestNotificationPermissions()
|
||||
} else if (typeof (plugin as any).requestPermissions === 'function') {
|
||||
await (plugin as { requestPermissions: () => Promise<any> }).requestPermissions()
|
||||
} else {
|
||||
@@ -623,7 +630,6 @@ onMounted(async () => {
|
||||
|
||||
<style scoped>
|
||||
.home-view {
|
||||
padding: 20px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
@@ -654,8 +660,6 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
.platform-badge {
|
||||
padding: 6px 12px;
|
||||
border-radius: 16px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
@@ -663,8 +667,6 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
padding: 6px 12px;
|
||||
border-radius: 16px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
@@ -688,17 +690,18 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
.system-status {
|
||||
margin-bottom: 40px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 30px;
|
||||
margin-bottom: 24px;
|
||||
margin-top: 32px;
|
||||
}
|
||||
|
||||
/* Mobile responsiveness */
|
||||
@media (max-width: 768px) {
|
||||
.home-view {
|
||||
padding: 16px;
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
.welcome-title {
|
||||
@@ -708,10 +711,5 @@ onMounted(async () => {
|
||||
.action-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.platform-info {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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>
|
||||
@@ -21,7 +21,6 @@ export default toNative(NotFoundView)
|
||||
|
||||
<style scoped>
|
||||
.not-found-view {
|
||||
padding: 20px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -182,32 +182,52 @@
|
||||
}
|
||||
|
||||
function loadPluginStatus() {
|
||||
console.log('loadPluginStatus called');
|
||||
console.log('[UI Refresh] loadPluginStatus called');
|
||||
const pluginStatusContent = document.getElementById('pluginStatusContent');
|
||||
const statusCard = document.getElementById('statusCard');
|
||||
|
||||
try {
|
||||
if (!window.DailyNotification) {
|
||||
console.warn('[UI Refresh] DailyNotification plugin not available');
|
||||
pluginStatusContent.innerHTML = '❌ DailyNotification plugin not available';
|
||||
statusCard.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background
|
||||
return;
|
||||
}
|
||||
console.log('[UI Refresh] Calling getNotificationStatus()...');
|
||||
window.DailyNotification.getNotificationStatus()
|
||||
.then(result => {
|
||||
console.log('[UI Refresh] getNotificationStatus result:', {
|
||||
nextNotificationTime: result.nextNotificationTime,
|
||||
isEnabled: result.isEnabled,
|
||||
pending: result.pending,
|
||||
lastNotificationTime: result.lastNotificationTime
|
||||
});
|
||||
|
||||
const nextTime = formatDateTimeNormalized(result.nextNotificationTime);
|
||||
const hasSchedules = result.isEnabled || (result.pending && result.pending > 0);
|
||||
const statusIcon = hasSchedules ? '✅' : '⏸️';
|
||||
|
||||
console.log('[UI Refresh] Updating UI:', {
|
||||
nextTime: nextTime,
|
||||
hasSchedules: hasSchedules,
|
||||
statusIcon: statusIcon
|
||||
});
|
||||
|
||||
pluginStatusContent.innerHTML = `${statusIcon} Active Schedules: ${hasSchedules ? 'Yes' : 'No'}<br>
|
||||
📅 Next Notification: ${nextTime}<br>
|
||||
⏳ Pending: ${result.pending || 0}`;
|
||||
statusCard.style.background = hasSchedules ?
|
||||
'rgba(0, 255, 0, 0.15)' : 'rgba(255, 255, 255, 0.1)'; // Green if active, light gray if none
|
||||
|
||||
console.log('[UI Refresh] UI updated successfully');
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('[UI Refresh] getNotificationStatus failed:', error);
|
||||
pluginStatusContent.innerHTML = `⚠️ Status check failed: ${error.message}`;
|
||||
statusCard.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[UI Refresh] loadPluginStatus exception:', error);
|
||||
pluginStatusContent.innerHTML = `⚠️ Status check failed: ${error.message}`;
|
||||
statusCard.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background
|
||||
}
|
||||
@@ -444,18 +464,36 @@
|
||||
|
||||
// Check for notification delivery and status updates periodically
|
||||
function checkNotificationDelivery() {
|
||||
if (!window.DailyNotification) return;
|
||||
if (!window.DailyNotification) {
|
||||
console.log('[Poll] DailyNotification not available, skipping check');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[Poll] checkNotificationDelivery called');
|
||||
window.DailyNotification.getNotificationStatus()
|
||||
.then(result => {
|
||||
console.log('[Poll] Status check result:', {
|
||||
nextNotificationTime: result.nextNotificationTime,
|
||||
lastNotificationTime: result.lastNotificationTime,
|
||||
lastKnownNextNotificationTime: lastKnownNextNotificationTime
|
||||
});
|
||||
|
||||
// Check for notification delivery
|
||||
if (result.lastNotificationTime) {
|
||||
const lastTime = new Date(result.lastNotificationTime);
|
||||
const now = new Date();
|
||||
const timeDiff = now - lastTime;
|
||||
|
||||
console.log('[Poll] Notification delivery check:', {
|
||||
lastTime: lastTime.toISOString(),
|
||||
now: now.toISOString(),
|
||||
timeDiff: timeDiff,
|
||||
withinWindow: timeDiff > 0 && timeDiff < 120000
|
||||
});
|
||||
|
||||
// If notification was received in the last 2 minutes, show indicator
|
||||
if (timeDiff > 0 && timeDiff < 120000) {
|
||||
console.log('[Poll] Notification received recently, showing indicator');
|
||||
const indicator = document.getElementById('notificationReceivedIndicator');
|
||||
const timeSpan = document.getElementById('notificationReceivedTime');
|
||||
|
||||
@@ -471,7 +509,9 @@
|
||||
}, 30000);
|
||||
|
||||
// Force immediate refresh when notification is received (rollover may have occurred)
|
||||
console.log('[Poll] Scheduling UI refresh in 1 second (waiting for rollover)...');
|
||||
setTimeout(() => {
|
||||
console.log('[Poll] Triggering UI refresh after notification received');
|
||||
loadPluginStatus();
|
||||
}, 1000); // Wait 1 second for rollover to complete
|
||||
}
|
||||
@@ -480,20 +520,37 @@
|
||||
|
||||
// Detect if nextNotificationTime changed (rollover occurred)
|
||||
const currentNextTime = result.nextNotificationTime;
|
||||
console.log('[Poll] Comparing nextNotificationTime:', {
|
||||
current: currentNextTime,
|
||||
lastKnown: lastKnownNextNotificationTime,
|
||||
changed: currentNextTime && currentNextTime !== lastKnownNextNotificationTime
|
||||
});
|
||||
|
||||
if (currentNextTime && currentNextTime !== lastKnownNextNotificationTime) {
|
||||
if (lastKnownNextNotificationTime !== null) {
|
||||
console.log('Next notification time changed - rollover detected!');
|
||||
console.log('[Poll] ⚠️ Next notification time changed - rollover detected!', {
|
||||
old: lastKnownNextNotificationTime,
|
||||
new: currentNextTime,
|
||||
oldDate: new Date(lastKnownNextNotificationTime).toISOString(),
|
||||
newDate: new Date(currentNextTime).toISOString()
|
||||
});
|
||||
// Force immediate refresh
|
||||
loadPluginStatus();
|
||||
} else {
|
||||
console.log('[Poll] Initializing lastKnownNextNotificationTime:', currentNextTime);
|
||||
}
|
||||
lastKnownNextNotificationTime = currentNextTime;
|
||||
} else {
|
||||
console.log('[Poll] No change detected in nextNotificationTime');
|
||||
}
|
||||
|
||||
// Auto-refresh plugin status periodically to show updated next notification time after rollover
|
||||
// This ensures the UI updates when the plugin reschedules the notification
|
||||
console.log('[Poll] Triggering periodic UI refresh');
|
||||
loadPluginStatus();
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('[Poll] Status check failed:', error);
|
||||
// Silently fail - this is just for visual feedback
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user