Browse Source
Complete refactoring of android-test app to modern Vue 3 stack: ## 🚀 New Architecture - Vue 3 with Composition API and TypeScript - Vite for fast development and building - vue-facing-decorator for class-based components - Pinia for reactive state management - Vue Router for navigation - Modern glassmorphism UI design ## 📱 App Structure - Comprehensive component library (cards, items, layout, ui) - Pinia stores for app and notification state management - Full view system (Home, Schedule, Notifications, Status, History) - Responsive design for mobile and desktop - TypeScript throughout with proper type definitions ## 🎨 Features - Dashboard with quick actions and status overview - Schedule notifications with time picker and options - Notification management with cancel functionality - System status with permission checks and diagnostics - Notification history with delivery tracking - Settings panel (placeholder for future features) ## 🔧 Technical Implementation - Class-based Vue components using vue-facing-decorator - Reactive Pinia stores with proper TypeScript types - Capacitor integration for native Android functionality - ESLint and TypeScript configuration - Vite build system with proper aliases and optimization ## 📚 Documentation - Comprehensive README with setup and usage instructions - Component documentation and examples - Development and production build instructions - Testing and debugging guidelines This creates a production-ready test app that closely mirrors the actual TimeSafari app architecture, making it ideal for plugin testing and demonstration purposes.master
31 changed files with 4575 additions and 25 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> |
@ -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": "vue-tsc && 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,145 @@ |
|||
<!-- |
|||
/** |
|||
* 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) { |
|||
throw new Error('DailyNotification plugin not available') |
|||
} |
|||
|
|||
console.log('✅ DailyNotification plugin available') |
|||
} else { |
|||
console.log('🌐 Running in web mode') |
|||
} |
|||
} |
|||
|
|||
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) |
|||
} |
|||
} |
|||
} |
|||
|
|||
private 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 |
|||
|
|||
private 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` |
|||
} |
|||
} |
|||
|
|||
private 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,238 @@ |
|||
<!-- |
|||
/** |
|||
* 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() |
|||
|
|||
private 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: '📋' } |
|||
] |
|||
|
|||
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 |
|||
|
|||
private handleClose(): void { |
|||
this.$emit('close') |
|||
} |
|||
|
|||
private handleRetry(): void { |
|||
this.$emit('retry') |
|||
} |
|||
|
|||
private 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> |
@ -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,100 @@ |
|||
/** |
|||
* 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: '/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 ${from.name || 'unknown'} to ${to.name || 'unknown'}`) |
|||
|
|||
next() |
|||
}) |
|||
|
|||
router.afterEach((to, from) => { |
|||
// Clear any previous errors on successful navigation
|
|||
console.log(`✅ Navigation completed: ${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,275 @@ |
|||
/** |
|||
* 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() || !window.DailyNotification) { |
|||
throw new Error('DailyNotification plugin not available') |
|||
} |
|||
|
|||
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() || !window.DailyNotification) { |
|||
throw new Error('DailyNotification plugin not available') |
|||
} |
|||
|
|||
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() || !window.DailyNotification) { |
|||
throw new Error('DailyNotification plugin not available') |
|||
} |
|||
|
|||
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() || !window.DailyNotification) { |
|||
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() || !window.DailyNotification) { |
|||
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,250 @@ |
|||
<!-- |
|||
/** |
|||
* 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" |
|||
/> |
|||
</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() |
|||
private 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) |
|||
} |
|||
} |
|||
|
|||
private 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 |
|||
} |
|||
} |
|||
|
|||
private navigateToSchedule(): void { |
|||
this.$router.push('/schedule') |
|||
} |
|||
|
|||
private navigateToNotifications(): void { |
|||
this.$router.push('/notifications') |
|||
} |
|||
|
|||
private navigateToHistory(): void { |
|||
this.$router.push('/history') |
|||
} |
|||
} |
|||
</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,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 { |
|||
private 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 |
|||
} |
|||
|
|||
private async handleCancelNotification(notificationId: string): Promise<void> { |
|||
try { |
|||
await this.notificationsStore.cancelReminder(notificationId) |
|||
} catch (error) { |
|||
console.error('❌ Failed to cancel notification:', error) |
|||
} |
|||
} |
|||
|
|||
private 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,519 @@ |
|||
<!-- |
|||
/** |
|||
* 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 { Capacitor } from '@capacitor/core' |
|||
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() |
|||
|
|||
private form: ScheduleForm = { |
|||
time: '', |
|||
title: 'Daily Update', |
|||
body: 'Your daily notification is ready', |
|||
sound: true, |
|||
priority: 'default', |
|||
url: '' |
|||
} |
|||
|
|||
private 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) |
|||
} |
|||
|
|||
private 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) |
|||
} |
|||
} |
|||
|
|||
private fillQuickOption(option: QuickOption): void { |
|||
this.form.time = option.time |
|||
this.form.title = option.title |
|||
this.form.body = option.body |
|||
} |
|||
|
|||
private 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() |
|||
private 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() |
|||
} |
|||
|
|||
private 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 |
|||
} |
|||
} |
|||
|
|||
private 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) |
|||
} |
|||
} |
|||
|
|||
private 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) |
|||
} |
|||
} |
|||
|
|||
private 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) |
|||
} |
|||
} |
|||
|
|||
private 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 @@ |
|||
{ |
|||
"extends": "@vue/tsconfig/tsconfig.dom.json", |
|||
"include": [ |
|||
"env.d.ts", |
|||
"src/**/*", |
|||
"src/**/*.vue" |
|||
], |
|||
"exclude": [ |
|||
"src/**/__tests__/*" |
|||
], |
|||
"compilerOptions": { |
|||
"target": "ES2020", |
|||
"module": "ES2020", |
|||
"moduleResolution": "node", |
|||
"composite": 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" |
|||
], |
|||
"strict": true, |
|||
"esModuleInterop": true, |
|||
"noUnusedLocals": true, |
|||
"noUnusedParameters": true, |
|||
"noFallthroughCasesInSwitch": true, |
|||
"experimentalDecorators": true, |
|||
"emitDecoratorMetadata": true, |
|||
"target": "ES2020", |
|||
"useDefineForClassFields": true, |
|||
"module": "ESNext", |
|||
"lib": ["ES2020", "DOM", "DOM.Iterable"], |
|||
"skipLibCheck": true, |
|||
"forceConsistentCasingInFileNames": true, |
|||
"outDir": "./dist", |
|||
"rootDir": "./src" |
|||
}, |
|||
"include": ["src/**/*"], |
|||
"exclude": ["node_modules", "dist"] |
|||
} |
|||
"moduleResolution": "bundler", |
|||
"allowImportingTsExtensions": true, |
|||
"resolveJsonModule": true, |
|||
"isolatedModules": true, |
|||
"noEmit": true, |
|||
"jsx": "preserve" |
|||
} |
|||
} |
@ -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