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:
163
test-apps/android-test/src/components/cards/ActionCard.vue
Normal file
163
test-apps/android-test/src/components/cards/ActionCard.vue
Normal 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>
|
||||
95
test-apps/android-test/src/components/cards/InfoCard.vue
Normal file
95
test-apps/android-test/src/components/cards/InfoCard.vue
Normal 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>
|
||||
181
test-apps/android-test/src/components/cards/NotificationCard.vue
Normal file
181
test-apps/android-test/src/components/cards/NotificationCard.vue
Normal 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>
|
||||
264
test-apps/android-test/src/components/cards/StatusCard.vue
Normal file
264
test-apps/android-test/src/components/cards/StatusCard.vue
Normal 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>
|
||||
141
test-apps/android-test/src/components/items/ActivityItem.vue
Normal file
141
test-apps/android-test/src/components/items/ActivityItem.vue
Normal 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>
|
||||
170
test-apps/android-test/src/components/items/HistoryItem.vue
Normal file
170
test-apps/android-test/src/components/items/HistoryItem.vue
Normal 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>
|
||||
99
test-apps/android-test/src/components/items/StatusItem.vue
Normal file
99
test-apps/android-test/src/components/items/StatusItem.vue
Normal 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>
|
||||
75
test-apps/android-test/src/components/layout/AppFooter.vue
Normal file
75
test-apps/android-test/src/components/layout/AppFooter.vue
Normal 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>
|
||||
238
test-apps/android-test/src/components/layout/AppHeader.vue
Normal file
238
test-apps/android-test/src/components/layout/AppHeader.vue
Normal 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>
|
||||
161
test-apps/android-test/src/components/ui/ErrorDialog.vue
Normal file
161
test-apps/android-test/src/components/ui/ErrorDialog.vue
Normal 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>
|
||||
78
test-apps/android-test/src/components/ui/LoadingOverlay.vue
Normal file
78
test-apps/android-test/src/components/ui/LoadingOverlay.vue
Normal 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>
|
||||
Reference in New Issue
Block a user