Initial V3 commit
This commit is contained in:
218
README.md
Normal file
218
README.md
Normal file
@@ -0,0 +1,218 @@
|
||||
# NotifyPulse — Multi-Profile Block Architecture
|
||||
|
||||
NotifyPulse is a Windows notification engine built around **Usecases** and **Blocks**.
|
||||
Each usecase is an isolated profile with its own folder, enabled blocks, and notification list.
|
||||
A tray app runs quietly in the background; a local web UI lets you manage everything.
|
||||
|
||||
---
|
||||
|
||||
## Folder structure
|
||||
|
||||
```
|
||||
NotifyPulse/
|
||||
├── notifier.py ← Main backend
|
||||
├── requirements.txt
|
||||
├── build.bat
|
||||
├── ui/
|
||||
│ └── index.html ← Desktop Web UI (usecase manager)
|
||||
└── pwa/
|
||||
├── index.html ← Mobile PWA (splash + selector + wallpaper)
|
||||
├── manifest.json
|
||||
└── sw.js
|
||||
```
|
||||
|
||||
Config and assets live in `%APPDATA%\Roaming\NotifyPulse\`:
|
||||
|
||||
```
|
||||
NotifyPulse\
|
||||
├── usecases.json ← All usecase definitions
|
||||
├── settings.json ← Global settings
|
||||
│
|
||||
├── icon.png ← Tray icon ──────────────────────────────────┐
|
||||
│ Shown in the Windows system tray. │
|
||||
│ Recommended: 256×256 px, PNG with │ App Icons
|
||||
│ transparency. Falls back to a default │
|
||||
│ coloured circle if missing. │
|
||||
│ │
|
||||
├── icon.ico ← Window / taskbar icon (optional) │
|
||||
│ Used when the app is compiled to .exe. │
|
||||
│ Recommended: multi-size ICO (16/32/48/256). │
|
||||
│ If absent, icon.png is used instead. ┘
|
||||
│
|
||||
├── main.png ← PWA splash background ──────────────────────┐
|
||||
│ Full-screen background shown on the phone │
|
||||
│ PWA while it loads. Recommended: your │ PWA Assets
|
||||
│ phone's native resolution (e.g. 1170×2532 │
|
||||
│ for iPhone 14). JPEG is also accepted. ┘
|
||||
│
|
||||
└── usecases\
|
||||
├── my_profile_abc123\
|
||||
│ ├── wallpapers\ ← Desktop wallpapers for this usecase
|
||||
│ ├── overlay\ ← Overlay images for this usecase
|
||||
│ └── mobile\ ← Mobile wallpapers for this usecase (pushed via PWA)
|
||||
└── ...
|
||||
```
|
||||
|
||||
### Placing app icons
|
||||
|
||||
| File | Where it shows | Recommended size | Notes |
|
||||
|------|----------------|-----------------|-------|
|
||||
| `icon.png` | Windows system tray | 256 × 256 px | PNG, transparent background |
|
||||
| `icon.ico` | Taskbar / .exe (compiled) | Multi-size ICO | 16, 32, 48, 256 px layers |
|
||||
| `main.png` | PWA splash screen | Phone native res | JPEG also accepted |
|
||||
|
||||
Drop these files into `%APPDATA%\Roaming\NotifyPulse\` and restart the app.
|
||||
No config change needed — the app picks them up automatically.
|
||||
|
||||
---
|
||||
|
||||
## Quick start
|
||||
|
||||
```bat
|
||||
pip install -r requirements.txt
|
||||
python notifier.py
|
||||
```
|
||||
|
||||
Or build an `.exe` (bundles everything, no Python required to run):
|
||||
|
||||
```bat
|
||||
build.bat
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Blocks
|
||||
|
||||
Each usecase can enable any combination of blocks:
|
||||
|
||||
| Block | What it does |
|
||||
|---|---|
|
||||
| 🔔 Notifications | Random & scheduled Windows toast notifications |
|
||||
| 🖼️ Wallpaper | Changes desktop wallpaper from usecase's `wallpapers\` folder |
|
||||
| ✨ Overlay | Fullscreen image overlay from usecase's `overlay\` folder |
|
||||
| ⏱️ Timer | Enables time-triggered entries (HH:MM syntax) |
|
||||
| 📱 Mobile Wallpaper | Pushes wallpapers to phone via PWA from usecase's `mobile\` folder |
|
||||
|
||||
---
|
||||
|
||||
## Creating a Usecase
|
||||
|
||||
**Desktop UI** → Usecases → `+ New Usecase`
|
||||
|
||||
1. Give it a name and accent color
|
||||
2. Check the blocks you need
|
||||
3. Set the min/max notification interval (in minutes)
|
||||
4. Add notification entries (supports `| %` weights and `| HH:MM` schedules)
|
||||
5. Click **▶ Activate** to start using it
|
||||
6. Click **📁 Folder** to open the usecase folder and drop in your images
|
||||
|
||||
---
|
||||
|
||||
## notifications.txt syntax (per usecase)
|
||||
|
||||
```
|
||||
# Weighted random
|
||||
Take a break! | 35%
|
||||
Drink water! | 30%
|
||||
change.wallpaper | 20%
|
||||
show.overlay | 15%
|
||||
show.overlay.10 | 10% ← overlay for 10 seconds
|
||||
change.wallpaper.mobile ← push to phone PWA
|
||||
|
||||
# Scheduled (needs Timer block)
|
||||
Morning standup | 09:00
|
||||
End of day | 17:30
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Settings reference
|
||||
|
||||
All settings are saved to `%APPDATA%\Roaming\NotifyPulse\settings.json`
|
||||
and editable in the desktop UI under the **Settings** tab.
|
||||
|
||||
### General
|
||||
|
||||
| Setting | Default | Description |
|
||||
|---|---|---|
|
||||
| `app_name` | `"NotifyPulse"` | Name shown in the header before any usecase is selected |
|
||||
| `hotkey` | `"F13"` | Global hotkey to pause / resume |
|
||||
| `log_max_entries` | `100` | How many log lines to keep in memory |
|
||||
| `startup_toast` | `true` | Show a toast notification when the app starts |
|
||||
| `notify_sound` | `true` | Play sound with toast notifications |
|
||||
| `auto_open_browser` | `true` | Open the web UI automatically on start |
|
||||
| `minimize_to_tray` | `true` | Closing the window hides to tray instead of quitting |
|
||||
| `run_on_startup` | `false` | Register NotifyPulse in Windows startup |
|
||||
| `confirm_delete` | `true` | Show a confirmation dialog before deleting a usecase |
|
||||
| `entry_display_mode` | `"percent"` | Default display for entry weights: `percent` or `chance` |
|
||||
|
||||
### Notifications
|
||||
|
||||
| Setting | Default | Description |
|
||||
|---|---|---|
|
||||
| `notification_duration` | `5` | How many seconds a toast stays on screen (1–30) |
|
||||
|
||||
### Overlay
|
||||
|
||||
| Setting | Default | Description |
|
||||
|---|---|---|
|
||||
| `overlay_duration` | `6` | Default overlay display time in seconds |
|
||||
| `overlay_opacity` | `0.4` | Overlay transparency (0.05 – 1.0) |
|
||||
| `overlay_stretch` | `false` | Stretch to fill (`true`) or letterbox-fit (`false`) |
|
||||
| `overlay_monitor` | `0` | Which monitor: `0` = primary, `-1` = all, `1/2/3` = specific |
|
||||
|
||||
### Wallpaper
|
||||
|
||||
| Setting | Default | Description |
|
||||
|---|---|---|
|
||||
| `wallpaper_fit` | `"fill"` | How the image is scaled: `fill` · `fit` · `stretch` · `center` · `tile` |
|
||||
|
||||
### Network / PWA
|
||||
|
||||
| Setting | Default | Description |
|
||||
|---|---|---|
|
||||
| `web_port` | `5000` | Port for the web UI and PWA |
|
||||
|
||||
---
|
||||
|
||||
## PWA Splash Screen
|
||||
|
||||
Drop a `main.png` (or `.jpg`) into `%APPDATA%\NotifyPulse\` to use it as the
|
||||
full-screen background when the PWA first loads on your phone.
|
||||
|
||||
---
|
||||
|
||||
## PWA Usage
|
||||
|
||||
1. Start NotifyPulse on your PC
|
||||
2. On your phone, open: `http://<your-pc-ip>:5000/pwa`
|
||||
3. **iOS**: Share → Add to Home Screen
|
||||
4. **Android**: ⋮ → Install App
|
||||
|
||||
The PWA shows your splash screen, then a list of usecases to switch between.
|
||||
Selecting one activates that usecase and shows the wallpaper receiver
|
||||
if the Mobile Wallpaper block is enabled.
|
||||
|
||||
---
|
||||
|
||||
## API Reference
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|---|---|---|
|
||||
| GET | `/api/usecases` | List all usecases + blocks |
|
||||
| POST | `/api/usecases` | Create a usecase |
|
||||
| PUT | `/api/usecases/:id` | Update a usecase |
|
||||
| DELETE | `/api/usecases/:id` | Delete a usecase |
|
||||
| POST | `/api/usecases/:id/activate` | Switch active usecase |
|
||||
| POST | `/api/usecases/:id/open_folder` | Open usecase folder in Explorer |
|
||||
| GET | `/api/state` | Full app state |
|
||||
| POST | `/api/pause` | Toggle pause |
|
||||
| POST | `/api/fire_now` | Fire a random entry immediately |
|
||||
| GET/POST | `/api/settings` | Read / update global settings |
|
||||
| GET | `/api/log` | Event log |
|
||||
| POST | `/api/pwa/ping` | PWA heartbeat |
|
||||
| GET | `/api/pwa/app_name` | App info + usecase list for PWA |
|
||||
| GET | `/api/pwa/splash_image` | Serve main.png as base64 |
|
||||
| GET | `/api/pwa/wallpaper` | Poll for pending mobile wallpaper |
|
||||
| POST | `/api/pwa/trigger_wallpaper` | Request a new wallpaper push |
|
||||
| POST | `/api/pwa/activate_usecase` | Activate a usecase from the PWA |
|
||||
49
build.bat
Normal file
49
build.bat
Normal file
@@ -0,0 +1,49 @@
|
||||
@echo off
|
||||
setlocal
|
||||
echo ============================================
|
||||
echo NotifyPulse V3 - Build
|
||||
echo ============================================
|
||||
echo.
|
||||
|
||||
where python >nul 2>&1
|
||||
if errorlevel 1 (
|
||||
echo ERROR: Python not found in PATH
|
||||
pause & exit /b 1
|
||||
)
|
||||
|
||||
echo [1/3] Installing dependencies...
|
||||
pip install -r requirements.txt --quiet
|
||||
if errorlevel 1 ( echo FAILED & pause & exit /b 1 )
|
||||
|
||||
echo [2/3] Generating icon...
|
||||
python make_ico.py
|
||||
if errorlevel 1 ( echo WARNING: icon generation failed, using default )
|
||||
|
||||
echo [3/3] Building executable...
|
||||
pyinstaller --noconfirm --onefile --windowed ^
|
||||
--name "NotifyPulse-V3" ^
|
||||
--icon "%APPDATA%\NotifyPulse\icon.ico" ^
|
||||
--add-data "ui;ui" ^
|
||||
--add-data "pwa;pwa" ^
|
||||
--hidden-import=winotify ^
|
||||
--hidden-import=pystray ^
|
||||
--hidden-import=PIL ^
|
||||
--hidden-import=schedule ^
|
||||
--hidden-import=keyboard ^
|
||||
--hidden-import=flask ^
|
||||
--hidden-import=flask_cors ^
|
||||
notifier.py
|
||||
|
||||
if errorlevel 1 (
|
||||
echo BUILD FAILED
|
||||
pause & exit /b 1
|
||||
)
|
||||
|
||||
echo.
|
||||
echo ============================================
|
||||
echo SUCCESS: dist\NotifyPulse-V3.exe
|
||||
echo ============================================
|
||||
echo.
|
||||
echo Place the exe alongside the ui\ and pwa\ folders.
|
||||
echo Or run with: python notifier.py
|
||||
pause
|
||||
1252
notifier.py
Normal file
1252
notifier.py
Normal file
File diff suppressed because it is too large
Load Diff
522
pwa/index.html
Normal file
522
pwa/index.html
Normal file
@@ -0,0 +1,522 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
|
||||
<meta name="theme-color" content="#08080a">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="apple-mobile-web-app-title" content="NotifyPulse">
|
||||
<link rel="apple-touch-icon" href="icon-192.png">
|
||||
<link rel="manifest" href="/pwa/manifest.json">
|
||||
<title>NotifyPulse</title>
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&family=Space+Mono:wght@400;700&display=swap');
|
||||
:root {
|
||||
--bg: #08080a;
|
||||
--card: #111115;
|
||||
--border: #1e1e28;
|
||||
--accent: #4f8ef7;
|
||||
--green: #22c55e;
|
||||
--red: #ef4444;
|
||||
--yellow: #f59e0b;
|
||||
--text: #e2e2e8;
|
||||
--muted: #6b6b7b;
|
||||
--safe-t: env(safe-area-inset-top, 0px);
|
||||
--safe-b: env(safe-area-inset-bottom, 0px);
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; -webkit-tap-highlight-color: transparent; }
|
||||
html, body { height: 100%; background: var(--bg); overflow: hidden; }
|
||||
body { font-family: 'Space Grotesk', system-ui, sans-serif; color: var(--text); }
|
||||
|
||||
/* ── SCREENS ── */
|
||||
.screen {
|
||||
position: fixed; inset: 0;
|
||||
display: flex; flex-direction: column;
|
||||
align-items: center; justify-content: center;
|
||||
transition: opacity .45s ease, transform .45s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
.screen.hidden { opacity: 0; pointer-events: none; transform: scale(.97); }
|
||||
.screen.gone { display: none; }
|
||||
|
||||
/* ── SHARED BACKGROUND (main.png, persists across screens) ── */
|
||||
#shared-bg {
|
||||
position: fixed; inset: 0; z-index: 0;
|
||||
background-size: contain; background-position: center; background-repeat: no-repeat;
|
||||
transition: opacity 1s ease;
|
||||
opacity: 0;
|
||||
}
|
||||
#shared-bg.loaded { opacity: 1; }
|
||||
|
||||
/* Overlay darkening — heavier on selector, lighter on splash */
|
||||
#shared-bg-overlay {
|
||||
position: fixed; inset: 0; z-index: 1;
|
||||
background: rgba(8,8,10,0);
|
||||
transition: background .6s ease;
|
||||
}
|
||||
#shared-bg-overlay.for-splash { background: rgba(8,8,10,.45); }
|
||||
#shared-bg-overlay.for-selector { background: rgba(8,8,10,.72); backdrop-filter: blur(18px) saturate(.7); -webkit-backdrop-filter: blur(18px) saturate(.7); }
|
||||
#shared-bg-overlay.for-app { background: rgba(8,8,10,.88); }
|
||||
|
||||
/* ── SPLASH ── */
|
||||
#splash { z-index: 10; background: transparent; }
|
||||
.splash-content {
|
||||
position: relative; z-index: 2;
|
||||
display: flex; flex-direction: column; align-items: center; gap: 10px;
|
||||
padding-bottom: 40px;
|
||||
animation: fadeUp .8s ease both; animation-delay: .3s;
|
||||
}
|
||||
@keyframes fadeUp { from { opacity:0; transform:translateY(18px); } to { opacity:1; transform:translateY(0); } }
|
||||
.splash-logo {
|
||||
font-family: 'Space Mono', monospace; font-size: 1.6rem; font-weight: 700;
|
||||
letter-spacing: .1em; color: #fff;
|
||||
text-shadow: 0 2px 40px rgba(79,142,247,.7);
|
||||
}
|
||||
.splash-sub {
|
||||
font-size: .72rem; color: rgba(255,255,255,.45);
|
||||
letter-spacing: .2em; text-transform: uppercase; font-family: 'Space Mono', monospace;
|
||||
}
|
||||
.splash-tap {
|
||||
margin-top: 36px; font-size: .78rem; color: rgba(255,255,255,.3);
|
||||
letter-spacing: .1em; text-transform: uppercase;
|
||||
animation: blink 2.2s infinite;
|
||||
}
|
||||
@keyframes blink { 0%,100%{opacity:.3} 50%{opacity:.65} }
|
||||
|
||||
/* ── SELECTOR ── */
|
||||
#selector {
|
||||
z-index: 5; background: transparent;
|
||||
justify-content: flex-start; align-items: stretch;
|
||||
}
|
||||
.sel-header {
|
||||
position: relative; z-index: 2;
|
||||
padding: calc(var(--safe-t) + 22px) 20px 16px;
|
||||
text-align: center; flex-shrink: 0;
|
||||
border-bottom: 1px solid rgba(255,255,255,.08);
|
||||
}
|
||||
.sel-header h1 {
|
||||
font-family: 'Space Mono', monospace; font-size: 1.05rem; font-weight: 700;
|
||||
color: rgba(255,255,255,.9); letter-spacing: .1em;
|
||||
text-shadow: 0 1px 20px rgba(79,142,247,.5);
|
||||
}
|
||||
.sel-header p { font-size: .76rem; color: rgba(255,255,255,.4); margin-top: 4px; }
|
||||
|
||||
.uc-list {
|
||||
flex: 1; overflow-y: auto; position: relative; z-index: 2;
|
||||
padding: 14px 16px calc(var(--safe-b) + 16px);
|
||||
display: flex; flex-direction: column; gap: 10px;
|
||||
}
|
||||
.uc-item {
|
||||
background: rgba(17,17,21,.75); border: 1px solid rgba(255,255,255,.1);
|
||||
border-radius: 14px; padding: 15px 17px;
|
||||
display: flex; align-items: center; gap: 13px;
|
||||
cursor: pointer; transition: .2s; position: relative; overflow: hidden;
|
||||
backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px);
|
||||
}
|
||||
.uc-item::before {
|
||||
content:''; position:absolute; left:0; top:0; bottom:0; width:4px;
|
||||
background: var(--uc-color, var(--accent));
|
||||
border-radius: 14px 0 0 14px;
|
||||
}
|
||||
.uc-item:active { transform: scale(.98); }
|
||||
.uc-item.active { border-color: rgba(255,255,255,.2); background: rgba(79,142,247,.12); }
|
||||
.uc-dot {
|
||||
width: 38px; height: 38px; border-radius: 11px; flex-shrink: 0;
|
||||
background: var(--uc-color, var(--accent));
|
||||
opacity: .25; display: flex; align-items: center; justify-content: center; font-size: 1.1rem;
|
||||
}
|
||||
.uc-item.active .uc-dot { opacity: .45; }
|
||||
.uc-info { flex: 1; min-width: 0; }
|
||||
.uc-name-big { font-size: .92rem; font-weight: 600; color: rgba(255,255,255,.9); }
|
||||
.uc-blocks-sm { font-size: .63rem; color: rgba(255,255,255,.4); margin-top: 3px; font-family: 'Space Mono', monospace; }
|
||||
.uc-active-tag {
|
||||
font-size: .58rem; font-family: 'Space Mono', monospace; color: var(--green);
|
||||
background: rgba(34,197,94,.12); border: 1px solid rgba(34,197,94,.25);
|
||||
border-radius: 4px; padding: 2px 6px; text-transform: uppercase; letter-spacing: .08em;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.uc-arrow { color: rgba(255,255,255,.25); font-size: .9rem; flex-shrink: 0; }
|
||||
|
||||
/* ── APP ── */
|
||||
#app { z-index: 1; background: transparent; justify-content: flex-start; align-items: stretch; }
|
||||
.app-header {
|
||||
position: relative; z-index: 2;
|
||||
padding: calc(var(--safe-t) + 17px) 17px 13px;
|
||||
flex-shrink: 0; display: flex; align-items: center; justify-content: space-between;
|
||||
border-bottom: 1px solid rgba(255,255,255,.08);
|
||||
}
|
||||
.app-header h1 {
|
||||
font-family: 'Space Mono', monospace; font-size: .92rem; font-weight: 700;
|
||||
color: var(--accent); letter-spacing: .06em;
|
||||
}
|
||||
.conn-badge {
|
||||
display: flex; align-items: center; gap: 6px; padding: 5px 12px;
|
||||
border-radius: 999px; font-size: .7rem; font-weight: 600;
|
||||
border: 1px solid rgba(255,255,255,.1); background: rgba(17,17,21,.6); color: var(--muted);
|
||||
transition: .3s;
|
||||
}
|
||||
.conn-badge .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--muted); }
|
||||
.conn-badge.ok { border-color: rgba(34,197,94,.35); color: var(--green); }
|
||||
.conn-badge.ok .dot { background: var(--green); box-shadow: 0 0 6px var(--green); }
|
||||
.conn-badge.err { border-color: rgba(239,68,68,.35); color: var(--red); }
|
||||
.conn-badge.err .dot { background: var(--red); }
|
||||
|
||||
.app-body {
|
||||
flex: 1; overflow-y: auto; position: relative; z-index: 2;
|
||||
padding: 13px 15px calc(var(--safe-b) + 18px);
|
||||
display: flex; flex-direction: column; gap: 11px;
|
||||
}
|
||||
|
||||
/* Stat row */
|
||||
.stat-row { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||||
.stat { background: rgba(17,17,21,.7); border: 1px solid rgba(255,255,255,.08); border-radius: 10px; padding: 11px 13px; backdrop-filter: blur(4px); }
|
||||
.sl { font-size: 9px; color: var(--muted); font-family: 'Space Mono', monospace; text-transform: uppercase; letter-spacing: .07em; margin-bottom: 3px; }
|
||||
.sv { font-size: .92rem; font-weight: 700; }
|
||||
.sv.green { color: var(--green); }
|
||||
.sv.yellow { color: var(--yellow); }
|
||||
.sv.accent { color: var(--accent); }
|
||||
|
||||
/* Cards */
|
||||
.c { background: rgba(17,17,21,.7); border: 1px solid rgba(255,255,255,.08); border-radius: 13px; padding: 14px; backdrop-filter: blur(4px); }
|
||||
.ct { font-size: .6rem; text-transform: uppercase; letter-spacing: .1em; color: var(--muted); font-family: 'Space Mono', monospace; margin-bottom: 11px; }
|
||||
|
||||
/* Wallpaper section */
|
||||
.wp-tabs { display: flex; gap: 6px; margin-bottom: 10px; }
|
||||
.wp-tab {
|
||||
flex: 1; padding: 8px; border-radius: 8px;
|
||||
border: 1px solid rgba(255,255,255,.1); background: transparent;
|
||||
color: var(--muted); font-size: .74rem; font-weight: 600;
|
||||
cursor: pointer; font-family: inherit; transition: .15s;
|
||||
}
|
||||
.wp-tab.active { border-color: var(--accent); color: var(--accent); background: rgba(79,142,247,.1); }
|
||||
.wp-frame {
|
||||
border-radius: 10px; overflow: hidden; background: rgba(0,0,0,.4);
|
||||
border: 1px solid rgba(255,255,255,.08); aspect-ratio: 9/16;
|
||||
max-height: 250px; display: flex; align-items: center; justify-content: center; position: relative;
|
||||
}
|
||||
.wp-frame img { width: 100%; height: 100%; object-fit: cover; transition: opacity .4s; }
|
||||
.wp-ph { display: flex; flex-direction: column; align-items: center; gap: 7px; color: var(--muted); font-size: .72rem; }
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
width: 100%; padding: 12px; border: none; border-radius: 10px; cursor: pointer;
|
||||
font-family: inherit; font-size: .83rem; font-weight: 600; transition: .15s;
|
||||
display: flex; align-items: center; justify-content: center; gap: 7px;
|
||||
}
|
||||
.btn-accent { background: var(--accent); color: #fff; }
|
||||
.btn-accent:active { filter: brightness(.9); }
|
||||
.btn-ghost { background: rgba(255,255,255,.07); color: var(--text); border: 1px solid rgba(255,255,255,.1); }
|
||||
.btn-ghost:active { background: rgba(255,255,255,.12); }
|
||||
.btn-green { background: rgba(34,197,94,.1); color: var(--green); border: 1px solid rgba(34,197,94,.2); }
|
||||
.btn-green:active { background: rgba(34,197,94,.2); }
|
||||
|
||||
/* Usecase chip at top of app */
|
||||
.uc-chip {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
background: rgba(17,17,21,.7); border: 1px solid rgba(255,255,255,.1);
|
||||
border-radius: 10px; padding: 10px 14px; cursor: pointer;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
.uc-chip-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
|
||||
.uc-chip-name { font-size: .85rem; font-weight: 600; flex: 1; }
|
||||
.uc-chip-change { font-size: .7rem; color: var(--muted); }
|
||||
|
||||
.save-row { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||||
|
||||
/* Toast */
|
||||
.toast {
|
||||
position: fixed; bottom: calc(var(--safe-b) + 80px); left: 50%; transform: translateX(-50%);
|
||||
background: var(--green); color: #000; padding: 9px 20px; border-radius: 999px;
|
||||
font-size: .8rem; font-weight: 700; white-space: nowrap;
|
||||
pointer-events: none; opacity: 0; transition: .3s; z-index: 99;
|
||||
}
|
||||
.toast.show { opacity: 1; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- Persistent blurred background image (behind all screens) -->
|
||||
<div id="shared-bg"></div>
|
||||
<div id="shared-bg-overlay" class="for-splash"></div>
|
||||
|
||||
<!-- SPLASH -->
|
||||
<div class="screen" id="splash" onclick="goToSelector()">
|
||||
<div class="splash-content">
|
||||
<div class="splash-logo" id="splashName">NOTIFYPULSE</div>
|
||||
<div class="splash-sub" id="splashSub">V3</div>
|
||||
<div class="splash-tap">Tap to continue</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SELECTOR -->
|
||||
<div class="screen hidden gone" id="selector">
|
||||
<div class="sel-header">
|
||||
<h1 id="selHeaderName">NOTIFYPULSE</h1>
|
||||
<p>Choose a usecase</p>
|
||||
</div>
|
||||
<div class="uc-list" id="ucList">Loading…</div>
|
||||
</div>
|
||||
|
||||
<!-- APP -->
|
||||
<div class="screen hidden gone" id="app">
|
||||
<div class="app-header">
|
||||
<h1 id="appNameEl">NOTIFYPULSE</h1>
|
||||
<div class="conn-badge" id="connBadge">
|
||||
<div class="dot"></div><span id="connText">Connecting</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="app-body">
|
||||
|
||||
<div class="uc-chip" onclick="goToSelector()">
|
||||
<div class="uc-chip-dot" id="ucChipDot" style="background:var(--accent)"></div>
|
||||
<span class="uc-chip-name" id="ucChipName">—</span>
|
||||
<span class="uc-chip-change">Switch ↗</span>
|
||||
</div>
|
||||
|
||||
<div class="stat-row">
|
||||
<div class="stat"><div class="sl">Status</div><div class="sv" id="a-status">—</div></div>
|
||||
<div class="stat"><div class="sl">Next Fire</div><div class="sv accent" id="a-next">—</div></div>
|
||||
</div>
|
||||
|
||||
<div class="c" id="wpCard">
|
||||
<div class="ct">Mobile Wallpaper</div>
|
||||
<div class="wp-tabs">
|
||||
<button class="wp-tab active" onclick="wpTab('lockscreen',this)">Lock Screen</button>
|
||||
<button class="wp-tab" onclick="wpTab('background',this)">Home Screen</button>
|
||||
</div>
|
||||
<div class="wp-frame">
|
||||
<img id="wpImg" src="" style="opacity:0">
|
||||
<div class="wp-ph" id="wpPh">
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" opacity=".4"><rect x="3" y="3" width="18" height="18" rx="3"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="m21 15-5-5L5 21"/></svg>
|
||||
<span>No wallpaper yet</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="height:8px"></div>
|
||||
<div class="save-row">
|
||||
<button class="btn btn-ghost" onclick="saveWp('lockscreen')">💾 Save Lock</button>
|
||||
<button class="btn btn-ghost" onclick="saveWp('background')">💾 Save Home</button>
|
||||
</div>
|
||||
<div style="height:8px"></div>
|
||||
<button class="btn btn-green" onclick="requestWallpaper()">🔄 Request New Wallpaper</button>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-ghost" onclick="goToSelector()">↩ Back to Usecases</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
<script>
|
||||
const CLIENT_ID = 'pwa_' + Math.random().toString(36).slice(2,11);
|
||||
let appInfo = null, activeUCId = '', allBlocks = [];
|
||||
let wpImages = {lockscreen:null, background:null}, wpCurTab = 'lockscreen';
|
||||
let pingInterval, wallpaperInterval, statInterval;
|
||||
let bgImageLoaded = false;
|
||||
|
||||
// ── Screen transitions ─────────────────────────────────────
|
||||
function show(id) {
|
||||
// Update background overlay style
|
||||
const overlay = document.getElementById('shared-bg-overlay');
|
||||
overlay.className = 'for-' + (id === 'splash' ? 'splash' : id === 'selector' ? 'selector' : 'app');
|
||||
|
||||
document.querySelectorAll('.screen').forEach(s => {
|
||||
s.classList.remove('gone');
|
||||
s.classList.toggle('hidden', s.id !== id);
|
||||
});
|
||||
setTimeout(() => {
|
||||
document.querySelectorAll('.screen.hidden').forEach(s => s.classList.add('gone'));
|
||||
}, 500);
|
||||
}
|
||||
|
||||
function goToSelector() {
|
||||
show('selector');
|
||||
loadSelectorList();
|
||||
}
|
||||
|
||||
function goToApp(ucId) {
|
||||
show('app');
|
||||
setActiveUC(ucId);
|
||||
}
|
||||
|
||||
// ── Init ──────────────────────────────────────────────────
|
||||
async function init() {
|
||||
// Load splash image and keep it as shared background
|
||||
const splashR = await fetchJSON('/api/pwa/splash_image').catch(()=>null);
|
||||
if (splashR && splashR.image) {
|
||||
const bg = document.getElementById('shared-bg');
|
||||
bg.style.backgroundImage = `url('${splashR.image}')`;
|
||||
requestAnimationFrame(() => { bg.classList.add('loaded'); bgImageLoaded = true; });
|
||||
}
|
||||
|
||||
// Load app info
|
||||
const info = await fetchJSON('/api/pwa/app_name').catch(()=>null);
|
||||
if (info) {
|
||||
appInfo = info;
|
||||
activeUCId = info.active || '';
|
||||
allBlocks = info.blocks || [];
|
||||
const settingsName = info.settings_app_name || info.app_name || 'NOTIFYPULSE';
|
||||
document.getElementById('splashName').textContent = settingsName.toUpperCase();
|
||||
document.getElementById('selHeaderName').textContent = settingsName.toUpperCase();
|
||||
document.getElementById('splashSub').textContent = `${(info.usecases||[]).length} USECASE(S)`;
|
||||
}
|
||||
|
||||
startPing();
|
||||
}
|
||||
|
||||
// ── Selector list ─────────────────────────────────────────
|
||||
async function loadSelectorList() {
|
||||
const r = await fetchJSON('/api/pwa/app_name').catch(()=>null);
|
||||
if (!r) { document.getElementById('ucList').innerHTML = '<p style="color:rgba(255,255,255,.3);padding:20px;text-align:center">Connection error</p>'; return; }
|
||||
|
||||
const settingsName = r.settings_app_name || r.app_name || 'NOTIFYPULSE';
|
||||
document.getElementById('selHeaderName').textContent = settingsName.toUpperCase();
|
||||
|
||||
activeUCId = r.active || '';
|
||||
allBlocks = r.blocks || [];
|
||||
const usecases = r.usecases || [];
|
||||
|
||||
document.getElementById('ucList').innerHTML = usecases.length ? usecases.map(uc => {
|
||||
const isActive = uc.id === activeUCId;
|
||||
const blockIcons = (uc.blocks||[]).map(bid => {
|
||||
const b = allBlocks.find(x=>x.id===bid);
|
||||
return b ? b.icon : '';
|
||||
}).join(' ');
|
||||
return `<div class="uc-item ${isActive?'active':''}" style="--uc-color:${uc.color}" onclick="pickUC('${uc.id}','${uc.color}')">
|
||||
<div class="uc-dot" style="background:${uc.color}">${uc.name[0]}</div>
|
||||
<div class="uc-info">
|
||||
<div class="uc-name-big">${escHtml(uc.name)}</div>
|
||||
<div class="uc-blocks-sm">${blockIcons}</div>
|
||||
</div>
|
||||
${isActive ? '<span class="uc-active-tag">Active</span>' : '<span class="uc-arrow">›</span>'}
|
||||
</div>`;
|
||||
}).join('') : '<p style="color:rgba(255,255,255,.3);padding:20px;text-align:center">No usecases yet.<br>Create them in the desktop UI.</p>';
|
||||
}
|
||||
|
||||
async function pickUC(id, color) {
|
||||
// Activate on server
|
||||
await postJSON('/api/pwa/activate_usecase', {usecase_id: id}).catch(()=>null);
|
||||
activeUCId = id;
|
||||
goToApp(id);
|
||||
}
|
||||
|
||||
// ── App view ──────────────────────────────────────────────
|
||||
async function setActiveUC(ucId) {
|
||||
const r = await fetchJSON('/api/pwa/app_name').catch(()=>null);
|
||||
if (!r) return;
|
||||
const uc = (r.usecases||[]).find(u=>u.id===ucId);
|
||||
if (!uc) return;
|
||||
|
||||
document.getElementById('appNameEl').textContent = uc.name.toUpperCase();
|
||||
document.getElementById('ucChipName').textContent = uc.name;
|
||||
document.getElementById('ucChipDot').style.background = uc.color;
|
||||
|
||||
const hasMobile = (uc.blocks||[]).includes('mobile_wallpaper');
|
||||
document.getElementById('wpCard').style.display = hasMobile ? 'block' : 'none';
|
||||
|
||||
clearInterval(statInterval);
|
||||
statInterval = setInterval(updateStats, 3000);
|
||||
updateStats();
|
||||
|
||||
clearInterval(wallpaperInterval);
|
||||
if (hasMobile) wallpaperInterval = setInterval(pollWallpaper, 4000);
|
||||
}
|
||||
|
||||
async function updateStats() {
|
||||
const s = await fetchJSON('/api/state').catch(()=>null);
|
||||
if (!s) return;
|
||||
const paused = s.paused;
|
||||
document.getElementById('a-status').textContent = paused ? '⏸ Paused' : '▶ Running';
|
||||
document.getElementById('a-status').className = 'sv ' + (paused ? 'yellow' : 'green');
|
||||
const next = s.next_fire_at ? Math.max(0, Math.round(s.next_fire_at - Date.now()/1000)) : 0;
|
||||
document.getElementById('a-next').textContent = next > 0 ? `${Math.floor(next/60)}m ${next%60}s` : '—';
|
||||
}
|
||||
|
||||
// ── Wallpaper ─────────────────────────────────────────────
|
||||
async function pollWallpaper() {
|
||||
const r = await fetchJSON(`/api/pwa/wallpaper?client_id=${CLIENT_ID}`).catch(()=>null);
|
||||
if (!r || !r.pending) return;
|
||||
if (r.lockscreen) wpImages.lockscreen = r.lockscreen;
|
||||
if (r.background) wpImages.background = r.background;
|
||||
displayWp(wpCurTab);
|
||||
showToast('New wallpaper received!');
|
||||
}
|
||||
|
||||
async function requestWallpaper() {
|
||||
await postJSON('/api/pwa/trigger_wallpaper', {});
|
||||
showToast('Request sent…');
|
||||
}
|
||||
|
||||
function wpTab(tab, btn) {
|
||||
wpCurTab = tab;
|
||||
document.querySelectorAll('.wp-tab').forEach(b=>b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
displayWp(tab);
|
||||
}
|
||||
|
||||
function displayWp(tab) {
|
||||
const img = document.getElementById('wpImg');
|
||||
const ph = document.getElementById('wpPh');
|
||||
const src = wpImages[tab];
|
||||
if (src) { img.src=src; img.style.opacity='1'; ph.style.display='none'; }
|
||||
else { img.style.opacity='0'; ph.style.display='flex'; }
|
||||
}
|
||||
|
||||
function saveWp(tab) {
|
||||
const src = wpImages[tab];
|
||||
if (!src) { showToast('No image to save'); return; }
|
||||
const a = document.createElement('a');
|
||||
a.href = src; a.download = tab==='lockscreen'?'Lockscreen.jpg':'Background.jpg'; a.click();
|
||||
}
|
||||
|
||||
// ── Ping ──────────────────────────────────────────────────
|
||||
function startPing() {
|
||||
clearInterval(pingInterval);
|
||||
pingInterval = setInterval(doPing, 4000);
|
||||
doPing();
|
||||
}
|
||||
|
||||
async function doPing() {
|
||||
try {
|
||||
await postJSON('/api/pwa/ping', {client_id: CLIENT_ID});
|
||||
document.getElementById('connBadge').className = 'conn-badge ok';
|
||||
document.getElementById('connText').textContent = 'Connected';
|
||||
} catch {
|
||||
document.getElementById('connBadge').className = 'conn-badge err';
|
||||
document.getElementById('connText').textContent = 'Offline';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────
|
||||
async function fetchJSON(url) {
|
||||
const r = await fetch(url);
|
||||
if (!r.ok) throw new Error(r.status);
|
||||
return r.json();
|
||||
}
|
||||
async function postJSON(url, body) {
|
||||
return fetch(url, {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body)}).then(r=>r.json());
|
||||
}
|
||||
function showToast(msg) {
|
||||
const t = document.getElementById('toast');
|
||||
t.textContent = msg; t.classList.add('show');
|
||||
setTimeout(()=>t.classList.remove('show'), 2500);
|
||||
}
|
||||
function escHtml(s) { return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||
|
||||
// Countdown ticker
|
||||
setInterval(()=>{
|
||||
const el = document.getElementById('a-next');
|
||||
if (!el) return;
|
||||
const m = el.textContent.match(/(\d+)m (\d+)s/);
|
||||
if (!m) return;
|
||||
let t = parseInt(m[1])*60 + parseInt(m[2]) - 1;
|
||||
if (t < 0) t = 0;
|
||||
el.textContent = `${Math.floor(t/60)}m ${t%60}s`;
|
||||
}, 1000);
|
||||
|
||||
if ('serviceWorker' in navigator) navigator.serviceWorker.register('/pwa/sw.js');
|
||||
|
||||
init();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
15
pwa/manifest.json
Normal file
15
pwa/manifest.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "NotifyPulse",
|
||||
"short_name": "NotifyPulse",
|
||||
"description": "Multi-usecase notification and wallpaper manager",
|
||||
"start_url": "/pwa/",
|
||||
"scope": "/pwa/",
|
||||
"display": "standalone",
|
||||
"background_color": "#08080a",
|
||||
"theme_color": "#08080a",
|
||||
"orientation": "portrait",
|
||||
"icons": [
|
||||
{ "src": "icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" },
|
||||
{ "src": "icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" }
|
||||
]
|
||||
}
|
||||
26
pwa/sw.js
Normal file
26
pwa/sw.js
Normal file
@@ -0,0 +1,26 @@
|
||||
const CACHE = 'notifypulse-v3';
|
||||
const PRECACHE = ['/pwa/', '/pwa/index.html', '/pwa/manifest.json'];
|
||||
|
||||
self.addEventListener('install', e => {
|
||||
e.waitUntil(caches.open(CACHE).then(c => c.addAll(PRECACHE)));
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener('activate', e => {
|
||||
e.waitUntil(
|
||||
caches.keys().then(keys =>
|
||||
Promise.all(keys.filter(k => k !== CACHE).map(k => caches.delete(k)))
|
||||
)
|
||||
);
|
||||
self.clients.claim();
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', e => {
|
||||
const url = new URL(e.request.url);
|
||||
// Always hit network for API calls
|
||||
if (url.pathname.startsWith('/api/')) return;
|
||||
// Cache-first for PWA assets
|
||||
e.respondWith(
|
||||
caches.match(e.request).then(r => r || fetch(e.request))
|
||||
);
|
||||
});
|
||||
8
requirements.txt
Normal file
8
requirements.txt
Normal file
@@ -0,0 +1,8 @@
|
||||
flask>=3.0
|
||||
flask-cors>=4.0
|
||||
winotify>=1.1
|
||||
pystray>=0.19
|
||||
Pillow>=10.0
|
||||
keyboard>=0.13
|
||||
schedule>=1.2
|
||||
pyinstaller>=6.0
|
||||
1080
ui/index.html
Normal file
1080
ui/index.html
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user