Compare commits
5 Commits
49fd1dfedf
...
54478b1c97
Author | SHA1 | Date |
---|---|---|
|
54478b1c97 | 1 week ago |
|
a625adecf4 | 1 week ago |
|
425189d933 | 1 week ago |
|
ed8db53612 | 1 week ago |
|
6213235a16 | 1 week ago |
35 changed files with 8166 additions and 5620 deletions
@ -0,0 +1,20 @@ |
|||
/* eslint-env node */ |
|||
require('@rushstack/eslint-patch/modern-module-resolution') |
|||
|
|||
module.exports = { |
|||
root: true, |
|||
'extends': [ |
|||
'plugin:vue/vue3-essential', |
|||
'eslint:recommended', |
|||
'@vue/eslint-config-typescript' |
|||
], |
|||
parserOptions: { |
|||
ecmaVersion: 'latest' |
|||
}, |
|||
rules: { |
|||
'vue/multi-word-component-names': 'off', |
|||
'@typescript-eslint/no-unused-vars': ['error', { 'argsIgnorePattern': '^_' }], |
|||
'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off', |
|||
'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off' |
|||
} |
|||
} |
@ -0,0 +1,329 @@ |
|||
# Daily Notification Test App - Vue 3 |
|||
|
|||
A modern Vue 3 + Vite + Capacitor test application for the Daily Notification Plugin, built with vue-facing-decorator for TypeScript class-based components. |
|||
|
|||
## 🚀 Features |
|||
|
|||
- **Vue 3** with Composition API and TypeScript |
|||
- **Vite** for fast development and building |
|||
- **vue-facing-decorator** for class-based components |
|||
- **Pinia** for state management |
|||
- **Vue Router** for navigation |
|||
- **Capacitor** for native Android functionality |
|||
- **Modern UI** with glassmorphism design |
|||
- **Responsive** design for mobile and desktop |
|||
|
|||
## 📱 App Structure |
|||
|
|||
``` |
|||
src/ |
|||
├── components/ # Reusable Vue components |
|||
│ ├── cards/ # Card components (ActionCard, StatusCard, etc.) |
|||
│ ├── items/ # List item components |
|||
│ ├── layout/ # Layout components (Header, Footer) |
|||
│ └── ui/ # UI components (Loading, Error, etc.) |
|||
├── stores/ # Pinia stores |
|||
│ ├── app.ts # Global app state |
|||
│ └── notifications.ts # Notification management |
|||
├── views/ # Page components |
|||
│ ├── HomeView.vue # Dashboard |
|||
│ ├── ScheduleView.vue # Schedule notifications |
|||
│ ├── NotificationsView.vue # Manage notifications |
|||
│ ├── StatusView.vue # System status |
|||
│ ├── HistoryView.vue # Notification history |
|||
│ └── SettingsView.vue # App settings |
|||
├── router/ # Vue Router configuration |
|||
├── types/ # TypeScript type definitions |
|||
└── utils/ # Utility functions |
|||
``` |
|||
|
|||
## 🛠️ Development |
|||
|
|||
### Prerequisites |
|||
|
|||
- Node.js 18+ |
|||
- npm or yarn |
|||
- Android Studio (for Android development) |
|||
- Capacitor CLI |
|||
|
|||
### Installation |
|||
|
|||
```bash |
|||
# Install dependencies |
|||
npm install |
|||
|
|||
# Start development server |
|||
npm run dev |
|||
|
|||
# Build for production |
|||
npm run build |
|||
|
|||
# Type checking |
|||
npm run type-check |
|||
|
|||
# Linting |
|||
npm run lint |
|||
``` |
|||
|
|||
### Android Development |
|||
|
|||
```bash |
|||
# Sync with Capacitor |
|||
npm run sync |
|||
|
|||
# Open Android Studio |
|||
npm run open |
|||
|
|||
# Run on Android device/emulator |
|||
npm run android |
|||
``` |
|||
|
|||
## 🎨 UI Components |
|||
|
|||
### Class-Based Components |
|||
|
|||
All components use vue-facing-decorator for TypeScript class-based syntax: |
|||
|
|||
```typescript |
|||
@Component({ |
|||
components: { |
|||
ChildComponent |
|||
} |
|||
}) |
|||
export default class MyComponent extends Vue { |
|||
@Prop() title!: string |
|||
|
|||
private data = ref('') |
|||
|
|||
get computedValue(): string { |
|||
return this.data.value.toUpperCase() |
|||
} |
|||
|
|||
private handleClick(): void { |
|||
// Handle click |
|||
} |
|||
} |
|||
``` |
|||
|
|||
### State Management |
|||
|
|||
Uses Pinia stores for reactive state management: |
|||
|
|||
```typescript |
|||
// stores/notifications.ts |
|||
export const useNotificationsStore = defineStore('notifications', () => { |
|||
const scheduledNotifications = ref<ScheduledNotification[]>([]) |
|||
|
|||
async function scheduleNotification(options: ScheduleOptions): Promise<void> { |
|||
// Schedule logic |
|||
} |
|||
|
|||
return { |
|||
scheduledNotifications, |
|||
scheduleNotification |
|||
} |
|||
}) |
|||
``` |
|||
|
|||
## 📊 Features |
|||
|
|||
### Dashboard (Home) |
|||
- Quick action cards |
|||
- System status overview |
|||
- Next scheduled notification |
|||
- Recent activity feed |
|||
|
|||
### Schedule Notifications |
|||
- Time picker |
|||
- Title and message inputs |
|||
- Sound and priority options |
|||
- URL support for deep linking |
|||
- Quick schedule presets |
|||
|
|||
### Notification Management |
|||
- View all scheduled notifications |
|||
- Cancel notifications |
|||
- Status indicators |
|||
|
|||
### System Status |
|||
- Permission checks |
|||
- Channel status |
|||
- Exact alarm settings |
|||
- Platform information |
|||
- Test notification functionality |
|||
|
|||
### History |
|||
- Delivered notification history |
|||
- Click and dismiss tracking |
|||
- Time-based filtering |
|||
|
|||
## 🔧 Configuration |
|||
|
|||
### Capacitor Config |
|||
|
|||
```typescript |
|||
// capacitor.config.ts |
|||
const config: CapacitorConfig = { |
|||
appId: 'com.timesafari.dailynotification.androidtest', |
|||
appName: 'Daily Notification Test - Vue 3', |
|||
webDir: 'dist', |
|||
plugins: { |
|||
DailyNotification: { |
|||
storage: 'shared', |
|||
ttlSeconds: 1800, |
|||
prefetchLeadMinutes: 15, |
|||
enableETagSupport: true, |
|||
enableErrorHandling: true, |
|||
enablePerformanceOptimization: true |
|||
} |
|||
} |
|||
} |
|||
``` |
|||
|
|||
### Vite Config |
|||
|
|||
```typescript |
|||
// vite.config.ts |
|||
export default defineConfig({ |
|||
plugins: [vue()], |
|||
build: { |
|||
outDir: 'dist', |
|||
sourcemap: true |
|||
}, |
|||
resolve: { |
|||
alias: { |
|||
'@': resolve(__dirname, 'src') |
|||
} |
|||
} |
|||
}) |
|||
``` |
|||
|
|||
## 🎯 Testing the Plugin |
|||
|
|||
1. **Start Development Server** |
|||
```bash |
|||
npm run dev |
|||
``` |
|||
|
|||
2. **Build and Sync** |
|||
```bash |
|||
npm run build |
|||
npm run sync |
|||
``` |
|||
|
|||
3. **Run on Android** |
|||
```bash |
|||
npm run android |
|||
``` |
|||
|
|||
4. **Test Features** |
|||
- Schedule notifications |
|||
- Check system status |
|||
- View notification history |
|||
- Test clickable notifications |
|||
|
|||
## 📱 Native Features |
|||
|
|||
- **Notification Scheduling** - Schedule daily notifications |
|||
- **Permission Management** - Check and request permissions |
|||
- **Status Monitoring** - Real-time system status |
|||
- **Deep Linking** - URL support in notifications |
|||
- **Background Processing** - WorkManager integration |
|||
|
|||
## 🎨 Design System |
|||
|
|||
### Colors |
|||
- Primary: Linear gradient (purple to blue) |
|||
- Success: #4caf50 |
|||
- Warning: #ff9800 |
|||
- Error: #f44336 |
|||
- Info: #2196f3 |
|||
|
|||
### Typography |
|||
- Headers: Bold, white with text-shadow |
|||
- Body: Regular, rgba white |
|||
- Code: Courier New monospace |
|||
|
|||
### Components |
|||
- Glassmorphism design with backdrop-filter |
|||
- Rounded corners (8px, 12px, 16px) |
|||
- Smooth transitions and hover effects |
|||
- Responsive grid layouts |
|||
|
|||
## 🚀 Production Build |
|||
|
|||
```bash |
|||
# Build for production |
|||
npm run build |
|||
|
|||
# Preview production build |
|||
npm run preview |
|||
|
|||
# Sync with Capacitor |
|||
npm run sync |
|||
|
|||
# Build Android APK |
|||
npm run android |
|||
``` |
|||
|
|||
## 📝 Scripts |
|||
|
|||
- `npm run dev` - Start development server |
|||
- `npm run build` - Build for production |
|||
- `npm run preview` - Preview production build |
|||
- `npm run android` - Run on Android |
|||
- `npm run sync` - Sync with Capacitor |
|||
- `npm run open` - Open Android Studio |
|||
- `npm run lint` - Run ESLint |
|||
- `npm run type-check` - TypeScript type checking |
|||
|
|||
## 🔍 Debugging |
|||
|
|||
### Vue DevTools |
|||
Install Vue DevTools browser extension for component inspection. |
|||
|
|||
### Capacitor Logs |
|||
```bash |
|||
# Android logs |
|||
adb logcat | grep -i "daily\|notification" |
|||
|
|||
# Capacitor logs |
|||
npx cap run android --livereload --external |
|||
``` |
|||
|
|||
### TypeScript |
|||
Enable strict mode in `tsconfig.json` for better type checking. |
|||
|
|||
## 📚 Dependencies |
|||
|
|||
### Core |
|||
- Vue 3.4+ |
|||
- Vite 5.0+ |
|||
- TypeScript 5.3+ |
|||
- Capacitor 5.0+ |
|||
|
|||
### UI & State |
|||
- vue-facing-decorator 3.0+ |
|||
- Pinia 2.1+ |
|||
- Vue Router 4.2+ |
|||
|
|||
### Development |
|||
- ESLint + TypeScript configs |
|||
- Vue TSC for type checking |
|||
- Modern module resolution |
|||
|
|||
## 🤝 Contributing |
|||
|
|||
1. Follow Vue 3 + TypeScript best practices |
|||
2. Use vue-facing-decorator for class components |
|||
3. Maintain responsive design |
|||
4. Add proper TypeScript types |
|||
5. Test on both web and Android |
|||
|
|||
## 📄 License |
|||
|
|||
MIT License - see LICENSE file for details. |
|||
|
|||
--- |
|||
|
|||
**Built with ❤️ using Vue 3 + Vite + Capacitor** |
@ -0,0 +1,90 @@ |
|||
/** |
|||
* Environment Type Declarations |
|||
* |
|||
* @author Matthew Raymer |
|||
* @version 1.0.0 |
|||
*/ |
|||
|
|||
/// <reference types="vite/client" />
|
|||
|
|||
declare module '*.vue' { |
|||
import type { DefineComponent } from 'vue' |
|||
const component: DefineComponent<{}, {}, any> |
|||
export default component |
|||
} |
|||
|
|||
// Capacitor plugin declarations
|
|||
declare global { |
|||
interface Window { |
|||
DailyNotification: { |
|||
scheduleDailyNotification: (options: { |
|||
time: string |
|||
title?: string |
|||
body?: string |
|||
sound?: boolean |
|||
priority?: string |
|||
url?: string |
|||
}) => Promise<void> |
|||
|
|||
scheduleDailyReminder: (options: { |
|||
id: string |
|||
title: string |
|||
body: string |
|||
time: string |
|||
sound?: boolean |
|||
vibration?: boolean |
|||
priority?: string |
|||
repeatDaily?: boolean |
|||
timezone?: string |
|||
}) => Promise<void> |
|||
|
|||
cancelDailyReminder: (options: { |
|||
reminderId: string |
|||
}) => Promise<void> |
|||
|
|||
updateDailyReminder: (options: { |
|||
reminderId: string |
|||
title?: string |
|||
body?: string |
|||
time?: string |
|||
sound?: boolean |
|||
vibration?: boolean |
|||
priority?: string |
|||
repeatDaily?: boolean |
|||
timezone?: string |
|||
}) => Promise<void> |
|||
|
|||
getLastNotification: () => Promise<{ |
|||
id: string |
|||
title: string |
|||
body: string |
|||
scheduledTime: number |
|||
deliveredAt: number |
|||
}> |
|||
|
|||
checkStatus: () => Promise<{ |
|||
canScheduleNow: boolean |
|||
postNotificationsGranted: boolean |
|||
channelEnabled: boolean |
|||
channelImportance: number |
|||
channelId: string |
|||
exactAlarmsGranted: boolean |
|||
exactAlarmsSupported: boolean |
|||
androidVersion: number |
|||
nextScheduledAt: number |
|||
}> |
|||
|
|||
checkChannelStatus: () => Promise<{ |
|||
enabled: boolean |
|||
importance: number |
|||
id: string |
|||
}> |
|||
|
|||
openChannelSettings: () => Promise<void> |
|||
|
|||
openExactAlarmSettings: () => Promise<void> |
|||
} |
|||
} |
|||
} |
|||
|
|||
export {} |
@ -0,0 +1,73 @@ |
|||
<!DOCTYPE html> |
|||
<html lang="en"> |
|||
<head> |
|||
<meta charset="UTF-8" /> |
|||
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> |
|||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" /> |
|||
<title>Daily Notification Test - Vue 3</title> |
|||
|
|||
<!-- Capacitor meta tags --> |
|||
<meta name="color-scheme" content="light dark" /> |
|||
<meta name="format-detection" content="telephone=no" /> |
|||
<meta name="msapplication-tap-highlight" content="no" /> |
|||
|
|||
<!-- PWA meta tags --> |
|||
<meta name="theme-color" content="#1976d2" /> |
|||
<meta name="apple-mobile-web-app-capable" content="yes" /> |
|||
<meta name="apple-mobile-web-app-status-bar-style" content="default" /> |
|||
<meta name="apple-mobile-web-app-title" content="Daily Notification Test" /> |
|||
|
|||
<!-- Styles --> |
|||
<style> |
|||
body { |
|||
margin: 0; |
|||
padding: 0; |
|||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', |
|||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', |
|||
sans-serif; |
|||
-webkit-font-smoothing: antialiased; |
|||
-moz-osx-font-smoothing: grayscale; |
|||
background-color: #f5f5f5; |
|||
} |
|||
|
|||
#app { |
|||
min-height: 100vh; |
|||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); |
|||
} |
|||
|
|||
/* Loading spinner */ |
|||
.loading { |
|||
display: flex; |
|||
justify-content: center; |
|||
align-items: center; |
|||
height: 100vh; |
|||
color: white; |
|||
font-size: 18px; |
|||
} |
|||
|
|||
.spinner { |
|||
width: 40px; |
|||
height: 40px; |
|||
border: 4px solid rgba(255, 255, 255, 0.3); |
|||
border-top: 4px solid white; |
|||
border-radius: 50%; |
|||
animation: spin 1s linear infinite; |
|||
margin-right: 16px; |
|||
} |
|||
|
|||
@keyframes spin { |
|||
0% { transform: rotate(0deg); } |
|||
100% { transform: rotate(360deg); } |
|||
} |
|||
</style> |
|||
</head> |
|||
<body> |
|||
<div id="app"> |
|||
<div class="loading"> |
|||
<div class="spinner"></div> |
|||
Loading Daily Notification Test App... |
|||
</div> |
|||
</div> |
|||
<script type="module" src="/src/main.ts"></script> |
|||
</body> |
|||
</html> |
File diff suppressed because it is too large
@ -1,29 +1,42 @@ |
|||
{ |
|||
"name": "daily-notification-android-test", |
|||
"version": "1.0.0", |
|||
"description": "Minimal Android test app for Daily Notification Plugin", |
|||
"main": "index.js", |
|||
"description": "Vue 3 + Vite + Capacitor test app for Daily Notification Plugin", |
|||
"type": "module", |
|||
"scripts": { |
|||
"build": "webpack --mode=production", |
|||
"dev": "webpack serve --mode=development", |
|||
"dev": "vite", |
|||
"build": "vite build", |
|||
"preview": "vite preview", |
|||
"android": "npx cap run android", |
|||
"sync": "npx cap sync android", |
|||
"open": "npx cap open android" |
|||
"open": "npx cap open android", |
|||
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore", |
|||
"type-check": "vue-tsc --noEmit" |
|||
}, |
|||
"keywords": ["capacitor", "android", "notifications", "test"], |
|||
"keywords": ["capacitor", "android", "notifications", "test", "vue3", "vite", "typescript"], |
|||
"author": "Matthew Raymer", |
|||
"license": "MIT", |
|||
"dependencies": { |
|||
"@capacitor/core": "^5.0.0", |
|||
"@capacitor/android": "^5.0.0", |
|||
"@capacitor/cli": "^5.0.0" |
|||
"@capacitor/cli": "^5.0.0", |
|||
"vue": "^3.4.0", |
|||
"vue-router": "^4.2.0", |
|||
"pinia": "^2.1.0", |
|||
"vue-facing-decorator": "^3.0.0" |
|||
}, |
|||
"devDependencies": { |
|||
"webpack": "^5.88.0", |
|||
"webpack-cli": "^5.1.0", |
|||
"webpack-dev-server": "^4.15.0", |
|||
"html-webpack-plugin": "^5.5.0", |
|||
"typescript": "^5.0.0", |
|||
"ts-loader": "^9.4.0" |
|||
"@capacitor/cli": "^5.0.0", |
|||
"@types/node": "^20.0.0", |
|||
"@typescript-eslint/eslint-plugin": "^6.0.0", |
|||
"@typescript-eslint/parser": "^6.0.0", |
|||
"@vitejs/plugin-vue": "^4.5.0", |
|||
"@vue/eslint-config-typescript": "^12.0.0", |
|||
"@vue/tsconfig": "^0.5.0", |
|||
"eslint": "^8.0.0", |
|||
"eslint-plugin-vue": "^9.0.0", |
|||
"typescript": "~5.3.0", |
|||
"vite": "^5.0.0", |
|||
"vue-tsc": "^1.8.0" |
|||
} |
|||
} |
@ -0,0 +1,147 @@ |
|||
<!-- |
|||
/** |
|||
* Main App Component |
|||
* |
|||
* Vue 3 + vue-facing-decorator + TypeScript |
|||
* |
|||
* @author Matthew Raymer |
|||
* @version 1.0.0 |
|||
*/ |
|||
--> |
|||
|
|||
<template> |
|||
<div id="app" class="app-container"> |
|||
<!-- Header --> |
|||
<AppHeader /> |
|||
|
|||
<!-- Main Content --> |
|||
<main class="main-content"> |
|||
<router-view /> |
|||
</main> |
|||
|
|||
<!-- Footer --> |
|||
<AppFooter /> |
|||
|
|||
<!-- Global Loading Overlay --> |
|||
<LoadingOverlay v-if="isLoading" /> |
|||
|
|||
<!-- Global Error Dialog --> |
|||
<ErrorDialog |
|||
v-if="errorMessage" |
|||
:message="errorMessage" |
|||
@close="clearError" |
|||
/> |
|||
</div> |
|||
</template> |
|||
|
|||
<script lang="ts"> |
|||
import { Component, Vue } from 'vue-facing-decorator' |
|||
import { Capacitor } from '@capacitor/core' |
|||
import AppHeader from '@/components/layout/AppHeader.vue' |
|||
import AppFooter from '@/components/layout/AppFooter.vue' |
|||
import LoadingOverlay from '@/components/ui/LoadingOverlay.vue' |
|||
import ErrorDialog from '@/components/ui/ErrorDialog.vue' |
|||
import { useAppStore } from '@/stores/app' |
|||
|
|||
@Component({ |
|||
components: { |
|||
AppHeader, |
|||
AppFooter, |
|||
LoadingOverlay, |
|||
ErrorDialog |
|||
} |
|||
}) |
|||
export default class App extends Vue { |
|||
private appStore = useAppStore() |
|||
|
|||
get isLoading(): boolean { |
|||
return this.appStore.isLoading |
|||
} |
|||
|
|||
get errorMessage(): string | null { |
|||
return this.appStore.errorMessage |
|||
} |
|||
|
|||
mounted(): void { |
|||
this.initializeApp() |
|||
} |
|||
|
|||
private async initializeApp(): Promise<void> { |
|||
try { |
|||
this.appStore.setLoading(true) |
|||
|
|||
// Initialize Capacitor plugins |
|||
await this.initializeCapacitor() |
|||
|
|||
// Check notification permissions |
|||
await this.checkNotificationPermissions() |
|||
|
|||
console.log('✅ App initialized successfully') |
|||
|
|||
} catch (error) { |
|||
console.error('❌ App initialization failed:', error) |
|||
this.appStore.setError('Failed to initialize app: ' + (error as Error).message) |
|||
} finally { |
|||
this.appStore.setLoading(false) |
|||
} |
|||
} |
|||
|
|||
private async initializeCapacitor(): Promise<void> { |
|||
if (Capacitor.isNativePlatform()) { |
|||
console.log('📱 Initializing Capacitor for native platform') |
|||
|
|||
// Check if DailyNotification plugin is available |
|||
if (!window.DailyNotification) { |
|||
console.warn('⚠️ DailyNotification plugin not loaded - buttons will show error messages') |
|||
this.appStore.setError('DailyNotification plugin not loaded. Please restart the app to enable full functionality.') |
|||
return |
|||
} |
|||
|
|||
console.log('✅ DailyNotification plugin available') |
|||
} else { |
|||
console.log('🌐 Running in web mode - DailyNotification plugin not available') |
|||
} |
|||
} |
|||
|
|||
private async checkNotificationPermissions(): Promise<void> { |
|||
if (Capacitor.isNativePlatform() && window.DailyNotification) { |
|||
try { |
|||
const status = await window.DailyNotification.checkStatus() |
|||
console.log('📊 Notification Status:', status) |
|||
|
|||
if (!status.canScheduleNow) { |
|||
console.warn('⚠️ Notifications not fully configured') |
|||
} |
|||
} catch (error) { |
|||
console.warn('⚠️ Could not check notification status:', error) |
|||
} |
|||
} |
|||
} |
|||
|
|||
clearError(): void { |
|||
this.appStore.clearError() |
|||
} |
|||
} |
|||
</script> |
|||
|
|||
<style scoped> |
|||
.app-container { |
|||
min-height: 100vh; |
|||
display: flex; |
|||
flex-direction: column; |
|||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); |
|||
} |
|||
|
|||
.main-content { |
|||
flex: 1; |
|||
padding: 16px; |
|||
overflow-y: auto; |
|||
} |
|||
|
|||
/* Responsive design */ |
|||
@media (max-width: 768px) { |
|||
.main-content { |
|||
padding: 8px; |
|||
} |
|||
} |
|||
</style> |
@ -0,0 +1,163 @@ |
|||
<!-- |
|||
/** |
|||
* Action Card Component |
|||
* |
|||
* Reusable card component for quick actions |
|||
* |
|||
* @author Matthew Raymer |
|||
* @version 1.0.0 |
|||
*/ |
|||
--> |
|||
|
|||
<template> |
|||
<div |
|||
class="action-card" |
|||
:class="{ 'loading': loading, 'disabled': disabled }" |
|||
@click="handleClick" |
|||
> |
|||
<div class="card-content"> |
|||
<div class="icon-container"> |
|||
<span class="icon">{{ icon }}</span> |
|||
</div> |
|||
<div class="text-content"> |
|||
<h3 class="title">{{ title }}</h3> |
|||
<p class="description">{{ description }}</p> |
|||
</div> |
|||
<div class="action-indicator"> |
|||
<span v-if="loading" class="spinner"></span> |
|||
<span v-else class="arrow">→</span> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</template> |
|||
|
|||
<script lang="ts"> |
|||
import { Component, Vue, Prop } from 'vue-facing-decorator' |
|||
|
|||
@Component |
|||
export default class ActionCard extends Vue { |
|||
@Prop({ required: true }) icon!: string |
|||
@Prop({ required: true }) title!: string |
|||
@Prop({ required: true }) description!: string |
|||
@Prop({ default: false }) loading!: boolean |
|||
@Prop({ default: false }) disabled!: boolean |
|||
|
|||
handleClick(): void { |
|||
if (!this.loading && !this.disabled) { |
|||
this.$emit('click') |
|||
} |
|||
} |
|||
} |
|||
</script> |
|||
|
|||
<style scoped> |
|||
.action-card { |
|||
background: rgba(255, 255, 255, 0.1); |
|||
border-radius: 12px; |
|||
padding: 20px; |
|||
cursor: pointer; |
|||
transition: all 0.3s ease; |
|||
backdrop-filter: blur(10px); |
|||
border: 1px solid rgba(255, 255, 255, 0.2); |
|||
} |
|||
|
|||
.action-card:hover:not(.disabled) { |
|||
background: rgba(255, 255, 255, 0.15); |
|||
transform: translateY(-2px); |
|||
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.2); |
|||
} |
|||
|
|||
.action-card:active:not(.disabled) { |
|||
transform: translateY(0); |
|||
} |
|||
|
|||
.action-card.loading { |
|||
cursor: wait; |
|||
opacity: 0.7; |
|||
} |
|||
|
|||
.action-card.disabled { |
|||
cursor: not-allowed; |
|||
opacity: 0.5; |
|||
} |
|||
|
|||
.card-content { |
|||
display: flex; |
|||
align-items: center; |
|||
gap: 16px; |
|||
} |
|||
|
|||
.icon-container { |
|||
flex-shrink: 0; |
|||
} |
|||
|
|||
.icon { |
|||
font-size: 2rem; |
|||
display: block; |
|||
} |
|||
|
|||
.text-content { |
|||
flex: 1; |
|||
min-width: 0; |
|||
} |
|||
|
|||
.title { |
|||
font-size: 1.1rem; |
|||
font-weight: 600; |
|||
color: white; |
|||
margin: 0 0 4px 0; |
|||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3); |
|||
} |
|||
|
|||
.description { |
|||
font-size: 0.9rem; |
|||
color: rgba(255, 255, 255, 0.8); |
|||
margin: 0; |
|||
line-height: 1.4; |
|||
} |
|||
|
|||
.action-indicator { |
|||
flex-shrink: 0; |
|||
color: rgba(255, 255, 255, 0.7); |
|||
font-size: 1.2rem; |
|||
font-weight: bold; |
|||
} |
|||
|
|||
.spinner { |
|||
display: inline-block; |
|||
width: 16px; |
|||
height: 16px; |
|||
border: 2px solid rgba(255, 255, 255, 0.3); |
|||
border-top: 2px solid white; |
|||
border-radius: 50%; |
|||
animation: spin 1s linear infinite; |
|||
} |
|||
|
|||
@keyframes spin { |
|||
0% { transform: rotate(0deg); } |
|||
100% { transform: rotate(360deg); } |
|||
} |
|||
|
|||
/* Responsive design */ |
|||
@media (max-width: 768px) { |
|||
.action-card { |
|||
padding: 16px; |
|||
} |
|||
|
|||
.card-content { |
|||
gap: 12px; |
|||
} |
|||
|
|||
.icon { |
|||
font-size: 1.5rem; |
|||
} |
|||
|
|||
.title { |
|||
font-size: 1rem; |
|||
} |
|||
|
|||
.description { |
|||
font-size: 0.85rem; |
|||
} |
|||
} |
|||
</style> |
@ -0,0 +1,95 @@ |
|||
<!-- |
|||
/** |
|||
* Info Card Component |
|||
* |
|||
* Simple information display card |
|||
* |
|||
* @author Matthew Raymer |
|||
* @version 1.0.0 |
|||
*/ |
|||
--> |
|||
|
|||
<template> |
|||
<div class="info-card"> |
|||
<div class="info-header"> |
|||
<span class="info-icon">{{ icon }}</span> |
|||
<h3 class="info-title">{{ title }}</h3> |
|||
</div> |
|||
<div class="info-content"> |
|||
<p class="info-value">{{ value }}</p> |
|||
</div> |
|||
</div> |
|||
</template> |
|||
|
|||
<script lang="ts"> |
|||
import { Component, Vue, Prop } from 'vue-facing-decorator' |
|||
|
|||
@Component |
|||
export default class InfoCard extends Vue { |
|||
@Prop({ required: true }) title!: string |
|||
@Prop({ required: true }) value!: string |
|||
@Prop({ required: true }) icon!: string |
|||
} |
|||
</script> |
|||
|
|||
<style scoped> |
|||
.info-card { |
|||
background: rgba(255, 255, 255, 0.1); |
|||
border-radius: 12px; |
|||
padding: 16px; |
|||
backdrop-filter: blur(10px); |
|||
border: 1px solid rgba(255, 255, 255, 0.2); |
|||
transition: all 0.3s ease; |
|||
} |
|||
|
|||
.info-card:hover { |
|||
background: rgba(255, 255, 255, 0.15); |
|||
} |
|||
|
|||
.info-header { |
|||
display: flex; |
|||
align-items: center; |
|||
gap: 8px; |
|||
margin-bottom: 12px; |
|||
} |
|||
|
|||
.info-icon { |
|||
font-size: 1.2rem; |
|||
} |
|||
|
|||
.info-title { |
|||
font-size: 0.9rem; |
|||
font-weight: 600; |
|||
color: rgba(255, 255, 255, 0.9); |
|||
margin: 0; |
|||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3); |
|||
} |
|||
|
|||
.info-content { |
|||
margin: 0; |
|||
} |
|||
|
|||
.info-value { |
|||
font-size: 1rem; |
|||
font-weight: 500; |
|||
color: white; |
|||
margin: 0; |
|||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3); |
|||
word-break: break-word; |
|||
} |
|||
|
|||
/* Responsive design */ |
|||
@media (max-width: 768px) { |
|||
.info-card { |
|||
padding: 12px; |
|||
} |
|||
|
|||
.info-title { |
|||
font-size: 0.85rem; |
|||
} |
|||
|
|||
.info-value { |
|||
font-size: 0.9rem; |
|||
} |
|||
} |
|||
</style> |
@ -0,0 +1,181 @@ |
|||
<!-- |
|||
/** |
|||
* Notification Card Component |
|||
* |
|||
* Displays notification information |
|||
* |
|||
* @author Matthew Raymer |
|||
* @version 1.0.0 |
|||
*/ |
|||
--> |
|||
|
|||
<template> |
|||
<div class="notification-card"> |
|||
<div class="notification-header"> |
|||
<h3 class="notification-title">{{ notification.title }}</h3> |
|||
<div class="notification-status" :class="statusClass"> |
|||
{{ statusText }} |
|||
</div> |
|||
</div> |
|||
|
|||
<div class="notification-content"> |
|||
<p class="notification-body">{{ notification.body }}</p> |
|||
</div> |
|||
|
|||
<div class="notification-footer"> |
|||
<div class="notification-time"> |
|||
<span class="time-label">Scheduled:</span> |
|||
<span class="time-value">{{ formatScheduledTime }}</span> |
|||
</div> |
|||
|
|||
<div v-if="notification.deliveredAt" class="notification-delivered"> |
|||
<span class="delivered-label">Delivered:</span> |
|||
<span class="delivered-value">{{ formatDeliveredTime }}</span> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</template> |
|||
|
|||
<script lang="ts"> |
|||
import { Component, Vue, Prop } from 'vue-facing-decorator' |
|||
import type { ScheduledNotification } from '@/stores/notifications' |
|||
|
|||
@Component |
|||
export default class NotificationCard extends Vue { |
|||
@Prop({ required: true }) notification!: ScheduledNotification |
|||
|
|||
get statusClass(): string { |
|||
return `status-${this.notification.status}` |
|||
} |
|||
|
|||
get statusText(): string { |
|||
const statusMap = { |
|||
scheduled: 'Scheduled', |
|||
delivered: 'Delivered', |
|||
cancelled: 'Cancelled' |
|||
} |
|||
return statusMap[this.notification.status] |
|||
} |
|||
|
|||
get formatScheduledTime(): string { |
|||
const date = new Date(this.notification.scheduledTime) |
|||
return date.toLocaleString() |
|||
} |
|||
|
|||
get formatDeliveredTime(): string { |
|||
if (!this.notification.deliveredAt) return 'Not delivered' |
|||
const date = new Date(this.notification.deliveredAt) |
|||
return date.toLocaleString() |
|||
} |
|||
} |
|||
</script> |
|||
|
|||
<style scoped> |
|||
.notification-card { |
|||
background: rgba(255, 255, 255, 0.1); |
|||
border-radius: 12px; |
|||
padding: 16px; |
|||
backdrop-filter: blur(10px); |
|||
border: 1px solid rgba(255, 255, 255, 0.2); |
|||
} |
|||
|
|||
.notification-header { |
|||
display: flex; |
|||
justify-content: space-between; |
|||
align-items: flex-start; |
|||
margin-bottom: 12px; |
|||
} |
|||
|
|||
.notification-title { |
|||
font-size: 1.1rem; |
|||
font-weight: 600; |
|||
color: white; |
|||
margin: 0; |
|||
flex: 1; |
|||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3); |
|||
} |
|||
|
|||
.notification-status { |
|||
padding: 4px 8px; |
|||
border-radius: 12px; |
|||
font-size: 0.75rem; |
|||
font-weight: 600; |
|||
text-transform: uppercase; |
|||
letter-spacing: 0.5px; |
|||
} |
|||
|
|||
.status-scheduled { |
|||
background: rgba(33, 150, 243, 0.2); |
|||
color: #2196f3; |
|||
border: 1px solid rgba(33, 150, 243, 0.3); |
|||
} |
|||
|
|||
.status-delivered { |
|||
background: rgba(76, 175, 80, 0.2); |
|||
color: #4caf50; |
|||
border: 1px solid rgba(76, 175, 80, 0.3); |
|||
} |
|||
|
|||
.status-cancelled { |
|||
background: rgba(158, 158, 158, 0.2); |
|||
color: #9e9e9e; |
|||
border: 1px solid rgba(158, 158, 158, 0.3); |
|||
} |
|||
|
|||
.notification-content { |
|||
margin-bottom: 16px; |
|||
} |
|||
|
|||
.notification-body { |
|||
color: rgba(255, 255, 255, 0.9); |
|||
font-size: 0.95rem; |
|||
line-height: 1.5; |
|||
margin: 0; |
|||
} |
|||
|
|||
.notification-footer { |
|||
display: flex; |
|||
flex-direction: column; |
|||
gap: 8px; |
|||
padding-top: 12px; |
|||
border-top: 1px solid rgba(255, 255, 255, 0.1); |
|||
} |
|||
|
|||
.notification-time, |
|||
.notification-delivered { |
|||
display: flex; |
|||
justify-content: space-between; |
|||
align-items: center; |
|||
} |
|||
|
|||
.time-label, |
|||
.delivered-label { |
|||
font-size: 0.8rem; |
|||
color: rgba(255, 255, 255, 0.7); |
|||
font-weight: 500; |
|||
} |
|||
|
|||
.time-value, |
|||
.delivered-value { |
|||
font-size: 0.8rem; |
|||
color: rgba(255, 255, 255, 0.9); |
|||
font-family: 'Courier New', monospace; |
|||
} |
|||
|
|||
/* Responsive design */ |
|||
@media (max-width: 768px) { |
|||
.notification-card { |
|||
padding: 12px; |
|||
} |
|||
|
|||
.notification-header { |
|||
flex-direction: column; |
|||
align-items: flex-start; |
|||
gap: 8px; |
|||
} |
|||
|
|||
.notification-footer { |
|||
gap: 6px; |
|||
} |
|||
} |
|||
</style> |
@ -0,0 +1,264 @@ |
|||
<!-- |
|||
/** |
|||
* Status Card Component |
|||
* |
|||
* Displays notification system status with visual indicators |
|||
* |
|||
* @author Matthew Raymer |
|||
* @version 1.0.0 |
|||
*/ |
|||
--> |
|||
|
|||
<template> |
|||
<div class="status-card"> |
|||
<div class="status-header"> |
|||
<h3 class="status-title">System Status</h3> |
|||
<div class="status-indicator" :class="statusClass"> |
|||
<span class="status-dot"></span> |
|||
<span class="status-text">{{ statusText }}</span> |
|||
</div> |
|||
</div> |
|||
|
|||
<div v-if="status" class="status-details"> |
|||
<div class="status-grid"> |
|||
<StatusItem |
|||
label="Notifications" |
|||
:value="status.postNotificationsGranted ? 'Granted' : 'Denied'" |
|||
:status="status.postNotificationsGranted ? 'success' : 'error'" |
|||
/> |
|||
<StatusItem |
|||
label="Channel" |
|||
:value="channelStatusText" |
|||
:status="status.channelEnabled ? 'success' : 'warning'" |
|||
/> |
|||
<StatusItem |
|||
label="Exact Alarms" |
|||
:value="status.exactAlarmsGranted ? 'Granted' : 'Denied'" |
|||
:status="status.exactAlarmsGranted ? 'success' : 'error'" |
|||
/> |
|||
<StatusItem |
|||
label="Android Version" |
|||
:value="`API ${status.androidVersion}`" |
|||
status="info" |
|||
/> |
|||
</div> |
|||
|
|||
<div v-if="status.nextScheduledAt > 0" class="next-scheduled"> |
|||
<h4 class="next-title">Next Scheduled</h4> |
|||
<p class="next-time">{{ formatNextScheduledTime }}</p> |
|||
</div> |
|||
</div> |
|||
|
|||
<div v-else class="no-status"> |
|||
<p class="no-status-text">Status not available</p> |
|||
<button class="refresh-button" @click="refreshStatus"> |
|||
🔄 Refresh |
|||
</button> |
|||
</div> |
|||
</div> |
|||
</template> |
|||
|
|||
<script lang="ts"> |
|||
import { Component, Vue, Prop } from 'vue-facing-decorator' |
|||
import type { NotificationStatus } from '@/stores/app' |
|||
import StatusItem from '@/components/items/StatusItem.vue' |
|||
|
|||
@Component({ |
|||
components: { |
|||
StatusItem |
|||
} |
|||
}) |
|||
export default class StatusCard extends Vue { |
|||
@Prop() status!: NotificationStatus | null |
|||
|
|||
get statusClass(): string { |
|||
if (!this.status) return 'unknown' |
|||
if (this.status.canScheduleNow) return 'ready' |
|||
return 'not-ready' |
|||
} |
|||
|
|||
get statusText(): string { |
|||
if (!this.status) return 'Unknown' |
|||
if (this.status.canScheduleNow) return 'Ready' |
|||
return 'Not Ready' |
|||
} |
|||
|
|||
get channelStatusText(): string { |
|||
if (!this.status) return 'Unknown' |
|||
if (!this.status.channelEnabled) return 'Disabled' |
|||
|
|||
const importanceMap: Record<number, string> = { |
|||
0: 'None', |
|||
1: 'Min', |
|||
2: 'Low', |
|||
3: 'Default', |
|||
4: 'High', |
|||
5: 'Max' |
|||
} |
|||
|
|||
return importanceMap[this.status.channelImportance] || 'Unknown' |
|||
} |
|||
|
|||
get formatNextScheduledTime(): string { |
|||
if (!this.status || this.status.nextScheduledAt <= 0) { |
|||
return 'No notifications scheduled' |
|||
} |
|||
|
|||
const date = new Date(this.status.nextScheduledAt) |
|||
const now = new Date() |
|||
const diffMs = date.getTime() - now.getTime() |
|||
|
|||
if (diffMs < 0) { |
|||
return 'Past due' |
|||
} |
|||
|
|||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60)) |
|||
const diffMinutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60)) |
|||
|
|||
if (diffHours > 24) { |
|||
const diffDays = Math.floor(diffHours / 24) |
|||
return `In ${diffDays} day${diffDays > 1 ? 's' : ''}` |
|||
} else if (diffHours > 0) { |
|||
return `In ${diffHours}h ${diffMinutes}m` |
|||
} else { |
|||
return `In ${diffMinutes} minutes` |
|||
} |
|||
} |
|||
|
|||
refreshStatus(): void { |
|||
this.$emit('refresh') |
|||
} |
|||
} |
|||
</script> |
|||
|
|||
<style scoped> |
|||
.status-card { |
|||
background: rgba(255, 255, 255, 0.1); |
|||
border-radius: 12px; |
|||
padding: 20px; |
|||
backdrop-filter: blur(10px); |
|||
border: 1px solid rgba(255, 255, 255, 0.2); |
|||
} |
|||
|
|||
.status-header { |
|||
display: flex; |
|||
justify-content: space-between; |
|||
align-items: center; |
|||
margin-bottom: 16px; |
|||
} |
|||
|
|||
.status-title { |
|||
font-size: 1.2rem; |
|||
font-weight: 600; |
|||
color: white; |
|||
margin: 0; |
|||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3); |
|||
} |
|||
|
|||
.status-indicator { |
|||
display: flex; |
|||
align-items: center; |
|||
gap: 8px; |
|||
padding: 6px 12px; |
|||
border-radius: 20px; |
|||
font-size: 0.9rem; |
|||
font-weight: 500; |
|||
} |
|||
|
|||
.status-indicator.ready { |
|||
background: rgba(76, 175, 80, 0.2); |
|||
color: #4caf50; |
|||
border: 1px solid rgba(76, 175, 80, 0.3); |
|||
} |
|||
|
|||
.status-indicator.not-ready { |
|||
background: rgba(244, 67, 54, 0.2); |
|||
color: #f44336; |
|||
border: 1px solid rgba(244, 67, 54, 0.3); |
|||
} |
|||
|
|||
.status-indicator.unknown { |
|||
background: rgba(158, 158, 158, 0.2); |
|||
color: #9e9e9e; |
|||
border: 1px solid rgba(158, 158, 158, 0.3); |
|||
} |
|||
|
|||
.status-dot { |
|||
width: 8px; |
|||
height: 8px; |
|||
border-radius: 50%; |
|||
background: currentColor; |
|||
} |
|||
|
|||
.status-details { |
|||
space-y: 16px; |
|||
} |
|||
|
|||
.status-grid { |
|||
display: grid; |
|||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); |
|||
gap: 12px; |
|||
margin-bottom: 16px; |
|||
} |
|||
|
|||
.next-scheduled { |
|||
padding-top: 16px; |
|||
border-top: 1px solid rgba(255, 255, 255, 0.1); |
|||
} |
|||
|
|||
.next-title { |
|||
font-size: 1rem; |
|||
font-weight: 600; |
|||
color: white; |
|||
margin: 0 0 8px 0; |
|||
} |
|||
|
|||
.next-time { |
|||
font-size: 0.9rem; |
|||
color: rgba(255, 255, 255, 0.8); |
|||
margin: 0; |
|||
font-family: 'Courier New', monospace; |
|||
} |
|||
|
|||
.no-status { |
|||
text-align: center; |
|||
padding: 20px; |
|||
} |
|||
|
|||
.no-status-text { |
|||
color: rgba(255, 255, 255, 0.7); |
|||
margin: 0 0 16px 0; |
|||
} |
|||
|
|||
.refresh-button { |
|||
background: rgba(255, 255, 255, 0.1); |
|||
border: 1px solid rgba(255, 255, 255, 0.2); |
|||
color: white; |
|||
padding: 8px 16px; |
|||
border-radius: 8px; |
|||
cursor: pointer; |
|||
font-size: 0.9rem; |
|||
transition: all 0.3s ease; |
|||
} |
|||
|
|||
.refresh-button:hover { |
|||
background: rgba(255, 255, 255, 0.15); |
|||
} |
|||
|
|||
/* Responsive design */ |
|||
@media (max-width: 768px) { |
|||
.status-card { |
|||
padding: 16px; |
|||
} |
|||
|
|||
.status-header { |
|||
flex-direction: column; |
|||
align-items: flex-start; |
|||
gap: 12px; |
|||
} |
|||
|
|||
.status-grid { |
|||
grid-template-columns: 1fr; |
|||
} |
|||
} |
|||
</style> |
@ -0,0 +1,141 @@ |
|||
<!-- |
|||
/** |
|||
* Activity Item Component |
|||
* |
|||
* Individual activity history item |
|||
* |
|||
* @author Matthew Raymer |
|||
* @version 1.0.0 |
|||
*/ |
|||
--> |
|||
|
|||
<template> |
|||
<div class="activity-item"> |
|||
<div class="activity-icon"> |
|||
<span>{{ activityIcon }}</span> |
|||
</div> |
|||
<div class="activity-content"> |
|||
<div class="activity-title">{{ activity.title }}</div> |
|||
<div class="activity-time">{{ formatActivityTime }}</div> |
|||
</div> |
|||
<div class="activity-status" :class="statusClass"> |
|||
<span class="status-dot"></span> |
|||
</div> |
|||
</div> |
|||
</template> |
|||
|
|||
<script lang="ts"> |
|||
import { Component, Vue, Prop } from 'vue-facing-decorator' |
|||
import type { NotificationHistory } from '@/stores/notifications' |
|||
|
|||
@Component |
|||
export default class ActivityItem extends Vue { |
|||
@Prop({ required: true }) activity!: NotificationHistory |
|||
|
|||
get activityIcon(): string { |
|||
if (this.activity.clicked) return '👆' |
|||
if (this.activity.dismissed) return '❌' |
|||
return '📱' |
|||
} |
|||
|
|||
get statusClass(): string { |
|||
if (this.activity.clicked) return 'clicked' |
|||
if (this.activity.dismissed) return 'dismissed' |
|||
return 'delivered' |
|||
} |
|||
|
|||
get formatActivityTime(): string { |
|||
const date = new Date(this.activity.deliveredAt) |
|||
const now = new Date() |
|||
const diffMs = now.getTime() - date.getTime() |
|||
|
|||
if (diffMs < 60000) { // Less than 1 minute |
|||
return 'Just now' |
|||
} else if (diffMs < 3600000) { // Less than 1 hour |
|||
const minutes = Math.floor(diffMs / 60000) |
|||
return `${minutes}m ago` |
|||
} else if (diffMs < 86400000) { // Less than 1 day |
|||
const hours = Math.floor(diffMs / 3600000) |
|||
return `${hours}h ago` |
|||
} else { |
|||
return date.toLocaleDateString() |
|||
} |
|||
} |
|||
} |
|||
</script> |
|||
|
|||
<style scoped> |
|||
.activity-item { |
|||
display: flex; |
|||
align-items: center; |
|||
gap: 12px; |
|||
padding: 12px; |
|||
background: rgba(255, 255, 255, 0.05); |
|||
border-radius: 8px; |
|||
border: 1px solid rgba(255, 255, 255, 0.1); |
|||
transition: all 0.3s ease; |
|||
} |
|||
|
|||
.activity-item:hover { |
|||
background: rgba(255, 255, 255, 0.08); |
|||
} |
|||
|
|||
.activity-icon { |
|||
flex-shrink: 0; |
|||
width: 32px; |
|||
height: 32px; |
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: center; |
|||
background: rgba(255, 255, 255, 0.1); |
|||
border-radius: 50%; |
|||
font-size: 1.2rem; |
|||
} |
|||
|
|||
.activity-content { |
|||
flex: 1; |
|||
min-width: 0; |
|||
} |
|||
|
|||
.activity-title { |
|||
font-size: 0.9rem; |
|||
font-weight: 500; |
|||
color: white; |
|||
margin: 0 0 4px 0; |
|||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3); |
|||
overflow: hidden; |
|||
text-overflow: ellipsis; |
|||
white-space: nowrap; |
|||
} |
|||
|
|||
.activity-time { |
|||
font-size: 0.8rem; |
|||
color: rgba(255, 255, 255, 0.7); |
|||
margin: 0; |
|||
} |
|||
|
|||
.activity-status { |
|||
flex-shrink: 0; |
|||
display: flex; |
|||
align-items: center; |
|||
gap: 4px; |
|||
} |
|||
|
|||
.status-dot { |
|||
width: 8px; |
|||
height: 8px; |
|||
border-radius: 50%; |
|||
} |
|||
|
|||
.status-clicked .status-dot { |
|||
background: #4caf50; |
|||
} |
|||
|
|||
.status-dismissed .status-dot { |
|||
background: #f44336; |
|||
} |
|||
|
|||
.status-delivered .status-dot { |
|||
background: #2196f3; |
|||
} |
|||
</style> |
@ -0,0 +1,170 @@ |
|||
<!-- |
|||
/** |
|||
* History Item Component |
|||
* |
|||
* Individual notification history item |
|||
* |
|||
* @author Matthew Raymer |
|||
* @version 1.0.0 |
|||
*/ |
|||
--> |
|||
|
|||
<template> |
|||
<div class="history-item"> |
|||
<div class="history-icon"> |
|||
<span>{{ historyIcon }}</span> |
|||
</div> |
|||
<div class="history-content"> |
|||
<div class="history-title">{{ history.title }}</div> |
|||
<div class="history-body">{{ history.body }}</div> |
|||
<div class="history-time">{{ formatHistoryTime }}</div> |
|||
</div> |
|||
<div class="history-actions"> |
|||
<span class="action-indicator" :class="actionClass"> |
|||
{{ actionText }} |
|||
</span> |
|||
</div> |
|||
</div> |
|||
</template> |
|||
|
|||
<script lang="ts"> |
|||
import { Component, Vue, Prop } from 'vue-facing-decorator' |
|||
import type { NotificationHistory } from '@/stores/notifications' |
|||
|
|||
@Component |
|||
export default class HistoryItem extends Vue { |
|||
@Prop({ required: true }) history!: NotificationHistory |
|||
|
|||
get historyIcon(): string { |
|||
if (this.history.clicked) return '👆' |
|||
if (this.history.dismissed) return '❌' |
|||
return '📱' |
|||
} |
|||
|
|||
get actionClass(): string { |
|||
if (this.history.clicked) return 'clicked' |
|||
if (this.history.dismissed) return 'dismissed' |
|||
return 'delivered' |
|||
} |
|||
|
|||
get actionText(): string { |
|||
if (this.history.clicked) return 'Clicked' |
|||
if (this.history.dismissed) return 'Dismissed' |
|||
return 'Delivered' |
|||
} |
|||
|
|||
get formatHistoryTime(): string { |
|||
const date = new Date(this.history.deliveredAt) |
|||
return date.toLocaleString() |
|||
} |
|||
} |
|||
</script> |
|||
|
|||
<style scoped> |
|||
.history-item { |
|||
display: flex; |
|||
align-items: flex-start; |
|||
gap: 16px; |
|||
padding: 16px; |
|||
background: rgba(255, 255, 255, 0.1); |
|||
border-radius: 12px; |
|||
backdrop-filter: blur(10px); |
|||
border: 1px solid rgba(255, 255, 255, 0.2); |
|||
transition: all 0.3s ease; |
|||
} |
|||
|
|||
.history-item:hover { |
|||
background: rgba(255, 255, 255, 0.15); |
|||
} |
|||
|
|||
.history-icon { |
|||
flex-shrink: 0; |
|||
width: 40px; |
|||
height: 40px; |
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: center; |
|||
background: rgba(255, 255, 255, 0.1); |
|||
border-radius: 50%; |
|||
font-size: 1.5rem; |
|||
} |
|||
|
|||
.history-content { |
|||
flex: 1; |
|||
min-width: 0; |
|||
} |
|||
|
|||
.history-title { |
|||
font-size: 1rem; |
|||
font-weight: 600; |
|||
color: white; |
|||
margin: 0 0 4px 0; |
|||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3); |
|||
} |
|||
|
|||
.history-body { |
|||
font-size: 0.9rem; |
|||
color: rgba(255, 255, 255, 0.8); |
|||
margin: 0 0 8px 0; |
|||
line-height: 1.4; |
|||
} |
|||
|
|||
.history-time { |
|||
font-size: 0.8rem; |
|||
color: rgba(255, 255, 255, 0.6); |
|||
font-family: 'Courier New', monospace; |
|||
} |
|||
|
|||
.history-actions { |
|||
flex-shrink: 0; |
|||
} |
|||
|
|||
.action-indicator { |
|||
padding: 4px 8px; |
|||
border-radius: 12px; |
|||
font-size: 0.75rem; |
|||
font-weight: 600; |
|||
text-transform: uppercase; |
|||
letter-spacing: 0.5px; |
|||
} |
|||
|
|||
.action-indicator.clicked { |
|||
background: rgba(76, 175, 80, 0.2); |
|||
color: #4caf50; |
|||
border: 1px solid rgba(76, 175, 80, 0.3); |
|||
} |
|||
|
|||
.action-indicator.dismissed { |
|||
background: rgba(244, 67, 54, 0.2); |
|||
color: #f44336; |
|||
border: 1px solid rgba(244, 67, 54, 0.3); |
|||
} |
|||
|
|||
.action-indicator.delivered { |
|||
background: rgba(33, 150, 243, 0.2); |
|||
color: #2196f3; |
|||
border: 1px solid rgba(33, 150, 243, 0.3); |
|||
} |
|||
|
|||
/* Responsive design */ |
|||
@media (max-width: 768px) { |
|||
.history-item { |
|||
padding: 12px; |
|||
gap: 12px; |
|||
} |
|||
|
|||
.history-icon { |
|||
width: 32px; |
|||
height: 32px; |
|||
font-size: 1.2rem; |
|||
} |
|||
|
|||
.history-title { |
|||
font-size: 0.9rem; |
|||
} |
|||
|
|||
.history-body { |
|||
font-size: 0.85rem; |
|||
} |
|||
} |
|||
</style> |
@ -0,0 +1,99 @@ |
|||
<!-- |
|||
/** |
|||
* Status Item Component |
|||
* |
|||
* Individual status display item |
|||
* |
|||
* @author Matthew Raymer |
|||
* @version 1.0.0 |
|||
*/ |
|||
--> |
|||
|
|||
<template> |
|||
<div class="status-item"> |
|||
<div class="status-label">{{ label }}</div> |
|||
<div class="status-value" :class="statusClass"> |
|||
<span class="status-dot"></span> |
|||
{{ value }} |
|||
</div> |
|||
</div> |
|||
</template> |
|||
|
|||
<script lang="ts"> |
|||
import { Component, Vue, Prop } from 'vue-facing-decorator' |
|||
|
|||
@Component |
|||
export default class StatusItem extends Vue { |
|||
@Prop({ required: true }) label!: string |
|||
@Prop({ required: true }) value!: string |
|||
@Prop({ default: 'info' }) status!: 'success' | 'warning' | 'error' | 'info' |
|||
|
|||
get statusClass(): string { |
|||
return `status-${this.status}` |
|||
} |
|||
} |
|||
</script> |
|||
|
|||
<style scoped> |
|||
.status-item { |
|||
display: flex; |
|||
justify-content: space-between; |
|||
align-items: center; |
|||
padding: 8px 12px; |
|||
background: rgba(255, 255, 255, 0.05); |
|||
border-radius: 8px; |
|||
border: 1px solid rgba(255, 255, 255, 0.1); |
|||
} |
|||
|
|||
.status-label { |
|||
font-size: 0.9rem; |
|||
color: rgba(255, 255, 255, 0.8); |
|||
font-weight: 500; |
|||
} |
|||
|
|||
.status-value { |
|||
display: flex; |
|||
align-items: center; |
|||
gap: 6px; |
|||
font-size: 0.85rem; |
|||
font-weight: 600; |
|||
} |
|||
|
|||
.status-dot { |
|||
width: 6px; |
|||
height: 6px; |
|||
border-radius: 50%; |
|||
} |
|||
|
|||
.status-success { |
|||
color: #4caf50; |
|||
} |
|||
|
|||
.status-success .status-dot { |
|||
background: #4caf50; |
|||
} |
|||
|
|||
.status-warning { |
|||
color: #ff9800; |
|||
} |
|||
|
|||
.status-warning .status-dot { |
|||
background: #ff9800; |
|||
} |
|||
|
|||
.status-error { |
|||
color: #f44336; |
|||
} |
|||
|
|||
.status-error .status-dot { |
|||
background: #f44336; |
|||
} |
|||
|
|||
.status-info { |
|||
color: #2196f3; |
|||
} |
|||
|
|||
.status-info .status-dot { |
|||
background: #2196f3; |
|||
} |
|||
</style> |
@ -0,0 +1,75 @@ |
|||
<!-- |
|||
/** |
|||
* App Footer Component |
|||
* |
|||
* Simple footer with app information |
|||
* |
|||
* @author Matthew Raymer |
|||
* @version 1.0.0 |
|||
*/ |
|||
--> |
|||
|
|||
<template> |
|||
<footer class="app-footer"> |
|||
<div class="footer-content"> |
|||
<div class="footer-left"> |
|||
<p class="footer-text"> |
|||
Daily Notification Test App v1.0.0 |
|||
</p> |
|||
</div> |
|||
<div class="footer-right"> |
|||
<p class="footer-text"> |
|||
Built with Vue 3 + Vite + Capacitor |
|||
</p> |
|||
</div> |
|||
</div> |
|||
</footer> |
|||
</template> |
|||
|
|||
<script lang="ts"> |
|||
import { Component, Vue } from 'vue-facing-decorator' |
|||
|
|||
@Component |
|||
export default class AppFooter extends Vue {} |
|||
</script> |
|||
|
|||
<style scoped> |
|||
.app-footer { |
|||
background: rgba(0, 0, 0, 0.2); |
|||
backdrop-filter: blur(10px); |
|||
border-top: 1px solid rgba(255, 255, 255, 0.1); |
|||
padding: 16px 20px; |
|||
margin-top: auto; |
|||
} |
|||
|
|||
.footer-content { |
|||
display: flex; |
|||
justify-content: space-between; |
|||
align-items: center; |
|||
max-width: 1200px; |
|||
margin: 0 auto; |
|||
} |
|||
|
|||
.footer-text { |
|||
font-size: 0.85rem; |
|||
color: rgba(255, 255, 255, 0.7); |
|||
margin: 0; |
|||
} |
|||
|
|||
/* Responsive design */ |
|||
@media (max-width: 768px) { |
|||
.app-footer { |
|||
padding: 12px 16px; |
|||
} |
|||
|
|||
.footer-content { |
|||
flex-direction: column; |
|||
gap: 8px; |
|||
text-align: center; |
|||
} |
|||
|
|||
.footer-text { |
|||
font-size: 0.8rem; |
|||
} |
|||
} |
|||
</style> |
@ -0,0 +1,239 @@ |
|||
<!-- |
|||
/** |
|||
* App Header Component |
|||
* |
|||
* Navigation header with menu and status indicators |
|||
* |
|||
* @author Matthew Raymer |
|||
* @version 1.0.0 |
|||
*/ |
|||
--> |
|||
|
|||
<template> |
|||
<header class="app-header"> |
|||
<div class="header-content"> |
|||
<!-- Logo and Title --> |
|||
<div class="header-left"> |
|||
<div class="logo"> |
|||
<span class="logo-icon">🔔</span> |
|||
<span class="logo-text">Daily Notification Test</span> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- Navigation --> |
|||
<nav class="header-nav"> |
|||
<router-link |
|||
v-for="item in navigationItems" |
|||
:key="item.name" |
|||
:to="item.path" |
|||
class="nav-item" |
|||
:class="{ 'active': $route.name === item.name }" |
|||
> |
|||
<span class="nav-icon">{{ item.icon }}</span> |
|||
<span class="nav-text">{{ item.label }}</span> |
|||
</router-link> |
|||
</nav> |
|||
|
|||
<!-- Status Indicator --> |
|||
<div class="header-right"> |
|||
<div class="status-indicator" :class="statusClass"> |
|||
<span class="status-dot"></span> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</header> |
|||
</template> |
|||
|
|||
<script lang="ts"> |
|||
import { Component, Vue } from 'vue-facing-decorator' |
|||
import { useAppStore } from '@/stores/app' |
|||
|
|||
interface NavigationItem { |
|||
name: string |
|||
path: string |
|||
label: string |
|||
icon: string |
|||
} |
|||
|
|||
@Component |
|||
export default class AppHeader extends Vue { |
|||
private appStore = useAppStore() |
|||
|
|||
navigationItems: NavigationItem[] = [ |
|||
{ name: 'Home', path: '/', label: 'Home', icon: '🏠' }, |
|||
{ name: 'Schedule', path: '/schedule', label: 'Schedule', icon: '📅' }, |
|||
{ name: 'Notifications', path: '/notifications', label: 'Notifications', icon: '📱' }, |
|||
{ name: 'Status', path: '/status', label: 'Status', icon: '📊' }, |
|||
{ name: 'History', path: '/history', label: 'History', icon: '📋' }, |
|||
{ name: 'Logs', path: '/logs', label: 'Logs', icon: '📜' } |
|||
] |
|||
|
|||
get statusClass(): string { |
|||
const status = this.appStore.notificationStatus |
|||
if (!status) return 'unknown' |
|||
if (status.canScheduleNow) return 'ready' |
|||
return 'not-ready' |
|||
} |
|||
} |
|||
</script> |
|||
|
|||
<style scoped> |
|||
.app-header { |
|||
background: rgba(255, 255, 255, 0.1); |
|||
backdrop-filter: blur(10px); |
|||
border-bottom: 1px solid rgba(255, 255, 255, 0.2); |
|||
padding: 12px 20px; |
|||
position: sticky; |
|||
top: 0; |
|||
z-index: 100; |
|||
} |
|||
|
|||
.header-content { |
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: space-between; |
|||
max-width: 1200px; |
|||
margin: 0 auto; |
|||
} |
|||
|
|||
.header-left { |
|||
flex-shrink: 0; |
|||
} |
|||
|
|||
.logo { |
|||
display: flex; |
|||
align-items: center; |
|||
gap: 8px; |
|||
} |
|||
|
|||
.logo-icon { |
|||
font-size: 1.5rem; |
|||
} |
|||
|
|||
.logo-text { |
|||
font-size: 1.1rem; |
|||
font-weight: 600; |
|||
color: white; |
|||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3); |
|||
} |
|||
|
|||
.header-nav { |
|||
display: flex; |
|||
gap: 8px; |
|||
flex: 1; |
|||
justify-content: center; |
|||
} |
|||
|
|||
.nav-item { |
|||
display: flex; |
|||
align-items: center; |
|||
gap: 6px; |
|||
padding: 8px 12px; |
|||
border-radius: 8px; |
|||
text-decoration: none; |
|||
color: rgba(255, 255, 255, 0.8); |
|||
font-size: 0.9rem; |
|||
font-weight: 500; |
|||
transition: all 0.3s ease; |
|||
} |
|||
|
|||
.nav-item:hover { |
|||
background: rgba(255, 255, 255, 0.1); |
|||
color: white; |
|||
} |
|||
|
|||
.nav-item.active { |
|||
background: rgba(255, 255, 255, 0.2); |
|||
color: white; |
|||
} |
|||
|
|||
.nav-icon { |
|||
font-size: 1rem; |
|||
} |
|||
|
|||
.nav-text { |
|||
display: none; |
|||
} |
|||
|
|||
.header-right { |
|||
flex-shrink: 0; |
|||
} |
|||
|
|||
.status-indicator { |
|||
display: flex; |
|||
align-items: center; |
|||
gap: 6px; |
|||
padding: 6px 10px; |
|||
border-radius: 16px; |
|||
font-size: 0.8rem; |
|||
} |
|||
|
|||
.status-indicator.ready { |
|||
background: rgba(76, 175, 80, 0.2); |
|||
border: 1px solid rgba(76, 175, 80, 0.3); |
|||
} |
|||
|
|||
.status-indicator.not-ready { |
|||
background: rgba(244, 67, 54, 0.2); |
|||
border: 1px solid rgba(244, 67, 54, 0.3); |
|||
} |
|||
|
|||
.status-indicator.unknown { |
|||
background: rgba(158, 158, 158, 0.2); |
|||
border: 1px solid rgba(158, 158, 158, 0.3); |
|||
} |
|||
|
|||
.status-dot { |
|||
width: 8px; |
|||
height: 8px; |
|||
border-radius: 50%; |
|||
background: currentColor; |
|||
} |
|||
|
|||
.status-indicator.ready .status-dot { |
|||
background: #4caf50; |
|||
} |
|||
|
|||
.status-indicator.not-ready .status-dot { |
|||
background: #f44336; |
|||
} |
|||
|
|||
.status-indicator.unknown .status-dot { |
|||
background: #9e9e9e; |
|||
} |
|||
|
|||
/* Responsive design */ |
|||
@media (min-width: 768px) { |
|||
.nav-text { |
|||
display: inline; |
|||
} |
|||
|
|||
.header-nav { |
|||
gap: 16px; |
|||
} |
|||
|
|||
.nav-item { |
|||
padding: 10px 16px; |
|||
} |
|||
} |
|||
|
|||
@media (max-width: 767px) { |
|||
.header-content { |
|||
padding: 0 16px; |
|||
} |
|||
|
|||
.logo-text { |
|||
display: none; |
|||
} |
|||
|
|||
.header-nav { |
|||
gap: 4px; |
|||
} |
|||
|
|||
.nav-item { |
|||
padding: 6px 8px; |
|||
min-width: 44px; |
|||
justify-content: center; |
|||
} |
|||
} |
|||
</style> |
@ -0,0 +1,161 @@ |
|||
<!-- |
|||
/** |
|||
* Error Dialog Component |
|||
* |
|||
* Global error display dialog |
|||
* |
|||
* @author Matthew Raymer |
|||
* @version 1.0.0 |
|||
*/ |
|||
--> |
|||
|
|||
<template> |
|||
<div class="error-dialog-overlay" @click="handleOverlayClick"> |
|||
<div class="error-dialog" @click.stop> |
|||
<div class="error-header"> |
|||
<span class="error-icon">⚠️</span> |
|||
<h3 class="error-title">Error</h3> |
|||
</div> |
|||
|
|||
<div class="error-content"> |
|||
<p class="error-message">{{ message }}</p> |
|||
</div> |
|||
|
|||
<div class="error-actions"> |
|||
<button class="error-button primary" @click="handleClose"> |
|||
OK |
|||
</button> |
|||
<button v-if="showRetry" class="error-button secondary" @click="handleRetry"> |
|||
Retry |
|||
</button> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</template> |
|||
|
|||
<script lang="ts"> |
|||
import { Component, Vue, Prop } from 'vue-facing-decorator' |
|||
|
|||
@Component |
|||
export default class ErrorDialog extends Vue { |
|||
@Prop({ required: true }) message!: string |
|||
@Prop({ default: false }) showRetry!: boolean |
|||
|
|||
handleClose(): void { |
|||
this.$emit('close') |
|||
} |
|||
|
|||
handleRetry(): void { |
|||
this.$emit('retry') |
|||
} |
|||
|
|||
handleOverlayClick(): void { |
|||
this.handleClose() |
|||
} |
|||
} |
|||
</script> |
|||
|
|||
<style scoped> |
|||
.error-dialog-overlay { |
|||
position: fixed; |
|||
top: 0; |
|||
left: 0; |
|||
right: 0; |
|||
bottom: 0; |
|||
background: rgba(0, 0, 0, 0.7); |
|||
display: flex; |
|||
justify-content: center; |
|||
align-items: center; |
|||
z-index: 9999; |
|||
backdrop-filter: blur(4px); |
|||
} |
|||
|
|||
.error-dialog { |
|||
background: rgba(255, 255, 255, 0.95); |
|||
border-radius: 16px; |
|||
padding: 24px; |
|||
max-width: 400px; |
|||
width: 90%; |
|||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3); |
|||
border: 1px solid rgba(255, 255, 255, 0.2); |
|||
} |
|||
|
|||
.error-header { |
|||
display: flex; |
|||
align-items: center; |
|||
gap: 12px; |
|||
margin-bottom: 16px; |
|||
} |
|||
|
|||
.error-icon { |
|||
font-size: 1.5rem; |
|||
} |
|||
|
|||
.error-title { |
|||
font-size: 1.3rem; |
|||
font-weight: 600; |
|||
color: #d32f2f; |
|||
margin: 0; |
|||
} |
|||
|
|||
.error-content { |
|||
margin-bottom: 24px; |
|||
} |
|||
|
|||
.error-message { |
|||
color: #424242; |
|||
font-size: 1rem; |
|||
line-height: 1.5; |
|||
margin: 0; |
|||
} |
|||
|
|||
.error-actions { |
|||
display: flex; |
|||
gap: 12px; |
|||
justify-content: flex-end; |
|||
} |
|||
|
|||
.error-button { |
|||
padding: 10px 20px; |
|||
border-radius: 8px; |
|||
font-size: 0.9rem; |
|||
font-weight: 500; |
|||
cursor: pointer; |
|||
transition: all 0.3s ease; |
|||
border: none; |
|||
} |
|||
|
|||
.error-button.primary { |
|||
background: #d32f2f; |
|||
color: white; |
|||
} |
|||
|
|||
.error-button.primary:hover { |
|||
background: #b71c1c; |
|||
} |
|||
|
|||
.error-button.secondary { |
|||
background: rgba(0, 0, 0, 0.1); |
|||
color: #424242; |
|||
} |
|||
|
|||
.error-button.secondary:hover { |
|||
background: rgba(0, 0, 0, 0.15); |
|||
} |
|||
|
|||
/* Responsive design */ |
|||
@media (max-width: 480px) { |
|||
.error-dialog { |
|||
padding: 20px; |
|||
margin: 16px; |
|||
} |
|||
|
|||
.error-actions { |
|||
flex-direction: column; |
|||
} |
|||
|
|||
.error-button { |
|||
width: 100%; |
|||
} |
|||
} |
|||
</style> |
@ -0,0 +1,78 @@ |
|||
<!-- |
|||
/** |
|||
* Loading Overlay Component |
|||
* |
|||
* Global loading indicator overlay |
|||
* |
|||
* @author Matthew Raymer |
|||
* @version 1.0.0 |
|||
*/ |
|||
--> |
|||
|
|||
<template> |
|||
<div class="loading-overlay"> |
|||
<div class="loading-content"> |
|||
<div class="spinner"></div> |
|||
<p class="loading-text">{{ message }}</p> |
|||
</div> |
|||
</div> |
|||
</template> |
|||
|
|||
<script lang="ts"> |
|||
import { Component, Vue, Prop } from 'vue-facing-decorator' |
|||
|
|||
@Component |
|||
export default class LoadingOverlay extends Vue { |
|||
@Prop({ default: 'Loading...' }) message!: string |
|||
} |
|||
</script> |
|||
|
|||
<style scoped> |
|||
.loading-overlay { |
|||
position: fixed; |
|||
top: 0; |
|||
left: 0; |
|||
right: 0; |
|||
bottom: 0; |
|||
background: rgba(0, 0, 0, 0.7); |
|||
display: flex; |
|||
justify-content: center; |
|||
align-items: center; |
|||
z-index: 9999; |
|||
backdrop-filter: blur(4px); |
|||
} |
|||
|
|||
.loading-content { |
|||
display: flex; |
|||
flex-direction: column; |
|||
align-items: center; |
|||
gap: 16px; |
|||
padding: 32px; |
|||
background: rgba(255, 255, 255, 0.1); |
|||
border-radius: 16px; |
|||
backdrop-filter: blur(10px); |
|||
border: 1px solid rgba(255, 255, 255, 0.2); |
|||
} |
|||
|
|||
.spinner { |
|||
width: 40px; |
|||
height: 40px; |
|||
border: 4px solid rgba(255, 255, 255, 0.3); |
|||
border-top: 4px solid white; |
|||
border-radius: 50%; |
|||
animation: spin 1s linear infinite; |
|||
} |
|||
|
|||
.loading-text { |
|||
color: white; |
|||
font-size: 1.1rem; |
|||
font-weight: 500; |
|||
margin: 0; |
|||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3); |
|||
} |
|||
|
|||
@keyframes spin { |
|||
0% { transform: rotate(0deg); } |
|||
100% { transform: rotate(360deg); } |
|||
} |
|||
</style> |
@ -1,476 +0,0 @@ |
|||
<!DOCTYPE html> |
|||
<html lang="en"> |
|||
<head> |
|||
<meta charset="UTF-8"> |
|||
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|||
<title>TimeSafari Daily Notification - Android Test</title> |
|||
<style> |
|||
body { |
|||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; |
|||
margin: 0; |
|||
padding: 20px; |
|||
background-color: #f5f5f5; |
|||
} |
|||
.container { |
|||
max-width: 800px; |
|||
margin: 0 auto; |
|||
background: white; |
|||
border-radius: 12px; |
|||
padding: 20px; |
|||
box-shadow: 0 2px 10px rgba(0,0,0,0.1); |
|||
} |
|||
h1 { |
|||
color: #333; |
|||
text-align: center; |
|||
margin-bottom: 30px; |
|||
} |
|||
.ui-section { |
|||
margin-bottom: 30px; |
|||
padding: 20px; |
|||
border: 1px solid #e0e0e0; |
|||
border-radius: 8px; |
|||
background: #fafafa; |
|||
} |
|||
.ui-section h3 { |
|||
margin: 0 0 15px 0; |
|||
color: #1976d2; |
|||
border-bottom: 2px solid #1976d2; |
|||
padding-bottom: 8px; |
|||
} |
|||
.status { |
|||
background: #e3f2fd; |
|||
padding: 15px; |
|||
border-radius: 8px; |
|||
margin-bottom: 20px; |
|||
text-align: center; |
|||
font-weight: bold; |
|||
color: #1976d2; |
|||
} |
|||
.button-grid { |
|||
display: grid; |
|||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); |
|||
gap: 10px; |
|||
margin-bottom: 20px; |
|||
} |
|||
button { |
|||
background: #1976d2; |
|||
color: white; |
|||
border: none; |
|||
padding: 12px 16px; |
|||
border-radius: 6px; |
|||
cursor: pointer; |
|||
font-size: 14px; |
|||
transition: background-color 0.2s; |
|||
} |
|||
button:hover { |
|||
background: #1565c0; |
|||
} |
|||
button:disabled { |
|||
background: #ccc; |
|||
cursor: not-allowed; |
|||
} |
|||
.btn-secondary { |
|||
background: #f5f5f5; |
|||
color: #333; |
|||
border: 1px solid #ddd; |
|||
} |
|||
.btn-secondary:hover { |
|||
background: #e0e0e0; |
|||
} |
|||
.btn-tertiary { |
|||
background: transparent; |
|||
color: #666; |
|||
} |
|||
.btn-tertiary:hover { |
|||
background: #f5f5f5; |
|||
} |
|||
.log-container { |
|||
background: #f8f9fa; |
|||
border: 1px solid #dee2e6; |
|||
border-radius: 6px; |
|||
padding: 15px; |
|||
height: 300px; |
|||
overflow-y: auto; |
|||
font-family: 'Courier New', monospace; |
|||
font-size: 12px; |
|||
} |
|||
.timestamp { |
|||
color: #666; |
|||
font-weight: bold; |
|||
} |
|||
pre { |
|||
background: #e9ecef; |
|||
padding: 8px; |
|||
border-radius: 4px; |
|||
margin: 5px 0; |
|||
overflow-x: auto; |
|||
} |
|||
.clear-button { |
|||
background: #dc3545; |
|||
margin-top: 10px; |
|||
width: 100%; |
|||
} |
|||
.clear-button:hover { |
|||
background: #c82333; |
|||
} |
|||
.permission-status { |
|||
padding: 16px; |
|||
border-radius: 8px; |
|||
margin: 16px 0; |
|||
} |
|||
.status-granted { |
|||
background: #e8f5e8; |
|||
border: 1px solid #4caf50; |
|||
} |
|||
.status-denied { |
|||
background: #ffebee; |
|||
border: 1px solid #f44336; |
|||
} |
|||
.status-indicator { |
|||
display: flex; |
|||
align-items: center; |
|||
gap: 8px; |
|||
margin-bottom: 12px; |
|||
} |
|||
.status-icon { |
|||
font-size: 20px; |
|||
font-weight: bold; |
|||
} |
|||
.permission-details { |
|||
margin: 12px 0; |
|||
} |
|||
.permission-item { |
|||
display: flex; |
|||
justify-content: space-between; |
|||
margin: 8px 0; |
|||
} |
|||
.granted { |
|||
color: #4caf50; |
|||
font-weight: bold; |
|||
} |
|||
.denied { |
|||
color: #f44336; |
|||
font-weight: bold; |
|||
} |
|||
.settings-panel { |
|||
background: white; |
|||
border-radius: 8px; |
|||
padding: 20px; |
|||
margin: 16px 0; |
|||
} |
|||
.setting-group { |
|||
margin: 20px 0; |
|||
} |
|||
.setting-label { |
|||
display: flex; |
|||
align-items: center; |
|||
gap: 8px; |
|||
font-weight: 500; |
|||
color: #333; |
|||
} |
|||
.checkbox-group { |
|||
display: grid; |
|||
grid-template-columns: 1fr 1fr; |
|||
gap: 12px; |
|||
margin-top: 8px; |
|||
} |
|||
.preference-grid { |
|||
display: grid; |
|||
grid-template-columns: 1fr 1fr 1fr; |
|||
gap: 16px; |
|||
margin-top: 8px; |
|||
} |
|||
.setting-actions { |
|||
display: flex; |
|||
gap: 12px; |
|||
margin-top: 24px; |
|||
} |
|||
.status-dashboard { |
|||
background: white; |
|||
border-radius: 8px; |
|||
padding: 20px; |
|||
margin: 16px 0; |
|||
} |
|||
.status-grid { |
|||
display: grid; |
|||
grid-template-columns: 1fr 1fr; |
|||
gap: 16px; |
|||
margin: 20px 0; |
|||
} |
|||
.status-item { |
|||
padding: 16px; |
|||
background: #f8f9fa; |
|||
border-radius: 8px; |
|||
} |
|||
.status-label { |
|||
font-size: 14px; |
|||
color: #666; |
|||
margin-bottom: 8px; |
|||
} |
|||
.status-value { |
|||
font-size: 18px; |
|||
font-weight: bold; |
|||
color: #333; |
|||
} |
|||
.status-value.active { |
|||
color: #4caf50; |
|||
} |
|||
.status-value.warning { |
|||
color: #ff9800; |
|||
} |
|||
.status-value.error { |
|||
color: #f44336; |
|||
} |
|||
.performance-metrics { |
|||
margin: 24px 0; |
|||
padding: 16px; |
|||
background: #f8f9fa; |
|||
border-radius: 8px; |
|||
} |
|||
.metrics-grid { |
|||
display: grid; |
|||
grid-template-columns: 1fr 1fr; |
|||
gap: 16px; |
|||
margin-top: 12px; |
|||
} |
|||
.metric-item { |
|||
display: flex; |
|||
justify-content: space-between; |
|||
} |
|||
.metric-label { |
|||
color: #666; |
|||
} |
|||
.metric-value { |
|||
font-weight: bold; |
|||
color: #333; |
|||
} |
|||
.error-display { |
|||
background: #ffebee; |
|||
border: 1px solid #f44336; |
|||
border-radius: 8px; |
|||
padding: 16px; |
|||
margin: 16px 0; |
|||
} |
|||
.error-icon { |
|||
font-size: 24px; |
|||
margin-bottom: 12px; |
|||
} |
|||
.error-content h3 { |
|||
margin: 0 0 8px 0; |
|||
color: #d32f2f; |
|||
} |
|||
.error-message { |
|||
color: #666; |
|||
margin: 8px 0; |
|||
} |
|||
.error-code { |
|||
font-size: 12px; |
|||
color: #999; |
|||
font-family: monospace; |
|||
} |
|||
.error-actions { |
|||
display: flex; |
|||
gap: 12px; |
|||
margin-top: 16px; |
|||
} |
|||
.dialog-overlay { |
|||
position: fixed; |
|||
top: 0; |
|||
left: 0; |
|||
right: 0; |
|||
bottom: 0; |
|||
background: rgba(0, 0, 0, 0.5); |
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: center; |
|||
z-index: 1000; |
|||
} |
|||
.dialog-content { |
|||
background: white; |
|||
border-radius: 12px; |
|||
padding: 24px; |
|||
max-width: 400px; |
|||
margin: 20px; |
|||
} |
|||
.dialog-content h2 { |
|||
margin: 0 0 16px 0; |
|||
color: #333; |
|||
} |
|||
.dialog-content p { |
|||
margin: 0 0 16px 0; |
|||
color: #666; |
|||
line-height: 1.5; |
|||
} |
|||
.dialog-content ul { |
|||
margin: 0 0 24px 0; |
|||
padding-left: 20px; |
|||
} |
|||
.dialog-content li { |
|||
margin: 8px 0; |
|||
color: #666; |
|||
} |
|||
.dialog-actions { |
|||
display: flex; |
|||
flex-direction: column; |
|||
gap: 12px; |
|||
} |
|||
.hidden { |
|||
display: none; |
|||
} |
|||
@media (max-width: 768px) { |
|||
.status-grid { |
|||
grid-template-columns: 1fr; |
|||
} |
|||
.preference-grid { |
|||
grid-template-columns: 1fr; |
|||
} |
|||
.checkbox-group { |
|||
grid-template-columns: 1fr; |
|||
} |
|||
.metrics-grid { |
|||
grid-template-columns: 1fr; |
|||
} |
|||
} |
|||
</style> |
|||
</head> |
|||
<body> |
|||
<div class="container"> |
|||
<h1>📱 TimeSafari Daily Notification - Android Test</h1> |
|||
|
|||
<div class="status" id="status">Ready</div> |
|||
|
|||
<!-- Permission Management Section --> |
|||
<div class="ui-section"> |
|||
<h3>🔐 Permission Management</h3> |
|||
<div id="permission-status-container"></div> |
|||
<div class="button-grid"> |
|||
<button id="check-permissions" class="btn-secondary">Check Permissions</button> |
|||
<button id="request-permissions" class="btn-primary">Request Permissions</button> |
|||
<button id="open-settings" class="btn-tertiary">Open Settings</button> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- Configuration Section --> |
|||
<div class="ui-section"> |
|||
<h3>⚙️ Configuration</h3> |
|||
<div id="settings-container"></div> |
|||
<div class="button-grid"> |
|||
<button id="configure">Configure TimeSafari</button> |
|||
<button id="test-notification" class="btn-secondary">Test Notification</button> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- Status Monitoring Section --> |
|||
<div class="ui-section"> |
|||
<h3>📊 Status Monitoring</h3> |
|||
<div id="status-container"></div> |
|||
<div class="button-grid"> |
|||
<button id="check-status">Check Status</button> |
|||
<button id="refresh-status" class="btn-secondary">Refresh</button> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- Platform-Specific Section --> |
|||
<div class="ui-section"> |
|||
<h3>🤖 Android-Specific Features</h3> |
|||
<div id="battery-dialog-container"></div> |
|||
<div class="button-grid"> |
|||
<button id="battery-status">Check Battery Optimization</button> |
|||
<button id="exact-alarm-status">Check Exact Alarm</button> |
|||
<button id="reboot-recovery">Check Reboot Recovery</button> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- Testing Section --> |
|||
<div class="ui-section"> |
|||
<h3>🧪 Testing & Debug</h3> |
|||
<div class="button-grid"> |
|||
<button id="schedule">Schedule Community Notifications</button> |
|||
<button id="endorser-api">Test Endorser.ch API</button> |
|||
<button id="callbacks">Register Callbacks</button> |
|||
<button id="performance">Performance Metrics</button> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- Phase 4: TimeSafari Components Testing --> |
|||
<div class="ui-section"> |
|||
<h3>🚀 Phase 4: TimeSafari Components</h3> |
|||
<div class="button-grid"> |
|||
<button id="test-security-manager" class="btn-primary">Test SecurityManager</button> |
|||
<button id="test-endorser-api-client" class="btn-primary">Test EndorserAPIClient</button> |
|||
<button id="test-notification-manager" class="btn-primary">Test NotificationManager</button> |
|||
<button id="test-phase4-integration" class="btn-secondary">Test Complete Integration</button> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- Static Daily Reminders Testing --> |
|||
<div class="ui-section"> |
|||
<h3>⏰ Static Daily Reminders</h3> |
|||
<div class="button-grid"> |
|||
<button id="schedule-reminder" class="btn-primary">Schedule Daily Reminder</button> |
|||
<button id="cancel-reminder" class="btn-primary">Cancel Reminder</button> |
|||
<button id="get-reminders" class="btn-primary">Get Scheduled Reminders</button> |
|||
<button id="update-reminder" class="btn-primary">Update Reminder</button> |
|||
</div> |
|||
<div class="form-section"> |
|||
<h4>Reminder Configuration</h4> |
|||
<div class="form-group"> |
|||
<label for="reminder-id">Reminder ID:</label> |
|||
<input type="text" id="reminder-id" value="morning_checkin" placeholder="e.g., morning_checkin"> |
|||
</div> |
|||
<div class="form-group"> |
|||
<label for="reminder-title">Title:</label> |
|||
<input type="text" id="reminder-title" value="Good Morning!" placeholder="Reminder title"> |
|||
</div> |
|||
<div class="form-group"> |
|||
<label for="reminder-body">Body:</label> |
|||
<input type="text" id="reminder-body" value="Time to check your TimeSafari community updates" placeholder="Reminder message"> |
|||
</div> |
|||
<div class="form-group"> |
|||
<label for="reminder-time">Time (HH:mm):</label> |
|||
<input type="text" id="reminder-time" value="09:00" placeholder="09:00"> |
|||
</div> |
|||
<div class="form-group"> |
|||
<label> |
|||
<input type="checkbox" id="reminder-sound" checked> Sound |
|||
</label> |
|||
<label> |
|||
<input type="checkbox" id="reminder-vibration" checked> Vibration |
|||
</label> |
|||
</div> |
|||
<div class="form-group"> |
|||
<label for="reminder-priority">Priority:</label> |
|||
<select id="reminder-priority"> |
|||
<option value="low">Low</option> |
|||
<option value="normal" selected>Normal</option> |
|||
<option value="high">High</option> |
|||
</select> |
|||
</div> |
|||
<div class="form-group"> |
|||
<label> |
|||
<input type="checkbox" id="reminder-repeat" checked> Repeat Daily |
|||
</label> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- Error Handling Section --> |
|||
<div class="ui-section"> |
|||
<h3>⚠️ Error Handling</h3> |
|||
<div id="error-container"></div> |
|||
</div> |
|||
|
|||
<!-- Log Section --> |
|||
<div class="ui-section"> |
|||
<h3>📝 Activity Log</h3> |
|||
<div class="log-container" id="log"></div> |
|||
<button class="clear-button" id="clear-log">Clear Log</button> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- Permission Dialog --> |
|||
<div id="permission-dialog-container"></div> |
|||
|
|||
<script type="module" src="index.js"></script> |
|||
</body> |
|||
</html> |
File diff suppressed because it is too large
@ -0,0 +1,42 @@ |
|||
/** |
|||
* Main Application Entry Point |
|||
* |
|||
* Vue 3 + TypeScript + Capacitor + vue-facing-decorator setup |
|||
* |
|||
* @author Matthew Raymer |
|||
* @version 1.0.0 |
|||
*/ |
|||
|
|||
import { createApp } from 'vue' |
|||
import { createPinia } from 'pinia' |
|||
import { Capacitor } from '@capacitor/core' |
|||
import App from './App.vue' |
|||
import router from './router' |
|||
|
|||
// Create Vue app instance
|
|||
const app = createApp(App) |
|||
|
|||
// Configure Pinia for state management
|
|||
const pinia = createPinia() |
|||
app.use(pinia) |
|||
|
|||
// Configure Vue Router
|
|||
app.use(router) |
|||
|
|||
// Global error handler for Capacitor
|
|||
app.config.errorHandler = (err, _instance, info) => { |
|||
console.error('Vue Error:', err, info) |
|||
if (Capacitor.isNativePlatform()) { |
|||
// Log to native platform
|
|||
console.error('Native Platform Error:', err) |
|||
} |
|||
} |
|||
|
|||
// Mount the app
|
|||
app.mount('#app') |
|||
|
|||
// Log platform information
|
|||
console.log('🚀 Daily Notification Test App Started') |
|||
console.log('📱 Platform:', Capacitor.getPlatform()) |
|||
console.log('🔧 Native Platform:', Capacitor.isNativePlatform()) |
|||
console.log('🌐 Web Platform:', Capacitor.isPluginAvailable('App')) |
@ -0,0 +1,109 @@ |
|||
/** |
|||
* Vue Router Configuration |
|||
* |
|||
* @author Matthew Raymer |
|||
* @version 1.0.0 |
|||
*/ |
|||
|
|||
import { createRouter, createWebHistory } from 'vue-router' |
|||
import type { RouteRecordRaw } from 'vue-router' |
|||
|
|||
const routes: RouteRecordRaw[] = [ |
|||
{ |
|||
path: '/', |
|||
name: 'Home', |
|||
component: () => import('@/views/HomeView.vue'), |
|||
meta: { |
|||
title: 'Daily Notification Test', |
|||
requiresAuth: false |
|||
} |
|||
}, |
|||
{ |
|||
path: '/notifications', |
|||
name: 'Notifications', |
|||
component: () => import('@/views/NotificationsView.vue'), |
|||
meta: { |
|||
title: 'Notification Management', |
|||
requiresAuth: false |
|||
} |
|||
}, |
|||
{ |
|||
path: '/schedule', |
|||
name: 'Schedule', |
|||
component: () => import('@/views/ScheduleView.vue'), |
|||
meta: { |
|||
title: 'Schedule Notification', |
|||
requiresAuth: false |
|||
} |
|||
}, |
|||
{ |
|||
path: '/status', |
|||
name: 'Status', |
|||
component: () => import('@/views/StatusView.vue'), |
|||
meta: { |
|||
title: 'System Status', |
|||
requiresAuth: false |
|||
} |
|||
}, |
|||
{ |
|||
path: '/history', |
|||
name: 'History', |
|||
component: () => import('@/views/HistoryView.vue'), |
|||
meta: { |
|||
title: 'Notification History', |
|||
requiresAuth: false |
|||
} |
|||
}, |
|||
{ |
|||
path: '/logs', |
|||
name: 'Logs', |
|||
component: () => import('@/views/LogsView.vue'), |
|||
meta: { |
|||
title: 'Android Logs', |
|||
requiresAuth: false |
|||
} |
|||
}, |
|||
{ |
|||
path: '/settings', |
|||
name: 'Settings', |
|||
component: () => import('@/views/SettingsView.vue'), |
|||
meta: { |
|||
title: 'Settings', |
|||
requiresAuth: false |
|||
} |
|||
}, |
|||
{ |
|||
path: '/:pathMatch(.*)*', |
|||
name: 'NotFound', |
|||
component: () => import('@/views/NotFoundView.vue'), |
|||
meta: { |
|||
title: 'Page Not Found', |
|||
requiresAuth: false |
|||
} |
|||
} |
|||
] |
|||
|
|||
const router = createRouter({ |
|||
history: createWebHistory(), |
|||
routes |
|||
}) |
|||
|
|||
// Global navigation guards
|
|||
router.beforeEach((to, from, next) => { |
|||
// Set page title
|
|||
if (to.meta?.title) { |
|||
document.title = `${to.meta.title} - Daily Notification Test` |
|||
} |
|||
|
|||
// Add loading state
|
|||
console.log(`🔄 Navigating from ${String(from.name) || 'unknown'} to ${String(to.name) || 'unknown'}`) |
|||
|
|||
next() |
|||
}) |
|||
|
|||
router.afterEach((to) => { |
|||
// Clear any previous errors on successful navigation
|
|||
console.log(`✅ Navigation completed: ${String(to.name) || 'unknown'}`) |
|||
}) |
|||
|
|||
export default router |
@ -0,0 +1,108 @@ |
|||
/** |
|||
* App Store - Global Application State |
|||
* |
|||
* Pinia store for managing global app state, loading, and errors |
|||
* |
|||
* @author Matthew Raymer |
|||
* @version 1.0.0 |
|||
*/ |
|||
|
|||
import { defineStore } from 'pinia' |
|||
import { ref, computed } from 'vue' |
|||
|
|||
export interface AppState { |
|||
isLoading: boolean |
|||
errorMessage: string | null |
|||
platform: string |
|||
isNative: boolean |
|||
notificationStatus: NotificationStatus | null |
|||
} |
|||
|
|||
export interface NotificationStatus { |
|||
canScheduleNow: boolean |
|||
postNotificationsGranted: boolean |
|||
channelEnabled: boolean |
|||
channelImportance: number |
|||
channelId: string |
|||
exactAlarmsGranted: boolean |
|||
exactAlarmsSupported: boolean |
|||
androidVersion: number |
|||
nextScheduledAt: number |
|||
} |
|||
|
|||
export const useAppStore = defineStore('app', () => { |
|||
// State
|
|||
const isLoading = ref(false) |
|||
const errorMessage = ref<string | null>(null) |
|||
const platform = ref('web') |
|||
const isNative = ref(false) |
|||
const notificationStatus = ref<NotificationStatus | null>(null) |
|||
|
|||
// Getters
|
|||
const hasError = computed(() => errorMessage.value !== null) |
|||
const isNotificationReady = computed(() => |
|||
notificationStatus.value?.canScheduleNow ?? false |
|||
) |
|||
|
|||
// Actions
|
|||
function setLoading(loading: boolean): void { |
|||
isLoading.value = loading |
|||
} |
|||
|
|||
function setError(message: string): void { |
|||
errorMessage.value = message |
|||
console.error('App Error:', message) |
|||
} |
|||
|
|||
function clearError(): void { |
|||
errorMessage.value = null |
|||
} |
|||
|
|||
function setPlatform(platformName: string, native: boolean): void { |
|||
platform.value = platformName |
|||
isNative.value = native |
|||
} |
|||
|
|||
function setNotificationStatus(status: NotificationStatus): void { |
|||
notificationStatus.value = status |
|||
} |
|||
|
|||
function updateNotificationStatus(status: Partial<NotificationStatus>): void { |
|||
if (notificationStatus.value) { |
|||
notificationStatus.value = { ...notificationStatus.value, ...status } |
|||
} else { |
|||
notificationStatus.value = status as NotificationStatus |
|||
} |
|||
} |
|||
|
|||
// Reset store
|
|||
function $reset(): void { |
|||
isLoading.value = false |
|||
errorMessage.value = null |
|||
platform.value = 'web' |
|||
isNative.value = false |
|||
notificationStatus.value = null |
|||
} |
|||
|
|||
return { |
|||
// State
|
|||
isLoading, |
|||
errorMessage, |
|||
platform, |
|||
isNative, |
|||
notificationStatus, |
|||
|
|||
// Getters
|
|||
hasError, |
|||
isNotificationReady, |
|||
|
|||
// Actions
|
|||
setLoading, |
|||
setError, |
|||
clearError, |
|||
setPlatform, |
|||
setNotificationStatus, |
|||
updateNotificationStatus, |
|||
$reset |
|||
} |
|||
}) |
@ -0,0 +1,299 @@ |
|||
/** |
|||
* Notifications Store - Notification Management State |
|||
* |
|||
* Pinia store for managing notification scheduling, status, and history |
|||
* |
|||
* @author Matthew Raymer |
|||
* @version 1.0.0 |
|||
*/ |
|||
|
|||
import { defineStore } from 'pinia' |
|||
import { ref, computed } from 'vue' |
|||
import { Capacitor } from '@capacitor/core' |
|||
|
|||
export interface ScheduledNotification { |
|||
id: string |
|||
title: string |
|||
body: string |
|||
scheduledTime: number |
|||
deliveredAt?: number |
|||
status: 'scheduled' | 'delivered' | 'cancelled' |
|||
} |
|||
|
|||
export interface NotificationHistory { |
|||
id: string |
|||
title: string |
|||
body: string |
|||
scheduledTime: number |
|||
deliveredAt: number |
|||
clicked: boolean |
|||
dismissed: boolean |
|||
} |
|||
|
|||
export const useNotificationsStore = defineStore('notifications', () => { |
|||
// State
|
|||
const scheduledNotifications = ref<ScheduledNotification[]>([]) |
|||
const notificationHistory = ref<NotificationHistory[]>([]) |
|||
const isScheduling = ref(false) |
|||
const lastError = ref<string | null>(null) |
|||
|
|||
// Getters
|
|||
const hasScheduledNotifications = computed(() => |
|||
scheduledNotifications.value.length > 0 |
|||
) |
|||
|
|||
const nextNotification = computed(() => { |
|||
const future = scheduledNotifications.value |
|||
.filter(n => n.status === 'scheduled' && n.scheduledTime > Date.now()) |
|||
.sort((a, b) => a.scheduledTime - b.scheduledTime) |
|||
return future.length > 0 ? future[0] : null |
|||
}) |
|||
|
|||
const notificationCount = computed(() => |
|||
scheduledNotifications.value.length |
|||
) |
|||
|
|||
// Actions
|
|||
async function scheduleNotification(options: { |
|||
time: string |
|||
title?: string |
|||
body?: string |
|||
sound?: boolean |
|||
priority?: string |
|||
url?: string |
|||
}): Promise<void> { |
|||
if (!Capacitor.isNativePlatform()) { |
|||
throw new Error('DailyNotification plugin only available on native platforms') |
|||
} |
|||
|
|||
if (!window.DailyNotification) { |
|||
throw new Error('DailyNotification plugin not loaded. Please restart the app.') |
|||
} |
|||
|
|||
try { |
|||
isScheduling.value = true |
|||
lastError.value = null |
|||
|
|||
await window.DailyNotification.scheduleDailyNotification(options) |
|||
|
|||
// Add to local state (we'll get the actual ID from the plugin)
|
|||
const notification: ScheduledNotification = { |
|||
id: `temp-${Date.now()}`, |
|||
title: options.title || 'Daily Update', |
|||
body: options.body || 'Your daily notification is ready', |
|||
scheduledTime: parseTimeToTimestamp(options.time), |
|||
status: 'scheduled' |
|||
} |
|||
|
|||
scheduledNotifications.value.push(notification) |
|||
|
|||
console.log('✅ Notification scheduled successfully') |
|||
|
|||
} catch (error) { |
|||
const errorMessage = (error as Error).message |
|||
lastError.value = errorMessage |
|||
console.error('❌ Failed to schedule notification:', errorMessage) |
|||
throw error |
|||
} finally { |
|||
isScheduling.value = false |
|||
} |
|||
} |
|||
|
|||
async function scheduleReminder(options: { |
|||
id: string |
|||
title: string |
|||
body: string |
|||
time: string |
|||
sound?: boolean |
|||
vibration?: boolean |
|||
priority?: string |
|||
repeatDaily?: boolean |
|||
timezone?: string |
|||
}): Promise<void> { |
|||
if (!Capacitor.isNativePlatform()) { |
|||
throw new Error('DailyNotification plugin only available on native platforms') |
|||
} |
|||
|
|||
if (!window.DailyNotification) { |
|||
throw new Error('DailyNotification plugin not loaded. Please restart the app.') |
|||
} |
|||
|
|||
try { |
|||
isScheduling.value = true |
|||
lastError.value = null |
|||
|
|||
await window.DailyNotification.scheduleDailyReminder(options) |
|||
|
|||
// Add to local state
|
|||
const notification: ScheduledNotification = { |
|||
id: options.id, |
|||
title: options.title, |
|||
body: options.body, |
|||
scheduledTime: parseTimeToTimestamp(options.time), |
|||
status: 'scheduled' |
|||
} |
|||
|
|||
scheduledNotifications.value.push(notification) |
|||
|
|||
console.log('✅ Reminder scheduled successfully') |
|||
|
|||
} catch (error) { |
|||
const errorMessage = (error as Error).message |
|||
lastError.value = errorMessage |
|||
console.error('❌ Failed to schedule reminder:', errorMessage) |
|||
throw error |
|||
} finally { |
|||
isScheduling.value = false |
|||
} |
|||
} |
|||
|
|||
async function cancelReminder(reminderId: string): Promise<void> { |
|||
if (!Capacitor.isNativePlatform()) { |
|||
throw new Error('DailyNotification plugin only available on native platforms') |
|||
} |
|||
|
|||
if (!window.DailyNotification) { |
|||
throw new Error('DailyNotification plugin not loaded. Please restart the app.') |
|||
} |
|||
|
|||
try { |
|||
isScheduling.value = true |
|||
lastError.value = null |
|||
|
|||
await window.DailyNotification.cancelDailyReminder({ reminderId }) |
|||
|
|||
// Update local state
|
|||
const index = scheduledNotifications.value.findIndex(n => n.id === reminderId) |
|||
if (index !== -1) { |
|||
scheduledNotifications.value[index].status = 'cancelled' |
|||
} |
|||
|
|||
console.log('✅ Reminder cancelled successfully') |
|||
|
|||
} catch (error) { |
|||
const errorMessage = (error as Error).message |
|||
lastError.value = errorMessage |
|||
console.error('❌ Failed to cancel reminder:', errorMessage) |
|||
throw error |
|||
} finally { |
|||
isScheduling.value = false |
|||
} |
|||
} |
|||
|
|||
async function checkStatus(): Promise<void> { |
|||
if (!Capacitor.isNativePlatform()) { |
|||
console.warn('DailyNotification plugin only available on native platforms') |
|||
return |
|||
} |
|||
|
|||
if (!window.DailyNotification) { |
|||
console.warn('DailyNotification plugin not loaded') |
|||
return |
|||
} |
|||
|
|||
try { |
|||
const status = await window.DailyNotification.checkStatus() |
|||
console.log('📊 Notification Status:', status) |
|||
|
|||
// Update app store with status
|
|||
const { useAppStore } = await import('@/stores/app') |
|||
const appStore = useAppStore() |
|||
appStore.setNotificationStatus(status) |
|||
|
|||
} catch (error) { |
|||
console.error('❌ Failed to check notification status:', error) |
|||
} |
|||
} |
|||
|
|||
async function getLastNotification(): Promise<void> { |
|||
if (!Capacitor.isNativePlatform()) { |
|||
console.warn('DailyNotification plugin only available on native platforms') |
|||
return |
|||
} |
|||
|
|||
if (!window.DailyNotification) { |
|||
console.warn('DailyNotification plugin not loaded') |
|||
return |
|||
} |
|||
|
|||
try { |
|||
const lastNotification = await window.DailyNotification.getLastNotification() |
|||
console.log('📱 Last Notification:', lastNotification) |
|||
|
|||
// Add to history
|
|||
const historyItem: NotificationHistory = { |
|||
id: lastNotification.id, |
|||
title: lastNotification.title, |
|||
body: lastNotification.body, |
|||
scheduledTime: lastNotification.scheduledTime, |
|||
deliveredAt: lastNotification.deliveredAt, |
|||
clicked: false, |
|||
dismissed: false |
|||
} |
|||
|
|||
notificationHistory.value.unshift(historyItem) |
|||
|
|||
// Keep only last 50 items
|
|||
if (notificationHistory.value.length > 50) { |
|||
notificationHistory.value = notificationHistory.value.slice(0, 50) |
|||
} |
|||
|
|||
} catch (error) { |
|||
console.error('❌ Failed to get last notification:', error) |
|||
} |
|||
} |
|||
|
|||
function clearError(): void { |
|||
lastError.value = null |
|||
} |
|||
|
|||
function clearHistory(): void { |
|||
notificationHistory.value = [] |
|||
} |
|||
|
|||
// Helper function to parse time string to timestamp
|
|||
function parseTimeToTimestamp(timeString: string): number { |
|||
const [hours, minutes] = timeString.split(':').map(Number) |
|||
const now = new Date() |
|||
const scheduled = new Date() |
|||
scheduled.setHours(hours, minutes, 0, 0) |
|||
|
|||
// If time has passed today, schedule for tomorrow
|
|||
if (scheduled <= now) { |
|||
scheduled.setDate(scheduled.getDate() + 1) |
|||
} |
|||
|
|||
return scheduled.getTime() |
|||
} |
|||
|
|||
// Reset store
|
|||
function $reset(): void { |
|||
scheduledNotifications.value = [] |
|||
notificationHistory.value = [] |
|||
isScheduling.value = false |
|||
lastError.value = null |
|||
} |
|||
|
|||
return { |
|||
// State
|
|||
scheduledNotifications, |
|||
notificationHistory, |
|||
isScheduling, |
|||
lastError, |
|||
|
|||
// Getters
|
|||
hasScheduledNotifications, |
|||
nextNotification, |
|||
notificationCount, |
|||
|
|||
// Actions
|
|||
scheduleNotification, |
|||
scheduleReminder, |
|||
cancelReminder, |
|||
checkStatus, |
|||
getLastNotification, |
|||
clearError, |
|||
clearHistory, |
|||
$reset |
|||
} |
|||
}) |
@ -0,0 +1,144 @@ |
|||
<!-- |
|||
/** |
|||
* History View - Notification History |
|||
* |
|||
* @author Matthew Raymer |
|||
* @version 1.0.0 |
|||
*/ |
|||
--> |
|||
|
|||
<template> |
|||
<div class="history-view"> |
|||
<div class="view-header"> |
|||
<h1 class="page-title">📋 Notification History</h1> |
|||
<p class="page-subtitle">View delivered notifications</p> |
|||
</div> |
|||
|
|||
<div v-if="hasHistory" class="history-list"> |
|||
<HistoryItem |
|||
v-for="item in notificationHistory" |
|||
:key="item.id" |
|||
:history="item" |
|||
/> |
|||
</div> |
|||
|
|||
<div v-else class="no-history"> |
|||
<div class="empty-state"> |
|||
<span class="empty-icon">📋</span> |
|||
<h3 class="empty-title">No History Available</h3> |
|||
<p class="empty-description"> |
|||
Notification history will appear here after notifications are delivered |
|||
</p> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</template> |
|||
|
|||
<script lang="ts"> |
|||
import { Component, Vue } from 'vue-facing-decorator' |
|||
import HistoryItem from '@/components/items/HistoryItem.vue' |
|||
import { useNotificationsStore } from '@/stores/notifications' |
|||
|
|||
@Component({ |
|||
components: { |
|||
HistoryItem |
|||
} |
|||
}) |
|||
export default class HistoryView extends Vue { |
|||
private notificationsStore = useNotificationsStore() |
|||
|
|||
get hasHistory(): boolean { |
|||
return this.notificationHistory.length > 0 |
|||
} |
|||
|
|||
get notificationHistory() { |
|||
return this.notificationsStore.notificationHistory |
|||
} |
|||
} |
|||
</script> |
|||
|
|||
<style scoped> |
|||
.history-view { |
|||
max-width: 800px; |
|||
margin: 0 auto; |
|||
padding: 20px; |
|||
} |
|||
|
|||
.view-header { |
|||
text-align: center; |
|||
margin-bottom: 32px; |
|||
} |
|||
|
|||
.page-title { |
|||
font-size: 2rem; |
|||
font-weight: 700; |
|||
color: white; |
|||
margin: 0 0 8px 0; |
|||
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); |
|||
} |
|||
|
|||
.page-subtitle { |
|||
font-size: 1.1rem; |
|||
color: rgba(255, 255, 255, 0.9); |
|||
margin: 0; |
|||
font-weight: 300; |
|||
} |
|||
|
|||
.history-list { |
|||
display: flex; |
|||
flex-direction: column; |
|||
gap: 12px; |
|||
} |
|||
|
|||
.no-history { |
|||
display: flex; |
|||
justify-content: center; |
|||
align-items: center; |
|||
min-height: 400px; |
|||
} |
|||
|
|||
.empty-state { |
|||
text-align: center; |
|||
padding: 40px 20px; |
|||
background: rgba(255, 255, 255, 0.1); |
|||
border-radius: 16px; |
|||
backdrop-filter: blur(10px); |
|||
border: 1px solid rgba(255, 255, 255, 0.2); |
|||
} |
|||
|
|||
.empty-icon { |
|||
font-size: 4rem; |
|||
display: block; |
|||
margin-bottom: 16px; |
|||
} |
|||
|
|||
.empty-title { |
|||
font-size: 1.5rem; |
|||
font-weight: 600; |
|||
color: white; |
|||
margin: 0 0 8px 0; |
|||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3); |
|||
} |
|||
|
|||
.empty-description { |
|||
font-size: 1rem; |
|||
color: rgba(255, 255, 255, 0.8); |
|||
margin: 0; |
|||
line-height: 1.5; |
|||
} |
|||
|
|||
/* Responsive design */ |
|||
@media (max-width: 768px) { |
|||
.history-view { |
|||
padding: 16px; |
|||
} |
|||
|
|||
.empty-state { |
|||
padding: 32px 16px; |
|||
} |
|||
|
|||
.empty-icon { |
|||
font-size: 3rem; |
|||
} |
|||
} |
|||
</style> |
@ -0,0 +1,260 @@ |
|||
<!-- |
|||
/** |
|||
* Home View - Main Dashboard |
|||
* |
|||
* Vue 3 + vue-facing-decorator + TypeScript |
|||
* |
|||
* @author Matthew Raymer |
|||
* @version 1.0.0 |
|||
*/ |
|||
--> |
|||
|
|||
<template> |
|||
<div class="home-view"> |
|||
<!-- Welcome Section --> |
|||
<div class="welcome-section"> |
|||
<h1 class="welcome-title"> |
|||
🔔 Daily Notification Test |
|||
</h1> |
|||
<p class="welcome-subtitle"> |
|||
Vue 3 + Vite + Capacitor + vue-facing-decorator |
|||
</p> |
|||
</div> |
|||
|
|||
<!-- Quick Actions --> |
|||
<div class="quick-actions"> |
|||
<h2 class="section-title">Quick Actions</h2> |
|||
<div class="action-grid"> |
|||
<ActionCard |
|||
icon="📅" |
|||
title="Schedule Notification" |
|||
description="Schedule a new daily notification" |
|||
@click="navigateToSchedule" |
|||
:loading="isScheduling" |
|||
/> |
|||
<ActionCard |
|||
icon="📊" |
|||
title="Check Status" |
|||
description="View notification system status" |
|||
@click="checkSystemStatus" |
|||
:loading="isCheckingStatus" |
|||
/> |
|||
<ActionCard |
|||
icon="📱" |
|||
title="View Notifications" |
|||
description="Manage scheduled notifications" |
|||
@click="navigateToNotifications" |
|||
/> |
|||
<ActionCard |
|||
icon="📋" |
|||
title="View History" |
|||
description="See notification delivery history" |
|||
@click="navigateToHistory" |
|||
/> |
|||
<ActionCard |
|||
icon="📜" |
|||
title="View Logs" |
|||
description="View and copy Android logs" |
|||
@click="navigateToLogs" |
|||
/> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- System Status --> |
|||
<div class="status-section"> |
|||
<h2 class="section-title">System Status</h2> |
|||
<StatusCard :status="notificationStatus" /> |
|||
</div> |
|||
|
|||
<!-- Next Notification --> |
|||
<div v-if="nextNotification" class="next-notification"> |
|||
<h2 class="section-title">Next Notification</h2> |
|||
<NotificationCard :notification="nextNotification" /> |
|||
</div> |
|||
|
|||
<!-- Recent Activity --> |
|||
<div v-if="recentHistory.length > 0" class="recent-activity"> |
|||
<h2 class="section-title">Recent Activity</h2> |
|||
<div class="activity-list"> |
|||
<ActivityItem |
|||
v-for="item in recentHistory" |
|||
:key="item.id" |
|||
:activity="item" |
|||
/> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</template> |
|||
|
|||
<script lang="ts"> |
|||
import { Component, Vue } from 'vue-facing-decorator' |
|||
import { Capacitor } from '@capacitor/core' |
|||
import ActionCard from '@/components/cards/ActionCard.vue' |
|||
import StatusCard from '@/components/cards/StatusCard.vue' |
|||
import NotificationCard from '@/components/cards/NotificationCard.vue' |
|||
import ActivityItem from '@/components/items/ActivityItem.vue' |
|||
import { useAppStore } from '@/stores/app' |
|||
import { useNotificationsStore } from '@/stores/notifications' |
|||
|
|||
@Component({ |
|||
components: { |
|||
ActionCard, |
|||
StatusCard, |
|||
NotificationCard, |
|||
ActivityItem |
|||
} |
|||
}) |
|||
export default class HomeView extends Vue { |
|||
private appStore = useAppStore() |
|||
private notificationsStore = useNotificationsStore() |
|||
isCheckingStatus = false |
|||
|
|||
get isScheduling(): boolean { |
|||
return this.notificationsStore.isScheduling |
|||
} |
|||
|
|||
get notificationStatus() { |
|||
return this.appStore.notificationStatus |
|||
} |
|||
|
|||
get nextNotification() { |
|||
return this.notificationsStore.nextNotification |
|||
} |
|||
|
|||
get recentHistory() { |
|||
return this.notificationsStore.notificationHistory.slice(0, 5) |
|||
} |
|||
|
|||
async mounted(): Promise<void> { |
|||
await this.initializeHome() |
|||
} |
|||
|
|||
private async initializeHome(): Promise<void> { |
|||
try { |
|||
// Check system status |
|||
await this.checkSystemStatus() |
|||
|
|||
// Load recent notifications |
|||
await this.notificationsStore.getLastNotification() |
|||
|
|||
} catch (error) { |
|||
console.error('❌ Failed to initialize home:', error) |
|||
this.appStore.setError('Failed to load home data: ' + (error as Error).message) |
|||
} |
|||
} |
|||
|
|||
async checkSystemStatus(): Promise<void> { |
|||
if (!Capacitor.isNativePlatform()) { |
|||
return |
|||
} |
|||
|
|||
try { |
|||
this.isCheckingStatus = true |
|||
await this.notificationsStore.checkStatus() |
|||
} catch (error) { |
|||
console.error('❌ Failed to check system status:', error) |
|||
} finally { |
|||
this.isCheckingStatus = false |
|||
} |
|||
} |
|||
|
|||
navigateToSchedule(): void { |
|||
this.$router.push('/schedule') |
|||
} |
|||
|
|||
navigateToNotifications(): void { |
|||
this.$router.push('/notifications') |
|||
} |
|||
|
|||
navigateToHistory(): void { |
|||
this.$router.push('/history') |
|||
} |
|||
|
|||
navigateToLogs(): void { |
|||
this.$router.push('/logs') |
|||
} |
|||
} |
|||
</script> |
|||
|
|||
<style scoped> |
|||
.home-view { |
|||
max-width: 1200px; |
|||
margin: 0 auto; |
|||
padding: 20px; |
|||
} |
|||
|
|||
.welcome-section { |
|||
text-align: center; |
|||
margin-bottom: 32px; |
|||
padding: 24px; |
|||
background: rgba(255, 255, 255, 0.1); |
|||
border-radius: 16px; |
|||
backdrop-filter: blur(10px); |
|||
} |
|||
|
|||
.welcome-title { |
|||
font-size: 2.5rem; |
|||
font-weight: 700; |
|||
color: white; |
|||
margin: 0 0 8px 0; |
|||
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); |
|||
} |
|||
|
|||
.welcome-subtitle { |
|||
font-size: 1.1rem; |
|||
color: rgba(255, 255, 255, 0.9); |
|||
margin: 0; |
|||
font-weight: 300; |
|||
} |
|||
|
|||
.section-title { |
|||
font-size: 1.5rem; |
|||
font-weight: 600; |
|||
color: white; |
|||
margin: 0 0 16px 0; |
|||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3); |
|||
} |
|||
|
|||
.quick-actions { |
|||
margin-bottom: 32px; |
|||
} |
|||
|
|||
.action-grid { |
|||
display: grid; |
|||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); |
|||
gap: 16px; |
|||
} |
|||
|
|||
.status-section { |
|||
margin-bottom: 32px; |
|||
} |
|||
|
|||
.next-notification { |
|||
margin-bottom: 32px; |
|||
} |
|||
|
|||
.recent-activity { |
|||
margin-bottom: 32px; |
|||
} |
|||
|
|||
.activity-list { |
|||
display: flex; |
|||
flex-direction: column; |
|||
gap: 12px; |
|||
} |
|||
|
|||
/* Responsive design */ |
|||
@media (max-width: 768px) { |
|||
.home-view { |
|||
padding: 16px; |
|||
} |
|||
|
|||
.welcome-title { |
|||
font-size: 2rem; |
|||
} |
|||
|
|||
.action-grid { |
|||
grid-template-columns: 1fr; |
|||
} |
|||
} |
|||
</style> |
@ -0,0 +1,629 @@ |
|||
<!-- |
|||
/** |
|||
* Logs View - View and Copy Android Logs |
|||
* |
|||
* @author Matthew Raymer |
|||
* @version 1.0.0 |
|||
*/ |
|||
--> |
|||
|
|||
<template> |
|||
<div class="logs-view"> |
|||
<div class="view-header"> |
|||
<h1 class="page-title">📋 Android Logs</h1> |
|||
<p class="page-subtitle">View and copy DailyNotification plugin logs</p> |
|||
</div> |
|||
|
|||
<div class="logs-controls"> |
|||
<button |
|||
class="control-button refresh-button" |
|||
@click="refreshLogs" |
|||
:disabled="isRefreshing" |
|||
> |
|||
<span v-if="isRefreshing">🔄</span> |
|||
<span v-else>🔄</span> |
|||
{{ isRefreshing ? 'Refreshing...' : 'Refresh Logs' }} |
|||
</button> |
|||
|
|||
<button |
|||
class="control-button copy-button" |
|||
@click="copyLogsToClipboard" |
|||
:disabled="!hasLogs || isCopying" |
|||
> |
|||
<span v-if="isCopying">📋</span> |
|||
<span v-else>📋</span> |
|||
{{ isCopying ? 'Copying...' : 'Copy to Clipboard' }} |
|||
</button> |
|||
|
|||
<button |
|||
class="control-button clear-button" |
|||
@click="clearLogs" |
|||
:disabled="!hasLogs" |
|||
> |
|||
🗑️ Clear Logs |
|||
</button> |
|||
</div> |
|||
|
|||
<div class="logs-container"> |
|||
<div v-if="hasLogs" class="logs-content"> |
|||
<div class="logs-header"> |
|||
<span class="logs-count">{{ logs.length }} log entries</span> |
|||
<span class="logs-timestamp">Last updated: {{ lastUpdated }}</span> |
|||
</div> |
|||
|
|||
<div class="logs-list" ref="logsList"> |
|||
<div |
|||
v-for="(log, index) in logs" |
|||
:key="index" |
|||
class="log-entry" |
|||
:class="getLogLevelClass(log)" |
|||
> |
|||
<span class="log-timestamp">{{ formatTimestamp(log.timestamp) }}</span> |
|||
<span class="log-level">{{ log.level }}</span> |
|||
<span class="log-tag">{{ log.tag }}</span> |
|||
<span class="log-message">{{ log.message }}</span> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<div v-else class="no-logs"> |
|||
<div class="empty-state"> |
|||
<span class="empty-icon">📋</span> |
|||
<h3 class="empty-title">No Logs Available</h3> |
|||
<p class="empty-description"> |
|||
Click "Refresh Logs" to fetch DailyNotification plugin logs from the device |
|||
</p> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- Success/Error Messages --> |
|||
<div v-if="successMessage" class="message success-message"> |
|||
✅ {{ successMessage }} |
|||
</div> |
|||
|
|||
<div v-if="errorMessage" class="message error-message"> |
|||
❌ {{ errorMessage }} |
|||
</div> |
|||
</div> |
|||
</template> |
|||
|
|||
<script lang="ts"> |
|||
import { Component, Vue } from 'vue-facing-decorator' |
|||
import { Capacitor } from '@capacitor/core' |
|||
|
|||
interface LogEntry { |
|||
timestamp: string |
|||
level: string |
|||
tag: string |
|||
message: string |
|||
} |
|||
|
|||
@Component |
|||
export default class LogsView extends Vue { |
|||
logs: LogEntry[] = [] |
|||
isRefreshing = false |
|||
isCopying = false |
|||
successMessage = '' |
|||
errorMessage = '' |
|||
lastUpdated = '' |
|||
|
|||
get hasLogs(): boolean { |
|||
return this.logs.length > 0 |
|||
} |
|||
|
|||
async refreshLogs(): Promise<void> { |
|||
this.isRefreshing = true |
|||
this.clearMessages() |
|||
|
|||
try { |
|||
if (!Capacitor.isNativePlatform()) { |
|||
// Mock logs for web testing |
|||
this.logs = this.generateMockLogs() |
|||
this.lastUpdated = new Date().toLocaleString() |
|||
this.successMessage = 'Mock logs loaded (web mode)' |
|||
return |
|||
} |
|||
|
|||
// In a real implementation, you would fetch logs from the device |
|||
// For now, we'll simulate fetching logs |
|||
await this.simulateLogFetch() |
|||
|
|||
} catch (error) { |
|||
console.error('❌ Failed to refresh logs:', error) |
|||
this.errorMessage = 'Failed to refresh logs: ' + (error as Error).message |
|||
} finally { |
|||
this.isRefreshing = false |
|||
} |
|||
} |
|||
|
|||
async copyLogsToClipboard(): Promise<void> { |
|||
if (!this.hasLogs) { |
|||
this.errorMessage = 'No logs to copy' |
|||
return |
|||
} |
|||
|
|||
this.isCopying = true |
|||
this.clearMessages() |
|||
|
|||
try { |
|||
const logsText = this.formatLogsForCopy() |
|||
|
|||
if (Capacitor.isNativePlatform()) { |
|||
// Use Capacitor Clipboard plugin if available |
|||
if (window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Clipboard) { |
|||
await window.Capacitor.Plugins.Clipboard.write({ |
|||
string: logsText |
|||
}) |
|||
} else { |
|||
// Fallback to web clipboard API |
|||
await navigator.clipboard.writeText(logsText) |
|||
} |
|||
} else { |
|||
// Web clipboard API |
|||
await navigator.clipboard.writeText(logsText) |
|||
} |
|||
|
|||
this.successMessage = `Copied ${this.logs.length} log entries to clipboard` |
|||
|
|||
// Auto-hide success message |
|||
setTimeout(() => { |
|||
this.successMessage = '' |
|||
}, 3000) |
|||
|
|||
} catch (error) { |
|||
console.error('❌ Failed to copy logs:', error) |
|||
this.errorMessage = 'Failed to copy logs: ' + (error as Error).message |
|||
} finally { |
|||
this.isCopying = false |
|||
} |
|||
} |
|||
|
|||
clearLogs(): void { |
|||
this.logs = [] |
|||
this.lastUpdated = '' |
|||
this.clearMessages() |
|||
this.successMessage = 'Logs cleared' |
|||
|
|||
// Auto-hide success message |
|||
setTimeout(() => { |
|||
this.successMessage = '' |
|||
}, 2000) |
|||
} |
|||
|
|||
private clearMessages(): void { |
|||
this.successMessage = '' |
|||
this.errorMessage = '' |
|||
} |
|||
|
|||
private async simulateLogFetch(): Promise<void> { |
|||
// Simulate network delay |
|||
await new Promise(resolve => setTimeout(resolve, 1000)) |
|||
|
|||
// Generate sample logs based on the terminal output you showed |
|||
this.logs = this.generateSampleLogs() |
|||
this.lastUpdated = new Date().toLocaleString() |
|||
this.successMessage = `Loaded ${this.logs.length} log entries` |
|||
|
|||
// Auto-hide success message |
|||
setTimeout(() => { |
|||
this.successMessage = '' |
|||
}, 3000) |
|||
} |
|||
|
|||
private generateSampleLogs(): LogEntry[] { |
|||
const now = new Date() |
|||
return [ |
|||
{ |
|||
timestamp: new Date(now.getTime() - 1000).toISOString(), |
|||
level: 'D', |
|||
tag: 'DailyNotificationReceiver', |
|||
message: 'DN|RECEIVE_START action=com.timesafari.daily.NOTIFICATION' |
|||
}, |
|||
{ |
|||
timestamp: new Date(now.getTime() - 900).toISOString(), |
|||
level: 'D', |
|||
tag: 'DailyNotificationReceiver', |
|||
message: 'DN|WORK_ENQUEUE display=3bc1b920-9407-4ccf-94c0-26f99ba4c39d' |
|||
}, |
|||
{ |
|||
timestamp: new Date(now.getTime() - 800).toISOString(), |
|||
level: 'D', |
|||
tag: 'DailyNotificationWorker', |
|||
message: 'DN|WORK_START id=3bc1b920-9407-4ccf-94c0-26f99ba4c39d action=display' |
|||
}, |
|||
{ |
|||
timestamp: new Date(now.getTime() - 700).toISOString(), |
|||
level: 'D', |
|||
tag: 'DailyNotificationWorker', |
|||
message: 'DN|DISPLAY_START id=3bc1b920-9407-4ccf-94c0-26f99ba4c39d' |
|||
}, |
|||
{ |
|||
timestamp: new Date(now.getTime() - 600).toISOString(), |
|||
level: 'D', |
|||
tag: 'DailyNotificationStorage', |
|||
message: 'Loading notifications from storage: [53 notifications]' |
|||
}, |
|||
{ |
|||
timestamp: new Date(now.getTime() - 500).toISOString(), |
|||
level: 'D', |
|||
tag: 'DailyNotificationWorker', |
|||
message: 'DN|JIT_FRESH skip=true ageMin=0 id=3bc1b920-9407-4ccf-94c0-26f99ba4c39d' |
|||
}, |
|||
{ |
|||
timestamp: new Date(now.getTime() - 400).toISOString(), |
|||
level: 'D', |
|||
tag: 'DailyNotificationWorker', |
|||
message: 'DN|CLICK_INTENT app_only' |
|||
}, |
|||
{ |
|||
timestamp: new Date(now.getTime() - 300).toISOString(), |
|||
level: 'D', |
|||
tag: 'DailyNotificationWorker', |
|||
message: 'DN|ACTION_BUTTONS dismiss_only' |
|||
}, |
|||
{ |
|||
timestamp: new Date(now.getTime() - 200).toISOString(), |
|||
level: 'I', |
|||
tag: 'DailyNotificationWorker', |
|||
message: 'DN|DISPLAY_NOTIF_OK id=3bc1b920-9407-4ccf-94c0-26f99ba4c39d' |
|||
}, |
|||
{ |
|||
timestamp: new Date(now.getTime() - 100).toISOString(), |
|||
level: 'D', |
|||
tag: 'DailyNotificationWorker', |
|||
message: 'DN|RESCHEDULE_START id=3bc1b920-9407-4ccf-94c0-26f99ba4c39d' |
|||
} |
|||
] |
|||
} |
|||
|
|||
private generateMockLogs(): LogEntry[] { |
|||
const now = new Date() |
|||
return [ |
|||
{ |
|||
timestamp: new Date(now.getTime() - 2000).toISOString(), |
|||
level: 'I', |
|||
tag: 'VueApp', |
|||
message: 'App initialized successfully' |
|||
}, |
|||
{ |
|||
timestamp: new Date(now.getTime() - 1500).toISOString(), |
|||
level: 'D', |
|||
tag: 'Capacitor', |
|||
message: 'Running in web mode - DailyNotification plugin not available' |
|||
}, |
|||
{ |
|||
timestamp: new Date(now.getTime() - 1000).toISOString(), |
|||
level: 'W', |
|||
tag: 'NotificationsStore', |
|||
message: 'DailyNotification plugin not loaded' |
|||
} |
|||
] |
|||
} |
|||
|
|||
private formatLogsForCopy(): string { |
|||
const header = `DailyNotification Plugin Logs\nGenerated: ${new Date().toLocaleString()}\nTotal Entries: ${this.logs.length}\n\n` |
|||
|
|||
const logLines = this.logs.map(log => |
|||
`${log.timestamp} ${log.level}/${log.tag}: ${log.message}` |
|||
).join('\n') |
|||
|
|||
return header + logLines |
|||
} |
|||
|
|||
private formatTimestamp(timestamp: string): string { |
|||
const date = new Date(timestamp) |
|||
return date.toLocaleTimeString() |
|||
} |
|||
|
|||
private getLogLevelClass(log: LogEntry): string { |
|||
switch (log.level) { |
|||
case 'E': return 'log-error' |
|||
case 'W': return 'log-warning' |
|||
case 'I': return 'log-info' |
|||
case 'D': return 'log-debug' |
|||
case 'V': return 'log-verbose' |
|||
default: return 'log-default' |
|||
} |
|||
} |
|||
|
|||
async mounted(): Promise<void> { |
|||
// Auto-refresh logs on mount |
|||
await this.refreshLogs() |
|||
} |
|||
} |
|||
</script> |
|||
|
|||
<style scoped> |
|||
.logs-view { |
|||
max-width: 1200px; |
|||
margin: 0 auto; |
|||
padding: 20px; |
|||
} |
|||
|
|||
.view-header { |
|||
text-align: center; |
|||
margin-bottom: 32px; |
|||
} |
|||
|
|||
.page-title { |
|||
font-size: 2rem; |
|||
font-weight: 700; |
|||
color: white; |
|||
margin: 0 0 8px 0; |
|||
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); |
|||
} |
|||
|
|||
.page-subtitle { |
|||
font-size: 1.1rem; |
|||
color: rgba(255, 255, 255, 0.9); |
|||
margin: 0; |
|||
font-weight: 300; |
|||
} |
|||
|
|||
.logs-controls { |
|||
display: flex; |
|||
gap: 12px; |
|||
margin-bottom: 24px; |
|||
flex-wrap: wrap; |
|||
} |
|||
|
|||
.control-button { |
|||
background: linear-gradient(135deg, #2196f3, #1976d2); |
|||
color: white; |
|||
border: none; |
|||
padding: 12px 20px; |
|||
border-radius: 8px; |
|||
font-size: 0.9rem; |
|||
font-weight: 600; |
|||
cursor: pointer; |
|||
transition: all 0.3s ease; |
|||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3); |
|||
display: flex; |
|||
align-items: center; |
|||
gap: 8px; |
|||
} |
|||
|
|||
.control-button:hover:not(:disabled) { |
|||
background: linear-gradient(135deg, #1976d2, #1565c0); |
|||
transform: translateY(-2px); |
|||
box-shadow: 0 8px 25px rgba(33, 150, 243, 0.3); |
|||
} |
|||
|
|||
.control-button:disabled { |
|||
background: rgba(255, 255, 255, 0.2); |
|||
cursor: not-allowed; |
|||
transform: none; |
|||
box-shadow: none; |
|||
} |
|||
|
|||
.refresh-button { |
|||
background: linear-gradient(135deg, #4caf50, #45a049); |
|||
} |
|||
|
|||
.refresh-button:hover:not(:disabled) { |
|||
background: linear-gradient(135deg, #45a049, #3d8b40); |
|||
box-shadow: 0 8px 25px rgba(76, 175, 80, 0.3); |
|||
} |
|||
|
|||
.copy-button { |
|||
background: linear-gradient(135deg, #ff9800, #f57c00); |
|||
} |
|||
|
|||
.copy-button:hover:not(:disabled) { |
|||
background: linear-gradient(135deg, #f57c00, #ef6c00); |
|||
box-shadow: 0 8px 25px rgba(255, 152, 0, 0.3); |
|||
} |
|||
|
|||
.clear-button { |
|||
background: linear-gradient(135deg, #f44336, #d32f2f); |
|||
} |
|||
|
|||
.clear-button:hover:not(:disabled) { |
|||
background: linear-gradient(135deg, #d32f2f, #c62828); |
|||
box-shadow: 0 8px 25px rgba(244, 67, 54, 0.3); |
|||
} |
|||
|
|||
.logs-container { |
|||
background: rgba(255, 255, 255, 0.1); |
|||
border-radius: 16px; |
|||
backdrop-filter: blur(10px); |
|||
border: 1px solid rgba(255, 255, 255, 0.2); |
|||
overflow: hidden; |
|||
} |
|||
|
|||
.logs-content { |
|||
padding: 20px; |
|||
} |
|||
|
|||
.logs-header { |
|||
display: flex; |
|||
justify-content: space-between; |
|||
align-items: center; |
|||
margin-bottom: 16px; |
|||
padding-bottom: 12px; |
|||
border-bottom: 1px solid rgba(255, 255, 255, 0.2); |
|||
} |
|||
|
|||
.logs-count { |
|||
font-weight: 600; |
|||
color: white; |
|||
font-size: 0.9rem; |
|||
} |
|||
|
|||
.logs-timestamp { |
|||
color: rgba(255, 255, 255, 0.7); |
|||
font-size: 0.8rem; |
|||
} |
|||
|
|||
.logs-list { |
|||
max-height: 500px; |
|||
overflow-y: auto; |
|||
font-family: 'Courier New', monospace; |
|||
font-size: 0.85rem; |
|||
line-height: 1.4; |
|||
} |
|||
|
|||
.log-entry { |
|||
display: grid; |
|||
grid-template-columns: 80px 20px 200px 1fr; |
|||
gap: 12px; |
|||
padding: 8px 0; |
|||
border-bottom: 1px solid rgba(255, 255, 255, 0.1); |
|||
align-items: start; |
|||
} |
|||
|
|||
.log-entry:last-child { |
|||
border-bottom: none; |
|||
} |
|||
|
|||
.log-timestamp { |
|||
color: rgba(255, 255, 255, 0.6); |
|||
font-size: 0.75rem; |
|||
} |
|||
|
|||
.log-level { |
|||
font-weight: bold; |
|||
text-align: center; |
|||
font-size: 0.8rem; |
|||
} |
|||
|
|||
.log-tag { |
|||
color: rgba(255, 255, 255, 0.8); |
|||
font-weight: 500; |
|||
overflow: hidden; |
|||
text-overflow: ellipsis; |
|||
white-space: nowrap; |
|||
} |
|||
|
|||
.log-message { |
|||
color: rgba(255, 255, 255, 0.9); |
|||
word-break: break-word; |
|||
} |
|||
|
|||
/* Log level colors */ |
|||
.log-error .log-level { |
|||
color: #f44336; |
|||
} |
|||
|
|||
.log-warning .log-level { |
|||
color: #ff9800; |
|||
} |
|||
|
|||
.log-info .log-level { |
|||
color: #4caf50; |
|||
} |
|||
|
|||
.log-debug .log-level { |
|||
color: #2196f3; |
|||
} |
|||
|
|||
.log-verbose .log-level { |
|||
color: #9c27b0; |
|||
} |
|||
|
|||
.log-default .log-level { |
|||
color: rgba(255, 255, 255, 0.7); |
|||
} |
|||
|
|||
.no-logs { |
|||
padding: 40px 20px; |
|||
text-align: center; |
|||
} |
|||
|
|||
.empty-state { |
|||
padding: 40px 20px; |
|||
} |
|||
|
|||
.empty-icon { |
|||
font-size: 4rem; |
|||
display: block; |
|||
margin-bottom: 16px; |
|||
} |
|||
|
|||
.empty-title { |
|||
font-size: 1.5rem; |
|||
font-weight: 600; |
|||
color: white; |
|||
margin: 0 0 8px 0; |
|||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3); |
|||
} |
|||
|
|||
.empty-description { |
|||
font-size: 1rem; |
|||
color: rgba(255, 255, 255, 0.8); |
|||
margin: 0; |
|||
line-height: 1.5; |
|||
} |
|||
|
|||
.message { |
|||
position: fixed; |
|||
top: 20px; |
|||
right: 20px; |
|||
padding: 12px 20px; |
|||
border-radius: 8px; |
|||
font-weight: 600; |
|||
z-index: 1000; |
|||
animation: slideIn 0.3s ease; |
|||
} |
|||
|
|||
.success-message { |
|||
background: linear-gradient(135deg, #4caf50, #45a049); |
|||
color: white; |
|||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3); |
|||
} |
|||
|
|||
.error-message { |
|||
background: linear-gradient(135deg, #f44336, #d32f2f); |
|||
color: white; |
|||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3); |
|||
} |
|||
|
|||
@keyframes slideIn { |
|||
from { |
|||
transform: translateX(100%); |
|||
opacity: 0; |
|||
} |
|||
to { |
|||
transform: translateX(0); |
|||
opacity: 1; |
|||
} |
|||
} |
|||
|
|||
/* Responsive design */ |
|||
@media (max-width: 768px) { |
|||
.logs-view { |
|||
padding: 16px; |
|||
} |
|||
|
|||
.logs-controls { |
|||
flex-direction: column; |
|||
} |
|||
|
|||
.control-button { |
|||
justify-content: center; |
|||
} |
|||
|
|||
.log-entry { |
|||
grid-template-columns: 1fr; |
|||
gap: 4px; |
|||
} |
|||
|
|||
.log-timestamp, |
|||
.log-level, |
|||
.log-tag { |
|||
font-size: 0.75rem; |
|||
} |
|||
|
|||
.logs-list { |
|||
font-size: 0.8rem; |
|||
} |
|||
|
|||
.message { |
|||
position: relative; |
|||
top: auto; |
|||
right: auto; |
|||
margin: 16px 0; |
|||
} |
|||
} |
|||
</style> |
@ -0,0 +1,117 @@ |
|||
<!-- |
|||
/** |
|||
* Not Found View - 404 Page |
|||
* |
|||
* @author Matthew Raymer |
|||
* @version 1.0.0 |
|||
*/ |
|||
--> |
|||
|
|||
<template> |
|||
<div class="not-found-view"> |
|||
<div class="not-found-content"> |
|||
<span class="not-found-icon">🔍</span> |
|||
<h1 class="not-found-title">404</h1> |
|||
<h2 class="not-found-subtitle">Page Not Found</h2> |
|||
<p class="not-found-description"> |
|||
The page you're looking for doesn't exist. |
|||
</p> |
|||
<button class="home-button" @click="goHome"> |
|||
🏠 Go Home |
|||
</button> |
|||
</div> |
|||
</div> |
|||
</template> |
|||
|
|||
<script lang="ts"> |
|||
import { Component, Vue } from 'vue-facing-decorator' |
|||
|
|||
@Component |
|||
export default class NotFoundView extends Vue { |
|||
goHome(): void { |
|||
this.$router.push('/') |
|||
} |
|||
} |
|||
</script> |
|||
|
|||
<style scoped> |
|||
.not-found-view { |
|||
display: flex; |
|||
justify-content: center; |
|||
align-items: center; |
|||
min-height: 60vh; |
|||
padding: 20px; |
|||
} |
|||
|
|||
.not-found-content { |
|||
text-align: center; |
|||
padding: 40px; |
|||
background: rgba(255, 255, 255, 0.1); |
|||
border-radius: 16px; |
|||
backdrop-filter: blur(10px); |
|||
border: 1px solid rgba(255, 255, 255, 0.2); |
|||
} |
|||
|
|||
.not-found-icon { |
|||
font-size: 4rem; |
|||
display: block; |
|||
margin-bottom: 16px; |
|||
} |
|||
|
|||
.not-found-title { |
|||
font-size: 4rem; |
|||
font-weight: 700; |
|||
color: white; |
|||
margin: 0 0 8px 0; |
|||
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); |
|||
} |
|||
|
|||
.not-found-subtitle { |
|||
font-size: 1.5rem; |
|||
font-weight: 600; |
|||
color: white; |
|||
margin: 0 0 16px 0; |
|||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3); |
|||
} |
|||
|
|||
.not-found-description { |
|||
font-size: 1.1rem; |
|||
color: rgba(255, 255, 255, 0.8); |
|||
margin: 0 0 24px 0; |
|||
line-height: 1.5; |
|||
} |
|||
|
|||
.home-button { |
|||
background: linear-gradient(135deg, #4caf50, #45a049); |
|||
color: white; |
|||
border: none; |
|||
padding: 12px 24px; |
|||
border-radius: 8px; |
|||
font-size: 1rem; |
|||
font-weight: 600; |
|||
cursor: pointer; |
|||
transition: all 0.3s ease; |
|||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3); |
|||
} |
|||
|
|||
.home-button:hover { |
|||
background: linear-gradient(135deg, #45a049, #3d8b40); |
|||
transform: translateY(-2px); |
|||
box-shadow: 0 8px 25px rgba(76, 175, 80, 0.3); |
|||
} |
|||
|
|||
/* Responsive design */ |
|||
@media (max-width: 768px) { |
|||
.not-found-content { |
|||
padding: 32px 20px; |
|||
} |
|||
|
|||
.not-found-title { |
|||
font-size: 3rem; |
|||
} |
|||
|
|||
.not-found-icon { |
|||
font-size: 3rem; |
|||
} |
|||
} |
|||
</style> |
@ -0,0 +1,179 @@ |
|||
<!-- |
|||
/** |
|||
* Notifications View - Manage Scheduled Notifications |
|||
* |
|||
* @author Matthew Raymer |
|||
* @version 1.0.0 |
|||
*/ |
|||
--> |
|||
|
|||
<template> |
|||
<div class="notifications-view"> |
|||
<div class="view-header"> |
|||
<h1 class="page-title">📱 Scheduled Notifications</h1> |
|||
<p class="page-subtitle">Manage your daily notifications</p> |
|||
</div> |
|||
|
|||
<div v-if="hasScheduledNotifications" class="notifications-list"> |
|||
<NotificationCard |
|||
v-for="notification in scheduledNotifications" |
|||
:key="notification.id" |
|||
:notification="notification" |
|||
@cancel="handleCancelNotification" |
|||
/> |
|||
</div> |
|||
|
|||
<div v-else class="no-notifications"> |
|||
<div class="empty-state"> |
|||
<span class="empty-icon">📅</span> |
|||
<h3 class="empty-title">No Notifications Scheduled</h3> |
|||
<p class="empty-description"> |
|||
Schedule your first daily notification to get started |
|||
</p> |
|||
<button class="empty-action" @click="navigateToSchedule"> |
|||
📅 Schedule Notification |
|||
</button> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</template> |
|||
|
|||
<script lang="ts"> |
|||
import { Component, Vue } from 'vue-facing-decorator' |
|||
import NotificationCard from '@/components/cards/NotificationCard.vue' |
|||
import { useNotificationsStore } from '@/stores/notifications' |
|||
|
|||
@Component({ |
|||
components: { |
|||
NotificationCard |
|||
} |
|||
}) |
|||
export default class NotificationsView extends Vue { |
|||
private notificationsStore = useNotificationsStore() |
|||
|
|||
get hasScheduledNotifications(): boolean { |
|||
return this.notificationsStore.hasScheduledNotifications |
|||
} |
|||
|
|||
get scheduledNotifications() { |
|||
return this.notificationsStore.scheduledNotifications |
|||
} |
|||
|
|||
async handleCancelNotification(notificationId: string): Promise<void> { |
|||
try { |
|||
await this.notificationsStore.cancelReminder(notificationId) |
|||
} catch (error) { |
|||
console.error('❌ Failed to cancel notification:', error) |
|||
} |
|||
} |
|||
|
|||
navigateToSchedule(): void { |
|||
this.$router.push('/schedule') |
|||
} |
|||
} |
|||
</script> |
|||
|
|||
<style scoped> |
|||
.notifications-view { |
|||
max-width: 800px; |
|||
margin: 0 auto; |
|||
padding: 20px; |
|||
} |
|||
|
|||
.view-header { |
|||
text-align: center; |
|||
margin-bottom: 32px; |
|||
} |
|||
|
|||
.page-title { |
|||
font-size: 2rem; |
|||
font-weight: 700; |
|||
color: white; |
|||
margin: 0 0 8px 0; |
|||
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); |
|||
} |
|||
|
|||
.page-subtitle { |
|||
font-size: 1.1rem; |
|||
color: rgba(255, 255, 255, 0.9); |
|||
margin: 0; |
|||
font-weight: 300; |
|||
} |
|||
|
|||
.notifications-list { |
|||
display: flex; |
|||
flex-direction: column; |
|||
gap: 16px; |
|||
} |
|||
|
|||
.no-notifications { |
|||
display: flex; |
|||
justify-content: center; |
|||
align-items: center; |
|||
min-height: 400px; |
|||
} |
|||
|
|||
.empty-state { |
|||
text-align: center; |
|||
padding: 40px 20px; |
|||
background: rgba(255, 255, 255, 0.1); |
|||
border-radius: 16px; |
|||
backdrop-filter: blur(10px); |
|||
border: 1px solid rgba(255, 255, 255, 0.2); |
|||
} |
|||
|
|||
.empty-icon { |
|||
font-size: 4rem; |
|||
display: block; |
|||
margin-bottom: 16px; |
|||
} |
|||
|
|||
.empty-title { |
|||
font-size: 1.5rem; |
|||
font-weight: 600; |
|||
color: white; |
|||
margin: 0 0 8px 0; |
|||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3); |
|||
} |
|||
|
|||
.empty-description { |
|||
font-size: 1rem; |
|||
color: rgba(255, 255, 255, 0.8); |
|||
margin: 0 0 24px 0; |
|||
line-height: 1.5; |
|||
} |
|||
|
|||
.empty-action { |
|||
background: linear-gradient(135deg, #4caf50, #45a049); |
|||
color: white; |
|||
border: none; |
|||
padding: 12px 24px; |
|||
border-radius: 8px; |
|||
font-size: 1rem; |
|||
font-weight: 600; |
|||
cursor: pointer; |
|||
transition: all 0.3s ease; |
|||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3); |
|||
} |
|||
|
|||
.empty-action:hover { |
|||
background: linear-gradient(135deg, #45a049, #3d8b40); |
|||
transform: translateY(-2px); |
|||
box-shadow: 0 8px 25px rgba(76, 175, 80, 0.3); |
|||
} |
|||
|
|||
/* Responsive design */ |
|||
@media (max-width: 768px) { |
|||
.notifications-view { |
|||
padding: 16px; |
|||
} |
|||
|
|||
.empty-state { |
|||
padding: 32px 16px; |
|||
} |
|||
|
|||
.empty-icon { |
|||
font-size: 3rem; |
|||
} |
|||
} |
|||
</style> |
@ -0,0 +1,518 @@ |
|||
<!-- |
|||
/** |
|||
* Schedule View - Schedule New Notifications |
|||
* |
|||
* Vue 3 + vue-facing-decorator + TypeScript |
|||
* |
|||
* @author Matthew Raymer |
|||
* @version 1.0.0 |
|||
*/ |
|||
--> |
|||
|
|||
<template> |
|||
<div class="schedule-view"> |
|||
<div class="schedule-header"> |
|||
<h1 class="page-title">📅 Schedule Notification</h1> |
|||
<p class="page-subtitle">Create a new daily notification</p> |
|||
</div> |
|||
|
|||
<div class="schedule-form"> |
|||
<form @submit.prevent="handleSubmit"> |
|||
<!-- Time Input --> |
|||
<div class="form-group"> |
|||
<label class="form-label">Time</label> |
|||
<input |
|||
v-model="form.time" |
|||
type="time" |
|||
class="form-input" |
|||
required |
|||
:disabled="isScheduling" |
|||
/> |
|||
</div> |
|||
|
|||
<!-- Title Input --> |
|||
<div class="form-group"> |
|||
<label class="form-label">Title</label> |
|||
<input |
|||
v-model="form.title" |
|||
type="text" |
|||
class="form-input" |
|||
placeholder="Daily Update" |
|||
:disabled="isScheduling" |
|||
/> |
|||
</div> |
|||
|
|||
<!-- Body Input --> |
|||
<div class="form-group"> |
|||
<label class="form-label">Message</label> |
|||
<textarea |
|||
v-model="form.body" |
|||
class="form-textarea" |
|||
placeholder="Your daily notification message" |
|||
rows="3" |
|||
:disabled="isScheduling" |
|||
></textarea> |
|||
</div> |
|||
|
|||
<!-- Options --> |
|||
<div class="form-options"> |
|||
<div class="option-group"> |
|||
<label class="option-label"> |
|||
<input |
|||
v-model="form.sound" |
|||
type="checkbox" |
|||
class="option-checkbox" |
|||
:disabled="isScheduling" |
|||
/> |
|||
<span class="option-text">🔊 Sound</span> |
|||
</label> |
|||
</div> |
|||
|
|||
<div class="option-group"> |
|||
<label class="option-label"> |
|||
<span class="option-text">Priority</span> |
|||
<select |
|||
v-model="form.priority" |
|||
class="form-select" |
|||
:disabled="isScheduling" |
|||
> |
|||
<option value="low">Low</option> |
|||
<option value="default">Default</option> |
|||
<option value="high">High</option> |
|||
</select> |
|||
</label> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- URL Input --> |
|||
<div class="form-group"> |
|||
<label class="form-label">URL (Optional)</label> |
|||
<input |
|||
v-model="form.url" |
|||
type="url" |
|||
class="form-input" |
|||
placeholder="https://example.com" |
|||
:disabled="isScheduling" |
|||
/> |
|||
</div> |
|||
|
|||
<!-- Submit Button --> |
|||
<div class="form-actions"> |
|||
<button |
|||
type="submit" |
|||
class="submit-button" |
|||
:disabled="isScheduling || !isFormValid" |
|||
:class="{ 'loading': isScheduling }" |
|||
> |
|||
<span v-if="isScheduling" class="button-spinner"></span> |
|||
<span v-else>📅 Schedule Notification</span> |
|||
</button> |
|||
</div> |
|||
</form> |
|||
</div> |
|||
|
|||
<!-- Quick Schedule Options --> |
|||
<div class="quick-schedule"> |
|||
<h2 class="section-title">Quick Schedule</h2> |
|||
<div class="quick-options"> |
|||
<button |
|||
v-for="option in quickOptions" |
|||
:key="option.label" |
|||
class="quick-button" |
|||
@click="fillQuickOption(option)" |
|||
:disabled="isScheduling" |
|||
> |
|||
<span class="quick-icon">{{ option.icon }}</span> |
|||
<span class="quick-label">{{ option.label }}</span> |
|||
</button> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</template> |
|||
|
|||
<script lang="ts"> |
|||
import { Component, Vue } from 'vue-facing-decorator' |
|||
import { useAppStore } from '@/stores/app' |
|||
import { useNotificationsStore } from '@/stores/notifications' |
|||
|
|||
interface QuickOption { |
|||
label: string |
|||
icon: string |
|||
time: string |
|||
title: string |
|||
body: string |
|||
} |
|||
|
|||
interface ScheduleForm { |
|||
time: string |
|||
title: string |
|||
body: string |
|||
sound: boolean |
|||
priority: string |
|||
url: string |
|||
} |
|||
|
|||
@Component |
|||
export default class ScheduleView extends Vue { |
|||
private appStore = useAppStore() |
|||
private notificationsStore = useNotificationsStore() |
|||
|
|||
form: ScheduleForm = { |
|||
time: '', |
|||
title: 'Daily Update', |
|||
body: 'Your daily notification is ready', |
|||
sound: true, |
|||
priority: 'default', |
|||
url: '' |
|||
} |
|||
|
|||
quickOptions: QuickOption[] = [ |
|||
{ |
|||
label: 'Morning', |
|||
icon: '🌅', |
|||
time: '08:00', |
|||
title: 'Good Morning!', |
|||
body: 'Ready to make today amazing?' |
|||
}, |
|||
{ |
|||
label: 'Lunch', |
|||
icon: '🍽️', |
|||
time: '12:00', |
|||
title: 'Lunch Break', |
|||
body: 'Time for a well-deserved break!' |
|||
}, |
|||
{ |
|||
label: 'Evening', |
|||
icon: '🌆', |
|||
time: '18:00', |
|||
title: 'Evening Update', |
|||
body: 'How was your day?' |
|||
}, |
|||
{ |
|||
label: 'Bedtime', |
|||
icon: '🌙', |
|||
time: '22:00', |
|||
title: 'Bedtime Reminder', |
|||
body: 'Time to wind down and rest' |
|||
} |
|||
] |
|||
|
|||
get isScheduling(): boolean { |
|||
return this.notificationsStore.isScheduling |
|||
} |
|||
|
|||
get isFormValid(): boolean { |
|||
return this.form.time.length > 0 && |
|||
this.form.title.trim().length > 0 && |
|||
this.form.body.trim().length > 0 |
|||
} |
|||
|
|||
mounted(): void { |
|||
this.initializeForm() |
|||
} |
|||
|
|||
private initializeForm(): void { |
|||
// Set default time to 1 hour from now |
|||
const now = new Date() |
|||
now.setHours(now.getHours() + 1) |
|||
this.form.time = now.toTimeString().slice(0, 5) |
|||
} |
|||
|
|||
async handleSubmit(): Promise<void> { |
|||
if (!this.isFormValid || this.isScheduling) { |
|||
return |
|||
} |
|||
|
|||
try { |
|||
await this.notificationsStore.scheduleNotification({ |
|||
time: this.form.time, |
|||
title: this.form.title.trim(), |
|||
body: this.form.body.trim(), |
|||
sound: this.form.sound, |
|||
priority: this.form.priority, |
|||
url: this.form.url.trim() || undefined |
|||
}) |
|||
|
|||
// Show success message |
|||
this.appStore.setError('Notification scheduled successfully!') |
|||
|
|||
// Reset form |
|||
this.resetForm() |
|||
|
|||
// Navigate back to home |
|||
setTimeout(() => { |
|||
this.$router.push('/') |
|||
}, 1500) |
|||
|
|||
} catch (error) { |
|||
console.error('❌ Failed to schedule notification:', error) |
|||
this.appStore.setError('Failed to schedule notification: ' + (error as Error).message) |
|||
} |
|||
} |
|||
|
|||
fillQuickOption(option: QuickOption): void { |
|||
this.form.time = option.time |
|||
this.form.title = option.title |
|||
this.form.body = option.body |
|||
} |
|||
|
|||
resetForm(): void { |
|||
this.form = { |
|||
time: '', |
|||
title: 'Daily Update', |
|||
body: 'Your daily notification is ready', |
|||
sound: true, |
|||
priority: 'default', |
|||
url: '' |
|||
} |
|||
this.initializeForm() |
|||
} |
|||
} |
|||
</script> |
|||
|
|||
<style scoped> |
|||
.schedule-view { |
|||
max-width: 600px; |
|||
margin: 0 auto; |
|||
padding: 20px; |
|||
} |
|||
|
|||
.schedule-header { |
|||
text-align: center; |
|||
margin-bottom: 32px; |
|||
} |
|||
|
|||
.page-title { |
|||
font-size: 2rem; |
|||
font-weight: 700; |
|||
color: white; |
|||
margin: 0 0 8px 0; |
|||
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); |
|||
} |
|||
|
|||
.page-subtitle { |
|||
font-size: 1.1rem; |
|||
color: rgba(255, 255, 255, 0.9); |
|||
margin: 0; |
|||
font-weight: 300; |
|||
} |
|||
|
|||
.schedule-form { |
|||
background: rgba(255, 255, 255, 0.1); |
|||
border-radius: 16px; |
|||
padding: 24px; |
|||
backdrop-filter: blur(10px); |
|||
border: 1px solid rgba(255, 255, 255, 0.2); |
|||
margin-bottom: 32px; |
|||
} |
|||
|
|||
.form-group { |
|||
margin-bottom: 20px; |
|||
} |
|||
|
|||
.form-label { |
|||
display: block; |
|||
font-size: 0.9rem; |
|||
font-weight: 600; |
|||
color: white; |
|||
margin-bottom: 8px; |
|||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3); |
|||
} |
|||
|
|||
.form-input, |
|||
.form-textarea, |
|||
.form-select { |
|||
width: 100%; |
|||
padding: 12px 16px; |
|||
border: 1px solid rgba(255, 255, 255, 0.3); |
|||
border-radius: 8px; |
|||
background: rgba(255, 255, 255, 0.1); |
|||
color: white; |
|||
font-size: 1rem; |
|||
transition: all 0.3s ease; |
|||
} |
|||
|
|||
.form-input:focus, |
|||
.form-textarea:focus, |
|||
.form-select:focus { |
|||
outline: none; |
|||
border-color: rgba(255, 255, 255, 0.5); |
|||
background: rgba(255, 255, 255, 0.15); |
|||
} |
|||
|
|||
.form-input::placeholder, |
|||
.form-textarea::placeholder { |
|||
color: rgba(255, 255, 255, 0.6); |
|||
} |
|||
|
|||
.form-textarea { |
|||
resize: vertical; |
|||
min-height: 80px; |
|||
} |
|||
|
|||
.form-options { |
|||
display: grid; |
|||
grid-template-columns: 1fr 1fr; |
|||
gap: 16px; |
|||
margin-bottom: 20px; |
|||
} |
|||
|
|||
.option-group { |
|||
display: flex; |
|||
align-items: center; |
|||
} |
|||
|
|||
.option-label { |
|||
display: flex; |
|||
align-items: center; |
|||
gap: 8px; |
|||
cursor: pointer; |
|||
color: white; |
|||
font-size: 0.9rem; |
|||
font-weight: 500; |
|||
} |
|||
|
|||
.option-checkbox { |
|||
width: 18px; |
|||
height: 18px; |
|||
accent-color: #4caf50; |
|||
} |
|||
|
|||
.option-text { |
|||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3); |
|||
} |
|||
|
|||
.form-select { |
|||
margin-left: auto; |
|||
width: auto; |
|||
min-width: 100px; |
|||
} |
|||
|
|||
.form-actions { |
|||
margin-top: 24px; |
|||
} |
|||
|
|||
.submit-button { |
|||
width: 100%; |
|||
padding: 16px 24px; |
|||
background: linear-gradient(135deg, #4caf50, #45a049); |
|||
color: white; |
|||
border: none; |
|||
border-radius: 12px; |
|||
font-size: 1.1rem; |
|||
font-weight: 600; |
|||
cursor: pointer; |
|||
transition: all 0.3s ease; |
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: center; |
|||
gap: 8px; |
|||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3); |
|||
} |
|||
|
|||
.submit-button:hover:not(:disabled) { |
|||
background: linear-gradient(135deg, #45a049, #3d8b40); |
|||
transform: translateY(-2px); |
|||
box-shadow: 0 8px 25px rgba(76, 175, 80, 0.3); |
|||
} |
|||
|
|||
.submit-button:disabled { |
|||
opacity: 0.6; |
|||
cursor: not-allowed; |
|||
transform: none; |
|||
} |
|||
|
|||
.submit-button.loading { |
|||
background: linear-gradient(135deg, #9e9e9e, #757575); |
|||
} |
|||
|
|||
.button-spinner { |
|||
width: 20px; |
|||
height: 20px; |
|||
border: 2px solid rgba(255, 255, 255, 0.3); |
|||
border-top: 2px solid white; |
|||
border-radius: 50%; |
|||
animation: spin 1s linear infinite; |
|||
} |
|||
|
|||
.quick-schedule { |
|||
background: rgba(255, 255, 255, 0.1); |
|||
border-radius: 16px; |
|||
padding: 24px; |
|||
backdrop-filter: blur(10px); |
|||
border: 1px solid rgba(255, 255, 255, 0.2); |
|||
} |
|||
|
|||
.section-title { |
|||
font-size: 1.3rem; |
|||
font-weight: 600; |
|||
color: white; |
|||
margin: 0 0 16px 0; |
|||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3); |
|||
} |
|||
|
|||
.quick-options { |
|||
display: grid; |
|||
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); |
|||
gap: 12px; |
|||
} |
|||
|
|||
.quick-button { |
|||
display: flex; |
|||
flex-direction: column; |
|||
align-items: center; |
|||
gap: 8px; |
|||
padding: 16px 12px; |
|||
background: rgba(255, 255, 255, 0.1); |
|||
border: 1px solid rgba(255, 255, 255, 0.2); |
|||
border-radius: 12px; |
|||
color: white; |
|||
cursor: pointer; |
|||
transition: all 0.3s ease; |
|||
text-decoration: none; |
|||
} |
|||
|
|||
.quick-button:hover:not(:disabled) { |
|||
background: rgba(255, 255, 255, 0.15); |
|||
transform: translateY(-2px); |
|||
} |
|||
|
|||
.quick-button:disabled { |
|||
opacity: 0.5; |
|||
cursor: not-allowed; |
|||
} |
|||
|
|||
.quick-icon { |
|||
font-size: 1.5rem; |
|||
} |
|||
|
|||
.quick-label { |
|||
font-size: 0.85rem; |
|||
font-weight: 500; |
|||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3); |
|||
} |
|||
|
|||
@keyframes spin { |
|||
0% { transform: rotate(0deg); } |
|||
100% { transform: rotate(360deg); } |
|||
} |
|||
|
|||
/* Responsive design */ |
|||
@media (max-width: 768px) { |
|||
.schedule-view { |
|||
padding: 16px; |
|||
} |
|||
|
|||
.schedule-form { |
|||
padding: 20px; |
|||
} |
|||
|
|||
.form-options { |
|||
grid-template-columns: 1fr; |
|||
} |
|||
|
|||
.quick-options { |
|||
grid-template-columns: repeat(2, 1fr); |
|||
} |
|||
} |
|||
</style> |
@ -0,0 +1,71 @@ |
|||
<!-- |
|||
/** |
|||
* Settings View - App Settings |
|||
* |
|||
* @author Matthew Raymer |
|||
* @version 1.0.0 |
|||
*/ |
|||
--> |
|||
|
|||
<template> |
|||
<div class="settings-view"> |
|||
<div class="view-header"> |
|||
<h1 class="page-title">⚙️ Settings</h1> |
|||
<p class="page-subtitle">Configure app preferences</p> |
|||
</div> |
|||
|
|||
<div class="settings-content"> |
|||
<p class="coming-soon">Settings panel coming soon...</p> |
|||
</div> |
|||
</div> |
|||
</template> |
|||
|
|||
<script lang="ts"> |
|||
import { Component, Vue } from 'vue-facing-decorator' |
|||
|
|||
@Component |
|||
export default class SettingsView extends Vue {} |
|||
</script> |
|||
|
|||
<style scoped> |
|||
.settings-view { |
|||
max-width: 800px; |
|||
margin: 0 auto; |
|||
padding: 20px; |
|||
} |
|||
|
|||
.view-header { |
|||
text-align: center; |
|||
margin-bottom: 32px; |
|||
} |
|||
|
|||
.page-title { |
|||
font-size: 2rem; |
|||
font-weight: 700; |
|||
color: white; |
|||
margin: 0 0 8px 0; |
|||
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); |
|||
} |
|||
|
|||
.page-subtitle { |
|||
font-size: 1.1rem; |
|||
color: rgba(255, 255, 255, 0.9); |
|||
margin: 0; |
|||
font-weight: 300; |
|||
} |
|||
|
|||
.settings-content { |
|||
background: rgba(255, 255, 255, 0.1); |
|||
border-radius: 16px; |
|||
padding: 40px; |
|||
backdrop-filter: blur(10px); |
|||
border: 1px solid rgba(255, 255, 255, 0.2); |
|||
text-align: center; |
|||
} |
|||
|
|||
.coming-soon { |
|||
font-size: 1.2rem; |
|||
color: rgba(255, 255, 255, 0.8); |
|||
margin: 0; |
|||
} |
|||
</style> |
@ -0,0 +1,310 @@ |
|||
<!-- |
|||
/** |
|||
* Status View - System Status and Diagnostics |
|||
* |
|||
* @author Matthew Raymer |
|||
* @version 1.0.0 |
|||
*/ |
|||
--> |
|||
|
|||
<template> |
|||
<div class="status-view"> |
|||
<div class="view-header"> |
|||
<h1 class="page-title">📊 System Status</h1> |
|||
<p class="page-subtitle">Notification system diagnostics</p> |
|||
</div> |
|||
|
|||
<!-- Status Overview --> |
|||
<div class="status-overview"> |
|||
<StatusCard |
|||
:status="notificationStatus" |
|||
@refresh="refreshStatus" |
|||
/> |
|||
</div> |
|||
|
|||
<!-- Action Buttons --> |
|||
<div class="status-actions"> |
|||
<h2 class="section-title">Actions</h2> |
|||
<div class="action-grid"> |
|||
<button |
|||
class="action-button" |
|||
@click="refreshStatus" |
|||
:disabled="isRefreshing" |
|||
> |
|||
<span class="action-icon">🔄</span> |
|||
<span class="action-text">Refresh Status</span> |
|||
</button> |
|||
|
|||
<button |
|||
class="action-button" |
|||
@click="openChannelSettings" |
|||
> |
|||
<span class="action-icon">🔧</span> |
|||
<span class="action-text">Channel Settings</span> |
|||
</button> |
|||
|
|||
<button |
|||
class="action-button" |
|||
@click="openExactAlarmSettings" |
|||
> |
|||
<span class="action-icon">⏰</span> |
|||
<span class="action-text">Alarm Settings</span> |
|||
</button> |
|||
|
|||
<button |
|||
class="action-button" |
|||
@click="testNotification" |
|||
:disabled="!canScheduleNow" |
|||
> |
|||
<span class="action-icon">🧪</span> |
|||
<span class="action-text">Test Notification</span> |
|||
</button> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- Detailed Information --> |
|||
<div v-if="notificationStatus" class="detailed-info"> |
|||
<h2 class="section-title">Detailed Information</h2> |
|||
<div class="info-grid"> |
|||
<InfoCard |
|||
title="Platform" |
|||
:value="platformInfo" |
|||
icon="📱" |
|||
/> |
|||
<InfoCard |
|||
title="Android Version" |
|||
:value="`API ${notificationStatus.androidVersion}`" |
|||
icon="🤖" |
|||
/> |
|||
<InfoCard |
|||
title="Channel ID" |
|||
:value="notificationStatus.channelId" |
|||
icon="📢" |
|||
/> |
|||
<InfoCard |
|||
title="Next Scheduled" |
|||
:value="nextScheduledText" |
|||
icon="⏰" |
|||
/> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</template> |
|||
|
|||
<script lang="ts"> |
|||
import { Component, Vue } from 'vue-facing-decorator' |
|||
import { Capacitor } from '@capacitor/core' |
|||
import StatusCard from '@/components/cards/StatusCard.vue' |
|||
import InfoCard from '@/components/cards/InfoCard.vue' |
|||
import { useAppStore } from '@/stores/app' |
|||
import { useNotificationsStore } from '@/stores/notifications' |
|||
|
|||
@Component({ |
|||
components: { |
|||
StatusCard, |
|||
InfoCard |
|||
} |
|||
}) |
|||
export default class StatusView extends Vue { |
|||
private appStore = useAppStore() |
|||
private notificationsStore = useNotificationsStore() |
|||
isRefreshing = false |
|||
|
|||
get notificationStatus() { |
|||
return this.appStore.notificationStatus |
|||
} |
|||
|
|||
get canScheduleNow(): boolean { |
|||
return this.notificationStatus?.canScheduleNow ?? false |
|||
} |
|||
|
|||
get platformInfo(): string { |
|||
const platform = Capacitor.getPlatform() |
|||
const isNative = Capacitor.isNativePlatform() |
|||
return `${platform} ${isNative ? '(Native)' : '(Web)'}` |
|||
} |
|||
|
|||
get nextScheduledText(): string { |
|||
if (!this.notificationStatus || this.notificationStatus.nextScheduledAt <= 0) { |
|||
return 'None' |
|||
} |
|||
|
|||
const date = new Date(this.notificationStatus.nextScheduledAt) |
|||
return date.toLocaleString() |
|||
} |
|||
|
|||
async mounted(): Promise<void> { |
|||
await this.refreshStatus() |
|||
} |
|||
|
|||
async refreshStatus(): Promise<void> { |
|||
try { |
|||
this.isRefreshing = true |
|||
await this.notificationsStore.checkStatus() |
|||
} catch (error) { |
|||
console.error('❌ Failed to refresh status:', error) |
|||
} finally { |
|||
this.isRefreshing = false |
|||
} |
|||
} |
|||
|
|||
async openChannelSettings(): Promise<void> { |
|||
if (!Capacitor.isNativePlatform() || !window.DailyNotification) { |
|||
return |
|||
} |
|||
|
|||
try { |
|||
await window.DailyNotification.openChannelSettings() |
|||
} catch (error) { |
|||
console.error('❌ Failed to open channel settings:', error) |
|||
} |
|||
} |
|||
|
|||
async openExactAlarmSettings(): Promise<void> { |
|||
if (!Capacitor.isNativePlatform() || !window.DailyNotification) { |
|||
return |
|||
} |
|||
|
|||
try { |
|||
await window.DailyNotification.openExactAlarmSettings() |
|||
} catch (error) { |
|||
console.error('❌ Failed to open exact alarm settings:', error) |
|||
} |
|||
} |
|||
|
|||
async testNotification(): Promise<void> { |
|||
try { |
|||
await this.notificationsStore.scheduleNotification({ |
|||
time: this.getTestTime(), |
|||
title: 'Test Notification', |
|||
body: 'This is a test notification from the status page', |
|||
sound: true, |
|||
priority: 'high' |
|||
}) |
|||
|
|||
this.appStore.setError('Test notification scheduled successfully!') |
|||
|
|||
} catch (error) { |
|||
console.error('❌ Failed to schedule test notification:', error) |
|||
this.appStore.setError('Failed to schedule test notification: ' + (error as Error).message) |
|||
} |
|||
} |
|||
|
|||
getTestTime(): string { |
|||
const now = new Date() |
|||
now.setMinutes(now.getMinutes() + 2) // 2 minutes from now |
|||
return now.toTimeString().slice(0, 5) |
|||
} |
|||
} |
|||
</script> |
|||
|
|||
<style scoped> |
|||
.status-view { |
|||
max-width: 1000px; |
|||
margin: 0 auto; |
|||
padding: 20px; |
|||
} |
|||
|
|||
.view-header { |
|||
text-align: center; |
|||
margin-bottom: 32px; |
|||
} |
|||
|
|||
.page-title { |
|||
font-size: 2rem; |
|||
font-weight: 700; |
|||
color: white; |
|||
margin: 0 0 8px 0; |
|||
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); |
|||
} |
|||
|
|||
.page-subtitle { |
|||
font-size: 1.1rem; |
|||
color: rgba(255, 255, 255, 0.9); |
|||
margin: 0; |
|||
font-weight: 300; |
|||
} |
|||
|
|||
.status-overview { |
|||
margin-bottom: 32px; |
|||
} |
|||
|
|||
.status-actions { |
|||
margin-bottom: 32px; |
|||
} |
|||
|
|||
.section-title { |
|||
font-size: 1.5rem; |
|||
font-weight: 600; |
|||
color: white; |
|||
margin: 0 0 16px 0; |
|||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3); |
|||
} |
|||
|
|||
.action-grid { |
|||
display: grid; |
|||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); |
|||
gap: 16px; |
|||
} |
|||
|
|||
.action-button { |
|||
display: flex; |
|||
align-items: center; |
|||
gap: 12px; |
|||
padding: 16px 20px; |
|||
background: rgba(255, 255, 255, 0.1); |
|||
border: 1px solid rgba(255, 255, 255, 0.2); |
|||
border-radius: 12px; |
|||
color: white; |
|||
cursor: pointer; |
|||
transition: all 0.3s ease; |
|||
backdrop-filter: blur(10px); |
|||
text-decoration: none; |
|||
} |
|||
|
|||
.action-button:hover:not(:disabled) { |
|||
background: rgba(255, 255, 255, 0.15); |
|||
transform: translateY(-2px); |
|||
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.2); |
|||
} |
|||
|
|||
.action-button:disabled { |
|||
opacity: 0.5; |
|||
cursor: not-allowed; |
|||
} |
|||
|
|||
.action-icon { |
|||
font-size: 1.2rem; |
|||
} |
|||
|
|||
.action-text { |
|||
font-size: 0.95rem; |
|||
font-weight: 500; |
|||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3); |
|||
} |
|||
|
|||
.detailed-info { |
|||
margin-bottom: 32px; |
|||
} |
|||
|
|||
.info-grid { |
|||
display: grid; |
|||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); |
|||
gap: 16px; |
|||
} |
|||
|
|||
/* Responsive design */ |
|||
@media (max-width: 768px) { |
|||
.status-view { |
|||
padding: 16px; |
|||
} |
|||
|
|||
.action-grid { |
|||
grid-template-columns: 1fr; |
|||
} |
|||
|
|||
.info-grid { |
|||
grid-template-columns: 1fr; |
|||
} |
|||
} |
|||
</style> |
@ -1,15 +1,45 @@ |
|||
{ |
|||
"compilerOptions": { |
|||
"target": "ES2020", |
|||
"module": "ES2020", |
|||
"moduleResolution": "node", |
|||
"strict": true, |
|||
"esModuleInterop": true, |
|||
"useDefineForClassFields": true, |
|||
"module": "ESNext", |
|||
"lib": ["ES2020", "DOM", "DOM.Iterable"], |
|||
"skipLibCheck": true, |
|||
"forceConsistentCasingInFileNames": true, |
|||
"outDir": "./dist", |
|||
"rootDir": "./src" |
|||
"moduleResolution": "bundler", |
|||
"allowImportingTsExtensions": true, |
|||
"resolveJsonModule": true, |
|||
"isolatedModules": true, |
|||
"noEmit": true, |
|||
"jsx": "preserve", |
|||
"strict": true, |
|||
"noUnusedLocals": false, |
|||
"noUnusedParameters": false, |
|||
"noFallthroughCasesInSwitch": true, |
|||
"experimentalDecorators": true, |
|||
"emitDecoratorMetadata": true, |
|||
"baseUrl": ".", |
|||
"paths": { |
|||
"@/*": ["./src/*"], |
|||
"@components/*": ["./src/components/*"], |
|||
"@views/*": ["./src/views/*"], |
|||
"@stores/*": ["./src/stores/*"], |
|||
"@services/*": ["./src/services/*"], |
|||
"@types/*": ["./src/types/*"], |
|||
"@utils/*": ["./src/utils/*"] |
|||
}, |
|||
"types": [ |
|||
"vite/client", |
|||
"node" |
|||
] |
|||
}, |
|||
"include": ["src/**/*"], |
|||
"exclude": ["node_modules", "dist"] |
|||
} |
|||
"include": [ |
|||
"env.d.ts", |
|||
"src/**/*", |
|||
"src/**/*.vue" |
|||
], |
|||
"exclude": [ |
|||
"src/**/__tests__/*", |
|||
"node_modules", |
|||
"dist" |
|||
] |
|||
} |
@ -0,0 +1,70 @@ |
|||
/** |
|||
* Vite Configuration for Daily Notification Test App |
|||
* |
|||
* Vue 3 + TypeScript + Capacitor setup with vue-facing-decorator support |
|||
* |
|||
* @author Matthew Raymer |
|||
* @version 1.0.0 |
|||
*/ |
|||
|
|||
import { defineConfig } from 'vite' |
|||
import vue from '@vitejs/plugin-vue' |
|||
import { resolve } from 'path' |
|||
|
|||
// https://vitejs.dev/config/
|
|||
export default defineConfig({ |
|||
plugins: [vue()], |
|||
|
|||
// Build configuration for Capacitor
|
|||
build: { |
|||
outDir: 'dist', |
|||
assetsDir: 'assets', |
|||
sourcemap: true, |
|||
rollupOptions: { |
|||
input: { |
|||
main: resolve(__dirname, 'index.html') |
|||
} |
|||
} |
|||
}, |
|||
|
|||
// Development server configuration
|
|||
server: { |
|||
host: '0.0.0.0', |
|||
port: 3000, |
|||
strictPort: true, |
|||
hmr: { |
|||
port: 3001 |
|||
} |
|||
}, |
|||
|
|||
// Preview server configuration
|
|||
preview: { |
|||
host: '0.0.0.0', |
|||
port: 4173, |
|||
strictPort: true |
|||
}, |
|||
|
|||
// Path resolution
|
|||
resolve: { |
|||
alias: { |
|||
'@': resolve(__dirname, 'src'), |
|||
'@components': resolve(__dirname, 'src/components'), |
|||
'@views': resolve(__dirname, 'src/views'), |
|||
'@stores': resolve(__dirname, 'src/stores'), |
|||
'@services': resolve(__dirname, 'src/services'), |
|||
'@types': resolve(__dirname, 'src/types'), |
|||
'@utils': resolve(__dirname, 'src/utils') |
|||
} |
|||
}, |
|||
|
|||
// TypeScript configuration
|
|||
esbuild: { |
|||
target: 'es2020' |
|||
}, |
|||
|
|||
// Define global constants
|
|||
define: { |
|||
__VUE_OPTIONS_API__: true, |
|||
__VUE_PROD_DEVTOOLS__: false |
|||
} |
|||
}) |
Loading…
Reference in new issue