Compare commits
128 Commits
2026-01-01
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ffa7bac319 | ||
| e0e0a0a183 | |||
| ea662f4430 | |||
| 81647e1f3c | |||
| bf1ee78025 | |||
|
|
66b7d0f46e | ||
|
|
63dcf44125 | ||
| cf1ecdfb4c | |||
| e9ad61b780 | |||
| ad8df3eb93 | |||
| 05d346edce | |||
| e259e60fa7 | |||
| 821de3f006 | |||
| 43f83031d4 | |||
| 688a48a332 | |||
| 8938c242ee | |||
| 358af42afd | |||
| 59c00241b8 | |||
| 33ec90e571 | |||
| fa1c639a8b | |||
| 5ae0d6ba2c | |||
| 3aff1e9749 | |||
|
|
6415eb2a03 | ||
|
|
9902e5fac7 | ||
|
|
fb9d5165df | ||
| ba8915e1fb | |||
|
|
616d0fd6e0 | ||
|
|
7ae36ec361 | ||
| f3cf228b48 | |||
| d5db13dc18 | |||
| 717efb087b | |||
|
|
f3cfa9552d | ||
|
|
de486a2e23 | ||
| 94f31faacc | |||
| 099eac594f | |||
|
|
6825bd5214 | ||
| b4b7d71330 | |||
|
|
af63ab70e7 | ||
| 41149ad28a | |||
| 4f89869a87 | |||
| a45f605c5f | |||
|
|
96ae89bcfa | ||
|
|
7e2b16ddad | ||
|
|
29aff896be | ||
|
|
d4721f5f4c | ||
|
|
baac36607b | ||
| 3c657848c5 | |||
| cbd71b7efd | |||
|
|
25c3cd99e4 | ||
|
|
cd5f9f5317 | ||
| 47ead0ced2 | |||
| dd8850aa24 | |||
| e7e2830807 | |||
| 41e50bdf95 | |||
| 0dc3e2e251 | |||
| 5809cd568a | |||
| 30c6df557c | |||
| 07ebd1c32f | |||
| f3152fc414 | |||
| 59303010c1 | |||
| 0d5602839c | |||
| 67ff0cfb99 | |||
| f7cee7df78 | |||
| 8310152c34 | |||
|
|
c28c47a3c9 | ||
|
|
1b97ac08fd | ||
|
|
17ccfd1fea | ||
|
|
0e096b1a46 | ||
| 7838eea30f | |||
| bb9b0d3c2f | |||
| a910399cad | |||
| dc3f12d53b | |||
|
|
ec55a74cbf | ||
|
|
c2fb493073 | ||
|
|
c05dff6654 | ||
|
|
a7fbb26847 | ||
| 0a927ccec5 | |||
| 1b19919121 | |||
| 1c3d449c85 | |||
|
|
0a88f23bc7 | ||
|
|
fe9cdd6398 | ||
|
|
f5cb70ec8b | ||
|
|
a71bd09b78 | ||
| e38b752b27 | |||
|
|
80cc09de95 | ||
|
|
d0878507a6 | ||
| 099d70e8a9 | |||
| cc7c7eb88b | |||
|
|
1345118b79 | ||
|
|
22c3ac80c2 | ||
|
|
fb4ea08f3c | ||
|
|
88f69054f4 | ||
|
|
77e8d2d2ab | ||
| 8991b29705 | |||
|
|
31dfeb0988 | ||
|
|
5a4ab84bfe | ||
| df61e899da | |||
| b775c5b4c1 | |||
|
|
84c3f79c57 | ||
| a04730cd64 | |||
| 077f45f900 | |||
|
|
14ffcb5434 | ||
| a0b99d5fca | |||
| bcf654e2e8 | |||
| e1b312a402 | |||
| 2684484a84 | |||
| 09c38a8b1c | |||
| 0c0bda725c | |||
| 6587506d83 | |||
| 29b2d9927d | |||
| 9a6e78ee9d | |||
|
|
679c4d6456 | ||
| 1fc7e4726d | |||
| b500a1e7c0 | |||
| 46f2cbfcc6 | |||
| 08f91e4c96 | |||
| e94effd111 | |||
| 84cad0e169 | |||
| b6704b348b | |||
| 662da79df8 | |||
| 02eb891ee9 | |||
| 051af89476 | |||
| 9b2d14b418 | |||
| 6e73ab4a84 | |||
| 11736b5751 | |||
| 85e7682b90 | |||
| b91d387815 | |||
| 4a3b968ee2 |
@@ -181,26 +181,26 @@ Brief description of the document's purpose and scope.
|
|||||||
### Check Single File
|
### Check Single File
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npx markdownlint docs/filename.md
|
npx markdownlint doc/filename.md
|
||||||
```
|
```
|
||||||
|
|
||||||
### Check All Documentation
|
### Check All Documentation
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npx markdownlint docs/
|
npx markdownlint doc/
|
||||||
```
|
```
|
||||||
|
|
||||||
### Auto-fix Common Issues
|
### Auto-fix Common Issues
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Remove trailing spaces
|
# Remove trailing spaces
|
||||||
sed -i 's/[[:space:]]*$//' docs/filename.md
|
sed -i 's/[[:space:]]*$//' doc/filename.md
|
||||||
|
|
||||||
# Remove multiple blank lines
|
# Remove multiple blank lines
|
||||||
sed -i '/^$/N;/^\n$/D' docs/filename.md
|
sed -i '/^$/N;/^\n$/D' doc/filename.md
|
||||||
|
|
||||||
# Add newline at end if missing
|
# Add newline at end if missing
|
||||||
echo "" >> docs/filename.md
|
echo "" >> doc/filename.md
|
||||||
```
|
```
|
||||||
|
|
||||||
## Common Patterns
|
## Common Patterns
|
||||||
|
|||||||
@@ -269,7 +269,7 @@ The workflow system integrates seamlessly with existing development practices:
|
|||||||
your task
|
your task
|
||||||
4. **Meta-Rules**: Use workflow-specific meta-rules for specialized tasks
|
4. **Meta-Rules**: Use workflow-specific meta-rules for specialized tasks
|
||||||
- **Documentation**: Use `meta_documentation.mdc` for all documentation work
|
- **Documentation**: Use `meta_documentation.mdc` for all documentation work
|
||||||
- **Getting Started**: See `docs/meta_rule_usage_guide.md` for comprehensive usage instructions
|
- **Getting Started**: See `doc/meta_rule_usage_guide.md` for comprehensive usage instructions
|
||||||
5. **Cross-References**: All files contain updated cross-references to
|
5. **Cross-References**: All files contain updated cross-references to
|
||||||
reflect the new structure
|
reflect the new structure
|
||||||
6. **Validation**: All files pass markdown validation and maintain
|
6. **Validation**: All files pass markdown validation and maintain
|
||||||
|
|||||||
@@ -122,11 +122,11 @@ npm run lint-fix
|
|||||||
|
|
||||||
## Resources
|
## Resources
|
||||||
|
|
||||||
- **Testing**: `docs/migration-testing/`
|
- **Testing**: `doc/migration-testing/`
|
||||||
|
|
||||||
- **Architecture**: `docs/architecture-decisions.md`
|
- **Architecture**: `doc/architecture-decisions.md`
|
||||||
|
|
||||||
- **Build Context**: `docs/build-modernization-context.md`
|
- **Build Context**: `doc/build-modernization-context.md`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -122,9 +122,9 @@ Copy/paste and fill:
|
|||||||
|
|
||||||
- `src/...`
|
- `src/...`
|
||||||
|
|
||||||
- ADR: `docs/adr/xxxx-yy-zz-something.md`
|
- ADR: `doc/adr/xxxx-yy-zz-something.md`
|
||||||
|
|
||||||
- Design: `docs/...`
|
- Design: `doc/...`
|
||||||
|
|
||||||
## Competence Hooks
|
## Competence Hooks
|
||||||
|
|
||||||
@@ -230,7 +230,7 @@ Before proposing solutions, trace the actual execution path:
|
|||||||
|
|
||||||
attach during service/feature investigations
|
attach during service/feature investigations
|
||||||
|
|
||||||
- `docs/adr/**` — attach when editing ADRs
|
- `doc/adr/**` — attach when editing ADRs
|
||||||
|
|
||||||
## Referenced Files
|
## Referenced Files
|
||||||
|
|
||||||
|
|||||||
@@ -6,10 +6,13 @@ VITE_LOG_LEVEL=debug
|
|||||||
# iOS doesn't like spaces in the app title.
|
# iOS doesn't like spaces in the app title.
|
||||||
TIME_SAFARI_APP_TITLE="TimeSafari_Dev"
|
TIME_SAFARI_APP_TITLE="TimeSafari_Dev"
|
||||||
VITE_APP_SERVER=http://localhost:8080
|
VITE_APP_SERVER=http://localhost:8080
|
||||||
# This is the claim ID for actions in the BVC project, with the JWT ID on this environment (not
|
|
||||||
|
|
||||||
|
|
||||||
|
# This is the claim ID for actions in the BVC project, with the JWT ID on the environment
|
||||||
|
# test server
|
||||||
VITE_BVC_MEETUPS_PROJECT_CLAIM_ID=https://endorser.ch/entity/01HWE8FWHQ1YGP7GFZYYPS272F
|
VITE_BVC_MEETUPS_PROJECT_CLAIM_ID=https://endorser.ch/entity/01HWE8FWHQ1YGP7GFZYYPS272F
|
||||||
|
# production server
|
||||||
|
#VITE_BVC_MEETUPS_PROJECT_CLAIM_ID=https://endorser.ch/entity/01GXYPFF7FA03NXKPYY142PY4H
|
||||||
|
|
||||||
VITE_DEFAULT_ENDORSER_API_SERVER=http://localhost:3000
|
VITE_DEFAULT_ENDORSER_API_SERVER=http://localhost:3000
|
||||||
# Using shared server by default to ease setup, which works for shared test users.
|
# Using shared server by default to ease setup, which works for shared test users.
|
||||||
VITE_DEFAULT_IMAGE_API_SERVER=https://test-image-api.timesafari.app
|
VITE_DEFAULT_IMAGE_API_SERVER=https://test-image-api.timesafari.app
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -16,6 +16,9 @@ myenv
|
|||||||
.env.local
|
.env.local
|
||||||
.env.*.local
|
.env.*.local
|
||||||
|
|
||||||
|
# npm configuration with sensitive tokens
|
||||||
|
.npmrc
|
||||||
|
|
||||||
# Log filesopenssl dgst -sha256 -verify public.pem -signature <(echo -n "$signature") "$signing_input"
|
# Log filesopenssl dgst -sha256 -verify public.pem -signature <(echo -n "$signature") "$signing_input"
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
|
|||||||
45
.husky/_/husky.sh
Executable file → Normal file
45
.husky/_/husky.sh
Executable file → Normal file
@@ -1,40 +1,9 @@
|
|||||||
|
echo "husky - DEPRECATED
|
||||||
|
|
||||||
|
Please remove the following two lines from $0:
|
||||||
|
|
||||||
#!/usr/bin/env sh
|
#!/usr/bin/env sh
|
||||||
#
|
. \"\$(dirname -- \"\$0\")/_/husky.sh\"
|
||||||
# Husky Helper Script
|
|
||||||
# This file is sourced by all Husky hooks
|
|
||||||
#
|
|
||||||
if [ -z "$husky_skip_init" ]; then
|
|
||||||
debug () {
|
|
||||||
if [ "$HUSKY_DEBUG" = "1" ]; then
|
|
||||||
echo "husky (debug) - $1"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
readonly hook_name="$(basename -- "$0")"
|
They WILL FAIL in v10.0.0
|
||||||
debug "starting $hook_name..."
|
"
|
||||||
|
|
||||||
if [ "$HUSKY" = "0" ]; then
|
|
||||||
debug "HUSKY env variable is set to 0, skipping hook"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -f ~/.huskyrc ]; then
|
|
||||||
debug "sourcing ~/.huskyrc"
|
|
||||||
. ~/.huskyrc
|
|
||||||
fi
|
|
||||||
|
|
||||||
readonly husky_skip_init=1
|
|
||||||
export husky_skip_init
|
|
||||||
sh -e "$0" "$@"
|
|
||||||
exitCode="$?"
|
|
||||||
|
|
||||||
if [ $exitCode != 0 ]; then
|
|
||||||
echo "husky - $hook_name hook exited with code $exitCode (error)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ $exitCode = 127 ]; then
|
|
||||||
echo "husky - command not found in PATH=$PATH"
|
|
||||||
fi
|
|
||||||
|
|
||||||
exit $exitCode
|
|
||||||
fi
|
|
||||||
165
BUILDING.md
165
BUILDING.md
@@ -196,7 +196,7 @@ cp .env.example .env.development
|
|||||||
|
|
||||||
- Node.js 18+ and npm
|
- Node.js 18+ and npm
|
||||||
- Git
|
- Git
|
||||||
- For mobile builds: Xcode (macOS) or Android Studio
|
- For mobile builds: Xcode (macOS) or Android Studio (or Android SDK Command Line Tools for Android emulator only; see [Android Emulator Without Android Studio](#android-emulator-without-android-studio-command-line-only))
|
||||||
- For desktop builds: Additional build tools based on your OS
|
- For desktop builds: Additional build tools based on your OS
|
||||||
|
|
||||||
## Forks
|
## Forks
|
||||||
@@ -333,11 +333,11 @@ The `serve` functionality provides a local HTTP server for testing production bu
|
|||||||
- If there are DB changes: before updating the test server, open browser(s) with
|
- If there are DB changes: before updating the test server, open browser(s) with
|
||||||
current version to test DB migrations.
|
current version to test DB migrations.
|
||||||
|
|
||||||
- Update the ClickUp tasks & CHANGELOG.md & the version in package.json, run
|
- Update the ClickUp tasks & CHANGELOG.md & the version in package.json, run:
|
||||||
`npm install`.
|
`npm install`.
|
||||||
|
|
||||||
- Run a build to make sure package-lock version is updated, linting works, etc:
|
- Run a build to make sure linting works, etc:
|
||||||
`npm install && npm run build:web`
|
`npm run build:web`
|
||||||
|
|
||||||
- Commit everything (since the commit hash is used the app).
|
- Commit everything (since the commit hash is used the app).
|
||||||
|
|
||||||
@@ -346,7 +346,7 @@ current version to test DB migrations.
|
|||||||
|
|
||||||
- Tag with the new version,
|
- Tag with the new version,
|
||||||
[online](https://gitea.anomalistdesign.com/trent_larson/crowd-funder-for-time-pwa/releases) or
|
[online](https://gitea.anomalistdesign.com/trent_larson/crowd-funder-for-time-pwa/releases) or
|
||||||
`git tag 1.0.2 && git push origin 1.0.2`.
|
`git tag 1.3.13 && git push origin 1.3.13`.
|
||||||
|
|
||||||
- For test, build the app:
|
- For test, build the app:
|
||||||
|
|
||||||
@@ -366,7 +366,7 @@ rsync -azvu -e "ssh -i ~/.ssh/..." dist ubuntutest@test.timesafari.app:time-safa
|
|||||||
|
|
||||||
- For prod, you can do the same with `build:web:prod` instead.
|
- For prod, you can do the same with `build:web:prod` instead.
|
||||||
|
|
||||||
... or log onto the server (though the build step can stay on "rendering chunks" for a long while):
|
Here are instructions directly on the server, but the build step can stay on "rendering chunks" for a long time and it basically hangs any other access to the server. In fact, last time it was killed: "Failed after 482 seconds (exit code: 137)" Maybe use `nice`?
|
||||||
|
|
||||||
- `pkgx +npm sh`
|
- `pkgx +npm sh`
|
||||||
|
|
||||||
@@ -376,7 +376,7 @@ rsync -azvu -e "ssh -i ~/.ssh/..." dist ubuntutest@test.timesafari.app:time-safa
|
|||||||
|
|
||||||
- Back up the time-safari/dist folder & deploy: `mv time-safari/dist time-safari-dist-prev-2 && mv crowd-funder-for-time-pwa/dist time-safari/`
|
- Back up the time-safari/dist folder & deploy: `mv time-safari/dist time-safari-dist-prev-2 && mv crowd-funder-for-time-pwa/dist time-safari/`
|
||||||
|
|
||||||
- Record the new hash in the changelog. Edit package.json to increment version &
|
Be sure to record the new hash in the changelog. Edit package.json to increment version &
|
||||||
add "-beta", `npm install`, commit, and push. Also record what version is on production.
|
add "-beta", `npm install`, commit, and push. Also record what version is on production.
|
||||||
|
|
||||||
## Docker Deployment
|
## Docker Deployment
|
||||||
@@ -1047,7 +1047,7 @@ npx cap sync electron
|
|||||||
- Package integrity verification
|
- Package integrity verification
|
||||||
- Rollback capabilities
|
- Rollback capabilities
|
||||||
|
|
||||||
For detailed documentation, see [docs/electron-build-patterns.md](docs/electron-build-patterns.md).
|
For detailed documentation, see [doc/electron-build-patterns.md](doc/electron-build-patterns.md).
|
||||||
|
|
||||||
## Mobile Builds (Capacitor)
|
## Mobile Builds (Capacitor)
|
||||||
|
|
||||||
@@ -1140,7 +1140,7 @@ export GEM_PATH=$shortened_path
|
|||||||
##### 1. Bump the version in package.json & CHANGELOG.md for `MARKETING_VERSION`, then `grep CURRENT_PROJECT_VERSION ios/App/App.xcodeproj/project.pbxproj` and add 1 for the numbered version here:
|
##### 1. Bump the version in package.json & CHANGELOG.md for `MARKETING_VERSION`, then `grep CURRENT_PROJECT_VERSION ios/App/App.xcodeproj/project.pbxproj` and add 1 for the numbered version here:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd ios/App && xcrun agvtool new-version 50 && perl -p -i -e "s/MARKETING_VERSION = .*;/MARKETING_VERSION = 1.1.5;/g" App.xcodeproj/project.pbxproj && cd -
|
cd ios/App && xcrun agvtool new-version 67 && perl -p -i -e "s/MARKETING_VERSION = .*;/MARKETING_VERSION = 1.3.12;/g" App.xcodeproj/project.pbxproj && cd -
|
||||||
# Unfortunately this edits Info.plist directly.
|
# Unfortunately this edits Info.plist directly.
|
||||||
#xcrun agvtool new-marketing-version 0.4.5
|
#xcrun agvtool new-marketing-version 0.4.5
|
||||||
```
|
```
|
||||||
@@ -1181,7 +1181,128 @@ npm run build:ios:prod
|
|||||||
|
|
||||||
### Android Build
|
### Android Build
|
||||||
|
|
||||||
Prerequisites: Android Studio with Java SDK installed
|
Prerequisites: Android Studio with Java SDK installed (or **Android SDK Command Line Tools** only — see [Android Emulator Without Android Studio](#android-emulator-without-android-studio-command-line-only) below).
|
||||||
|
|
||||||
|
#### Android Emulator Without Android Studio (Command-Line Only)
|
||||||
|
|
||||||
|
You can build and run the app on an Android emulator using only the **Android SDK Command Line Tools** (no Android Studio). The project uses **API 36** (see `android/variables.gradle`: `compileSdkVersion` / `targetSdkVersion`).
|
||||||
|
|
||||||
|
##### 1. Environment
|
||||||
|
|
||||||
|
Set your SDK location and PATH (e.g. in `~/.zshrc` or `~/.bashrc`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# macOS default SDK location
|
||||||
|
export ANDROID_HOME=$HOME/Library/Android/sdk
|
||||||
|
# or: export ANDROID_HOME=$HOME/Android/Sdk
|
||||||
|
|
||||||
|
export PATH=$PATH:$ANDROID_HOME/emulator
|
||||||
|
export PATH=$PATH:$ANDROID_HOME/platform-tools
|
||||||
|
export PATH=$PATH:$ANDROID_HOME/cmdline-tools/latest/bin
|
||||||
|
export PATH=$PATH:$ANDROID_HOME/build-tools/34.0.0
|
||||||
|
```
|
||||||
|
|
||||||
|
Reload your shell (e.g. `source ~/.zshrc`), then verify:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
adb version
|
||||||
|
emulator -version
|
||||||
|
avdmanager list
|
||||||
|
```
|
||||||
|
|
||||||
|
##### 2. Install SDK components
|
||||||
|
|
||||||
|
Install platform tools, build tools, platform, and emulator:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sdkmanager "platform-tools"
|
||||||
|
sdkmanager "build-tools;34.0.0"
|
||||||
|
sdkmanager "platforms;android-36"
|
||||||
|
sdkmanager "emulator"
|
||||||
|
```
|
||||||
|
|
||||||
|
##### 3. Install system image and create AVD
|
||||||
|
|
||||||
|
**Mac Silicon (Apple M1/M2/M3)** — use **ARM64** for native performance:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# System image (API 36 matches the project)
|
||||||
|
sdkmanager "system-images;android-36;google_apis;arm64-v8a"
|
||||||
|
|
||||||
|
# Create AVD
|
||||||
|
avdmanager create avd \
|
||||||
|
--name "TimeSafari_Emulator" \
|
||||||
|
--package "system-images;android-36;google_apis;arm64-v8a" \
|
||||||
|
--device "pixel_7"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Intel Mac (x86_64):**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sdkmanager "system-images;android-36;google_apis;x86_64"
|
||||||
|
|
||||||
|
avdmanager create avd \
|
||||||
|
--name "TimeSafari_Emulator" \
|
||||||
|
--package "system-images;android-36;google_apis;x86_64" \
|
||||||
|
--device "pixel_7"
|
||||||
|
```
|
||||||
|
|
||||||
|
List AVDs: `avdmanager list avd`
|
||||||
|
|
||||||
|
##### 4. Start the emulator
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start in background (Mac Silicon or Intel)
|
||||||
|
emulator -avd TimeSafari_Emulator -gpu host -no-audio &
|
||||||
|
|
||||||
|
# Optional: wait until booted
|
||||||
|
adb wait-for-device
|
||||||
|
while [ "$(adb shell getprop sys.boot_completed 2>/dev/null)" != "1" ]; do sleep 2; done
|
||||||
|
```
|
||||||
|
|
||||||
|
If you have limited RAM, use reduced resources:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
emulator -avd TimeSafari_Emulator -no-audio -memory 2048 -cores 2 -gpu swiftshader_indirect &
|
||||||
|
```
|
||||||
|
|
||||||
|
Check device: `adb devices`
|
||||||
|
|
||||||
|
##### 5. Build the app
|
||||||
|
|
||||||
|
From the project root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build:android
|
||||||
|
# or: npm run build:android:debug
|
||||||
|
```
|
||||||
|
|
||||||
|
The debug APK is produced at:
|
||||||
|
`android/app/build/outputs/apk/debug/app-debug.apk`
|
||||||
|
|
||||||
|
##### 6. Install and launch on the emulator
|
||||||
|
|
||||||
|
With the emulator running:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
adb install -r android/app/build/outputs/apk/debug/app-debug.apk
|
||||||
|
adb shell am start -n app.timesafari.app/app.timesafari.MainActivity
|
||||||
|
```
|
||||||
|
|
||||||
|
##### One-shot build and run
|
||||||
|
|
||||||
|
To build and run in one go (emulator or device must already be running):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build:android:debug:run # debug build, install, launch
|
||||||
|
# or
|
||||||
|
npm run build:android:test:run # test env build, install, launch
|
||||||
|
```
|
||||||
|
|
||||||
|
##### Reference
|
||||||
|
|
||||||
|
- Emulator troubleshooting and options: [doc/android-emulator-deployment-guide.md](doc/android-emulator-deployment-guide.md)
|
||||||
|
- **Physical device testing**: [doc/android-physical-device-guide.md](doc/android-physical-device-guide.md)
|
||||||
|
|
||||||
#### Android Build Commands
|
#### Android Build Commands
|
||||||
|
|
||||||
@@ -1298,8 +1419,8 @@ The recommended way to build for Android is using the automated build script:
|
|||||||
##### 1. Bump the version in package.json, then update these versions & run:
|
##### 1. Bump the version in package.json, then update these versions & run:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
perl -p -i -e 's/versionCode .*/versionCode 50/g' android/app/build.gradle
|
perl -p -i -e 's/versionCode .*/versionCode 67/g' android/app/build.gradle
|
||||||
perl -p -i -e 's/versionName .*/versionName "1.1.5"/g' android/app/build.gradle
|
perl -p -i -e 's/versionName .*/versionName "1.3.12"/g' android/app/build.gradle
|
||||||
```
|
```
|
||||||
|
|
||||||
##### 2. Build
|
##### 2. Build
|
||||||
@@ -1688,11 +1809,13 @@ npm run build:android:assets
|
|||||||
|
|
||||||
## Additional Resources
|
## Additional Resources
|
||||||
|
|
||||||
- [Electron Build Patterns](docs/electron-build-patterns.md)
|
- [Electron Build Patterns](doc/electron-build-patterns.md)
|
||||||
- [iOS Build Scripts](docs/ios-build-scripts.md)
|
- [iOS Build Scripts](doc/ios-build-scripts.md)
|
||||||
- [Android Build Scripts](docs/android-build-scripts.md)
|
- [Android Build Scripts](doc/android-build-scripts.md)
|
||||||
- [Web Build Scripts](docs/web-build-scripts.md)
|
- [Android Physical Device Guide](doc/android-physical-device-guide.md)
|
||||||
- [Build Troubleshooting](docs/build-troubleshooting.md)
|
- [Android Emulator Deployment Guide](doc/android-emulator-deployment-guide.md)
|
||||||
|
- [Web Build Scripts](doc/web-build-scripts.md)
|
||||||
|
- [Build Troubleshooting](doc/build-troubleshooting.md)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -2315,7 +2438,7 @@ export async function createBuildConfig(platform: string): Promise<UserConfig> {
|
|||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@': path.resolve(__dirname, 'src'),
|
'@': path.resolve(__dirname, 'src'),
|
||||||
'@nostr/tools': path.resolve(__dirname, 'node_modules/@nostr/tools'),
|
'nostr-tools': path.resolve(__dirname, 'node_modules/nostr-tools'),
|
||||||
'path': path.resolve(__dirname, './src/utils/node-modules/path.js'),
|
'path': path.resolve(__dirname, './src/utils/node-modules/path.js'),
|
||||||
'fs': path.resolve(__dirname, './src/utils/node-modules/fs.js'),
|
'fs': path.resolve(__dirname, './src/utils/node-modules/fs.js'),
|
||||||
'crypto': path.resolve(__dirname, './src/utils/node-modules/crypto.js'),
|
'crypto': path.resolve(__dirname, './src/utils/node-modules/crypto.js'),
|
||||||
@@ -2324,7 +2447,7 @@ export async function createBuildConfig(platform: string): Promise<UserConfig> {
|
|||||||
},
|
},
|
||||||
optimizeDeps: {
|
optimizeDeps: {
|
||||||
include: [
|
include: [
|
||||||
'@nostr/tools',
|
'nostr-tools',
|
||||||
'@jlongster/sql.js',
|
'@jlongster/sql.js',
|
||||||
'absurd-sql',
|
'absurd-sql',
|
||||||
// ... additional dependencies
|
// ... additional dependencies
|
||||||
@@ -2349,7 +2472,7 @@ export async function createBuildConfig(platform: string): Promise<UserConfig> {
|
|||||||
**Path Aliases**:
|
**Path Aliases**:
|
||||||
|
|
||||||
- `@`: Points to `src/` directory
|
- `@`: Points to `src/` directory
|
||||||
- `@nostr/tools`: Nostr tools library
|
- `nostr-tools`: Nostr tools library
|
||||||
- `path`, `fs`, `crypto`: Node.js polyfills for browser
|
- `path`, `fs`, `crypto`: Node.js polyfills for browser
|
||||||
|
|
||||||
### B.2 vite.config.web.mts
|
### B.2 vite.config.web.mts
|
||||||
@@ -2489,7 +2612,7 @@ export default defineConfig(async () => {
|
|||||||
output: {
|
output: {
|
||||||
manualChunks: {
|
manualChunks: {
|
||||||
vendor: ["vue", "vue-router", "@vueuse/core"],
|
vendor: ["vue", "vue-router", "@vueuse/core"],
|
||||||
crypto: ["@nostr/tools", "crypto-js"],
|
crypto: ["nostr-tools", "crypto-js"],
|
||||||
ui: ["@fortawesome/vue-fontawesome"]
|
ui: ["@fortawesome/vue-fontawesome"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
48
CHANGELOG.md
48
CHANGELOG.md
@@ -6,6 +6,54 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
|
||||||
|
## [1.3.13] - 2026.04.05
|
||||||
|
### Added
|
||||||
|
- Ability to select project that the current one fulfills
|
||||||
|
- Separate Terms & Conditions page (required for SMS campaigns)
|
||||||
|
### Fixed
|
||||||
|
- Edits to a 'give' would delete the image
|
||||||
|
|
||||||
|
|
||||||
|
## [1.3.12] - 2026.03.21
|
||||||
|
### Added
|
||||||
|
- Device wake-up for notifications
|
||||||
|
### Changed
|
||||||
|
- Rename to "Gifties"
|
||||||
|
|
||||||
|
|
||||||
|
## [1.3.7]
|
||||||
|
### Added
|
||||||
|
- Attendee exclusion and do-not-pair groups for meeting matching.
|
||||||
|
### Fixed
|
||||||
|
- Contact deep-links clicked or pasted act consistenly
|
||||||
|
|
||||||
|
|
||||||
|
## [1.3.5] - 2026.02.22
|
||||||
|
### Fixed
|
||||||
|
- SQL error on startup (contact_labels -> contacts foreign key)
|
||||||
|
### Added
|
||||||
|
- Ability to toggle embeddings on list of contacts
|
||||||
|
|
||||||
|
|
||||||
|
## [1.3.3] - 2026.02.17
|
||||||
|
### Added
|
||||||
|
- People can be marked as vector-embeddings users.
|
||||||
|
- People can be matched during a meeting.
|
||||||
|
### Fixed
|
||||||
|
- Problem hiding new contacts in feed
|
||||||
|
|
||||||
|
|
||||||
|
## [1.1.6] - 2026.01.21
|
||||||
|
### Added
|
||||||
|
- Labels on contacts
|
||||||
|
- Ability to switch giver & recipient on the gift-details page
|
||||||
|
### Changed
|
||||||
|
- Invitations now must be explicitly accepted.
|
||||||
|
### Fixed
|
||||||
|
- Show all starred projects.
|
||||||
|
- Incorrect contacts as "most recent" on gift-details page
|
||||||
|
|
||||||
|
|
||||||
## [1.1.5] - 2025.12.28
|
## [1.1.5] - 2025.12.28
|
||||||
### Fixed
|
### Fixed
|
||||||
- Incorrect prompts in give-dialog on a project or offer
|
- Incorrect prompts in give-dialog on a project or offer
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ Large Components (>500 lines): 5 components (12.5%)
|
|||||||
├── GiftedDialog.vue (670 lines) ⚠️ HIGH PRIORITY
|
├── GiftedDialog.vue (670 lines) ⚠️ HIGH PRIORITY
|
||||||
├── PhotoDialog.vue (669 lines) ⚠️ HIGH PRIORITY
|
├── PhotoDialog.vue (669 lines) ⚠️ HIGH PRIORITY
|
||||||
├── PushNotificationPermission.vue (660 lines) ⚠️ HIGH PRIORITY
|
├── PushNotificationPermission.vue (660 lines) ⚠️ HIGH PRIORITY
|
||||||
└── MembersList.vue (550 lines) ⚠️ MODERATE PRIORITY
|
└── MeetingMembersList.vue (550 lines) ⚠️ MODERATE PRIORITY
|
||||||
|
|
||||||
Medium Components (200-500 lines): 12 components (30%)
|
Medium Components (200-500 lines): 12 components (30%)
|
||||||
├── GiftDetailsStep.vue (450 lines)
|
├── GiftDetailsStep.vue (450 lines)
|
||||||
|
|||||||
25
README.md
25
README.md
@@ -15,10 +15,31 @@ Quick start:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install
|
npm install
|
||||||
npm run build:web:serve -- --test
|
|
||||||
```
|
```
|
||||||
|
|
||||||
To be able to take action on the platform: go to [the test page](http://localhost:8080/test) and click "Become User 0".
|
### Web
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build:web:dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Then go to [the test page](http://localhost:8080/test) and click "Become User 0" to take action on the platform.
|
||||||
|
|
||||||
|
### Android
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build:android:test:run
|
||||||
|
```
|
||||||
|
|
||||||
|
Assumes ADB is installed; see [Android Build](BUILDING.md#android-build) for SDK, emulator, and `PATH` setup.
|
||||||
|
|
||||||
|
### iOS
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build:ios:studio
|
||||||
|
```
|
||||||
|
|
||||||
|
Assumes Xcode and Xcode Command Line Tools are installed.
|
||||||
|
|
||||||
See [BUILDING.md](BUILDING.md) for comprehensive build instructions for all platforms (Web, Electron, iOS, Android, Docker).
|
See [BUILDING.md](BUILDING.md) for comprehensive build instructions for all platforms (Web, Electron, iOS, Android, Docker).
|
||||||
|
|
||||||
|
|||||||
@@ -27,12 +27,18 @@ if (!project.ext.MY_KEYSTORE_FILE) {
|
|||||||
android {
|
android {
|
||||||
namespace 'app.timesafari'
|
namespace 'app.timesafari'
|
||||||
compileSdk rootProject.ext.compileSdkVersion
|
compileSdk rootProject.ext.compileSdkVersion
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility JavaVersion.VERSION_17
|
||||||
|
targetCompatibility JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId "app.timesafari.app"
|
applicationId "app.timesafari.app"
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 50
|
versionCode 67
|
||||||
versionName "1.1.5"
|
versionName "1.3.12"
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
aaptOptions {
|
aaptOptions {
|
||||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||||
@@ -101,6 +107,20 @@ dependencies {
|
|||||||
implementation project(':capacitor-android')
|
implementation project(':capacitor-android')
|
||||||
implementation project(':capacitor-community-sqlite')
|
implementation project(':capacitor-community-sqlite')
|
||||||
implementation "androidx.biometric:biometric:1.2.0-alpha05"
|
implementation "androidx.biometric:biometric:1.2.0-alpha05"
|
||||||
|
|
||||||
|
// Daily Notification Plugin dependencies
|
||||||
|
implementation "androidx.room:room-runtime:2.6.1"
|
||||||
|
implementation "androidx.work:work-runtime-ktx:2.9.0"
|
||||||
|
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3"
|
||||||
|
annotationProcessor "androidx.room:room-compiler:2.6.1"
|
||||||
|
|
||||||
|
// Capacitor annotation processor for automatic plugin discovery
|
||||||
|
annotationProcessor project(':capacitor-android')
|
||||||
|
|
||||||
|
// Additional dependencies for notification plugin
|
||||||
|
implementation 'androidx.lifecycle:lifecycle-service:2.7.0'
|
||||||
|
implementation 'com.google.code.gson:gson:2.10.1'
|
||||||
|
|
||||||
testImplementation "junit:junit:$junitVersion"
|
testImplementation "junit:junit:$junitVersion"
|
||||||
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
|
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
|
||||||
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
|
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ dependencies {
|
|||||||
implementation project(':capacitor-share')
|
implementation project(':capacitor-share')
|
||||||
implementation project(':capacitor-status-bar')
|
implementation project(':capacitor-status-bar')
|
||||||
implementation project(':capawesome-capacitor-file-picker')
|
implementation project(':capawesome-capacitor-file-picker')
|
||||||
|
implementation project(':timesafari-daily-notification-plugin')
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
<?xml version="1.0" encoding="utf-8" ?>
|
<?xml version="1.0" encoding="utf-8" ?>
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<application
|
<application
|
||||||
|
android:name=".TimeSafariApplication"
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
|
android:networkSecurityConfig="@xml/network_security_config"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/AppTheme">
|
android:theme="@style/AppTheme">
|
||||||
<activity
|
<activity
|
||||||
@@ -43,6 +45,43 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
|
<!-- Daily Notification Plugin Receivers (must be inside application) -->
|
||||||
|
<!-- DailyNotificationReceiver: Handles alarm-triggered notifications -->
|
||||||
|
<!-- Note: exported="true" allows AlarmManager to trigger this receiver -->
|
||||||
|
<receiver
|
||||||
|
android:name="org.timesafari.dailynotification.DailyNotificationReceiver"
|
||||||
|
android:enabled="true"
|
||||||
|
android:exported="false">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="org.timesafari.daily.NOTIFICATION" />
|
||||||
|
</intent-filter>
|
||||||
|
</receiver>
|
||||||
|
|
||||||
|
<!-- NotifyReceiver: Handles notification delivery -->
|
||||||
|
<receiver
|
||||||
|
android:name="org.timesafari.dailynotification.NotifyReceiver"
|
||||||
|
android:enabled="true"
|
||||||
|
android:exported="false"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- BootReceiver: reschedule daily notification after device restart.
|
||||||
|
Two intent-filters: BOOT_COMPLETED has no Uri, so must not share a filter with <data scheme="package"/> or the boot broadcast never matches. -->
|
||||||
|
<receiver
|
||||||
|
android:name="org.timesafari.dailynotification.BootReceiver"
|
||||||
|
android:enabled="true"
|
||||||
|
android:exported="true"
|
||||||
|
android:directBootAware="true">
|
||||||
|
<intent-filter android:priority="1000">
|
||||||
|
<action android:name="android.intent.action.LOCKED_BOOT_COMPLETED" />
|
||||||
|
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||||
|
</intent-filter>
|
||||||
|
<intent-filter android:priority="1000">
|
||||||
|
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
||||||
|
<action android:name="android.intent.action.PACKAGE_REPLACED" />
|
||||||
|
<data android:scheme="package" />
|
||||||
|
</intent-filter>
|
||||||
|
</receiver>
|
||||||
|
|
||||||
<provider
|
<provider
|
||||||
android:name="androidx.core.content.FileProvider"
|
android:name="androidx.core.content.FileProvider"
|
||||||
android:authorities="${applicationId}.fileprovider"
|
android:authorities="${applicationId}.fileprovider"
|
||||||
@@ -59,4 +98,14 @@
|
|||||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||||
<uses-permission android:name="android.permission.CAMERA" />
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
<uses-feature android:name="android.hardware.camera" android:required="true" />
|
<uses-feature android:name="android.hardware.camera" android:required="true" />
|
||||||
|
|
||||||
|
<!-- Daily Notification Plugin Permissions -->
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
|
||||||
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||||
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
|
||||||
|
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@@ -42,6 +42,31 @@
|
|||||||
"biometricTitle": "Biometric login for TimeSafari"
|
"biometricTitle": "Biometric login for TimeSafari"
|
||||||
},
|
},
|
||||||
"electronIsEncryption": false
|
"electronIsEncryption": false
|
||||||
|
},
|
||||||
|
"DailyNotification": {
|
||||||
|
"debugMode": true,
|
||||||
|
"enableNotifications": true,
|
||||||
|
"timesafariConfig": {
|
||||||
|
"activeDid": "",
|
||||||
|
"endpoints": {
|
||||||
|
"projectsLastUpdated": "https://api.endorser.ch/api/v2/report/plansLastUpdatedBetween"
|
||||||
|
},
|
||||||
|
"starredProjectsConfig": {
|
||||||
|
"enabled": true,
|
||||||
|
"starredPlanHandleIds": [],
|
||||||
|
"fetchInterval": "0 8 * * *"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"networkConfig": {
|
||||||
|
"timeout": 30000,
|
||||||
|
"retryAttempts": 3,
|
||||||
|
"retryDelay": 1000
|
||||||
|
},
|
||||||
|
"contentFetch": {
|
||||||
|
"enabled": true,
|
||||||
|
"schedule": "0 8 * * *",
|
||||||
|
"fetchLeadTimeMinutes": 5
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ios": {
|
"ios": {
|
||||||
|
|||||||
@@ -35,6 +35,10 @@
|
|||||||
"pkg": "@capawesome/capacitor-file-picker",
|
"pkg": "@capawesome/capacitor-file-picker",
|
||||||
"classpath": "io.capawesome.capacitorjs.plugins.filepicker.FilePickerPlugin"
|
"classpath": "io.capawesome.capacitorjs.plugins.filepicker.FilePickerPlugin"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"pkg": "@timesafari/daily-notification-plugin",
|
||||||
|
"classpath": "org.timesafari.dailynotification.DailyNotificationPlugin"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"pkg": "SafeArea",
|
"pkg": "SafeArea",
|
||||||
"classpath": "app.timesafari.safearea.SafeAreaPlugin"
|
"classpath": "app.timesafari.safearea.SafeAreaPlugin"
|
||||||
|
|||||||
@@ -67,6 +67,10 @@ public class MainActivity extends BridgeActivity {
|
|||||||
// Register SharedImage plugin
|
// Register SharedImage plugin
|
||||||
registerPlugin(SharedImagePlugin.class);
|
registerPlugin(SharedImagePlugin.class);
|
||||||
|
|
||||||
|
// Register DailyNotification plugin
|
||||||
|
// Plugin is written in Kotlin but compiles to Java-compatible bytecode
|
||||||
|
registerPlugin(org.timesafari.dailynotification.DailyNotificationPlugin.class);
|
||||||
|
|
||||||
// Initialize SQLite
|
// Initialize SQLite
|
||||||
//registerPlugin(SQLite.class);
|
//registerPlugin(SQLite.class);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package app.timesafari;
|
||||||
|
|
||||||
|
import android.app.Application;
|
||||||
|
import android.content.Context;
|
||||||
|
import android.util.Log;
|
||||||
|
import org.timesafari.dailynotification.DailyNotificationPlugin;
|
||||||
|
import org.timesafari.dailynotification.NativeNotificationContentFetcher;
|
||||||
|
|
||||||
|
public class TimeSafariApplication extends Application {
|
||||||
|
|
||||||
|
private static final String TAG = "TimeSafariApplication";
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onCreate() {
|
||||||
|
super.onCreate();
|
||||||
|
|
||||||
|
Log.i(TAG, "Initializing TimeSafari notifications");
|
||||||
|
|
||||||
|
// Register native fetcher with application context
|
||||||
|
Context context = getApplicationContext();
|
||||||
|
NativeNotificationContentFetcher fetcher =
|
||||||
|
new TimeSafariNativeFetcher(context);
|
||||||
|
DailyNotificationPlugin.setNativeFetcher(fetcher);
|
||||||
|
|
||||||
|
Log.i(TAG, "Native fetcher registered");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package app.timesafari;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import android.util.Log;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
|
||||||
|
import org.timesafari.dailynotification.FetchContext;
|
||||||
|
import org.timesafari.dailynotification.NativeNotificationContentFetcher;
|
||||||
|
import org.timesafari.dailynotification.NotificationContent;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
|
||||||
|
public class TimeSafariNativeFetcher implements NativeNotificationContentFetcher {
|
||||||
|
|
||||||
|
private static final String TAG = "TimeSafariNativeFetcher";
|
||||||
|
private final Context context;
|
||||||
|
|
||||||
|
// Configuration from TypeScript (set via configure())
|
||||||
|
private volatile String apiBaseUrl;
|
||||||
|
private volatile String activeDid;
|
||||||
|
private volatile String jwtToken;
|
||||||
|
|
||||||
|
public TimeSafariNativeFetcher(Context context) {
|
||||||
|
this.context = context;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void configure(String apiBaseUrl, String activeDid, String jwtToken) {
|
||||||
|
this.apiBaseUrl = apiBaseUrl;
|
||||||
|
this.activeDid = activeDid;
|
||||||
|
this.jwtToken = jwtToken;
|
||||||
|
Log.i(TAG, "Fetcher configured with API: " + apiBaseUrl + ", DID: " + activeDid);
|
||||||
|
}
|
||||||
|
|
||||||
|
@NonNull
|
||||||
|
@Override
|
||||||
|
public CompletableFuture<List<NotificationContent>> fetchContent(@NonNull FetchContext fetchContext) {
|
||||||
|
Log.d(TAG, "Fetching notification content, trigger: " + fetchContext.trigger);
|
||||||
|
|
||||||
|
return CompletableFuture.supplyAsync(() -> {
|
||||||
|
try {
|
||||||
|
// TODO: Implement actual content fetching for TimeSafari
|
||||||
|
// This should query the TimeSafari API for notification content
|
||||||
|
// using the configured apiBaseUrl, activeDid, and jwtToken
|
||||||
|
|
||||||
|
// For now, return a placeholder notification
|
||||||
|
long scheduledTime = fetchContext.scheduledTime != null
|
||||||
|
? fetchContext.scheduledTime
|
||||||
|
: System.currentTimeMillis() + 60000; // 1 minute from now
|
||||||
|
|
||||||
|
NotificationContent content = new NotificationContent(
|
||||||
|
"TimeSafari Update",
|
||||||
|
"Check your starred projects for updates!",
|
||||||
|
scheduledTime
|
||||||
|
);
|
||||||
|
|
||||||
|
List<NotificationContent> results = new ArrayList<>();
|
||||||
|
results.add(content);
|
||||||
|
|
||||||
|
Log.d(TAG, "Returning " + results.size() + " notification(s)");
|
||||||
|
return results;
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "Fetch failed", e);
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<?xml version='1.0' encoding='utf-8'?>
|
<?xml version='1.0' encoding='utf-8'?>
|
||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">TimeSafari</string>
|
<string name="app_name">Giftopia</string>
|
||||||
<string name="title_activity_main">TimeSafari</string>
|
<string name="title_activity_main">Giftopia</string>
|
||||||
<string name="package_name">timesafari.app</string>
|
<string name="package_name">timesafari.app</string>
|
||||||
<string name="custom_url_scheme">timesafari.app</string>
|
<string name="custom_url_scheme">timesafari.app</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
12
android/app/src/main/res/xml/network_security_config.xml
Normal file
12
android/app/src/main/res/xml/network_security_config.xml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<network-security-config>
|
||||||
|
<base-config cleartextTrafficPermitted="true">
|
||||||
|
<trust-anchors>
|
||||||
|
<certificates src="system" />
|
||||||
|
</trust-anchors>
|
||||||
|
</base-config>
|
||||||
|
<domain-config cleartextTrafficPermitted="true">
|
||||||
|
<domain includeSubdomains="true">localhost</domain>
|
||||||
|
<domain includeSubdomains="true">10.0.2.2</domain>
|
||||||
|
</domain-config>
|
||||||
|
</network-security-config>
|
||||||
@@ -22,6 +22,9 @@ allprojects {
|
|||||||
google()
|
google()
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Note: KAPT JVM arguments for Java 17+ compatibility are configured in gradle.properties
|
||||||
|
// The org.gradle.jvmargs setting includes --add-opens flags needed for KAPT
|
||||||
}
|
}
|
||||||
|
|
||||||
task clean(type: Delete) {
|
task clean(type: Delete) {
|
||||||
|
|||||||
@@ -28,3 +28,6 @@ project(':capacitor-status-bar').projectDir = new File('../node_modules/@capacit
|
|||||||
|
|
||||||
include ':capawesome-capacitor-file-picker'
|
include ':capawesome-capacitor-file-picker'
|
||||||
project(':capawesome-capacitor-file-picker').projectDir = new File('../node_modules/@capawesome/capacitor-file-picker/android')
|
project(':capawesome-capacitor-file-picker').projectDir = new File('../node_modules/@capawesome/capacitor-file-picker/android')
|
||||||
|
|
||||||
|
include ':timesafari-daily-notification-plugin'
|
||||||
|
project(':timesafari-daily-notification-plugin').projectDir = new File('../node_modules/@timesafari/daily-notification-plugin/android')
|
||||||
|
|||||||
@@ -9,7 +9,8 @@
|
|||||||
|
|
||||||
# Specifies the JVM arguments used for the daemon process.
|
# Specifies the JVM arguments used for the daemon process.
|
||||||
# The setting is particularly useful for tweaking memory settings.
|
# The setting is particularly useful for tweaking memory settings.
|
||||||
org.gradle.jvmargs=-Xmx1536m
|
# Added --add-opens flags for KAPT compatibility with Java 17+
|
||||||
|
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
|
||||||
|
|
||||||
# When configured, Gradle will run in incubating parallel mode.
|
# When configured, Gradle will run in incubating parallel mode.
|
||||||
# This option should only be used with decoupled projects. More details, visit
|
# This option should only be used with decoupled projects. More details, visit
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"appId": "app.timesafari",
|
"appId": "app.timesafari",
|
||||||
"appName": "TimeSafari",
|
"appName": "Giftopia",
|
||||||
"webDir": "dist",
|
"webDir": "dist",
|
||||||
"server": {
|
"server": {
|
||||||
"cleartext": true
|
"cleartext": true
|
||||||
@@ -34,12 +34,12 @@
|
|||||||
"iosIsEncryption": false,
|
"iosIsEncryption": false,
|
||||||
"iosBiometric": {
|
"iosBiometric": {
|
||||||
"biometricAuth": false,
|
"biometricAuth": false,
|
||||||
"biometricTitle": "Biometric login for TimeSafari"
|
"biometricTitle": "Biometric login for Giftopia"
|
||||||
},
|
},
|
||||||
"androidIsEncryption": false,
|
"androidIsEncryption": false,
|
||||||
"androidBiometric": {
|
"androidBiometric": {
|
||||||
"biometricAuth": false,
|
"biometricAuth": false,
|
||||||
"biometricTitle": "Biometric login for TimeSafari"
|
"biometricTitle": "Biometric login for Giftopia"
|
||||||
},
|
},
|
||||||
"electronIsEncryption": false
|
"electronIsEncryption": false
|
||||||
}
|
}
|
||||||
@@ -73,7 +73,7 @@
|
|||||||
},
|
},
|
||||||
"buildOptions": {
|
"buildOptions": {
|
||||||
"appId": "app.timesafari",
|
"appId": "app.timesafari",
|
||||||
"productName": "TimeSafari",
|
"productName": "Giftopia",
|
||||||
"directories": {
|
"directories": {
|
||||||
"output": "dist-electron-packages"
|
"output": "dist-electron-packages"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { CapacitorConfig } from '@capacitor/cli';
|
|||||||
|
|
||||||
const config: CapacitorConfig = {
|
const config: CapacitorConfig = {
|
||||||
appId: 'app.timesafari',
|
appId: 'app.timesafari',
|
||||||
appName: 'TimeSafari',
|
appName: 'Giftopia',
|
||||||
webDir: 'dist',
|
webDir: 'dist',
|
||||||
server: {
|
server: {
|
||||||
cleartext: true
|
cleartext: true
|
||||||
@@ -36,14 +36,39 @@ const config: CapacitorConfig = {
|
|||||||
iosIsEncryption: false,
|
iosIsEncryption: false,
|
||||||
iosBiometric: {
|
iosBiometric: {
|
||||||
biometricAuth: false,
|
biometricAuth: false,
|
||||||
biometricTitle: 'Biometric login for TimeSafari'
|
biometricTitle: 'Biometric login for Giftopia'
|
||||||
},
|
},
|
||||||
androidIsEncryption: false,
|
androidIsEncryption: false,
|
||||||
androidBiometric: {
|
androidBiometric: {
|
||||||
biometricAuth: false,
|
biometricAuth: false,
|
||||||
biometricTitle: 'Biometric login for TimeSafari'
|
biometricTitle: 'Biometric login for Giftopia'
|
||||||
},
|
},
|
||||||
electronIsEncryption: false
|
electronIsEncryption: false
|
||||||
|
},
|
||||||
|
DailyNotification: {
|
||||||
|
debugMode: true,
|
||||||
|
enableNotifications: true,
|
||||||
|
timesafariConfig: {
|
||||||
|
activeDid: '', // Will be set dynamically from user's DID
|
||||||
|
endpoints: {
|
||||||
|
projectsLastUpdated: 'https://api.endorser.ch/api/v2/report/plansLastUpdatedBetween'
|
||||||
|
},
|
||||||
|
starredProjectsConfig: {
|
||||||
|
enabled: true,
|
||||||
|
starredPlanHandleIds: [],
|
||||||
|
fetchInterval: '0 8 * * *'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
networkConfig: {
|
||||||
|
timeout: 30000,
|
||||||
|
retryAttempts: 3,
|
||||||
|
retryDelay: 1000
|
||||||
|
},
|
||||||
|
contentFetch: {
|
||||||
|
enabled: true,
|
||||||
|
schedule: '0 8 * * *',
|
||||||
|
fetchLeadTimeMinutes: 5
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
ios: {
|
ios: {
|
||||||
@@ -75,7 +100,7 @@ const config: CapacitorConfig = {
|
|||||||
},
|
},
|
||||||
buildOptions: {
|
buildOptions: {
|
||||||
appId: 'app.timesafari',
|
appId: 'app.timesafari',
|
||||||
productName: 'TimeSafari',
|
productName: 'Giftopia',
|
||||||
directories: {
|
directories: {
|
||||||
output: 'dist-electron-packages'
|
output: 'dist-electron-packages'
|
||||||
},
|
},
|
||||||
|
|||||||
122
doc/DAILY_NOTIFICATION_BUG_DIAGNOSIS.md
Normal file
122
doc/DAILY_NOTIFICATION_BUG_DIAGNOSIS.md
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
# Daily Notification Bugs — Diagnosis (Plugin + App)
|
||||||
|
|
||||||
|
**Context:** Fixes were applied in both the plugin and the app, but "reset doesn't fire" and "notification text defaults to fallback" still occur. This doc summarizes what was checked and what to do next.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What Was Verified
|
||||||
|
|
||||||
|
### App integration (correct)
|
||||||
|
|
||||||
|
- **NativeNotificationService.ts**
|
||||||
|
- Pre-cancel is gated: only iOS calls `cancelDailyReminder()` before scheduling (lines 289–305). Android skips it.
|
||||||
|
- Schedules with `id: this.reminderId` (`"daily_timesafari_reminder"`), plus `time`, `title`, `body`.
|
||||||
|
- Calls `DailyNotification.scheduleDailyNotification(scheduleOptions)` (not `scheduleDailyReminder`).
|
||||||
|
|
||||||
|
- **AccountViewView.vue**
|
||||||
|
- `editReminderNotification()` only calls `cancelDailyNotification()` when **not** Android (lines 1303–1305). On Android it only calls `scheduleDailyNotification()`.
|
||||||
|
|
||||||
|
So the app is not double-cancelling on Android and is passing the expected options.
|
||||||
|
|
||||||
|
### Plugin in app’s node_modules (fixed code present)
|
||||||
|
|
||||||
|
- **node_modules/@timesafari/daily-notification-plugin** is at **version 1.1.4** and contains:
|
||||||
|
- **NotifyReceiver.kt:** DB idempotence is skipped when `skipPendingIntentIdempotence=true` (wrapped in `if (!skipPendingIntentIdempotence)`).
|
||||||
|
- **DailyNotificationWorker.java:** `preserveStaticReminder` read from input, stable `scheduleId` for static reminders, and `scheduleExactNotification(..., preserveStaticReminder, ...)`.
|
||||||
|
- **DailyNotificationPlugin.kt:** `cancelDailyReminder(call)` implemented.
|
||||||
|
|
||||||
|
So the **source** the app uses (from its dependency) already has the fixes.
|
||||||
|
|
||||||
|
### Plugin schedule path (correct)
|
||||||
|
|
||||||
|
- App calls `scheduleDailyNotification` → plugin’s `scheduleDailyNotification(call)` → `ScheduleHelper.scheduleDailyNotification(...)`.
|
||||||
|
- That helper calls `NotifyReceiver.cancelNotification(context, scheduleId)` then `scheduleExactNotification(..., skipPendingIntentIdempotence = true)`.
|
||||||
|
- So the “re-set” path does set `skipPendingIntentIdempotence = true` and the DB idempotence skip should apply.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Likely Causes Why Bugs Still Appear
|
||||||
|
|
||||||
|
### 1. Stale Android build / old APK
|
||||||
|
|
||||||
|
The Android app compiles the plugin from:
|
||||||
|
|
||||||
|
`android/capacitor.settings.gradle` →
|
||||||
|
`project(':timesafari-daily-notification-plugin').projectDir = new File('../node_modules/@timesafari/daily-notification-plugin/android')`
|
||||||
|
|
||||||
|
If the app was not fully rebuilt after the plugin in node_modules was updated, the running APK may still contain old plugin code.
|
||||||
|
|
||||||
|
**Do this:**
|
||||||
|
|
||||||
|
- In the **app** repo (`crowd-funder-for-time-pwa`):
|
||||||
|
- `./gradlew clean` (or Android Studio → Build → Clean Project)
|
||||||
|
- Build and reinstall the app (e.g. Run on device/emulator).
|
||||||
|
- Confirm you’re not installing an older APK from somewhere else.
|
||||||
|
|
||||||
|
### 2. Dependency not actually updated after plugin changes
|
||||||
|
|
||||||
|
The app depends on:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"@timesafari/daily-notification-plugin": "git+https://gitea.anomalistdesign.com/trent_larson/daily-notification-plugin.git#master"
|
||||||
|
```
|
||||||
|
|
||||||
|
If the fixes were only made in a **different** clone (e.g. `daily-notification-plugin_test`) and never pushed to that gitea `master`, then:
|
||||||
|
|
||||||
|
- `npm install` / `npm update` in the app would not pull the fixes.
|
||||||
|
- The app’s `node_modules` would only have the fixes if they were copied/linked from the fixed repo.
|
||||||
|
|
||||||
|
**Do this:**
|
||||||
|
|
||||||
|
- If the fixes live in another clone: either **push** the fixed plugin to gitea `master` and run `npm update @timesafari/daily-notification-plugin` (then `npx cap sync android`, then clean build), **or** point the app at the fixed plugin locally, e.g. in **app** `package.json`:
|
||||||
|
- `"@timesafari/daily-notification-plugin": "file:../daily-notification-plugin"`
|
||||||
|
(adjust path to your fixed plugin repo), then `npm install`, `npx cap sync android`, clean build and reinstall.
|
||||||
|
|
||||||
|
### 3. Fallback text from native fetcher (Bug 2 only)
|
||||||
|
|
||||||
|
**TimeSafariNativeFetcher.java** in the app is still a placeholder: it always returns:
|
||||||
|
|
||||||
|
- Title: `"TimeSafari Update"`
|
||||||
|
- Body: `"Check your starred projects for updates!"`
|
||||||
|
|
||||||
|
That only affects flows that **fetch** content (e.g. prefetch or any path that uses the fetcher for display). The **static** daily reminder path does not use the fetcher for display: title/body come from the schedule Intent and WorkManager input. So if you only use the “daily reminder” (one time + custom title/body), the fetcher placeholder should not be the cause. If you have any flow that relies on **fetched** content for the text, you’ll see that placeholder until the fetcher is implemented and wired (and optionally token persistence).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification Steps (after clean build + reinstall)
|
||||||
|
|
||||||
|
1. **Reset / “re-set” (Bug 1)**
|
||||||
|
- Set reminder for 2–3 minutes from now.
|
||||||
|
- Edit and save **without changing the time**.
|
||||||
|
- Wait for the time; the notification should fire.
|
||||||
|
- In logcat, filter by the plugin’s tags and look for:
|
||||||
|
- `Skipping DB idempotence (skipPendingIntentIdempotence=true) for scheduleId=...`
|
||||||
|
- `Scheduling next daily alarm: id=daily_timesafari_reminder ...`
|
||||||
|
If you see these, the fixed path is running.
|
||||||
|
|
||||||
|
2. **Static text on rollover (Bug 2)**
|
||||||
|
- Set a custom title/body, let the notification fire once.
|
||||||
|
- In logcat look for:
|
||||||
|
- `DN|ROLLOVER next=... scheduleId=daily_timesafari_reminder static=true`
|
||||||
|
If you see `static=true` and the same `scheduleId`, the next occurrence should keep your custom text.
|
||||||
|
|
||||||
|
3. **Plugin version at build time**
|
||||||
|
- In the app’s `node_modules/@timesafari/daily-notification-plugin/package.json`, confirm `"version": "1.1.4"` (or the version that includes the fixes).
|
||||||
|
- After that, a clean build ensures that version is what’s in the APK.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
| Check | Status |
|
||||||
|
|-------|--------|
|
||||||
|
| App gates cancel on Android | OK |
|
||||||
|
| App calls scheduleDailyNotification with id/title/body | OK |
|
||||||
|
| Plugin in app node_modules has DB idempotence skip | OK (1.1.4) |
|
||||||
|
| Plugin in app node_modules has static rollover fix | OK |
|
||||||
|
| Plugin in app node_modules has cancelDailyReminder | OK |
|
||||||
|
| Schedule path passes skipPendingIntentIdempotence = true | OK |
|
||||||
|
|
||||||
|
**See also:** `doc/plugin-feedback-android-rollover-double-fire-and-user-content.md` — when two notifications fire (e.g. one ~3 min early, one on the dot) and neither shows user-set content.
|
||||||
|
|
||||||
|
Most likely the app is still running an **old Android build**. Do a **clean build and reinstall**, and ensure the plugin dependency in the app really points at the fixed code (gitea master or local path). Then re-test and check logcat for the lines above. If the bugs persist after that, the next step is to capture a full logcat from “edit reminder (same time)” through the next fire and from “first fire” through “next day” to see which path runs.
|
||||||
169
doc/DAILY_NOTIFICATION_DUPLICATE_FALLBACK_ANALYSIS.md
Normal file
169
doc/DAILY_NOTIFICATION_DUPLICATE_FALLBACK_ANALYSIS.md
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
# Daily Notification: Why Extra Notifications With Fallback / "Starred Projects" Still Fire
|
||||||
|
|
||||||
|
**Date:** 2026-03-02
|
||||||
|
**Context:** After previous fixes (see `DAILY_NOTIFICATION_BUG_DIAGNOSIS.md` and `plugin-feedback-android-rollover-double-fire-and-user-content.md`), duplicate notifications and fallback/"starred projects" text still occur. This doc explains root causes and where fixes must happen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary of What’s Happening
|
||||||
|
|
||||||
|
1. **Extra notification(s)** fire at a different time (e.g. ~3 min early) or at the same time as the user-set one.
|
||||||
|
2. **Wrong text** appears: either generic fallback ("Daily Update" / "Good morning! Ready to make today amazing?") or the app’s placeholder ("TimeSafari Update" / "Check your starred projects for updates!").
|
||||||
|
3. The **correct** notification (user-set time and message) can still fire as well, so the user sees both correct and wrong notifications.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Root Causes
|
||||||
|
|
||||||
|
### 1. Second alarm from prefetch (UUID / fallback)
|
||||||
|
|
||||||
|
**Mechanism**
|
||||||
|
|
||||||
|
- The plugin has two scheduling paths:
|
||||||
|
- **NotifyReceiver** (AlarmManager): used for the app’s single daily reminder; uses `scheduleId` (e.g. `daily_timesafari_reminder`) and carries title/body in the Intent.
|
||||||
|
- **DailyNotificationScheduler** (legacy): used by **DailyNotificationFetchWorker** when prefetch runs and then calls `scheduleNotificationIfNeeded(fallbackContent)`. That creates a **second** alarm with `notification_id` = **UUID** (from `createEmergencyFallbackContent()` or from fetcher placeholder).
|
||||||
|
|
||||||
|
- **ScheduleHelper** correctly **does not** enqueue prefetch for static reminders (see comment in `DailyNotificationPlugin.kt` ~2686: "Do not enqueue prefetch for static reminders"). So **new** schedules from the app no longer create a prefetch job.
|
||||||
|
|
||||||
|
- However:
|
||||||
|
- **Existing** WorkManager prefetch jobs (tag `daily_notification_fetch`) that were enqueued **before** that fix (or by an older build) are still pending. When they run, fetch fails or returns placeholder → `useFallbackContent()` → `scheduleNotificationIfNeeded(fallbackContent)` → **second alarm with UUID**.
|
||||||
|
- That UUID alarm is **not** stored in the Schedule table. So when the user later calls `scheduleDailyNotification`, **cleanupExistingNotificationSchedules** only cancels alarms for schedule IDs that exist in the DB (e.g. `daily_timesafari_reminder`, `daily_rollover_*`). The **UUID alarm is never cancelled**.
|
||||||
|
|
||||||
|
- **Result:** You can have two alarms: one for `daily_timesafari_reminder` (correct) and one for a UUID (fallback text). If the UUID alarm was set for a slightly different time (e.g. from an old rollover), you get two notifications at two times.
|
||||||
|
|
||||||
|
**Where the fallback text comes from (plugin)**
|
||||||
|
|
||||||
|
- **DailyNotificationFetchWorker** (in both app’s `node_modules` plugin and the standalone repo):
|
||||||
|
- On failed fetch after max retries: `useFallbackContent(scheduledTime)` → `createEmergencyFallbackContent(scheduledTime)` → title "Daily Update", body "🌅 Good morning! Ready to make today amazing?".
|
||||||
|
- That content is saved and then **scheduled** via `scheduleNotificationIfNeeded(fallbackContent)`, which uses **DailyNotificationScheduler** (legacy) and assigns a **new UUID** to the content. So the second alarm fires with that UUID and shows that fallback text.
|
||||||
|
|
||||||
|
### 2. Prefetch WorkManager jobs not cancelled when user reschedules
|
||||||
|
|
||||||
|
- **scheduleDailyNotification** (plugin) calls:
|
||||||
|
- `ScheduleHelper.cleanupExistingNotificationSchedules(...)` → cancels **alarms** for all DB schedules (except current `scheduleId`).
|
||||||
|
- `ScheduleHelper.scheduleDailyNotification(...)` → cancels alarm for current `scheduleId`, schedules NotifyReceiver alarm, **does not** enqueue prefetch.
|
||||||
|
|
||||||
|
- It does **not** cancel **WorkManager** jobs. So any already-enqueued prefetch work (tag `daily_notification_fetch`) remains. When that work runs, it creates the second (UUID) alarm as above.
|
||||||
|
|
||||||
|
- **ScheduleHelper** has `cancelAllWorkManagerJobs(context)` (cancels tags `prefetch`, `daily_notification_fetch`, etc.), but **nothing calls it** in the schedule path. So pending prefetch jobs are left in place.
|
||||||
|
|
||||||
|
**Fix (plugin):** When the app calls `scheduleDailyNotification`, **cancel all fetch-related WorkManager work** (e.g. call `ScheduleHelper.cancelAllWorkManagerJobs(context)` or a helper that only cancels `daily_notification_fetch` and `prefetch`) **before** or **right after** `cleanupExistingNotificationSchedules`. That prevents any pending prefetch from running and creating a UUID alarm later.
|
||||||
|
|
||||||
|
### 3. "Starred projects" message from the app’s native fetcher
|
||||||
|
|
||||||
|
- **TimeSafariNativeFetcher** (`android/app/src/main/java/app/timesafari/TimeSafariNativeFetcher.java`) is still a **placeholder**: it always returns:
|
||||||
|
- Title: `"TimeSafari Update"`
|
||||||
|
- Body: `"Check your starred projects for updates!"`
|
||||||
|
|
||||||
|
- That text is used whenever the plugin **fetches** content and then displays it:
|
||||||
|
- **DailyNotificationFetchWorker**: on “successful” fetch it saves and schedules the fetcher’s result; for your app that result is the placeholder, so any notification created from that path shows “starred projects”.
|
||||||
|
- **DailyNotificationWorker** (JIT path): when `is_static_reminder` is false and content is loaded from Room by `notification_id`, if the worker then does a JIT refresh (e.g. content stale), it calls `DailyNotificationFetcher.fetchContentImmediately()` which can use the app’s native fetcher and **overwrite** title/body with the placeholder.
|
||||||
|
|
||||||
|
- So “starred projects” appears on any notification that goes through a **fetch** path (prefetch success or JIT) instead of the **static reminder** path (Intent title/body or Room by canonical `schedule_id`).
|
||||||
|
|
||||||
|
**Fix (app):** For a static-reminder-only flow, the plugin should not run prefetch (already done) and should not overwrite with fetcher in JIT for static reminders. Reducing duplicate/out-of-schedule alarms (fixes above) ensures the main run is the static one. Optionally, implement **TimeSafariNativeFetcher** to return real content if you ever want “fetch-based” notifications; until then, the only path that should show user text is the NotifyReceiver alarm with `daily_timesafari_reminder` and title/body from Intent or from Room by `schedule_id`.
|
||||||
|
|
||||||
|
### 4. Rollover / Room content keyed by run-specific id
|
||||||
|
|
||||||
|
- When an alarm fires with `notification_id` = **UUID** or **notify_<timestamp>** (and no or missing title/body in the Intent), the Worker treats it as **non-static**. It loads content from Room by that `notification_id`. The entity for `daily_timesafari_reminder` (user title/body) is stored under a **different** id, so the Worker either finds nothing or finds content written by prefetch/fallback for that run → wrong text.
|
||||||
|
|
||||||
|
- When the alarm is the **correct** one (`daily_timesafari_reminder`) and Intent has title/body (or `schedule_id`), the Worker uses static reminder or resolves by `schedule_id` and shows user text. So the main fix is to **avoid creating the UUID/notify_* run in the first place** (cancel prefetch work; no second alarm). Rollover for the static reminder already passes `scheduleId` and title/body in the Intent (NotifyReceiver puts them in the PendingIntent), so once there’s only one alarm, rollover should keep user text.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Where Fixes Must Happen
|
||||||
|
|
||||||
|
### Plugin (daily-notification-plugin)
|
||||||
|
|
||||||
|
**1. Cancel prefetch (and related) WorkManager jobs when scheduling**
|
||||||
|
|
||||||
|
- **File:** `DailyNotificationPlugin.kt` (or wherever `scheduleDailyNotification` is implemented).
|
||||||
|
- **Change:** When handling `scheduleDailyNotification`, after `cleanupExistingNotificationSchedules` and before (or after) `ScheduleHelper.scheduleDailyNotification`, call a method that cancels all WorkManager work that can create a second alarm. Prefer reusing **ScheduleHelper.cancelAllWorkManagerJobs(context)** or adding a small helper that cancels only fetch-related tags (e.g. `daily_notification_fetch`, `prefetch`) so you don’t cancel display/dismiss work unnecessarily.
|
||||||
|
- **Effect:** Pending prefetch jobs from older builds or previous flows will not run, so no new UUID alarm is created and no extra notification with fallback text.
|
||||||
|
|
||||||
|
**2. (Already done) Do not enqueue prefetch for static reminders**
|
||||||
|
|
||||||
|
- **ScheduleHelper.scheduleDailyNotification** already does **not** enqueue FetchWorker for static reminders. No change needed here; just ensure no other code path enqueues prefetch for the app’s single daily reminder.
|
||||||
|
|
||||||
|
**3. (Optional) DailyNotificationFetchWorker: skip scheduling second alarm for static-reminder schedules**
|
||||||
|
|
||||||
|
- If you ever enqueue prefetch with an explicit “static reminder” flag, in **DailyNotificationFetchWorker** inside `useFallbackContent` / `scheduleNotificationIfNeeded`, skip calling `scheduleNotificationIfNeeded` when that flag is set. For your current setup (no prefetch for static), this is redundant but makes the contract clear and future-proof.
|
||||||
|
|
||||||
|
**4. Receiver: no DB on main thread**
|
||||||
|
|
||||||
|
- Your **DailyNotificationReceiver** in the app’s plugin only reads Intent extras and enqueues work; it does not read Room on the main thread. If you still see `db_fallback_failed` in logcat, the failing DB access is elsewhere (e.g. another receiver or an old build). Ensure no BroadcastReceiver does Room/DB access on the main thread; resolve title/body in the Worker from `schedule_id` if Intent lacks them.
|
||||||
|
|
||||||
|
### App (crowd-funder-for-time-pwa)
|
||||||
|
|
||||||
|
**Scope: static reminders only.** For fixing static reminders, **no app code changes are required.** Real fetch-based content can be added later.
|
||||||
|
|
||||||
|
**1. TimeSafariNativeFetcher**
|
||||||
|
|
||||||
|
- **File:** `android/app/src/main/java/app/timesafari/TimeSafariNativeFetcher.java`
|
||||||
|
- **Current behavior:** Placeholder that returns `"TimeSafari Update"` / `"Check your starred projects for updates!"` (expected).
|
||||||
|
- **For static reminders now:** Leave as-is. The plugin fix (cancel prefetch work when scheduling) ensures the only notification path is the static one; the fetcher is never used for display in that flow. No change needed.
|
||||||
|
- **Later (optional):** When you implement real-world content fetching, replace the placeholder here so any future fetch-driven notifications show real content.
|
||||||
|
|
||||||
|
**2. Build and dependency**
|
||||||
|
|
||||||
|
- After plugin changes, ensure the app uses the updated plugin (point `package.json` at the fixed repo or publish and bump version), then **clean build** Android (`./gradlew clean`, rebuild, reinstall). Confirming the APK contains the plugin version that cancels prefetch work and does not enqueue prefetch for static reminders avoids stale behavior from old builds.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification After Fixes
|
||||||
|
|
||||||
|
1. **Single notification, user text**
|
||||||
|
- Set daily reminder with a **distinct** title/body and a time 2–3 minutes ahead. Wait until that time.
|
||||||
|
- **Expect:** Exactly **one** notification at that time with your text. No second notification (no UUID, no “Daily Update” or “starred projects”).
|
||||||
|
|
||||||
|
2. **No out-of-schedule notification**
|
||||||
|
- Change reminder time (e.g. from 21:53 to 21:56) and save. Wait past 21:53 and until 21:56.
|
||||||
|
- **Expect:** No notification at 21:53; one at 21:56 with your text.
|
||||||
|
|
||||||
|
3. **Rollover**
|
||||||
|
- Let the correct notification fire once so rollover runs. Next day (or next occurrence) you should see **one** notification with the same user text.
|
||||||
|
|
||||||
|
4. **Logcat**
|
||||||
|
- No `display=<uuid>` at the same time as `static_reminder id=daily_timesafari_reminder`.
|
||||||
|
- After scheduling (e.g. edit and save), you should see prefetch/fetch work being cancelled if you add a log in the cancel path.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Short Summary
|
||||||
|
|
||||||
|
| Issue | Cause | Fix location |
|
||||||
|
|-------|--------|--------------|
|
||||||
|
| Extra notification at same or different time | Prefetch WorkManager job still runs and creates second (UUID) alarm via legacy scheduler; that alarm is never cancelled on reschedule | **Plugin:** Cancel fetch-related WorkManager jobs when `scheduleDailyNotification` is called |
|
||||||
|
| Fallback text ("Daily Update" / "Good morning!") | FetchWorker’s `useFallbackContent` → `scheduleNotificationIfNeeded` creates alarm with that content | **Plugin:** Same as above (no prefetch run → no fallback alarm); optionally FetchWorker skips scheduling when static-reminder flag set |
|
||||||
|
| "Starred projects" text | TimeSafariNativeFetcher placeholder used when a fetch path runs | **Plugin:** Same as above (no prefetch → no fetch path). **App:** No change for static reminders; leave fetcher as placeholder until real fetch is implemented. |
|
||||||
|
| Wrong content on rollover | Rollover run keyed by UUID or notify_* and no title/body in Intent → Worker loads from Room by that id → wrong/empty content | **Plugin:** Avoid creating UUID/notify_* run (cancel prefetch). Static rollover already passes schedule_id and title/body. |
|
||||||
|
|
||||||
|
The critical missing step is **cancelling prefetch (and fetch) WorkManager work when the user schedules or reschedules** the daily notification. That prevents any pending prefetch from running and creating the second alarm with fallback or “starred projects” text.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## For Cursor (plugin repo) — actionable handoff
|
||||||
|
|
||||||
|
Use this section when applying the fix in the **daily-notification-plugin** repo (e.g. with Cursor). Paste or @-mention this doc as context.
|
||||||
|
|
||||||
|
**Goal:** For static reminders, only one notification at the user's chosen time with user-set title/body. No extra notification from pending prefetch (UUID alarm with fallback or "starred projects" text).
|
||||||
|
|
||||||
|
**Root cause:** `scheduleDailyNotification` cleans up DB schedules and alarms but **does not cancel WorkManager prefetch jobs**. Any previously enqueued job (tag `daily_notification_fetch`) still runs, then creates a second alarm via `DailyNotificationScheduler` (UUID). That alarm is never cancelled on reschedule. Fix: cancel fetch-related WorkManager work when the user schedules.
|
||||||
|
|
||||||
|
**Change (required):**
|
||||||
|
|
||||||
|
1. **Cancel fetch-related WorkManager jobs when handling `scheduleDailyNotification`**
|
||||||
|
- **File:** `android/src/main/java/com/timesafari/dailynotification/DailyNotificationPlugin.kt`
|
||||||
|
- **Where:** In `scheduleDailyNotification(call)`, inside the `CoroutineScope(Dispatchers.IO).launch { ... }` block, **after** `ScheduleHelper.cleanupExistingNotificationSchedules(...)` and **before** `ScheduleHelper.scheduleDailyNotification(...)`.
|
||||||
|
- **What:** Call a method that cancels WorkManager work that can create a second alarm. Reuse **ScheduleHelper.cancelAllWorkManagerJobs(context)** (it already cancels `prefetch`, `daily_notification_fetch`, etc.). If you prefer not to cancel display/dismiss work, add a helper that only cancels `daily_notification_fetch` and `prefetch` and call that instead.
|
||||||
|
- **Example (using existing helper):**
|
||||||
|
```kotlin
|
||||||
|
ScheduleHelper.cancelAllWorkManagerJobs(context)
|
||||||
|
```
|
||||||
|
(If `cancelAllWorkManagerJobs` is suspend, call it with `runBlocking { }` or from the same coroutine scope.)
|
||||||
|
|
||||||
|
**No other plugin changes needed for this fix:** ScheduleHelper already does not enqueue prefetch for static reminders; the only missing step is cancelling **pending** prefetch work when the user schedules or reschedules.
|
||||||
|
|
||||||
|
**Files to look at (plugin Android):**
|
||||||
|
- `DailyNotificationPlugin.kt` — `scheduleDailyNotification(call)` (add cancel call after cleanup, before ScheduleHelper.scheduleDailyNotification).
|
||||||
|
- `ScheduleHelper` (in same file or separate) — `cancelAllWorkManagerJobs(context)` (already exists; ensure it cancels at least `daily_notification_fetch` and `prefetch`).
|
||||||
82
doc/NOTIFICATION_TROUBLESHOOTING.md
Normal file
82
doc/NOTIFICATION_TROUBLESHOOTING.md
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
# TimeSafari — Daily notifications troubleshooting (iOS & Android)
|
||||||
|
|
||||||
|
**Last updated:** 2026-03-06 17:08 PST
|
||||||
|
**Audience:** End-users
|
||||||
|
**Applies to:** TimeSafari iOS/Android native app (daily notifications scheduled on-device)
|
||||||
|
|
||||||
|
If your **Daily Reminder** or notification doesn’t show up, follow the steps below.
|
||||||
|
|
||||||
|
## Before you start
|
||||||
|
|
||||||
|
- These notifications are **scheduled on your device** (no browser/web push).
|
||||||
|
- If you previously followed an older “web notifications” guide, those steps no longer apply for iOS/Android builds.
|
||||||
|
|
||||||
|
## 1) Check your in-app notification settings
|
||||||
|
|
||||||
|
- Tap **Profile** in the bottom bar
|
||||||
|
- Under **Notifications**, confirm:
|
||||||
|
- **Daily Reminder** is **enabled**
|
||||||
|
- The **time** is set correctly
|
||||||
|
- The message looks correct
|
||||||
|
- If it’s already enabled, try to:
|
||||||
|
- Turn it **off**
|
||||||
|
- Turn it **on** again
|
||||||
|
- Re-set the time and message
|
||||||
|
|
||||||
|
## 2) iOS troubleshooting
|
||||||
|
|
||||||
|
### Allow notifications for TimeSafari
|
||||||
|
|
||||||
|
1. Open **Settings** → **Notifications**
|
||||||
|
2. Tap **TimeSafari**
|
||||||
|
3. Turn **Allow Notifications** on
|
||||||
|
4. Enable at least one delivery style (recommended):
|
||||||
|
- **Lock Screen**
|
||||||
|
- **Notification Center**
|
||||||
|
- **Banners**
|
||||||
|
5. Optional but helpful:
|
||||||
|
- **Sounds** on (if you want an audible reminder)
|
||||||
|
|
||||||
|
### Focus / Do Not Disturb
|
||||||
|
|
||||||
|
If you’re using **Focus** or **Do Not Disturb**, notifications may be silenced or hidden.
|
||||||
|
|
||||||
|
- Open **Settings** → **Focus**
|
||||||
|
- Check the active Focus mode and ensure **TimeSafari** is allowed (or temporarily disable Focus to test)
|
||||||
|
|
||||||
|
### After restarting your phone
|
||||||
|
|
||||||
|
If you recently restarted iOS and don’t see the notification, open **TimeSafari** once. (You don’t need to change anything.)
|
||||||
|
|
||||||
|
## 3) Android troubleshooting
|
||||||
|
|
||||||
|
### Allow notifications for TimeSafari
|
||||||
|
|
||||||
|
1. Open **Settings** → **Apps**
|
||||||
|
2. Tap **TimeSafari** → **Manage notifications** (wording varies)
|
||||||
|
3. Turn notifications **on**
|
||||||
|
4. If Android shows notification categories/channels for the app, ensure the relevant channel is allowed.
|
||||||
|
|
||||||
|
### Battery / background restrictions
|
||||||
|
|
||||||
|
Battery optimization can delay or block scheduled notifications.
|
||||||
|
|
||||||
|
- Open **Settings** → **Apps** → **TimeSafari** → **Battery usage** (wording varies)
|
||||||
|
- If available:
|
||||||
|
- Set **Battery usage** to **Unrestricted**
|
||||||
|
- Turn **Allow background usage** on
|
||||||
|
- Disable optimization for TimeSafari
|
||||||
|
- If your device has lists like **Sleeping apps** / **Restricted apps**, remove TimeSafari from them
|
||||||
|
|
||||||
|
### After restarting your phone
|
||||||
|
|
||||||
|
Depending on the device manufacturer, Android can clear scheduled notifications during a reboot. If you restarted recently:
|
||||||
|
|
||||||
|
- Open **TimeSafari** once (you don’t need to change anything)
|
||||||
|
|
||||||
|
## 4) If it still doesn’t work
|
||||||
|
|
||||||
|
- Ensure you’re on the latest TimeSafari app version.
|
||||||
|
- If you denied permission earlier, re-enable notifications in system settings (above).
|
||||||
|
- As a last resort, uninstall/reinstall the app (you’ll need to enable notifications again and reconfigure the daily reminder). **Important:** Before uninstalling, back up your identifier seed so you can import it back later: **Profile → Data Management → Backup Identifier Seed**.
|
||||||
|
|
||||||
85
doc/android-daily-notification-second-schedule-issue.md
Normal file
85
doc/android-daily-notification-second-schedule-issue.md
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
# Android: Second notification doesn't fire (investigation & plan)
|
||||||
|
|
||||||
|
**Handoff to plugin repo:** This doc can be used as context in the daily-notification-plugin repo (e.g. in Cursor) to fix the Android re-schedule issue. See **Plugin-side: where to look and what to try** and **Could "re-scheduling too soon" cause the failure?** for actionable plugin changes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current state
|
||||||
|
|
||||||
|
- **Symptom**: After a fresh install, the first scheduled daily notification fires. When the user sets another notification (same or different time), it does not fire until the app is uninstalled and reinstalled.
|
||||||
|
- **Test app**: The plugin's test app (`daily-notification-test`) does not show this issue; scheduling a second notification works.
|
||||||
|
- **Attempted fix**: We changed the reminder ID from `timesafari_daily_reminder` to `daily_timesafari_reminder` so the plugin's rollover logic preserves the schedule ID (IDs starting with `daily_` are preserved). That did not fix the issue.
|
||||||
|
|
||||||
|
## Could "re-scheduling too soon" cause the failure?
|
||||||
|
|
||||||
|
**Yes, timing can matter.** The plugin is not very forgiving on Android in one case:
|
||||||
|
|
||||||
|
- **Idempotence in `NotifyReceiver.scheduleExactNotification`**: Before scheduling, the plugin checks for an existing PendingIntent (same `scheduleId` or same trigger time). If one exists, it **skips** scheduling to avoid duplicates.
|
||||||
|
- **After cancel**: When you re-schedule, the flow is `cancelNotification(scheduleId)` then `scheduleExactNotification(...)`. Android may not remove a cancelled PendingIntent from its cache immediately. If the idempotence check runs right after cancel, it can still see the old PendingIntent and treat the new schedule as a duplicate, so the second schedule is skipped.
|
||||||
|
- **After the first notification fires**: The alarm is gone but the PendingIntent might still be in the system. If the user opens the app and re-schedules within a few seconds, the same “duplicate” logic can trigger.
|
||||||
|
|
||||||
|
**Practical check:** Try waiting **5–10 seconds** after the first notification fires (or after changing time and saving) before saving again. If re-scheduling works when you wait but fails when you do it immediately, the cause is this timing/idempotence behavior. Fix would be in the plugin (e.g. short delay after cancel before idempotence check, or re-check after cancel).
|
||||||
|
|
||||||
|
**Other timing in the plugin (do not apply to your flow):** `DailyNotificationScheduler` has a 10s “notification throttle” and a 30s “activeDid changed” grace; those are used only when scheduling from **fetched content / rollover**, not when the user calls `scheduleDailyNotification`. Your re-schedule path goes through `NotifyReceiver.scheduleExactNotification` only, so those timeouts are not the cause.
|
||||||
|
|
||||||
|
## Differences: Test app vs TimeSafari
|
||||||
|
|
||||||
|
| Aspect | Test app | TimeSafari (before alignment) |
|
||||||
|
|--------|----------|-------------------------------|
|
||||||
|
| **Method** | `scheduleDailyNotification(options)` | `scheduleDailyReminder(options)` |
|
||||||
|
| **Options** | `{ time, title, body, sound, priority }` — **no `id`** | `{ id, time, title, body, repeatDaily, sound, vibration, priority }` |
|
||||||
|
| **Effective scheduleId** | Plugin default: `"daily_notification"` | Explicit: `"daily_timesafari_reminder"` (then `"daily_timesafari_reminder"` after prefix fix) |
|
||||||
|
| **Pre-cancel** | None | Calls `cancelDailyReminder({ reminderId })` before scheduling |
|
||||||
|
| **Android cancelDailyReminder** | Not used | Plugin **does not expose** `cancelDailyReminder` on Android (only `cancelAllNotifications`). So the pre-cancel is a no-op or fails silently. |
|
||||||
|
|
||||||
|
The plugin's `scheduleDailyNotification` flow already cancels the existing alarm for the **same** scheduleId via `NotifyReceiver.cancelNotification(context, scheduleId)` before scheduling. So the only behavioral difference that might matter is **which scheduleId is used** and **whether we pass an `id`**.
|
||||||
|
|
||||||
|
## Plan (app-side only)
|
||||||
|
|
||||||
|
1. **Platform-specific behavior** (implemented):
|
||||||
|
- **Android**: Use **`scheduleDailyNotification`** without passing `id` so the plugin uses default scheduleId **`"daily_notification"`**. Use **`reminderId = "daily_notification"`** for cancel/getStatus. **Do not** call `cancelDailyReminder` before scheduling on Android (test app does not; plugin cancels the previous alarm internally).
|
||||||
|
- **iOS**: Use **`scheduleDailyNotification`** with **`id: "daily_timesafari_reminder"`** and call **`cancelDailyReminder`** before scheduling so the reminder is removed from the notification center before rescheduling.
|
||||||
|
2. **If Android re-schedule still fails**, next step is **plugin-side investigation** in the plugin repo (no patch in this repo):
|
||||||
|
- Add logging in `NotifyReceiver.scheduleExactNotification` (idempotence checks, PendingIntent/DB) and in `ScheduleHelper.scheduleDailyNotification` / `cleanupExistingNotificationSchedules`; compare logcat for test app vs TimeSafari when scheduling twice.
|
||||||
|
- Optionally in test app: pass an explicit `id` when scheduling and test scheduling twice; if it then fails, the bug is tied to custom scheduleIds and the fix belongs in the plugin.
|
||||||
|
- Confirm whether the second schedule is skipped by an idempotence check (e.g. PendingIntent still present, or DB `nextRunAt` within 1 min of new trigger) or by another code path.
|
||||||
|
|
||||||
|
## Plugin-side: where to look and what to try
|
||||||
|
|
||||||
|
*(Use this section when working in the daily-notification-plugin repo.)*
|
||||||
|
|
||||||
|
**Entry point (user schedule):**
|
||||||
|
`DailyNotificationPlugin.kt` → `scheduleDailyNotification` → `ScheduleHelper.scheduleDailyNotification` → `NotifyReceiver.cancelNotification(context, scheduleId)` then `NotifyReceiver.scheduleExactNotification(...)`.
|
||||||
|
|
||||||
|
**Relevant plugin files (paths relative to plugin root):**
|
||||||
|
|
||||||
|
- **`android/.../NotifyReceiver.kt`**
|
||||||
|
- `scheduleExactNotification`: idempotence checks at start (PendingIntent by requestCode, by trigger time, then DB by scheduleId + nextRunAt within 60s). If any check finds an existing schedule, the function returns without scheduling.
|
||||||
|
- `cancelNotification`: cancels alarm and `existingPendingIntent.cancel()`. Android may not drop the PendingIntent from its cache immediately.
|
||||||
|
- **`android/.../DailyNotificationPlugin.kt`** (or ScheduleHelper companion/object)
|
||||||
|
- `ScheduleHelper.scheduleDailyNotification`: calls `NotifyReceiver.cancelNotification(context, scheduleId)` then `NotifyReceiver.scheduleExactNotification(...)`.
|
||||||
|
- `cleanupExistingNotificationSchedules`: cancels and deletes other schedules; excludes current scheduleId.
|
||||||
|
|
||||||
|
**Likely cause:** Idempotence in `scheduleExactNotification` runs *after* `cancelNotification` in the same flow. A just-cancelled PendingIntent can still be returned by `PendingIntent.getBroadcast(..., FLAG_NO_CREATE)` and cause the new schedule to be skipped.
|
||||||
|
|
||||||
|
**Suggested fixes (in plugin):**
|
||||||
|
|
||||||
|
1. **Re-check after cancel:** In the path that does cancel-then-schedule (e.g. in `ScheduleHelper.scheduleDailyNotification`), after `cancelNotification(scheduleId)` either:
|
||||||
|
- Call `PendingIntent.getBroadcast(..., FLAG_NO_CREATE)` for that scheduleId in a short loop with a small delay (e.g. 50–100 ms) until it returns null, with a timeout (e.g. 500 ms), then call `scheduleExactNotification`; or
|
||||||
|
- Pass a flag into `scheduleExactNotification` to skip or relax the "existing PendingIntent" idempotence when the caller has just cancelled this scheduleId.
|
||||||
|
2. **Or brief delay before idempotence:** When the schedule path has just called `cancelNotification(scheduleId)`, have `scheduleExactNotification` skip the PendingIntent check for that scheduleId if last cancel was < 1–2 s ago (e.g. store "justCancelled(scheduleId)" with timestamp).
|
||||||
|
3. **Logging:** In `NotifyReceiver.scheduleExactNotification`, log when scheduling is skipped and which check triggered (PendingIntent by requestCode, by time, or DB). Capture logcat for "schedule, then fire, then re-schedule within a few seconds" to confirm.
|
||||||
|
|
||||||
|
**Reproduce in test app:** In `daily-notification-test`, schedule once, let it fire (or wait), then schedule again within 1–2 seconds. If the second schedule doesn't fire, the bug is reproducible in the plugin; then apply one of the fixes above and re-test.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## If changes are needed in the plugin repo (TimeSafari app note)
|
||||||
|
|
||||||
|
Do **not** add a patch in this (TimeSafari) repo. Instead:
|
||||||
|
|
||||||
|
1. **Reproduce in the plugin's test app** (e.g. pass an explicit `id` like `"custom_id"` when scheduling and try scheduling twice) to see if the issue is tied to custom scheduleIds.
|
||||||
|
2. **Add the logging** above in the plugin's Android code and capture logs for “first schedule → fire → second schedule” in both test app and TimeSafari.
|
||||||
|
3. **Fix in the plugin** (e.g. relax or correct idempotence, or ensure cancel + DB state are consistent for the same scheduleId) and release a new plugin version; then bump the plugin dependency in this app.
|
||||||
|
|
||||||
|
No patch file or copy of plugin code is needed in the TimeSafari repo.
|
||||||
515
doc/android-physical-device-guide.md
Normal file
515
doc/android-physical-device-guide.md
Normal file
@@ -0,0 +1,515 @@
|
|||||||
|
# Android Physical Device Deployment Guide
|
||||||
|
|
||||||
|
**Author**: Matthew Raymer
|
||||||
|
**Date**: 2025-02-12
|
||||||
|
**Status**: 🎯 **ACTIVE** - Complete guide for deploying TimeSafari to physical Android devices
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This guide provides comprehensive instructions for building and deploying TimeSafari to physical Android devices for testing and development. Unlike emulator testing, physical device testing requires additional setup for USB connections and network configuration.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
### Required Tools
|
||||||
|
|
||||||
|
1. **Android SDK Platform Tools** (includes `adb`)
|
||||||
|
```bash
|
||||||
|
# macOS (Homebrew)
|
||||||
|
brew install android-platform-tools
|
||||||
|
|
||||||
|
# Or via Android SDK Manager
|
||||||
|
sdkmanager "platform-tools"
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Node.js 18+** and npm
|
||||||
|
|
||||||
|
3. **Java Development Kit (JDK) 17+**
|
||||||
|
```bash
|
||||||
|
# macOS (Homebrew)
|
||||||
|
brew install openjdk@17
|
||||||
|
|
||||||
|
# Verify installation
|
||||||
|
java -version
|
||||||
|
```
|
||||||
|
|
||||||
|
### Environment Setup
|
||||||
|
|
||||||
|
Add to your shell configuration (`~/.zshrc` or `~/.bashrc`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Android SDK location
|
||||||
|
export ANDROID_HOME=$HOME/Library/Android/sdk # macOS default
|
||||||
|
# export ANDROID_HOME=$HOME/Android/Sdk # Linux default
|
||||||
|
|
||||||
|
export PATH=$PATH:$ANDROID_HOME/platform-tools
|
||||||
|
export PATH=$PATH:$ANDROID_HOME/cmdline-tools/latest/bin
|
||||||
|
export PATH=$PATH:$ANDROID_HOME/build-tools/34.0.0
|
||||||
|
```
|
||||||
|
|
||||||
|
Reload your shell:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source ~/.zshrc # or source ~/.bashrc
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify installation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
adb version
|
||||||
|
```
|
||||||
|
|
||||||
|
## Device Setup
|
||||||
|
|
||||||
|
### Step 1: Enable Developer Options
|
||||||
|
|
||||||
|
Developer Options is hidden by default on Android devices. To enable it:
|
||||||
|
|
||||||
|
1. Open **Settings** on your Android device
|
||||||
|
2. Scroll down and tap **About phone** (or **About device**)
|
||||||
|
3. Find **Build number** and tap it **7 times** rapidly
|
||||||
|
4. You'll see a message: "You are now a developer!"
|
||||||
|
5. Go back to Settings - **Developer options** now appears
|
||||||
|
|
||||||
|
### Step 2: Enable USB Debugging
|
||||||
|
|
||||||
|
1. Go to **Settings** → **Developer options**
|
||||||
|
2. Enable **USB debugging** (toggle it ON)
|
||||||
|
3. Optionally enable these helpful options:
|
||||||
|
- **Stay awake** - Screen stays on while charging
|
||||||
|
- **Install via USB** - Allow app installations via USB
|
||||||
|
|
||||||
|
### Step 3: Connect Your Device
|
||||||
|
|
||||||
|
1. Connect your Android device to your computer via USB cable
|
||||||
|
2. On your device, you'll see a prompt: "Allow USB debugging?"
|
||||||
|
3. Check **"Always allow from this computer"** (recommended)
|
||||||
|
4. Tap **Allow**
|
||||||
|
|
||||||
|
### Step 4: Verify Connection
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# List connected devices
|
||||||
|
adb devices
|
||||||
|
|
||||||
|
# Expected output:
|
||||||
|
# List of devices attached
|
||||||
|
# XXXXXXXXXX device
|
||||||
|
```
|
||||||
|
|
||||||
|
If you see `unauthorized` instead of `device`, check your phone for the USB debugging authorization prompt.
|
||||||
|
|
||||||
|
## Network Configuration for Development
|
||||||
|
|
||||||
|
### Understanding the Network Challenge
|
||||||
|
|
||||||
|
When running a local development server on your computer:
|
||||||
|
- **Emulators** use `10.0.2.2` to reach the host machine
|
||||||
|
- **Physical devices** need your computer's actual LAN IP address
|
||||||
|
|
||||||
|
### Step 1: Find Your Computer's IP Address
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# macOS
|
||||||
|
ipconfig getifaddr en0 # Wi-Fi
|
||||||
|
# or
|
||||||
|
ipconfig getifaddr en1 # Ethernet
|
||||||
|
|
||||||
|
# Linux
|
||||||
|
hostname -I | awk '{print $1}'
|
||||||
|
# or
|
||||||
|
ip addr show | grep 'inet ' | grep -v '127.0.0.1' | awk '{print $2}' | cut -d'/' -f1
|
||||||
|
```
|
||||||
|
|
||||||
|
Example output: `192.168.1.100`
|
||||||
|
|
||||||
|
### Step 2: Ensure Same Network
|
||||||
|
|
||||||
|
Your Android device and computer **must be on the same Wi-Fi network** for the device to reach your local development servers.
|
||||||
|
|
||||||
|
### Step 3: Configure API Endpoints
|
||||||
|
|
||||||
|
Create or edit `.env.development` with your computer's IP:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# .env.development - for physical device testing
|
||||||
|
VITE_DEFAULT_ENDORSER_API_SERVER=http://192.168.1.100:3000
|
||||||
|
VITE_DEFAULT_PARTNER_API_SERVER=http://192.168.1.100:3000
|
||||||
|
VITE_DEFAULT_IMAGE_API_SERVER=https://test-image-api.timesafari.app
|
||||||
|
VITE_APP_SERVER=http://192.168.1.100:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
**Important**: Replace `192.168.1.100` with your actual IP address.
|
||||||
|
|
||||||
|
### Step 4: Start Your Local Server
|
||||||
|
|
||||||
|
If testing against local API servers, ensure they're accessible from the network:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start your API server bound to all interfaces (not just localhost)
|
||||||
|
# Example for Node.js:
|
||||||
|
node server.js --host 0.0.0.0
|
||||||
|
|
||||||
|
# Or configure your server to listen on 0.0.0.0 instead of 127.0.0.1
|
||||||
|
```
|
||||||
|
|
||||||
|
### Alternative: Use Test/Production Servers
|
||||||
|
|
||||||
|
For simpler testing without local servers, use the test environment:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build with test API servers (no local server needed)
|
||||||
|
npm run build:android:test
|
||||||
|
```
|
||||||
|
|
||||||
|
## Building and Deploying
|
||||||
|
|
||||||
|
### Quick Start (Recommended)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Verify device is connected
|
||||||
|
adb devices
|
||||||
|
|
||||||
|
# 2. Build and deploy in one command
|
||||||
|
npm run build:android:debug:run
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step-by-Step Deployment
|
||||||
|
|
||||||
|
#### Step 1: Build the App
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Development build (uses .env.development)
|
||||||
|
npm run build:android:dev
|
||||||
|
|
||||||
|
# Test build (uses test API servers)
|
||||||
|
npm run build:android:test
|
||||||
|
|
||||||
|
# Production build
|
||||||
|
npm run build:android:prod
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Step 2: Install the APK
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install (replace existing if present)
|
||||||
|
adb install -r android/app/build/outputs/apk/debug/app-debug.apk
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Step 3: Launch the App
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start the app
|
||||||
|
adb shell am start -n app.timesafari.app/app.timesafari.MainActivity
|
||||||
|
```
|
||||||
|
|
||||||
|
### One-Line Deploy Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Development build + install + launch
|
||||||
|
npm run build:android:debug:run
|
||||||
|
|
||||||
|
# Test build + install + launch
|
||||||
|
npm run build:android:test:run
|
||||||
|
|
||||||
|
# Deploy to connected device (build must exist)
|
||||||
|
npm run build:android:deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
## Debugging
|
||||||
|
|
||||||
|
### View App Logs
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# All logs from your app
|
||||||
|
adb logcat | grep -E "(TimeSafari|Capacitor)"
|
||||||
|
|
||||||
|
# With color highlighting
|
||||||
|
adb logcat | grep -E "(TimeSafari|Capacitor)" --color=always
|
||||||
|
|
||||||
|
# Save logs to file
|
||||||
|
adb logcat > device-logs.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
### Chrome DevTools (Remote Debugging)
|
||||||
|
|
||||||
|
1. Open Chrome on your computer
|
||||||
|
2. Navigate to `chrome://inspect`
|
||||||
|
3. Your device should appear under "Remote Target"
|
||||||
|
4. Click **inspect** to open DevTools for your app
|
||||||
|
|
||||||
|
**Requirements**:
|
||||||
|
- USB debugging must be enabled
|
||||||
|
- Device must be connected via USB
|
||||||
|
- App must be a debug build
|
||||||
|
|
||||||
|
### Common Log Filters
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Network-related issues
|
||||||
|
adb logcat | grep -i "network\|http\|socket"
|
||||||
|
|
||||||
|
# JavaScript errors
|
||||||
|
adb logcat | grep -i "console\|error\|exception"
|
||||||
|
|
||||||
|
# Capacitor plugin issues
|
||||||
|
adb logcat | grep -i "capacitor"
|
||||||
|
|
||||||
|
# Detailed app logs
|
||||||
|
adb logcat -s "TimeSafari:V"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Device Not Detected
|
||||||
|
|
||||||
|
**Symptom**: `adb devices` shows nothing or shows `unauthorized`
|
||||||
|
|
||||||
|
**Solutions**:
|
||||||
|
|
||||||
|
1. **Check USB cable**: Some cables are charge-only. Use a data-capable USB cable.
|
||||||
|
|
||||||
|
2. **Revoke USB debugging authorizations** (on device):
|
||||||
|
- Settings → Developer options → Revoke USB debugging authorizations
|
||||||
|
- Reconnect and re-authorize
|
||||||
|
|
||||||
|
3. **Restart ADB server**:
|
||||||
|
```bash
|
||||||
|
adb kill-server
|
||||||
|
adb start-server
|
||||||
|
adb devices
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Try different USB port**: Some USB hubs don't work well with ADB.
|
||||||
|
|
||||||
|
5. **Check device USB mode**: Pull down notification shade and ensure USB is set to "File Transfer" or "MTP" mode, not just charging.
|
||||||
|
|
||||||
|
### App Can't Connect to Local Server
|
||||||
|
|
||||||
|
**Symptom**: App loads but shows network errors or can't reach API
|
||||||
|
|
||||||
|
**Solutions**:
|
||||||
|
|
||||||
|
1. **Verify IP address**:
|
||||||
|
```bash
|
||||||
|
# Make sure you have the right IP
|
||||||
|
ipconfig getifaddr en0 # macOS
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Check firewall**: Temporarily disable firewall or add exception for port 3000
|
||||||
|
|
||||||
|
3. **Test connectivity from device**:
|
||||||
|
- Open Chrome on your Android device
|
||||||
|
- Navigate to `http://YOUR_IP:3000`
|
||||||
|
- Should see your API response
|
||||||
|
|
||||||
|
4. **Verify server is listening on all interfaces**:
|
||||||
|
```bash
|
||||||
|
# Should show 0.0.0.0:3000, not 127.0.0.1:3000
|
||||||
|
lsof -i :3000
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Same network check**: Ensure phone Wi-Fi and computer are on the same network
|
||||||
|
|
||||||
|
### Installation Failed
|
||||||
|
|
||||||
|
**Symptom**: `adb install` fails with error
|
||||||
|
|
||||||
|
**Common errors and solutions**:
|
||||||
|
|
||||||
|
1. **INSTALL_FAILED_UPDATE_INCOMPATIBLE**:
|
||||||
|
```bash
|
||||||
|
# Uninstall existing app first
|
||||||
|
adb uninstall app.timesafari.app
|
||||||
|
adb install android/app/build/outputs/apk/debug/app-debug.apk
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **INSTALL_FAILED_INSUFFICIENT_STORAGE**:
|
||||||
|
- Free up space on the device
|
||||||
|
- Or install to SD card if available
|
||||||
|
|
||||||
|
3. **INSTALL_FAILED_USER_RESTRICTED**:
|
||||||
|
- Enable "Install via USB" in Developer options
|
||||||
|
- On some devices: Settings → Security → Unknown sources
|
||||||
|
|
||||||
|
4. **Signature mismatch**:
|
||||||
|
```bash
|
||||||
|
# Full clean reinstall
|
||||||
|
adb uninstall app.timesafari.app
|
||||||
|
npm run clean:android
|
||||||
|
npm run build:android:debug
|
||||||
|
adb install android/app/build/outputs/apk/debug/app-debug.apk
|
||||||
|
```
|
||||||
|
|
||||||
|
### App Crashes on Launch
|
||||||
|
|
||||||
|
**Symptom**: App opens briefly then closes
|
||||||
|
|
||||||
|
**Debug steps**:
|
||||||
|
|
||||||
|
1. **Check crash logs**:
|
||||||
|
```bash
|
||||||
|
adb logcat | grep -E "FATAL|AndroidRuntime|Exception"
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Clear app data**:
|
||||||
|
```bash
|
||||||
|
adb shell pm clear app.timesafari.app
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Reinstall clean**:
|
||||||
|
```bash
|
||||||
|
adb uninstall app.timesafari.app
|
||||||
|
npm run clean:android
|
||||||
|
npm run build:android:debug:run
|
||||||
|
```
|
||||||
|
|
||||||
|
### Build Failures
|
||||||
|
|
||||||
|
**Symptom**: Build fails before APK is created
|
||||||
|
|
||||||
|
**Solutions**:
|
||||||
|
|
||||||
|
1. **Asset validation**:
|
||||||
|
```bash
|
||||||
|
npm run assets:validate:android
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Clean and rebuild**:
|
||||||
|
```bash
|
||||||
|
npm run clean:android
|
||||||
|
npm run build:android:debug
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Check Gradle**:
|
||||||
|
```bash
|
||||||
|
cd android
|
||||||
|
./gradlew clean --stacktrace
|
||||||
|
./gradlew assembleDebug --stacktrace
|
||||||
|
```
|
||||||
|
|
||||||
|
## Wireless Debugging (Optional)
|
||||||
|
|
||||||
|
Once initial USB connection is established, you can switch to wireless:
|
||||||
|
|
||||||
|
### Enable Wireless Debugging
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Connect via USB first
|
||||||
|
adb devices
|
||||||
|
|
||||||
|
# 2. Enable TCP/IP mode on port 5555
|
||||||
|
adb tcpip 5555
|
||||||
|
|
||||||
|
# 3. Find device IP (on device: Settings → About → IP address)
|
||||||
|
# Or:
|
||||||
|
adb shell ip addr show wlan0
|
||||||
|
|
||||||
|
# 4. Connect wirelessly (disconnect USB cable)
|
||||||
|
adb connect 192.168.1.XXX:5555
|
||||||
|
|
||||||
|
# 5. Verify
|
||||||
|
adb devices
|
||||||
|
```
|
||||||
|
|
||||||
|
### Reconnect After Reboot
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Device IP may have changed - check it first
|
||||||
|
adb connect 192.168.1.XXX:5555
|
||||||
|
```
|
||||||
|
|
||||||
|
### Return to USB Mode
|
||||||
|
|
||||||
|
```bash
|
||||||
|
adb usb
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### Development Workflow
|
||||||
|
|
||||||
|
1. **Keep device connected during development** for quick iteration
|
||||||
|
|
||||||
|
2. **Use test builds for most testing**:
|
||||||
|
```bash
|
||||||
|
npm run build:android:test:run
|
||||||
|
```
|
||||||
|
This avoids local server configuration hassles.
|
||||||
|
|
||||||
|
3. **Use Chrome DevTools** for JavaScript debugging - much easier than logcat
|
||||||
|
|
||||||
|
4. **Test on multiple devices** if possible - different Android versions behave differently
|
||||||
|
|
||||||
|
### Performance Testing
|
||||||
|
|
||||||
|
Physical devices give you real-world performance insights that emulators can't:
|
||||||
|
|
||||||
|
- **Battery consumption**: Monitor with Settings → Battery
|
||||||
|
- **Network conditions**: Test on slow/unstable Wi-Fi
|
||||||
|
- **Memory pressure**: Test with many apps open
|
||||||
|
- **Touch responsiveness**: Actual finger input vs mouse clicks
|
||||||
|
|
||||||
|
### Before Release Testing
|
||||||
|
|
||||||
|
Always test on physical devices before any release:
|
||||||
|
|
||||||
|
1. Fresh install (not upgrade)
|
||||||
|
2. Upgrade from previous version
|
||||||
|
3. Test on lowest supported Android version
|
||||||
|
4. Test on both phone and tablet if applicable
|
||||||
|
|
||||||
|
## Quick Reference
|
||||||
|
|
||||||
|
### Essential Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check connected devices
|
||||||
|
adb devices
|
||||||
|
|
||||||
|
# Build and run (debug)
|
||||||
|
npm run build:android:debug:run
|
||||||
|
|
||||||
|
# Build and run (test environment)
|
||||||
|
npm run build:android:test:run
|
||||||
|
|
||||||
|
# Install existing APK
|
||||||
|
adb install -r android/app/build/outputs/apk/debug/app-debug.apk
|
||||||
|
|
||||||
|
# Uninstall app
|
||||||
|
adb uninstall app.timesafari.app
|
||||||
|
|
||||||
|
# Launch app
|
||||||
|
adb shell am start -n app.timesafari.app/app.timesafari.MainActivity
|
||||||
|
|
||||||
|
# View logs
|
||||||
|
adb logcat | grep TimeSafari
|
||||||
|
|
||||||
|
# Take screenshot
|
||||||
|
adb exec-out screencap -p > screenshot.png
|
||||||
|
|
||||||
|
# Record screen
|
||||||
|
adb shell screenrecord /sdcard/demo.mp4
|
||||||
|
# (Ctrl+C to stop, then pull file)
|
||||||
|
adb pull /sdcard/demo.mp4
|
||||||
|
```
|
||||||
|
|
||||||
|
### Build Modes Quick Reference
|
||||||
|
|
||||||
|
| Command | Environment | API Servers |
|
||||||
|
|---------|-------------|-------------|
|
||||||
|
| `npm run build:android:dev` | Development | Local (your IP:3000) |
|
||||||
|
| `npm run build:android:test` | Test | test-api.endorser.ch |
|
||||||
|
| `npm run build:android:prod` | Production | api.endorser.ch |
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
Physical device testing is essential for:
|
||||||
|
- ✅ Real-world performance validation
|
||||||
|
- ✅ Touch and gesture testing
|
||||||
|
- ✅ Camera and hardware feature testing
|
||||||
|
- ✅ Network condition testing
|
||||||
|
- ✅ Battery and resource usage analysis
|
||||||
|
|
||||||
|
For emulator-based testing (useful for quick iteration), see [Android Emulator Deployment Guide](android-emulator-deployment-guide.md).
|
||||||
|
|
||||||
|
For questions or additional troubleshooting, refer to the main [BUILDING.md](../BUILDING.md) documentation.
|
||||||
106
doc/daily-notification-alignment-outline.md
Normal file
106
doc/daily-notification-alignment-outline.md
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
# Daily Notification Plugin: Alignment Outline
|
||||||
|
|
||||||
|
**Purpose:** Checklist of changes/additions needed in this app to align with the test app (`daily-notification-plugin/test-apps/daily-notification-test`) so that:
|
||||||
|
|
||||||
|
1. **Rollover recovery** (and rollover itself) works.
|
||||||
|
2. **Notifications show when the app is in the foreground** (not only background/closed).
|
||||||
|
3. **Plugin loads at app launch** so recovery runs after reboot without the user opening notification UI.
|
||||||
|
|
||||||
|
**Reference:** Test app at
|
||||||
|
`/Users/aardimus/Sites/trentlarson/daily-notification-plugin_test/daily-notification-plugin/test-apps/daily-notification-test`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. iOS AppDelegate
|
||||||
|
|
||||||
|
**File:** `ios/App/App/AppDelegate.swift`
|
||||||
|
|
||||||
|
### 1.1 Add imports
|
||||||
|
|
||||||
|
- [ ] `import UserNotifications`
|
||||||
|
- [ ] Import the Daily Notification plugin framework (Swift module name: **TimesafariDailyNotificationPlugin** per this app’s Podfile; test app uses **DailyNotificationPlugin**)
|
||||||
|
|
||||||
|
### 1.2 Conform to `UNUserNotificationCenterDelegate`
|
||||||
|
|
||||||
|
- [ ] Add `, UNUserNotificationCenterDelegate` to the class declaration:
|
||||||
|
`class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterDelegate`
|
||||||
|
|
||||||
|
### 1.3 Force-load plugin at launch
|
||||||
|
|
||||||
|
- [ ] In `application(_:didFinishLaunchingWithOptions:)`, **before** other setup, add logic to force-load the plugin class (e.g. `_ = DailyNotificationPlugin.self` or the class exposed by the TimesafariDailyNotificationPlugin pod) so that the plugin’s `load()` (and thus `performRecovery()`) runs at app launch, not only when JS first calls the plugin.
|
||||||
|
|
||||||
|
### 1.4 Set notification center delegate
|
||||||
|
|
||||||
|
- [ ] In `didFinishLaunchingWithOptions`, set:
|
||||||
|
`UNUserNotificationCenter.current().delegate = self`
|
||||||
|
- [ ] In `applicationDidBecomeActive`, **re-set** the same delegate (in case Capacitor or another component clears it).
|
||||||
|
|
||||||
|
### 1.5 Implement `userNotificationCenter(_:willPresent:withCompletionHandler:)`
|
||||||
|
|
||||||
|
- [ ] When a notification is delivered (including in foreground), read `notification_id` and `scheduled_time` from `notification.request.content.userInfo`.
|
||||||
|
- [ ] Post rollover event:
|
||||||
|
`NotificationCenter.default.post(name: NSNotification.Name("DailyNotificationDelivered"), object: nil, userInfo: ["notification_id": id, "scheduled_time": scheduledTime])`
|
||||||
|
- [ ] Call completion handler with presentation options so the notification is shown in foreground, e.g.
|
||||||
|
`completionHandler([.banner, .sound, .badge])` (use `.alert` on iOS 13 if needed).
|
||||||
|
|
||||||
|
### 1.6 Implement `userNotificationCenter(_:didReceive:withCompletionHandler:)`
|
||||||
|
|
||||||
|
- [ ] Handle notification tap/interaction; call `completionHandler()` when done.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Android Manifest
|
||||||
|
|
||||||
|
**File:** `android/app/src/main/AndroidManifest.xml`
|
||||||
|
|
||||||
|
### 2.1 Fix receiver placement
|
||||||
|
|
||||||
|
- [ ] Move the two `<receiver>` elements (**DailyNotificationReceiver** and **BootReceiver**) **inside** the `<application>` block (e.g. after `<activity>...</activity>` and before `<provider>...</provider>`).
|
||||||
|
- [ ] Remove the stray second `</application>` so there is a single `<application>...</application>` containing activity, receivers, and provider.
|
||||||
|
|
||||||
|
### 2.2 (Optional) Add NotifyReceiver
|
||||||
|
|
||||||
|
- [ ] If the plugin’s Android integration expects **NotifyReceiver** for alarm-based delivery, add a `<receiver>` for `org.timesafari.dailynotification.NotifyReceiver` inside `<application>` (see test app manifest for exact declaration).
|
||||||
|
|
||||||
|
### 2.3 (Optional) BootReceiver options
|
||||||
|
|
||||||
|
- [ ] Consider aligning with test app: add `android:directBootAware="true"`, `android:exported="true"`, and intent-filter actions `LOCKED_BOOT_COMPLETED`, `MY_PACKAGE_REPLACED`, `PACKAGE_REPLACED` if you need the same boot/update behavior.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Capacitor / JS startup (optional but recommended)
|
||||||
|
|
||||||
|
**File:** `src/main.capacitor.ts` (or the main entry used for native builds)
|
||||||
|
|
||||||
|
### 3.1 Load plugin at startup
|
||||||
|
|
||||||
|
- [ ] Add a top-level import or an early call that touches the Daily Notification plugin so the JS side loads it at app startup (e.g. `import "@timesafari/daily-notification-plugin"` or a small init that calls `getRebootRecoveryStatus()` or `configure()`).
|
||||||
|
This ensures the plugin is loaded as soon as the app runs; together with the iOS force-load in AppDelegate, recovery runs at launch.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Plugin configuration (optional)
|
||||||
|
|
||||||
|
- [ ] If you use the native fetcher or need plugin config (db path, storage, etc.), call `DailyNotification.configure()` and/or `configureNativeFetcher()` when appropriate (e.g. after login or when notification UI is first used), similar to the test app’s `configureNativeFetcher()` in HomeView.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Summary table
|
||||||
|
|
||||||
|
| Area | Change / addition |
|
||||||
|
|-------------------------|------------------------------------------------------------------------------------|
|
||||||
|
| **iOS AppDelegate** | Conform to `UNUserNotificationCenterDelegate`; set delegate; force-load plugin; implement `willPresent` (post `DailyNotificationDelivered` + show in foreground) and `didReceive`. |
|
||||||
|
| **Android manifest** | Move DailyNotificationReceiver and BootReceiver inside `<application>`; remove duplicate `</application>`; optionally add NotifyReceiver and BootReceiver options. |
|
||||||
|
| **main.capacitor.ts** | Optionally import or call plugin at startup so it (and recovery) load at launch. |
|
||||||
|
| **Plugin config** | Optionally call `configure()` / `configureNativeFetcher()` where appropriate. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Verification
|
||||||
|
|
||||||
|
After making the changes:
|
||||||
|
|
||||||
|
- [ ] **iOS:** Build and run; trigger a daily notification and confirm it appears when the app is in the foreground.
|
||||||
|
- [ ] **iOS:** Confirm rollover (next day’s schedule) still occurs after a notification fires (check logs for `DNP-ROLLOVER` / `DailyNotificationDelivered`).
|
||||||
|
- [ ] **iOS:** Restart the app (or reboot) and confirm recovery runs without opening the notification settings screen (e.g. logs show plugin load and recovery).
|
||||||
|
- [ ] **Android:** Build and run; confirm receivers are registered (no manifest errors) and that notifications and boot recovery behave as expected.
|
||||||
251
doc/daily-notification-plugin-android-receiver-issue.md
Normal file
251
doc/daily-notification-plugin-android-receiver-issue.md
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
# Daily Notification Plugin - Android Receiver Not Triggered by AlarmManager
|
||||||
|
|
||||||
|
**Date**: 2026-02-02
|
||||||
|
**Status**: ✅ Resolved (2026-02-06)
|
||||||
|
**Plugin**: @timesafari/daily-notification-plugin
|
||||||
|
**Platform**: Android
|
||||||
|
**Issue**: AlarmManager fires alarms but DailyNotificationReceiver is not receiving broadcasts
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Resolution (2026-02-06)
|
||||||
|
|
||||||
|
The bug was fixed in the plugin repository. The plugin now:
|
||||||
|
|
||||||
|
- Creates the PendingIntent with the receiver component explicitly set (`setComponent(ComponentName(context, DailyNotificationReceiver::class.java))`), so AlarmManager delivers the broadcast to the receiver.
|
||||||
|
- Adds the schedule ID to the Intent extras (`intent.putExtra("id", scheduleId)`), resolving the `missing_id` error.
|
||||||
|
|
||||||
|
**In this app after pulling the fix:**
|
||||||
|
|
||||||
|
1. Run `npm install` to get the latest plugin from `#master`.
|
||||||
|
2. Run `npx cap sync` so the Android (and iOS) native projects get the updated plugin code.
|
||||||
|
3. Run `node scripts/restore-local-plugins.js` if you use local plugins (e.g. SafeArea, SharedImage).
|
||||||
|
4. Rebuild and run on Android, then verify using the [Testing Steps for Plugin Fix](#testing-steps-for-plugin-fix) below.
|
||||||
|
</think>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Problem Summary
|
||||||
|
|
||||||
|
Alarms are being scheduled successfully and fire at the correct time, but the `DailyNotificationReceiver` is not being triggered when AlarmManager delivers the broadcast. Manual broadcasts to the receiver work correctly, indicating the receiver itself is functional.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What Works ✅
|
||||||
|
|
||||||
|
1. **Receiver Registration**: The receiver is properly registered in AndroidManifest.xml with `exported="true"`
|
||||||
|
2. **Manual Broadcasts**: Manually triggering the receiver via `adb shell am broadcast` successfully triggers it
|
||||||
|
3. **Alarm Scheduling**: Alarms are successfully scheduled via `setAlarmClock()` and appear in `dumpsys alarm`
|
||||||
|
4. **Alarm Firing**: Alarms fire at the scheduled time (confirmed by alarm disappearing from dumpsys)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What Doesn't Work ❌
|
||||||
|
|
||||||
|
1. **Automatic Receiver Triggering**: When AlarmManager fires the alarm, the broadcast PendingIntent does not reach the receiver
|
||||||
|
2. **No Logs on Alarm Fire**: No `DN|RECEIVE_START` logs appear when alarms fire automatically
|
||||||
|
3. **Missing ID in Intent**: When manually tested, receiver shows `DN|RECEIVE_ERR missing_id` (separate issue but related)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Technical Details
|
||||||
|
|
||||||
|
### Receiver Configuration
|
||||||
|
|
||||||
|
**File**: `android/app/src/main/AndroidManifest.xml`
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<receiver
|
||||||
|
android:name="org.timesafari.dailynotification.DailyNotificationReceiver"
|
||||||
|
android:enabled="true"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="org.timesafari.daily.NOTIFICATION" />
|
||||||
|
</intent-filter>
|
||||||
|
</receiver>
|
||||||
|
```
|
||||||
|
|
||||||
|
- ✅ `exported="true"` is set (required for AlarmManager broadcasts)
|
||||||
|
- ✅ Intent action matches: `org.timesafari.daily.NOTIFICATION`
|
||||||
|
- ✅ Receiver is inside `<application>` tag
|
||||||
|
|
||||||
|
### Alarm Scheduling Evidence
|
||||||
|
|
||||||
|
From logs when scheduling (23:51:32):
|
||||||
|
```
|
||||||
|
I DNP-SCHEDULE: Scheduling OS alarm: variant=ALARM_CLOCK, action=org.timesafari.daily.NOTIFICATION, triggerTime=1770105300000, requestCode=44490, scheduleId=timesafari_daily_reminder
|
||||||
|
I DNP-NOTIFY: Alarm clock scheduled (setAlarmClock): triggerAt=1770105300000, requestCode=44490
|
||||||
|
```
|
||||||
|
|
||||||
|
From `dumpsys alarm` output:
|
||||||
|
```
|
||||||
|
RTC_WAKEUP #36: Alarm{7a8fb5e type 0 origWhen 1770148800000 whenElapsed 122488536 app.timesafari.app}
|
||||||
|
tag=*walarm*:org.timesafari.daily.NOTIFICATION
|
||||||
|
type=RTC_WAKEUP origWhen=2026-02-03 12:00:00.000 window=0 exactAllowReason=policy_permission
|
||||||
|
operation=PendingIntent{6fce955: PendingIntentRecord{5856f6a app.timesafari.app broadcastIntent}}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Alarm Firing Evidence
|
||||||
|
|
||||||
|
- Alarm scheduled for 23:55:00 (timestamp: 1770105300000)
|
||||||
|
- At 23:55:00, alarm is no longer in `dumpsys alarm` (confirmed it fired)
|
||||||
|
- **No `DN|RECEIVE_START` log at 23:55:00** (receiver was not triggered)
|
||||||
|
|
||||||
|
### Manual Broadcast Test (Works)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
adb shell am broadcast -a org.timesafari.daily.NOTIFICATION -n app.timesafari.app/org.timesafari.dailynotification.DailyNotificationReceiver
|
||||||
|
```
|
||||||
|
|
||||||
|
**Result**: ✅ Receiver triggered successfully
|
||||||
|
```
|
||||||
|
02-02 23:46:07.505 DailyNotificationReceiver D DN|RECEIVE_START action=org.timesafari.daily.NOTIFICATION
|
||||||
|
02-02 23:46:07.506 DailyNotificationReceiver W DN|RECEIVE_ERR missing_id
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Root Cause Analysis
|
||||||
|
|
||||||
|
The issue appears to be in how the PendingIntent is created when scheduling alarms. Possible causes:
|
||||||
|
|
||||||
|
### Hypothesis 1: PendingIntent Not Targeting Receiver Correctly
|
||||||
|
|
||||||
|
The PendingIntent may be created without explicitly specifying the component, causing Android to not match it to the receiver when the alarm fires.
|
||||||
|
|
||||||
|
**Expected Fix**: When creating the PendingIntent for AlarmManager, explicitly set the component:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
val intent = Intent("org.timesafari.daily.NOTIFICATION").apply {
|
||||||
|
setComponent(ComponentName(context, DailyNotificationReceiver::class.java))
|
||||||
|
putExtra("id", scheduleId) // Also fix missing_id issue
|
||||||
|
}
|
||||||
|
val pendingIntent = PendingIntent.getBroadcast(
|
||||||
|
context,
|
||||||
|
requestCode,
|
||||||
|
intent,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Hypothesis 2: PendingIntent Flags Issue
|
||||||
|
|
||||||
|
The PendingIntent may be created with incorrect flags that prevent delivery when the app is in certain states.
|
||||||
|
|
||||||
|
**Check**: Ensure flags include:
|
||||||
|
- `FLAG_UPDATE_CURRENT` or `FLAG_CANCEL_CURRENT`
|
||||||
|
- `FLAG_IMMUTABLE` (required on Android 12+)
|
||||||
|
|
||||||
|
### Hypothesis 3: Package/Component Mismatch
|
||||||
|
|
||||||
|
The PendingIntent may be created with a different package name or component than what's registered in the manifest.
|
||||||
|
|
||||||
|
**Check**: Verify the package name in the Intent matches `app.timesafari.app` and the component matches the receiver class.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Additional Issue: Missing ID in Intent
|
||||||
|
|
||||||
|
When the receiver IS triggered (manually), it shows:
|
||||||
|
```
|
||||||
|
DN|RECEIVE_ERR missing_id
|
||||||
|
```
|
||||||
|
|
||||||
|
This indicates the Intent extras don't include the `scheduleId`. The plugin should add the ID to the Intent when creating the PendingIntent:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
intent.putExtra("id", scheduleId)
|
||||||
|
// or
|
||||||
|
intent.putExtra("scheduleId", scheduleId) // if receiver expects different key
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing Steps for Plugin Fix
|
||||||
|
|
||||||
|
1. **Verify PendingIntent Creation**:
|
||||||
|
- Check the code that creates PendingIntent for AlarmManager
|
||||||
|
- Ensure component is explicitly set
|
||||||
|
- Ensure ID is added to Intent extras
|
||||||
|
|
||||||
|
2. **Test Alarm Delivery**:
|
||||||
|
- Schedule an alarm for 1-2 minutes in the future
|
||||||
|
- Monitor logs: `adb logcat | grep -E "DN|RECEIVE_START|DailyNotification"`
|
||||||
|
- Verify `DN|RECEIVE_START` appears when alarm fires
|
||||||
|
- Verify no `missing_id` error
|
||||||
|
|
||||||
|
3. **Test Different App States**:
|
||||||
|
- App in foreground
|
||||||
|
- App in background
|
||||||
|
- App force-closed
|
||||||
|
- Device in doze mode (if possible on emulator)
|
||||||
|
|
||||||
|
4. **Compare with Manual Broadcast**:
|
||||||
|
- Manual broadcast works → receiver is fine
|
||||||
|
- Alarm broadcast doesn't work → PendingIntent creation is the issue
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files to Check in Plugin
|
||||||
|
|
||||||
|
1. **Alarm Scheduling Code**: Where `setAlarmClock()` or `setExact()` is called
|
||||||
|
2. **PendingIntent Creation**: Where `PendingIntent.getBroadcast()` is called
|
||||||
|
3. **Intent Creation**: Where the Intent for the alarm is created
|
||||||
|
4. **Receiver Code**: Verify what Intent extras it expects (for missing_id fix)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Related Configuration
|
||||||
|
|
||||||
|
### AndroidManifest.xml (App Side)
|
||||||
|
- ✅ Receiver exported="true"
|
||||||
|
- ✅ Correct intent action
|
||||||
|
- ✅ Receiver inside application tag
|
||||||
|
|
||||||
|
### Permissions (App Side)
|
||||||
|
- ✅ POST_NOTIFICATIONS
|
||||||
|
- ✅ SCHEDULE_EXACT_ALARM
|
||||||
|
- ✅ RECEIVE_BOOT_COMPLETED
|
||||||
|
- ✅ WAKE_LOCK
|
||||||
|
- ❌ USE_EXACT_ALARM -- must not use; see note below
|
||||||
|
|
||||||
|
> **Note on `USE_EXACT_ALARM`:** The `USE_EXACT_ALARM` permission is restricted
|
||||||
|
> by Google on Android. Apps that declare it must be primarily dedicated to alarm
|
||||||
|
> or calendar functionality. Google will reject apps from the Play Store that use
|
||||||
|
> this permission for other purposes. This plugin uses `SCHEDULE_EXACT_ALARM`
|
||||||
|
> instead, which is sufficient for scheduling daily notifications.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Expected Behavior After Fix
|
||||||
|
|
||||||
|
When an alarm fires:
|
||||||
|
1. AlarmManager delivers the broadcast
|
||||||
|
2. `DailyNotificationReceiver.onReceive()` is called
|
||||||
|
3. Log shows: `DN|RECEIVE_START action=org.timesafari.daily.NOTIFICATION`
|
||||||
|
4. Receiver finds the ID in Intent extras (no `missing_id` error)
|
||||||
|
5. Notification is displayed
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- The `exported="true"` change in the app's manifest was necessary and correct
|
||||||
|
- The issue is in the plugin's PendingIntent creation, not the app configuration
|
||||||
|
- Manual broadcasts work, proving the receiver registration is correct
|
||||||
|
- Alarms fire, proving AlarmManager scheduling is correct
|
||||||
|
- The gap is in the PendingIntent → Receiver delivery
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Reference: Working Manual Test
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# This works - receiver is triggered
|
||||||
|
adb shell am broadcast \
|
||||||
|
-a org.timesafari.daily.NOTIFICATION \
|
||||||
|
-n app.timesafari.app/org.timesafari.dailynotification.DailyNotificationReceiver \
|
||||||
|
--es "id" "timesafari_daily_reminder"
|
||||||
|
```
|
||||||
|
|
||||||
|
The plugin's PendingIntent should create an equivalent broadcast that AlarmManager can deliver.
|
||||||
312
doc/daily-notification-plugin-architecture.md
Normal file
312
doc/daily-notification-plugin-architecture.md
Normal file
@@ -0,0 +1,312 @@
|
|||||||
|
# Daily Notification Plugin - Architecture Overview
|
||||||
|
|
||||||
|
## System Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Vue Components │
|
||||||
|
│ (PushNotificationPermission.vue, AccountViewView.vue, etc.) │
|
||||||
|
└───────────────────────────┬─────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ NotificationService (Factory) │
|
||||||
|
│ - Platform detection via Capacitor API │
|
||||||
|
│ - Singleton pattern │
|
||||||
|
│ - Returns appropriate implementation │
|
||||||
|
└───────────────────────────┬─────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
┌───────────┴────────────┐
|
||||||
|
▼ ▼
|
||||||
|
┌───────────────────────────┐ ┌────────────────────────────┐
|
||||||
|
│ NativeNotificationService │ │ WebPushNotificationService │
|
||||||
|
│ │ │ │
|
||||||
|
│ iOS/Android │ │ Web/PWA │
|
||||||
|
│ - UNUserNotificationCenter│ │ - Web Push API │
|
||||||
|
│ - NotificationManager │ │ - Service Workers │
|
||||||
|
│ - AlarmManager │ │ - VAPID keys │
|
||||||
|
│ - Background tasks │ │ - Push server │
|
||||||
|
└─────────────┬─────────────┘ └────────────┬───────────────┘
|
||||||
|
│ │
|
||||||
|
▼ ▼
|
||||||
|
┌─────────────────────────┐ ┌──────────────────────────────┐
|
||||||
|
│ DailyNotificationPlugin│ │ Existing Web Push Logic │
|
||||||
|
│ (Capacitor Plugin) │ │ (PushNotificationPermission)│
|
||||||
|
│ │ │ │
|
||||||
|
│ - Native iOS code │ │ - Service worker │
|
||||||
|
│ - Native Android code │ │ - VAPID subscription │
|
||||||
|
│ - SQLite storage │ │ - Push server integration │
|
||||||
|
└─────────────────────────┘ └──────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Platform Decision Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
User Action: Schedule Notification
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
NotificationService.getInstance()
|
||||||
|
│
|
||||||
|
├──> Check: Capacitor.isNativePlatform()
|
||||||
|
│
|
||||||
|
┌────┴─────┐
|
||||||
|
│ │
|
||||||
|
YES NO
|
||||||
|
│ │
|
||||||
|
▼ ▼
|
||||||
|
Native Web/PWA
|
||||||
|
Service Service
|
||||||
|
│ │
|
||||||
|
▼ ▼
|
||||||
|
Plugin Web Push
|
||||||
|
```
|
||||||
|
|
||||||
|
## Data Flow Example: Scheduling a Notification
|
||||||
|
|
||||||
|
### Native Platform (iOS/Android)
|
||||||
|
```
|
||||||
|
1. User clicks "Enable Notifications"
|
||||||
|
│
|
||||||
|
2. PushNotificationPermission.vue
|
||||||
|
│
|
||||||
|
└─> NotificationService.getInstance()
|
||||||
|
│
|
||||||
|
└─> Returns NativeNotificationService (detected iOS/Android)
|
||||||
|
│
|
||||||
|
└─> nativeService.requestPermissions()
|
||||||
|
│
|
||||||
|
└─> DailyNotification.requestPermissions() [Capacitor Plugin]
|
||||||
|
│
|
||||||
|
└─> Native code requests OS permissions
|
||||||
|
│
|
||||||
|
└─> Returns: { granted: true/false }
|
||||||
|
|
||||||
|
3. User sets time & message
|
||||||
|
│
|
||||||
|
4. nativeService.scheduleDailyNotification({ time: '09:00', ... })
|
||||||
|
│
|
||||||
|
└─> DailyNotification.scheduleDailyReminder({ ... })
|
||||||
|
│
|
||||||
|
└─> Native code:
|
||||||
|
- Stores in SQLite
|
||||||
|
- Schedules AlarmManager (Android) or UNNotificationRequest (iOS)
|
||||||
|
- Returns: success/failure
|
||||||
|
|
||||||
|
5. At 9:00 AM:
|
||||||
|
- Android: AlarmManager triggers → DailyNotificationReceiver
|
||||||
|
- iOS: UNUserNotificationCenter triggers notification
|
||||||
|
- Notification appears even if app is closed
|
||||||
|
```
|
||||||
|
|
||||||
|
### Web Platform
|
||||||
|
```
|
||||||
|
1. User clicks "Enable Notifications"
|
||||||
|
│
|
||||||
|
2. PushNotificationPermission.vue
|
||||||
|
│
|
||||||
|
└─> NotificationService.getInstance()
|
||||||
|
│
|
||||||
|
└─> Returns WebPushNotificationService (detected web)
|
||||||
|
│
|
||||||
|
└─> webService.requestPermissions()
|
||||||
|
│
|
||||||
|
└─> Notification.requestPermission() [Browser API]
|
||||||
|
│
|
||||||
|
└─> Returns: 'granted'/'denied'/'default'
|
||||||
|
|
||||||
|
3. User sets time & message
|
||||||
|
│
|
||||||
|
4. webService.scheduleDailyNotification({ ... })
|
||||||
|
│
|
||||||
|
└─> [TODO] Subscribe to push service with VAPID
|
||||||
|
│
|
||||||
|
└─> Send subscription to server with schedule time
|
||||||
|
│
|
||||||
|
└─> Server sends push at scheduled time
|
||||||
|
│
|
||||||
|
└─> Service worker receives → shows notification
|
||||||
|
```
|
||||||
|
|
||||||
|
## File Organization
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── plugins/
|
||||||
|
│ └── DailyNotificationPlugin.ts [Plugin registration]
|
||||||
|
│
|
||||||
|
├── services/
|
||||||
|
│ └── notifications/
|
||||||
|
│ ├── index.ts [Barrel export]
|
||||||
|
│ ├── NotificationService.ts [Factory + Interface]
|
||||||
|
│ ├── NativeNotificationService.ts [iOS/Android impl]
|
||||||
|
│ └── WebPushNotificationService.ts [Web impl stub]
|
||||||
|
│
|
||||||
|
├── components/
|
||||||
|
│ └── PushNotificationPermission.vue [UI - to be updated]
|
||||||
|
│
|
||||||
|
└── views/
|
||||||
|
└── AccountViewView.vue [Settings UI]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Design Decisions
|
||||||
|
|
||||||
|
### 1. **Unified Interface**
|
||||||
|
- Single `NotificationServiceInterface` for all platforms
|
||||||
|
- Consistent API regardless of underlying implementation
|
||||||
|
- Type-safe across TypeScript codebase
|
||||||
|
|
||||||
|
### 2. **Runtime Platform Detection**
|
||||||
|
- No build-time configuration needed
|
||||||
|
- Same code bundle for all platforms
|
||||||
|
- Factory pattern selects implementation automatically
|
||||||
|
|
||||||
|
### 3. **Coexistence Strategy**
|
||||||
|
- Web Push and Native run on different platforms
|
||||||
|
- No conflicts - mutually exclusive at runtime
|
||||||
|
- Allows gradual migration and testing
|
||||||
|
|
||||||
|
### 4. **Singleton Pattern**
|
||||||
|
- One service instance per app lifecycle
|
||||||
|
- Efficient resource usage
|
||||||
|
- Consistent state management
|
||||||
|
|
||||||
|
## Permission Flow
|
||||||
|
|
||||||
|
### Android
|
||||||
|
```
|
||||||
|
App Launch
|
||||||
|
↓
|
||||||
|
Check if POST_NOTIFICATIONS granted (API 33+)
|
||||||
|
│
|
||||||
|
├─> YES: Ready to schedule
|
||||||
|
│
|
||||||
|
└─> NO: Request runtime permission
|
||||||
|
↓
|
||||||
|
Show system dialog
|
||||||
|
↓
|
||||||
|
User grants/denies
|
||||||
|
↓
|
||||||
|
Schedule notifications (if granted)
|
||||||
|
```
|
||||||
|
|
||||||
|
### iOS
|
||||||
|
```
|
||||||
|
App Launch
|
||||||
|
↓
|
||||||
|
Check notification authorization status
|
||||||
|
│
|
||||||
|
├─> authorized: Ready to schedule
|
||||||
|
│
|
||||||
|
├─> notDetermined: Request permission
|
||||||
|
│ ↓
|
||||||
|
│ Show system dialog
|
||||||
|
│ ↓
|
||||||
|
│ User grants/denies
|
||||||
|
│
|
||||||
|
└─> denied: Guide user to Settings
|
||||||
|
```
|
||||||
|
|
||||||
|
### Web
|
||||||
|
```
|
||||||
|
App Load
|
||||||
|
↓
|
||||||
|
Check Notification.permission
|
||||||
|
│
|
||||||
|
├─> "granted": Ready to subscribe
|
||||||
|
│
|
||||||
|
├─> "default": Request permission
|
||||||
|
│ ↓
|
||||||
|
│ Show browser prompt
|
||||||
|
│ ↓
|
||||||
|
│ User grants/denies
|
||||||
|
│
|
||||||
|
└─> "denied": Cannot show notifications
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Handling Strategy
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// All methods return promises with success/failure
|
||||||
|
try {
|
||||||
|
const granted = await service.requestPermissions();
|
||||||
|
if (granted) {
|
||||||
|
const success = await service.scheduleDailyNotification({...});
|
||||||
|
if (success) {
|
||||||
|
// Show success message
|
||||||
|
} else {
|
||||||
|
// Show scheduling error
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Show permission denied message
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Log error and show generic error message
|
||||||
|
logger.error('Notification error:', error);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Background Execution
|
||||||
|
|
||||||
|
### Native (iOS/Android)
|
||||||
|
- ✅ Full background support
|
||||||
|
- ✅ Survives app termination
|
||||||
|
- ✅ Survives device reboot (with BootReceiver)
|
||||||
|
- ✅ Exact alarm scheduling
|
||||||
|
- ✅ Works offline
|
||||||
|
|
||||||
|
### Web/PWA
|
||||||
|
- ⚠️ Limited background support
|
||||||
|
- ⚠️ Requires active service worker
|
||||||
|
- ⚠️ Browser/OS dependent
|
||||||
|
- ❌ Needs network for delivery
|
||||||
|
- ⚠️ iOS: Only on Home Screen PWAs (16.4+)
|
||||||
|
|
||||||
|
## Storage
|
||||||
|
|
||||||
|
### Native
|
||||||
|
```
|
||||||
|
DailyNotificationPlugin
|
||||||
|
↓
|
||||||
|
SQLite Database (Room/Core Data)
|
||||||
|
↓
|
||||||
|
Stores:
|
||||||
|
- Schedule configurations
|
||||||
|
- Content cache
|
||||||
|
- Delivery history
|
||||||
|
- Callback registrations
|
||||||
|
```
|
||||||
|
|
||||||
|
### Web
|
||||||
|
```
|
||||||
|
Web Push
|
||||||
|
↓
|
||||||
|
IndexedDB (via Dexie)
|
||||||
|
↓
|
||||||
|
Stores:
|
||||||
|
- Settings (notifyingNewActivityTime, etc.)
|
||||||
|
- Push subscription info
|
||||||
|
- VAPID keys
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
### Unit Testing
|
||||||
|
- Mock `Capacitor.isNativePlatform()` to test both paths
|
||||||
|
- Test factory returns correct implementation
|
||||||
|
- Test each service implementation independently
|
||||||
|
|
||||||
|
### Integration Testing
|
||||||
|
- Test on actual devices (iOS/Android)
|
||||||
|
- Test in browsers (Chrome, Safari, Firefox)
|
||||||
|
- Verify notification delivery
|
||||||
|
- Test permission flows
|
||||||
|
|
||||||
|
### E2E Testing
|
||||||
|
- Schedule notification → Wait → Verify delivery
|
||||||
|
- Test app restart scenarios
|
||||||
|
- Test device reboot scenarios
|
||||||
|
- Test permission denial recovery
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Key Takeaway**: The architecture provides a clean separation between platforms while maintaining a unified API for Vue components. Platform detection happens automatically at runtime, and the appropriate notification system is used transparently.
|
||||||
348
doc/daily-notification-plugin-checklist.md
Normal file
348
doc/daily-notification-plugin-checklist.md
Normal file
@@ -0,0 +1,348 @@
|
|||||||
|
# Daily Notification Plugin - Integration Checklist
|
||||||
|
|
||||||
|
**Integration Date**: 2026-01-21
|
||||||
|
**Plugin Version**: 1.0.11
|
||||||
|
**Status**: Phase 1 Complete ✅
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1: Infrastructure Setup ✅ COMPLETE
|
||||||
|
|
||||||
|
### Code Files
|
||||||
|
- [x] Created `src/plugins/DailyNotificationPlugin.ts`
|
||||||
|
- [x] Created `src/services/notifications/NotificationService.ts`
|
||||||
|
- [x] Created `src/services/notifications/NativeNotificationService.ts`
|
||||||
|
- [x] Created `src/services/notifications/WebPushNotificationService.ts`
|
||||||
|
- [x] Created `src/services/notifications/index.ts`
|
||||||
|
|
||||||
|
### Android Configuration
|
||||||
|
|
||||||
|
> **Note on `USE_EXACT_ALARM`:** The `USE_EXACT_ALARM` permission is restricted
|
||||||
|
> by Google on Android. Apps that declare it must be primarily dedicated to alarm
|
||||||
|
> or calendar functionality. Google will reject apps from the Play Store that use
|
||||||
|
> this permission for other purposes. This plugin uses `SCHEDULE_EXACT_ALARM`
|
||||||
|
> instead, which is sufficient for scheduling daily notifications.
|
||||||
|
|
||||||
|
- [x] Added permissions to `AndroidManifest.xml`:
|
||||||
|
- [x] `POST_NOTIFICATIONS`
|
||||||
|
- [x] `SCHEDULE_EXACT_ALARM`
|
||||||
|
- [x] `RECEIVE_BOOT_COMPLETED`
|
||||||
|
- [x] `WAKE_LOCK`
|
||||||
|
- [ ] `USE_EXACT_ALARM` -- must avoid; see note above
|
||||||
|
- [x] Registered receivers in `AndroidManifest.xml`:
|
||||||
|
- [x] `DailyNotificationReceiver`
|
||||||
|
- [x] `BootReceiver`
|
||||||
|
- [x] Added dependencies to `build.gradle`:
|
||||||
|
- [x] Room (`androidx.room:room-runtime:2.6.1`)
|
||||||
|
- [x] WorkManager (`androidx.work:work-runtime-ktx:2.9.0`)
|
||||||
|
- [x] Coroutines (`org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3`)
|
||||||
|
- [x] Room Compiler (`androidx.room:room-compiler:2.6.1`)
|
||||||
|
- [x] Registered plugin in `MainActivity.java`
|
||||||
|
|
||||||
|
### iOS Configuration
|
||||||
|
- [x] Added to `Info.plist`:
|
||||||
|
- [x] `UIBackgroundModes` (fetch, processing)
|
||||||
|
- [x] `BGTaskSchedulerPermittedIdentifiers`
|
||||||
|
- [x] `NSUserNotificationAlertStyle`
|
||||||
|
- [ ] ⚠️ **MANUAL STEP**: Xcode capabilities (see Phase 5)
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
- [x] Created `doc/daily-notification-plugin-integration.md`
|
||||||
|
- [x] Created `doc/daily-notification-plugin-integration-summary.md`
|
||||||
|
- [x] Created `doc/daily-notification-plugin-architecture.md`
|
||||||
|
- [x] Created this checklist
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: UI Integration ⏳ TODO
|
||||||
|
|
||||||
|
### Update Components
|
||||||
|
- [ ] Modify `PushNotificationPermission.vue`:
|
||||||
|
- [ ] Import `NotificationService`
|
||||||
|
- [ ] Replace direct web push calls with service methods
|
||||||
|
- [ ] Add platform-aware messaging
|
||||||
|
- [ ] Test permission flow
|
||||||
|
- [ ] Test notification scheduling
|
||||||
|
|
||||||
|
### Update Views
|
||||||
|
- [ ] Update `AccountViewView.vue`:
|
||||||
|
- [ ] Use `NotificationService` for status checks
|
||||||
|
- [ ] Add platform indicator
|
||||||
|
- [ ] Test settings display
|
||||||
|
|
||||||
|
### Settings Integration
|
||||||
|
- [ ] Verify settings save/load correctly:
|
||||||
|
- [ ] `notifyingNewActivityTime` for native
|
||||||
|
- [ ] `notifyingReminderMessage` for native
|
||||||
|
- [ ] `notifyingReminderTime` for native
|
||||||
|
- [ ] Existing web push settings preserved
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: Web Push Integration ⏳ TODO
|
||||||
|
|
||||||
|
### Wire WebPushNotificationService
|
||||||
|
- [ ] Extract subscription logic from `PushNotificationPermission.vue`
|
||||||
|
- [ ] Implement `scheduleDailyNotification()` method
|
||||||
|
- [ ] Implement `cancelDailyNotification()` method
|
||||||
|
- [ ] Implement `getStatus()` method
|
||||||
|
- [ ] Test web platform notification flow
|
||||||
|
|
||||||
|
### Server Integration
|
||||||
|
- [ ] Verify web push server endpoints still work
|
||||||
|
- [ ] Test subscription/unsubscription
|
||||||
|
- [ ] Test scheduled message delivery
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: Testing ⏳ TODO
|
||||||
|
|
||||||
|
### Desktop Development
|
||||||
|
- [ ] Code compiles without errors
|
||||||
|
- [ ] ESLint passes
|
||||||
|
- [ ] TypeScript types are correct
|
||||||
|
- [ ] Platform detection works in browser console
|
||||||
|
|
||||||
|
### Android Emulator
|
||||||
|
- [ ] App builds successfully
|
||||||
|
- [ ] Plugin loads without errors
|
||||||
|
- [ ] Can open app and navigate
|
||||||
|
- [ ] No JavaScript console errors
|
||||||
|
|
||||||
|
### Android Device (Real)
|
||||||
|
- [ ] Request permissions dialog appears
|
||||||
|
- [ ] Permissions can be granted
|
||||||
|
- [ ] Schedule notification succeeds
|
||||||
|
- [ ] Notification appears at scheduled time
|
||||||
|
- [ ] Notification survives app close
|
||||||
|
- [ ] Notification survives device reboot
|
||||||
|
- [ ] Notification can be cancelled
|
||||||
|
|
||||||
|
### iOS Simulator
|
||||||
|
- [ ] App builds successfully
|
||||||
|
- [ ] Plugin loads without errors
|
||||||
|
- [ ] Can open app and navigate
|
||||||
|
- [ ] No JavaScript console errors
|
||||||
|
|
||||||
|
### iOS Device (Real)
|
||||||
|
- [ ] Request permissions dialog appears
|
||||||
|
- [ ] Permissions can be granted
|
||||||
|
- [ ] Schedule notification succeeds
|
||||||
|
- [ ] Notification appears at scheduled time
|
||||||
|
- [ ] Background fetch works
|
||||||
|
- [ ] Notification survives app close
|
||||||
|
- [ ] Notification can be cancelled
|
||||||
|
|
||||||
|
### Web Browser
|
||||||
|
- [ ] Existing web push still works
|
||||||
|
- [ ] No JavaScript errors
|
||||||
|
- [ ] Platform detection selects web service
|
||||||
|
- [ ] Permission flow works
|
||||||
|
- [ ] Subscription works
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: iOS Xcode Setup ⚠️ MANUAL REQUIRED
|
||||||
|
|
||||||
|
### Open Xcode Project
|
||||||
|
```bash
|
||||||
|
cd ios
|
||||||
|
open App/App.xcodeproj
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configure Capabilities
|
||||||
|
- [ ] Select "App" target in project navigator
|
||||||
|
- [ ] Go to "Signing & Capabilities" tab
|
||||||
|
- [ ] Click "+ Capability" button
|
||||||
|
- [ ] Add "Background Modes":
|
||||||
|
- [ ] Enable "Background fetch"
|
||||||
|
- [ ] Enable "Background processing"
|
||||||
|
- [ ] Click "+ Capability" button again
|
||||||
|
- [ ] Add "Push Notifications" (if using remote notifications)
|
||||||
|
|
||||||
|
### Install CocoaPods
|
||||||
|
```bash
|
||||||
|
cd ios
|
||||||
|
pod install
|
||||||
|
cd ..
|
||||||
|
```
|
||||||
|
- [ ] Run `pod install` successfully
|
||||||
|
- [ ] Verify `CapacitorDailyNotification` pod is installed
|
||||||
|
|
||||||
|
### Verify Configuration
|
||||||
|
- [ ] Build succeeds in Xcode
|
||||||
|
- [ ] No capability warnings
|
||||||
|
- [ ] No pod errors
|
||||||
|
- [ ] Can run on simulator
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6: Build & Deploy ⏳ TODO
|
||||||
|
|
||||||
|
### Sync Capacitor
|
||||||
|
```bash
|
||||||
|
npx cap sync
|
||||||
|
```
|
||||||
|
- [ ] Sync completes without errors
|
||||||
|
- [ ] Plugin files copied to native projects
|
||||||
|
|
||||||
|
### Build Android
|
||||||
|
```bash
|
||||||
|
npm run build:android:debug
|
||||||
|
```
|
||||||
|
- [ ] Build succeeds
|
||||||
|
- [ ] APK/AAB generated
|
||||||
|
- [ ] Can install on device/emulator
|
||||||
|
|
||||||
|
### Build iOS
|
||||||
|
```bash
|
||||||
|
npm run build:ios:debug
|
||||||
|
```
|
||||||
|
- [ ] Build succeeds
|
||||||
|
- [ ] IPA generated (if release)
|
||||||
|
- [ ] Can install on device/simulator
|
||||||
|
|
||||||
|
### Test Production Builds
|
||||||
|
- [ ] Android release build works
|
||||||
|
- [ ] iOS release build works
|
||||||
|
- [ ] Notifications work in production
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting Checklist
|
||||||
|
|
||||||
|
### Android Issues
|
||||||
|
|
||||||
|
#### Notifications Not Appearing
|
||||||
|
- [ ] Verified `DailyNotificationReceiver` is in AndroidManifest.xml
|
||||||
|
- [ ] Checked logcat for errors: `adb logcat | grep DailyNotification`
|
||||||
|
- [ ] Verified permissions granted in app settings
|
||||||
|
- [ ] Checked "Exact alarms" permission (Android 12+)
|
||||||
|
- [ ] Verified notification channel is created
|
||||||
|
|
||||||
|
#### Build Errors
|
||||||
|
- [ ] Verified all dependencies in build.gradle
|
||||||
|
- [ ] Ran `./gradlew clean` and rebuilt
|
||||||
|
- [ ] Verified Kotlin version compatibility
|
||||||
|
- [ ] Checked for conflicting dependencies
|
||||||
|
|
||||||
|
### iOS Issues
|
||||||
|
|
||||||
|
#### Notifications Not Appearing
|
||||||
|
- [ ] Verified Background Modes enabled in Xcode
|
||||||
|
- [ ] Checked Xcode console for errors
|
||||||
|
- [ ] Verified permissions granted in Settings app
|
||||||
|
- [ ] Tested on real device (not just simulator)
|
||||||
|
- [ ] Checked BGTaskScheduler identifiers match Info.plist
|
||||||
|
|
||||||
|
#### Build Errors
|
||||||
|
- [ ] Ran `pod install` successfully
|
||||||
|
- [ ] Verified deployment target is iOS 13.0+
|
||||||
|
- [ ] Checked for pod conflicts
|
||||||
|
- [ ] Cleaned build folder (Xcode → Product → Clean Build Folder)
|
||||||
|
|
||||||
|
### Web Issues
|
||||||
|
|
||||||
|
#### Web Push Not Working
|
||||||
|
- [ ] Verified service worker is registered
|
||||||
|
- [ ] Checked browser console for errors
|
||||||
|
- [ ] Verified VAPID keys are correct
|
||||||
|
- [ ] Tested in supported browser (Chrome 42+, Firefox)
|
||||||
|
- [ ] Checked push server is running
|
||||||
|
|
||||||
|
#### Permission Issues
|
||||||
|
- [ ] Verified permissions not blocked in browser
|
||||||
|
- [ ] Checked site settings in browser
|
||||||
|
- [ ] Verified HTTPS connection (required for web push)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification Commands
|
||||||
|
|
||||||
|
### Check Plugin is Installed
|
||||||
|
```bash
|
||||||
|
npm list @timesafari/daily-notification-plugin
|
||||||
|
```
|
||||||
|
|
||||||
|
### Check Capacitor Sync
|
||||||
|
```bash
|
||||||
|
npx cap ls
|
||||||
|
```
|
||||||
|
|
||||||
|
### Check Android Build
|
||||||
|
```bash
|
||||||
|
cd android
|
||||||
|
./gradlew clean
|
||||||
|
./gradlew assembleDebug
|
||||||
|
```
|
||||||
|
|
||||||
|
### Check iOS Build
|
||||||
|
```bash
|
||||||
|
cd ios
|
||||||
|
pod install
|
||||||
|
xcodebuild -workspace App/App.xcworkspace -scheme App -configuration Debug build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Check TypeScript
|
||||||
|
```bash
|
||||||
|
npm run type-check
|
||||||
|
```
|
||||||
|
|
||||||
|
### Check Linting
|
||||||
|
```bash
|
||||||
|
npm run lint
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Next Immediate Actions
|
||||||
|
|
||||||
|
1. **Run Capacitor Sync**:
|
||||||
|
```bash
|
||||||
|
npx cap sync
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **For iOS Development**:
|
||||||
|
```bash
|
||||||
|
cd ios
|
||||||
|
open App/App.xcodeproj
|
||||||
|
# Enable Background Modes capability
|
||||||
|
pod install
|
||||||
|
cd ..
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Test on Emulator/Simulator**:
|
||||||
|
```bash
|
||||||
|
npm run build:android:debug # For Android
|
||||||
|
npm run build:ios:debug # For iOS
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Update UI Components**:
|
||||||
|
- Start with `PushNotificationPermission.vue`
|
||||||
|
- Import and use `NotificationService`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
- [x] **Phase 1**: All files created and configurations applied
|
||||||
|
- [ ] **Phase 2**: Components use NotificationService
|
||||||
|
- [ ] **Phase 3**: Web push integrated with service
|
||||||
|
- [ ] **Phase 4**: All tests pass on all platforms
|
||||||
|
- [ ] **Phase 5**: iOS capabilities configured in Xcode
|
||||||
|
- [ ] **Phase 6**: Production builds work on real devices
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Questions or Issues?
|
||||||
|
|
||||||
|
See documentation:
|
||||||
|
- Full guide: `doc/daily-notification-plugin-integration.md`
|
||||||
|
- Architecture: `doc/daily-notification-plugin-architecture.md`
|
||||||
|
- Summary: `doc/daily-notification-plugin-integration-summary.md`
|
||||||
|
|
||||||
|
Plugin docs: `node_modules/@timesafari/daily-notification-plugin/README.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Current Status**: Ready for Phase 2 (UI Integration) 🚀
|
||||||
193
doc/daily-notification-plugin-integration-summary.md
Normal file
193
doc/daily-notification-plugin-integration-summary.md
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
# Daily Notification Plugin Integration - Summary
|
||||||
|
|
||||||
|
**Date**: 2026-01-21
|
||||||
|
**Status**: ✅ Phase 1 Complete
|
||||||
|
**Next Phase**: UI Integration
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What Was Completed
|
||||||
|
|
||||||
|
### ✅ Plugin Infrastructure
|
||||||
|
1. **Plugin Registration**: `src/plugins/DailyNotificationPlugin.ts`
|
||||||
|
- Capacitor plugin registered with full TypeScript types
|
||||||
|
- Native-only (iOS/Android)
|
||||||
|
|
||||||
|
2. **Service Abstraction**: `src/services/notifications/`
|
||||||
|
- `NotificationService.ts` - Platform detection & factory
|
||||||
|
- `NativeNotificationService.ts` - Native implementation
|
||||||
|
- `WebPushNotificationService.ts` - Web stub (for future)
|
||||||
|
- `index.ts` - Barrel export
|
||||||
|
|
||||||
|
3. **Android Configuration**:
|
||||||
|
- ✅ Permissions added to `AndroidManifest.xml`
|
||||||
|
- ✅ Receivers registered (DailyNotificationReceiver, BootReceiver)
|
||||||
|
- ✅ Dependencies added to `build.gradle` (Room, WorkManager, Coroutines)
|
||||||
|
- ✅ Plugin registered in `MainActivity.java`
|
||||||
|
|
||||||
|
4. **iOS Configuration**:
|
||||||
|
- ✅ Background modes added to `Info.plist`
|
||||||
|
- ✅ BGTaskScheduler identifiers configured
|
||||||
|
- ⚠️ **Requires manual Xcode setup** (capabilities)
|
||||||
|
|
||||||
|
5. **Documentation**: `doc/daily-notification-plugin-integration.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Platform Support
|
||||||
|
|
||||||
|
| Platform | Notification System | Status |
|
||||||
|
|----------|---------------------|--------|
|
||||||
|
| **iOS** | Native (UNUserNotificationCenter) | ✅ Configured |
|
||||||
|
| **Android** | Native (NotificationManager + AlarmManager) | ✅ Configured |
|
||||||
|
| **Web/PWA** | Web Push (existing) | 🔄 Coexists, not yet wired |
|
||||||
|
| **Electron** | Native (via Capacitor) | ✅ Ready |
|
||||||
|
|
||||||
|
**Key Feature**: Both systems coexist using runtime platform detection.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Start Usage
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { NotificationService } from '@/services/notifications';
|
||||||
|
|
||||||
|
// Automatically uses native on iOS/Android, web push on web
|
||||||
|
const service = NotificationService.getInstance();
|
||||||
|
|
||||||
|
// Request permissions
|
||||||
|
const granted = await service.requestPermissions();
|
||||||
|
|
||||||
|
if (granted) {
|
||||||
|
// Schedule daily notification at 9 AM
|
||||||
|
await service.scheduleDailyNotification({
|
||||||
|
time: '09:00',
|
||||||
|
title: 'Daily Check-In',
|
||||||
|
body: 'Time to check your TimeSafari activity'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check status
|
||||||
|
const status = await service.getStatus();
|
||||||
|
console.log('Notifications enabled:', status.enabled);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
### Immediate (Phase 2)
|
||||||
|
1. **Update UI Components**:
|
||||||
|
- Modify `PushNotificationPermission.vue` to use `NotificationService`
|
||||||
|
- Add platform-aware messaging
|
||||||
|
- Test on simulator/emulator
|
||||||
|
|
||||||
|
2. **iOS Xcode Setup** (Required):
|
||||||
|
```bash
|
||||||
|
cd ios
|
||||||
|
open App/App.xcodeproj
|
||||||
|
```
|
||||||
|
- Enable "Background Modes" capability
|
||||||
|
- Enable "Push Notifications" capability
|
||||||
|
- Run `pod install`
|
||||||
|
|
||||||
|
### Short-term (Phase 3)
|
||||||
|
3. **Wire Web Push**: Connect `WebPushNotificationService` to existing web push logic
|
||||||
|
4. **Test on Devices**: Real iOS and Android devices
|
||||||
|
5. **Update Settings**: Ensure notification preferences save correctly
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Build & Sync
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Sync native projects with web code
|
||||||
|
npx cap sync
|
||||||
|
|
||||||
|
# Build for Android
|
||||||
|
npm run build:android:debug
|
||||||
|
|
||||||
|
# Build for iOS (after Xcode setup)
|
||||||
|
cd ios && pod install && cd ..
|
||||||
|
npm run build:ios:debug
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Important Notes
|
||||||
|
|
||||||
|
### ⚠️ Critical Requirements
|
||||||
|
|
||||||
|
**Android**:
|
||||||
|
- `DailyNotificationReceiver` must be in AndroidManifest.xml (✅ done)
|
||||||
|
- Runtime permissions needed for Android 13+ (API 33+)
|
||||||
|
- Exact alarm permission for Android 12+ (API 31+)
|
||||||
|
|
||||||
|
**iOS**:
|
||||||
|
- Background Modes capability must be enabled in Xcode (⚠️ manual)
|
||||||
|
- BGTaskScheduler identifiers must match Info.plist (✅ done)
|
||||||
|
- Test on real device (simulators have limitations)
|
||||||
|
|
||||||
|
**Web**:
|
||||||
|
- Existing Web Push continues to work unchanged
|
||||||
|
- No conflicts - platform detection ensures correct system
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files Created/Modified
|
||||||
|
|
||||||
|
### Created (8 files)
|
||||||
|
- `src/plugins/DailyNotificationPlugin.ts`
|
||||||
|
- `src/services/notifications/NotificationService.ts`
|
||||||
|
- `src/services/notifications/NativeNotificationService.ts`
|
||||||
|
- `src/services/notifications/WebPushNotificationService.ts`
|
||||||
|
- `src/services/notifications/index.ts`
|
||||||
|
- `doc/daily-notification-plugin-integration.md`
|
||||||
|
- `doc/daily-notification-plugin-integration-summary.md`
|
||||||
|
|
||||||
|
### Modified (4 files)
|
||||||
|
- `android/app/src/main/AndroidManifest.xml` - Permissions + Receivers
|
||||||
|
- `android/app/build.gradle` - Dependencies
|
||||||
|
- `android/app/src/main/java/app/timesafari/MainActivity.java` - Plugin registration
|
||||||
|
- `ios/App/App/Info.plist` - Background modes + BGTaskScheduler
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing Checklist
|
||||||
|
|
||||||
|
### Before Device Testing
|
||||||
|
- [ ] Code compiles without errors
|
||||||
|
- [ ] Platform detection logic verified
|
||||||
|
- [ ] Service factory creates correct implementation
|
||||||
|
|
||||||
|
### Android Device
|
||||||
|
- [ ] Request permissions (Android 13+)
|
||||||
|
- [ ] Schedule notification
|
||||||
|
- [ ] Notification appears at scheduled time
|
||||||
|
- [ ] Notification survives app close
|
||||||
|
- [ ] Notification survives device reboot
|
||||||
|
|
||||||
|
### iOS Device
|
||||||
|
- [ ] Xcode capabilities enabled
|
||||||
|
- [ ] Request permissions
|
||||||
|
- [ ] Schedule notification
|
||||||
|
- [ ] Notification appears at scheduled time
|
||||||
|
- [ ] Background fetch works
|
||||||
|
- [ ] Notification survives app close
|
||||||
|
|
||||||
|
### Web/PWA
|
||||||
|
- [ ] Existing web push still works
|
||||||
|
- [ ] No errors in console
|
||||||
|
- [ ] Platform detection selects web implementation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Questions?
|
||||||
|
|
||||||
|
See full documentation: `doc/daily-notification-plugin-integration.md`
|
||||||
|
|
||||||
|
Plugin README: `node_modules/@timesafari/daily-notification-plugin/README.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Status**: Ready for Phase 2 (UI Integration) 🚀
|
||||||
237
doc/daily-notification-plugin-integration.md
Normal file
237
doc/daily-notification-plugin-integration.md
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
# Daily Notification Plugin Integration
|
||||||
|
|
||||||
|
**Date**: 2026-01-21
|
||||||
|
**Status**: ✅ Phase 1 Complete - Native Infrastructure
|
||||||
|
**Integration Type**: Native + Web Coexistence
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The Daily Notification Plugin has been integrated to provide native notification functionality for iOS and Android while maintaining existing Web Push for web/PWA builds. The integration uses platform detection to automatically select the appropriate notification system at runtime.
|
||||||
|
|
||||||
|
## What Was Implemented
|
||||||
|
|
||||||
|
### 1. **Plugin Registration** ✅
|
||||||
|
- **File**: `src/plugins/DailyNotificationPlugin.ts`
|
||||||
|
- Registered Capacitor plugin with proper TypeScript types
|
||||||
|
- Native-only (no web implementation)
|
||||||
|
|
||||||
|
### 2. **Service Abstraction Layer** ✅
|
||||||
|
Created unified notification service with platform-specific implementations:
|
||||||
|
|
||||||
|
- **`NotificationService.ts`**: Factory that selects implementation based on platform
|
||||||
|
- **`NativeNotificationService.ts`**: Wraps DailyNotificationPlugin for iOS/Android
|
||||||
|
- **`WebPushNotificationService.ts`**: Stub for future Web Push integration
|
||||||
|
|
||||||
|
**Location**: `src/services/notifications/`
|
||||||
|
|
||||||
|
**Key Features**:
|
||||||
|
- Unified interface (`NotificationServiceInterface`)
|
||||||
|
- Automatic platform detection via `Capacitor.isNativePlatform()`
|
||||||
|
- Type-safe implementation
|
||||||
|
- Singleton pattern for efficiency
|
||||||
|
|
||||||
|
### 3. **Android Configuration** ✅
|
||||||
|
|
||||||
|
**Modified Files**:
|
||||||
|
- `android/app/src/main/AndroidManifest.xml`
|
||||||
|
- `android/app/build.gradle`
|
||||||
|
- `android/app/src/main/java/app/timesafari/MainActivity.java`
|
||||||
|
|
||||||
|
**Changes**:
|
||||||
|
- ✅ Added notification permissions (POST_NOTIFICATIONS, SCHEDULE_EXACT_ALARM, etc.)
|
||||||
|
- ✅ Registered `DailyNotificationReceiver` (critical for alarm delivery)
|
||||||
|
- ✅ Registered `BootReceiver` (restores schedules after device restart)
|
||||||
|
- ✅ Added Room, WorkManager, and Coroutines dependencies
|
||||||
|
- ✅ Registered plugin in MainActivity
|
||||||
|
|
||||||
|
### 4. **iOS Configuration** ✅
|
||||||
|
|
||||||
|
**Modified Files**:
|
||||||
|
- `ios/App/App/Info.plist`
|
||||||
|
|
||||||
|
**Changes**:
|
||||||
|
- ✅ Added `UIBackgroundModes` (fetch, processing)
|
||||||
|
- ✅ Added `BGTaskSchedulerPermittedIdentifiers` for background tasks
|
||||||
|
- ✅ Added `NSUserNotificationAlertStyle` for alert-style notifications
|
||||||
|
|
||||||
|
**Still Required** (Manual in Xcode):
|
||||||
|
- ⚠️ Enable "Background Modes" capability in Xcode
|
||||||
|
- Background fetch
|
||||||
|
- Background processing
|
||||||
|
- ⚠️ Enable "Push Notifications" capability (if using remote notifications)
|
||||||
|
|
||||||
|
## Platform Behavior
|
||||||
|
|
||||||
|
| Platform | Implementation | Status |
|
||||||
|
|----------|---------------|--------|
|
||||||
|
| **iOS** | DailyNotificationPlugin (native) | ✅ Configured |
|
||||||
|
| **Android** | DailyNotificationPlugin (native) | ✅ Configured |
|
||||||
|
| **Web/PWA** | Web Push (existing) | 🔄 Not yet wired up |
|
||||||
|
| **Electron** | Would use native | ✅ Ready |
|
||||||
|
|
||||||
|
## Usage Example
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { NotificationService } from '@/services/notifications/NotificationService';
|
||||||
|
|
||||||
|
// Get the appropriate service for current platform
|
||||||
|
const notificationService = NotificationService.getInstance();
|
||||||
|
|
||||||
|
// Check platform
|
||||||
|
console.log('Platform:', NotificationService.getPlatform());
|
||||||
|
console.log('Is native:', NotificationService.isNative());
|
||||||
|
|
||||||
|
// Request permissions
|
||||||
|
const granted = await notificationService.requestPermissions();
|
||||||
|
|
||||||
|
if (granted) {
|
||||||
|
// Schedule daily notification
|
||||||
|
await notificationService.scheduleDailyNotification({
|
||||||
|
time: '09:00',
|
||||||
|
title: 'Daily Check-In',
|
||||||
|
body: 'Time to check your TimeSafari activity',
|
||||||
|
priority: 'normal'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check status
|
||||||
|
const status = await notificationService.getStatus();
|
||||||
|
console.log('Enabled:', status.enabled);
|
||||||
|
console.log('Time:', status.scheduledTime);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
### Phase 2: UI Integration
|
||||||
|
- [ ] Update `PushNotificationPermission.vue` to use `NotificationService`
|
||||||
|
- [ ] Add platform-aware UI messaging
|
||||||
|
- [ ] Update settings storage to work with both systems
|
||||||
|
- [ ] Test notification scheduling UI
|
||||||
|
|
||||||
|
### Phase 3: Web Push Integration
|
||||||
|
- [ ] Wire `WebPushNotificationService` to existing PushNotificationPermission logic
|
||||||
|
- [ ] Extract web push subscription code into service methods
|
||||||
|
- [ ] Test web platform notification flow
|
||||||
|
|
||||||
|
### Phase 4: Testing & Polish
|
||||||
|
- [ ] Test on real iOS device
|
||||||
|
- [ ] Test on real Android device (API 23+, API 33+)
|
||||||
|
- [ ] Test permission flows
|
||||||
|
- [ ] Test notification delivery
|
||||||
|
- [ ] Test app restart/reboot scenarios
|
||||||
|
- [ ] Verify background notification delivery
|
||||||
|
|
||||||
|
### Phase 5: Xcode Configuration (iOS Only)
|
||||||
|
- [ ] Open `ios/App/App.xcodeproj` in Xcode
|
||||||
|
- [ ] Select App target → Signing & Capabilities
|
||||||
|
- [ ] Click "+ Capability" → Add "Background Modes"
|
||||||
|
- Enable "Background fetch"
|
||||||
|
- Enable "Background processing"
|
||||||
|
- [ ] Click "+ Capability" → Add "Push Notifications" (if using remote)
|
||||||
|
- [ ] Run `pod install` in `ios/` directory
|
||||||
|
- [ ] Build and test on device
|
||||||
|
|
||||||
|
## Build Commands
|
||||||
|
|
||||||
|
### Sync Capacitor
|
||||||
|
```bash
|
||||||
|
npx cap sync
|
||||||
|
# or
|
||||||
|
npx cap sync android
|
||||||
|
npx cap sync ios
|
||||||
|
```
|
||||||
|
|
||||||
|
### Build Android
|
||||||
|
```bash
|
||||||
|
npm run build:android
|
||||||
|
# or
|
||||||
|
npm run build:android:debug
|
||||||
|
```
|
||||||
|
|
||||||
|
### Build iOS
|
||||||
|
```bash
|
||||||
|
npm run build:ios
|
||||||
|
# or after Xcode setup:
|
||||||
|
cd ios && pod install && cd ..
|
||||||
|
npm run build:ios:debug
|
||||||
|
```
|
||||||
|
|
||||||
|
## Important Notes
|
||||||
|
|
||||||
|
### Android
|
||||||
|
- **Critical**: `DailyNotificationReceiver` must be in AndroidManifest.xml
|
||||||
|
- Android 12+ (API 31+) requires `SCHEDULE_EXACT_ALARM` permission
|
||||||
|
- Android 13+ (API 33+) requires runtime `POST_NOTIFICATIONS` permission
|
||||||
|
- BootReceiver restores schedules after device restart
|
||||||
|
|
||||||
|
### iOS
|
||||||
|
- **Critical**: Background modes must be enabled in Xcode capabilities
|
||||||
|
- iOS 13.0+ supported (already compatible with your deployment target)
|
||||||
|
- Background tasks use `BGTaskScheduler`
|
||||||
|
- User must grant notification permissions in Settings
|
||||||
|
|
||||||
|
### Web
|
||||||
|
- Existing Web Push continues to work
|
||||||
|
- No conflicts with native implementation
|
||||||
|
- Platform detection ensures correct system is used
|
||||||
|
|
||||||
|
## Files Modified
|
||||||
|
|
||||||
|
### Created
|
||||||
|
- `src/plugins/DailyNotificationPlugin.ts`
|
||||||
|
- `src/services/notifications/NotificationService.ts`
|
||||||
|
- `src/services/notifications/NativeNotificationService.ts`
|
||||||
|
- `src/services/notifications/WebPushNotificationService.ts`
|
||||||
|
|
||||||
|
### Modified
|
||||||
|
- `android/app/src/main/AndroidManifest.xml`
|
||||||
|
- `android/app/build.gradle`
|
||||||
|
- `android/app/src/main/java/app/timesafari/MainActivity.java`
|
||||||
|
- `ios/App/App/Info.plist`
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Android: Notifications Not Appearing
|
||||||
|
1. Check that `DailyNotificationReceiver` is registered in AndroidManifest.xml
|
||||||
|
2. Verify permissions are requested at runtime (Android 13+)
|
||||||
|
3. Check that notification channel is created
|
||||||
|
4. Enable "Exact alarms" in app settings (Android 12+)
|
||||||
|
|
||||||
|
### iOS: Background Tasks Not Running
|
||||||
|
1. Ensure Background Modes capability is enabled in Xcode
|
||||||
|
2. Check that BGTaskScheduler identifiers match Info.plist
|
||||||
|
3. Test on real device (simulator has limitations)
|
||||||
|
4. Check iOS Settings → Notifications → TimeSafari
|
||||||
|
|
||||||
|
### Permission Issues
|
||||||
|
1. Request permissions before scheduling: `requestPermissions()`
|
||||||
|
2. Check permission status: `checkPermissions()`
|
||||||
|
3. Guide users to system settings if denied
|
||||||
|
|
||||||
|
## Plugin Documentation
|
||||||
|
|
||||||
|
For complete plugin documentation, see:
|
||||||
|
- Plugin README: `node_modules/@timesafari/daily-notification-plugin/README.md`
|
||||||
|
- Plugin version: 1.0.11
|
||||||
|
- Repository: https://gitea.anomalistdesign.com/trent_larson/daily-notification-plugin
|
||||||
|
|
||||||
|
## Testing Checklist
|
||||||
|
|
||||||
|
- [ ] Android: Notification appears at scheduled time
|
||||||
|
- [ ] Android: Notification survives app close
|
||||||
|
- [ ] Android: Notification survives device reboot
|
||||||
|
- [ ] iOS: Notification appears at scheduled time
|
||||||
|
- [ ] iOS: Background fetch works
|
||||||
|
- [ ] iOS: Notification survives app close
|
||||||
|
- [ ] Web: Existing web push still works
|
||||||
|
- [ ] Platform detection works correctly
|
||||||
|
- [ ] Permission requests work on all platforms
|
||||||
|
- [ ] Status retrieval works correctly
|
||||||
|
|
||||||
|
## Current Status
|
||||||
|
|
||||||
|
✅ **Phase 1 Complete**: Native infrastructure configured
|
||||||
|
🔄 **Phase 2 In Progress**: Ready for UI integration
|
||||||
|
⏳ **Phase 3 Pending**: Web Push service integration
|
||||||
|
⏳ **Phase 4 Pending**: Testing and validation
|
||||||
|
⏳ **Phase 5 Pending**: Xcode capabilities setup
|
||||||
@@ -80,7 +80,7 @@ installed by each developer. They are not automatically active.
|
|||||||
- Test files: `*.test.js`, `*.spec.ts`, `*.test.vue`
|
- Test files: `*.test.js`, `*.spec.ts`, `*.test.vue`
|
||||||
- Scripts: `scripts/` directory
|
- Scripts: `scripts/` directory
|
||||||
- Test directories: `test-*` directories
|
- Test directories: `test-*` directories
|
||||||
- Documentation: `docs/`, `*.md`, `*.txt`
|
- Documentation: `doc/`, `*.md`, `*.txt`
|
||||||
- Config files: `*.json`, `*.yml`, `*.yaml`
|
- Config files: `*.json`, `*.yml`, `*.yaml`
|
||||||
- IDE files: `.cursor/` directory
|
- IDE files: `.cursor/` directory
|
||||||
|
|
||||||
|
|||||||
412
doc/notification-integration-changes-outline.md
Normal file
412
doc/notification-integration-changes-outline.md
Normal file
@@ -0,0 +1,412 @@
|
|||||||
|
# Notification Integration Changes - Implementation Outline
|
||||||
|
|
||||||
|
**Date**: 2026-01-23
|
||||||
|
**Purpose**: Detailed outline of changes needed to integrate DailyNotificationPlugin with UI
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This document outlines all changes required to integrate the DailyNotificationPlugin with the existing notification UI, making it work seamlessly on both native (iOS/Android) and web platforms.
|
||||||
|
|
||||||
|
**Estimated Complexity**: Medium
|
||||||
|
**Estimated Files Changed**: 3-4 files
|
||||||
|
**Breaking Changes**: None (backward compatible)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Change Summary
|
||||||
|
|
||||||
|
| File | Changes | Complexity | Risk |
|
||||||
|
|------|---------|------------|------|
|
||||||
|
| `PushNotificationPermission.vue` | Add platform detection, native flow | Medium | Low |
|
||||||
|
| `AccountViewView.vue` | Platform detection in toggles, hide push server on native | Low | Low |
|
||||||
|
| `WebPushNotificationService.ts` | Complete stub implementation (optional) | Medium | Low |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Detailed Changes
|
||||||
|
|
||||||
|
### 1. PushNotificationPermission.vue
|
||||||
|
|
||||||
|
**File**: `src/components/PushNotificationPermission.vue`
|
||||||
|
**Current Lines**: ~656 lines
|
||||||
|
**Estimated New Lines**: +50-80 lines
|
||||||
|
**Complexity**: Medium
|
||||||
|
|
||||||
|
#### Changes Required
|
||||||
|
|
||||||
|
**A. Add Imports** (Top of script section)
|
||||||
|
```typescript
|
||||||
|
import { Capacitor } from "@capacitor/core";
|
||||||
|
import { NotificationService } from "@/services/notifications";
|
||||||
|
```
|
||||||
|
|
||||||
|
**B. Add Platform Detection Property**
|
||||||
|
```typescript
|
||||||
|
// Add to class properties
|
||||||
|
private get isNativePlatform(): boolean {
|
||||||
|
return Capacitor.isNativePlatform();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**C. Modify `open()` Method** (Lines 170-258)
|
||||||
|
- **Current**: Always initializes web push (VAPID key, service worker)
|
||||||
|
- **Change**: Add platform check at start
|
||||||
|
- If native: Skip VAPID/service worker, show UI immediately
|
||||||
|
- If web: Keep existing logic
|
||||||
|
|
||||||
|
**D. Modify `turnOnNotifications()` Method** (Lines 393-499)
|
||||||
|
- **Current**: Web push subscription flow
|
||||||
|
- **Change**: Split into two paths:
|
||||||
|
- **Native path**: Use `NotificationService.getInstance()` → `requestPermissions()` → `scheduleDailyNotification()`
|
||||||
|
- **Web path**: Keep existing logic
|
||||||
|
|
||||||
|
**E. Add New Method: `turnOnNativeNotifications()`**
|
||||||
|
- Request permissions via `NotificationService`
|
||||||
|
- Convert time input (AM/PM) to 24-hour format (HH:mm)
|
||||||
|
- Call `scheduleDailyNotification()` with proper options
|
||||||
|
- Save to settings
|
||||||
|
- Call callback with success/time/message
|
||||||
|
|
||||||
|
**F. Update `handleTurnOnNotifications()` Method** (Line 643)
|
||||||
|
- Add platform check
|
||||||
|
- Route to `turnOnNativeNotifications()` or `turnOnNotifications()` based on platform
|
||||||
|
|
||||||
|
**G. Update Computed Properties**
|
||||||
|
- `isSystemReady`: For native, return `true` immediately (no VAPID needed)
|
||||||
|
- `canShowNotificationForm`: For native, return `true` immediately
|
||||||
|
|
||||||
|
**H. Update Template** (Optional - for better UX)
|
||||||
|
- Add platform-specific messaging if desired
|
||||||
|
- Native: "Notifications will be scheduled on your device"
|
||||||
|
- Web: Keep existing messaging
|
||||||
|
|
||||||
|
#### Code Structure Preview
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
async open(pushType: string, callback?: ...) {
|
||||||
|
this.callback = callback || this.callback;
|
||||||
|
this.isVisible = true;
|
||||||
|
this.pushType = pushType;
|
||||||
|
|
||||||
|
// Platform detection
|
||||||
|
if (this.isNativePlatform) {
|
||||||
|
// Native: No VAPID/service worker needed
|
||||||
|
this.serviceWorkerReady = true; // Fake it for UI
|
||||||
|
this.vapidKey = "native"; // Placeholder
|
||||||
|
return; // Skip web push initialization
|
||||||
|
}
|
||||||
|
|
||||||
|
// Existing web push initialization...
|
||||||
|
// (keep all existing code)
|
||||||
|
}
|
||||||
|
|
||||||
|
async turnOnNotifications() {
|
||||||
|
if (this.isNativePlatform) {
|
||||||
|
return this.turnOnNativeNotifications();
|
||||||
|
}
|
||||||
|
// Existing web push logic...
|
||||||
|
}
|
||||||
|
|
||||||
|
private async turnOnNativeNotifications(): Promise<void> {
|
||||||
|
const service = NotificationService.getInstance();
|
||||||
|
|
||||||
|
// Request permissions
|
||||||
|
const granted = await service.requestPermissions();
|
||||||
|
if (!granted) {
|
||||||
|
// Handle permission denial
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert time to 24-hour format
|
||||||
|
const time24h = this.convertTo24HourFormat();
|
||||||
|
|
||||||
|
// Determine title and body based on pushType
|
||||||
|
const title = this.pushType === this.DAILY_CHECK_TITLE
|
||||||
|
? "Daily Check-In"
|
||||||
|
: "Daily Reminder";
|
||||||
|
const body = this.pushType === this.DIRECT_PUSH_TITLE
|
||||||
|
? this.messageInput
|
||||||
|
: "Time to check your TimeSafari activity";
|
||||||
|
|
||||||
|
// Schedule notification
|
||||||
|
const success = await service.scheduleDailyNotification({
|
||||||
|
time: time24h,
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
priority: 'normal'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
// Save to settings
|
||||||
|
const timeText = this.notificationTimeText;
|
||||||
|
await this.$saveSettings({
|
||||||
|
[this.pushType === this.DAILY_CHECK_TITLE
|
||||||
|
? 'notifyingNewActivityTime'
|
||||||
|
: 'notifyingReminderTime']: timeText,
|
||||||
|
...(this.pushType === this.DIRECT_PUSH_TITLE && {
|
||||||
|
notifyingReminderMessage: this.messageInput
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
// Call callback
|
||||||
|
this.callback(true, timeText, this.messageInput);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private convertTo24HourFormat(): string {
|
||||||
|
const hour = parseInt(this.hourInput);
|
||||||
|
const minute = parseInt(this.minuteInput);
|
||||||
|
|
||||||
|
let hour24 = hour;
|
||||||
|
if (!this.hourAm && hour !== 12) {
|
||||||
|
hour24 = hour + 12;
|
||||||
|
} else if (this.hourAm && hour === 12) {
|
||||||
|
hour24 = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${hour24.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Testing Considerations
|
||||||
|
- Test on iOS device
|
||||||
|
- Test on Android device
|
||||||
|
- Test on web (should still work as before)
|
||||||
|
- Test permission denial flow
|
||||||
|
- Test time conversion (AM/PM → 24-hour)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. AccountViewView.vue
|
||||||
|
|
||||||
|
**File**: `src/views/AccountViewView.vue`
|
||||||
|
**Current Lines**: 2124 lines
|
||||||
|
**Estimated New Lines**: +20-30 lines
|
||||||
|
**Complexity**: Low
|
||||||
|
|
||||||
|
#### Changes Required
|
||||||
|
|
||||||
|
**A. Add Import** (Top of script section, around line 739)
|
||||||
|
```typescript
|
||||||
|
import { Capacitor } from "@capacitor/core";
|
||||||
|
```
|
||||||
|
|
||||||
|
**B. Add Computed Property** (In class, around line 888)
|
||||||
|
```typescript
|
||||||
|
private get isNativePlatform(): boolean {
|
||||||
|
return Capacitor.isNativePlatform();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**C. Modify Notification Toggle Methods** (Lines 1134-1202)
|
||||||
|
|
||||||
|
**`showNewActivityNotificationChoice()`** (Lines 1134-1158)
|
||||||
|
- **Current**: Always uses `PushNotificationPermission` component
|
||||||
|
- **Change**: Add platform check
|
||||||
|
- If native: Use `NotificationService` directly (or still use component - it will handle platform)
|
||||||
|
- If web: Keep existing logic
|
||||||
|
- **Note**: Since we're updating `PushNotificationPermission` to handle both, this might not need changes, but we could add direct native path for cleaner code
|
||||||
|
|
||||||
|
**`showReminderNotificationChoice()`** (Lines 1171-1202)
|
||||||
|
- Same as above
|
||||||
|
|
||||||
|
**D. Conditionally Hide Push Server Setting** (Lines 506-549)
|
||||||
|
- Wrap the entire "Notification Push Server" section in `v-if="!isNativePlatform"`
|
||||||
|
- This hides it on iOS/Android where it's not needed
|
||||||
|
|
||||||
|
**E. Update Status Display** (Optional)
|
||||||
|
- When showing notification status, could add platform indicator
|
||||||
|
- "Native notification scheduled" vs "Web push subscription active"
|
||||||
|
|
||||||
|
#### Code Structure Preview
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Add computed property
|
||||||
|
private get isNativePlatform(): boolean {
|
||||||
|
return Capacitor.isNativePlatform();
|
||||||
|
}
|
||||||
|
|
||||||
|
// In template, wrap push server section:
|
||||||
|
<section v-if="!isNativePlatform" id="sectionPushServer">
|
||||||
|
<h2 class="text-slate-500 text-sm font-bold mb-2">
|
||||||
|
Notification Push Server
|
||||||
|
</h2>
|
||||||
|
<!-- ... existing push server UI ... -->
|
||||||
|
</section>
|
||||||
|
|
||||||
|
// Optional: Update notification choice methods
|
||||||
|
async showNewActivityNotificationChoice(): Promise<void> {
|
||||||
|
if (!this.notifyingNewActivity) {
|
||||||
|
// Component now handles platform detection, so this can stay the same
|
||||||
|
// OR we could add direct native path here for cleaner separation
|
||||||
|
(this.$refs.pushNotificationPermission as PushNotificationPermission)
|
||||||
|
.open(DAILY_CHECK_TITLE, async (success: boolean, timeText: string) => {
|
||||||
|
// ... existing callback ...
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// ... existing turn-off logic ...
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Testing Considerations
|
||||||
|
- Verify push server section hidden on iOS
|
||||||
|
- Verify push server section hidden on Android
|
||||||
|
- Verify push server section visible on web
|
||||||
|
- Test notification toggles work on all platforms
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. WebPushNotificationService.ts (Optional Enhancement)
|
||||||
|
|
||||||
|
**File**: `src/services/notifications/WebPushNotificationService.ts`
|
||||||
|
**Current Lines**: 213 lines
|
||||||
|
**Estimated New Lines**: +100-150 lines
|
||||||
|
**Complexity**: Medium
|
||||||
|
**Priority**: Low (can be done later)
|
||||||
|
|
||||||
|
#### Changes Required
|
||||||
|
|
||||||
|
**A. Complete `scheduleDailyNotification()` Implementation**
|
||||||
|
- Extract logic from `PushNotificationPermission.vue`
|
||||||
|
- Subscribe to push service
|
||||||
|
- Send subscription to server
|
||||||
|
- Return success status
|
||||||
|
|
||||||
|
**B. Complete `cancelDailyNotification()` Implementation**
|
||||||
|
- Get current subscription
|
||||||
|
- Unsubscribe from push service
|
||||||
|
- Notify server to stop sending
|
||||||
|
|
||||||
|
**C. Complete `getStatus()` Implementation**
|
||||||
|
- Check settings for `notifyingNewActivityTime` / `notifyingReminderTime`
|
||||||
|
- Check service worker subscription status
|
||||||
|
- Return combined status
|
||||||
|
|
||||||
|
**Note**: This is optional because `PushNotificationPermission.vue` already handles web push. Completing this would allow using `NotificationService` directly for web too, but it's not required for the integration to work.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Order
|
||||||
|
|
||||||
|
### Phase 1: Core Integration (Required)
|
||||||
|
1. ✅ Update `PushNotificationPermission.vue` with platform detection
|
||||||
|
2. ✅ Update `AccountViewView.vue` to hide push server on native
|
||||||
|
3. ✅ Test on native platforms
|
||||||
|
|
||||||
|
### Phase 2: Polish (Optional)
|
||||||
|
4. Complete `WebPushNotificationService.ts` implementation
|
||||||
|
5. Add platform-specific UI messaging
|
||||||
|
6. Add status indicators
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Risk Assessment
|
||||||
|
|
||||||
|
### Low Risk Changes
|
||||||
|
- ✅ Adding platform detection (read-only check)
|
||||||
|
- ✅ Conditionally hiding UI elements
|
||||||
|
- ✅ Adding new code paths (not modifying existing)
|
||||||
|
|
||||||
|
### Medium Risk Changes
|
||||||
|
- ⚠️ Modifying `turnOnNotifications()` flow (but we're adding, not replacing)
|
||||||
|
- ⚠️ Time format conversion (need to test edge cases)
|
||||||
|
|
||||||
|
### Mitigation Strategies
|
||||||
|
1. **Backward Compatibility**: All changes are additive - existing web push flow remains unchanged
|
||||||
|
2. **Feature Flags**: Could add feature flag to enable/disable native notifications
|
||||||
|
3. **Gradual Rollout**: Test on one platform first (e.g., Android), then iOS
|
||||||
|
4. **Fallback**: If native service fails, could fall back to showing error message
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing Checklist
|
||||||
|
|
||||||
|
### Functional Testing
|
||||||
|
- [ ] Native iOS: Request permissions → Schedule notification → Verify scheduled
|
||||||
|
- [ ] Native Android: Request permissions → Schedule notification → Verify scheduled
|
||||||
|
- [ ] Web: Existing flow still works (no regression)
|
||||||
|
- [ ] Permission denial: Shows appropriate error message
|
||||||
|
- [ ] Time conversion: AM/PM correctly converts to 24-hour format
|
||||||
|
- [ ] Both notification types: Daily Check and Direct Push work on native
|
||||||
|
- [ ] Settings persistence: Times saved correctly to database
|
||||||
|
|
||||||
|
### UI Testing
|
||||||
|
- [ ] Push server setting hidden on iOS
|
||||||
|
- [ ] Push server setting hidden on Android
|
||||||
|
- [ ] Push server setting visible on web
|
||||||
|
- [ ] Notification toggles work on all platforms
|
||||||
|
- [ ] Time picker UI works on native (same as web)
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
- [ ] 12:00 AM conversion (should be 00:00)
|
||||||
|
- [ ] 12:00 PM conversion (should be 12:00)
|
||||||
|
- [ ] Invalid time input handling
|
||||||
|
- [ ] App restart: Notifications still scheduled
|
||||||
|
- [ ] Device reboot: Notifications still scheduled (Android)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
### Required
|
||||||
|
- ✅ `@capacitor/core` - Already in project
|
||||||
|
- ✅ `@timesafari/daily-notification-plugin` - Already installed
|
||||||
|
- ✅ `NotificationService` - Already created
|
||||||
|
|
||||||
|
### No New Dependencies Needed
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Estimated Effort
|
||||||
|
|
||||||
|
| Task | Time Estimate |
|
||||||
|
|------|---------------|
|
||||||
|
| Update PushNotificationPermission.vue | 2-3 hours |
|
||||||
|
| Update AccountViewView.vue | 30 minutes - 1 hour |
|
||||||
|
| Testing on iOS | 1-2 hours |
|
||||||
|
| Testing on Android | 1-2 hours |
|
||||||
|
| Bug fixes & polish | 1-2 hours |
|
||||||
|
| **Total** | **5-10 hours** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rollback Plan
|
||||||
|
|
||||||
|
If issues arise:
|
||||||
|
|
||||||
|
1. **Quick Rollback**: Revert changes to `PushNotificationPermission.vue` and `AccountViewView.vue`
|
||||||
|
2. **Partial Rollback**: Keep platform detection but disable native path (feature flag)
|
||||||
|
3. **No Data Migration Needed**: Settings structure unchanged
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Questions to Consider
|
||||||
|
|
||||||
|
1. **Should we keep using `PushNotificationPermission` component for native, or create separate native flow?**
|
||||||
|
- **Recommendation**: Keep using component (simpler, less code duplication)
|
||||||
|
|
||||||
|
2. **Should we show different UI messaging for native vs web?**
|
||||||
|
- **Recommendation**: Optional enhancement, not required for MVP
|
||||||
|
|
||||||
|
3. **Should we complete `WebPushNotificationService` now or later?**
|
||||||
|
- **Recommendation**: Later (not blocking, existing component works)
|
||||||
|
|
||||||
|
4. **How to handle notification cancellation on native?**
|
||||||
|
- **Recommendation**: Use `NotificationService.cancelDailyNotification()` in existing turn-off logic
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Next Steps After Implementation
|
||||||
|
|
||||||
|
1. Update documentation with platform-specific instructions
|
||||||
|
2. Add error handling for edge cases
|
||||||
|
3. Consider adding notification status display in UI
|
||||||
|
4. Test on real devices (critical for native notifications)
|
||||||
|
5. Monitor for any platform-specific issues
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Last Updated**: 2026-01-23
|
||||||
238
doc/notification-permissions-and-rollovers.md
Normal file
238
doc/notification-permissions-and-rollovers.md
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
# Notification Permissions & Rollover Handling
|
||||||
|
|
||||||
|
**Date**: 2026-01-23
|
||||||
|
**Purpose**: Answers to questions about permission requests and rollover handling
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Question 1: Where does the notification permission request happen?
|
||||||
|
|
||||||
|
### Permission Request Flow
|
||||||
|
|
||||||
|
The permission request flows through multiple layers:
|
||||||
|
|
||||||
|
```
|
||||||
|
User clicks "Turn on Daily Message"
|
||||||
|
↓
|
||||||
|
PushNotificationPermission.vue
|
||||||
|
↓ (line 715)
|
||||||
|
service.requestPermissions()
|
||||||
|
↓
|
||||||
|
NotificationService.getInstance()
|
||||||
|
↓ (platform detection)
|
||||||
|
NativeNotificationService.requestPermissions()
|
||||||
|
↓ (line 53)
|
||||||
|
DailyNotification.requestPermissions()
|
||||||
|
↓
|
||||||
|
Plugin Native Code
|
||||||
|
↓
|
||||||
|
┌─────────────────────┬─────────────────────┐
|
||||||
|
│ iOS Platform │ Android Platform │
|
||||||
|
├─────────────────────┼─────────────────────┤
|
||||||
|
│ UNUserNotification │ ActivityCompat │
|
||||||
|
│ Center.current() │ .requestPermissions()│
|
||||||
|
│ .requestAuthorization│ │
|
||||||
|
│ (options: [.alert, │ (POST_NOTIFICATIONS) │
|
||||||
|
│ .sound, .badge]) │ │
|
||||||
|
└─────────────────────┴─────────────────────┘
|
||||||
|
↓
|
||||||
|
Native OS Permission Dialog
|
||||||
|
↓
|
||||||
|
User grants/denies
|
||||||
|
↓
|
||||||
|
Result returned to app
|
||||||
|
```
|
||||||
|
|
||||||
|
### Code Locations
|
||||||
|
|
||||||
|
**1. UI Entry Point** (`src/components/PushNotificationPermission.vue`):
|
||||||
|
```typescript
|
||||||
|
// Line 715
|
||||||
|
const granted = await service.requestPermissions();
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. Service Layer** (`src/services/notifications/NativeNotificationService.ts`):
|
||||||
|
```typescript
|
||||||
|
// Lines 49-68
|
||||||
|
async requestPermissions(): Promise<boolean> {
|
||||||
|
const result = await DailyNotification.requestPermissions();
|
||||||
|
return result.allPermissionsGranted;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. Plugin Registration** (`src/plugins/DailyNotificationPlugin.ts`):
|
||||||
|
```typescript
|
||||||
|
// Line 30-36
|
||||||
|
const DailyNotification = registerPlugin<DailyNotificationPluginType>(
|
||||||
|
"DailyNotification"
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**4. iOS Native Implementation** (`node_modules/@timesafari/daily-notification-plugin/ios/Plugin/DailyNotificationScheduler.swift`):
|
||||||
|
```swift
|
||||||
|
// Lines 113-115
|
||||||
|
func requestPermissions() async -> Bool {
|
||||||
|
let granted = try await notificationCenter.requestAuthorization(
|
||||||
|
options: [.alert, .sound, .badge]
|
||||||
|
)
|
||||||
|
return granted
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**5. Android Native Implementation** (`node_modules/@timesafari/daily-notification-plugin/android/src/main/java/com/timesafari/dailynotification/PermissionManager.java`):
|
||||||
|
```java
|
||||||
|
// Line 87
|
||||||
|
ActivityCompat.requestPermissions(
|
||||||
|
activity,
|
||||||
|
new String[]{Manifest.permission.POST_NOTIFICATIONS},
|
||||||
|
REQUEST_CODE
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Platform-Specific Details
|
||||||
|
|
||||||
|
#### iOS
|
||||||
|
- **API Used**: `UNUserNotificationCenter.requestAuthorization()`
|
||||||
|
- **Options Requested**: `.alert`, `.sound`, `.badge`
|
||||||
|
- **Dialog**: System-native iOS permission dialog
|
||||||
|
- **Location**: First time user enables notifications
|
||||||
|
- **Result**: Returns `true` if granted, `false` if denied
|
||||||
|
|
||||||
|
#### Android
|
||||||
|
- **API Used**: `ActivityCompat.requestPermissions()`
|
||||||
|
- **Permission**: `POST_NOTIFICATIONS` (Android 13+)
|
||||||
|
- **Dialog**: System-native Android permission dialog
|
||||||
|
- **Location**: First time user enables notifications
|
||||||
|
- **Result**: Returns `true` if granted, `false` if denied
|
||||||
|
- **Note**: Android 12 and below don't require runtime permission (declared in manifest)
|
||||||
|
|
||||||
|
### When Permission Request Happens
|
||||||
|
|
||||||
|
The permission request is triggered when:
|
||||||
|
1. User opens the notification setup dialog (`PushNotificationPermission.vue`)
|
||||||
|
2. User clicks "Turn on Daily Message" button
|
||||||
|
3. App detects native platform (`isNativePlatform === true`)
|
||||||
|
4. `turnOnNativeNotifications()` method is called
|
||||||
|
5. `service.requestPermissions()` is called (line 715)
|
||||||
|
|
||||||
|
**Important**: The permission dialog only appears **once** per app installation. After that:
|
||||||
|
- If granted: Future calls to `requestPermissions()` return `true` immediately
|
||||||
|
- If denied: User must manually enable in system settings
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Question 2: Does the plugin handle rollovers automatically?
|
||||||
|
|
||||||
|
### ✅ Yes - Rollover Handling is Automatic
|
||||||
|
|
||||||
|
The plugin **automatically handles rollovers** in multiple scenarios:
|
||||||
|
|
||||||
|
### 1. Initial Scheduling (Time Has Passed Today)
|
||||||
|
|
||||||
|
**Location**: `ios/Plugin/DailyNotificationScheduler.swift` (lines 326-329)
|
||||||
|
|
||||||
|
```swift
|
||||||
|
// If time has passed today, schedule for tomorrow
|
||||||
|
if scheduledDate <= now {
|
||||||
|
scheduledDate = calendar.date(byAdding: .day, value: 1, to: scheduledDate) ?? scheduledDate
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Behavior**:
|
||||||
|
- If user schedules a notification for 9:00 AM but it's already 10:00 AM today
|
||||||
|
- Plugin automatically schedules it for 9:00 AM **tomorrow**
|
||||||
|
- No manual intervention needed
|
||||||
|
|
||||||
|
### 2. Daily Rollover (After Notification Fires)
|
||||||
|
|
||||||
|
**Location**: `ios/Plugin/DailyNotificationScheduler.swift` (lines 437-609)
|
||||||
|
|
||||||
|
The plugin has a `scheduleNextNotification()` function that:
|
||||||
|
- Automatically schedules the next day's notification after current one fires
|
||||||
|
- Handles 24-hour rollovers with DST (Daylight Saving Time) awareness
|
||||||
|
- Prevents duplicate rollovers with state tracking
|
||||||
|
|
||||||
|
**Key Function**: `calculateNextScheduledTime()` (lines 397-435)
|
||||||
|
```swift
|
||||||
|
// Add 24 hours (handles DST transitions automatically)
|
||||||
|
guard let nextDate = calendar.date(byAdding: .hour, value: 24, to: currentDate) else {
|
||||||
|
// Fallback to simple 24-hour addition
|
||||||
|
return currentScheduledTime + (24 * 60 * 60 * 1000)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Features**:
|
||||||
|
- ✅ DST-safe: Uses Calendar API to handle daylight saving transitions
|
||||||
|
- ✅ Automatic: No manual scheduling needed
|
||||||
|
- ✅ Persistent: Survives app restarts and device reboots
|
||||||
|
- ✅ Duplicate prevention: Tracks rollover state to prevent duplicates
|
||||||
|
|
||||||
|
### 3. Rollover State Tracking
|
||||||
|
|
||||||
|
**Location**: `ios/Plugin/DailyNotificationStorage.swift` (lines 161-195)
|
||||||
|
|
||||||
|
The plugin tracks rollover state to prevent duplicate scheduling:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
// Check if rollover was processed recently (< 1 hour ago)
|
||||||
|
if let lastTime = lastRolloverTime,
|
||||||
|
(currentTime - lastTime) < (60 * 60 * 1000) {
|
||||||
|
// Skip - already processed
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Purpose**: Prevents multiple rollover attempts if notification fires multiple times
|
||||||
|
|
||||||
|
### 4. Android Rollover Handling
|
||||||
|
|
||||||
|
Android implementation also handles rollovers:
|
||||||
|
- Uses `AlarmManager` with `setRepeating()` or schedules next alarm after current fires
|
||||||
|
- Handles timezone changes and DST transitions
|
||||||
|
- Persists across device reboots via `BootReceiver`
|
||||||
|
|
||||||
|
### Rollover Scenarios Handled
|
||||||
|
|
||||||
|
| Scenario | Handled? | How |
|
||||||
|
|----------|----------|-----|
|
||||||
|
| Time passed today | ✅ Yes | Schedules for tomorrow automatically |
|
||||||
|
| Daily rollover | ✅ Yes | Schedules next day after notification fires |
|
||||||
|
| DST transitions | ✅ Yes | Uses Calendar API for DST-aware calculations |
|
||||||
|
| Device reboot | ✅ Yes | BootReceiver restores schedules |
|
||||||
|
| App restart | ✅ Yes | Schedules persist in database |
|
||||||
|
| Duplicate prevention | ✅ Yes | State tracking prevents duplicate rollovers |
|
||||||
|
|
||||||
|
### Verification
|
||||||
|
|
||||||
|
You can verify rollover handling by:
|
||||||
|
|
||||||
|
1. **Check iOS logs** for rollover messages:
|
||||||
|
```
|
||||||
|
DNP-ROLLOVER: START id=... current_time=... scheduled_time=...
|
||||||
|
DNP-ROLLOVER: CALC_NEXT current=... next=... diff_hours=24.00
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Test scenario**: Schedule notification for a time that's already passed today
|
||||||
|
- Expected: Notification scheduled for tomorrow at same time
|
||||||
|
|
||||||
|
3. **Test scenario**: Wait for notification to fire
|
||||||
|
- Expected: Next day's notification automatically scheduled
|
||||||
|
|
||||||
|
### Summary
|
||||||
|
|
||||||
|
✅ **Permission Request**: Happens in native plugin code via platform-specific APIs:
|
||||||
|
- iOS: `UNUserNotificationCenter.requestAuthorization()`
|
||||||
|
- Android: `ActivityCompat.requestPermissions()`
|
||||||
|
|
||||||
|
✅ **Rollover Handling**: Fully automatic:
|
||||||
|
- Initial scheduling: If time passed, schedules for tomorrow
|
||||||
|
- Daily rollover: Automatically schedules next day after notification fires
|
||||||
|
- DST handling: Calendar-aware calculations
|
||||||
|
- Duplicate prevention: State tracking prevents issues
|
||||||
|
- Persistence: Survives app restarts and device reboots
|
||||||
|
|
||||||
|
**No manual intervention needed** - the plugin handles all rollover scenarios automatically!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Last Updated**: 2026-01-23
|
||||||
378
doc/notification-system-overview.md
Normal file
378
doc/notification-system-overview.md
Normal file
@@ -0,0 +1,378 @@
|
|||||||
|
# Notification System Overview
|
||||||
|
|
||||||
|
**Date**: 2026-01-23
|
||||||
|
**Purpose**: Understanding notification architecture and implementation guide for daily-notification-plugin
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
|
||||||
|
Your app has **two separate notification systems** that coexist:
|
||||||
|
|
||||||
|
1. **Web Push Notifications** (Web/PWA platforms)
|
||||||
|
- Uses service workers, VAPID keys, and a push server
|
||||||
|
- Requires the "Notification Push Server" setting
|
||||||
|
- Server-based delivery
|
||||||
|
|
||||||
|
2. **Native Notifications** (iOS/Android via DailyNotificationPlugin)
|
||||||
|
- Uses native OS notification APIs
|
||||||
|
- On-device scheduling (no server needed)
|
||||||
|
- The "Notification Push Server" setting is **NOT used** for native
|
||||||
|
|
||||||
|
The system automatically selects the correct implementation based on platform using `Capacitor.isNativePlatform()`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notification Push Server Setting
|
||||||
|
|
||||||
|
### Location
|
||||||
|
- **File**: `src/views/AccountViewView.vue` (lines 506-549)
|
||||||
|
- **UI Section**: Advanced Settings → "Notification Push Server"
|
||||||
|
- **Database Field**: `settings.webPushServer`
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
The "Notification Push Server" setting **ONLY applies to Web Push notifications** (web/PWA platforms). It configures:
|
||||||
|
|
||||||
|
1. **VAPID Key Retrieval**: The server URL used to fetch VAPID (Voluntary Application Server Identification) keys
|
||||||
|
2. **Subscription Endpoint**: Where push subscriptions are sent
|
||||||
|
3. **Push Message Delivery**: The server that sends push messages to browsers
|
||||||
|
|
||||||
|
### How It Works (Web Push Flow)
|
||||||
|
|
||||||
|
```
|
||||||
|
User enables notification
|
||||||
|
↓
|
||||||
|
PushNotificationPermission.vue opens
|
||||||
|
↓
|
||||||
|
Fetches VAPID key from: {webPushServer}/web-push/vapid
|
||||||
|
↓
|
||||||
|
Subscribes to browser push service
|
||||||
|
↓
|
||||||
|
Sends subscription + time + message to: {webPushServer}/web-push/subscribe
|
||||||
|
↓
|
||||||
|
Server stores subscription and schedules push messages
|
||||||
|
↓
|
||||||
|
Server sends push messages at scheduled time via browser push service
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Code Locations
|
||||||
|
|
||||||
|
**AccountViewView.vue** (lines 1473-1479):
|
||||||
|
```typescript
|
||||||
|
async onClickSavePushServer(): Promise<void> {
|
||||||
|
await this.$saveSettings({
|
||||||
|
webPushServer: this.webPushServerInput,
|
||||||
|
});
|
||||||
|
this.webPushServer = this.webPushServerInput;
|
||||||
|
this.notify.warning(ACCOUNT_VIEW_CONSTANTS.INFO.RELOAD_VAPID);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**PushNotificationPermission.vue** (lines 177-221):
|
||||||
|
- Retrieves `webPushServer` from settings
|
||||||
|
- Fetches VAPID key from `{webPushServer}/web-push/vapid`
|
||||||
|
- Uses VAPID key to subscribe to push notifications
|
||||||
|
|
||||||
|
**PushNotificationPermission.vue** (lines 556-575):
|
||||||
|
- Sends subscription to `/web-push/subscribe` endpoint (relative URL, handled by service worker)
|
||||||
|
|
||||||
|
### Important Notes
|
||||||
|
|
||||||
|
- ⚠️ **This setting is NOT used for native iOS/Android notifications**
|
||||||
|
- The setting defaults to `DEFAULT_PUSH_SERVER` if not configured
|
||||||
|
- Changing the server requires reloading VAPID keys (hence the warning message)
|
||||||
|
- Local development (`http://localhost`) skips VAPID key retrieval
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Daily Notification Plugin Integration
|
||||||
|
|
||||||
|
### Current Status
|
||||||
|
|
||||||
|
✅ **Infrastructure Complete**:
|
||||||
|
- Plugin registered (`src/plugins/DailyNotificationPlugin.ts`)
|
||||||
|
- Service abstraction layer created (`src/services/notifications/`)
|
||||||
|
- Platform detection working
|
||||||
|
- Native implementation ready (`NativeNotificationService.ts`)
|
||||||
|
|
||||||
|
🔄 **UI Integration Needed**:
|
||||||
|
- `PushNotificationPermission.vue` still uses web push logic
|
||||||
|
- AccountViewView notification toggles need platform detection
|
||||||
|
- Settings storage needs to handle both systems
|
||||||
|
|
||||||
|
### Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
NotificationService.getInstance()
|
||||||
|
↓
|
||||||
|
Platform Detection (Capacitor.isNativePlatform())
|
||||||
|
↓
|
||||||
|
┌─────────────────────┬─────────────────────┐
|
||||||
|
│ Native Platform │ Web Platform │
|
||||||
|
│ (iOS/Android) │ (Web/PWA) │
|
||||||
|
├─────────────────────┼─────────────────────┤
|
||||||
|
│ NativeNotification │ WebPushNotification │
|
||||||
|
│ Service │ Service │
|
||||||
|
│ │ │
|
||||||
|
│ Uses: │ Uses: │
|
||||||
|
│ - DailyNotification │ - Service Workers │
|
||||||
|
│ Plugin │ - VAPID Keys │
|
||||||
|
│ - Native OS APIs │ - Push Server │
|
||||||
|
│ - On-device alarms │ - Server scheduling │
|
||||||
|
└─────────────────────┴─────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Differences
|
||||||
|
|
||||||
|
| Feature | Native (Plugin) | Web Push |
|
||||||
|
|---------|----------------|----------|
|
||||||
|
| **Server Required** | ❌ No | ✅ Yes (Notification Push Server) |
|
||||||
|
| **Scheduling** | On-device | Server-side |
|
||||||
|
| **Offline Delivery** | ✅ Yes | ❌ No (requires network) |
|
||||||
|
| **Background Support** | ✅ Full | ⚠️ Limited (browser-dependent) |
|
||||||
|
| **Permission Model** | OS-level | Browser-level |
|
||||||
|
| **Settings Storage** | Local only | Local + server subscription |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Recommendations
|
||||||
|
|
||||||
|
### 1. Update PushNotificationPermission Component
|
||||||
|
|
||||||
|
**Current State**: Only handles web push
|
||||||
|
|
||||||
|
**Recommended Changes**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// In PushNotificationPermission.vue
|
||||||
|
import { NotificationService } from '@/services/notifications';
|
||||||
|
import { Capacitor } from '@capacitor/core';
|
||||||
|
|
||||||
|
async open(pushType: string, callback?: ...) {
|
||||||
|
const isNative = Capacitor.isNativePlatform();
|
||||||
|
|
||||||
|
if (isNative) {
|
||||||
|
// Use native notification service
|
||||||
|
const service = NotificationService.getInstance();
|
||||||
|
const granted = await service.requestPermissions();
|
||||||
|
|
||||||
|
if (granted) {
|
||||||
|
// Show time picker UI
|
||||||
|
// Then schedule via service.scheduleDailyNotification()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Existing web push logic
|
||||||
|
// ... current implementation ...
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Update AccountViewView Notification Toggles
|
||||||
|
|
||||||
|
**Current State**: Always uses `PushNotificationPermission` component (web push)
|
||||||
|
|
||||||
|
**Recommended Changes**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// In AccountViewView.vue
|
||||||
|
import { NotificationService } from '@/services/notifications';
|
||||||
|
import { Capacitor } from '@capacitor/core';
|
||||||
|
|
||||||
|
async showNewActivityNotificationChoice(): Promise<void> {
|
||||||
|
const isNative = Capacitor.isNativePlatform();
|
||||||
|
|
||||||
|
if (isNative) {
|
||||||
|
// Use native service directly
|
||||||
|
const service = NotificationService.getInstance();
|
||||||
|
// Show time picker, then schedule
|
||||||
|
} else {
|
||||||
|
// Use existing PushNotificationPermission component
|
||||||
|
(this.$refs.pushNotificationPermission as PushNotificationPermission)
|
||||||
|
.open(DAILY_CHECK_TITLE, ...);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Settings Storage Strategy
|
||||||
|
|
||||||
|
**Current Settings Fields** (from `src/db/tables/settings.ts`):
|
||||||
|
- `notifyingNewActivityTime` - Time string for daily check
|
||||||
|
- `notifyingReminderTime` - Time string for reminder
|
||||||
|
- `notifyingReminderMessage` - Reminder message text
|
||||||
|
- `webPushServer` - Push server URL (web only)
|
||||||
|
|
||||||
|
**Recommendation**: These settings work for both systems:
|
||||||
|
- ✅ `notifyingNewActivityTime` - Works for both (native stores locally, web sends to server)
|
||||||
|
- ✅ `notifyingReminderTime` - Works for both
|
||||||
|
- ✅ `notifyingReminderMessage` - Works for both
|
||||||
|
- ⚠️ `webPushServer` - Only used for web push (hide on native platforms)
|
||||||
|
|
||||||
|
### 4. Platform-Aware UI
|
||||||
|
|
||||||
|
**Recommendations**:
|
||||||
|
|
||||||
|
1. **Hide "Notification Push Server" setting on native platforms**:
|
||||||
|
```vue
|
||||||
|
<h2 v-if="!isNativePlatform" class="text-slate-500 text-sm font-bold mb-2">
|
||||||
|
Notification Push Server
|
||||||
|
</h2>
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Update help text** to explain platform differences
|
||||||
|
|
||||||
|
3. **Show different messaging** based on platform:
|
||||||
|
- Native: "Notifications are scheduled on your device"
|
||||||
|
- Web: "Notifications are sent via push server"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notification Types
|
||||||
|
|
||||||
|
Your app supports two notification types:
|
||||||
|
|
||||||
|
### 1. Daily Check (`DAILY_CHECK_TITLE`)
|
||||||
|
- **Purpose**: Notify user of new activity/updates
|
||||||
|
- **Message**: Auto-generated by server (web) or app (native)
|
||||||
|
- **Settings Field**: `notifyingNewActivityTime`
|
||||||
|
|
||||||
|
### 2. Direct Push (`DIRECT_PUSH_TITLE`)
|
||||||
|
- **Purpose**: Daily reminder with custom message
|
||||||
|
- **Message**: User-provided (max 100 characters)
|
||||||
|
- **Settings Fields**: `notifyingReminderTime`, `notifyingReminderMessage`
|
||||||
|
|
||||||
|
Both types can be enabled simultaneously.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Code Flow Examples
|
||||||
|
|
||||||
|
### Native Notification Flow (Recommended Implementation)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 1. Get service instance
|
||||||
|
const service = NotificationService.getInstance();
|
||||||
|
|
||||||
|
// 2. Request permissions
|
||||||
|
const granted = await service.requestPermissions();
|
||||||
|
if (!granted) {
|
||||||
|
// Show error, guide to settings
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Schedule notification
|
||||||
|
await service.scheduleDailyNotification({
|
||||||
|
time: '09:00', // HH:mm format (24-hour)
|
||||||
|
title: 'Daily Check-In',
|
||||||
|
body: 'Time to check your TimeSafari activity',
|
||||||
|
priority: 'normal'
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. Save to settings
|
||||||
|
await this.$saveSettings({
|
||||||
|
notifyingNewActivityTime: '09:00'
|
||||||
|
});
|
||||||
|
|
||||||
|
// 5. Check status
|
||||||
|
const status = await service.getStatus();
|
||||||
|
console.log('Enabled:', status.enabled);
|
||||||
|
console.log('Time:', status.scheduledTime);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Web Push Flow (Current Implementation)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 1. Open PushNotificationPermission component
|
||||||
|
(this.$refs.pushNotificationPermission as PushNotificationPermission)
|
||||||
|
.open(DAILY_CHECK_TITLE, async (success, timeText) => {
|
||||||
|
if (success) {
|
||||||
|
// Component handles:
|
||||||
|
// - VAPID key retrieval from webPushServer
|
||||||
|
// - Service worker subscription
|
||||||
|
// - Sending subscription to server
|
||||||
|
|
||||||
|
// Just save the time
|
||||||
|
await this.$saveSettings({
|
||||||
|
notifyingNewActivityTime: timeText
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing Checklist
|
||||||
|
|
||||||
|
### Native (iOS/Android)
|
||||||
|
- [ ] Request permissions works
|
||||||
|
- [ ] Notification appears at scheduled time
|
||||||
|
- [ ] Notification survives app close
|
||||||
|
- [ ] Notification survives device reboot
|
||||||
|
- [ ] Both notification types can be enabled
|
||||||
|
- [ ] Cancellation works correctly
|
||||||
|
|
||||||
|
### Web Push
|
||||||
|
- [ ] VAPID key retrieval works
|
||||||
|
- [ ] Service worker subscription works
|
||||||
|
- [ ] Subscription sent to server
|
||||||
|
- [ ] Push messages received at scheduled time
|
||||||
|
- [ ] Works with different push server URLs
|
||||||
|
|
||||||
|
### Platform Detection
|
||||||
|
- [ ] Correct service selected on iOS
|
||||||
|
- [ ] Correct service selected on Android
|
||||||
|
- [ ] Correct service selected on web
|
||||||
|
- [ ] Settings UI shows/hides appropriately
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Files Reference
|
||||||
|
|
||||||
|
### Core Notification Services
|
||||||
|
- `src/services/notifications/NotificationService.ts` - Factory/selector
|
||||||
|
- `src/services/notifications/NativeNotificationService.ts` - Native implementation
|
||||||
|
- `src/services/notifications/WebPushNotificationService.ts` - Web implementation (stub)
|
||||||
|
|
||||||
|
### UI Components
|
||||||
|
- `src/components/PushNotificationPermission.vue` - Web push UI (needs update)
|
||||||
|
- `src/views/AccountViewView.vue` - Settings UI (lines 506-549 for push server)
|
||||||
|
|
||||||
|
### Settings & Constants
|
||||||
|
- `src/db/tables/settings.ts` - Settings schema
|
||||||
|
- `src/constants/app.ts` - `DEFAULT_PUSH_SERVER` constant
|
||||||
|
- `src/libs/util.ts` - `DAILY_CHECK_TITLE`, `DIRECT_PUSH_TITLE`
|
||||||
|
|
||||||
|
### Plugin
|
||||||
|
- `src/plugins/DailyNotificationPlugin.ts` - Plugin registration
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
1. **Update `PushNotificationPermission.vue`** to detect platform and use appropriate service
|
||||||
|
2. **Update `AccountViewView.vue`** notification toggles to use platform detection
|
||||||
|
3. **Hide "Notification Push Server" setting** on native platforms
|
||||||
|
4. **Test on real devices** (iOS and Android)
|
||||||
|
5. **Update documentation** with platform-specific instructions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Questions & Answers
|
||||||
|
|
||||||
|
**Q: Do I need to configure the Notification Push Server for native apps?**
|
||||||
|
A: No. The setting is only for web push. Native notifications are scheduled on-device.
|
||||||
|
|
||||||
|
**Q: Can both notification systems be active at the same time?**
|
||||||
|
A: No, they're mutually exclusive per platform. The app automatically selects the correct one.
|
||||||
|
|
||||||
|
**Q: How do I test native notifications?**
|
||||||
|
A: Use `NotificationService.getInstance()` and test on a real device (simulators have limitations).
|
||||||
|
|
||||||
|
**Q: What happens if I change the push server URL?**
|
||||||
|
A: Only affects web push. Users need to re-subscribe to push notifications with the new server.
|
||||||
|
|
||||||
|
**Q: Can I use the same settings fields for both systems?**
|
||||||
|
A: Yes! The time and message fields work for both. Only `webPushServer` is web-specific.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Last Updated**: 2026-01-23
|
||||||
27
doc/plugin-android-edit-reschedule-alarm-not-firing.md
Normal file
27
doc/plugin-android-edit-reschedule-alarm-not-firing.md
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# Plugin: Android — Alarm set after edit doesn’t fire (cancel-before-reschedule)
|
||||||
|
|
||||||
|
**Context:** Consuming app (TimeSafari) — user sets reminder at 6:57pm (fires), then edits to 7:00pm. Only one `scheduleDailyNotification` call is made (skipSchedule fix). Logs show "Scheduling OS alarm" and "Updated schedule in database" for 19:00, but the notification never fires at 7:00pm.
|
||||||
|
|
||||||
|
**Likely cause (plugin):** In `NotifyReceiver.kt`, before calling `setAlarmClock(pendingIntent)` the code:
|
||||||
|
|
||||||
|
1. Creates `pendingIntent` with `PendingIntent.getBroadcast(..., requestCode, intent, FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE)`.
|
||||||
|
2. Gets `existingPendingIntent` with `PendingIntent.getBroadcast(..., requestCode, intent, FLAG_NO_CREATE | FLAG_IMMUTABLE)` (same `requestCode`, same `intent`).
|
||||||
|
3. If not null: `alarmManager.cancel(existingPendingIntent)` and **`existingPendingIntent.cancel()`**.
|
||||||
|
4. Then calls `alarmManager.setAlarmClock(alarmClockInfo, pendingIntent)`.
|
||||||
|
|
||||||
|
On Android, PendingIntent equality for caching is based on requestCode and Intent (action, component, etc.), not necessarily all extras. So `existingPendingIntent` is often the **same** (cached) PendingIntent as `pendingIntent`. Then we call **`existingPendingIntent.cancel()`**, which cancels that PendingIntent for future use. We then use the same (now cancelled) PendingIntent in **`setAlarmClock(..., pendingIntent)`**. On some devices/versions, setting an alarm with a cancelled PendingIntent can result in the alarm not firing.
|
||||||
|
|
||||||
|
**Suggested fix (plugin repo):**
|
||||||
|
|
||||||
|
- Remove the **`existingPendingIntent.cancel()`** call. Use only **`alarmManager.cancel(existingPendingIntent)`** to clear any existing alarm for this requestCode. That way the PendingIntent we pass to `setAlarmClock` is not cancelled; only the previous alarm is removed.
|
||||||
|
- Optionally: only run the “cancel existing” block when we know there was a previous schedule (e.g. from DB) for this scheduleId that hasn’t fired yet, so we don’t cancel when the previous alarm already fired (e.g. user edited after first fire).
|
||||||
|
|
||||||
|
**Verification:**
|
||||||
|
|
||||||
|
- In the consuming app: set reminder 2–3 min from now, let it fire, then edit to 2–3 min from then and save. Capture logcat through the second scheduled time.
|
||||||
|
- If the receiver never logs at the second time, the OS didn’t deliver the alarm; fixing the cancel-before-reschedule logic as above should be tried first in the plugin.
|
||||||
|
|
||||||
|
**References:**
|
||||||
|
|
||||||
|
- CONSUMING_APP_ANDROID_NOTES.md (double schedule, alarm scheduled but not firing).
|
||||||
|
- NotifyReceiver.kt around “Cancelling existing alarm before rescheduling” and the following `setAlarmClock` use of `pendingIntent`.
|
||||||
71
doc/plugin-feedback-android-6-api23-zoneid-fix.md
Normal file
71
doc/plugin-feedback-android-6-api23-zoneid-fix.md
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
# Plugin fix: Android 6.0 (API 23) compatibility — replace java.time.ZoneId with TimeZone
|
||||||
|
|
||||||
|
**Date:** 2026-02-27
|
||||||
|
**Target repo:** daily-notification-plugin
|
||||||
|
**Consuming app:** crowd-funder-for-time-pwa (TimeSafari)
|
||||||
|
**Platform:** Android
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
On Android 6.0 (API 23), the plugin crashes at runtime when scheduling a daily notification because it uses `java.time.ZoneId`, which is only available from **API 26**. Replacing that with `java.util.TimeZone.getDefault().getID()` restores compatibility with API 23 and has **no functional impact** on API 26+ (same timezone ID string, same behavior).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
- **File:** `android/src/main/java/com/timesafari/dailynotification/NotifyReceiver.kt`
|
||||||
|
- **Approximate location:** inside `scheduleExactNotification()`, when building `NotificationContentEntity` (around line 260).
|
||||||
|
|
||||||
|
The code uses:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
java.time.ZoneId.systemDefault().id
|
||||||
|
```
|
||||||
|
|
||||||
|
On API 23 this causes a runtime failure (e.g. `NoClassDefFoundError`) when the scheduling path runs, because `java.time` was added to Android only in API 26 (Oreo).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Required change
|
||||||
|
|
||||||
|
Replace the `java.time` call with the API-1–compatible equivalent.
|
||||||
|
|
||||||
|
**Before:**
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
java.time.ZoneId.systemDefault().id
|
||||||
|
```
|
||||||
|
|
||||||
|
**After:**
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
java.util.TimeZone.getDefault().id
|
||||||
|
```
|
||||||
|
|
||||||
|
Use this in the same place where `NotificationContentEntity` is constructed (the parameter that stores the system timezone ID string). No other code changes are needed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Why this is safe on newer Android
|
||||||
|
|
||||||
|
- Both `ZoneId.systemDefault().id` and `TimeZone.getDefault().id` refer to the **same** system default timezone and return the **same** IANA timezone ID string (e.g. `"America/Los_Angeles"`, `"Europe/London"`).
|
||||||
|
- Any downstream logic that reads this string (e.g. for display or next-run calculation) behaves identically on API 26+.
|
||||||
|
- No change to data format or semantics; this is a backward-compatible drop-in replacement.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
1. **Build:** From a consuming app (e.g. crowd-funder-for-time-pwa) with `minSdkVersion = 23`, run a full Android build including the plugin. No compilation errors.
|
||||||
|
2. **Runtime on API 23:** On an Android 6.0 device or emulator, enable daily notifications and schedule a time. The app should not crash; the notification should be scheduled and (after the delay) fire.
|
||||||
|
3. **Runtime on API 26+:** Confirm scheduling and delivery still work as before on Android 8+.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Context (consuming app)
|
||||||
|
|
||||||
|
- App and plugin both declare `minSdkVersion = 23` (Android 6.0). AlarmManager, permissions, and notification paths in the plugin are already API-23 safe; this `java.time` usage is the only blocker for running on Android 6.0 devices.
|
||||||
|
|
||||||
|
Use this document when applying the fix in the **daily-notification-plugin** repo (e.g. in Cursor). After changing the plugin, update the consuming app’s dependency (e.g. `npm update @timesafari/daily-notification-plugin` or point at the fixed commit), then `npx cap sync android` and rebuild.
|
||||||
114
doc/plugin-feedback-android-duplicate-reminder-notification.md
Normal file
114
doc/plugin-feedback-android-duplicate-reminder-notification.md
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
# Plugin feedback: Android duplicate reminder notification on first-time setup
|
||||||
|
|
||||||
|
**Date:** 2026-02-18
|
||||||
|
**Generated:** 2026-02-18 17:47:06 PST
|
||||||
|
**Target repo:** daily-notification-plugin (local copy at `daily-notification-plugin_test`)
|
||||||
|
**Consuming app:** crowd-funder-for-time-pwa (TimeSafari)
|
||||||
|
**Platform:** Android
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
When the user sets a **Reminder Notification for the first time** (toggle on → set message and time in `PushNotificationPermission`), **two notifications** fire at the scheduled time:
|
||||||
|
|
||||||
|
1. **Correct one:** User’s chosen title/message, from the static reminder alarm (`scheduleId` = `daily_timesafari_reminder`).
|
||||||
|
2. **Extra one:** Fallback message (“Daily Update” / “🌅 Good morning! Ready to make today amazing?”), from a second alarm that uses a **UUID** as `notification_id`.
|
||||||
|
|
||||||
|
When the user **edits** an existing reminder (Edit Notification Details), only one notification fires. The duplicate only happens on **initial** setup.
|
||||||
|
|
||||||
|
The app calls `scheduleDailyNotification` **once** per user action in both flows (first-time and edit). The duplicate is caused inside the plugin by the **prefetch worker** scheduling a second alarm via the legacy `DailyNotificationScheduler`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Evidence from Logcat
|
||||||
|
|
||||||
|
Filter: `DNP-SCHEDULE`, `DailyNotificationWorker`, `DailyNotificationReceiver`.
|
||||||
|
|
||||||
|
- **17:42:34** – Single call from app: plugin schedules the static reminder alarm (`scheduleId=daily_timesafari_reminder`, source=INITIAL_SETUP). One OS alarm is scheduled.
|
||||||
|
- **17:45:00** – **Two** `RECEIVE_START` events:
|
||||||
|
- First: `display=5e373fd1-0f08-4e8f-b166-cfd46d694d82` (UUID).
|
||||||
|
- Second: `static_reminder id=daily_timesafari_reminder`.
|
||||||
|
- Both run in parallel: Worker for UUID shows `DN|JIT_FRESH skip=true` and displays; Worker for `daily_timesafari_reminder` shows `DN|DISPLAY_STATIC_REMINDER` and displays. So two notifications are shown.
|
||||||
|
|
||||||
|
Conclusion: two different PendingIntents fire at the same time: one with `notification_id` = UUID, one with `notification_id` = `daily_timesafari_reminder`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Root cause (plugin side)
|
||||||
|
|
||||||
|
1. **ScheduleHelper.scheduleDailyNotification** (e.g. in `DailyNotificationPlugin.kt`):
|
||||||
|
- Cancels existing alarm for `scheduleId`.
|
||||||
|
- Schedules **one** alarm via **NotifyReceiver.scheduleExactNotification** with `reminderId = scheduleId`, `scheduleId = scheduleId`, `isStaticReminder = true` (INITIAL_SETUP). That alarm carries title/body in the intent and is the “correct” notification.
|
||||||
|
- Enqueues **DailyNotificationFetchWorker** (prefetch) to run 2 minutes before the same time.
|
||||||
|
|
||||||
|
2. **DailyNotificationFetchWorker** runs ~2 minutes before the display time:
|
||||||
|
- Tries to fetch content (e.g. native fetcher). For a static-reminder-only app (no URL, no fetcher returning content), the fetch returns empty/null.
|
||||||
|
- Goes to **handleFailedFetch** → **useFallbackContent** → **getFallbackContent** → **createEmergencyFallbackContent(scheduledTime)**.
|
||||||
|
- **createEmergencyFallbackContent** builds a `NotificationContent()` (default constructor), which assigns a **random UUID** as `id`, and sets title “Daily Update” and body “🌅 Good morning! Ready to make today amazing?”.
|
||||||
|
- **useFallbackContent** then calls **scheduleNotificationIfNeeded(fallbackContent)**.
|
||||||
|
|
||||||
|
3. **scheduleNotificationIfNeeded** uses the **legacy DailyNotificationScheduler** (AlarmManager) to schedule **another** alarm at the **same** `scheduledTime`, with `notification_id` = that UUID.
|
||||||
|
|
||||||
|
So at fire time there are two alarms:
|
||||||
|
|
||||||
|
- NotifyReceiver’s alarm: `notification_id` = `daily_timesafari_reminder`, `is_static_reminder` = true → correct user message.
|
||||||
|
- DailyNotificationScheduler’s alarm: `notification_id` = UUID → fallback message.
|
||||||
|
|
||||||
|
The prefetch path is intended for “fetch content then display” flows. For **static reminder** schedules, the display is already fully handled by the single NotifyReceiver alarm; the prefetch worker should not schedule a second alarm.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Why edit doesn’t show the duplicate (in observed behavior)
|
||||||
|
|
||||||
|
On edit, the app still calls the plugin once and the plugin again enqueues the prefetch worker. Possible reasons the duplicate is less obvious on edit:
|
||||||
|
|
||||||
|
- Different timing (e.g. user sets a time further out, or doesn’t wait for the second notification).
|
||||||
|
- Or the first-time run leaves the prefetch/legacy path in a state where the duplicate only appears on first setup.
|
||||||
|
|
||||||
|
Regardless, the **correct fix** is to ensure that for static-reminder schedules the prefetch worker never schedules a second alarm.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recommended fix (in the plugin)
|
||||||
|
|
||||||
|
**Option A (recommended): Do not enqueue prefetch for static reminder schedules**
|
||||||
|
|
||||||
|
In **ScheduleHelper.scheduleDailyNotification** (or equivalent), when scheduling a **static reminder** (title/body from app, no URL, display already in the intent), **do not** enqueue `DailyNotificationFetchWorker` for that run. The prefetch is for “fetch content then show”; for static reminders there is nothing to fetch and the only alarm should be the one from NotifyReceiver.
|
||||||
|
|
||||||
|
- No new inputData flags needed.
|
||||||
|
- No change to DailyNotificationFetchWorker semantics for other flows.
|
||||||
|
|
||||||
|
**Option B: Prefetch worker skips scheduling when display is already scheduled**
|
||||||
|
|
||||||
|
- When enqueueing the prefetch work for a static-reminder schedule, pass an input flag (e.g. `display_already_scheduled` or `is_static_reminder_schedule` = true).
|
||||||
|
- In **DailyNotificationFetchWorker**, in **useFallbackContent** (and anywhere else that calls **scheduleNotificationIfNeeded** for this work item), if that flag is set, **do not** call **scheduleNotificationIfNeeded**.
|
||||||
|
- Ensures only the NotifyReceiver alarm fires for that time.
|
||||||
|
|
||||||
|
Option A is simpler and matches the semantics: static reminder = one alarm, no prefetch.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## App-side behavior (no change required)
|
||||||
|
|
||||||
|
- **First-time reminder:** Account view opens `PushNotificationPermission` without `skipSchedule`. User sets time/message and confirms. Dialog’s `turnOnNativeNotifications` calls `NotificationService.scheduleDailyNotification(...)` **once** and then the callback saves settings. No second schedule from the app.
|
||||||
|
- **Edit reminder:** Account view opens the dialog with `skipSchedule: true`. Only the parent’s callback runs; it calls `cancelDailyNotification()` (on iOS) then `scheduleDailyNotification(...)` **once**. No double schedule from the app.
|
||||||
|
|
||||||
|
So the duplicate is entirely due to the plugin’s prefetch worker scheduling an extra alarm via the legacy scheduler; fixing it in the plugin as above will resolve the issue.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files to consider in the plugin
|
||||||
|
|
||||||
|
- **ScheduleHelper.scheduleDailyNotification** (e.g. in `DailyNotificationPlugin.kt`): where the single NotifyReceiver alarm and the prefetch work are enqueued. Either skip enqueueing prefetch for static reminder (Option A), or add inputData for “display already scheduled” (Option B).
|
||||||
|
- **DailyNotificationFetchWorker**: `useFallbackContent` → `scheduleNotificationIfNeeded`; if using Option B, skip `scheduleNotificationIfNeeded` when the new flag is set.
|
||||||
|
- **DailyNotificationScheduler** (legacy): used by `scheduleNotificationIfNeeded` to add the second (UUID) alarm; no change required if the worker simply stops calling it for static-reminder schedules.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
After the fix:
|
||||||
|
|
||||||
|
1. **First-time:** Turn on Reminder Notification, set message and time (e.g. 2–3 minutes ahead). Wait until the scheduled time. **Only one** notification should appear, with the user’s message.
|
||||||
|
2. Logcat should show a single `RECEIVE_START` at that time (e.g. `static_reminder id=daily_timesafari_reminder`), and no second `display=<uuid>` for the same time.
|
||||||
|
|
||||||
|
You can reuse the same Logcat filter as above to confirm a single receiver run per scheduled time.
|
||||||
74
doc/plugin-feedback-android-exact-alarm-settings.md
Normal file
74
doc/plugin-feedback-android-exact-alarm-settings.md
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
# Plugin feedback: Android exact alarm — stop opening Settings automatically
|
||||||
|
|
||||||
|
**Date:** 2026-03-09
|
||||||
|
**Target repo:** daily-notification-plugin
|
||||||
|
**Consuming app:** crowd-funder-for-time-pwa (TimeSafari)
|
||||||
|
**Platform:** Android
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
When the consuming app calls `scheduleDailyNotification()` after the user has granted `POST_NOTIFICATIONS`, the plugin checks whether **exact alarms** can be scheduled (Android 12+). If not, it **opens the system Settings** (exact-alarm or app-details screen) and **rejects** the call. This is intrusive: the app prefers to schedule without forcing the user into Settings, and to inform the user about exact alarms in its own UI (e.g. a note in the success message).
|
||||||
|
|
||||||
|
**Requested change:** Remove the automatic opening of Settings for exact alarm permission from `scheduleDailyNotification()`. Either:
|
||||||
|
|
||||||
|
- **Option A (preferred):** Do not open Settings and do not reject when exact alarm is not granted. Proceed with scheduling (using inexact alarms if necessary when exact is unavailable), and let the consuming app handle any UX (e.g. optional hint to enable exact alarms).
|
||||||
|
- **Option B:** Do not open Settings, but still reject with a clear error code/message when exact alarm is required and not granted, so the app can show its own message or deep-link to Settings if desired.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Where this lives in the plugin
|
||||||
|
|
||||||
|
**File:** `android/src/main/java/com/timesafari/dailynotification/DailyNotificationPlugin.kt`
|
||||||
|
**Method:** `scheduleDailyNotification(call: PluginCall)`
|
||||||
|
**Lines:** ~1057–1109 (exact line numbers may shift with edits)
|
||||||
|
|
||||||
|
Current behavior:
|
||||||
|
|
||||||
|
1. At the start of `scheduleDailyNotification()`, the plugin calls `canScheduleExactAlarms(context)`.
|
||||||
|
2. If `false`:
|
||||||
|
- If Android S+ and `canRequestExactAlarmPermission(context)` is true: it builds `Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM`, calls `context.startActivity(intent)`, logs **"Exact alarm permission required. Opened Settings for user to grant permission."** (tag `DNP-PLUGIN`), and rejects with `EXACT_ALARM_PERMISSION_REQUIRED`.
|
||||||
|
- Else: it opens app details (`Settings.ACTION_APPLICATION_DETAILS_SETTINGS`), logs **"Exact alarm permission denied. Directing user to app settings."**, and rejects with `PERMISSION_DENIED`.
|
||||||
|
3. Only if exact alarms are allowed does the plugin continue to schedule.
|
||||||
|
|
||||||
|
So the **exact alarms** feature here is: **gate scheduling on exact alarm permission and, when not granted, open Settings and reject.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Evidence from consumer app (logcat)
|
||||||
|
|
||||||
|
Filter: `DNP-PLUGIN`, `DNP-SCHEDULE`, `DailyNotificationWorker`, `DailyNotificationReceiver`.
|
||||||
|
|
||||||
|
Typical sequence when user enables daily notification:
|
||||||
|
|
||||||
|
1. `DNP-PLUGIN: Created pending permission request: ... type=POST_NOTIFICATIONS`
|
||||||
|
2. User grants notification permission.
|
||||||
|
3. `DNP-PLUGIN: Resolving pending POST_NOTIFICATIONS request on resume: granted=true`
|
||||||
|
4. App calls `scheduleDailyNotification(...)`.
|
||||||
|
5. `DNP-PLUGIN: Exact alarm permission required. Opened Settings for user to grant permission.`
|
||||||
|
|
||||||
|
The consumer app does **not** call any plugin API to “request exact alarm” or “open exact alarm settings”; it only calls `requestPermissions()` (POST_NOTIFICATIONS) and then `scheduleDailyNotification()`. The plugin’s own guard in `scheduleDailyNotification()` is what opens Settings.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Consumer app context
|
||||||
|
|
||||||
|
- **Permission flow:** The app requests `POST_NOTIFICATIONS` via the plugin’s `requestPermissions()`, then calls `scheduleDailyNotification()`. It does not request exact alarm permission itself.
|
||||||
|
- **UX:** The app already shows an optional note when exact alarm is not granted (e.g. “If notifications don’t appear, enable ‘Exact alarms’ in Android Settings → Apps → TimeSafari → App settings”). It does not want the plugin to open Settings automatically.
|
||||||
|
- **Manifest:** The app declares `SCHEDULE_EXACT_ALARM` in its AndroidManifest; the issue is only the **automatic redirect to Settings** and the **reject** when exact alarm is not yet granted.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Suggested plugin changes
|
||||||
|
|
||||||
|
1. **In `scheduleDailyNotification()`:** Remove the block that opens Settings and rejects when `!canScheduleExactAlarms(context)` (the block ~1057–1109). Do **not** call `startActivity` for `ACTION_REQUEST_SCHEDULE_EXACT_ALARM` or `ACTION_APPLICATION_DETAILS_SETTINGS` from this method.
|
||||||
|
2. **Scheduling when exact alarm is not granted:** Prefer Option A: continue and schedule even when exact alarms are not allowed (e.g. use inexact/alarm manager APIs that don’t require exact alarm, or document that timing may be approximate). If the plugin must reject when exact is required, use Option B: reject with a specific error code/message and no `startActivity`.
|
||||||
|
3. **Leave other APIs unchanged:** Methods such as `openExactAlarmSettings()` or `requestExactAlarmPermission()` can remain for apps that explicitly want to send the user to Settings; the change is only to stop doing it automatically inside `scheduleDailyNotification()`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Relation to existing docs
|
||||||
|
|
||||||
|
- **Plugin:** `doc/daily-notification-plugin-android-receiver-issue.md` and `doc/daily-notification-plugin-checklist.md` describe use of `SCHEDULE_EXACT_ALARM` (not `USE_EXACT_ALARM`). This feedback does not change that; it only asks to stop auto-opening Settings in `scheduleDailyNotification()`.
|
||||||
|
- **Consumer app:** `doc/notification-permissions-and-rollovers.md` describes the permission flow; `doc/NOTIFICATION_TROUBLESHOOTING.md` mentions exact alarms for user guidance.
|
||||||
|
|
||||||
|
Use this document when implementing the change in the **daily-notification-plugin** repo (e.g. with Cursor). After changing the plugin, update the consuming app’s dependency (e.g. `npm update @timesafari/daily-notification-plugin`), then `npx cap sync android` and rebuild.
|
||||||
151
doc/plugin-feedback-android-post-reboot-fallback-text.md
Normal file
151
doc/plugin-feedback-android-post-reboot-fallback-text.md
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
# Plugin feedback: Android daily notification shows fallback text after device restart
|
||||||
|
|
||||||
|
**Date:** 2026-02-23
|
||||||
|
**Target repo:** daily-notification-plugin
|
||||||
|
**Consuming app:** crowd-funder-for-time-pwa (TimeSafari)
|
||||||
|
**Platform:** Android
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
When the user sets a daily reminder (custom title/message) and then **restarts the device**, the notification still fires at the scheduled time but displays **fallback text** instead of the user’s message. If the device is **not** restarted, the same flow (app active, background, or closed) shows the correct user-set text.
|
||||||
|
|
||||||
|
So the regression is specific to **post-reboot**: the alarm survives reboot (good), but the **content** used for display is wrong (fallback).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Evidence from Logcat
|
||||||
|
|
||||||
|
Filter: `DNP-SCHEDULE`, `DailyNotificationWorker`, `DailyNotificationReceiver`.
|
||||||
|
|
||||||
|
**After reboot (boot recovery):**
|
||||||
|
|
||||||
|
```
|
||||||
|
02-23 16:28:44.489 W/DNP-SCHEDULE: Skipping duplicate schedule: id=daily_timesafari_reminder, nextRun=2026-02-24 16:28:44, source=BOOT_RECOVERY
|
||||||
|
02-23 16:28:44.489 W/DNP-SCHEDULE: Existing PendingIntent found for requestCode=53438 - alarm already scheduled
|
||||||
|
```
|
||||||
|
|
||||||
|
So boot recovery does **not** replace the alarm; the existing PendingIntent is kept.
|
||||||
|
|
||||||
|
**When the notification fires (after reboot):**
|
||||||
|
|
||||||
|
```
|
||||||
|
02-23 16:32:00.601 D/DailyNotificationReceiver: DN|RECEIVE_START action=org.timesafari.daily.NOTIFICATION
|
||||||
|
02-23 16:32:00.650 D/DailyNotificationReceiver: DN|WORK_ENQUEUE display=notify_1771835520000 work_name=display_notify_1771835520000
|
||||||
|
02-23 16:32:00.847 D/DailyNotificationWorker: DN|WORK_START id=notify_1771835520000 action=display ...
|
||||||
|
02-23 16:32:00.912 D/DailyNotificationWorker: DN|DISPLAY_START id=notify_1771835520000
|
||||||
|
02-23 16:32:00.982 D/DailyNotificationWorker: DN|JIT_FRESH skip=true ageMin=0 id=notify_1771835520000
|
||||||
|
02-23 16:32:00.982 D/DailyNotificationWorker: DN|DISPLAY_NOTIF_START id=notify_1771835520000
|
||||||
|
02-23 16:32:01.018 I/DailyNotificationWorker: DN|DISPLAY_NOTIF_OK id=notify_1771835520000
|
||||||
|
```
|
||||||
|
|
||||||
|
Important detail: the worker logs **`DN|JIT_FRESH skip=true`**, and there is **no** `DN|DISPLAY_STATIC_REMINDER`. So the **static reminder path** (title/body from Intent extras) is **not** used; the worker is using the path that loads content from Room/legacy and runs the JIT freshness check. That path is used when `is_static_reminder` is false or when `title`/`body` are missing from the WorkManager input.
|
||||||
|
|
||||||
|
Conclusion: when the alarm fires after reboot, the receiver either gets an Intent **without** (or with cleared) `title`, `body`, and `is_static_reminder`, or the WorkManager input is built without them, so the worker falls back to Room/legacy (and possibly to NativeFetcher), which produces fallback text.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Root cause (plugin side)
|
||||||
|
|
||||||
|
### 1. PendingIntent extras may not survive reboot
|
||||||
|
|
||||||
|
On Android, when an alarm is scheduled, the system stores the PendingIntent. After a **device reboot**, the alarm is restored from persisted state, but it is possible that **Intent extras** (e.g. `title`, `body`, `is_static_reminder`) are **not** persisted or are stripped when the broadcast is delivered. So when `DailyNotificationReceiver.onReceive` runs after reboot, `intent.getStringExtra("title")` and `intent.getStringExtra("body")` may be null, and `intent.getBooleanExtra("is_static_reminder", false)` may be false. The receiver still has `notification_id` (so the work is enqueued with that id), but the Worker input has no static reminder data, so the worker correctly takes the “load from Room / JIT” path. If the content then comes from Room with wrong/fallback data, or from the app’s NativeFetcher (which returns placeholder text), the user sees fallback text.
|
||||||
|
|
||||||
|
### 2. Boot/force-stop recovery uses hardcoded title/body
|
||||||
|
|
||||||
|
In `ReactivationManager.rescheduleAlarmForBoot` and `rescheduleAlarm` (and similarly in `BootReceiver` if it ever reschedules), the config used for rescheduling is:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
val config = UserNotificationConfig(
|
||||||
|
...
|
||||||
|
title = "Daily Notification",
|
||||||
|
body = "Your daily update is ready",
|
||||||
|
...
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
So whenever recovery **does** reschedule (e.g. after force-stop or in a code path that replaces the alarm), the new Intent carries this fallback text. In the **current** log, boot recovery **skips** rescheduling (duplicate found), so this is not the path that ran. But if in other builds or OEMs recovery does reschedule, or if a future change replaces the PendingIntent after reboot, the same bug would appear. So recovery should not use hardcoded strings when the schedule has known title/body.
|
||||||
|
|
||||||
|
### 3. Schedule entity does not store title/body
|
||||||
|
|
||||||
|
`Schedule` in `DatabaseSchema.kt` has no `title` or `body` fields. So after reboot there is no way to recover the user’s message from the plugin DB when:
|
||||||
|
|
||||||
|
- The Intent extras are missing (post-reboot delivery), or
|
||||||
|
- Recovery needs to reschedule and should use the same title/body as before.
|
||||||
|
|
||||||
|
The plugin **does** store a `NotificationContentEntity` (with title/body) when scheduling in `NotifyReceiver`, keyed by `notificationId`. So in principle the worker could get the right text by loading that entity when the Intent lacks title/body. That only works if:
|
||||||
|
|
||||||
|
- The worker is given the same `notification_id` that was used when storing the entity, and
|
||||||
|
- The entity was actually written and not overwritten by another path (e.g. prefetch/fallback).
|
||||||
|
|
||||||
|
If after reboot the delivered Intent has a different or missing `notification_id`, or the Room lookup fails (e.g. different id convention, DB not ready), the worker would fall back to legacy storage or fetcher, hence fallback text.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recommended fix (in the plugin)
|
||||||
|
|
||||||
|
### A. Persist title/body for static reminders and use when extras are missing
|
||||||
|
|
||||||
|
1. **Persist title/body (and optionally sound/vibration/priority) for static reminders**
|
||||||
|
- Either extend the `Schedule` entity with `title`, `body` (and optionally other display fields), or ensure there is a single, authoritative `NotificationContentEntity` per schedule/notification id that is written at schedule time and not overwritten by prefetch/fallback.
|
||||||
|
- When the app calls `scheduleDailyNotification` with a static reminder, store these values (already done for `NotificationContentEntity` in `NotifyReceiver`; ensure the same id is used for lookup after reboot).
|
||||||
|
|
||||||
|
2. **In `DailyNotificationReceiver.enqueueNotificationWork`**
|
||||||
|
- If the Intent has `notification_id` but **missing** `title`/`body` (or they are empty), or `is_static_reminder` is false but the schedule is known to be a static reminder:
|
||||||
|
- Resolve the schedule/notification id (e.g. from `schedule_id` extra if present, or from `notification_id` if it matches a known pattern).
|
||||||
|
- Load title/body (and other display fields) from the plugin DB (Schedule or NotificationContentEntity).
|
||||||
|
- If found, pass them into the Worker input and set `is_static_reminder = true` so the worker uses the static reminder path with the correct text.
|
||||||
|
|
||||||
|
3. **In `DailyNotificationWorker.handleDisplayNotification`**
|
||||||
|
- When loading content from Room by `notification_id`, if the entity exists and has title/body, use it as-is for display and **skip** replacing it with JIT/fetcher content for that run (or treat it as static for this display so JIT doesn’t overwrite user text with fetcher fallback).
|
||||||
|
|
||||||
|
This way, even if the broadcast Intent loses extras after reboot, the receiver or worker can still show the user’s message from persisted storage.
|
||||||
|
|
||||||
|
### B. Use persisted title/body in boot/force-stop recovery
|
||||||
|
|
||||||
|
- In `ReactivationManager.rescheduleAlarmForBoot`, `rescheduleAlarm`, and any similar recovery path that builds a `UserNotificationConfig`:
|
||||||
|
- Load the schedule (and associated title/body) from the DB (e.g. from `Schedule` if extended, or from `NotificationContentEntity` by schedule/notification id).
|
||||||
|
- If title/body exist, use them in the config instead of `"Daily Notification"` / `"Your daily update is ready"`.
|
||||||
|
- Only use the hardcoded fallback when no persisted title/body exist (e.g. legacy schedules).
|
||||||
|
|
||||||
|
This ensures that any time recovery reschedules an alarm, the user’s custom message is preserved.
|
||||||
|
|
||||||
|
### C. Ensure one canonical content record per static reminder
|
||||||
|
|
||||||
|
- Ensure that for a given static reminder schedule, the `NotificationContentEntity` written at schedule time (in `NotifyReceiver`) is the one used for display when the alarm fires (including after reboot), and that prefetch/fallback paths do not overwrite that entity for the same logical notification (e.g. same schedule id or same notification id). If the worker currently loads by `notification_id`, ensure that id is stable and matches what was stored at schedule time.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## App-side behavior (no change required for this bug)
|
||||||
|
|
||||||
|
- The app calls `scheduleDailyNotification` once with `id: "daily_timesafari_reminder"`, `title`, and `body`. It does not reschedule after reboot; the plugin’s boot recovery and alarm delivery are entirely on the plugin side.
|
||||||
|
- The app’s `TimeSafariNativeFetcher` returns placeholder text; that is only used when the plugin takes the “fetch content” path. Fixing the plugin so that after reboot the static reminder path (or Room content with user title/body) is used will prevent that placeholder from appearing for the user’s reminder.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification after fix
|
||||||
|
|
||||||
|
1. Set a daily reminder with a **distinct** custom message (e.g. “My custom reminder text”).
|
||||||
|
2. **Restart the device** (full reboot).
|
||||||
|
3. Wait until the scheduled time (or set it 1–2 minutes ahead for a quick test).
|
||||||
|
4. Confirm that the notification shows **“My custom reminder text”** (or the chosen title), not “Daily Notification” / “Your daily update is ready” or the NativeFetcher placeholder.
|
||||||
|
5. In logcat, after the notification fires, you should see either:
|
||||||
|
- `DN|DISPLAY_STATIC_REMINDER` with the correct title, or
|
||||||
|
- A path that loads content from Room and displays it without overwriting with fetcher fallback.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files to consider in the plugin
|
||||||
|
|
||||||
|
- **NotifyReceiver.kt** – Already stores `NotificationContentEntity` at schedule time; ensure the same `notificationId` used in the PendingIntent is the one used for this entity so post-reboot lookup by `notification_id` finds it.
|
||||||
|
- **DailyNotificationReceiver.java** – In `enqueueNotificationWork`, add a fallback: if Intent has `notification_id` but no (or empty) `title`/`body`, look up title/body from DB (by `schedule_id` or `notification_id`) and pass them into Worker input with `is_static_reminder = true`.
|
||||||
|
- **DailyNotificationWorker.java** – When loading from Room for a given `notification_id`, prefer that entity for display and avoid overwriting with JIT/fetcher content when the content is for a static reminder (e.g. same id as a schedule that was created as static).
|
||||||
|
- **ReactivationManager.kt** – In `rescheduleAlarmForBoot` and `rescheduleAlarm`, load title/body from Schedule or NotificationContentEntity and use them in `UserNotificationConfig` instead of hardcoded strings.
|
||||||
|
- **DatabaseSchema.kt** (optional) – If you prefer to keep title/body on the schedule, add `title` and `body` (and optionally other display fields) to the `Schedule` entity and persist them when the app calls `scheduleDailyNotification`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Short summary for Cursor (plugin-side)
|
||||||
|
|
||||||
|
**Bug:** After Android device restart, the daily notification still fires but shows fallback text instead of the user-set message. Logs show the worker uses the non-static path (`JIT_FRESH`, no `DISPLAY_STATIC_REMINDER`), so Intent extras (title/body/is_static_reminder) are likely missing after reboot.
|
||||||
|
|
||||||
|
**Fix:** (1) When the receiver has `notification_id` but missing title/body, look up title/body from the plugin DB (Schedule or NotificationContentEntity) and pass them into the Worker as static reminder data. (2) In boot/force-stop recovery, load title/body from DB and use them when rescheduling instead of hardcoded “Daily Notification” / “Your daily update is ready”. (3) Ensure the NotificationContentEntity written at schedule time is the one used for display after reboot (same id, not overwritten by prefetch/fallback).
|
||||||
169
doc/plugin-feedback-android-rollover-after-reboot.md
Normal file
169
doc/plugin-feedback-android-rollover-after-reboot.md
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
# Plugin feedback: Android rollover notification may not fire after device restart (app not launched)
|
||||||
|
|
||||||
|
**Date:** 2026-02-24 18:24
|
||||||
|
**Target repo:** daily-notification-plugin
|
||||||
|
**Consuming app:** crowd-funder-for-time-pwa (TimeSafari)
|
||||||
|
**Platform:** Android
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Boot recovery can **skip** rescheduling after a device restart (it sees an “existing PendingIntent” and skips). Whether the next notification **fails to fire** as a result depends on **whether the alarm survived the reboot**: Android’s documented behavior is that AlarmManager alarms do **not** persist across reboot, but on some devices/builds they **do** (implementation-dependent). So: **(1)** *Set schedule → restart shortly after → wait for first notification:* On at least one device, the initial notification **fired** even though boot recovery skipped (alarm had survived reboot). On devices where alarms are cleared, that initial notification would not fire. **(2)** *Set schedule → first notification fires → restart → wait for rollover:* Same logic—if the rollover alarm is cleared and boot recovery skips, the rollover won’t fire. The **fix** (always reschedule in the boot path, skip idempotence there) remains correct: it makes behavior reliable regardless of alarm persistence. See [Scenario 1: observed behavior](#scenario-1-observed-behavior) and [Two distinct scenarios](#two-distinct-scenarios-same-bug-different-victim-notification).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Definitions
|
||||||
|
|
||||||
|
- **Rollover (in this doc):** The next occurrence of the daily notification. Concretely: when today’s alarm fires, the plugin runs `scheduleNextNotification()` and sets an alarm for the same time the next day. That “next day” alarm is the rollover.
|
||||||
|
- **Boot recovery:** When the device boots, the plugin’s `BootReceiver` receives `BOOT_COMPLETED` (and/or `LOCKED_BOOT_COMPLETED`) and calls into the plugin to reschedule alarms from persisted schedule data.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Android behavior: alarm persistence across reboot is implementation-dependent
|
||||||
|
|
||||||
|
- **Documented behavior:** AlarmManager alarms are **not** guaranteed to persist across a full device reboot; the platform may clear them when the device is turned off and rebooted. Apps are expected to reschedule on `BOOT_COMPLETED`.
|
||||||
|
- **Observed behavior:** On some devices or Android builds, alarms (e.g. from `setAlarmClock()`) **do** survive reboot. So whether the next notification fires after a reboot when boot recovery **skips** depends on the device: if the alarm survived, it can still fire; if it was cleared, it will not fire until the app is opened and reschedules.
|
||||||
|
|
||||||
|
So the **reliable** way to guarantee the next notification fires after reboot is for boot recovery to **always** call `AlarmManager.setAlarmClock()` (or equivalent) again, and not to skip based on “existing PendingIntent.”
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scenario 1: observed behavior (schedule → restart → wait for first notification)
|
||||||
|
|
||||||
|
Logcat from a real test (schedule set, device restarted shortly after, app not launched):
|
||||||
|
|
||||||
|
**Before reboot (initial schedule):**
|
||||||
|
```
|
||||||
|
02-24 18:56:36 ... Scheduling next daily alarm: id=daily_timesafari_reminder, nextRun=2026-02-24 19:00:00, source=INITIAL_SETUP
|
||||||
|
02-24 18:56:36 ... Scheduling OS alarm: ... requestCode=53438, scheduleId=daily_timesafari_reminder ...
|
||||||
|
```
|
||||||
|
|
||||||
|
**After reboot (boot recovery):**
|
||||||
|
```
|
||||||
|
02-24 18:56:48 ... Skipping duplicate schedule: id=daily_timesafari_reminder, nextRun=2026-02-24 19:00:00, source=BOOT_RECOVERY
|
||||||
|
02-24 18:56:48 ... Existing PendingIntent found for requestCode=53438 - alarm already scheduled
|
||||||
|
```
|
||||||
|
|
||||||
|
**At scheduled time (19:00:00):**
|
||||||
|
```
|
||||||
|
02-24 19:00:00 ... DailyNotificationReceiver: DN|RECEIVE_START action=org.timesafari.daily.NOTIFICATION
|
||||||
|
02-24 19:00:00 ... DailyNotificationWorker: DN|DISPLAY_NOTIF_OK ...
|
||||||
|
02-24 19:00:01 ... DN|ROLLOVER next=1772017200000 scheduleId=daily_rollover_1771930801007 ...
|
||||||
|
```
|
||||||
|
|
||||||
|
So in this run, **boot recovery skipped** (duplicate + existing PendingIntent), but the **initial notification still fired** at 19:00. That implies the alarm **survived the reboot** on this device. On devices where alarms are cleared on reboot, the same skip would mean the initial notification would **not** fire. Conclusion: scenario 1 failure is **device-dependent**; the fix (always reschedule on boot) removes that dependence.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Two distinct scenarios (same bug, different “victim” notification)
|
||||||
|
|
||||||
|
The same boot-recovery skip can affect either the **initial** notification or the **rollover** notification, depending on when the user restarts and whether the alarm survived reboot:
|
||||||
|
|
||||||
|
| # | User sequence | What is lost on reboot (if alarms cleared) | What fails if boot recovery skips **and** alarm was cleared |
|
||||||
|
|---|----------------|--------------------------------------------|--------------------------------------------------------------|
|
||||||
|
| **1** | Set schedule → **restart shortly after** → wait for first notification | Alarm for the **first** occurrence (e.g. 19:00 same day). | **Initial** notification never fires. *(Observed on one device: alarm survived, so notification fired despite skip.)* |
|
||||||
|
| **2** | Set schedule → **first notification fires** (rollover set) → restart → wait for next day | Alarm for the **rollover** (next day, e.g. `daily_rollover_*`). | **Rollover** notification never fires. |
|
||||||
|
|
||||||
|
- **Scenario 1:** User configures a daily reminder, then reboots before the first fire. If the alarm is cleared on reboot and boot recovery skips, the first notification never fires. If the alarm survives (as in the logcat above), it can still fire.
|
||||||
|
- **Scenario 2:** After the first fire, the plugin creates a **new** schedule (e.g. `daily_rollover_1771930801007`) and sets an alarm for the next day. If the device reboots, that rollover alarm may or may not persist. If it is cleared and boot recovery only reschedules the primary `daily_timesafari_reminder` (and skips), or does not reschedule the rollover, the rollover notification may not fire.
|
||||||
|
|
||||||
|
In both cases the **fix** is the same: in the boot recovery path, skip the “existing PendingIntent” idempotence check so the plugin always re-registers the alarm(s) after reboot, making behavior reliable regardless of whether the OEM clears alarms.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Daily notification flow (relevant parts)
|
||||||
|
|
||||||
|
1. **Initial schedule (app):** User sets a daily time (e.g. 09:00). App calls `scheduleDailyNotification({ time, title, body, id })`. Plugin stores schedule and sets an alarm for the next occurrence (e.g. tomorrow 09:00 if today 09:00 has passed).
|
||||||
|
2. **When the alarm fires:** `DailyNotificationReceiver` runs, shows the notification, and the plugin calls `scheduleNextNotification()` (rollover), which schedules the **next day** at the same time via `NotifyReceiver.scheduleExactNotification(..., ScheduleSource.ROLLOVER_ON_FIRE)`.
|
||||||
|
3. **After reboot:** No alarm exists. `BootReceiver` runs (without the app being launched). It should load the schedule from the DB, compute the next run time, and call the same scheduling path to re-register the alarm with AlarmManager.
|
||||||
|
|
||||||
|
If step 3 does **not** actually register an alarm (because boot recovery skips), and the device **cleared** alarms on reboot, the next notification will not fire until the user opens the app. If the alarm survived reboot (device-dependent), it can still fire despite the skip.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Evidence that boot recovery can skip rescheduling
|
||||||
|
|
||||||
|
Boot recovery repeatedly logs that it is **skipping** reschedule (see also `doc/plugin-feedback-android-post-reboot-fallback-text.md` and the Scenario 1 logcat above):
|
||||||
|
|
||||||
|
```
|
||||||
|
Skipping duplicate schedule: id=daily_timesafari_reminder, nextRun=..., source=BOOT_RECOVERY
|
||||||
|
Existing PendingIntent found for requestCode=53438 - alarm already scheduled
|
||||||
|
```
|
||||||
|
|
||||||
|
So boot recovery **does not** call `AlarmManager.setAlarmClock()` in those runs; it relies on “existing PendingIntent” and skips. The “existing PendingIntent” comes from the plugin’s idempotence check (e.g. `PendingIntent.getBroadcast(..., FLAG_NO_CREATE)`), which can still return non-null after reboot (e.g. cached by package/requestCode/Intent identity). That does **not** prove an alarm is still registered: on some devices alarms are cleared on reboot, so after a skip there would be no alarm and the next notification would not fire. On other devices (as in the Scenario 1 test above) the alarm can survive, so the notification still fires despite the skip. So the **risk** of a missed notification after reboot is **device-dependent**; the fix (always reschedule on boot) removes that dependence.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Root cause (plugin side)
|
||||||
|
|
||||||
|
1. **Idempotence in `scheduleExactNotification`:** Before scheduling, the plugin checks for an “existing” PendingIntent (and possibly DB state). If found, it skips scheduling to avoid duplicates.
|
||||||
|
2. **Boot recovery uses the same path:** When `BootReceiver` runs, it calls into the same scheduling logic with `source = BOOT_RECOVERY` and **without** skipping the idempotence check (default `skipPendingIntentIdempotence = false` or equivalent).
|
||||||
|
3. **After reboot:** The “existing PendingIntent” check can still succeed (e.g. cached), so boot recovery skips and does not call `AlarmManager.setAlarmClock()`. On devices where alarms are cleared on reboot, no alarm is re-registered and the next notification will not fire until the app is opened. On devices where alarms survive (as in the Scenario 1 test), the notification can still fire.
|
||||||
|
|
||||||
|
So the **reliable** behavior is: **boot recovery should always re-register the alarm after reboot** (e.g. by skipping the PendingIntent idempotence check in the boot path), so that the app does not depend on implementation-dependent alarm persistence.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recommended fix (in the plugin)
|
||||||
|
|
||||||
|
**Idea:** In the **boot recovery** path only, force a real reschedule and avoid the “existing PendingIntent” skip. After reboot there is no alarm; treating it as “already scheduled” is wrong.
|
||||||
|
|
||||||
|
**Concrete options:**
|
||||||
|
|
||||||
|
1. **Skip PendingIntent idempotence when source is BOOT_RECOVERY**
|
||||||
|
When calling `NotifyReceiver.scheduleExactNotification` from boot recovery (e.g. from `ReactivationManager.rescheduleAlarmForBoot` or from `BootReceiver`), pass a flag so that the “existing PendingIntent” check is **skipped** (e.g. `skipPendingIntentIdempotence = true` or a dedicated `forceRescheduleAfterBoot = true`).
|
||||||
|
That way, boot recovery always calls `AlarmManager.setAlarmClock()` (or equivalent) and re-registers the alarm, even if `PendingIntent.getBroadcast(..., FLAG_NO_CREATE)` still returns non-null from a pre-reboot cache.
|
||||||
|
|
||||||
|
2. **Separate boot path that never skips**
|
||||||
|
Alternatively, implement a dedicated “reschedule for boot” path that does not go through the same idempotence branch as user/manual reschedule. That path should always compute the next run time from the persisted schedule and call AlarmManager to set the alarm, without checking for an “existing” PendingIntent.
|
||||||
|
|
||||||
|
3. **Do not rely on PendingIntent existence as “alarm is set” after reboot**
|
||||||
|
If the plugin currently infers “alarm already scheduled” from “PendingIntent exists,” that inference is wrong after reboot. Either skip that check when the call is from boot recovery, or after reboot always re-register and only use idempotence for in-process duplicate prevention (e.g. when the user taps “Save” twice in a short time).
|
||||||
|
|
||||||
|
**Recommendation:** Option 1 is the smallest change: in the boot recovery call site(s), pass `skipPendingIntentIdempotence = true` (or the equivalent flag) so that scheduling is not skipped and the alarm is always re-registered after reboot.
|
||||||
|
|
||||||
|
**Will this cause duplicate alarms when the alarm survived reboot?** No. When boot recovery calls `setAlarmClock()` (or equivalent), it uses the same `scheduleId` and thus the same `requestCode` and same Intent (and hence the same logical PendingIntent) as the existing alarm. On Android, setting an alarm with a PendingIntent that matches one already registered **replaces** that alarm; it does not add a second one. So you end up with one alarm either way—either the one that survived reboot (now effectively “confirmed” by the second call) or the one just set if the previous one had been cleared. No duplicate notifications.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification after fix
|
||||||
|
|
||||||
|
1. Schedule a daily notification for a time a few minutes in the future (or use a test build that allows short intervals).
|
||||||
|
2. Let it fire once so the plugin schedules the rollover (next day).
|
||||||
|
3. **Restart the device** and do **not** open the app.
|
||||||
|
4. Wait until the next scheduled time (next day, or the same day if testing with a second alarm a few minutes later).
|
||||||
|
5. Confirm that the notification **does** fire.
|
||||||
|
6. In logcat after reboot, you should see boot recovery **not** logging “Skipping duplicate schedule” / “Existing PendingIntent found” for this schedule, and you should see the alarm being set (e.g. “Scheduling OS alarm” or similar).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## App-side behavior
|
||||||
|
|
||||||
|
No change is required in the consuming app for this bug. The app does not reschedule after reboot; that is the plugin’s responsibility via `BootReceiver` and boot recovery. Fixing the plugin so that boot recovery always re-registers the alarm (and does not skip due to PendingIntent idempotence) is sufficient.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Short summary for plugin maintainers
|
||||||
|
|
||||||
|
**Issue:** After an Android device restart, boot recovery skips rescheduling when it finds an “existing PendingIntent.” On devices where AlarmManager clears alarms on reboot, that skip means the next daily notification (initial or rollover) will not fire until the app is opened. On devices where alarms survive reboot, the notification can still fire (as observed in a Scenario 1 test). So the failure is device-dependent; the plugin should not rely on alarm persistence.
|
||||||
|
|
||||||
|
**Fix:** In the boot recovery path, when calling `scheduleExactNotification` (or the equivalent), pass a flag to **skip** the “existing PendingIntent” idempotence check (e.g. `skipPendingIntentIdempotence = true`), so that the alarm is always re-registered after reboot and behavior is reliable on all devices.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## For Cursor (plugin repo) — actionable fix
|
||||||
|
|
||||||
|
Use this section when applying the fix in the **daily-notification-plugin** repo (e.g. with Cursor).
|
||||||
|
|
||||||
|
**Goal:** When rescheduling after boot, **always** register the alarm with AlarmManager. Do not skip because “existing PendingIntent” was found (that check can be true after reboot even though the alarm was cleared).
|
||||||
|
|
||||||
|
**Change:** At every call site where the plugin invokes `NotifyReceiver.scheduleExactNotification` (or the Kotlin equivalent) for **boot recovery** (i.e. when the schedule source is `BOOT_RECOVERY` or the call is from `BootReceiver` / `ReactivationManager.rescheduleAlarmForBoot`), pass **`skipPendingIntentIdempotence = true`** so that the idempotence check is skipped and the alarm is always set.
|
||||||
|
|
||||||
|
**Files to look at (plugin Android code):**
|
||||||
|
|
||||||
|
- **ReactivationManager.kt** — Find `rescheduleAlarmForBoot` (or similar). It likely calls `NotifyReceiver.scheduleExactNotification(...)`. Ensure that call passes `skipPendingIntentIdempotence = true` (and `source = ScheduleSource.BOOT_RECOVERY` if applicable).
|
||||||
|
- **BootReceiver.kt** — If it calls `scheduleExactNotification` or invokes ReactivationManager for boot, ensure that path passes `skipPendingIntentIdempotence = true`.
|
||||||
|
|
||||||
|
**Method signature (for reference):**
|
||||||
|
`NotifyReceiver.scheduleExactNotification(context, triggerAtMillis, config, isStaticReminder, reminderId, scheduleId, source, skipPendingIntentIdempotence)`. The last parameter is what must be `true` for boot recovery.
|
||||||
|
|
||||||
|
**Verification:** After the change, trigger a device reboot (app not launched), then inspect logcat. You should **not** see “Skipping duplicate schedule” / “Existing PendingIntent found” for `source=BOOT_RECOVERY`; you should see “Scheduling OS alarm” (or equivalent) so the alarm is re-registered.
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
# Plugin feedback: Android rollover — two notifications, neither with user content
|
||||||
|
|
||||||
|
**Date:** 2026-02-26 18:03
|
||||||
|
**Target repo:** daily-notification-plugin
|
||||||
|
**Consuming app:** crowd-funder-for-time-pwa (TimeSafari)
|
||||||
|
**Platform:** Android
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
When waiting for the rollover notification at the scheduled time:
|
||||||
|
|
||||||
|
1. **Two different notifications fired** — one ~3 minutes before the schedule (21:53), one on the dot (21:56). They showed different text (neither the user’s).
|
||||||
|
2. **Neither notification contained the user-set content** — both used Room/fallback content (`DN|DISPLAY_USE_ROOM_CONTENT`, `skip JIT`), not the static reminder path.
|
||||||
|
3. **Main-thread DB access** — receiver logged `db_fallback_failed` with "Cannot access database on the main thread".
|
||||||
|
|
||||||
|
Fixes are required in the **daily-notification-plugin** (and optionally one app-side improvement). This doc gives the diagnosis and recommended changes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Evidence from Logcat
|
||||||
|
|
||||||
|
Filter: `DNP-SCHEDULE`, `DailyNotificationWorker`, `DailyNotificationReceiver`.
|
||||||
|
|
||||||
|
### First notification (21:53 — 3 minutes before schedule)
|
||||||
|
|
||||||
|
- `display=68ea176c-c9c0-4ef3-bd0c-61b67c8a3982` (UUID-style id)
|
||||||
|
- `DN|WORK_ENQUEUE db_fallback_failed` — DB access on main thread when building work input
|
||||||
|
- `DN|DISPLAY_USE_ROOM_CONTENT id=68ea176c-... (skip JIT)` — content from Room, not static reminder
|
||||||
|
- After display: `DN|ROLLOVER next=1772113980000 scheduleId=daily_rollover_1772027581028 static=false`
|
||||||
|
- New schedule created for **next day at 21:53**
|
||||||
|
|
||||||
|
### Second notification (21:56 — on the dot)
|
||||||
|
|
||||||
|
- `display=notify_1772027760000` (time-based id; 1772027760000 = 2026-02-25 21:56)
|
||||||
|
- `DN|DISPLAY_USE_ROOM_CONTENT id=notify_1772027760000 (skip JIT)` — again Room content, not user text
|
||||||
|
- After display: `DN|ROLLOVER next=1772114160000 scheduleId=daily_rollover_1772027760210 static=false`
|
||||||
|
- New schedule created for **next day at 21:56**
|
||||||
|
|
||||||
|
So the user’s chosen time was **21:56**. The 21:53 alarm was a **separate** schedule (from a previous rollover or prefetch that used 21:53).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Root causes
|
||||||
|
|
||||||
|
### 1. Two alarms for two different times
|
||||||
|
|
||||||
|
- **21:53** — Alarm with `notification_id` = UUID (`68ea176c-...`). This matches the “prefetch fallback” or “legacy scheduler” path: when prefetch fails or a rollover is created with a time that doesn’t match the **current** user schedule, the plugin can schedule an alarm with a **random UUID** and default content (see `doc/plugin-feedback-android-duplicate-reminder-notification.md`). So at some point an alarm was set for 21:53 (e.g. a previous day’s rollover for 21:53, or a prefetch that scheduled fallback for 21:53).
|
||||||
|
- **21:56** — Alarm with `notification_id` = `notify_1772027760000`. This is the “real” schedule (user chose 21:56). The id is time-based, not the app’s static reminder id `daily_timesafari_reminder`.
|
||||||
|
|
||||||
|
So there are **two logical schedules** active: one for 21:53 (stale or from prefetch) and one for 21:56. When the user reschedules to 21:56, the plugin must **cancel all previous alarms** for this reminder, including any rollover or prefetch-created alarm for 21:53 (and any other `daily_rollover_*` or UUID-based alarms that belong to the same logical reminder). Otherwise both fire and the user sees two notifications with different text.
|
||||||
|
|
||||||
|
**Plugin fix:** When the app calls `scheduleDailyNotification` with a given `scheduleId` (e.g. `daily_timesafari_reminder`):
|
||||||
|
|
||||||
|
- Cancel **every** alarm that belongs to this reminder: the main schedule, all rollover schedules (`daily_rollover_*` that map to this reminder), and any prefetch-scheduled display alarm (UUID) that was created for this reminder.
|
||||||
|
- For static reminders, **do not** enqueue prefetch work that will create a second alarm (see duplicate-reminder doc). If prefetch is already disabled for static reminders, then the 21:53 UUID alarm likely came from an **old rollover** (previous day’s fire at 21:53). So rollover must either (a) use a **stable** schedule id that gets cancelled when the user reschedules (e.g. same `scheduleId` or a known prefix), or (b) the plugin must cancel by “logical reminder” (e.g. all schedules whose next run is for this reminder) when the user sets a new time.
|
||||||
|
|
||||||
|
### 2. User content not used (USE_ROOM_CONTENT, skip JIT)
|
||||||
|
|
||||||
|
- There is **no** `DN|DISPLAY_STATIC_REMINDER` in the logs. So the worker did **not** receive (or use) static reminder title/body.
|
||||||
|
- Both runs show `DN|DISPLAY_USE_ROOM_CONTENT ... (skip JIT)`: content is loaded from Room by `notification_id` and JIT/fetcher is skipped. So the worker is using **Room content keyed by the run’s notification_id** (UUID or `notify_*`), not by the app’s reminder id `daily_timesafari_reminder`.
|
||||||
|
|
||||||
|
The app stores title/body when it calls `scheduleDailyNotification`; the plugin should store that in a way that survives rollover and is used when the alarm fires. If the **Intent** carries `notification_id` = `notify_1772027760000` (or a UUID) and no title/body (e.g. after reboot or when the rollover PendingIntent doesn’t carry extras), the worker looks up Room by that id. The entity for `daily_timesafari_reminder` (user title/body) is a **different** key, so the worker either finds nothing or finds fallback content written by prefetch for that run.
|
||||||
|
|
||||||
|
**Plugin fix (see also `doc/plugin-feedback-android-post-reboot-fallback-text.md`):**
|
||||||
|
|
||||||
|
- **Receiver:** When the Intent has `notification_id` but **missing** title/body (or `is_static_reminder` is false), resolve the “logical” reminder id (e.g. from `schedule_id` extra, or from a mapping: rollover schedule id → `daily_timesafari_reminder`, or from NotificationContentEntity by schedule id). Load title/body from DB (Schedule or NotificationContentEntity) for that reminder and pass them into the Worker with `is_static_reminder = true`.
|
||||||
|
- **Worker:** When displaying, if input has static reminder title/body, use them and do not overwrite with Room content keyed by run-specific id. When loading from Room by `notification_id`, if the run’s id is a rollover or time-based id, also look up the **canonical** reminder id (e.g. `daily_timesafari_reminder`) and prefer that entity’s title/body if present, so rollover displays user text.
|
||||||
|
- **Rollover scheduling:** When scheduling the next day’s alarm (ROLLOVER_ON_FIRE), pass title/body (or a stable reminder id) so the next fire’s Intent or Worker input can resolve user content. Optionally store title/body on the Schedule entity so boot recovery and rollover can always load them.
|
||||||
|
|
||||||
|
### 3. Main-thread database access
|
||||||
|
|
||||||
|
- `DN|WORK_ENQUEUE db_fallback_failed id=68ea176c-... err=Cannot access database on the main thread...`
|
||||||
|
|
||||||
|
The receiver is trying to read from the DB (e.g. to fill in title/body when extras are missing) on the main thread. Room disallows this.
|
||||||
|
|
||||||
|
**Plugin fix:** In `DailyNotificationReceiver.enqueueNotificationWork`, do **not** call Room/DB on the main thread. Either (a) enqueue the work with the Intent extras only and let the **Worker** load title/body from DB on a background thread, or (b) use a coroutine/background executor in the receiver to load from DB and then enqueue work with the result. Prefer (a) unless the receiver must decide work parameters synchronously.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Relation to existing docs
|
||||||
|
|
||||||
|
- **Duplicate reminder** (`doc/plugin-feedback-android-duplicate-reminder-notification.md`): Prefetch should not schedule a second alarm for static reminders. That would prevent a **second** alarm at the **same** time. Here we also have a **second** alarm at a **different** time (21:53 vs 21:56), so in addition the plugin must cancel **all** alarms for the reminder when the user reschedules (including old rollover times).
|
||||||
|
- **Post-reboot fallback text** (`doc/plugin-feedback-android-post-reboot-fallback-text.md`): Same idea — resolve title/body from DB when Intent lacks them; use canonical reminder id / NotificationContentEntity so rollover and post-reboot show user text.
|
||||||
|
- **Rollover after reboot** (`doc/plugin-feedback-android-rollover-after-reboot.md`): Boot recovery should always re-register alarms. Not the direct cause of “two notifications at two times” but relevant for consistency.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## App-side behavior
|
||||||
|
|
||||||
|
- The app calls `scheduleDailyNotification` once with `id: "daily_timesafari_reminder"`, `time`, `title`, and `body`. It does not manage rollover or prefetch; that is all plugin-side.
|
||||||
|
- **Optional app-side mitigation:** When the user **changes** the reminder time (or turns the reminder off then on with a new time), the app could call a plugin API to “cancel all daily notification alarms for this app” before calling `scheduleDailyNotification` again, if the plugin exposes such a method. That would reduce the chance of leftover 21:53 alarms. The **correct** fix is still plugin-side: when scheduling for `daily_timesafari_reminder`, cancel every existing alarm that belongs to that reminder (including rollover and prefetch-created ones).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification after plugin fixes
|
||||||
|
|
||||||
|
1. Set a daily reminder for 21:56 with **distinct** custom title/body.
|
||||||
|
2. Wait for the notification (or set it 1–2 minutes ahead). **One** notification at 21:56 with your custom text.
|
||||||
|
3. Let it fire once so rollover is scheduled for next day 21:56. Optionally reboot; next day **one** notification at 21:56 with your custom text.
|
||||||
|
4. Change time to 21:58 and save. Wait until 21:56 and 21:58: **no** notification at 21:56; **one** at 21:58 with your text.
|
||||||
|
5. Logcat: no `db_fallback_failed`; for the display that shows user text, either `DN|DISPLAY_STATIC_REMINDER` or Room lookup by canonical id with user title/body.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Short summary for plugin maintainers
|
||||||
|
|
||||||
|
- **Two notifications:** Two different alarms were active (21:53 and 21:56). When the user sets 21:56, the plugin must cancel **all** alarms for this reminder (main + rollover + any prefetch-created), not only the “primary” schedule. For static reminders, prefetch must not schedule a second alarm (see duplicate-reminder doc).
|
||||||
|
- **Wrong content:** Worker used Room content keyed by run id (UUID / `notify_*`), not the app’s reminder id. Resolve canonical reminder id and load title/body from DB in receiver or worker; pass static reminder data into Worker when Intent lacks it; when scheduling rollover, preserve title/body (or stable reminder id) so the next fire shows user text.
|
||||||
|
- **Main-thread DB:** Receiver must not access Room on the main thread; move DB read to Worker or background in receiver.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## For Cursor (plugin repo) — actionable handoff
|
||||||
|
|
||||||
|
Use this section when applying fixes in the **daily-notification-plugin** repo (e.g. with Cursor). You can paste or @-mention this doc as context.
|
||||||
|
|
||||||
|
**Goal:** (1) Only one notification at the user’s chosen time, with user-set title/body. (2) No main-thread DB access in the receiver.
|
||||||
|
|
||||||
|
**Changes:**
|
||||||
|
|
||||||
|
1. **Cancel all alarms for the reminder when the app reschedules**
|
||||||
|
When `scheduleDailyNotification` is called with a given `scheduleId` (e.g. `daily_timesafari_reminder`), cancel every alarm that belongs to this reminder: the main schedule, all rollover schedules (`daily_rollover_*` that correspond to this reminder), and any prefetch-created display alarm (UUID). That prevents a second notification at a stale time (e.g. 21:53 when the user set 21:56).
|
||||||
|
|
||||||
|
2. **Static reminder: no second alarm from prefetch**
|
||||||
|
For static reminders, do not enqueue prefetch work that schedules a second alarm (see duplicate-reminder doc). Prefetch is for “fetch content then display”; for static reminders the single NotifyReceiver alarm is enough.
|
||||||
|
|
||||||
|
3. **Use user title/body when displaying (receiver + worker)**
|
||||||
|
When the Intent has `notification_id` but missing title/body (or `is_static_reminder` false), resolve the canonical reminder id (e.g. from `schedule_id`, or rollover id → reminder id, or NotificationContentEntity by schedule). Load title/body from DB and pass into Worker with `is_static_reminder = true`. In the worker, when displaying rollover or time-based runs, prefer content for the canonical reminder id so user text is shown. When scheduling rollover (ROLLOVER_ON_FIRE), pass or persist title/body (or stable reminder id) so the next day’s fire can resolve them.
|
||||||
|
|
||||||
|
4. **No DB on main thread in receiver**
|
||||||
|
In `DailyNotificationReceiver.enqueueNotificationWork`, do not call Room/DB on the main thread. Either enqueue work with Intent extras only and let the Worker load title/body on a background thread, or use a coroutine/background executor in the receiver before enqueueing.
|
||||||
|
|
||||||
|
**Files to look at (plugin Android):** ScheduleHelper / NotifyReceiver (cancel all alarms for reminder; schedule with correct id); DailyNotificationReceiver (no main-thread DB; optionally pass static reminder data from DB on background thread); DailyNotificationWorker (use static reminder input; resolve canonical id from Room when run id is rollover/notify_*); DailyNotificationFetchWorker (do not schedule second alarm for static reminders).
|
||||||
104
doc/plugin-feedback-android-rollover-interval-bugs.md
Normal file
104
doc/plugin-feedback-android-rollover-interval-bugs.md
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
# Plugin feedback: Android rollover interval – two bugs (logcat evidence)
|
||||||
|
|
||||||
|
**Date:** 2026-03-04
|
||||||
|
**Target:** daily-notification-plugin (Android)
|
||||||
|
**Feature:** `rolloverIntervalMinutes` (e.g. 10 minutes for testing)
|
||||||
|
**Result:** Two bugs prevent rollover notifications from firing every 10 minutes when the user does not open the app.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test setup
|
||||||
|
|
||||||
|
- Schedule set with **rolloverIntervalMinutes=10** (e.g. first run 20:05).
|
||||||
|
- Expected: notification at 20:05, 20:15, 20:25, 20:35, 20:40 (after user edit), 20:50, 21:00, 21:10, 21:20, …
|
||||||
|
- User did **not** open the app between 20:25 and 20:36, or between 21:10 and 21:20.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bug 1: Rollover interval not applied when the firing run is a rollover schedule
|
||||||
|
|
||||||
|
### Observed
|
||||||
|
|
||||||
|
- **20:25** – Notification fired (room content; work id UUID, scheduleId `daily_rollover_1772540701872`).
|
||||||
|
- **20:35** – **No notification.**
|
||||||
|
|
||||||
|
### Logcat evidence (20:25 fire)
|
||||||
|
|
||||||
|
There is **no** `DN|ROLLOVER_INTERVAL` or `DN|ROLLOVER_NEXT using_interval_minutes=10` in this block. Next run is set to **next day** at 20:25, not today 20:35:
|
||||||
|
|
||||||
|
```
|
||||||
|
03-03 20:25:01.844 D/DailyNotificationWorker: DN|RESCHEDULE_START id=29e1e984-d8b2-49ea-bb69-68b923fe4428
|
||||||
|
03-03 20:25:01.874 D/DailyNotificationWorker: DN|ROLLOVER next=1772627100000 scheduleId=daily_rollover_1772540701872 static=false
|
||||||
|
03-03 20:25:01.928 I/DNP-SCHEDULE: Scheduling next daily alarm: id=daily_rollover_1772540701872, nextRun=2026-03-04 20:25:00, source=ROLLOVER_ON_FIRE
|
||||||
|
```
|
||||||
|
|
||||||
|
Compare with a fire that **does** use the interval (e.g. 20:15):
|
||||||
|
|
||||||
|
```
|
||||||
|
03-03 20:15:01.860 D/DailyNotificationWorker: DN|ROLLOVER_INTERVAL scheduleId=daily_timesafari_reminder minutes=10
|
||||||
|
03-03 20:15:01.862 D/DailyNotificationWorker: DN|ROLLOVER_NEXT using_interval_minutes=10 next=1772540700870
|
||||||
|
03-03 20:15:01.870 D/DailyNotificationWorker: DN|ROLLOVER next=1772540700870 scheduleId=daily_timesafari_reminder static=false
|
||||||
|
```
|
||||||
|
|
||||||
|
### Root cause
|
||||||
|
|
||||||
|
When the notification that just fired was scheduled from a **previous** rollover (i.e. work id is UUID / scheduleId is `daily_rollover_*`), the rollover path appears to use **+24 hours** and never reads or applies the stored `rolloverIntervalMinutes`. The interval is only applied when the firing schedule is the main/canonical one (e.g. `daily_timesafari_reminder`).
|
||||||
|
|
||||||
|
### Required fix
|
||||||
|
|
||||||
|
When scheduling the next run after a notification fires (rollover path), **always** resolve the **logical** schedule (e.g. map `daily_rollover_*` back to the main schedule id) and read the stored `rolloverIntervalMinutes` for that reminder. If present and > 0, set next trigger = current trigger + that many minutes (using the same logic as the path that already logs `ROLLOVER_INTERVAL` / `ROLLOVER_NEXT`). Only use +24 hours when the interval is absent or 0.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bug 2: ROLLOVER_ON_FIRE reschedule skipped as “duplicate” so next alarm is never set
|
||||||
|
|
||||||
|
### Observed
|
||||||
|
|
||||||
|
- **21:10** – Notification fired; worker correctly computes next = 21:20 (epoch 1772544000862).
|
||||||
|
- **21:20** – **No notification.**
|
||||||
|
|
||||||
|
### Logcat evidence (21:10 fire)
|
||||||
|
|
||||||
|
Worker applies interval and requests next at 21:20; schedule layer skips and does **not** set the alarm:
|
||||||
|
|
||||||
|
```
|
||||||
|
03-03 21:10:01.281 D/DailyNotificationWorker: DN|ROLLOVER_INTERVAL scheduleId=daily_timesafari_reminder minutes=10
|
||||||
|
03-03 21:10:01.284 D/DailyNotificationWorker: DN|ROLLOVER_NEXT using_interval_minutes=10 next=1772544000862
|
||||||
|
03-03 21:10:01.294 D/DailyNotificationWorker: DN|ROLLOVER next=1772544000862 scheduleId=daily_timesafari_reminder static=false
|
||||||
|
03-03 21:10:01.313 W/DNP-SCHEDULE: Skipping duplicate schedule: id=daily_timesafari_reminder, nextRun=2026-03-03 21:20:00, source=ROLLOVER_ON_FIRE
|
||||||
|
03-03 21:10:01.314 W/DNP-SCHEDULE: Existing PendingIntent found for requestCode=53438 - alarm already scheduled
|
||||||
|
03-03 21:10:01.332 I/DailyNotificationWorker: DN|RESCHEDULE_OK ...
|
||||||
|
```
|
||||||
|
|
||||||
|
So the worker reports RESCHEDULE_OK, but the scheduler did **not** call through to set the OS alarm for 21:20. The “existing” PendingIntent was for the alarm that **just fired** (21:10). Idempotence is preventing the **update** to the new trigger time.
|
||||||
|
|
||||||
|
### Root cause
|
||||||
|
|
||||||
|
Duplicate/idempotence logic (e.g. “Existing PendingIntent found for requestCode=53438”) is applied in a way that skips scheduling when the same schedule id is used with a **new** trigger time. For `source=ROLLOVER_ON_FIRE`, the same schedule id is **supposed** to be updated to a new trigger time every time a rollover fires. Skipping when only the trigger time changes breaks the rollover chain.
|
||||||
|
|
||||||
|
### Required fix
|
||||||
|
|
||||||
|
For `source=ROLLOVER_ON_FIRE`, do **not** skip scheduling when the only “match” is the same schedule id with a **different** `nextRun`/trigger time. Either:
|
||||||
|
|
||||||
|
- Treat “same schedule id, different trigger time” as an **update**: cancel the existing alarm (or PendingIntent) for that schedule and set the new one for the new trigger time, or
|
||||||
|
- In the idempotence check, require that the **existing** alarm’s trigger time equals the **requested** trigger time before skipping; if the requested time is different, proceed with cancel + set.
|
||||||
|
|
||||||
|
After the fix, when the 21:10 alarm fires and the worker requests next at 21:20, the schedule layer should cancel the 21:10 alarm and set a new alarm for 21:20 (same schedule id, new trigger).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Desired behavior (for reference)
|
||||||
|
|
||||||
|
Once both bugs are fixed:
|
||||||
|
|
||||||
|
- Rollover notifications should keep being scheduled every `rolloverIntervalMinutes` (e.g. 10 minutes) **without the user opening the app** between fires.
|
||||||
|
- Flow: alarm fires → Receiver → Worker (display + reschedule) → schedule layer sets next alarm. All of this runs when the alarm fires; no app launch required.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary table
|
||||||
|
|
||||||
|
| Time | Expected | Actual | Bug |
|
||||||
|
|--------|------------------------|---------------|-----|
|
||||||
|
| 20:35 | Rollover notification | No notification | **Bug 1:** Rollover from `daily_rollover_*` path uses +24h instead of `rolloverIntervalMinutes`. |
|
||||||
|
| 21:20 | Rollover notification | No notification | **Bug 2:** Schedule layer skips with “Skipping duplicate schedule” / “Existing PendingIntent found”; 21:20 alarm never set. |
|
||||||
108
doc/plugin-fix-scheduleExactNotification-calls.md
Normal file
108
doc/plugin-fix-scheduleExactNotification-calls.md
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
# Plugin fix: Update Java call sites for scheduleExactNotification (8th parameter)
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
After adding the 8th parameter `skipPendingIntentIdempotence: Boolean = false` to `NotifyReceiver.scheduleExactNotification()` in NotifyReceiver.kt, the Java callers still pass only 7 arguments. That causes a compilation error when building an app that depends on the plugin:
|
||||||
|
|
||||||
|
```
|
||||||
|
error: method scheduleExactNotification in class NotifyReceiver cannot be applied to given types;
|
||||||
|
required: Context,long,UserNotificationConfig,boolean,String,String,ScheduleSource,boolean
|
||||||
|
found: Context,long,UserNotificationConfig,boolean,<null>,String,ScheduleSource
|
||||||
|
reason: actual and formal argument lists differ in length
|
||||||
|
```
|
||||||
|
|
||||||
|
**Affected files (in the plugin repo):**
|
||||||
|
- `android/src/main/java/org/timesafari/dailynotification/DailyNotificationReceiver.java`
|
||||||
|
- `android/src/main/java/org/timesafari/dailynotification/DailyNotificationWorker.java`
|
||||||
|
|
||||||
|
## Current Kotlin signature (NotifyReceiver.kt)
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
fun scheduleExactNotification(
|
||||||
|
context: Context,
|
||||||
|
triggerAtMillis: Long,
|
||||||
|
config: UserNotificationConfig,
|
||||||
|
isStaticReminder: Boolean = false,
|
||||||
|
reminderId: String? = null,
|
||||||
|
scheduleId: String? = null,
|
||||||
|
source: ScheduleSource = ScheduleSource.MANUAL_RESCHEDULE,
|
||||||
|
skipPendingIntentIdempotence: Boolean = false // 8th parameter
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Required change
|
||||||
|
|
||||||
|
In both Java files, add the **8th argument** to every call to `NotifyReceiver.scheduleExactNotification(...)`.
|
||||||
|
|
||||||
|
### 1. DailyNotificationReceiver.java
|
||||||
|
|
||||||
|
**Location:** around line 441, inside `scheduleNextNotification()`.
|
||||||
|
|
||||||
|
**Current call:**
|
||||||
|
```java
|
||||||
|
org.timesafari.dailynotification.NotifyReceiver.scheduleExactNotification(
|
||||||
|
context,
|
||||||
|
nextScheduledTime,
|
||||||
|
config,
|
||||||
|
false, // isStaticReminder
|
||||||
|
null, // reminderId
|
||||||
|
scheduleId,
|
||||||
|
org.timesafari.dailynotification.ScheduleSource.ROLLOVER_ON_FIRE
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fixed call (add 8th argument):**
|
||||||
|
```java
|
||||||
|
org.timesafari.dailynotification.NotifyReceiver.scheduleExactNotification(
|
||||||
|
context,
|
||||||
|
nextScheduledTime,
|
||||||
|
config,
|
||||||
|
false, // isStaticReminder
|
||||||
|
null, // reminderId
|
||||||
|
scheduleId,
|
||||||
|
org.timesafari.dailynotification.ScheduleSource.ROLLOVER_ON_FIRE,
|
||||||
|
false // skipPendingIntentIdempotence – rollover path does not skip
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. DailyNotificationWorker.java
|
||||||
|
|
||||||
|
**Location:** around line 584, inside `scheduleNextNotification()`.
|
||||||
|
|
||||||
|
**Current call:**
|
||||||
|
```java
|
||||||
|
org.timesafari.dailynotification.NotifyReceiver.scheduleExactNotification(
|
||||||
|
getApplicationContext(),
|
||||||
|
nextScheduledTime,
|
||||||
|
config,
|
||||||
|
false, // isStaticReminder
|
||||||
|
null, // reminderId
|
||||||
|
scheduleId,
|
||||||
|
org.timesafari.dailynotification.ScheduleSource.ROLLOVER_ON_FIRE
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fixed call (add 8th argument):**
|
||||||
|
```java
|
||||||
|
org.timesafari.dailynotification.NotifyReceiver.scheduleExactNotification(
|
||||||
|
getApplicationContext(),
|
||||||
|
nextScheduledTime,
|
||||||
|
config,
|
||||||
|
false, // isStaticReminder
|
||||||
|
null, // reminderId
|
||||||
|
scheduleId,
|
||||||
|
org.timesafari.dailynotification.ScheduleSource.ROLLOVER_ON_FIRE,
|
||||||
|
false // skipPendingIntentIdempotence – rollover path does not skip
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Other call sites
|
||||||
|
|
||||||
|
Kotlin call sites (NotifyReceiver.kt, DailyNotificationPlugin.kt, ReactivationManager.kt, BootReceiver.kt) use **named parameters**, so they already get the default for `skipPendingIntentIdempotence` and do not need changes. Only the **Java** call sites use positional arguments, so only the two files above need the 8th argument added. If you add new Java call sites later, pass the 8th parameter explicitly: `false` for rollover/fire paths, `true` only where the caller has just cancelled this schedule and you intend to skip the PendingIntent idempotence check.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
After updating the plugin:
|
||||||
|
|
||||||
|
1. Build the plugin (e.g. `./gradlew :timesafari-daily-notification-plugin:compileDebugJavaWithJavac` or full Android build from a consuming app).
|
||||||
|
2. Ensure there are no “actual and formal argument lists differ in length” errors.
|
||||||
148
doc/plugin-spec-rollover-interval-minutes.md
Normal file
148
doc/plugin-spec-rollover-interval-minutes.md
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
# Plugin spec: Configurable rollover interval (e.g. 10 minutes for testing)
|
||||||
|
|
||||||
|
**Date:** 2026-03-03
|
||||||
|
**Target repo:** daily-notification-plugin
|
||||||
|
**Consuming app:** crowd-funder-for-time-pwa (TimeSafari)
|
||||||
|
**Platforms:** iOS and Android
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
The consuming app needs to support **rapid testing** of daily notification rollover on device. Today, after a notification fires, the plugin always schedules the next occurrence **24 hours** later. We need an **optional** parameter so the app can request a different interval (e.g. **10 minutes**) for dev/testing. When that parameter is present, the plugin must:
|
||||||
|
|
||||||
|
1. Use the given interval (in minutes) when scheduling the **next** occurrence after a notification fires (rollover).
|
||||||
|
2. **Persist** that interval with the schedule so that it survives **device reboot** and is used again when:
|
||||||
|
- Boot recovery reschedules alarms from stored data, and
|
||||||
|
- Any subsequent rollover runs (after the next notification fires).
|
||||||
|
|
||||||
|
If the interval is not persisted, then after a device restart the plugin would no longer know to use 10 minutes and would fall back to 24 hours; rapid testing after reboot would break. So persistence is a **required** part of this feature.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API contract (app → plugin)
|
||||||
|
|
||||||
|
### Method: `scheduleDailyNotification` (or equivalent used for the app’s daily reminder)
|
||||||
|
|
||||||
|
**Add an optional parameter:**
|
||||||
|
|
||||||
|
- **Name:** `rolloverIntervalMinutes` (or equivalent, e.g. `repeatIntervalMinutes`).
|
||||||
|
- **Type:** `number` (integer), optional.
|
||||||
|
- **Meaning:** When the scheduled notification fires, schedule the **next** occurrence this many **minutes** after the current trigger time (instead of 24 hours). When **absent** or not provided, behavior is unchanged: next occurrence is **24 hours** later (current behavior).
|
||||||
|
|
||||||
|
**Example (pseudocode):**
|
||||||
|
|
||||||
|
- App calls: `scheduleDailyNotification({ id, time, title, body, ..., rolloverIntervalMinutes: 10 })`.
|
||||||
|
- Plugin stores the schedule **including** `rolloverIntervalMinutes: 10`.
|
||||||
|
- When the notification fires, plugin computes next trigger = current trigger + 10 minutes (instead of + 24 hours), and schedules that.
|
||||||
|
- When the device reboots, boot recovery loads the schedule, sees `rolloverIntervalMinutes: 10`, and uses it when (a) computing the next run time for reschedule and (b) any future rollover after the next fire.
|
||||||
|
|
||||||
|
**Example (normal production, no param):**
|
||||||
|
|
||||||
|
- App calls: `scheduleDailyNotification({ id, time, title, body })` (no `rolloverIntervalMinutes`).
|
||||||
|
- Plugin stores the schedule with no interval (or default 24h).
|
||||||
|
- Rollover and boot recovery behave as today: next occurrence 24 hours later.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Persistence requirement (critical for device restart)
|
||||||
|
|
||||||
|
The rollover interval must be **stored with the schedule** in the plugin’s persistent storage (e.g. Room on Android, UserDefaults/DB on iOS), not only kept in memory. Concretely:
|
||||||
|
|
||||||
|
1. **When the app calls `scheduleDailyNotification` with `rolloverIntervalMinutes`:**
|
||||||
|
- Persist that value in the same place you persist the rest of the schedule (e.g. Schedule entity, or equivalent table/row that is read on boot and on rollover).
|
||||||
|
|
||||||
|
2. **When computing the next occurrence (rollover path, after a notification fires):**
|
||||||
|
- Read the stored `rolloverIntervalMinutes` for that schedule.
|
||||||
|
- If present and > 0: next trigger = current trigger + `rolloverIntervalMinutes` minutes.
|
||||||
|
- If absent or 0: next trigger = current trigger + 24 hours (existing behavior).
|
||||||
|
|
||||||
|
3. **When boot recovery runs (after device restart):**
|
||||||
|
- Load schedules from persistent storage (including the stored `rolloverIntervalMinutes`).
|
||||||
|
- When rescheduling each alarm, use the stored interval to compute the next run time (same logic as rollover: if interval is set, use it; otherwise 24 hours).
|
||||||
|
- When the next notification fires after reboot, the rollover path will again read the same stored value, so the 10-minute (or whatever) interval continues to apply.
|
||||||
|
|
||||||
|
4. **When the app calls `scheduleDailyNotification` without `rolloverIntervalMinutes` (or to turn off fast rollover):**
|
||||||
|
- Overwrite the stored schedule so that the interval is cleared or set to default (24h). Subsequent rollovers and boot recovery then use 24 hours again.
|
||||||
|
|
||||||
|
**Why this matters:** Without persisting the interval, a device restart would lose the “10 minutes” setting; rollover and boot recovery would have no way to know to use 10 minutes and would default to 24 hours. Rapid testing after reboot would not work.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Platform-specific notes
|
||||||
|
|
||||||
|
### Android
|
||||||
|
|
||||||
|
- **Storage:** Add `rollover_interval_minutes` (or equivalent) to the Schedule entity (or wherever the app’s reminder schedule is stored) and persist it when handling `scheduleDailyNotification`. Use it in:
|
||||||
|
- Rollover path (e.g. when scheduling next alarm after notification fires).
|
||||||
|
- Boot recovery path (when rebuilding alarms from DB after `BOOT_COMPLETED`).
|
||||||
|
- **Next trigger:** Current trigger time + `rolloverIntervalMinutes` minutes (using `Calendar` or equivalent so DST/timezone is handled correctly; same care as for 24h rollover).
|
||||||
|
|
||||||
|
### iOS
|
||||||
|
|
||||||
|
- **Storage:** Add the same field to whatever persistent structure holds the schedule (e.g. the same place that stores time, title, body, id). Persist it when the app calls the schedule method.
|
||||||
|
- **Rollover:** In `scheduleNextNotification()` (or equivalent), read the stored interval; if set, use `Calendar.date(byAdding: .minute, value: rolloverIntervalMinutes, to: currentDate)` (or equivalent) instead of adding 24 hours.
|
||||||
|
- **App launch / recovery:** If the plugin has any path that restores or reschedules after app launch or system events, use the stored interval there as well so behavior is consistent.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Edge cases and defaults
|
||||||
|
|
||||||
|
- **Parameter absent:** Do not change current behavior. Next occurrence = 24 hours later.
|
||||||
|
- **Parameter = 0 or negative:** Treat as “use default”; same as absent (24 hours).
|
||||||
|
- **Parameter > 0 (e.g. 10):** Next occurrence = current trigger + that many minutes.
|
||||||
|
- **Existing schedules (created before this feature):** No stored interval → treat as 24 hours. No migration required beyond “missing field = default”.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## App-side behavior (for context)
|
||||||
|
|
||||||
|
- The app will only pass `rolloverIntervalMinutes` when a **dev-only** setting is enabled (e.g. “Use 10-minute rollover for testing” in the Notifications section). Production users will not set it.
|
||||||
|
- The app will pass it on every `scheduleDailyNotification` call when the user has that setting on (first-time enable and edit). When the user turns the setting off, the app will call `scheduleDailyNotification` without the parameter (so the plugin can persist “no interval” / 24h).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification (plugin repo)
|
||||||
|
|
||||||
|
1. **Rollover with interval:** Schedule with `rolloverIntervalMinutes: 10`. Trigger the notification (or wait). Confirm the **next** scheduled time is ~10 minutes after the current trigger (not 24 hours). Let it fire again; confirm the following occurrence is again ~10 minutes later.
|
||||||
|
2. **Persistence:** Schedule with `rolloverIntervalMinutes: 10`, then **restart the device** (do not open the app). After boot, confirm (via logs or next fire) that the rescheduled alarm uses the 10-minute interval (e.g. next fire is 10 minutes after the last stored trigger, not 24 hours). After that notification fires, confirm the **next** rollover is still 10 minutes later.
|
||||||
|
3. **Default:** Schedule without `rolloverIntervalMinutes`. Confirm next occurrence is 24 hours later. Reboot; confirm boot recovery still uses 24 hours.
|
||||||
|
4. **Turn off:** Schedule with 10 minutes, then have the app call `scheduleDailyNotification` again with the same id/time but **no** `rolloverIntervalMinutes`. Confirm stored interval is cleared and next rollover is 24 hours.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Short summary for plugin maintainers
|
||||||
|
|
||||||
|
- **New optional parameter:** `rolloverIntervalMinutes?: number` on the schedule method used for the app’s daily reminder.
|
||||||
|
- **When set (e.g. 10):** After a notification fires, schedule the next occurrence in that many **minutes** instead of 24 hours.
|
||||||
|
- **Must persist:** Store the value with the schedule in the plugin’s DB/storage. Use it in **rollover** and in **boot recovery** so that after a device restart the same interval is used. Without persistence, the feature would not work after reboot.
|
||||||
|
- **When absent:** Behavior unchanged (24-hour rollover). No migration needed for existing schedules.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## For Cursor (plugin repo) — actionable handoff
|
||||||
|
|
||||||
|
Use this section when implementing this feature in the **daily-notification-plugin** repo (e.g. with Cursor). You can paste or @-mention this doc as context.
|
||||||
|
|
||||||
|
**Goal:** Support an optional `rolloverIntervalMinutes` (or equivalent) on the daily reminder schedule API. When provided (e.g. `10`), schedule the next occurrence that many minutes after the current trigger instead of 24 hours. **Persist this value** with the schedule so that rollover and boot recovery both use it; after a device restart, the same interval must still apply.
|
||||||
|
|
||||||
|
**Concrete tasks:**
|
||||||
|
|
||||||
|
1. **API:** In the plugin interface used by the app (e.g. `scheduleDailyNotification`), add an optional parameter `rolloverIntervalMinutes?: number`. Document that when absent, next occurrence is 24 hours (current behavior).
|
||||||
|
|
||||||
|
2. **Storage (Android):** In the Schedule entity (or equivalent), add a column/field for the rollover interval (e.g. `rollover_interval_minutes` nullable Int). When handling `scheduleDailyNotification`, persist the value if present; if absent, store null or 0 to mean “24 hours”.
|
||||||
|
|
||||||
|
3. **Storage (iOS):** Add the same field to the persistent structure that holds the reminder schedule. Persist it when the app calls the schedule method.
|
||||||
|
|
||||||
|
4. **Rollover (both platforms):** In the code that runs when a scheduled notification fires and schedules the next occurrence:
|
||||||
|
- Read the stored `rolloverIntervalMinutes` for that schedule.
|
||||||
|
- If present and > 0: next trigger = current trigger + that many minutes (using Calendar/date APIs that respect timezone/DST).
|
||||||
|
- Else: next trigger = current trigger + 24 hours (existing behavior).
|
||||||
|
- Persist the same interval on the new schedule record so the next rollover still uses it.
|
||||||
|
|
||||||
|
5. **Boot recovery (both platforms):** In the path that runs after device reboot and reschedules from stored data:
|
||||||
|
- Load the stored `rolloverIntervalMinutes` with each schedule.
|
||||||
|
- When computing “next run time” for reschedule, use the same logic: if interval set, current trigger + that many minutes; else + 24 hours.
|
||||||
|
- Do not rely on in-memory state; always read from persisted storage so behavior is correct after restart.
|
||||||
|
|
||||||
|
6. **Clearing the interval:** When the app calls `scheduleDailyNotification` without `rolloverIntervalMinutes` (e.g. user turned off “fast rollover” in the app), overwrite the stored schedule so the interval field is null/0. Subsequent rollovers and boot recovery then use 24 hours.
|
||||||
|
|
||||||
|
7. **Tests:** Add or extend tests for: (a) rollover with 10 minutes, (b) boot recovery with stored 10-minute interval, (c) default 24h when parameter absent or cleared.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"appId": "app.timesafari",
|
"appId": "app.timesafari",
|
||||||
"appName": "TimeSafari",
|
"appName": "Giftopia",
|
||||||
"webDir": "dist",
|
"webDir": "dist",
|
||||||
"server": {
|
"server": {
|
||||||
"cleartext": true
|
"cleartext": true
|
||||||
@@ -34,12 +34,12 @@
|
|||||||
"iosIsEncryption": false,
|
"iosIsEncryption": false,
|
||||||
"iosBiometric": {
|
"iosBiometric": {
|
||||||
"biometricAuth": false,
|
"biometricAuth": false,
|
||||||
"biometricTitle": "Biometric login for TimeSafari"
|
"biometricTitle": "Biometric login for Giftopia"
|
||||||
},
|
},
|
||||||
"androidIsEncryption": false,
|
"androidIsEncryption": false,
|
||||||
"androidBiometric": {
|
"androidBiometric": {
|
||||||
"biometricAuth": false,
|
"biometricAuth": false,
|
||||||
"biometricTitle": "Biometric login for TimeSafari"
|
"biometricTitle": "Biometric login for Giftopia"
|
||||||
},
|
},
|
||||||
"electronIsEncryption": false
|
"electronIsEncryption": false
|
||||||
}
|
}
|
||||||
@@ -72,7 +72,7 @@
|
|||||||
},
|
},
|
||||||
"buildOptions": {
|
"buildOptions": {
|
||||||
"appId": "app.timesafari",
|
"appId": "app.timesafari",
|
||||||
"productName": "TimeSafari",
|
"productName": "Giftopia",
|
||||||
"directories": {
|
"directories": {
|
||||||
"output": "dist-electron-packages"
|
"output": "dist-electron-packages"
|
||||||
},
|
},
|
||||||
|
|||||||
13
electron/package-lock.json
generated
13
electron/package-lock.json
generated
@@ -56,6 +56,7 @@
|
|||||||
"version": "6.0.2",
|
"version": "6.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@capacitor-community/sqlite/-/sqlite-6.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@capacitor-community/sqlite/-/sqlite-6.0.2.tgz",
|
||||||
"integrity": "sha512-sj+2SPLu7E/3dM3xxcWwfNomG+aQHuN96/EFGrOtp4Dv30/2y5oIPyi6hZGjQGjPc5GDNoTQwW7vxWNzybjuMg==",
|
"integrity": "sha512-sj+2SPLu7E/3dM3xxcWwfNomG+aQHuN96/EFGrOtp4Dv30/2y5oIPyi6hZGjQGjPc5GDNoTQwW7vxWNzybjuMg==",
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"jeep-sqlite": "^2.7.2"
|
"jeep-sqlite": "^2.7.2"
|
||||||
},
|
},
|
||||||
@@ -129,6 +130,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@capacitor/core/-/core-6.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/@capacitor/core/-/core-6.2.1.tgz",
|
||||||
"integrity": "sha512-urZwxa7hVE/BnA18oCFAdizXPse6fCKanQyEqpmz6cBJ2vObwMpyJDG5jBeoSsgocS9+Ax+9vb4ducWJn0y2qQ==",
|
"integrity": "sha512-urZwxa7hVE/BnA18oCFAdizXPse6fCKanQyEqpmz6cBJ2vObwMpyJDG5jBeoSsgocS9+Ax+9vb4ducWJn0y2qQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tslib": "^2.1.0"
|
"tslib": "^2.1.0"
|
||||||
}
|
}
|
||||||
@@ -1069,6 +1071,7 @@
|
|||||||
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fast-deep-equal": "^3.1.1",
|
"fast-deep-equal": "^3.1.1",
|
||||||
"fast-json-stable-stringify": "^2.0.0",
|
"fast-json-stable-stringify": "^2.0.0",
|
||||||
@@ -2874,16 +2877,6 @@
|
|||||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/encoding": {
|
|
||||||
"version": "0.1.13",
|
|
||||||
"resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz",
|
|
||||||
"integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==",
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
|
||||||
"iconv-lite": "^0.6.2"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/end-of-stream": {
|
"node_modules/end-of-stream": {
|
||||||
"version": "1.4.5",
|
"version": "1.4.5",
|
||||||
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
|
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
|
||||||
|
|||||||
3
ios/.gitignore
vendored
3
ios/.gitignore
vendored
@@ -17,6 +17,7 @@ App/App/config.xml
|
|||||||
App/App.xcodeproj/xcuserdata/*.xcuserdatad/
|
App/App.xcodeproj/xcuserdata/*.xcuserdatad/
|
||||||
App/App.xcodeproj/*.xcuserstate
|
App/App.xcodeproj/*.xcuserstate
|
||||||
|
|
||||||
# Generated Icons from capacitor-assets (also Contents.json which is confusing; see BUILDING.md)
|
# Generated by capacitor-assets at build time (not in repo). Fresh clones lack these
|
||||||
|
# folders; scripts/common.sh ensure_ios_capacitor_asset_directories creates them before generate.
|
||||||
App/App/Assets.xcassets/AppIcon.appiconset
|
App/App/Assets.xcassets/AppIcon.appiconset
|
||||||
App/App/Assets.xcassets/Splash.imageset
|
App/App/Assets.xcassets/Splash.imageset
|
||||||
|
|||||||
@@ -524,17 +524,18 @@
|
|||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
|
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 50;
|
CURRENT_PROJECT_VERSION = 67;
|
||||||
DEVELOPMENT_TEAM = GM3FS5JQPH;
|
DEVELOPMENT_TEAM = GM3FS5JQPH;
|
||||||
ENABLE_APP_SANDBOX = NO;
|
ENABLE_APP_SANDBOX = NO;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
INFOPLIST_FILE = App/Info.plist;
|
INFOPLIST_FILE = App/Info.plist;
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName = Giftopia;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.1.5;
|
MARKETING_VERSION = 1.3.12;
|
||||||
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
|
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
@@ -552,17 +553,18 @@
|
|||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
|
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 50;
|
CURRENT_PROJECT_VERSION = 67;
|
||||||
DEVELOPMENT_TEAM = GM3FS5JQPH;
|
DEVELOPMENT_TEAM = GM3FS5JQPH;
|
||||||
ENABLE_APP_SANDBOX = NO;
|
ENABLE_APP_SANDBOX = NO;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
INFOPLIST_FILE = App/Info.plist;
|
INFOPLIST_FILE = App/Info.plist;
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName = Giftopia;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.1.5;
|
MARKETING_VERSION = 1.3.12;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
|
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
||||||
@@ -580,12 +582,12 @@
|
|||||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = TimeSafariShareExtension/TimeSafariShareExtension.entitlements;
|
CODE_SIGN_ENTITLEMENTS = TimeSafariShareExtension/TimeSafariShareExtension.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 50;
|
CURRENT_PROJECT_VERSION = 67;
|
||||||
DEVELOPMENT_TEAM = GM3FS5JQPH;
|
DEVELOPMENT_TEAM = GM3FS5JQPH;
|
||||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = TimeSafariShareExtension/Info.plist;
|
INFOPLIST_FILE = TimeSafariShareExtension/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = TimeSafari;
|
INFOPLIST_KEY_CFBundleDisplayName = Giftopia;
|
||||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
@@ -594,7 +596,7 @@
|
|||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||||
MARKETING_VERSION = 1.1.5;
|
MARKETING_VERSION = 1.3.12;
|
||||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari.TimeSafariShareExtension;
|
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari.TimeSafariShareExtension;
|
||||||
@@ -618,12 +620,12 @@
|
|||||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = TimeSafariShareExtension/TimeSafariShareExtension.entitlements;
|
CODE_SIGN_ENTITLEMENTS = TimeSafariShareExtension/TimeSafariShareExtension.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 50;
|
CURRENT_PROJECT_VERSION = 67;
|
||||||
DEVELOPMENT_TEAM = GM3FS5JQPH;
|
DEVELOPMENT_TEAM = GM3FS5JQPH;
|
||||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = TimeSafariShareExtension/Info.plist;
|
INFOPLIST_FILE = TimeSafariShareExtension/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = TimeSafari;
|
INFOPLIST_KEY_CFBundleDisplayName = Giftopia;
|
||||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
@@ -632,7 +634,7 @@
|
|||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||||
MARKETING_VERSION = 1.1.5;
|
MARKETING_VERSION = 1.3.12;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari.TimeSafariShareExtension;
|
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari.TimeSafariShareExtension;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
import UIKit
|
import UIKit
|
||||||
import Capacitor
|
import Capacitor
|
||||||
import CapacitorCommunitySqlite
|
import CapacitorCommunitySqlite
|
||||||
|
import UserNotifications
|
||||||
|
|
||||||
@UIApplicationMain
|
@UIApplicationMain
|
||||||
class AppDelegate: UIResponder, UIApplicationDelegate {
|
class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterDelegate {
|
||||||
|
|
||||||
var window: UIWindow?
|
var window: UIWindow?
|
||||||
|
|
||||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
|
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
|
||||||
|
// Set notification center delegate so notifications show in foreground and rollover is triggered
|
||||||
|
UNUserNotificationCenter.current().delegate = self
|
||||||
|
|
||||||
// Initialize SQLite
|
// Initialize SQLite
|
||||||
//let sqlite = SQLite()
|
//let sqlite = SQLite()
|
||||||
//sqlite.initialize()
|
//sqlite.initialize()
|
||||||
@@ -73,9 +77,37 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
|||||||
func applicationDidBecomeActive(_ application: UIApplication) {
|
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.
|
// 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 notification delegate when app becomes active (in case Capacitor resets it)
|
||||||
|
UNUserNotificationCenter.current().delegate = self
|
||||||
|
|
||||||
// Check for shared image from Share Extension when app becomes active
|
// Check for shared image from Share Extension when app becomes active
|
||||||
checkForSharedImageOnActivation()
|
checkForSharedImageOnActivation()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - UNUserNotificationCenterDelegate
|
||||||
|
|
||||||
|
/// Show notifications when app is in foreground and post DailyNotificationDelivered for rollover.
|
||||||
|
func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
|
||||||
|
let userInfo = notification.request.content.userInfo
|
||||||
|
if let notificationId = userInfo["notification_id"] as? String,
|
||||||
|
let scheduledTime = userInfo["scheduled_time"] as? Int64 {
|
||||||
|
NotificationCenter.default.post(
|
||||||
|
name: NSNotification.Name("DailyNotificationDelivered"),
|
||||||
|
object: nil,
|
||||||
|
userInfo: ["notification_id": notificationId, "scheduled_time": scheduledTime]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if #available(iOS 14.0, *) {
|
||||||
|
completionHandler([.banner, .sound, .badge])
|
||||||
|
} else {
|
||||||
|
completionHandler([.alert, .sound, .badge])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle notification tap/interaction.
|
||||||
|
func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
|
||||||
|
completionHandler()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check for shared image when app launches or becomes active
|
* Check for shared image when app launches or becomes active
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<key>CFBundleDevelopmentRegion</key>
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
<string>en</string>
|
<string>en</string>
|
||||||
<key>CFBundleDisplayName</key>
|
<key>CFBundleDisplayName</key>
|
||||||
<string>TimeSafari</string>
|
<string>Giftopia</string>
|
||||||
<key>CFBundleExecutable</key>
|
<key>CFBundleExecutable</key>
|
||||||
<string>$(EXECUTABLE_NAME)</string>
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
<key>CFBundleIdentifier</key>
|
<key>CFBundleIdentifier</key>
|
||||||
@@ -58,5 +58,19 @@
|
|||||||
</array>
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</array>
|
</array>
|
||||||
|
<key>UIBackgroundModes</key>
|
||||||
|
<array>
|
||||||
|
<string>fetch</string>
|
||||||
|
<string>processing</string>
|
||||||
|
</array>
|
||||||
|
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
||||||
|
<array>
|
||||||
|
<string>org.timesafari.dailynotification.fetch</string>
|
||||||
|
<string>org.timesafari.dailynotification.notify</string>
|
||||||
|
<string>org.timesafari.dailynotification.content-fetch</string>
|
||||||
|
<string>org.timesafari.dailynotification.notification-delivery</string>
|
||||||
|
</array>
|
||||||
|
<key>NSUserNotificationAlertStyle</key>
|
||||||
|
<string>alert</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ def capacitor_pods
|
|||||||
pod 'CapacitorShare', :path => '../../node_modules/@capacitor/share'
|
pod 'CapacitorShare', :path => '../../node_modules/@capacitor/share'
|
||||||
pod 'CapacitorStatusBar', :path => '../../node_modules/@capacitor/status-bar'
|
pod 'CapacitorStatusBar', :path => '../../node_modules/@capacitor/status-bar'
|
||||||
pod 'CapawesomeCapacitorFilePicker', :path => '../../node_modules/@capawesome/capacitor-file-picker'
|
pod 'CapawesomeCapacitorFilePicker', :path => '../../node_modules/@capawesome/capacitor-file-picker'
|
||||||
|
pod 'TimesafariDailyNotificationPlugin', :path => '../../node_modules/@timesafari/daily-notification-plugin'
|
||||||
end
|
end
|
||||||
|
|
||||||
target 'App' do
|
target 'App' do
|
||||||
|
|||||||
@@ -86,6 +86,8 @@ PODS:
|
|||||||
- SQLCipher/common (4.9.0)
|
- SQLCipher/common (4.9.0)
|
||||||
- SQLCipher/standard (4.9.0):
|
- SQLCipher/standard (4.9.0):
|
||||||
- SQLCipher/common
|
- SQLCipher/common
|
||||||
|
- TimesafariDailyNotificationPlugin (2.0.0):
|
||||||
|
- Capacitor
|
||||||
- ZIPFoundation (0.9.19)
|
- ZIPFoundation (0.9.19)
|
||||||
|
|
||||||
DEPENDENCIES:
|
DEPENDENCIES:
|
||||||
@@ -100,6 +102,7 @@ DEPENDENCIES:
|
|||||||
- "CapacitorShare (from `../../node_modules/@capacitor/share`)"
|
- "CapacitorShare (from `../../node_modules/@capacitor/share`)"
|
||||||
- "CapacitorStatusBar (from `../../node_modules/@capacitor/status-bar`)"
|
- "CapacitorStatusBar (from `../../node_modules/@capacitor/status-bar`)"
|
||||||
- "CapawesomeCapacitorFilePicker (from `../../node_modules/@capawesome/capacitor-file-picker`)"
|
- "CapawesomeCapacitorFilePicker (from `../../node_modules/@capawesome/capacitor-file-picker`)"
|
||||||
|
- "TimesafariDailyNotificationPlugin (from `../../node_modules/@timesafari/daily-notification-plugin`)"
|
||||||
|
|
||||||
SPEC REPOS:
|
SPEC REPOS:
|
||||||
trunk:
|
trunk:
|
||||||
@@ -141,6 +144,8 @@ EXTERNAL SOURCES:
|
|||||||
:path: "../../node_modules/@capacitor/status-bar"
|
:path: "../../node_modules/@capacitor/status-bar"
|
||||||
CapawesomeCapacitorFilePicker:
|
CapawesomeCapacitorFilePicker:
|
||||||
:path: "../../node_modules/@capawesome/capacitor-file-picker"
|
:path: "../../node_modules/@capawesome/capacitor-file-picker"
|
||||||
|
TimesafariDailyNotificationPlugin:
|
||||||
|
:path: "../../node_modules/@timesafari/daily-notification-plugin"
|
||||||
|
|
||||||
SPEC CHECKSUMS:
|
SPEC CHECKSUMS:
|
||||||
Capacitor: c95400d761e376be9da6be5a05f226c0e865cebf
|
Capacitor: c95400d761e376be9da6be5a05f226c0e865cebf
|
||||||
@@ -167,8 +172,9 @@ SPEC CHECKSUMS:
|
|||||||
nanopb: 438bc412db1928dac798aa6fd75726007be04262
|
nanopb: 438bc412db1928dac798aa6fd75726007be04262
|
||||||
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
|
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
|
||||||
SQLCipher: 31878d8ebd27e5c96db0b7cb695c96e9f8ad77da
|
SQLCipher: 31878d8ebd27e5c96db0b7cb695c96e9f8ad77da
|
||||||
|
TimesafariDailyNotificationPlugin: 3c12e8c39fc27f689f56cf4e57230a8c28611fcc
|
||||||
ZIPFoundation: b8c29ea7ae353b309bc810586181fd073cb3312c
|
ZIPFoundation: b8c29ea7ae353b309bc810586181fd073cb3312c
|
||||||
|
|
||||||
PODFILE CHECKSUM: 5fa870b031c7c4e0733e2f96deaf81866c75ff7d
|
PODFILE CHECKSUM: 6d92bfa46c6c2d31d19b8c0c38f56a8ae9fd222f
|
||||||
|
|
||||||
COCOAPODS: 1.16.2
|
COCOAPODS: 1.16.2
|
||||||
|
|||||||
8369
package-lock.json
generated
8369
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
17
package.json
17
package.json
@@ -1,9 +1,9 @@
|
|||||||
{
|
{
|
||||||
"name": "timesafari",
|
"name": "giftopia",
|
||||||
"version": "1.1.6-beta",
|
"version": "1.3.14-beta",
|
||||||
"description": "Time Safari Application",
|
"description": "Giftopia App",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Time Safari Team"
|
"name": "Gift Economies Team"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"lint": "eslint --ext .js,.ts,.vue --ignore-path .gitignore src",
|
"lint": "eslint --ext .js,.ts,.vue --ignore-path .gitignore src",
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
"auto-run:electron": "./scripts/auto-run.sh --platform=electron",
|
"auto-run:electron": "./scripts/auto-run.sh --platform=electron",
|
||||||
"build:capacitor": "VITE_GIT_HASH=`git log -1 --pretty=format:%h` vite build --mode capacitor --config vite.config.capacitor.mts",
|
"build:capacitor": "VITE_GIT_HASH=`git log -1 --pretty=format:%h` vite build --mode capacitor --config vite.config.capacitor.mts",
|
||||||
"build:capacitor:sync": "npm run build:capacitor && npx cap sync && node scripts/restore-local-plugins.js",
|
"build:capacitor:sync": "npm run build:capacitor && npx cap sync && node scripts/restore-local-plugins.js",
|
||||||
"build:native": "vite build && npx cap sync && node scripts/restore-local-plugins.js && npx capacitor-assets generate",
|
"build:native": "vite build && npx cap sync && node scripts/restore-local-plugins.js && bash -c 'source scripts/common.sh && ensure_ios_capacitor_asset_directories' && npx capacitor-assets generate",
|
||||||
"assets:config": "npx tsx scripts/assets-config.ts",
|
"assets:config": "npx tsx scripts/assets-config.ts",
|
||||||
"assets:validate": "npx tsx scripts/assets-validator.ts",
|
"assets:validate": "npx tsx scripts/assets-validator.ts",
|
||||||
"assets:validate:android": "./scripts/build-android.sh --assets-only",
|
"assets:validate:android": "./scripts/build-android.sh --assets-only",
|
||||||
@@ -161,12 +161,12 @@
|
|||||||
"@fortawesome/free-solid-svg-icons": "^6.5.1",
|
"@fortawesome/free-solid-svg-icons": "^6.5.1",
|
||||||
"@fortawesome/vue-fontawesome": "^3.0.6",
|
"@fortawesome/vue-fontawesome": "^3.0.6",
|
||||||
"@jlongster/sql.js": "^1.6.7",
|
"@jlongster/sql.js": "^1.6.7",
|
||||||
"@nostr/tools": "npm:@jsr/nostr__tools@^2.15.0",
|
|
||||||
"@peculiar/asn1-ecc": "^2.3.8",
|
"@peculiar/asn1-ecc": "^2.3.8",
|
||||||
"@peculiar/asn1-schema": "^2.3.8",
|
"@peculiar/asn1-schema": "^2.3.8",
|
||||||
"@pvermeer/dexie-encrypted-addon": "^3.0.0",
|
"@pvermeer/dexie-encrypted-addon": "^3.0.0",
|
||||||
"@simplewebauthn/browser": "^10.0.0",
|
"@simplewebauthn/browser": "^10.0.0",
|
||||||
"@simplewebauthn/server": "^10.0.0",
|
"@simplewebauthn/server": "^10.0.0",
|
||||||
|
"@timesafari/daily-notification-plugin": "git+https://gitea.anomalistdesign.com/trent_larson/daily-notification-plugin.git#master",
|
||||||
"@tweenjs/tween.js": "^21.1.1",
|
"@tweenjs/tween.js": "^21.1.1",
|
||||||
"@types/qrcode": "^1.5.5",
|
"@types/qrcode": "^1.5.5",
|
||||||
"@veramo/core": "^5.6.0",
|
"@veramo/core": "^5.6.0",
|
||||||
@@ -189,6 +189,7 @@
|
|||||||
"dexie-export-import": "^4.1.4",
|
"dexie-export-import": "^4.1.4",
|
||||||
"did-jwt": "^7.4.7",
|
"did-jwt": "^7.4.7",
|
||||||
"did-resolver": "^4.1.0",
|
"did-resolver": "^4.1.0",
|
||||||
|
"diff": "^8.0.2",
|
||||||
"dotenv": "^16.0.3",
|
"dotenv": "^16.0.3",
|
||||||
"electron-builder": "^26.0.12",
|
"electron-builder": "^26.0.12",
|
||||||
"ethereum-cryptography": "^2.1.3",
|
"ethereum-cryptography": "^2.1.3",
|
||||||
@@ -202,9 +203,10 @@
|
|||||||
"lru-cache": "^10.2.0",
|
"lru-cache": "^10.2.0",
|
||||||
"luxon": "^3.4.4",
|
"luxon": "^3.4.4",
|
||||||
"merkletreejs": "^0.3.11",
|
"merkletreejs": "^0.3.11",
|
||||||
|
"nostr-tools": "^2.15.0",
|
||||||
"notiwind": "^2.0.2",
|
"notiwind": "^2.0.2",
|
||||||
"papaparse": "^5.4.1",
|
"papaparse": "^5.4.1",
|
||||||
"pina": "^0.20.2204228",
|
"pinia": "^2.1.7",
|
||||||
"pinia-plugin-persistedstate": "^3.2.1",
|
"pinia-plugin-persistedstate": "^3.2.1",
|
||||||
"qr-code-generator-vue3": "^1.4.21",
|
"qr-code-generator-vue3": "^1.4.21",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
@@ -233,6 +235,7 @@
|
|||||||
"@commitlint/cli": "^18.6.1",
|
"@commitlint/cli": "^18.6.1",
|
||||||
"@commitlint/config-conventional": "^18.6.2",
|
"@commitlint/config-conventional": "^18.6.2",
|
||||||
"@playwright/test": "^1.54.2",
|
"@playwright/test": "^1.54.2",
|
||||||
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@types/dom-webcodecs": "^0.1.7",
|
"@types/dom-webcodecs": "^0.1.7",
|
||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "^30.0.0",
|
||||||
"@types/js-yaml": "^4.0.9",
|
"@types/js-yaml": "^4.0.9",
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export default defineConfig({
|
|||||||
// },
|
// },
|
||||||
{
|
{
|
||||||
name: 'chromium',
|
name: 'chromium',
|
||||||
testMatch: /^(?!.*\/(35-record-gift-from-image-share|40-add-contact)\.spec\.ts).+\.spec\.ts$/,
|
testMatch: /^(?!.*\/(35-record-gift-from-image-share)\.spec\.ts).+\.spec\.ts$/,
|
||||||
use: {
|
use: {
|
||||||
...devices['Desktop Chrome'],
|
...devices['Desktop Chrome'],
|
||||||
permissions: ["clipboard-read"],
|
permissions: ["clipboard-read"],
|
||||||
@@ -65,7 +65,7 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'firefox',
|
name: 'firefox',
|
||||||
testMatch: /^(?!.*\/(35-record-gift-from-image-share|40-add-contact)\.spec\.ts).+\.spec\.ts$/,
|
testMatch: /^(?!.*\/(35-record-gift-from-image-share)\.spec\.ts).+\.spec\.ts$/,
|
||||||
use: { ...devices['Desktop Firefox'] },
|
use: { ...devices['Desktop Firefox'] },
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -75,6 +75,146 @@ validate_dependencies() {
|
|||||||
log_success "All critical dependencies validated successfully"
|
log_success "All critical dependencies validated successfully"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Function to detect and set JAVA_HOME for Android builds
|
||||||
|
setup_java_home() {
|
||||||
|
log_info "Setting up Java environment..."
|
||||||
|
|
||||||
|
# If JAVA_HOME is already set and valid, use it
|
||||||
|
if [ -n "$JAVA_HOME" ] && [ -x "$JAVA_HOME/bin/java" ]; then
|
||||||
|
log_debug "Using existing JAVA_HOME: $JAVA_HOME"
|
||||||
|
export JAVA_HOME
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Try to find Java in Android Studio's bundled JBR
|
||||||
|
local android_studio_jbr="/Applications/Android Studio.app/Contents/jbr/Contents/Home"
|
||||||
|
if [ -d "$android_studio_jbr" ] && [ -x "$android_studio_jbr/bin/java" ]; then
|
||||||
|
export JAVA_HOME="$android_studio_jbr"
|
||||||
|
log_info "Found Java in Android Studio: $JAVA_HOME"
|
||||||
|
if [ -x "$JAVA_HOME/bin/java" ]; then
|
||||||
|
log_debug "Java version: $(\"$JAVA_HOME/bin/java\" -version 2>&1 | head -1 || echo 'Unable to get version')"
|
||||||
|
fi
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Try alternative Android Studio location (older versions)
|
||||||
|
local android_studio_jre="/Applications/Android Studio.app/Contents/jre/Contents/Home"
|
||||||
|
if [ -d "$android_studio_jre" ] && [ -x "$android_studio_jre/bin/java" ]; then
|
||||||
|
export JAVA_HOME="$android_studio_jre"
|
||||||
|
log_info "Found Java in Android Studio (legacy): $JAVA_HOME"
|
||||||
|
log_debug "Java version: $(\"$JAVA_HOME/bin/java\" -version 2>&1 | head -1)"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Try to use /usr/libexec/java_home on macOS
|
||||||
|
if [ "$(uname)" = "Darwin" ] && command -v /usr/libexec/java_home >/dev/null 2>&1; then
|
||||||
|
local java_home_output=$(/usr/libexec/java_home 2>/dev/null)
|
||||||
|
if [ -n "$java_home_output" ] && [ -x "$java_home_output/bin/java" ]; then
|
||||||
|
export JAVA_HOME="$java_home_output"
|
||||||
|
log_info "Found Java via java_home utility: $JAVA_HOME"
|
||||||
|
log_debug "Java version: $(\"$JAVA_HOME/bin/java\" -version 2>&1 | head -1)"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Try to find java in PATH
|
||||||
|
if command -v java >/dev/null 2>&1; then
|
||||||
|
local java_path=$(command -v java)
|
||||||
|
# Resolve symlinks to find actual Java home (portable approach)
|
||||||
|
local java_real="$java_path"
|
||||||
|
# Try different methods to resolve symlinks
|
||||||
|
if [ -L "$java_path" ]; then
|
||||||
|
if command -v readlink >/dev/null 2>&1; then
|
||||||
|
java_real=$(readlink "$java_path" 2>/dev/null || echo "$java_path")
|
||||||
|
elif command -v realpath >/dev/null 2>&1; then
|
||||||
|
java_real=$(realpath "$java_path" 2>/dev/null || echo "$java_path")
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
local java_home_candidate=$(dirname "$(dirname "$java_real")")
|
||||||
|
if [ -d "$java_home_candidate" ] && [ -x "$java_home_candidate/bin/java" ]; then
|
||||||
|
export JAVA_HOME="$java_home_candidate"
|
||||||
|
log_info "Found Java in PATH: $JAVA_HOME"
|
||||||
|
log_debug "Java version: $(\"$JAVA_HOME/bin/java\" -version 2>&1 | head -1)"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# If we get here, Java was not found
|
||||||
|
log_error "Java Runtime not found!"
|
||||||
|
log_error "Please ensure one of the following:"
|
||||||
|
log_error " 1. Android Studio is installed (includes bundled Java)"
|
||||||
|
log_error " 2. JAVA_HOME is set to a valid Java installation"
|
||||||
|
log_error " 3. Java is available in your PATH"
|
||||||
|
log_error ""
|
||||||
|
log_error "On macOS, Android Studio typically includes Java at:"
|
||||||
|
log_error " /Applications/Android Studio.app/Contents/jbr/Contents/Home"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to detect and set ANDROID_HOME for Android builds
|
||||||
|
setup_android_home() {
|
||||||
|
log_info "Setting up Android SDK environment..."
|
||||||
|
|
||||||
|
# If ANDROID_HOME is already set and valid, use it
|
||||||
|
if [ -n "$ANDROID_HOME" ] && [ -d "$ANDROID_HOME" ]; then
|
||||||
|
log_debug "Using existing ANDROID_HOME: $ANDROID_HOME"
|
||||||
|
export ANDROID_HOME
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check for local.properties file in android directory
|
||||||
|
local local_props="android/local.properties"
|
||||||
|
if [ -f "$local_props" ]; then
|
||||||
|
local sdk_dir=$(grep "^sdk.dir=" "$local_props" 2>/dev/null | cut -d'=' -f2 | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | sed "s|^file://||")
|
||||||
|
if [ -n "$sdk_dir" ] && [ -d "$sdk_dir" ]; then
|
||||||
|
export ANDROID_HOME="$sdk_dir"
|
||||||
|
log_info "Found Android SDK in local.properties: $ANDROID_HOME"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Try common macOS locations for Android SDK
|
||||||
|
local common_locations=(
|
||||||
|
"$HOME/Library/Android/sdk"
|
||||||
|
"$HOME/Android/Sdk"
|
||||||
|
"$HOME/.android/sdk"
|
||||||
|
)
|
||||||
|
|
||||||
|
for sdk_path in "${common_locations[@]}"; do
|
||||||
|
if [ -d "$sdk_path" ] && [ -d "$sdk_path/platform-tools" ]; then
|
||||||
|
export ANDROID_HOME="$sdk_path"
|
||||||
|
log_info "Found Android SDK: $ANDROID_HOME"
|
||||||
|
|
||||||
|
# Write to local.properties if it doesn't exist or doesn't have sdk.dir
|
||||||
|
if [ ! -f "$local_props" ] || ! grep -q "^sdk.dir=" "$local_props" 2>/dev/null; then
|
||||||
|
log_info "Writing Android SDK location to local.properties"
|
||||||
|
mkdir -p android
|
||||||
|
if [ -f "$local_props" ]; then
|
||||||
|
echo "" >> "$local_props"
|
||||||
|
echo "sdk.dir=$ANDROID_HOME" >> "$local_props"
|
||||||
|
else
|
||||||
|
echo "sdk.dir=$ANDROID_HOME" > "$local_props"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# If we get here, Android SDK was not found
|
||||||
|
log_error "Android SDK not found!"
|
||||||
|
log_error "Please ensure one of the following:"
|
||||||
|
log_error " 1. ANDROID_HOME is set to a valid Android SDK location"
|
||||||
|
log_error " 2. Android SDK is installed at one of these locations:"
|
||||||
|
log_error " - $HOME/Library/Android/sdk (macOS default)"
|
||||||
|
log_error " - $HOME/Android/Sdk"
|
||||||
|
log_error " 3. android/local.properties contains sdk.dir pointing to SDK"
|
||||||
|
log_error ""
|
||||||
|
log_error "You can find your SDK location in Android Studio:"
|
||||||
|
log_error " Preferences > Appearance & Behavior > System Settings > Android SDK"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
# Function to validate Android assets and resources
|
# Function to validate Android assets and resources
|
||||||
validate_android_assets() {
|
validate_android_assets() {
|
||||||
log_info "Validating Android assets and resources..."
|
log_info "Validating Android assets and resources..."
|
||||||
@@ -326,6 +466,18 @@ print_header "TimeSafari Android Build Process"
|
|||||||
# Validate dependencies before proceeding
|
# Validate dependencies before proceeding
|
||||||
validate_dependencies
|
validate_dependencies
|
||||||
|
|
||||||
|
# Setup Java environment for Gradle
|
||||||
|
setup_java_home || {
|
||||||
|
log_error "Failed to setup Java environment. Cannot proceed with Android build."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Setup Android SDK environment for Gradle
|
||||||
|
setup_android_home || {
|
||||||
|
log_error "Failed to setup Android SDK environment. Cannot proceed with Android build."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
# Validate Android assets and resources
|
# Validate Android assets and resources
|
||||||
validate_android_assets || {
|
validate_android_assets || {
|
||||||
log_error "Android asset validation failed. Please fix the issues above and try again."
|
log_error "Android asset validation failed. Please fix the issues above and try again."
|
||||||
|
|||||||
@@ -215,9 +215,9 @@ clean_electron_artifacts() {
|
|||||||
safe_execute "Cleaning Electron app directory" "rm -rf electron/app"
|
safe_execute "Cleaning Electron app directory" "rm -rf electron/app"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Clean TypeScript compilation artifacts
|
# Clean TypeScript compilation artifacts (exclude hand-maintained electron-plugins.js)
|
||||||
if [[ -d "electron/src" ]]; then
|
if [[ -d "electron/src" ]]; then
|
||||||
safe_execute "Cleaning TypeScript artifacts" "find electron/src -name '*.js' -delete 2>/dev/null || true"
|
safe_execute "Cleaning TypeScript artifacts" "find electron/src -name '*.js' ! -path 'electron/src/rt/electron-plugins.js' -delete 2>/dev/null || true"
|
||||||
safe_execute "Cleaning TypeScript artifacts" "find electron/src -name '*.js.map' -delete 2>/dev/null || true"
|
safe_execute "Cleaning TypeScript artifacts" "find electron/src -name '*.js.map' -delete 2>/dev/null || true"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -222,7 +222,8 @@ build_ios_app() {
|
|||||||
|
|
||||||
if [ "$BUILD_TYPE" = "debug" ]; then
|
if [ "$BUILD_TYPE" = "debug" ]; then
|
||||||
build_config="Debug"
|
build_config="Debug"
|
||||||
destination="platform=iOS Simulator,name=iPhone 15 Pro"
|
# Any Simulator — avoids hardcoding a device name (e.g. iPhone 15 Pro) that may not exist in newer Xcode runtimes
|
||||||
|
destination="generic/platform=iOS Simulator"
|
||||||
else
|
else
|
||||||
build_config="Release"
|
build_config="Release"
|
||||||
destination="platform=iOS,id=auto"
|
destination="platform=iOS,id=auto"
|
||||||
@@ -232,15 +233,21 @@ build_ios_app() {
|
|||||||
|
|
||||||
cd ios/App
|
cd ios/App
|
||||||
|
|
||||||
# Build the app
|
# Build the app:
|
||||||
xcodebuild -workspace App.xcworkspace \
|
# -quiet: skip the huge export VAR dump (compiler warnings still show unless suppressed below).
|
||||||
|
# SWIFT_SUPPRESS_WARNINGS / GCC_WARN_INHIBIT_ALL_WARNINGS: quiet CLI output from Pods + plugins;
|
||||||
|
# build in Xcode for full diagnostics. Real errors still fail the build.
|
||||||
|
xcodebuild -quiet \
|
||||||
|
-workspace App.xcworkspace \
|
||||||
-scheme "$scheme" \
|
-scheme "$scheme" \
|
||||||
-configuration "$build_config" \
|
-configuration "$build_config" \
|
||||||
-destination "$destination" \
|
-destination "$destination" \
|
||||||
build \
|
build \
|
||||||
CODE_SIGN_IDENTITY="" \
|
CODE_SIGN_IDENTITY="" \
|
||||||
CODE_SIGNING_REQUIRED=NO \
|
CODE_SIGNING_REQUIRED=NO \
|
||||||
CODE_SIGNING_ALLOWED=NO
|
CODE_SIGNING_ALLOWED=NO \
|
||||||
|
SWIFT_SUPPRESS_WARNINGS=YES \
|
||||||
|
GCC_WARN_INHIBIT_ALL_WARNINGS=YES
|
||||||
|
|
||||||
cd ../..
|
cd ../..
|
||||||
|
|
||||||
@@ -349,10 +356,56 @@ if [ "$CLEAN_ONLY" = true ]; then
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Xcode 26 / CocoaPods workaround for cap sync (used by sync-only and full build)
|
||||||
|
# Temporarily downgrade project.pbxproj objectVersion 70 -> 56 so pod install succeeds.
|
||||||
|
run_cap_sync_with_workaround() {
|
||||||
|
local PROJECT_FILE="ios/App/App.xcodeproj/project.pbxproj"
|
||||||
|
|
||||||
|
if [ ! -f "$PROJECT_FILE" ]; then
|
||||||
|
log_error "Project file not found: $PROJECT_FILE (run full build first?)"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local current_version
|
||||||
|
current_version=$(grep "objectVersion" "$PROJECT_FILE" | head -1 | grep -o "[0-9]\+" || echo "")
|
||||||
|
|
||||||
|
if [ -z "$current_version" ]; then
|
||||||
|
log_error "Could not determine project format version for Capacitor sync"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$current_version" = "70" ]; then
|
||||||
|
log_debug "Applying Xcode 26 workaround for Capacitor sync: temporarily downgrading to format 56"
|
||||||
|
|
||||||
|
if ! sed -i '' 's/objectVersion = 70;/objectVersion = 56;/' "$PROJECT_FILE"; then
|
||||||
|
log_error "Failed to downgrade project format for Capacitor sync"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_info "Running Capacitor sync..."
|
||||||
|
if ! npx cap sync ios; then
|
||||||
|
log_error "Capacitor sync failed"
|
||||||
|
sed -i '' 's/objectVersion = 56;/objectVersion = 70;/' "$PROJECT_FILE" || true
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_debug "Restoring project format to 70 after Capacitor sync..."
|
||||||
|
sed -i '' 's/objectVersion = 56;/objectVersion = 70;/' "$PROJECT_FILE" || true
|
||||||
|
log_success "Capacitor sync completed successfully"
|
||||||
|
else
|
||||||
|
log_debug "Project format is $current_version, running Capacitor sync normally"
|
||||||
|
if ! npx cap sync ios; then
|
||||||
|
log_error "Capacitor sync failed"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
log_success "Capacitor sync completed successfully"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
# Handle sync-only mode
|
# Handle sync-only mode
|
||||||
if [ "$SYNC_ONLY" = true ]; then
|
if [ "$SYNC_ONLY" = true ]; then
|
||||||
log_info "Sync-only mode: syncing with Capacitor"
|
log_info "Sync-only mode: syncing with Capacitor (with Xcode 26 workaround if needed)"
|
||||||
safe_execute "Syncing with Capacitor" "npx cap sync ios" || exit 6
|
safe_execute "Syncing with Capacitor" "run_cap_sync_with_workaround" || exit 6
|
||||||
log_success "Sync completed successfully!"
|
log_success "Sync completed successfully!"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
@@ -360,6 +413,7 @@ fi
|
|||||||
# Handle assets-only mode
|
# Handle assets-only mode
|
||||||
if [ "$ASSETS_ONLY" = true ]; then
|
if [ "$ASSETS_ONLY" = true ]; then
|
||||||
log_info "Assets-only mode: generating assets"
|
log_info "Assets-only mode: generating assets"
|
||||||
|
ensure_ios_capacitor_asset_directories
|
||||||
safe_execute "Generating assets" "npx capacitor-assets generate --ios" || exit 7
|
safe_execute "Generating assets" "npx capacitor-assets generate --ios" || exit 7
|
||||||
log_success "Assets generation completed successfully!"
|
log_success "Assets generation completed successfully!"
|
||||||
exit 0
|
exit 0
|
||||||
@@ -404,7 +458,24 @@ elif [ "$BUILD_MODE" = "production" ]; then
|
|||||||
safe_execute "Building Capacitor version (production)" "npm run build:capacitor -- --mode production" || exit 3
|
safe_execute "Building Capacitor version (production)" "npm run build:capacitor -- --mode production" || exit 3
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Step 6: Install CocoaPods dependencies (with Xcode 26 workaround)
|
# Step 6: Fix Daily Notification Plugin podspec name (must run before pod install)
|
||||||
|
# ===================================================================
|
||||||
|
# The Podfile expects TimesafariDailyNotificationPlugin.podspec, but the plugin
|
||||||
|
# package only includes CapacitorDailyNotification.podspec. This script creates
|
||||||
|
# the expected podspec file before CocoaPods tries to resolve dependencies.
|
||||||
|
# ===================================================================
|
||||||
|
log_info "Fixing Daily Notification Plugin podspec name..."
|
||||||
|
if [ -f "./scripts/fix-daily-notification-podspec.sh" ]; then
|
||||||
|
if ./scripts/fix-daily-notification-podspec.sh; then
|
||||||
|
log_success "Daily Notification Plugin podspec created"
|
||||||
|
else
|
||||||
|
log_warn "Failed to create podspec (may already exist)"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
log_warn "fix-daily-notification-podspec.sh not found, skipping"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Step 6.5: Install CocoaPods dependencies (with Xcode 26 workaround)
|
||||||
# ===================================================================
|
# ===================================================================
|
||||||
# WORKAROUND: Xcode 26 / CocoaPods Compatibility Issue
|
# WORKAROUND: Xcode 26 / CocoaPods Compatibility Issue
|
||||||
# ===================================================================
|
# ===================================================================
|
||||||
@@ -416,13 +487,13 @@ fi
|
|||||||
# it back to 70 when opened, which is fine.
|
# it back to 70 when opened, which is fine.
|
||||||
#
|
#
|
||||||
# NOTE: Both explicit pod install AND Capacitor sync (which runs pod install
|
# NOTE: Both explicit pod install AND Capacitor sync (which runs pod install
|
||||||
# internally) need this workaround. See run_pod_install_with_workaround()
|
# internally) need this workaround. run_pod_install_with_workaround() is below;
|
||||||
# and run_cap_sync_with_workaround() functions below.
|
# run_cap_sync_with_workaround() is defined earlier (used by --sync and Step 6.6).
|
||||||
#
|
#
|
||||||
# TO REMOVE THIS WORKAROUND IN THE FUTURE:
|
# TO REMOVE THIS WORKAROUND IN THE FUTURE:
|
||||||
# 1. Check if xcodeproj gem has been updated: bundle exec gem list xcodeproj
|
# 1. Check if xcodeproj gem has been updated: bundle exec gem list xcodeproj
|
||||||
# 2. Test if pod install works without the workaround
|
# 2. Test if pod install works without the workaround
|
||||||
# 3. If it works, remove both workaround functions below
|
# 3. If it works, remove run_pod_install_with_workaround() and run_cap_sync_with_workaround()
|
||||||
# 4. Replace with:
|
# 4. Replace with:
|
||||||
# - safe_execute "Installing CocoaPods dependencies" "cd ios/App && bundle exec pod install && cd ../.." || exit 6
|
# - safe_execute "Installing CocoaPods dependencies" "cd ios/App && bundle exec pod install && cd ../.." || exit 6
|
||||||
# - safe_execute "Syncing with Capacitor" "npx cap sync ios" || exit 6
|
# - safe_execute "Syncing with Capacitor" "npx cap sync ios" || exit 6
|
||||||
@@ -488,59 +559,11 @@ run_pod_install_with_workaround() {
|
|||||||
|
|
||||||
safe_execute "Installing CocoaPods dependencies" "run_pod_install_with_workaround" || exit 6
|
safe_execute "Installing CocoaPods dependencies" "run_pod_install_with_workaround" || exit 6
|
||||||
|
|
||||||
# Step 6.5: Sync with Capacitor (also needs workaround since it runs pod install internally)
|
# Step 6.6: Sync with Capacitor (uses run_cap_sync_with_workaround defined above for Xcode 26)
|
||||||
# Capacitor sync internally runs pod install, so we need to apply the workaround here too
|
|
||||||
run_cap_sync_with_workaround() {
|
|
||||||
local PROJECT_FILE="ios/App/App.xcodeproj/project.pbxproj"
|
|
||||||
|
|
||||||
# Check current format version
|
|
||||||
local current_version=$(grep "objectVersion" "$PROJECT_FILE" | head -1 | grep -o "[0-9]\+" || echo "")
|
|
||||||
|
|
||||||
if [ -z "$current_version" ]; then
|
|
||||||
log_error "Could not determine project format version for Capacitor sync"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Only apply workaround if format is 70
|
|
||||||
if [ "$current_version" = "70" ]; then
|
|
||||||
log_debug "Applying Xcode 26 workaround for Capacitor sync: temporarily downgrading to format 56"
|
|
||||||
|
|
||||||
# Downgrade to format 56 (supported by CocoaPods)
|
|
||||||
if ! sed -i '' 's/objectVersion = 70;/objectVersion = 56;/' "$PROJECT_FILE"; then
|
|
||||||
log_error "Failed to downgrade project format for Capacitor sync"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Run Capacitor sync (which will run pod install internally)
|
|
||||||
log_info "Running Capacitor sync..."
|
|
||||||
if ! npx cap sync ios; then
|
|
||||||
log_error "Capacitor sync failed"
|
|
||||||
# Try to restore format even on failure
|
|
||||||
sed -i '' 's/objectVersion = 56;/objectVersion = 70;/' "$PROJECT_FILE" || true
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Restore to format 70
|
|
||||||
log_debug "Restoring project format to 70 after Capacitor sync..."
|
|
||||||
if ! sed -i '' 's/objectVersion = 56;/objectVersion = 70;/' "$PROJECT_FILE"; then
|
|
||||||
log_warn "Failed to restore project format to 70 (Xcode will upgrade it automatically)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
log_success "Capacitor sync completed successfully"
|
|
||||||
else
|
|
||||||
# Format is not 70, run sync normally
|
|
||||||
log_debug "Project format is $current_version, running Capacitor sync normally"
|
|
||||||
if ! npx cap sync ios; then
|
|
||||||
log_error "Capacitor sync failed"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
log_success "Capacitor sync completed successfully"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
safe_execute "Syncing with Capacitor" "run_cap_sync_with_workaround" || exit 6
|
safe_execute "Syncing with Capacitor" "run_cap_sync_with_workaround" || exit 6
|
||||||
|
|
||||||
# Step 7: Generate assets
|
# Step 7: Generate assets
|
||||||
|
ensure_ios_capacitor_asset_directories
|
||||||
safe_execute "Generating assets" "npx capacitor-assets generate --ios" || exit 7
|
safe_execute "Generating assets" "npx capacitor-assets generate --ios" || exit 7
|
||||||
|
|
||||||
# Step 8: Build iOS app
|
# Step 8: Build iOS app
|
||||||
@@ -550,16 +573,19 @@ safe_execute "Building iOS app" "build_ios_app" || exit 5
|
|||||||
if [ "$BUILD_IPA" = true ]; then
|
if [ "$BUILD_IPA" = true ]; then
|
||||||
log_info "Building IPA package..."
|
log_info "Building IPA package..."
|
||||||
cd ios/App
|
cd ios/App
|
||||||
xcodebuild -workspace App.xcworkspace \
|
xcodebuild -quiet \
|
||||||
|
-workspace App.xcworkspace \
|
||||||
-scheme App \
|
-scheme App \
|
||||||
-configuration Release \
|
-configuration Release \
|
||||||
-archivePath build/App.xcarchive \
|
-archivePath build/App.xcarchive \
|
||||||
archive \
|
archive \
|
||||||
CODE_SIGN_IDENTITY="" \
|
CODE_SIGN_IDENTITY="" \
|
||||||
CODE_SIGNING_REQUIRED=NO \
|
CODE_SIGNING_REQUIRED=NO \
|
||||||
CODE_SIGNING_ALLOWED=NO
|
CODE_SIGNING_ALLOWED=NO \
|
||||||
|
SWIFT_SUPPRESS_WARNINGS=YES \
|
||||||
|
GCC_WARN_INHIBIT_ALL_WARNINGS=YES
|
||||||
|
|
||||||
xcodebuild -exportArchive \
|
xcodebuild -quiet -exportArchive \
|
||||||
-archivePath build/App.xcarchive \
|
-archivePath build/App.xcarchive \
|
||||||
-exportPath build/ \
|
-exportPath build/ \
|
||||||
-exportOptionsPlist exportOptions.plist
|
-exportOptionsPlist exportOptions.plist
|
||||||
|
|||||||
70
scripts/check-alarm-logs.sh
Executable file
70
scripts/check-alarm-logs.sh
Executable file
@@ -0,0 +1,70 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# check-alarm-logs.sh
|
||||||
|
# Author: Matthew Raymer
|
||||||
|
# Description: Check logs around a specific time to see if alarm fired
|
||||||
|
|
||||||
|
# Function to find adb command
|
||||||
|
find_adb() {
|
||||||
|
# Check if adb is in PATH
|
||||||
|
if command -v adb >/dev/null 2>&1; then
|
||||||
|
echo "adb"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check for ANDROID_HOME
|
||||||
|
if [ -n "$ANDROID_HOME" ] && [ -x "$ANDROID_HOME/platform-tools/adb" ]; then
|
||||||
|
echo "$ANDROID_HOME/platform-tools/adb"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check for local.properties
|
||||||
|
local local_props="android/local.properties"
|
||||||
|
if [ -f "$local_props" ]; then
|
||||||
|
local sdk_dir=$(grep "^sdk.dir=" "$local_props" 2>/dev/null | cut -d'=' -f2 | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | sed "s|^file://||")
|
||||||
|
if [ -n "$sdk_dir" ] && [ -x "$sdk_dir/platform-tools/adb" ]; then
|
||||||
|
echo "$sdk_dir/platform-tools/adb"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Try common macOS locations
|
||||||
|
local common_locations=(
|
||||||
|
"$HOME/Library/Android/sdk"
|
||||||
|
"$HOME/Android/Sdk"
|
||||||
|
"$HOME/.android/sdk"
|
||||||
|
)
|
||||||
|
|
||||||
|
for sdk_path in "${common_locations[@]}"; do
|
||||||
|
if [ -x "$sdk_path/platform-tools/adb" ]; then
|
||||||
|
echo "$sdk_path/platform-tools/adb"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Not found
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Find adb
|
||||||
|
ADB_CMD=$(find_adb)
|
||||||
|
if [ $? -ne 0 ] || [ -z "$ADB_CMD" ]; then
|
||||||
|
echo "Error: adb command not found!"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Checking logs for alarm activity..."
|
||||||
|
echo "Looking for: DN|RECEIVE_START, AlarmManager, DailyNotification, timesafari"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Check recent logs for alarm-related activity
|
||||||
|
echo "=== Recent alarm/receiver logs ==="
|
||||||
|
"$ADB_CMD" logcat -d | grep -iE "DN|RECEIVE_START|RECEIVE_ERR|alarm.*timesafari|daily.*notification|com\.timesafari\.daily" | tail -20
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== All AlarmManager activity (last 50 lines) ==="
|
||||||
|
"$ADB_CMD" logcat -d | grep -i "AlarmManager" | tail -50
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Check if alarm is still scheduled ==="
|
||||||
|
echo "Run this to see all scheduled alarms:"
|
||||||
|
echo " $ADB_CMD shell dumpsys alarm | grep -A 5 timesafari"
|
||||||
@@ -337,6 +337,27 @@ parse_args() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# iOS: capacitor-assets writes into AppIcon.appiconset and Splash.imageset under
|
||||||
|
# Assets.xcassets. Those paths are gitignored (generated). On a fresh clone the
|
||||||
|
# folders and Contents.json are missing; the tool opens Contents.json before writing
|
||||||
|
# PNGs, so we create minimal asset-catalog stubs when absent.
|
||||||
|
ensure_ios_capacitor_asset_directories() {
|
||||||
|
local base="ios/App/App/Assets.xcassets"
|
||||||
|
if [ ! -d "$base" ]; then
|
||||||
|
log_warn "Missing $base — cannot prepare iOS asset directories"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
mkdir -p "$base/AppIcon.appiconset" "$base/Splash.imageset"
|
||||||
|
local minimal_contents='{"images":[],"info":{"author":"xcode","version":1}}'
|
||||||
|
if [ ! -f "$base/AppIcon.appiconset/Contents.json" ]; then
|
||||||
|
printf '%s\n' "$minimal_contents" > "$base/AppIcon.appiconset/Contents.json"
|
||||||
|
fi
|
||||||
|
if [ ! -f "$base/Splash.imageset/Contents.json" ]; then
|
||||||
|
printf '%s\n' "$minimal_contents" > "$base/Splash.imageset/Contents.json"
|
||||||
|
fi
|
||||||
|
log_debug "Ensured iOS capacitor-assets output directories exist"
|
||||||
|
}
|
||||||
|
|
||||||
# Export functions for use in child scripts
|
# Export functions for use in child scripts
|
||||||
export -f log_info log_success log_warn log_error log_debug log_step
|
export -f log_info log_success log_warn log_error log_debug log_step
|
||||||
export -f measure_time print_header print_footer
|
export -f measure_time print_header print_footer
|
||||||
@@ -344,4 +365,5 @@ export -f check_command check_directory check_file
|
|||||||
export -f safe_execute check_venv get_git_hash
|
export -f safe_execute check_venv get_git_hash
|
||||||
export -f clean_build_artifacts validate_env_vars
|
export -f clean_build_artifacts validate_env_vars
|
||||||
export -f setup_build_env setup_app_directories load_env_file print_env_vars
|
export -f setup_build_env setup_app_directories load_env_file print_env_vars
|
||||||
export -f print_usage parse_args
|
export -f print_usage parse_args
|
||||||
|
export -f ensure_ios_capacitor_asset_directories
|
||||||
@@ -116,7 +116,7 @@ echo "=============================="
|
|||||||
|
|
||||||
# Analyze critical files identified in the assessment
|
# Analyze critical files identified in the assessment
|
||||||
critical_files=(
|
critical_files=(
|
||||||
src/components/MembersList.vue"
|
src/components/MeetingMembersList.vue"
|
||||||
"src/views/ContactsView.vue"
|
"src/views/ContactsView.vue"
|
||||||
src/views/OnboardMeetingSetupView.vue"
|
src/views/OnboardMeetingSetupView.vue"
|
||||||
src/db/databaseUtil.ts"
|
src/db/databaseUtil.ts"
|
||||||
|
|||||||
35
scripts/fix-daily-notification-podspec.sh
Executable file
35
scripts/fix-daily-notification-podspec.sh
Executable file
@@ -0,0 +1,35 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Fix Daily Notification Plugin Podspec Name
|
||||||
|
# Creates a podspec with the expected name for Capacitor sync
|
||||||
|
|
||||||
|
PLUGIN_DIR="node_modules/@timesafari/daily-notification-plugin"
|
||||||
|
PODSPEC_ACTUAL="CapacitorDailyNotification.podspec"
|
||||||
|
PODSPEC_EXPECTED="TimesafariDailyNotificationPlugin.podspec"
|
||||||
|
|
||||||
|
if [ -f "$PLUGIN_DIR/$PODSPEC_ACTUAL" ] && [ ! -f "$PLUGIN_DIR/$PODSPEC_EXPECTED" ]; then
|
||||||
|
echo "Creating podspec: $PODSPEC_EXPECTED"
|
||||||
|
cat > "$PLUGIN_DIR/$PODSPEC_EXPECTED" << 'EOF'
|
||||||
|
require 'json'
|
||||||
|
|
||||||
|
package = JSON.parse(File.read(File.join(__dir__, 'package.json')))
|
||||||
|
|
||||||
|
Pod::Spec.new do |s|
|
||||||
|
s.name = 'TimesafariDailyNotificationPlugin'
|
||||||
|
s.version = package['version']
|
||||||
|
s.summary = package['description']
|
||||||
|
s.license = package['license']
|
||||||
|
s.homepage = package['repository']['url']
|
||||||
|
s.author = package['author']
|
||||||
|
s.source = { :git => package['repository']['url'], :tag => s.version.to_s }
|
||||||
|
s.source_files = 'ios/Plugin/**/*.{swift,h,m,c,cc,mm,cpp}'
|
||||||
|
s.ios.deployment_target = '13.0'
|
||||||
|
s.dependency 'Capacitor'
|
||||||
|
s.swift_version = '5.1'
|
||||||
|
end
|
||||||
|
EOF
|
||||||
|
echo "✓ Podspec created successfully"
|
||||||
|
elif [ -f "$PLUGIN_DIR/$PODSPEC_EXPECTED" ]; then
|
||||||
|
echo "ℹ Podspec already exists"
|
||||||
|
else
|
||||||
|
echo "⚠ Actual podspec not found at $PLUGIN_DIR/$PODSPEC_ACTUAL"
|
||||||
|
fi
|
||||||
@@ -38,7 +38,7 @@ The `pre-commit` hook automatically checks for debug code when committing to pro
|
|||||||
- Test files: `*.test.js`, `*.spec.ts`, `*.test.vue`
|
- Test files: `*.test.js`, `*.spec.ts`, `*.test.vue`
|
||||||
- Scripts: `scripts/` directory
|
- Scripts: `scripts/` directory
|
||||||
- Test directories: `test-*` directories
|
- Test directories: `test-*` directories
|
||||||
- Documentation: `docs/`, `*.md`, `*.txt`
|
- Documentation: `doc/`, `*.md`, `*.txt`
|
||||||
- Config files: `*.json`, `*.yml`, `*.yaml`
|
- Config files: `*.json`, `*.yml`, `*.yaml`
|
||||||
- IDE files: `.cursor/` directory
|
- IDE files: `.cursor/` directory
|
||||||
|
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ SKIP_PATTERNS=(
|
|||||||
"^test-.*/" # Test directories (must end with /)
|
"^test-.*/" # Test directories (must end with /)
|
||||||
"^\.git/" # Git directory
|
"^\.git/" # Git directory
|
||||||
"^node_modules/" # Dependencies
|
"^node_modules/" # Dependencies
|
||||||
"^docs/" # Documentation
|
"^doc/" # Documentation
|
||||||
"^\.cursor/" # Cursor IDE files
|
"^\.cursor/" # Cursor IDE files
|
||||||
"\.md$" # Markdown files
|
"\.md$" # Markdown files
|
||||||
"\.txt$" # Text files
|
"\.txt$" # Text files
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ echo "=========================="
|
|||||||
|
|
||||||
# Critical files from our assessment
|
# Critical files from our assessment
|
||||||
files=(
|
files=(
|
||||||
src/components/MembersList.vue"
|
src/components/MeetingMembersList.vue"
|
||||||
"src/views/ContactsView.vue"
|
"src/views/ContactsView.vue"
|
||||||
src/views/OnboardMeetingSetupView.vue"
|
src/views/OnboardMeetingSetupView.vue"
|
||||||
src/db/databaseUtil.ts"
|
src/db/databaseUtil.ts"
|
||||||
|
|||||||
104
scripts/test-notification-receiver.sh
Executable file
104
scripts/test-notification-receiver.sh
Executable file
@@ -0,0 +1,104 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# test-notification-receiver.sh
|
||||||
|
# Author: Matthew Raymer
|
||||||
|
# Description: Test script to manually trigger the DailyNotificationReceiver
|
||||||
|
# to verify it's working correctly
|
||||||
|
|
||||||
|
# Function to find adb command
|
||||||
|
find_adb() {
|
||||||
|
# Check if adb is in PATH
|
||||||
|
if command -v adb >/dev/null 2>&1; then
|
||||||
|
echo "adb"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check for ANDROID_HOME
|
||||||
|
if [ -n "$ANDROID_HOME" ] && [ -x "$ANDROID_HOME/platform-tools/adb" ]; then
|
||||||
|
echo "$ANDROID_HOME/platform-tools/adb"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check for local.properties
|
||||||
|
local local_props="android/local.properties"
|
||||||
|
if [ -f "$local_props" ]; then
|
||||||
|
local sdk_dir=$(grep "^sdk.dir=" "$local_props" 2>/dev/null | cut -d'=' -f2 | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | sed "s|^file://||")
|
||||||
|
if [ -n "$sdk_dir" ] && [ -x "$sdk_dir/platform-tools/adb" ]; then
|
||||||
|
echo "$sdk_dir/platform-tools/adb"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Try common macOS locations
|
||||||
|
local common_locations=(
|
||||||
|
"$HOME/Library/Android/sdk"
|
||||||
|
"$HOME/Android/Sdk"
|
||||||
|
"$HOME/.android/sdk"
|
||||||
|
)
|
||||||
|
|
||||||
|
for sdk_path in "${common_locations[@]}"; do
|
||||||
|
if [ -x "$sdk_path/platform-tools/adb" ]; then
|
||||||
|
echo "$sdk_path/platform-tools/adb"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Not found
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Find adb
|
||||||
|
ADB_CMD=$(find_adb)
|
||||||
|
if [ $? -ne 0 ] || [ -z "$ADB_CMD" ]; then
|
||||||
|
echo "Error: adb command not found!"
|
||||||
|
echo ""
|
||||||
|
echo "Please ensure one of the following:"
|
||||||
|
echo " 1. adb is in your PATH"
|
||||||
|
echo " 2. ANDROID_HOME is set and points to Android SDK"
|
||||||
|
echo " 3. Android SDK is installed at:"
|
||||||
|
echo " - $HOME/Library/Android/sdk (macOS default)"
|
||||||
|
echo " - $HOME/Android/Sdk"
|
||||||
|
echo ""
|
||||||
|
echo "You can find your SDK location in Android Studio:"
|
||||||
|
echo " Preferences > Appearance & Behavior > System Settings > Android SDK"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Testing DailyNotificationReceiver..."
|
||||||
|
echo "Using adb: $ADB_CMD"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Get the package name
|
||||||
|
PACKAGE_NAME="app.timesafari.app"
|
||||||
|
INTENT_ACTION="org.timesafari.daily.NOTIFICATION"
|
||||||
|
|
||||||
|
echo "Package: $PACKAGE_NAME"
|
||||||
|
echo "Intent Action: $INTENT_ACTION"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Check if device is connected
|
||||||
|
if ! "$ADB_CMD" devices | grep -q $'\tdevice'; then
|
||||||
|
echo "Error: No Android device/emulator connected!"
|
||||||
|
echo ""
|
||||||
|
echo "Please:"
|
||||||
|
echo " 1. Start an Android emulator in Android Studio, or"
|
||||||
|
echo " 2. Connect a physical device via USB"
|
||||||
|
echo ""
|
||||||
|
echo "Then run: $ADB_CMD devices"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Test 1: Send broadcast intent to trigger receiver (without ID - simulates current bug)
|
||||||
|
echo "Test 1: Sending broadcast intent to DailyNotificationReceiver (without ID)..."
|
||||||
|
"$ADB_CMD" shell am broadcast -a "$INTENT_ACTION" -n "$PACKAGE_NAME/org.timesafari.dailynotification.DailyNotificationReceiver"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Test 2: Sending broadcast intent WITH ID (to test if receiver works with ID)..."
|
||||||
|
"$ADB_CMD" shell am broadcast -a "$INTENT_ACTION" -n "$PACKAGE_NAME/org.timesafari.dailynotification.DailyNotificationReceiver" --es "id" "timesafari_daily_reminder"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Check logcat for 'DN|RECEIVE_START' to see if receiver was triggered"
|
||||||
|
echo "Test 1 should show 'missing_id' error"
|
||||||
|
echo "Test 2 should work correctly (if plugin supports it)"
|
||||||
|
echo ""
|
||||||
|
echo "To monitor logs, run:"
|
||||||
|
echo " $ADB_CMD logcat | grep -E 'DN|RECEIVE_START|DailyNotification'"
|
||||||
@@ -95,7 +95,7 @@ print_status "All type safety checks passed! 🎉"
|
|||||||
print_status "Your code is ready for commit"
|
print_status "Your code is ready for commit"
|
||||||
echo ""
|
echo ""
|
||||||
echo "📚 Remember to follow the Type Safety Guidelines:"
|
echo "📚 Remember to follow the Type Safety Guidelines:"
|
||||||
echo " - docs/typescript-type-safety-guidelines.md"
|
echo " - doc/typescript-type-safety-guidelines.md"
|
||||||
echo " - Use proper error handling patterns"
|
echo " - Use proper error handling patterns"
|
||||||
echo " - Leverage existing type definitions"
|
echo " - Leverage existing type definitions"
|
||||||
echo " - Run 'npm run lint-fix' for automatic fixes"
|
echo " - Run 'npm run lint-fix' for automatic fixes"
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ echo "=========================="
|
|||||||
|
|
||||||
# Critical files from our assessment
|
# Critical files from our assessment
|
||||||
files=(
|
files=(
|
||||||
src/components/MembersList.vue"
|
src/components/MeetingMembersList.vue"
|
||||||
"src/views/ContactsView.vue"
|
"src/views/ContactsView.vue"
|
||||||
src/views/OnboardMeetingSetupView.vue"
|
src/views/OnboardMeetingSetupView.vue"
|
||||||
src/db/databaseUtil.ts"
|
src/db/databaseUtil.ts"
|
||||||
|
|||||||
@@ -321,7 +321,7 @@ suggest_next_steps() {
|
|||||||
echo "3. Update documentation to reflect new patterns"
|
echo "3. Update documentation to reflect new patterns"
|
||||||
else
|
else
|
||||||
echo "1. Start with high-priority files (databaseUtil and logging)"
|
echo "1. Start with high-priority files (databaseUtil and logging)"
|
||||||
echo "2. Use the migration template: docs/migration-templates/component-migration.md"
|
echo "2. Use the migration template: doc/migration-templates/component-migration.md"
|
||||||
echo "3. Test each component after migration"
|
echo "3. Test each component after migration"
|
||||||
echo "4. Set up ESLint rules to prevent new legacy usage"
|
echo "4. Set up ESLint rules to prevent new legacy usage"
|
||||||
echo "5. Re-run this script to track progress"
|
echo "5. Re-run this script to track progress"
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ if [[ $total_issues -gt 0 ]]; then
|
|||||||
echo ""
|
echo ""
|
||||||
echo "🚨 ACTION REQUIRED:"
|
echo "🚨 ACTION REQUIRED:"
|
||||||
echo " $total_issues components need notification migration completion"
|
echo " $total_issues components need notification migration completion"
|
||||||
echo " Follow: docs/migration-templates/COMPLETE_MIGRATION_CHECKLIST.md"
|
echo " Follow: doc/migration-templates/COMPLETE_MIGRATION_CHECKLIST.md"
|
||||||
exit 1
|
exit 1
|
||||||
else
|
else
|
||||||
echo ""
|
echo ""
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<font-awesome
|
<font-awesome
|
||||||
icon="circle-info"
|
icon="circle-info"
|
||||||
class="text-2xl text-blue-500 ml-2"
|
class="text-2xl text-blue-500 ml-4"
|
||||||
@click="emitShowCopyInfo"
|
@click="emitShowCopyInfo"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -105,11 +105,9 @@ import { Component, Prop, Vue } from "vue-facing-decorator";
|
|||||||
import { Router } from "vue-router";
|
import { Router } from "vue-router";
|
||||||
import * as R from "ramda";
|
import * as R from "ramda";
|
||||||
|
|
||||||
import { AppString, NotificationIface } from "../constants/app";
|
import { NotificationIface } from "../constants/app";
|
||||||
import { Contact } from "../db/tables/contacts";
|
|
||||||
|
|
||||||
import { logger } from "../utils/logger";
|
import { logger } from "../utils/logger";
|
||||||
import { contactsToExportJson } from "../libs/util";
|
|
||||||
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
|
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
|
||||||
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
|
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
|
||||||
import { ACCOUNT_VIEW_CONSTANTS } from "@/constants/accountView";
|
import { ACCOUNT_VIEW_CONSTANTS } from "@/constants/accountView";
|
||||||
@@ -222,26 +220,15 @@ export default class DataExportSection extends Vue {
|
|||||||
return "list-disc list-outside ml-4";
|
return "list-disc list-outside ml-4";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Computed property for the export file name
|
|
||||||
* Includes today's date for easy identification of backup files
|
|
||||||
*/
|
|
||||||
private get fileName(): string {
|
|
||||||
const today = new Date();
|
|
||||||
const dateString = today.toISOString().split("T")[0]; // YYYY-MM-DD format
|
|
||||||
return `${AppString.APP_NAME_NO_SPACES}-backup-contacts-${dateString}.json`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Exports the database to a JSON file
|
* Exports the database to a JSON file
|
||||||
|
* Exports contacts and contact labels tables
|
||||||
* Uses the platform service to handle platform-specific export logic
|
* Uses the platform service to handle platform-specific export logic
|
||||||
* Shows success/error notifications to user
|
* Shows success/error notifications to user
|
||||||
*
|
*
|
||||||
* @throws {Error} If export fails
|
* @throws {Error} If export fails
|
||||||
*/
|
*/
|
||||||
public async exportDatabase(): Promise<void> {
|
public async exportDatabase(): Promise<void> {
|
||||||
// Note that similar code is in ContactsView.vue exportContactData()
|
|
||||||
|
|
||||||
if (this.isExporting) {
|
if (this.isExporting) {
|
||||||
return; // Prevent multiple simultaneous exports
|
return; // Prevent multiple simultaneous exports
|
||||||
}
|
}
|
||||||
@@ -249,49 +236,11 @@ export default class DataExportSection extends Vue {
|
|||||||
try {
|
try {
|
||||||
this.isExporting = true;
|
this.isExporting = true;
|
||||||
|
|
||||||
// Fetch contacts from database using mixin's cached method
|
// Prepare export data using shared utility function
|
||||||
const allContacts = await this.$contacts();
|
await this.$saveContactExport();
|
||||||
|
|
||||||
// Convert contacts to export format
|
|
||||||
const processedContacts: Contact[] = allContacts.map((contact) => {
|
|
||||||
// first remove the contactMethods field, mostly to cast to a clear type (that will end up with JSON objects)
|
|
||||||
const exContact: Contact = R.omit(["contactMethods"], contact);
|
|
||||||
// now add contactMethods as a true array of ContactMethod objects
|
|
||||||
// $contacts() returns normalized contacts where contactMethods is already an array,
|
|
||||||
// but we handle both array and string cases for robustness
|
|
||||||
if (contact.contactMethods) {
|
|
||||||
if (Array.isArray(contact.contactMethods)) {
|
|
||||||
// Already an array, use it directly
|
|
||||||
exContact.contactMethods = contact.contactMethods;
|
|
||||||
} else {
|
|
||||||
// Check if it's a string that needs parsing (shouldn't happen with normalized contacts, but handle for robustness)
|
|
||||||
const contactMethodsValue = contact.contactMethods as unknown;
|
|
||||||
if (
|
|
||||||
typeof contactMethodsValue === "string" &&
|
|
||||||
contactMethodsValue.trim() !== ""
|
|
||||||
) {
|
|
||||||
// String that needs parsing
|
|
||||||
exContact.contactMethods = JSON.parse(contactMethodsValue);
|
|
||||||
} else {
|
|
||||||
// Invalid data, use empty array
|
|
||||||
exContact.contactMethods = [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// No contactMethods, use empty array
|
|
||||||
exContact.contactMethods = [];
|
|
||||||
}
|
|
||||||
return exContact;
|
|
||||||
});
|
|
||||||
|
|
||||||
const exportData = contactsToExportJson(processedContacts);
|
|
||||||
const jsonStr = JSON.stringify(exportData, null, 2);
|
|
||||||
|
|
||||||
// Use platform service to handle export (no platform-specific logic here!)
|
|
||||||
await this.platformService.writeAndShareFile(this.fileName, jsonStr);
|
|
||||||
|
|
||||||
this.notify.success(
|
this.notify.success(
|
||||||
"Contact export completed successfully. Check your downloads or share dialog.",
|
"Contact export completed successfully. Check downloads or the share dialog.",
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Export Error:", error);
|
logger.error("Export Error:", error);
|
||||||
|
|||||||
@@ -57,8 +57,8 @@ projects, and special entities with selection. * * @author Matthew Raymer */
|
|||||||
:selectable="youSelectable"
|
:selectable="youSelectable"
|
||||||
:conflicted="youConflicted"
|
:conflicted="youConflicted"
|
||||||
:entity-data="youEntityData"
|
:entity-data="youEntityData"
|
||||||
:notify="notify"
|
|
||||||
:conflict-context="conflictContext"
|
:conflict-context="conflictContext"
|
||||||
|
:notify="notify"
|
||||||
@entity-selected="handleEntitySelected"
|
@entity-selected="handleEntitySelected"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -69,8 +69,8 @@ projects, and special entities with selection. * * @author Matthew Raymer */
|
|||||||
:label="unnamedEntityName"
|
:label="unnamedEntityName"
|
||||||
icon="circle-question"
|
icon="circle-question"
|
||||||
:entity-data="unnamedEntityData"
|
:entity-data="unnamedEntityData"
|
||||||
:notify="notify"
|
|
||||||
:conflict-context="conflictContext"
|
:conflict-context="conflictContext"
|
||||||
|
:notify="notify"
|
||||||
@entity-selected="handleEntitySelected"
|
@entity-selected="handleEntitySelected"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
@@ -97,8 +97,8 @@ projects, and special entities with selection. * * @author Matthew Raymer */
|
|||||||
:person="person"
|
:person="person"
|
||||||
:conflicted="isPersonConflicted(person.did)"
|
:conflicted="isPersonConflicted(person.did)"
|
||||||
:show-time-icon="true"
|
:show-time-icon="true"
|
||||||
:notify="notify"
|
|
||||||
:conflict-context="conflictContext"
|
:conflict-context="conflictContext"
|
||||||
|
:notify="notify"
|
||||||
@person-selected="handlePersonSelected"
|
@person-selected="handlePersonSelected"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
@@ -116,8 +116,8 @@ projects, and special entities with selection. * * @author Matthew Raymer */
|
|||||||
:person="person"
|
:person="person"
|
||||||
:conflicted="isPersonConflicted(person.did)"
|
:conflicted="isPersonConflicted(person.did)"
|
||||||
:show-time-icon="true"
|
:show-time-icon="true"
|
||||||
:notify="notify"
|
|
||||||
:conflict-context="conflictContext"
|
:conflict-context="conflictContext"
|
||||||
|
:notify="notify"
|
||||||
@person-selected="handlePersonSelected"
|
@person-selected="handlePersonSelected"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
@@ -131,40 +131,40 @@ projects, and special entities with selection. * * @author Matthew Raymer */
|
|||||||
:person="person"
|
:person="person"
|
||||||
:conflicted="isPersonConflicted(person.did)"
|
:conflicted="isPersonConflicted(person.did)"
|
||||||
:show-time-icon="true"
|
:show-time-icon="true"
|
||||||
:notify="notify"
|
|
||||||
:conflict-context="conflictContext"
|
:conflict-context="conflictContext"
|
||||||
|
:notify="notify"
|
||||||
@person-selected="handlePersonSelected"
|
@person-selected="handlePersonSelected"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else-if="entityType === 'projects'">
|
<template v-else-if="entityType === 'projects'">
|
||||||
<!-- When showing projects without search: split into recently bookmarked and rest -->
|
<!-- When showing projects without search: split into recently starred and rest -->
|
||||||
<template v-if="!searchTerm.trim()">
|
<template v-if="!searchTerm.trim()">
|
||||||
<!-- Recently Bookmarked Section -->
|
<!-- Recently Starred Section -->
|
||||||
<template v-if="recentBookmarkedProjects.length > 0">
|
<template v-if="recentStarredProjectsToShow.length > 0">
|
||||||
<li
|
<li
|
||||||
class="text-xs font-semibold text-slate-500 uppercase pt-5 pb-1.5 px-2 border-b border-slate-300"
|
class="text-xs font-semibold text-slate-500 uppercase pt-5 pb-1.5 px-2 border-b border-slate-300"
|
||||||
>
|
>
|
||||||
Recently Bookmarked
|
Recently Starred
|
||||||
</li>
|
</li>
|
||||||
<ProjectCard
|
<ProjectCard
|
||||||
v-for="project in recentBookmarkedProjects"
|
v-for="project in recentStarredProjectsToShow"
|
||||||
:key="project.handleId"
|
:key="project.handleId"
|
||||||
:project="project"
|
:project="project"
|
||||||
:active-did="activeDid"
|
:active-did="activeDid"
|
||||||
:all-my-dids="allMyDids"
|
:all-my-dids="allMyDids"
|
||||||
:all-contacts="allContacts"
|
:all-contacts="allContacts"
|
||||||
:conflicted="isProjectConflicted(project.handleId)"
|
:conflicted="isProjectConflicted(project.handleId)"
|
||||||
:notify="notify"
|
|
||||||
:conflict-context="conflictContext"
|
:conflict-context="conflictContext"
|
||||||
|
:notify="notify"
|
||||||
@project-selected="handleProjectSelected"
|
@project-selected="handleProjectSelected"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Rest of Projects Section -->
|
<!-- Rest of Projects Section -->
|
||||||
<li
|
<li
|
||||||
v-if="recentBookmarkedProjects.length > 0"
|
v-if="remainingProjects.length > 0"
|
||||||
class="text-xs font-semibold text-slate-500 uppercase pt-5 pb-1.5 px-2 border-b border-slate-300"
|
class="text-xs font-semibold text-slate-500 uppercase pt-5 pb-1.5 px-2 border-b border-slate-300"
|
||||||
>
|
>
|
||||||
All Projects
|
All Projects
|
||||||
@@ -177,8 +177,8 @@ projects, and special entities with selection. * * @author Matthew Raymer */
|
|||||||
:all-my-dids="allMyDids"
|
:all-my-dids="allMyDids"
|
||||||
:all-contacts="allContacts"
|
:all-contacts="allContacts"
|
||||||
:conflicted="isProjectConflicted(project.handleId)"
|
:conflicted="isProjectConflicted(project.handleId)"
|
||||||
:notify="notify"
|
|
||||||
:conflict-context="conflictContext"
|
:conflict-context="conflictContext"
|
||||||
|
:notify="notify"
|
||||||
@project-selected="handleProjectSelected"
|
@project-selected="handleProjectSelected"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
@@ -193,8 +193,8 @@ projects, and special entities with selection. * * @author Matthew Raymer */
|
|||||||
:all-my-dids="allMyDids"
|
:all-my-dids="allMyDids"
|
||||||
:all-contacts="allContacts"
|
:all-contacts="allContacts"
|
||||||
:conflicted="isProjectConflicted(project.handleId)"
|
:conflicted="isProjectConflicted(project.handleId)"
|
||||||
:notify="notify"
|
|
||||||
:conflict-context="conflictContext"
|
:conflict-context="conflictContext"
|
||||||
|
:notify="notify"
|
||||||
@project-selected="handleProjectSelected"
|
@project-selected="handleProjectSelected"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
@@ -223,7 +223,7 @@ import { TIMEOUTS } from "@/utils/notify";
|
|||||||
const INITIAL_BATCH_SIZE = 20;
|
const INITIAL_BATCH_SIZE = 20;
|
||||||
const INCREMENT_SIZE = 20;
|
const INCREMENT_SIZE = 20;
|
||||||
const RECENT_CONTACTS_COUNT = 3;
|
const RECENT_CONTACTS_COUNT = 3;
|
||||||
const RECENT_BOOKMARKED_PROJECTS_COUNT = 10;
|
const RECENT_STARRED_PROJECTS_COUNT = 10;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* EntityGrid - Unified grid layout for displaying people or projects
|
* EntityGrid - Unified grid layout for displaying people or projects
|
||||||
@@ -251,30 +251,6 @@ export default class EntityGrid extends Vue {
|
|||||||
@Prop({ required: true })
|
@Prop({ required: true })
|
||||||
entityType!: "people" | "projects";
|
entityType!: "people" | "projects";
|
||||||
|
|
||||||
// Search state
|
|
||||||
searchTerm = "";
|
|
||||||
isSearching = false;
|
|
||||||
searchTimeout: NodeJS.Timeout | null = null;
|
|
||||||
filteredEntities: Contact[] | PlanData[] = [];
|
|
||||||
searchBeforeId: string | undefined = undefined;
|
|
||||||
isLoadingSearchMore = false;
|
|
||||||
|
|
||||||
// API server for project searches
|
|
||||||
apiServer = "";
|
|
||||||
|
|
||||||
// Internal project state (when entities prop not provided for projects)
|
|
||||||
allProjects: PlanData[] = [];
|
|
||||||
loadBeforeId: string | undefined = undefined;
|
|
||||||
isLoadingProjects = false;
|
|
||||||
|
|
||||||
// Infinite scroll state
|
|
||||||
displayedCount = INITIAL_BATCH_SIZE;
|
|
||||||
infiniteScrollReset?: () => void;
|
|
||||||
scrollContainer?: HTMLElement;
|
|
||||||
|
|
||||||
// Starred projects state (for showing recently bookmarked projects)
|
|
||||||
starredPlanHandleIds: string[] = [];
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Array of entities to display
|
* Array of entities to display
|
||||||
*
|
*
|
||||||
@@ -326,32 +302,30 @@ export default class EntityGrid extends Vue {
|
|||||||
@Prop({ default: "other party" })
|
@Prop({ default: "other party" })
|
||||||
conflictContext!: string;
|
conflictContext!: string;
|
||||||
|
|
||||||
/**
|
// Search state
|
||||||
* Function to determine which entities to display (allows parent control)
|
searchTerm = "";
|
||||||
*
|
isSearching = false;
|
||||||
* This function prop allows parent components to customize which entities
|
searchTimeout: NodeJS.Timeout | null = null;
|
||||||
* are displayed in the grid, enabling advanced filtering and sorting.
|
filteredEntities: Contact[] | PlanData[] = [];
|
||||||
* Note: Infinite scroll is disabled when this prop is provided.
|
searchBeforeId: string | undefined = undefined;
|
||||||
*
|
isLoadingSearchMore = false;
|
||||||
* @param entities - The full array of entities (Contact[] or PlanData[])
|
|
||||||
* @param entityType - The type of entities being displayed ("people" or "projects")
|
// API server for project searches
|
||||||
* @returns Filtered/sorted array of entities to display
|
apiServer = "";
|
||||||
*
|
|
||||||
* @example
|
// Internal project state (when entities prop not provided for projects)
|
||||||
* // Custom filtering: only show contacts with profile images
|
allProjects: PlanData[] = [];
|
||||||
* :display-entities-function="(entities, type) =>
|
loadBeforeId: string | undefined = undefined;
|
||||||
* entities.filter(e => e.profileImageUrl)"
|
isLoadingProjects = false;
|
||||||
*
|
|
||||||
* @example
|
// Infinite scroll state
|
||||||
* // Custom sorting: sort projects by name
|
displayedCount = INITIAL_BATCH_SIZE;
|
||||||
* :display-entities-function="(entities, type) =>
|
infiniteScrollReset?: () => void;
|
||||||
* entities.sort((a, b) => a.name.localeCompare(b.name))"
|
scrollContainer?: HTMLElement;
|
||||||
*/
|
|
||||||
@Prop({ default: null })
|
// Starred projects state (for showing recently starred projects)
|
||||||
displayEntitiesFunction?: (
|
starredPlanHandleIds: string[] = [];
|
||||||
entities: Contact[] | PlanData[],
|
recentStarredProjects: PlanData[] = [];
|
||||||
entityType: "people" | "projects",
|
|
||||||
) => Contact[] | PlanData[];
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CSS classes for the empty state message
|
* CSS classes for the empty state message
|
||||||
@@ -397,11 +371,6 @@ export default class EntityGrid extends Vue {
|
|||||||
return this.filteredEntities.slice(0, this.displayedCount);
|
return this.filteredEntities.slice(0, this.displayedCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If custom function provided, use it (disables infinite scroll)
|
|
||||||
if (this.displayEntitiesFunction) {
|
|
||||||
return this.displayEntitiesFunction(this.entitiesToUse, this.entityType);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default: projects use infinite scroll
|
// Default: projects use infinite scroll
|
||||||
if (this.entityType === "projects") {
|
if (this.entityType === "projects") {
|
||||||
return (this.entitiesToUse as PlanData[]).slice(0, this.displayedCount);
|
return (this.entitiesToUse as PlanData[]).slice(0, this.displayedCount);
|
||||||
@@ -457,40 +426,19 @@ export default class EntityGrid extends Vue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the 3 most recently bookmarked projects (when showing projects and not searching)
|
* Get the 3 most recently starred projects (when showing projects and not searching)
|
||||||
* The starredPlanHandleIds array order represents bookmark order (newest at the end)
|
* Returns the cached member field
|
||||||
*/
|
*/
|
||||||
get recentBookmarkedProjects(): PlanData[] {
|
get recentStarredProjectsToShow(): PlanData[] {
|
||||||
if (
|
if (this.entityType !== "projects" || this.searchTerm.trim()) {
|
||||||
this.entityType !== "projects" ||
|
|
||||||
this.searchTerm.trim() ||
|
|
||||||
this.starredPlanHandleIds.length === 0
|
|
||||||
) {
|
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
return this.recentStarredProjects;
|
||||||
const projects = this.entitiesToUse as PlanData[];
|
|
||||||
if (projects.length === 0) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the last 3 starred IDs (most recently bookmarked)
|
|
||||||
const recentStarredIds = this.starredPlanHandleIds.slice(
|
|
||||||
-RECENT_BOOKMARKED_PROJECTS_COUNT,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Find projects matching those IDs, sorting with newest first
|
|
||||||
const recentProjects = recentStarredIds
|
|
||||||
.map((id) => projects.find((p) => p.handleId === id))
|
|
||||||
.filter((p): p is PlanData => p !== undefined)
|
|
||||||
.reverse();
|
|
||||||
|
|
||||||
return recentProjects;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all projects (when showing projects and not searching)
|
* Get all projects (when showing projects and not searching)
|
||||||
* Includes projects shown in "Recently Bookmarked" section as well
|
* Includes projects shown in "Recently Starred" section as well
|
||||||
* Uses infinite scroll to control how many are displayed
|
* Uses infinite scroll to control how many are displayed
|
||||||
*/
|
*/
|
||||||
get remainingProjects(): PlanData[] {
|
get remainingProjects(): PlanData[] {
|
||||||
@@ -552,6 +500,115 @@ export default class EntityGrid extends Vue {
|
|||||||
return UNNAMED_ENTITY_NAME;
|
return UNNAMED_ENTITY_NAME;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize infinite scroll on mount
|
||||||
|
*/
|
||||||
|
async mounted(): Promise<void> {
|
||||||
|
if (this.entityType === "projects") {
|
||||||
|
const settings = await this.$accountSettings();
|
||||||
|
this.apiServer = settings.apiServer || "";
|
||||||
|
|
||||||
|
// Load starred project IDs for showing recently starred projects
|
||||||
|
this.starredPlanHandleIds = settings.starredPlanHandleIds || [];
|
||||||
|
|
||||||
|
// Load projects on mount if entities prop not provided
|
||||||
|
this.isLoadingProjects = true;
|
||||||
|
if (!this.entities) {
|
||||||
|
await this.loadProjects();
|
||||||
|
}
|
||||||
|
await this.loadRecentStarredProjects();
|
||||||
|
this.isLoadingProjects = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate entities prop for people
|
||||||
|
if (this.entityType === "people") {
|
||||||
|
if (!this.entities) {
|
||||||
|
logger.error(
|
||||||
|
"EntityGrid: entities prop or allContacts prop is required when entityType is 'people'",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.$nextTick(() => {
|
||||||
|
const container = this.$refs.scrollContainer as HTMLElement;
|
||||||
|
|
||||||
|
if (container) {
|
||||||
|
const { reset } = useInfiniteScroll(
|
||||||
|
container,
|
||||||
|
async () => {
|
||||||
|
// Search mode: handle search pagination
|
||||||
|
if (this.searchTerm.trim()) {
|
||||||
|
if (this.entityType === "projects") {
|
||||||
|
// Projects: load more search results if available
|
||||||
|
if (
|
||||||
|
this.displayedCount >= this.filteredEntities.length &&
|
||||||
|
this.searchBeforeId &&
|
||||||
|
!this.isLoadingSearchMore
|
||||||
|
) {
|
||||||
|
this.isLoadingSearchMore = true;
|
||||||
|
try {
|
||||||
|
const searchLower = this.searchTerm.toLowerCase().trim();
|
||||||
|
await this.loadProjects(this.searchBeforeId, searchLower);
|
||||||
|
// After loading more, reset scroll state to allow further loading
|
||||||
|
this.infiniteScrollReset?.();
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error loading more search results:", error);
|
||||||
|
// Error already handled in loadProjects
|
||||||
|
} finally {
|
||||||
|
this.isLoadingSearchMore = false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Show more from already-loaded search results
|
||||||
|
this.displayedCount += INCREMENT_SIZE;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Contacts: show more from already-filtered results
|
||||||
|
this.displayedCount += INCREMENT_SIZE;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Non-search mode
|
||||||
|
if (this.entityType === "projects") {
|
||||||
|
const projectsToCheck = this.entities || this.allProjects;
|
||||||
|
const beforeId = this.entities ? undefined : this.loadBeforeId;
|
||||||
|
|
||||||
|
// If using internal state and need to load more from server
|
||||||
|
if (
|
||||||
|
!this.entities &&
|
||||||
|
this.displayedCount >= projectsToCheck.length &&
|
||||||
|
beforeId &&
|
||||||
|
!this.isLoadingProjects
|
||||||
|
) {
|
||||||
|
this.isLoadingProjects = true;
|
||||||
|
try {
|
||||||
|
await this.loadProjects(beforeId);
|
||||||
|
// After loading more, reset scroll state to allow further loading
|
||||||
|
this.infiniteScrollReset?.();
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error loading more projects:", error);
|
||||||
|
// Error already handled in loadProjects
|
||||||
|
} finally {
|
||||||
|
this.isLoadingProjects = false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Normal case: increment displayedCount to show more from memory
|
||||||
|
this.displayedCount += INCREMENT_SIZE;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// People: increment displayedCount to show more from memory
|
||||||
|
this.displayedCount += INCREMENT_SIZE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
distance: 50, // pixels from bottom
|
||||||
|
canLoadMore: () => this.canLoadMore(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
this.infiniteScrollReset = reset;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a person DID is conflicted
|
* Check if a person DID is conflicted
|
||||||
*/
|
*/
|
||||||
@@ -636,7 +693,7 @@ export default class EntityGrid extends Vue {
|
|||||||
if (this.entityType === "projects") {
|
if (this.entityType === "projects") {
|
||||||
// Server-side search for projects (initial load, no beforeId)
|
// Server-side search for projects (initial load, no beforeId)
|
||||||
const searchLower = this.searchTerm.toLowerCase().trim();
|
const searchLower = this.searchTerm.toLowerCase().trim();
|
||||||
await this.fetchProjects(undefined, searchLower);
|
await this.loadProjects(undefined, searchLower);
|
||||||
} else {
|
} else {
|
||||||
// Client-side filtering for contacts (complete list)
|
// Client-side filtering for contacts (complete list)
|
||||||
await this.performContactSearch();
|
await this.performContactSearch();
|
||||||
@@ -659,10 +716,7 @@ export default class EntityGrid extends Vue {
|
|||||||
* @param beforeId - Optional rowId for pagination (loads projects before this ID)
|
* @param beforeId - Optional rowId for pagination (loads projects before this ID)
|
||||||
* @param claimContents - Optional search term (if provided, performs search; if not, loads all)
|
* @param claimContents - Optional search term (if provided, performs search; if not, loads all)
|
||||||
*/
|
*/
|
||||||
async fetchProjects(
|
async loadProjects(beforeId?: string, claimContents?: string): Promise<void> {
|
||||||
beforeId?: string,
|
|
||||||
claimContents?: string,
|
|
||||||
): Promise<void> {
|
|
||||||
if (!this.apiServer) {
|
if (!this.apiServer) {
|
||||||
if (claimContents) {
|
if (claimContents) {
|
||||||
this.filteredEntities = [];
|
this.filteredEntities = [];
|
||||||
@@ -806,6 +860,57 @@ export default class EntityGrid extends Vue {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load the most recently starred projects
|
||||||
|
* The starredPlanHandleIds array order represents starred order (newest at the end)
|
||||||
|
*/
|
||||||
|
async loadRecentStarredProjects(): Promise<void> {
|
||||||
|
if (
|
||||||
|
this.entityType !== "projects" ||
|
||||||
|
this.searchTerm.trim() ||
|
||||||
|
this.starredPlanHandleIds.length === 0
|
||||||
|
) {
|
||||||
|
this.recentStarredProjects = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the last 3 starred IDs (most recently starred)
|
||||||
|
const recentStarredIds = this.starredPlanHandleIds.slice(
|
||||||
|
-RECENT_STARRED_PROJECTS_COUNT,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Find projects matching those IDs, sorting with newest first
|
||||||
|
const projects = this.entitiesToUse as PlanData[];
|
||||||
|
const recentProjects = recentStarredIds
|
||||||
|
.map((id) => projects.find((p) => p.handleId === id))
|
||||||
|
.filter((p): p is PlanData => p !== undefined)
|
||||||
|
.reverse();
|
||||||
|
|
||||||
|
// If any projects are not found, fetch them from the API server
|
||||||
|
if (recentProjects.length < recentStarredIds.length) {
|
||||||
|
const missingIds = recentStarredIds.filter(
|
||||||
|
(id) => !recentProjects.some((p) => p.handleId === id),
|
||||||
|
);
|
||||||
|
const missingProjects = await this.fetchProjectsByIds(missingIds);
|
||||||
|
recentProjects.push(...missingProjects);
|
||||||
|
}
|
||||||
|
this.recentStarredProjects = recentProjects;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchProjectsByIds(ids: string[]): Promise<PlanData[]> {
|
||||||
|
const idsString = encodeURIComponent(JSON.stringify(ids));
|
||||||
|
const url = `${this.apiServer}/api/v2/report/plans?planHandleIds=${idsString}`;
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: "GET",
|
||||||
|
headers: await getHeaders(this.activeDid),
|
||||||
|
});
|
||||||
|
if (response.status !== 200) {
|
||||||
|
throw new Error("Failed to fetch projects");
|
||||||
|
}
|
||||||
|
const results = await response.json();
|
||||||
|
return results.data;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Client-side contact search
|
* Client-side contact search
|
||||||
* Assumes entities prop contains complete contact list from local database
|
* Assumes entities prop contains complete contact list from local database
|
||||||
@@ -860,11 +965,6 @@ export default class EntityGrid extends Vue {
|
|||||||
* Determine if more entities can be loaded
|
* Determine if more entities can be loaded
|
||||||
*/
|
*/
|
||||||
canLoadMore(): boolean {
|
canLoadMore(): boolean {
|
||||||
if (this.displayEntitiesFunction) {
|
|
||||||
// Custom function disables infinite scroll
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.searchTerm.trim()) {
|
if (this.searchTerm.trim()) {
|
||||||
// Search mode: check if more results available
|
// Search mode: check if more results available
|
||||||
if (this.entityType === "projects") {
|
if (this.entityType === "projects") {
|
||||||
@@ -911,129 +1011,6 @@ export default class EntityGrid extends Vue {
|
|||||||
return this.displayedCount < this.entities.length;
|
return this.displayedCount < this.entities.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize infinite scroll on mount
|
|
||||||
*/
|
|
||||||
async mounted(): Promise<void> {
|
|
||||||
// Load apiServer for project searches/loads
|
|
||||||
if (this.entityType === "projects") {
|
|
||||||
const settings = await this.$accountSettings();
|
|
||||||
this.apiServer = settings.apiServer || "";
|
|
||||||
|
|
||||||
// Load starred project IDs for showing recently bookmarked projects
|
|
||||||
this.starredPlanHandleIds = settings.starredPlanHandleIds || [];
|
|
||||||
|
|
||||||
// Load projects on mount if entities prop not provided
|
|
||||||
if (!this.entities && this.apiServer) {
|
|
||||||
this.isLoadingProjects = true;
|
|
||||||
try {
|
|
||||||
await this.fetchProjects();
|
|
||||||
} catch (error) {
|
|
||||||
logger.error("Error loading projects on mount:", error);
|
|
||||||
} finally {
|
|
||||||
this.isLoadingProjects = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate entities prop for people
|
|
||||||
if (this.entityType === "people" && !this.entities) {
|
|
||||||
logger.error(
|
|
||||||
"EntityGrid: entities prop is required when entityType is 'people'",
|
|
||||||
);
|
|
||||||
if (this.notify) {
|
|
||||||
this.notify(
|
|
||||||
{
|
|
||||||
group: "alert",
|
|
||||||
type: "danger",
|
|
||||||
title: "Error",
|
|
||||||
text: "Contacts data is required but not provided.",
|
|
||||||
},
|
|
||||||
TIMEOUTS.SHORT,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.$nextTick(() => {
|
|
||||||
const container = this.$refs.scrollContainer as HTMLElement;
|
|
||||||
|
|
||||||
if (container) {
|
|
||||||
const { reset } = useInfiniteScroll(
|
|
||||||
container,
|
|
||||||
async () => {
|
|
||||||
// Search mode: handle search pagination
|
|
||||||
if (this.searchTerm.trim()) {
|
|
||||||
if (this.entityType === "projects") {
|
|
||||||
// Projects: load more search results if available
|
|
||||||
if (
|
|
||||||
this.displayedCount >= this.filteredEntities.length &&
|
|
||||||
this.searchBeforeId &&
|
|
||||||
!this.isLoadingSearchMore
|
|
||||||
) {
|
|
||||||
this.isLoadingSearchMore = true;
|
|
||||||
try {
|
|
||||||
const searchLower = this.searchTerm.toLowerCase().trim();
|
|
||||||
await this.fetchProjects(this.searchBeforeId, searchLower);
|
|
||||||
// After loading more, reset scroll state to allow further loading
|
|
||||||
this.infiniteScrollReset?.();
|
|
||||||
} catch (error) {
|
|
||||||
logger.error("Error loading more search results:", error);
|
|
||||||
// Error already handled in fetchProjects
|
|
||||||
} finally {
|
|
||||||
this.isLoadingSearchMore = false;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Show more from already-loaded search results
|
|
||||||
this.displayedCount += INCREMENT_SIZE;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Contacts: show more from already-filtered results
|
|
||||||
this.displayedCount += INCREMENT_SIZE;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Non-search mode
|
|
||||||
if (this.entityType === "projects") {
|
|
||||||
const projectsToCheck = this.entities || this.allProjects;
|
|
||||||
const beforeId = this.entities ? undefined : this.loadBeforeId;
|
|
||||||
|
|
||||||
// If using internal state and need to load more from server
|
|
||||||
if (
|
|
||||||
!this.entities &&
|
|
||||||
this.displayedCount >= projectsToCheck.length &&
|
|
||||||
beforeId &&
|
|
||||||
!this.isLoadingProjects
|
|
||||||
) {
|
|
||||||
this.isLoadingProjects = true;
|
|
||||||
try {
|
|
||||||
await this.fetchProjects(beforeId);
|
|
||||||
// After loading more, reset scroll state to allow further loading
|
|
||||||
this.infiniteScrollReset?.();
|
|
||||||
} catch (error) {
|
|
||||||
logger.error("Error loading more projects:", error);
|
|
||||||
// Error already handled in fetchProjects
|
|
||||||
} finally {
|
|
||||||
this.isLoadingProjects = false;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Normal case: increment displayedCount to show more from memory
|
|
||||||
this.displayedCount += INCREMENT_SIZE;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// People: increment displayedCount to show more from memory
|
|
||||||
this.displayedCount += INCREMENT_SIZE;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
distance: 50, // pixels from bottom
|
|
||||||
canLoadMore: () => this.canLoadMore(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
this.infiniteScrollReset = reset;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Emit methods using @Emit decorator
|
// Emit methods using @Emit decorator
|
||||||
|
|
||||||
@Emit("entity-selected")
|
@Emit("entity-selected")
|
||||||
@@ -1061,26 +1038,15 @@ export default class EntityGrid extends Vue {
|
|||||||
|
|
||||||
// When switching to projects, load them if not provided via entities prop
|
// When switching to projects, load them if not provided via entities prop
|
||||||
if (newType === "projects" && !this.entities) {
|
if (newType === "projects" && !this.entities) {
|
||||||
// Ensure apiServer is loaded
|
const settings = await this.$accountSettings();
|
||||||
if (!this.apiServer) {
|
this.apiServer = settings.apiServer || "";
|
||||||
const settings = await this.$accountSettings();
|
this.starredPlanHandleIds = settings.starredPlanHandleIds || [];
|
||||||
this.apiServer = settings.apiServer || "";
|
|
||||||
this.starredPlanHandleIds = settings.starredPlanHandleIds || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load projects if we have an API server
|
if (this.allProjects.length === 0) {
|
||||||
if (this.apiServer && this.allProjects.length === 0) {
|
|
||||||
this.isLoadingProjects = true;
|
this.isLoadingProjects = true;
|
||||||
try {
|
await this.loadProjects();
|
||||||
await this.fetchProjects();
|
await this.loadRecentStarredProjects();
|
||||||
} catch (error) {
|
this.isLoadingProjects = false;
|
||||||
logger.error(
|
|
||||||
"Error loading projects when switching to projects:",
|
|
||||||
error,
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
this.isLoadingProjects = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,14 +24,14 @@ properties * * @author Matthew Raymer */
|
|||||||
|
|
||||||
<EntityGrid
|
<EntityGrid
|
||||||
:entity-type="shouldShowProjects ? 'projects' : 'people'"
|
:entity-type="shouldShowProjects ? 'projects' : 'people'"
|
||||||
:entities="shouldShowProjects ? projects || undefined : allContacts"
|
:entities="shouldShowProjects ? undefined : allContacts"
|
||||||
:active-did="activeDid"
|
:active-did="activeDid"
|
||||||
:all-my-dids="allMyDids"
|
:all-my-dids="allMyDids"
|
||||||
:all-contacts="allContacts"
|
:all-contacts="allContacts"
|
||||||
:conflict-checker="conflictChecker"
|
:conflict-checker="conflictChecker"
|
||||||
:you-selectable="youSelectable"
|
|
||||||
:notify="notify"
|
|
||||||
:conflict-context="conflictContext"
|
:conflict-context="conflictContext"
|
||||||
|
:notify="notify"
|
||||||
|
:you-selectable="youSelectable"
|
||||||
@entity-selected="handleEntitySelected"
|
@entity-selected="handleEntitySelected"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -45,16 +45,7 @@ import EntityGrid from "./EntityGrid.vue";
|
|||||||
import { Contact } from "../db/tables/contacts";
|
import { Contact } from "../db/tables/contacts";
|
||||||
import { PlanData } from "../interfaces/records";
|
import { PlanData } from "../interfaces/records";
|
||||||
import { NotificationIface } from "../constants/app";
|
import { NotificationIface } from "../constants/app";
|
||||||
|
import { GiverReceiverInputInfo } from "@/libs/util";
|
||||||
/**
|
|
||||||
* Entity data interface for giver/receiver
|
|
||||||
*/
|
|
||||||
interface EntityData {
|
|
||||||
did?: string;
|
|
||||||
handleId?: string;
|
|
||||||
name?: string;
|
|
||||||
image?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Entity selection event data structure
|
* Entity selection event data structure
|
||||||
@@ -87,22 +78,6 @@ export default class EntitySelectionStep extends Vue {
|
|||||||
@Prop({ required: true })
|
@Prop({ required: true })
|
||||||
stepType!: "giver" | "recipient";
|
stepType!: "giver" | "recipient";
|
||||||
|
|
||||||
/** Type of giver entity: 'person' or 'project' */
|
|
||||||
@Prop({ required: true })
|
|
||||||
giverEntityType!: "person" | "project";
|
|
||||||
|
|
||||||
/** Type of recipient entity: 'person' or 'project' */
|
|
||||||
@Prop({ required: true })
|
|
||||||
recipientEntityType!: "person" | "project";
|
|
||||||
|
|
||||||
/** Whether to show projects instead of people */
|
|
||||||
@Prop({ default: false })
|
|
||||||
showProjects!: boolean;
|
|
||||||
|
|
||||||
/** Array of available projects (optional - EntityGrid loads internally if not provided) */
|
|
||||||
@Prop({ required: false })
|
|
||||||
projects?: PlanData[];
|
|
||||||
|
|
||||||
/** Array of available contacts */
|
/** Array of available contacts */
|
||||||
@Prop({ required: true })
|
@Prop({ required: true })
|
||||||
allContacts!: Contact[];
|
allContacts!: Contact[];
|
||||||
@@ -119,40 +94,22 @@ export default class EntitySelectionStep extends Vue {
|
|||||||
@Prop({ required: true })
|
@Prop({ required: true })
|
||||||
conflictChecker!: (did: string) => boolean;
|
conflictChecker!: (did: string) => boolean;
|
||||||
|
|
||||||
/** Project ID for context (giver) */
|
|
||||||
@Prop({ default: "" })
|
|
||||||
fromProjectId!: string;
|
|
||||||
|
|
||||||
/** Project ID for context (recipient) */
|
|
||||||
@Prop({ default: "" })
|
|
||||||
toProjectId!: string;
|
|
||||||
|
|
||||||
/** Current giver entity for context */
|
/** Current giver entity for context */
|
||||||
@Prop()
|
@Prop()
|
||||||
giver?: EntityData | null;
|
giver?: GiverReceiverInputInfo | null;
|
||||||
|
|
||||||
/** Current receiver entity for context */
|
/** Current receiver entity for context */
|
||||||
@Prop()
|
@Prop()
|
||||||
receiver?: EntityData | null;
|
receiver?: GiverReceiverInputInfo | null;
|
||||||
|
|
||||||
/** Form field values to preserve when navigating to "Show All" */
|
|
||||||
@Prop({ default: "" })
|
|
||||||
description!: string;
|
|
||||||
|
|
||||||
@Prop({ default: "0" })
|
|
||||||
amountInput!: string;
|
|
||||||
|
|
||||||
@Prop({ default: "HUR" })
|
|
||||||
unitCode!: string;
|
|
||||||
|
|
||||||
/** Offer ID for context when fulfilling an offer */
|
|
||||||
@Prop({ default: "" })
|
|
||||||
offerId!: string;
|
|
||||||
|
|
||||||
/** Notification function from parent component */
|
/** Notification function from parent component */
|
||||||
@Prop()
|
@Prop()
|
||||||
notify?: (notification: NotificationIface, timeout?: number) => void;
|
notify?: (notification: NotificationIface, timeout?: number) => void;
|
||||||
|
|
||||||
|
// initializing based on a Prop doesn't work here; see "mounted()"
|
||||||
|
newGiverEntityType: "person" | "project" = "person";
|
||||||
|
newRecipientEntityType: "person" | "project" = "person";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CSS classes for the cancel button
|
* CSS classes for the cancel button
|
||||||
*/
|
*/
|
||||||
@@ -195,11 +152,10 @@ export default class EntitySelectionStep extends Vue {
|
|||||||
* Whether to show projects in the grid
|
* Whether to show projects in the grid
|
||||||
*/
|
*/
|
||||||
get shouldShowProjects(): boolean {
|
get shouldShowProjects(): boolean {
|
||||||
// When editing an entity, show the appropriate entity type for that entity
|
|
||||||
if (this.stepType === "giver") {
|
if (this.stepType === "giver") {
|
||||||
return this.giverEntityType === "project";
|
return this.newGiverEntityType === "project";
|
||||||
} else if (this.stepType === "recipient") {
|
} else if (this.stepType === "recipient") {
|
||||||
return this.recipientEntityType === "project";
|
return this.newRecipientEntityType === "project";
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -222,6 +178,13 @@ export default class EntitySelectionStep extends Vue {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async mounted(): Promise<void> {
|
||||||
|
this.newGiverEntityType = this.giver?.handleId ? "project" : "person";
|
||||||
|
this.newRecipientEntityType = this.receiver?.handleId
|
||||||
|
? "project"
|
||||||
|
: "person";
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle entity selection from EntityGrid
|
* Handle entity selection from EntityGrid
|
||||||
*/
|
*/
|
||||||
@@ -236,7 +199,13 @@ export default class EntitySelectionStep extends Vue {
|
|||||||
* Handle toggle entity type button click
|
* Handle toggle entity type button click
|
||||||
*/
|
*/
|
||||||
handleToggleEntityType(): void {
|
handleToggleEntityType(): void {
|
||||||
this.emitToggleEntityType();
|
if (this.stepType === "giver") {
|
||||||
|
this.newGiverEntityType =
|
||||||
|
this.newGiverEntityType === "person" ? "project" : "person";
|
||||||
|
} else if (this.stepType === "recipient") {
|
||||||
|
this.newRecipientEntityType =
|
||||||
|
this.newRecipientEntityType === "person" ? "project" : "person";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -259,11 +228,6 @@ export default class EntitySelectionStep extends Vue {
|
|||||||
emitCancel(): void {
|
emitCancel(): void {
|
||||||
// No return value needed
|
// No return value needed
|
||||||
}
|
}
|
||||||
|
|
||||||
@Emit("toggle-entity-type")
|
|
||||||
emitToggleEntityType(): void {
|
|
||||||
// No return value needed
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -50,16 +50,7 @@ import EntityIcon from "./EntityIcon.vue";
|
|||||||
import ProjectIcon from "./ProjectIcon.vue";
|
import ProjectIcon from "./ProjectIcon.vue";
|
||||||
import { Contact } from "../db/tables/contacts";
|
import { Contact } from "../db/tables/contacts";
|
||||||
import { UNNAMED_ENTITY_NAME } from "@/constants/entities";
|
import { UNNAMED_ENTITY_NAME } from "@/constants/entities";
|
||||||
|
import { GiverReceiverInputInfo } from "@/libs/util";
|
||||||
/**
|
|
||||||
* Entity interface for both person and project entities
|
|
||||||
*/
|
|
||||||
interface EntityData {
|
|
||||||
did?: string;
|
|
||||||
handleId?: string;
|
|
||||||
name?: string;
|
|
||||||
image?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* EntitySummaryButton - Displays selected entity with edit capability
|
* EntitySummaryButton - Displays selected entity with edit capability
|
||||||
@@ -81,11 +72,7 @@ interface EntityData {
|
|||||||
export default class EntitySummaryButton extends Vue {
|
export default class EntitySummaryButton extends Vue {
|
||||||
/** Entity data to display */
|
/** Entity data to display */
|
||||||
@Prop({ required: true })
|
@Prop({ required: true })
|
||||||
entity!: EntityData | Contact | null;
|
entity!: GiverReceiverInputInfo | Contact | null;
|
||||||
|
|
||||||
/** Type of entity: 'person' or 'project' */
|
|
||||||
@Prop({ required: true })
|
|
||||||
entityType!: "person" | "project";
|
|
||||||
|
|
||||||
/** Display label for the entity role */
|
/** Display label for the entity role */
|
||||||
@Prop({ required: true })
|
@Prop({ required: true })
|
||||||
@@ -98,9 +85,13 @@ export default class EntitySummaryButton extends Vue {
|
|||||||
@Prop({ type: Function, default: () => {} })
|
@Prop({ type: Function, default: () => {} })
|
||||||
onEditRequested!: (data: {
|
onEditRequested!: (data: {
|
||||||
entityType: string;
|
entityType: string;
|
||||||
entity: EntityData | Contact | null;
|
entity: GiverReceiverInputInfo | Contact | null;
|
||||||
}) => void | Promise<void>;
|
}) => void | Promise<void>;
|
||||||
|
|
||||||
|
get entityType(): string {
|
||||||
|
return this.entity && "handleId" in this.entity ? "project" : "person";
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CSS classes for the main container
|
* CSS classes for the main container
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ control over updates and validation * * @author Matthew Raymer */
|
|||||||
<!-- Giver Button -->
|
<!-- Giver Button -->
|
||||||
<EntitySummaryButton
|
<EntitySummaryButton
|
||||||
:entity="giver"
|
:entity="giver"
|
||||||
:entity-type="giverEntityType"
|
|
||||||
:label="giverLabel"
|
:label="giverLabel"
|
||||||
:on-edit-requested="handleEditGiver"
|
:on-edit-requested="handleEditGiver"
|
||||||
/>
|
/>
|
||||||
@@ -22,7 +21,6 @@ control over updates and validation * * @author Matthew Raymer */
|
|||||||
<!-- Recipient Button -->
|
<!-- Recipient Button -->
|
||||||
<EntitySummaryButton
|
<EntitySummaryButton
|
||||||
:entity="receiver"
|
:entity="receiver"
|
||||||
:entity-type="recipientEntityType"
|
|
||||||
:label="recipientLabel"
|
:label="recipientLabel"
|
||||||
:on-edit-requested="handleEditRecipient"
|
:on-edit-requested="handleEditRecipient"
|
||||||
/>
|
/>
|
||||||
@@ -104,17 +102,7 @@ import { Component, Prop, Vue, Watch, Emit } from "vue-facing-decorator";
|
|||||||
import EntitySummaryButton from "./EntitySummaryButton.vue";
|
import EntitySummaryButton from "./EntitySummaryButton.vue";
|
||||||
import AmountInput from "./AmountInput.vue";
|
import AmountInput from "./AmountInput.vue";
|
||||||
import { RouteLocationRaw } from "vue-router";
|
import { RouteLocationRaw } from "vue-router";
|
||||||
import { logger } from "@/utils/logger";
|
import { GiverReceiverInputInfo } from "@/libs/util";
|
||||||
|
|
||||||
/**
|
|
||||||
* Entity data interface for giver/receiver
|
|
||||||
*/
|
|
||||||
interface EntityData {
|
|
||||||
did?: string;
|
|
||||||
handleId?: string;
|
|
||||||
name?: string;
|
|
||||||
image?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GiftDetailsStep - Complete step 2 gift details form interface
|
* GiftDetailsStep - Complete step 2 gift details form interface
|
||||||
@@ -140,19 +128,11 @@ interface EntityData {
|
|||||||
export default class GiftDetailsStep extends Vue {
|
export default class GiftDetailsStep extends Vue {
|
||||||
/** Giver entity data */
|
/** Giver entity data */
|
||||||
@Prop({ required: true })
|
@Prop({ required: true })
|
||||||
giver!: EntityData | null;
|
giver!: GiverReceiverInputInfo | null;
|
||||||
|
|
||||||
/** Receiver entity data */
|
/** Receiver entity data */
|
||||||
@Prop({ required: true })
|
@Prop({ required: true })
|
||||||
receiver!: EntityData | null;
|
receiver!: GiverReceiverInputInfo | null;
|
||||||
|
|
||||||
/** Type of giver entity: 'person' or 'project' */
|
|
||||||
@Prop({ required: true })
|
|
||||||
giverEntityType!: "person" | "project";
|
|
||||||
|
|
||||||
/** Type of recipient entity: 'person' or 'project' */
|
|
||||||
@Prop({ required: true })
|
|
||||||
recipientEntityType!: "person" | "project";
|
|
||||||
|
|
||||||
/** Gift description */
|
/** Gift description */
|
||||||
@Prop({ default: "" })
|
@Prop({ default: "" })
|
||||||
@@ -212,6 +192,14 @@ export default class GiftDetailsStep extends Vue {
|
|||||||
private localAmount: number = 0;
|
private localAmount: number = 0;
|
||||||
private localUnitCode: string = "HUR";
|
private localUnitCode: string = "HUR";
|
||||||
|
|
||||||
|
get giverEntityType(): string {
|
||||||
|
return this.giver?.handleId ? "project" : "person";
|
||||||
|
}
|
||||||
|
|
||||||
|
get recipientEntityType(): string {
|
||||||
|
return this.receiver?.handleId ? "project" : "person";
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CSS classes for the photo & more options link
|
* CSS classes for the photo & more options link
|
||||||
*/
|
*/
|
||||||
@@ -290,20 +278,12 @@ export default class GiftDetailsStep extends Vue {
|
|||||||
query: {
|
query: {
|
||||||
amountInput: this.localAmount.toString(),
|
amountInput: this.localAmount.toString(),
|
||||||
description: this.localDescription,
|
description: this.localDescription,
|
||||||
giverDid:
|
giverDid: this.giver?.did,
|
||||||
this.giverEntityType === "person" ? this.giver?.did : undefined,
|
|
||||||
giverName: this.giver?.name,
|
giverName: this.giver?.name,
|
||||||
offerId: this.offerId,
|
offerId: this.offerId,
|
||||||
fulfillsProjectId:
|
fulfillsProjectId: this.receiver?.handleId,
|
||||||
this.recipientEntityType === "project" ? this.toProjectId : undefined,
|
providerProjectId: this.giver?.handleId,
|
||||||
providerProjectId:
|
recipientDid: this.receiver?.did,
|
||||||
this.giverEntityType === "project"
|
|
||||||
? this.giver?.handleId
|
|
||||||
: this.fromProjectId,
|
|
||||||
recipientDid:
|
|
||||||
this.recipientEntityType === "person"
|
|
||||||
? this.receiver?.did
|
|
||||||
: undefined,
|
|
||||||
recipientName: this.receiver?.name,
|
recipientName: this.receiver?.name,
|
||||||
unitCode: this.localUnitCode,
|
unitCode: this.localUnitCode,
|
||||||
},
|
},
|
||||||
@@ -323,10 +303,6 @@ export default class GiftDetailsStep extends Vue {
|
|||||||
* Calls the onUpdateAmount function prop for parent control
|
* Calls the onUpdateAmount function prop for parent control
|
||||||
*/
|
*/
|
||||||
handleAmountChange(newAmount: number): void {
|
handleAmountChange(newAmount: number): void {
|
||||||
logger.debug("[GiftDetailsStep] handleAmountChange() called", {
|
|
||||||
oldAmount: this.localAmount,
|
|
||||||
newAmount,
|
|
||||||
});
|
|
||||||
this.localAmount = newAmount;
|
this.localAmount = newAmount;
|
||||||
this.onUpdateAmount(newAmount);
|
this.onUpdateAmount(newAmount);
|
||||||
}
|
}
|
||||||
@@ -345,7 +321,7 @@ export default class GiftDetailsStep extends Vue {
|
|||||||
*/
|
*/
|
||||||
handleEditGiver(_data: {
|
handleEditGiver(_data: {
|
||||||
entityType: string;
|
entityType: string;
|
||||||
entity: EntityData | null;
|
entity: GiverReceiverInputInfo | null;
|
||||||
}): void {
|
}): void {
|
||||||
this.emitEditEntity({
|
this.emitEditEntity({
|
||||||
entityType: "giver",
|
entityType: "giver",
|
||||||
@@ -359,7 +335,7 @@ export default class GiftDetailsStep extends Vue {
|
|||||||
*/
|
*/
|
||||||
handleEditRecipient(_data: {
|
handleEditRecipient(_data: {
|
||||||
entityType: string;
|
entityType: string;
|
||||||
entity: EntityData | null;
|
entity: GiverReceiverInputInfo | null;
|
||||||
}): void {
|
}): void {
|
||||||
this.emitEditEntity({
|
this.emitEditEntity({
|
||||||
entityType: "recipient",
|
entityType: "recipient",
|
||||||
@@ -399,8 +375,8 @@ export default class GiftDetailsStep extends Vue {
|
|||||||
@Emit("edit-entity")
|
@Emit("edit-entity")
|
||||||
emitEditEntity(data: {
|
emitEditEntity(data: {
|
||||||
entityType: string;
|
entityType: string;
|
||||||
currentEntity: EntityData | null;
|
currentEntity: GiverReceiverInputInfo | null;
|
||||||
}): { entityType: string; currentEntity: EntityData | null } {
|
}): { entityType: string; currentEntity: GiverReceiverInputInfo | null } {
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,33 +3,20 @@
|
|||||||
<div
|
<div
|
||||||
class="dialog"
|
class="dialog"
|
||||||
data-testid="gifted-dialog"
|
data-testid="gifted-dialog"
|
||||||
:data-recipient-entity-type="currentRecipientEntityType"
|
:data-recipient-entity-type="recipientEntityType"
|
||||||
>
|
>
|
||||||
<!-- Step 1: Entity Selection -->
|
<!-- Step 1: Entity Selection -->
|
||||||
<EntitySelectionStep
|
<EntitySelectionStep
|
||||||
v-show="firstStep"
|
v-show="firstStep"
|
||||||
:step-type="stepType"
|
:step-type="stepType"
|
||||||
:giver-entity-type="currentGiverEntityType"
|
|
||||||
:recipient-entity-type="currentRecipientEntityType"
|
|
||||||
:show-projects="
|
|
||||||
currentGiverEntityType === 'project' ||
|
|
||||||
currentRecipientEntityType === 'project'
|
|
||||||
"
|
|
||||||
:all-contacts="allContacts"
|
:all-contacts="allContacts"
|
||||||
:active-did="activeDid"
|
:active-did="activeDid"
|
||||||
:all-my-dids="allMyDids"
|
:all-my-dids="allMyDids"
|
||||||
:conflict-checker="wouldCreateConflict"
|
:conflict-checker="wouldCreateConflict"
|
||||||
:from-project-id="fromProjectId"
|
|
||||||
:to-project-id="toProjectId"
|
|
||||||
:giver="giver"
|
:giver="giver"
|
||||||
:receiver="receiver"
|
:receiver="receiver"
|
||||||
:description="description"
|
|
||||||
:amount-input="amountInput"
|
|
||||||
:unit-code="unitCode"
|
|
||||||
:offer-id="offerId"
|
|
||||||
:notify="$notify"
|
:notify="$notify"
|
||||||
@entity-selected="handleEntitySelected"
|
@entity-selected="handleEntitySelected"
|
||||||
@toggle-entity-type="handleToggleEntityType"
|
|
||||||
@cancel="cancel"
|
@cancel="cancel"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -38,8 +25,8 @@
|
|||||||
v-show="!firstStep"
|
v-show="!firstStep"
|
||||||
:giver="giver"
|
:giver="giver"
|
||||||
:receiver="receiver"
|
:receiver="receiver"
|
||||||
:giver-entity-type="currentGiverEntityType"
|
:giver-entity-type="giverEntityType"
|
||||||
:recipient-entity-type="currentRecipientEntityType"
|
:recipient-entity-type="recipientEntityType"
|
||||||
:description="description"
|
:description="description"
|
||||||
:amount="parseFloat(amountInput) || 0"
|
:amount="parseFloat(amountInput) || 0"
|
||||||
:unit-code="unitCode"
|
:unit-code="unitCode"
|
||||||
@@ -130,8 +117,6 @@ export default class GiftedDialog extends Vue {
|
|||||||
description = "";
|
description = "";
|
||||||
firstStep = true; // true = Step 1 (giver/recipient selection), false = Step 2 (amount/description)
|
firstStep = true; // true = Step 1 (giver/recipient selection), false = Step 2 (amount/description)
|
||||||
giver?: libsUtil.GiverReceiverInputInfo; // undefined means no identified giver agent
|
giver?: libsUtil.GiverReceiverInputInfo; // undefined means no identified giver agent
|
||||||
currentGiverEntityType: "person" | "project" = "person"; // Mutable version (can be toggled)
|
|
||||||
currentRecipientEntityType: "person" | "project" = "person"; // Mutable version (can be toggled)
|
|
||||||
offerId = "";
|
offerId = "";
|
||||||
prompt = "";
|
prompt = "";
|
||||||
receiver?: libsUtil.GiverReceiverInputInfo;
|
receiver?: libsUtil.GiverReceiverInputInfo;
|
||||||
@@ -143,12 +128,20 @@ export default class GiftedDialog extends Vue {
|
|||||||
|
|
||||||
didInfo = didInfo;
|
didInfo = didInfo;
|
||||||
|
|
||||||
|
get giverEntityType(): string {
|
||||||
|
return this.giver && "handleId" in this.giver ? "project" : "person";
|
||||||
|
}
|
||||||
|
|
||||||
|
get recipientEntityType(): string {
|
||||||
|
return this.receiver && "handleId" in this.receiver ? "project" : "person";
|
||||||
|
}
|
||||||
|
|
||||||
// Computed property to check if current selection would create a conflict
|
// Computed property to check if current selection would create a conflict
|
||||||
get hasPersonConflict() {
|
get hasPersonConflict() {
|
||||||
// Only check for conflicts when both entities are persons
|
// Only check for conflicts when both entities are persons
|
||||||
if (
|
if (
|
||||||
this.currentGiverEntityType !== "person" ||
|
this.giverEntityType !== "person" ||
|
||||||
this.currentRecipientEntityType !== "person"
|
this.recipientEntityType !== "person"
|
||||||
) {
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -169,8 +162,8 @@ export default class GiftedDialog extends Vue {
|
|||||||
get hasProjectConflict() {
|
get hasProjectConflict() {
|
||||||
// Only check for conflicts when both entities are projects
|
// Only check for conflicts when both entities are projects
|
||||||
if (
|
if (
|
||||||
this.currentGiverEntityType !== "project" ||
|
this.giverEntityType !== "project" ||
|
||||||
this.currentRecipientEntityType !== "project"
|
this.recipientEntityType !== "project"
|
||||||
) {
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -187,39 +180,6 @@ export default class GiftedDialog extends Vue {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Computed property to check if a contact or project would create a conflict when selected
|
|
||||||
wouldCreateConflict(identifier: string) {
|
|
||||||
// Check for person conflicts when both entities are persons
|
|
||||||
if (
|
|
||||||
this.currentGiverEntityType === "person" &&
|
|
||||||
this.currentRecipientEntityType === "person"
|
|
||||||
) {
|
|
||||||
if (this.stepType === "giver") {
|
|
||||||
// If selecting as giver, check if it conflicts with current recipient
|
|
||||||
return this.receiver?.did === identifier;
|
|
||||||
} else if (this.stepType === "recipient") {
|
|
||||||
// If selecting as recipient, check if it conflicts with current giver
|
|
||||||
return this.giver?.did === identifier;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for project conflicts when both entities are projects
|
|
||||||
if (
|
|
||||||
this.currentGiverEntityType === "project" &&
|
|
||||||
this.currentRecipientEntityType === "project"
|
|
||||||
) {
|
|
||||||
if (this.stepType === "giver") {
|
|
||||||
// If selecting as giver, check if it conflicts with current recipient
|
|
||||||
return this.receiver?.handleId === identifier;
|
|
||||||
} else if (this.stepType === "recipient") {
|
|
||||||
// If selecting as recipient, check if it conflicts with current giver
|
|
||||||
return this.giver?.handleId === identifier;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
async open(
|
async open(
|
||||||
giver?: libsUtil.GiverReceiverInputInfo,
|
giver?: libsUtil.GiverReceiverInputInfo,
|
||||||
receiver?: libsUtil.GiverReceiverInputInfo,
|
receiver?: libsUtil.GiverReceiverInputInfo,
|
||||||
@@ -238,9 +198,6 @@ export default class GiftedDialog extends Vue {
|
|||||||
this.amountInput = amountInput || "0";
|
this.amountInput = amountInput || "0";
|
||||||
this.unitCode = unitCode || "HUR";
|
this.unitCode = unitCode || "HUR";
|
||||||
this.callbackOnSuccess = callbackOnSuccess;
|
this.callbackOnSuccess = callbackOnSuccess;
|
||||||
// Initialize current entity types from initial prop values
|
|
||||||
this.currentGiverEntityType = this.initialGiverEntityType;
|
|
||||||
this.currentRecipientEntityType = this.initialRecipientEntityType;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const settings = await this.$accountSettings();
|
const settings = await this.$accountSettings();
|
||||||
@@ -307,15 +264,47 @@ export default class GiftedDialog extends Vue {
|
|||||||
this.eraseValues();
|
this.eraseValues();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Computed property to check if a contact or project would create a conflict when selected
|
||||||
|
wouldCreateConflict(identifier: string) {
|
||||||
|
// Check for person conflicts when both entities are persons
|
||||||
|
if (
|
||||||
|
this.giverEntityType === "person" &&
|
||||||
|
this.recipientEntityType === "person"
|
||||||
|
) {
|
||||||
|
if (this.stepType === "giver") {
|
||||||
|
// If selecting as giver, check if it conflicts with current recipient
|
||||||
|
return this.receiver?.did === identifier;
|
||||||
|
} else if (this.stepType === "recipient") {
|
||||||
|
// If selecting as recipient, check if it conflicts with current giver
|
||||||
|
return this.giver?.did === identifier;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for project conflicts when both entities are projects
|
||||||
|
if (
|
||||||
|
this.giverEntityType === "project" &&
|
||||||
|
this.recipientEntityType === "project"
|
||||||
|
) {
|
||||||
|
if (this.stepType === "giver") {
|
||||||
|
// If selecting as giver, check if it conflicts with current recipient
|
||||||
|
return this.receiver?.handleId === identifier;
|
||||||
|
} else if (this.stepType === "recipient") {
|
||||||
|
// If selecting as recipient, check if it conflicts with current giver
|
||||||
|
return this.giver?.handleId === identifier;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
eraseValues() {
|
eraseValues() {
|
||||||
this.description = "";
|
this.description = "";
|
||||||
this.giver = undefined;
|
this.giver = undefined;
|
||||||
|
this.receiver = undefined;
|
||||||
this.amountInput = "0";
|
this.amountInput = "0";
|
||||||
this.prompt = "";
|
this.prompt = "";
|
||||||
this.unitCode = "HUR";
|
this.unitCode = "HUR";
|
||||||
this.firstStep = true;
|
this.firstStep = true;
|
||||||
// Reset to initial prop values
|
|
||||||
this.currentGiverEntityType = this.initialGiverEntityType;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async confirm() {
|
async confirm() {
|
||||||
@@ -403,29 +392,38 @@ export default class GiftedDialog extends Vue {
|
|||||||
let providerPlanHandleId: string | undefined;
|
let providerPlanHandleId: string | undefined;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
this.currentGiverEntityType === "project" &&
|
this.giverEntityType === "project" &&
|
||||||
this.currentRecipientEntityType === "person"
|
this.recipientEntityType === "person"
|
||||||
) {
|
) {
|
||||||
// Project-to-person gift
|
// Project-to-person gift
|
||||||
fromDid = undefined; // No person giver
|
fromDid = undefined;
|
||||||
toDid = recipientDid as string; // Person recipient
|
toDid = recipientDid as string;
|
||||||
fulfillsProjectHandleId = undefined; // No project recipient
|
fulfillsProjectHandleId = undefined;
|
||||||
providerPlanHandleId = this.giver?.handleId; // Project giver
|
providerPlanHandleId = this.giver?.handleId;
|
||||||
} else if (
|
} else if (
|
||||||
this.currentGiverEntityType === "person" &&
|
this.giverEntityType === "person" &&
|
||||||
this.currentRecipientEntityType === "project"
|
this.recipientEntityType === "project"
|
||||||
) {
|
) {
|
||||||
// Person-to-project gift
|
// Person-to-project gift
|
||||||
fromDid = giverDid as string; // Person giver
|
fromDid = giverDid as string;
|
||||||
toDid = undefined; // No person recipient
|
toDid = undefined;
|
||||||
fulfillsProjectHandleId = this.toProjectId; // Project recipient
|
fulfillsProjectHandleId = this.receiver?.handleId;
|
||||||
providerPlanHandleId = undefined; // No project giver
|
providerPlanHandleId = undefined;
|
||||||
} else {
|
} else if (
|
||||||
|
this.giverEntityType === "person" &&
|
||||||
|
this.recipientEntityType === "person"
|
||||||
|
) {
|
||||||
// Person-to-person gift
|
// Person-to-person gift
|
||||||
fromDid = giverDid as string;
|
fromDid = giverDid as string;
|
||||||
toDid = recipientDid as string;
|
toDid = recipientDid as string;
|
||||||
fulfillsProjectHandleId = undefined;
|
fulfillsProjectHandleId = undefined;
|
||||||
providerPlanHandleId = undefined;
|
providerPlanHandleId = undefined;
|
||||||
|
} else {
|
||||||
|
// Project-to-project gift
|
||||||
|
fromDid = undefined;
|
||||||
|
toDid = undefined;
|
||||||
|
fulfillsProjectHandleId = this.receiver?.handleId;
|
||||||
|
providerPlanHandleId = this.giver?.handleId;
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await createAndSubmitGive(
|
const result = await createAndSubmitGive(
|
||||||
@@ -496,7 +494,16 @@ export default class GiftedDialog extends Vue {
|
|||||||
this.safeNotify.info(libsUtil.PRIVACY_MESSAGE, TIMEOUTS.MODAL);
|
this.safeNotify.info(libsUtil.PRIVACY_MESSAGE, TIMEOUTS.MODAL);
|
||||||
}
|
}
|
||||||
|
|
||||||
selectGiver(contact?: Contact) {
|
goBackToStep1(step: string) {
|
||||||
|
this.stepType = step;
|
||||||
|
this.firstStep = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
moveToStep2() {
|
||||||
|
this.firstStep = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
selectGiverPerson(contact?: Contact) {
|
||||||
if (contact) {
|
if (contact) {
|
||||||
this.giver = {
|
this.giver = {
|
||||||
did: contact.did,
|
did: contact.did,
|
||||||
@@ -514,33 +521,16 @@ export default class GiftedDialog extends Vue {
|
|||||||
this.firstStep = false;
|
this.firstStep = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
goBackToStep1(step: string) {
|
selectGiverProject(project: PlanData) {
|
||||||
this.stepType = step;
|
|
||||||
this.firstStep = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
moveToStep2() {
|
|
||||||
this.firstStep = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
selectProject(project: PlanData) {
|
|
||||||
this.giver = {
|
this.giver = {
|
||||||
did: project.handleId,
|
|
||||||
name: project.name,
|
name: project.name,
|
||||||
image: project.image,
|
image: project.image,
|
||||||
handleId: project.handleId,
|
handleId: project.handleId,
|
||||||
};
|
};
|
||||||
// Only set receiver to "You" if no receiver has been selected yet
|
|
||||||
if (!this.receiver || !this.receiver.did) {
|
|
||||||
this.receiver = {
|
|
||||||
did: this.activeDid,
|
|
||||||
name: "You",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
this.firstStep = false;
|
this.firstStep = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
selectRecipient(contact?: Contact) {
|
selectRecipientPerson(contact?: Contact) {
|
||||||
if (contact) {
|
if (contact) {
|
||||||
this.receiver = {
|
this.receiver = {
|
||||||
did: contact.did,
|
did: contact.did,
|
||||||
@@ -560,7 +550,6 @@ export default class GiftedDialog extends Vue {
|
|||||||
|
|
||||||
selectRecipientProject(project: PlanData) {
|
selectRecipientProject(project: PlanData) {
|
||||||
this.receiver = {
|
this.receiver = {
|
||||||
did: project.handleId,
|
|
||||||
name: project.name,
|
name: project.name,
|
||||||
image: project.image,
|
image: project.image,
|
||||||
handleId: project.handleId,
|
handleId: project.handleId,
|
||||||
@@ -568,32 +557,6 @@ export default class GiftedDialog extends Vue {
|
|||||||
this.firstStep = false;
|
this.firstStep = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Computed property for the query parameters
|
|
||||||
get giftedDetailsQuery() {
|
|
||||||
return {
|
|
||||||
amountInput: this.amountInput,
|
|
||||||
description: this.description,
|
|
||||||
giverDid:
|
|
||||||
this.currentGiverEntityType === "person" ? this.giver?.did : undefined,
|
|
||||||
giverName: this.giver?.name,
|
|
||||||
offerId: this.offerId,
|
|
||||||
fulfillsProjectId:
|
|
||||||
this.currentRecipientEntityType === "project"
|
|
||||||
? this.toProjectId
|
|
||||||
: undefined,
|
|
||||||
providerProjectId:
|
|
||||||
this.currentGiverEntityType === "project"
|
|
||||||
? this.giver?.handleId
|
|
||||||
: this.fromProjectId,
|
|
||||||
recipientDid:
|
|
||||||
this.currentRecipientEntityType === "person"
|
|
||||||
? this.receiver?.did
|
|
||||||
: undefined,
|
|
||||||
recipientName: this.receiver?.name,
|
|
||||||
unitCode: this.unitCode,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// New event handlers for component integration
|
// New event handlers for component integration
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -610,14 +573,14 @@ export default class GiftedDialog extends Vue {
|
|||||||
// Apply DID-based logic for person entities
|
// Apply DID-based logic for person entities
|
||||||
const processedContact = this.processPersonEntity(contact);
|
const processedContact = this.processPersonEntity(contact);
|
||||||
if (entity.stepType === "giver") {
|
if (entity.stepType === "giver") {
|
||||||
this.selectGiver(processedContact);
|
this.selectGiverPerson(processedContact);
|
||||||
} else {
|
} else {
|
||||||
this.selectRecipient(processedContact);
|
this.selectRecipientPerson(processedContact);
|
||||||
}
|
}
|
||||||
} else if (entity.type === "project") {
|
} else if (entity.type === "project") {
|
||||||
const project = entity.data as PlanData;
|
const project = entity.data as PlanData;
|
||||||
if (entity.stepType === "giver") {
|
if (entity.stepType === "giver") {
|
||||||
this.selectProject(project);
|
this.selectGiverProject(project);
|
||||||
} else {
|
} else {
|
||||||
this.selectRecipientProject(project);
|
this.selectRecipientProject(project);
|
||||||
}
|
}
|
||||||
@@ -659,24 +622,6 @@ export default class GiftedDialog extends Vue {
|
|||||||
this.confirm();
|
this.confirm();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle toggle entity type request from EntitySelectionStep
|
|
||||||
*/
|
|
||||||
handleToggleEntityType() {
|
|
||||||
// Toggle the appropriate entity type based on current step
|
|
||||||
if (this.stepType === "giver") {
|
|
||||||
this.currentGiverEntityType =
|
|
||||||
this.currentGiverEntityType === "person" ? "project" : "person";
|
|
||||||
// Clear any selected giver when toggling
|
|
||||||
this.giver = undefined;
|
|
||||||
} else if (this.stepType === "recipient") {
|
|
||||||
this.currentRecipientEntityType =
|
|
||||||
this.currentRecipientEntityType === "person" ? "project" : "person";
|
|
||||||
// Clear any selected receiver when toggling
|
|
||||||
this.receiver = undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle amount update from GiftDetailsStep
|
* Handle amount update from GiftDetailsStep
|
||||||
*/
|
*/
|
||||||
|
|||||||
279
src/components/MeetingExclusionGroups.vue
Normal file
279
src/components/MeetingExclusionGroups.vue
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div v-for="group in groups" :key="group.id" class="mb-3">
|
||||||
|
<div
|
||||||
|
:class="[
|
||||||
|
'rounded-lg border p-3',
|
||||||
|
colorSet(group.colorIndex).bg,
|
||||||
|
colorSet(group.colorIndex).border,
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<div class="flex items-center gap-2 flex-1 min-w-0">
|
||||||
|
<span
|
||||||
|
:class="[
|
||||||
|
'w-3 h-3 rounded-full shrink-0',
|
||||||
|
colorSet(group.colorIndex).dot,
|
||||||
|
]"
|
||||||
|
></span>
|
||||||
|
<input
|
||||||
|
:value="group.name"
|
||||||
|
:disabled="disabled"
|
||||||
|
:class="[
|
||||||
|
'text-sm font-medium bg-transparent border-none',
|
||||||
|
'outline-none flex-1 min-w-0 placeholder-gray-400',
|
||||||
|
{ 'cursor-default': disabled },
|
||||||
|
]"
|
||||||
|
placeholder="Group name…"
|
||||||
|
@input="
|
||||||
|
updateGroupName(
|
||||||
|
group.id,
|
||||||
|
($event.target as HTMLInputElement).value,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
:class="[
|
||||||
|
'transition-colors ml-2 shrink-0',
|
||||||
|
disabled
|
||||||
|
? 'text-slate-300 cursor-not-allowed'
|
||||||
|
: 'text-slate-400 hover:text-red-600',
|
||||||
|
]"
|
||||||
|
title="Delete group"
|
||||||
|
@click="disabled ? notifyLocked() : removeGroup(group.id)"
|
||||||
|
>
|
||||||
|
<font-awesome icon="trash-can" class="text-sm" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-1.5 mb-2">
|
||||||
|
<span
|
||||||
|
v-for="did in group.memberDids"
|
||||||
|
:key="did"
|
||||||
|
:class="[
|
||||||
|
'inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs',
|
||||||
|
colorSet(group.colorIndex).chip,
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
{{ getMemberName(did) }}
|
||||||
|
<button
|
||||||
|
:class="
|
||||||
|
disabled ? 'opacity-40 cursor-not-allowed' : 'hover:opacity-70'
|
||||||
|
"
|
||||||
|
@click="
|
||||||
|
disabled ? notifyLocked() : removeMemberFromGroup(group.id, did)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<font-awesome icon="xmark" class="text-xs" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="group.memberDids.length === 0"
|
||||||
|
class="text-xs text-slate-400 italic"
|
||||||
|
>
|
||||||
|
No members yet
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!disabled && addingToGroupId === group.id" class="mt-2">
|
||||||
|
<div
|
||||||
|
class="flex flex-wrap gap-1.5 p-2 bg-white bg-opacity-60 rounded border border-gray-200"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-for="member in availableMembersForGroup(group)"
|
||||||
|
:key="member.did"
|
||||||
|
class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-white border border-gray-300 hover:bg-gray-100 transition-colors"
|
||||||
|
@click="addMemberToGroup(group.id, member.did)"
|
||||||
|
>
|
||||||
|
<font-awesome icon="plus" class="text-xs text-green-600" />
|
||||||
|
{{ member.name }}
|
||||||
|
</button>
|
||||||
|
<span
|
||||||
|
v-if="availableMembersForGroup(group).length === 0"
|
||||||
|
class="text-xs text-slate-400 italic"
|
||||||
|
>
|
||||||
|
All members already assigned
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="text-xs text-slate-500 mt-1"
|
||||||
|
@click="addingToGroupId = ''"
|
||||||
|
>
|
||||||
|
Done
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
v-else
|
||||||
|
:class="[
|
||||||
|
'text-xs transition-colors',
|
||||||
|
disabled
|
||||||
|
? 'text-slate-400 cursor-not-allowed'
|
||||||
|
: 'text-blue-600 hover:text-blue-800',
|
||||||
|
]"
|
||||||
|
@click="disabled ? notifyLocked() : (addingToGroupId = group.id)"
|
||||||
|
>
|
||||||
|
<font-awesome icon="plus" class="text-xs" />
|
||||||
|
Add member
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
:class="[
|
||||||
|
'text-sm transition-colors',
|
||||||
|
disabled
|
||||||
|
? 'text-slate-400 cursor-not-allowed'
|
||||||
|
: 'text-blue-600 hover:text-blue-800',
|
||||||
|
]"
|
||||||
|
@click="disabled ? notifyLocked() : addGroup()"
|
||||||
|
>
|
||||||
|
<font-awesome icon="plus" class="text-sm" />
|
||||||
|
New Group
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { Component, Vue, Prop, Emit } from "vue-facing-decorator";
|
||||||
|
import { DoNotPairGroup } from "@/interfaces";
|
||||||
|
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
|
||||||
|
import { NotificationIface } from "@/constants/app";
|
||||||
|
|
||||||
|
interface MemberInfo {
|
||||||
|
did: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const GROUP_COLORS = [
|
||||||
|
{
|
||||||
|
bg: "bg-orange-50",
|
||||||
|
border: "border-orange-200",
|
||||||
|
dot: "bg-orange-400",
|
||||||
|
chip: "bg-orange-200 text-orange-800",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bg: "bg-purple-50",
|
||||||
|
border: "border-purple-200",
|
||||||
|
dot: "bg-purple-400",
|
||||||
|
chip: "bg-purple-200 text-purple-800",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bg: "bg-teal-50",
|
||||||
|
border: "border-teal-200",
|
||||||
|
dot: "bg-teal-400",
|
||||||
|
chip: "bg-teal-200 text-teal-800",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bg: "bg-pink-50",
|
||||||
|
border: "border-pink-200",
|
||||||
|
dot: "bg-pink-400",
|
||||||
|
chip: "bg-pink-200 text-pink-800",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bg: "bg-indigo-50",
|
||||||
|
border: "border-indigo-200",
|
||||||
|
dot: "bg-indigo-400",
|
||||||
|
chip: "bg-indigo-200 text-indigo-800",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bg: "bg-yellow-50",
|
||||||
|
border: "border-yellow-200",
|
||||||
|
dot: "bg-yellow-400",
|
||||||
|
chip: "bg-yellow-200 text-yellow-800",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
@Component
|
||||||
|
export default class MeetingExclusionGroups extends Vue {
|
||||||
|
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
||||||
|
notify!: ReturnType<typeof createNotifyHelpers>;
|
||||||
|
|
||||||
|
@Prop({ required: true }) groups!: DoNotPairGroup[];
|
||||||
|
@Prop({ required: true }) availableMembers!: MemberInfo[];
|
||||||
|
@Prop({ default: false }) disabled!: boolean;
|
||||||
|
|
||||||
|
addingToGroupId = "";
|
||||||
|
|
||||||
|
created() {
|
||||||
|
this.notify = createNotifyHelpers(this.$notify);
|
||||||
|
}
|
||||||
|
|
||||||
|
notifyLocked(): void {
|
||||||
|
this.notify.warning(
|
||||||
|
"Erase the current matches before changing exclusion groups.",
|
||||||
|
TIMEOUTS.LONG,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
colorSet(colorIndex: number): (typeof GROUP_COLORS)[0] {
|
||||||
|
return GROUP_COLORS[colorIndex % GROUP_COLORS.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
getMemberName(did: string): string {
|
||||||
|
const member = this.availableMembers.find((m) => m.did === did);
|
||||||
|
return member?.name || did.substring(0, 16) + "…";
|
||||||
|
}
|
||||||
|
|
||||||
|
availableMembersForGroup(group: DoNotPairGroup): MemberInfo[] {
|
||||||
|
const allAssignedDids = new Set(this.groups.flatMap((g) => g.memberDids));
|
||||||
|
return this.availableMembers
|
||||||
|
.filter(
|
||||||
|
(m) => !allAssignedDids.has(m.did) || group.memberDids.includes(m.did),
|
||||||
|
)
|
||||||
|
.filter((m) => !group.memberDids.includes(m.did));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Emit("update")
|
||||||
|
emitUpdate(): DoNotPairGroup[] {
|
||||||
|
return [...this.groups];
|
||||||
|
}
|
||||||
|
|
||||||
|
addGroup(): void {
|
||||||
|
const newGroup: DoNotPairGroup = {
|
||||||
|
id: Date.now().toString(36) + Math.random().toString(36).substring(2, 6),
|
||||||
|
name: "",
|
||||||
|
colorIndex: this.groups.length % GROUP_COLORS.length,
|
||||||
|
memberDids: [],
|
||||||
|
};
|
||||||
|
this.groups.push(newGroup);
|
||||||
|
this.addingToGroupId = newGroup.id;
|
||||||
|
this.emitUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
removeGroup(groupId: string): void {
|
||||||
|
const idx = this.groups.findIndex((g) => g.id === groupId);
|
||||||
|
if (idx !== -1) {
|
||||||
|
this.groups.splice(idx, 1);
|
||||||
|
if (this.addingToGroupId === groupId) {
|
||||||
|
this.addingToGroupId = "";
|
||||||
|
}
|
||||||
|
this.emitUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateGroupName(groupId: string, name: string): void {
|
||||||
|
const group = this.groups.find((g) => g.id === groupId);
|
||||||
|
if (group) {
|
||||||
|
group.name = name;
|
||||||
|
this.emitUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addMemberToGroup(groupId: string, did: string): void {
|
||||||
|
const group = this.groups.find((g) => g.id === groupId);
|
||||||
|
if (group && !group.memberDids.includes(did)) {
|
||||||
|
group.memberDids.push(did);
|
||||||
|
this.emitUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
removeMemberFromGroup(groupId: string, did: string): void {
|
||||||
|
const group = this.groups.find((g) => g.id === groupId);
|
||||||
|
if (group) {
|
||||||
|
group.memberDids = group.memberDids.filter((d) => d !== did);
|
||||||
|
this.emitUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
249
src/components/MeetingMemberMatch.vue
Normal file
249
src/components/MeetingMemberMatch.vue
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
<template>
|
||||||
|
<div class="group-onboard-match-display">
|
||||||
|
<!-- Loading -->
|
||||||
|
<div
|
||||||
|
v-if="isLoading"
|
||||||
|
class="flex items-center justify-center gap-2 py-6 text-slate-600"
|
||||||
|
>
|
||||||
|
<font-awesome icon="spinner" class="fa-spin-pulse" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error -->
|
||||||
|
<div
|
||||||
|
v-else-if="errorMessage"
|
||||||
|
class="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-red-700"
|
||||||
|
>
|
||||||
|
Inform the organizer that there was an error. {{ errorMessage }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Matched person -->
|
||||||
|
<div
|
||||||
|
v-else-if="matchedPerson"
|
||||||
|
class="rounded-lg border border-slate-200 bg-white p-4 shadow-sm"
|
||||||
|
>
|
||||||
|
<h2 class="mb-3 font-bold text-slate-700">Your Current Match</h2>
|
||||||
|
<p v-if="myPair != null" class="mb-3 text-sm text-slate-600">
|
||||||
|
You are in Pair #{{ myPair.pairNumber }} with:
|
||||||
|
</p>
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<EntityIcon
|
||||||
|
:contact="matchedPersonContact"
|
||||||
|
class="!size-14 shrink-0 overflow-hidden rounded-full border border-slate-300 bg-white"
|
||||||
|
/>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<p class="font-medium text-slate-900">
|
||||||
|
{{ matchedPerson.name || "(No name)" }}
|
||||||
|
</p>
|
||||||
|
<p class="mt-0.5 truncate text-xs text-slate-500">
|
||||||
|
{{ matchedPerson.did }}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
v-if="matchedPerson.description"
|
||||||
|
class="mt-2 line-clamp-3 text-sm text-slate-600"
|
||||||
|
>
|
||||||
|
{{ matchedPerson.description }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { Component, Vue, Prop, Watch } from "vue-facing-decorator";
|
||||||
|
import {
|
||||||
|
errorStringForLog,
|
||||||
|
getHeaders,
|
||||||
|
serverMessageForUser,
|
||||||
|
} from "@/libs/endorserServer";
|
||||||
|
import { decryptMessage } from "@/libs/crypto";
|
||||||
|
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
|
||||||
|
import EntityIcon from "./EntityIcon.vue";
|
||||||
|
import { Contact } from "@/db/tables/contacts";
|
||||||
|
import { AxiosErrorResponse } from "@/interfaces";
|
||||||
|
|
||||||
|
/** Participant from GET /api/partner/groupOnboardMatch pair */
|
||||||
|
interface MatchPairParticipant {
|
||||||
|
issuerDid: string;
|
||||||
|
content: string;
|
||||||
|
description?: string;
|
||||||
|
decryptedContentObject?: {
|
||||||
|
name: string;
|
||||||
|
did: string;
|
||||||
|
isRegistered: boolean;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MatchPair {
|
||||||
|
pairNumber: number;
|
||||||
|
similarity: number;
|
||||||
|
participants: MatchPairParticipant[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Normalized matched person for display */
|
||||||
|
interface MatchedPersonData {
|
||||||
|
name: string;
|
||||||
|
did: string;
|
||||||
|
isRegistered: boolean;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
components: {
|
||||||
|
EntityIcon,
|
||||||
|
},
|
||||||
|
mixins: [PlatformServiceMixin],
|
||||||
|
})
|
||||||
|
export default class GroupOnboardMatchDisplay extends Vue {
|
||||||
|
@Prop({ required: true })
|
||||||
|
meetingPassword!: string;
|
||||||
|
|
||||||
|
/** When provided, used to determine this person's match instead of calling groupOnboardMatch */
|
||||||
|
@Prop()
|
||||||
|
matchPairs?: MatchPair[] | null;
|
||||||
|
|
||||||
|
activeDid = "";
|
||||||
|
apiServer = "";
|
||||||
|
errorMessage = "";
|
||||||
|
isLoading = true;
|
||||||
|
matchedPerson: MatchedPersonData | null = null;
|
||||||
|
/** Pair that contains the current user (for similarity display if needed) */
|
||||||
|
myPair: MatchPair | null = null;
|
||||||
|
|
||||||
|
/** Contact-like object for EntityIcon from matched person */
|
||||||
|
get matchedPersonContact(): Contact | undefined {
|
||||||
|
if (!this.matchedPerson) return undefined;
|
||||||
|
return {
|
||||||
|
did: this.matchedPerson.did,
|
||||||
|
name: this.matchedPerson.name,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async created() {
|
||||||
|
const settings = await this.$accountSettings();
|
||||||
|
this.apiServer = settings?.apiServer || "";
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const activeIdentity = await (this as any).$getActiveIdentity();
|
||||||
|
this.activeDid = activeIdentity?.activeDid || "";
|
||||||
|
await this.fetchMatches();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Watch("meetingPassword")
|
||||||
|
async onPasswordChange() {
|
||||||
|
if (
|
||||||
|
this.meetingPassword &&
|
||||||
|
(this.matchPairs != null || (this.apiServer && this.activeDid))
|
||||||
|
) {
|
||||||
|
await this.fetchMatches();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Watch("matchPairs")
|
||||||
|
async onMatchPairsChange() {
|
||||||
|
if (this.activeDid && this.meetingPassword) {
|
||||||
|
await this.fetchMatches();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note that this is called externally by MeetingMembersList when user triggers a refresh
|
||||||
|
async fetchMatches(): Promise<void> {
|
||||||
|
const usePropPairs =
|
||||||
|
this.matchPairs != null &&
|
||||||
|
Array.isArray(this.matchPairs) &&
|
||||||
|
this.matchPairs.length > 0;
|
||||||
|
const needApi = !usePropPairs;
|
||||||
|
|
||||||
|
if (
|
||||||
|
needApi &&
|
||||||
|
(!this.meetingPassword?.trim() || !this.apiServer || !this.activeDid)
|
||||||
|
) {
|
||||||
|
this.isLoading = false;
|
||||||
|
this.matchedPerson = null;
|
||||||
|
this.myPair = null;
|
||||||
|
this.errorMessage = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (usePropPairs && (!this.meetingPassword?.trim() || !this.activeDid)) {
|
||||||
|
this.isLoading = false;
|
||||||
|
this.matchedPerson = null;
|
||||||
|
this.myPair = null;
|
||||||
|
this.errorMessage = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isLoading = true;
|
||||||
|
this.errorMessage = "";
|
||||||
|
this.matchedPerson = null;
|
||||||
|
this.myPair = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
let pairs: MatchPair[] | null = null;
|
||||||
|
if (usePropPairs) {
|
||||||
|
// Shallow-copy so we can set decryptedContentObject without mutating the prop
|
||||||
|
pairs = (this.matchPairs ?? []).map((p) => ({
|
||||||
|
...p,
|
||||||
|
participants: p.participants.map((part) => ({ ...part })),
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
const headers = await getHeaders(this.activeDid);
|
||||||
|
const response = await this.axios.get(
|
||||||
|
`${this.apiServer}/api/partner/groupOnboardMatch`,
|
||||||
|
{ headers },
|
||||||
|
);
|
||||||
|
pairs = response?.data?.data?.pairs ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(pairs) || pairs.length === 0) {
|
||||||
|
this.isLoading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt each participant's content and find the pair containing this user
|
||||||
|
for (const pair of pairs) {
|
||||||
|
if (!pair.participants || pair.participants.length !== 2) continue;
|
||||||
|
|
||||||
|
for (const participant of pair.participants) {
|
||||||
|
try {
|
||||||
|
const decrypted = await decryptMessage(
|
||||||
|
participant.content,
|
||||||
|
this.meetingPassword,
|
||||||
|
);
|
||||||
|
participant.decryptedContentObject = JSON.parse(decrypted);
|
||||||
|
} catch {
|
||||||
|
participant.decryptedContentObject = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const myIndex = pair.participants.findIndex(
|
||||||
|
(p) => p.issuerDid === this.activeDid,
|
||||||
|
);
|
||||||
|
if (myIndex === -1) continue;
|
||||||
|
|
||||||
|
this.myPair = pair;
|
||||||
|
const other = pair.participants[1 - myIndex];
|
||||||
|
const obj = other.decryptedContentObject;
|
||||||
|
this.matchedPerson = {
|
||||||
|
name: obj?.name ?? "",
|
||||||
|
did: obj?.did ?? other.issuerDid,
|
||||||
|
isRegistered: !!obj?.isRegistered,
|
||||||
|
description: other.description,
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isLoading = false;
|
||||||
|
} catch (error) {
|
||||||
|
this.$logAndConsole(
|
||||||
|
"Error fetching group onboard match: " + errorStringForLog(error),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
this.errorMessage =
|
||||||
|
serverMessageForUser(error as unknown as AxiosErrorResponse) ||
|
||||||
|
"Failed to load your match.";
|
||||||
|
this.matchedPerson = null;
|
||||||
|
this.myPair = null;
|
||||||
|
this.isLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -36,6 +36,15 @@
|
|||||||
<font-awesome icon="circle-minus" class="text-rose-500 text-sm" />
|
<font-awesome icon="circle-minus" class="text-rose-500 text-sm" />
|
||||||
to add/remove them to/from the meeting.
|
to add/remove them to/from the meeting.
|
||||||
</li>
|
</li>
|
||||||
|
<li
|
||||||
|
v-if="
|
||||||
|
membersToShow().length > 0 && showOrganizerTools && isOrganizer
|
||||||
|
"
|
||||||
|
>
|
||||||
|
Click
|
||||||
|
<font-awesome icon="ban" class="text-slate-500 text-sm" />
|
||||||
|
to exclude someone from matching.
|
||||||
|
</li>
|
||||||
<li
|
<li
|
||||||
v-if="
|
v-if="
|
||||||
membersToShow().length > 0 && getNonContactMembers().length > 0
|
membersToShow().length > 0 && getNonContactMembers().length > 0
|
||||||
@@ -47,11 +56,18 @@
|
|||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div class="flex justify-between">
|
<MeetingMemberMatch
|
||||||
|
ref="memberMatch"
|
||||||
|
:match-pairs="matchPairs"
|
||||||
|
:meeting-password="password || ''"
|
||||||
|
class="mt-4"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="flex justify-between mt-4">
|
||||||
<!--
|
<!--
|
||||||
always have at least one refresh button even without members in case the organizer
|
always have at least one refresh button even without members in case the organizer
|
||||||
changes the password
|
changes the password
|
||||||
-->
|
-->
|
||||||
<button
|
<button
|
||||||
class="text-sm bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-3 py-1.5 rounded-md"
|
class="text-sm bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-3 py-1.5 rounded-md"
|
||||||
title="Refresh members list now"
|
title="Refresh members list now"
|
||||||
@@ -75,6 +91,8 @@
|
|||||||
'bg-blue-50 border-t border-blue-300 -mt-[1px]':
|
'bg-blue-50 border-t border-blue-300 -mt-[1px]':
|
||||||
!member.member.admitted &&
|
!member.member.admitted &&
|
||||||
(isOrganizer || member.did === activeDid),
|
(isOrganizer || member.did === activeDid),
|
||||||
|
'bg-amber-50 opacity-60':
|
||||||
|
member.member.admitted && excludedDids.includes(member.did),
|
||||||
},
|
},
|
||||||
{ 'border-slate-300': member.member.admitted },
|
{ 'border-slate-300': member.member.admitted },
|
||||||
]"
|
]"
|
||||||
@@ -88,6 +106,9 @@
|
|||||||
'text-slate-500':
|
'text-slate-500':
|
||||||
!member.member.admitted &&
|
!member.member.admitted &&
|
||||||
(isOrganizer || member.did === activeDid),
|
(isOrganizer || member.did === activeDid),
|
||||||
|
'line-through text-slate-400':
|
||||||
|
member.member.admitted &&
|
||||||
|
excludedDids.includes(member.did),
|
||||||
},
|
},
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
@@ -161,8 +182,31 @@
|
|||||||
v-if="
|
v-if="
|
||||||
showOrganizerTools && isOrganizer && member.did !== activeDid
|
showOrganizerTools && isOrganizer && member.did !== activeDid
|
||||||
"
|
"
|
||||||
class="flex items-center gap-1.5"
|
class="flex items-center gap-6"
|
||||||
>
|
>
|
||||||
|
<button
|
||||||
|
v-if="member.member.admitted"
|
||||||
|
:class="[
|
||||||
|
'btn-exclusion-toggle',
|
||||||
|
exclusionLocked
|
||||||
|
? excludedDids.includes(member.did)
|
||||||
|
? 'text-amber-400 opacity-50'
|
||||||
|
: 'text-slate-300 opacity-50'
|
||||||
|
: excludedDids.includes(member.did)
|
||||||
|
? 'text-amber-600'
|
||||||
|
: 'text-slate-500',
|
||||||
|
]"
|
||||||
|
:title="
|
||||||
|
exclusionLocked
|
||||||
|
? 'Erase matches to change exclusions'
|
||||||
|
: excludedDids.includes(member.did)
|
||||||
|
? 'Include in matching'
|
||||||
|
: 'Exclude from matching'
|
||||||
|
"
|
||||||
|
@click="handleExclusionClick(member.did)"
|
||||||
|
>
|
||||||
|
<font-awesome icon="ban" />
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
:class="
|
:class="
|
||||||
member.member.admitted
|
member.member.admitted
|
||||||
@@ -198,9 +242,9 @@
|
|||||||
|
|
||||||
<div v-if="membersToShow().length > 0" class="flex justify-between">
|
<div v-if="membersToShow().length > 0" class="flex justify-between">
|
||||||
<!--
|
<!--
|
||||||
always have at least one refresh button even without members in case the organizer
|
always have at least one refresh button even without members in case the organizer
|
||||||
changes the password
|
changes the password
|
||||||
-->
|
-->
|
||||||
<button
|
<button
|
||||||
class="text-sm bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-3 py-1.5 rounded-md"
|
class="text-sm bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-3 py-1.5 rounded-md"
|
||||||
title="Refresh members list now"
|
title="Refresh members list now"
|
||||||
@@ -246,10 +290,13 @@ import {
|
|||||||
} from "@/libs/endorserServer";
|
} from "@/libs/endorserServer";
|
||||||
import { decryptMessage } from "@/libs/crypto";
|
import { decryptMessage } from "@/libs/crypto";
|
||||||
import { Contact } from "@/db/tables/contacts";
|
import { Contact } from "@/db/tables/contacts";
|
||||||
import { MemberData } from "@/interfaces";
|
import { MemberData, MatchPair } from "@/interfaces";
|
||||||
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
|
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
|
||||||
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
|
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
|
||||||
import BulkMembersDialog from "./BulkMembersDialog.vue";
|
import BulkMembersDialog from "./BulkMembersDialog.vue";
|
||||||
|
import MeetingMemberMatch from "./MeetingMemberMatch.vue";
|
||||||
|
|
||||||
|
const AUTO_REFRESH_INTERVAL = 15;
|
||||||
|
|
||||||
interface Member {
|
interface Member {
|
||||||
admitted: boolean;
|
admitted: boolean;
|
||||||
@@ -257,6 +304,7 @@ interface Member {
|
|||||||
memberId: number;
|
memberId: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// there's a similar structure in OnboardMeetingSetupView.vue but without the member
|
||||||
interface DecryptedMember {
|
interface DecryptedMember {
|
||||||
member: Member;
|
member: Member;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -267,16 +315,20 @@ interface DecryptedMember {
|
|||||||
@Component({
|
@Component({
|
||||||
components: {
|
components: {
|
||||||
BulkMembersDialog,
|
BulkMembersDialog,
|
||||||
|
MeetingMemberMatch,
|
||||||
},
|
},
|
||||||
mixins: [PlatformServiceMixin],
|
mixins: [PlatformServiceMixin],
|
||||||
})
|
})
|
||||||
export default class MembersList extends Vue {
|
export default class MeetingMembersList extends Vue {
|
||||||
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
||||||
|
|
||||||
notify!: ReturnType<typeof createNotifyHelpers>;
|
notify!: ReturnType<typeof createNotifyHelpers>;
|
||||||
|
|
||||||
@Prop({ required: true }) password!: string;
|
@Prop({ required: true }) password!: string;
|
||||||
@Prop({ default: false }) showOrganizerTools!: boolean;
|
@Prop({ default: false }) showOrganizerTools!: boolean;
|
||||||
|
@Prop({ default: null }) matchPairs!: MatchPair[] | null;
|
||||||
|
@Prop({ default: () => [] }) excludedDids!: string[];
|
||||||
|
@Prop({ default: false }) exclusionLocked!: boolean;
|
||||||
|
|
||||||
// Emit methods using @Emit decorator
|
// Emit methods using @Emit decorator
|
||||||
@Emit("error")
|
@Emit("error")
|
||||||
@@ -284,6 +336,16 @@ export default class MembersList extends Vue {
|
|||||||
return message;
|
return message;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Emit("toggle-exclusion")
|
||||||
|
emitToggleExclusion(did: string) {
|
||||||
|
return did;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Emit("members-loaded")
|
||||||
|
emitMembersLoaded() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
contacts: Array<Contact> = [];
|
contacts: Array<Contact> = [];
|
||||||
decryptedMembers: DecryptedMember[] = [];
|
decryptedMembers: DecryptedMember[] = [];
|
||||||
firstName = "";
|
firstName = "";
|
||||||
@@ -296,7 +358,7 @@ export default class MembersList extends Vue {
|
|||||||
apiServer = "";
|
apiServer = "";
|
||||||
|
|
||||||
// Auto-refresh functionality
|
// Auto-refresh functionality
|
||||||
countdownTimer = 10;
|
countdownTimer = AUTO_REFRESH_INTERVAL;
|
||||||
autoRefreshInterval: NodeJS.Timeout | null = null;
|
autoRefreshInterval: NodeJS.Timeout | null = null;
|
||||||
lastRefreshTime = 0;
|
lastRefreshTime = 0;
|
||||||
previousMemberDidsIgnored: string[] = [];
|
previousMemberDidsIgnored: string[] = [];
|
||||||
@@ -345,6 +407,7 @@ export default class MembersList extends Vue {
|
|||||||
this.emitError(serverMessageForUser(error) || "Failed to fetch members.");
|
this.emitError(serverMessageForUser(error) || "Failed to fetch members.");
|
||||||
} finally {
|
} finally {
|
||||||
this.isLoading = false;
|
this.isLoading = false;
|
||||||
|
this.emitMembersLoaded();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -486,7 +549,7 @@ export default class MembersList extends Vue {
|
|||||||
|
|
||||||
informAboutAdmission() {
|
informAboutAdmission() {
|
||||||
this.notify.info(
|
this.notify.info(
|
||||||
"This is to register people in Time Safari and to admit them to the meeting. A (+) symbol means they are not yet admitted and you can register and admit them. A (-) symbol means you can remove them, but they will stay registered.",
|
"Click the 'ban' button to exclude from matching. The '+/-' buttons are for admissions: A blue (+) symbol means they are not yet admitted and you can register and admit them. A red (-) symbol means you can remove them, but they will stay registered.",
|
||||||
TIMEOUTS.VERY_LONG,
|
TIMEOUTS.VERY_LONG,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -544,8 +607,17 @@ export default class MembersList extends Vue {
|
|||||||
* (admit pending members for organizers, add to contacts for non-organizers)
|
* (admit pending members for organizers, add to contacts for non-organizers)
|
||||||
*/
|
*/
|
||||||
async refreshData(bypassPromptIfAllWereIgnored = true) {
|
async refreshData(bypassPromptIfAllWereIgnored = true) {
|
||||||
// Force refresh both contacts and members
|
// Force refresh of many things
|
||||||
|
|
||||||
|
// Matches may have been generated or erased
|
||||||
|
(
|
||||||
|
this.$refs.memberMatch as InstanceType<typeof MeetingMemberMatch>
|
||||||
|
)?.fetchMatches();
|
||||||
|
|
||||||
|
// Someone may have been added to their contacts
|
||||||
this.contacts = await this.$getAllContacts();
|
this.contacts = await this.$getAllContacts();
|
||||||
|
|
||||||
|
// The members list may have changed
|
||||||
await this.fetchMembers();
|
await this.fetchMembers();
|
||||||
|
|
||||||
const pendingMembers = this.isOrganizer
|
const pendingMembers = this.isOrganizer
|
||||||
@@ -724,25 +796,42 @@ export default class MembersList extends Vue {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getAdmittedMembers(): Array<{ did: string; name: string }> {
|
||||||
|
return this.decryptedMembers
|
||||||
|
.filter((m) => m.member.admitted)
|
||||||
|
.map((m) => ({ did: m.did, name: m.name }));
|
||||||
|
}
|
||||||
|
|
||||||
|
handleExclusionClick(did: string): void {
|
||||||
|
if (this.exclusionLocked) {
|
||||||
|
this.notify.warning(
|
||||||
|
"Erase the current matches before changing exclusions.",
|
||||||
|
TIMEOUTS.LONG,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.emitToggleExclusion(did);
|
||||||
|
}
|
||||||
|
|
||||||
startAutoRefresh() {
|
startAutoRefresh() {
|
||||||
this.stopAutoRefresh();
|
this.stopAutoRefresh();
|
||||||
this.lastRefreshTime = Date.now();
|
this.lastRefreshTime = Date.now();
|
||||||
this.countdownTimer = 10;
|
this.countdownTimer = AUTO_REFRESH_INTERVAL;
|
||||||
|
|
||||||
this.autoRefreshInterval = setInterval(() => {
|
this.autoRefreshInterval = setInterval(() => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const timeSinceLastRefresh = (now - this.lastRefreshTime) / 1000;
|
const timeSinceLastRefresh = (now - this.lastRefreshTime) / 1000;
|
||||||
|
|
||||||
if (timeSinceLastRefresh >= 10) {
|
if (timeSinceLastRefresh >= AUTO_REFRESH_INTERVAL) {
|
||||||
// Time to refresh
|
// Time to refresh
|
||||||
this.refreshData();
|
this.refreshData();
|
||||||
this.lastRefreshTime = now;
|
this.lastRefreshTime = now;
|
||||||
this.countdownTimer = 10;
|
this.countdownTimer = AUTO_REFRESH_INTERVAL;
|
||||||
} else {
|
} else {
|
||||||
// Update countdown
|
// Update countdown
|
||||||
this.countdownTimer = Math.max(
|
this.countdownTimer = Math.max(
|
||||||
0,
|
0,
|
||||||
Math.round(10 - timeSinceLastRefresh),
|
Math.round(AUTO_REFRESH_INTERVAL - timeSinceLastRefresh),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}, 1000); // Update every second
|
}, 1000); // Update every second
|
||||||
@@ -789,6 +878,11 @@ export default class MembersList extends Vue {
|
|||||||
transition-colors;
|
transition-colors;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-exclusion-toggle {
|
||||||
|
/* stylelint-disable-next-line at-rule-no-unknown */
|
||||||
|
@apply text-lg transition-colors;
|
||||||
|
}
|
||||||
|
|
||||||
.btn-admission-remove {
|
.btn-admission-remove {
|
||||||
/* stylelint-disable-next-line at-rule-no-unknown */
|
/* stylelint-disable-next-line at-rule-no-unknown */
|
||||||
@apply text-lg text-rose-500 hover:text-rose-700
|
@apply text-lg text-rose-500 hover:text-rose-700
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
<div v-if="visible" class="dialog-overlay">
|
<div v-if="visible" class="dialog-overlay">
|
||||||
<div v-if="page === OnboardPage.Home" class="dialog">
|
<div v-if="page === OnboardPage.Home" class="dialog">
|
||||||
<h1 class="text-xl font-bold text-center mb-4 relative">
|
<h1 class="text-xl font-bold text-center mb-4 relative">
|
||||||
Welcome to Time Safari
|
Welcome to {{ AppString.APP_NAME }}
|
||||||
<br />
|
<br />
|
||||||
- Showcase Impact & Magnify Time
|
- Showcase Impact & Magnify Time
|
||||||
<div :class="closeButtonClasses" @click="onClickClose(true)">
|
<div :class="closeButtonClasses" @click="onClickClose(true)">
|
||||||
@@ -199,7 +199,7 @@
|
|||||||
import { Component, Vue } from "vue-facing-decorator";
|
import { Component, Vue } from "vue-facing-decorator";
|
||||||
import { Router } from "vue-router";
|
import { Router } from "vue-router";
|
||||||
|
|
||||||
import { NotificationIface } from "../constants/app";
|
import { AppString, NotificationIface } from "../constants/app";
|
||||||
import { OnboardPage } from "../libs/util";
|
import { OnboardPage } from "../libs/util";
|
||||||
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
|
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
|
||||||
|
|
||||||
@@ -226,6 +226,13 @@ export default class OnboardingDialog extends Vue {
|
|||||||
return OnboardPage;
|
return OnboardPage;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns AppString enum for template access
|
||||||
|
*/
|
||||||
|
get AppString() {
|
||||||
|
return AppString;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CSS classes for primary action buttons (blue gradient)
|
* CSS classes for primary action buttons (blue gradient)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ import { PlanData } from "../interfaces/records";
|
|||||||
import { NotificationIface } from "../constants/app";
|
import { NotificationIface } from "../constants/app";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* MeetingProjectDialog - Dialog for selecting a project link for a meeting
|
* ProjectSelectionDialog - Dialog for selecting a project
|
||||||
*
|
*
|
||||||
* Features:
|
* Features:
|
||||||
* - EntityGrid integration for project selection
|
* - EntityGrid integration for project selection
|
||||||
@@ -52,7 +52,7 @@ import { NotificationIface } from "../constants/app";
|
|||||||
EntityGrid,
|
EntityGrid,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
export default class MeetingProjectDialog extends Vue {
|
export default class ProjectSelectionDialog extends Vue {
|
||||||
/** Whether the dialog is visible */
|
/** Whether the dialog is visible */
|
||||||
visible = false;
|
visible = false;
|
||||||
|
|
||||||
@@ -14,75 +14,65 @@
|
|||||||
<div
|
<div
|
||||||
class="flex w-11/12 max-w-sm mx-auto mb-3 overflow-hidden bg-white rounded-lg shadow-lg"
|
class="flex w-11/12 max-w-sm mx-auto mb-3 overflow-hidden bg-white rounded-lg shadow-lg"
|
||||||
>
|
>
|
||||||
<div class="w-full px-6 py-6 text-slate-900 text-center">
|
<div class="w-full px-6 py-6 text-slate-900">
|
||||||
<p v-if="isSystemReady" class="text-lg mb-4">
|
<h1 v-if="isSystemReady" class="text-center font-bold mb-4">
|
||||||
<span v-if="isDailyCheck">
|
<span v-if="isDailyCheck">
|
||||||
Would you like to be notified of new activity, up to once a day?
|
Would you like to be notified of new activity, up to once a day?
|
||||||
</span>
|
</span>
|
||||||
<span v-else>
|
<span v-else>
|
||||||
Would you like to get a reminder message once a day?
|
Would you like to get a reminder message once a day?
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</h1>
|
||||||
<p v-else class="text-lg mb-4">
|
<h1 v-else class="text-center font-bold mb-4">
|
||||||
{{ waitingMessage }}
|
{{ waitingMessage }}
|
||||||
<font-awesome icon="spinner" spin />
|
<font-awesome icon="spinner" spin />
|
||||||
</p>
|
</h1>
|
||||||
|
|
||||||
<div v-if="canShowNotificationForm">
|
<div v-if="canShowNotificationForm">
|
||||||
<div v-if="isDailyCheck">
|
<div v-if="isDailyCheck">
|
||||||
<span>Yes, send me a message when there is new data for me</span>
|
<span
|
||||||
|
><b>Yes</b>, send me a message when there is new data for
|
||||||
|
me</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
<div v-else>
|
<div v-else>
|
||||||
<span>Yes, send me this message:</span>
|
<span class="text-slate-500 text-sm font-bold"
|
||||||
|
>Send me this message:</span
|
||||||
|
>
|
||||||
<!-- eslint-disable -->
|
<!-- eslint-disable -->
|
||||||
<textarea
|
<textarea
|
||||||
type="text"
|
type="text"
|
||||||
id="push-message"
|
id="push-message"
|
||||||
v-model="messageInput"
|
v-model="messageInput"
|
||||||
class="rounded border border-slate-400 mt-2 px-2 py-2 w-full"
|
class="rounded border border-slate-400 mt-2 p-2 w-full"
|
||||||
maxlength="100"
|
maxlength="100"
|
||||||
></textarea
|
></textarea
|
||||||
>
|
>
|
||||||
<!-- eslint-enable -->
|
<div class="text-xs text-slate-500">
|
||||||
<span class="w-full flex justify-between text-xs text-slate-500">
|
|
||||||
<span></span>
|
|
||||||
<span>(100 characters max)</span>
|
<span>(100 characters max)</span>
|
||||||
</span>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div class="mt-2 mb-4 flex items-center gap-2">
|
||||||
<span class="flex flex-row justify-center">
|
<label for="time" class="text-slate-500 text-sm font-bold">At this time:</label>
|
||||||
<span class="mt-2">... at: </span>
|
<input
|
||||||
<input
|
v-model="timeValue"
|
||||||
v-model="hourInput"
|
type="time"
|
||||||
type="number"
|
id="time"
|
||||||
class="rounded-l border border-r-0 border-slate-400 ml-2 mt-2 px-2 py-2 text-center w-20"
|
class="!px-4 !py-4 text-md bg-white border border-slate-400 rounded"
|
||||||
@change="checkHourInput"
|
required
|
||||||
/>
|
/>
|
||||||
<input
|
|
||||||
v-model="minuteInput"
|
|
||||||
type="number"
|
|
||||||
class="border border-slate-400 mt-2 px-2 py-2 text-center w-20"
|
|
||||||
@change="checkMinuteInput"
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
class="rounded-r border border-slate-400 bg-slate-200 text-center text-blue-500 mt-2 px-2 py-2 w-20"
|
|
||||||
@click="toggleHourAm"
|
|
||||||
>
|
|
||||||
<span>{{ amPmLabel }} <font-awesome :icon="amPmIcon" /></span>
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
class="block w-full text-center text-md font-bold uppercase bg-blue-600 text-white mt-2 px-2 py-2 rounded-md"
|
class="block w-full text-center text-md font-bold uppercase bg-blue-600 text-white mt-2 px-2 py-2 rounded-md"
|
||||||
@click="handleTurnOnNotifications"
|
@click="handleTurnOnNotifications"
|
||||||
>
|
>
|
||||||
Turn on Daily Message
|
Turn on Daily Reminder
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class="block w-full text-center text-md font-bold uppercase bg-slate-600 text-white mt-4 px-2 py-2 rounded-md"
|
class="block w-full text-center text-md font-bold uppercase bg-slate-600 text-white mt-2 px-2 py-2 rounded-md"
|
||||||
@click="close()"
|
@click="close()"
|
||||||
>
|
>
|
||||||
No, Not Now
|
No, Not Now
|
||||||
@@ -95,6 +85,7 @@
|
|||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Component, Vue } from "vue-facing-decorator";
|
import { Component, Vue } from "vue-facing-decorator";
|
||||||
|
import { Capacitor } from "@capacitor/core";
|
||||||
|
|
||||||
import { DEFAULT_PUSH_SERVER, NotificationIface } from "../constants/app";
|
import { DEFAULT_PUSH_SERVER, NotificationIface } from "../constants/app";
|
||||||
import {
|
import {
|
||||||
@@ -116,6 +107,7 @@ import * as libsUtil from "../libs/util";
|
|||||||
import { logger } from "../utils/logger";
|
import { logger } from "../utils/logger";
|
||||||
import { PlatformServiceMixin } from "../utils/PlatformServiceMixin";
|
import { PlatformServiceMixin } from "../utils/PlatformServiceMixin";
|
||||||
import { UNNAMED_ENTITY_NAME } from "@/constants/entities";
|
import { UNNAMED_ENTITY_NAME } from "@/constants/entities";
|
||||||
|
import { NotificationService } from "@/services/notifications";
|
||||||
|
|
||||||
// Example interface for error
|
// Example interface for error
|
||||||
interface ErrorResponse {
|
interface ErrorResponse {
|
||||||
@@ -164,16 +156,54 @@ export default class PushNotificationPermission extends Vue {
|
|||||||
messageInput = "";
|
messageInput = "";
|
||||||
minuteInput = "00";
|
minuteInput = "00";
|
||||||
pushType = "";
|
pushType = "";
|
||||||
|
/** When true, dialog only returns time/message to parent; parent does cancel+schedule (avoids double schedule on edit). */
|
||||||
|
skipScheduleForOpen = false;
|
||||||
|
/** When set (e.g. 10), passed to plugin for dev/test fast rollover. */
|
||||||
|
rolloverIntervalMinutesForSchedule: number | undefined = undefined;
|
||||||
serviceWorkerReady = false;
|
serviceWorkerReady = false;
|
||||||
vapidKey = "";
|
vapidKey = "";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if running on native platform (iOS/Android)
|
||||||
|
*/
|
||||||
|
private get isNativePlatform(): boolean {
|
||||||
|
return Capacitor.isNativePlatform();
|
||||||
|
}
|
||||||
|
|
||||||
async open(
|
async open(
|
||||||
pushType: string,
|
pushType: string,
|
||||||
callback?: (success: boolean, time: string, message?: string) => void,
|
callback?: (success: boolean, time: string, message?: string) => void,
|
||||||
|
options?: { skipSchedule?: boolean; rolloverIntervalMinutes?: number },
|
||||||
) {
|
) {
|
||||||
this.callback = callback || this.callback;
|
this.callback = callback || this.callback;
|
||||||
this.isVisible = true;
|
this.isVisible = true;
|
||||||
this.pushType = pushType;
|
this.pushType = pushType;
|
||||||
|
this.skipScheduleForOpen = options?.skipSchedule ?? false;
|
||||||
|
this.rolloverIntervalMinutesForSchedule = options?.rolloverIntervalMinutes;
|
||||||
|
|
||||||
|
// Native platforms: Skip web push initialization
|
||||||
|
if (this.isNativePlatform) {
|
||||||
|
logger.debug(
|
||||||
|
"[PushNotificationPermission] Native platform detected, skipping web push initialization",
|
||||||
|
);
|
||||||
|
// For native, we don't need VAPID or service worker
|
||||||
|
this.serviceWorkerReady = true;
|
||||||
|
this.vapidKey = "native"; // Placeholder for computed properties
|
||||||
|
|
||||||
|
// Set up message input based on push type
|
||||||
|
if (this.pushType === this.DIRECT_PUSH_TITLE) {
|
||||||
|
this.messageInput = this.notificationMessagePlaceholder;
|
||||||
|
// focus on the message input
|
||||||
|
setTimeout(function () {
|
||||||
|
document.getElementById("push-message")?.focus();
|
||||||
|
}, 100);
|
||||||
|
} else {
|
||||||
|
this.messageInput = "";
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Web platform: Initialize web push (existing logic)
|
||||||
try {
|
try {
|
||||||
const settings = await this.$accountSettings();
|
const settings = await this.$accountSettings();
|
||||||
let pushUrl = DEFAULT_PUSH_SERVER;
|
let pushUrl = DEFAULT_PUSH_SERVER;
|
||||||
@@ -360,36 +390,6 @@ export default class PushNotificationPermission extends Vue {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private checkHourInput() {
|
|
||||||
const hourNum = parseInt(this.hourInput);
|
|
||||||
if (isNaN(hourNum)) {
|
|
||||||
this.hourInput = "12";
|
|
||||||
} else if (hourNum < 1) {
|
|
||||||
this.hourInput = "12";
|
|
||||||
this.hourAm = !this.hourAm;
|
|
||||||
} else if (hourNum > 12) {
|
|
||||||
this.hourInput = "1";
|
|
||||||
this.hourAm = !this.hourAm;
|
|
||||||
} else {
|
|
||||||
this.hourInput = hourNum.toString();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private checkMinuteInput() {
|
|
||||||
const minuteNum = parseInt(this.minuteInput);
|
|
||||||
if (isNaN(minuteNum)) {
|
|
||||||
this.minuteInput = "00";
|
|
||||||
} else if (minuteNum < 0) {
|
|
||||||
this.minuteInput = "59";
|
|
||||||
} else if (minuteNum < 10) {
|
|
||||||
this.minuteInput = "0" + minuteNum;
|
|
||||||
} else if (minuteNum > 59) {
|
|
||||||
this.minuteInput = "00";
|
|
||||||
} else {
|
|
||||||
this.minuteInput = minuteNum.toString();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async turnOnNotifications() {
|
private async turnOnNotifications() {
|
||||||
let notifyCloser = () => {};
|
let notifyCloser = () => {};
|
||||||
return this.askPermission()
|
return this.askPermission()
|
||||||
@@ -585,16 +585,24 @@ export default class PushNotificationPermission extends Vue {
|
|||||||
/**
|
/**
|
||||||
* Computed property: isSystemReady
|
* Computed property: isSystemReady
|
||||||
* Returns true if serviceWorkerReady and vapidKey are set
|
* Returns true if serviceWorkerReady and vapidKey are set
|
||||||
|
* For native platforms, always returns true (no VAPID needed)
|
||||||
*/
|
*/
|
||||||
get isSystemReady(): boolean {
|
get isSystemReady(): boolean {
|
||||||
|
if (this.isNativePlatform) {
|
||||||
|
return true; // Native doesn't need VAPID/service worker
|
||||||
|
}
|
||||||
return this.serviceWorkerReady && !!this.vapidKey;
|
return this.serviceWorkerReady && !!this.vapidKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Computed property: canShowNotificationForm
|
* Computed property: canShowNotificationForm
|
||||||
* Returns true if serviceWorkerReady and vapidKey are set
|
* Returns true if serviceWorkerReady and vapidKey are set
|
||||||
|
* For native platforms, always returns true (no VAPID needed)
|
||||||
*/
|
*/
|
||||||
get canShowNotificationForm(): boolean {
|
get canShowNotificationForm(): boolean {
|
||||||
|
if (this.isNativePlatform) {
|
||||||
|
return true; // Native doesn't need VAPID/service worker
|
||||||
|
}
|
||||||
return this.serviceWorkerReady && !!this.vapidKey;
|
return this.serviceWorkerReady && !!this.vapidKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -614,6 +622,27 @@ export default class PushNotificationPermission extends Vue {
|
|||||||
return `${this.hourInput}:${this.minuteInput} ${this.hourAm ? "AM" : "PM"}`;
|
return `${this.hourInput}:${this.minuteInput} ${this.hourAm ? "AM" : "PM"}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Two-way binding for native time input (HH:mm 24h).
|
||||||
|
* Syncs with hourInput, minuteInput, and hourAm.
|
||||||
|
*/
|
||||||
|
get timeValue(): string {
|
||||||
|
return this.convertTo24HourFormat();
|
||||||
|
}
|
||||||
|
set timeValue(value: string) {
|
||||||
|
const [h = "0", m = "0"] = value.split(":");
|
||||||
|
const hour24 = parseInt(h, 10);
|
||||||
|
const minute = parseInt(m, 10);
|
||||||
|
this.minuteInput = minute.toString().padStart(2, "0");
|
||||||
|
if (hour24 >= 12) {
|
||||||
|
this.hourAm = false;
|
||||||
|
this.hourInput = hour24 === 12 ? "12" : (hour24 - 12).toString();
|
||||||
|
} else {
|
||||||
|
this.hourAm = true;
|
||||||
|
this.hourInput = hour24 === 0 ? "12" : hour24.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Toggles the AM/PM state for the hour input
|
* Toggles the AM/PM state for the hour input
|
||||||
*/
|
*/
|
||||||
@@ -639,10 +668,15 @@ export default class PushNotificationPermission extends Vue {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles the main action button click
|
* Handles the main action button click
|
||||||
|
* Close only after async flow completes so success/error $notify runs while component is mounted (fixes Android).
|
||||||
*/
|
*/
|
||||||
handleTurnOnNotifications() {
|
async handleTurnOnNotifications() {
|
||||||
|
if (this.isNativePlatform) {
|
||||||
|
await this.turnOnNativeNotifications();
|
||||||
|
} else {
|
||||||
|
await this.turnOnNotifications();
|
||||||
|
}
|
||||||
this.close();
|
this.close();
|
||||||
this.turnOnNotifications();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -652,6 +686,231 @@ export default class PushNotificationPermission extends Vue {
|
|||||||
get waitingMessage(): string {
|
get waitingMessage(): string {
|
||||||
return "Waiting for system initialization, which may take up to 5 seconds...";
|
return "Waiting for system initialization, which may take up to 5 seconds...";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle native notification setup using DailyNotificationPlugin
|
||||||
|
*/
|
||||||
|
private async turnOnNativeNotifications(): Promise<void> {
|
||||||
|
try {
|
||||||
|
logger.debug(
|
||||||
|
"[PushNotificationPermission] Starting native notification setup",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Edit flow: parent will cancel + schedule; avoid double schedule (second call cancels alarm first set).
|
||||||
|
if (this.skipScheduleForOpen) {
|
||||||
|
this.callback(true, this.notificationTimeText, this.messageInput);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import and check plugin availability before using service
|
||||||
|
const { DailyNotification } = await import(
|
||||||
|
"@/plugins/DailyNotificationPlugin"
|
||||||
|
);
|
||||||
|
if (!DailyNotification) {
|
||||||
|
logger.error(
|
||||||
|
"[PushNotificationPermission] DailyNotification plugin not available",
|
||||||
|
);
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "danger",
|
||||||
|
title: NOTIFY_PUSH_SETUP_ERROR.title,
|
||||||
|
text: "DailyNotification plugin is not available. Please rebuild the app.",
|
||||||
|
},
|
||||||
|
PUSH_NOTIFICATION_TIMEOUT_SHORT,
|
||||||
|
);
|
||||||
|
this.callback(false, "", this.messageInput);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
"[PushNotificationPermission] Plugin available, getting service instance",
|
||||||
|
);
|
||||||
|
const service = NotificationService.getInstance();
|
||||||
|
|
||||||
|
// Request permissions
|
||||||
|
logger.debug(
|
||||||
|
"[PushNotificationPermission] Requesting native permissions",
|
||||||
|
);
|
||||||
|
const granted = await service.requestPermissions();
|
||||||
|
|
||||||
|
if (!granted) {
|
||||||
|
logger.warn(
|
||||||
|
"[PushNotificationPermission] Native notification permissions denied",
|
||||||
|
);
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "danger",
|
||||||
|
title: NOTIFY_PUSH_PERMISSION_ERROR.title,
|
||||||
|
text: NOTIFY_PUSH_PERMISSION_ERROR.message,
|
||||||
|
},
|
||||||
|
PUSH_NOTIFICATION_TIMEOUT_PERSISTENT,
|
||||||
|
);
|
||||||
|
this.callback(false, "", this.messageInput);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert time to 24-hour format (HH:mm)
|
||||||
|
const time24h = this.convertTo24HourFormat();
|
||||||
|
logger.debug(
|
||||||
|
"[PushNotificationPermission] Converted time to 24-hour format:",
|
||||||
|
time24h,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Determine title and body based on pushType
|
||||||
|
const title =
|
||||||
|
this.pushType === this.DAILY_CHECK_TITLE
|
||||||
|
? "Daily Check-In"
|
||||||
|
: "Daily Reminder";
|
||||||
|
const body =
|
||||||
|
this.pushType === this.DIRECT_PUSH_TITLE
|
||||||
|
? this.messageInput || this.notificationMessagePlaceholder
|
||||||
|
: "Time to check your TimeSafari activity";
|
||||||
|
|
||||||
|
// Schedule notification
|
||||||
|
logger.info(
|
||||||
|
"[PushNotificationPermission] Scheduling native notification:",
|
||||||
|
{
|
||||||
|
time: time24h,
|
||||||
|
title,
|
||||||
|
pushType: this.pushType,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check permissions one more time before scheduling
|
||||||
|
const finalPermissionCheck = await service.checkPermissions();
|
||||||
|
logger.debug(
|
||||||
|
"[PushNotificationPermission] Final permission check before scheduling:",
|
||||||
|
JSON.stringify(finalPermissionCheck, null, 2),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!finalPermissionCheck.granted) {
|
||||||
|
logger.warn(
|
||||||
|
"[PushNotificationPermission] Permissions not fully granted. " +
|
||||||
|
"Notification may not fire. Details:",
|
||||||
|
finalPermissionCheck.details,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const success = await service.scheduleDailyNotification({
|
||||||
|
time: time24h,
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
priority: "normal",
|
||||||
|
...(this.rolloverIntervalMinutesForSchedule != null &&
|
||||||
|
this.rolloverIntervalMinutesForSchedule > 0
|
||||||
|
? { rolloverIntervalMinutes: this.rolloverIntervalMinutesForSchedule }
|
||||||
|
: {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
logger.error(
|
||||||
|
"[PushNotificationPermission] Failed to schedule native notification",
|
||||||
|
);
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "danger",
|
||||||
|
title: NOTIFY_PUSH_SETUP_ERROR.title,
|
||||||
|
text: NOTIFY_PUSH_SETUP_ERROR.message,
|
||||||
|
},
|
||||||
|
PUSH_NOTIFICATION_TIMEOUT_SHORT,
|
||||||
|
);
|
||||||
|
this.callback(false, "", this.messageInput);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log final status after scheduling
|
||||||
|
const statusAfterSchedule = await service.getStatus();
|
||||||
|
logger.info(
|
||||||
|
"[PushNotificationPermission] Notification status after scheduling:",
|
||||||
|
JSON.stringify(statusAfterSchedule, null, 2),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Save to settings
|
||||||
|
const timeText = this.notificationTimeText;
|
||||||
|
const settingsToSave: Record<string, string> = {};
|
||||||
|
|
||||||
|
if (this.pushType === this.DAILY_CHECK_TITLE) {
|
||||||
|
settingsToSave.notifyingNewActivityTime = timeText;
|
||||||
|
} else {
|
||||||
|
settingsToSave.notifyingReminderTime = timeText;
|
||||||
|
if (this.messageInput) {
|
||||||
|
settingsToSave.notifyingReminderMessage = this.messageInput;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.$saveSettings(settingsToSave);
|
||||||
|
logger.debug(
|
||||||
|
"[PushNotificationPermission] Settings saved:",
|
||||||
|
settingsToSave,
|
||||||
|
);
|
||||||
|
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "success",
|
||||||
|
title: NOTIFY_PUSH_SUCCESS.title,
|
||||||
|
text: NOTIFY_PUSH_SUCCESS.message,
|
||||||
|
},
|
||||||
|
PUSH_NOTIFICATION_TIMEOUT_LONG,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Call callback with success
|
||||||
|
this.callback(true, timeText, this.messageInput);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
"[PushNotificationPermission] Error in native notification setup:",
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
// Log additional error details for debugging
|
||||||
|
if (error instanceof Error) {
|
||||||
|
logger.error("[PushNotificationPermission] Error details:", {
|
||||||
|
message: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
name: error.name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "danger",
|
||||||
|
title: NOTIFY_PUSH_SETUP_ERROR.title,
|
||||||
|
text: NOTIFY_PUSH_SETUP_ERROR.message,
|
||||||
|
},
|
||||||
|
PUSH_NOTIFICATION_TIMEOUT_SHORT,
|
||||||
|
);
|
||||||
|
this.callback(false, "", this.messageInput);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert AM/PM time input to 24-hour format (HH:mm)
|
||||||
|
* @returns Time string in HH:mm format
|
||||||
|
*/
|
||||||
|
private convertTo24HourFormat(): string {
|
||||||
|
const hour = parseInt(this.hourInput);
|
||||||
|
const minute = parseInt(this.minuteInput);
|
||||||
|
|
||||||
|
let hour24 = hour;
|
||||||
|
|
||||||
|
// Convert to 24-hour format
|
||||||
|
if (!this.hourAm && hour !== 12) {
|
||||||
|
// PM: add 12 (except for 12 PM which stays 12)
|
||||||
|
hour24 = hour + 12;
|
||||||
|
} else if (this.hourAm && hour === 12) {
|
||||||
|
// 12 AM: convert to 0
|
||||||
|
hour24 = 0;
|
||||||
|
}
|
||||||
|
// AM (except 12): keep as is
|
||||||
|
|
||||||
|
// Format with leading zeros
|
||||||
|
const hourStr = hour24.toString().padStart(2, "0");
|
||||||
|
const minuteStr = minute.toString().padStart(2, "0");
|
||||||
|
|
||||||
|
return `${hourStr}:${minuteStr}`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,8 @@
|
|||||||
export enum AppString {
|
export enum AppString {
|
||||||
// This is used in titles and verbiage inside the app.
|
// This is used in titles and verbiage inside the app.
|
||||||
// There is also an app name without spaces, for packaging in the package.json file used in the manifest.
|
// There is also an app name without spaces, for packaging in the package.json file used in the manifest.
|
||||||
APP_NAME = "Time Safari",
|
APP_NAME = "Giftopia",
|
||||||
APP_NAME_NO_SPACES = "TimeSafari",
|
APP_NAME_NO_SPACES = APP_NAME,
|
||||||
|
|
||||||
PROD_ENDORSER_API_SERVER = "https://api.endorser.ch",
|
PROD_ENDORSER_API_SERVER = "https://api.endorser.ch",
|
||||||
TEST_ENDORSER_API_SERVER = "https://test-api.endorser.ch",
|
TEST_ENDORSER_API_SERVER = "https://test-api.endorser.ch",
|
||||||
@@ -49,6 +49,15 @@ export const DEFAULT_PUSH_SERVER =
|
|||||||
|
|
||||||
export const IMAGE_TYPE_PROFILE = "profile";
|
export const IMAGE_TYPE_PROFILE = "profile";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when the current API server is not production (test/local build).
|
||||||
|
* Use this to show dev/test-only UI (e.g. 10-minute rollover toggle, test user mnemonic).
|
||||||
|
*/
|
||||||
|
export function isNotProdServer(apiServer: string): boolean {
|
||||||
|
return apiServer !== AppString.PROD_ENDORSER_API_SERVER;
|
||||||
|
}
|
||||||
|
export const SUPPORT_EMAIL = "info@TimeSafari.app";
|
||||||
|
|
||||||
export const PASSKEYS_ENABLED =
|
export const PASSKEYS_ENABLED =
|
||||||
!!import.meta.env.VITE_PASSKEYS_ENABLED || false;
|
!!import.meta.env.VITE_PASSKEYS_ENABLED || false;
|
||||||
|
|
||||||
|
|||||||
@@ -43,12 +43,6 @@ export const NOTIFY_CONFIRMATION_ERROR = {
|
|||||||
message: "There was a problem submitting the confirmation.",
|
message: "There was a problem submitting the confirmation.",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Used in: [Component usage not yet documented]
|
|
||||||
export const NOTIFY_DEFAULT_TO_ACTIVE_DID = {
|
|
||||||
title: "Your Info",
|
|
||||||
message: "No user was specified so showing your info.",
|
|
||||||
};
|
|
||||||
|
|
||||||
// Used in: [Component usage not yet documented]
|
// Used in: [Component usage not yet documented]
|
||||||
export const NOTIFY_CONTACT_DELETED = {
|
export const NOTIFY_CONTACT_DELETED = {
|
||||||
title: "Deleted",
|
title: "Deleted",
|
||||||
@@ -454,8 +448,8 @@ export const NOTIFY_UNCONFIRMED_HOURS_DYNAMIC = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Complex modal constants (for raw $notify calls with advanced features)
|
// Complex modal constants (for raw $notify calls with advanced features)
|
||||||
// MembersList.vue complex modals
|
// MeetingMembersList.vue complex modals
|
||||||
// Used in: MembersList.vue (complex modal for adding contacts)
|
// Used in: MeetingMembersList.vue (complex modal for adding contacts)
|
||||||
export const NOTIFY_ADD_CONTACT_FIRST = {
|
export const NOTIFY_ADD_CONTACT_FIRST = {
|
||||||
title: "Add as Contact First?",
|
title: "Add as Contact First?",
|
||||||
text: "This person is not in your contacts. Would you like to add them as a contact first?",
|
text: "This person is not in your contacts. Would you like to add them as a contact first?",
|
||||||
@@ -463,7 +457,7 @@ export const NOTIFY_ADD_CONTACT_FIRST = {
|
|||||||
noText: "Skip Adding Contact",
|
noText: "Skip Adding Contact",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Used in: MembersList.vue (complex modal for continuing without adding)
|
// Used in: MeetingMembersList.vue (complex modal for continuing without adding)
|
||||||
export const NOTIFY_CONTINUE_WITHOUT_ADDING = {
|
export const NOTIFY_CONTINUE_WITHOUT_ADDING = {
|
||||||
title: "Continue Without Adding?",
|
title: "Continue Without Adding?",
|
||||||
text: "Are you sure you want to proceed with admission? If they are not a contact, you will not know their name after this meeting.",
|
text: "Are you sure you want to proceed with admission? If they are not a contact, you will not know their name after this meeting.",
|
||||||
@@ -848,7 +842,8 @@ export const NOTIFY_EXPORT_DATA_PROMPT = {
|
|||||||
// Used in: ContactsView.vue (showCopySelectionsInfo method - info about copying contacts)
|
// Used in: ContactsView.vue (showCopySelectionsInfo method - info about copying contacts)
|
||||||
export const NOTIFY_CONTACT_INFO_COPY = {
|
export const NOTIFY_CONTACT_INFO_COPY = {
|
||||||
title: "Info",
|
title: "Info",
|
||||||
message: "Contact info will include name, ID, profile image, and public key.",
|
message:
|
||||||
|
"Copied contact info will include name, ID, profile image, and public key.",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Used in: ContactsView.vue (copySelectedContacts method - no contacts selected error)
|
// Used in: ContactsView.vue (copySelectedContacts method - no contacts selected error)
|
||||||
@@ -1180,11 +1175,6 @@ export const NOTIFY_GIFTED_DETAILS_DELETE_IMAGE_CONFIRM = {
|
|||||||
message: "",
|
message: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const NOTIFY_GIFTED_DETAILS_DELETE_IMAGE_ERROR = {
|
|
||||||
title: "Error",
|
|
||||||
message: "There was a problem deleting the image.",
|
|
||||||
};
|
|
||||||
|
|
||||||
export const NOTIFY_GIFTED_DETAILS_NO_IDENTIFIER = {
|
export const NOTIFY_GIFTED_DETAILS_NO_IDENTIFIER = {
|
||||||
title: "Missing Identifier",
|
title: "Missing Identifier",
|
||||||
message: "You must select an identifier before you can record a give.",
|
message: "You must select an identifier before you can record a give.",
|
||||||
@@ -1647,9 +1637,8 @@ export const NOTIFY_PUSH_SETUP_UNDERWAY = {
|
|||||||
|
|
||||||
// Used in: PushNotificationPermission.vue (turnOnNotifications method - success)
|
// Used in: PushNotificationPermission.vue (turnOnNotifications method - success)
|
||||||
export const NOTIFY_PUSH_SUCCESS = {
|
export const NOTIFY_PUSH_SUCCESS = {
|
||||||
title: "Notification Is On",
|
title: "Notifications On",
|
||||||
message:
|
message: "Daily Reminder notifications are now enabled.",
|
||||||
"You should see at least one on your device; if not, check the 'Troubleshoot' link.",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Used in: PushNotificationPermission.vue (turnOnNotifications method - general error)
|
// Used in: PushNotificationPermission.vue (turnOnNotifications method - general error)
|
||||||
|
|||||||
@@ -175,6 +175,7 @@ const MIGRATIONS = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "002_add_iViewContent_to_contacts",
|
name: "002_add_iViewContent_to_contacts",
|
||||||
|
// Note that many times iViewContent was set to null despite the DEFAULT setting.
|
||||||
sql: `
|
sql: `
|
||||||
ALTER TABLE contacts ADD COLUMN iViewContent BOOLEAN DEFAULT TRUE;
|
ALTER TABLE contacts ADD COLUMN iViewContent BOOLEAN DEFAULT TRUE;
|
||||||
`,
|
`,
|
||||||
@@ -199,6 +200,82 @@ const MIGRATIONS = [
|
|||||||
ALTER TABLE settings ADD COLUMN lastAckedStarredPlanChangesJwtId TEXT;
|
ALTER TABLE settings ADD COLUMN lastAckedStarredPlanChangesJwtId TEXT;
|
||||||
`,
|
`,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "006_add_labels_for_contacts",
|
||||||
|
sql: `
|
||||||
|
-- Create mapping table for contact labels
|
||||||
|
CREATE TABLE contact_labels (
|
||||||
|
did TEXT NOT NULL,
|
||||||
|
label TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (did, label),
|
||||||
|
FOREIGN KEY (did) REFERENCES contacts(did) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_contact_labels_label ON contact_labels(label);
|
||||||
|
CREATE INDEX idx_contact_labels_did ON contact_labels(did);
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "007_add_hideTheirContent_to_contacts",
|
||||||
|
// Since we have problems where iViewContent is not set, let's default to show content.
|
||||||
|
// Add hideTheirContent: null/absent/false = show content (safe default)
|
||||||
|
sql: `
|
||||||
|
ALTER TABLE contacts ADD COLUMN hideTheirContent BOOLEAN DEFAULT 0;
|
||||||
|
UPDATE contacts SET hideTheirContent = CASE WHEN iViewContent = 0 THEN 1 ELSE 0 END WHERE iViewContent IS NOT NULL;
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "008_remove_iViewContent_from_contacts",
|
||||||
|
// Recreate contacts without iViewContent: backup, drop, recreate, restore
|
||||||
|
sql: `
|
||||||
|
PRAGMA foreign_keys = OFF;
|
||||||
|
|
||||||
|
CREATE TABLE _contact_labels_backup_008 AS SELECT * FROM contact_labels;
|
||||||
|
DROP TABLE IF EXISTS contact_labels;
|
||||||
|
|
||||||
|
CREATE TABLE _contacts_backup_008 (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
did TEXT NOT NULL,
|
||||||
|
name TEXT,
|
||||||
|
contactMethods TEXT,
|
||||||
|
nextPubKeyHashB64 TEXT,
|
||||||
|
notes TEXT,
|
||||||
|
profileImageUrl TEXT,
|
||||||
|
publicKeyBase64 TEXT,
|
||||||
|
seesMe BOOLEAN,
|
||||||
|
registered BOOLEAN,
|
||||||
|
hideTheirContent BOOLEAN DEFAULT 0
|
||||||
|
);
|
||||||
|
INSERT INTO _contacts_backup_008 (id, did, name, contactMethods, nextPubKeyHashB64, notes, profileImageUrl, publicKeyBase64, seesMe, registered, hideTheirContent)
|
||||||
|
SELECT id, did, name, contactMethods, nextPubKeyHashB64, notes, profileImageUrl, publicKeyBase64, seesMe, registered, COALESCE(hideTheirContent, 0) FROM contacts;
|
||||||
|
|
||||||
|
DROP TABLE contacts;
|
||||||
|
ALTER TABLE _contacts_backup_008 RENAME TO contacts;
|
||||||
|
|
||||||
|
-- UNIQUE is important on 'did' because the foreign key from contact_labels fails on mobile without it.
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_contacts_did ON contacts(did);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_contacts_name ON contacts(name);
|
||||||
|
|
||||||
|
CREATE TABLE contact_labels (
|
||||||
|
did TEXT NOT NULL,
|
||||||
|
label TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (did, label),
|
||||||
|
FOREIGN KEY (did) REFERENCES contacts(did) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_contact_labels_label ON contact_labels(label);
|
||||||
|
CREATE INDEX idx_contact_labels_did ON contact_labels(did);
|
||||||
|
INSERT INTO contact_labels SELECT * FROM _contact_labels_backup_008;
|
||||||
|
DROP TABLE _contact_labels_backup_008;
|
||||||
|
|
||||||
|
PRAGMA foreign_keys = ON;
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "009_add_reminderFastRolloverForTesting_to_settings",
|
||||||
|
sql: `
|
||||||
|
-- Dev/test only: 10-minute rollover for daily reminder (plugin rolloverIntervalMinutes)
|
||||||
|
ALTER TABLE settings ADD COLUMN reminderFastRolloverForTesting BOOLEAN DEFAULT FALSE;
|
||||||
|
`,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
4
src/db/tables/contactLabels.ts
Normal file
4
src/db/tables/contactLabels.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export type ContactLabel = {
|
||||||
|
did: string;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
@@ -5,6 +5,8 @@ export type ContactMethod = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type Contact = {
|
export type Contact = {
|
||||||
|
// id is a property in most contacts, but besides sorting from DB we don't need it
|
||||||
|
|
||||||
//
|
//
|
||||||
// When adding a property:
|
// When adding a property:
|
||||||
// - Consider whether it should be added when exporting & sharing contacts, eg. DataExportSection
|
// - Consider whether it should be added when exporting & sharing contacts, eg. DataExportSection
|
||||||
@@ -14,7 +16,8 @@ export type Contact = {
|
|||||||
|
|
||||||
did: string;
|
did: string;
|
||||||
contactMethods?: Array<ContactMethod>;
|
contactMethods?: Array<ContactMethod>;
|
||||||
iViewContent?: boolean;
|
/** When true, hide this contact's activity from the feed. Default (null/undefined) = show. */
|
||||||
|
hideTheirContent?: boolean;
|
||||||
name?: string;
|
name?: string;
|
||||||
nextPubKeyHashB64?: string; // base64-encoded SHA256 hash of next public key
|
nextPubKeyHashB64?: string; // base64-encoded SHA256 hash of next public key
|
||||||
notes?: string;
|
notes?: string;
|
||||||
@@ -45,6 +48,8 @@ export type ContactMaybeWithJsonStrings = Omit<Contact, "contactMethods"> & {
|
|||||||
contactMethods?: string | Array<ContactMethod>;
|
contactMethods?: string | Array<ContactMethod>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ContactWithLabels = Contact & { labels?: Array<string> };
|
||||||
|
|
||||||
export const ContactSchema = {
|
export const ContactSchema = {
|
||||||
contacts: "&did, name", // no need to key by other things
|
contacts: "&did, name", // no need to key by other things
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ export type Settings = {
|
|||||||
notifyingNewActivityTime?: string; // set to their chosen time if they have turned on daily check for new activity via the push server
|
notifyingNewActivityTime?: string; // set to their chosen time if they have turned on daily check for new activity via the push server
|
||||||
notifyingReminderMessage?: string; // set to their chosen message for a daily reminder
|
notifyingReminderMessage?: string; // set to their chosen message for a daily reminder
|
||||||
notifyingReminderTime?: string; // set to their chosen time for a daily reminder
|
notifyingReminderTime?: string; // set to their chosen time for a daily reminder
|
||||||
|
/** Dev/test only: use 10-minute rollover interval for daily reminder (plugin rolloverIntervalMinutes) */
|
||||||
|
reminderFastRolloverForTesting?: boolean;
|
||||||
|
|
||||||
partnerApiServer?: string; // partner server API URL
|
partnerApiServer?: string; // partner server API URL
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export interface AccountSettings {
|
|||||||
notifyingNewActivityTime?: string;
|
notifyingNewActivityTime?: string;
|
||||||
notifyingReminderMessage?: string;
|
notifyingReminderMessage?: string;
|
||||||
notifyingReminderTime?: string;
|
notifyingReminderTime?: string;
|
||||||
|
reminderFastRolloverForTesting?: boolean;
|
||||||
partnerApiServer?: string;
|
partnerApiServer?: string;
|
||||||
profileImageUrl?: string;
|
profileImageUrl?: string;
|
||||||
showContactGivesInline?: boolean;
|
showContactGivesInline?: boolean;
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ export interface PlanActionClaim extends ClaimObject {
|
|||||||
agent?: { identifier: string };
|
agent?: { identifier: string };
|
||||||
description?: string;
|
description?: string;
|
||||||
endTime?: string;
|
endTime?: string;
|
||||||
|
fulfills?: { "@type": string; identifier?: string; lastClaimId?: string };
|
||||||
identifier?: string;
|
identifier?: string;
|
||||||
image?: string;
|
image?: string;
|
||||||
lastClaimId?: string;
|
lastClaimId?: string;
|
||||||
|
|||||||
@@ -55,6 +55,9 @@ export interface AxiosErrorResponse {
|
|||||||
response?: {
|
response?: {
|
||||||
data?: {
|
data?: {
|
||||||
error?: {
|
error?: {
|
||||||
|
// This is in responses from endorser-ch server
|
||||||
|
userMessage?: string;
|
||||||
|
// This is the old approach from endorser-ch server; remove when we've removed all "error: { message: ... }"
|
||||||
message?: string;
|
message?: string;
|
||||||
};
|
};
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user