feat(test-app): refactor to Vue 3 + Vite + vue-facing-decorator architecture

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.
This commit is contained in:
Matthew Raymer
2025-10-15 06:09:18 +00:00
parent 49fd1dfedf
commit 6213235a16
31 changed files with 4575 additions and 25 deletions

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>