Browse Source

Merge branch 'qrcode-reboot' into trent-tweaks

pull/133/head
Trent Larson 1 day ago
parent
commit
d555bc3e9c
  1. 2
      .cursor/rules/architectural_decision_record.mdc
  2. 177
      .cursor/rules/qr-code-handling-rule.mdc
  3. 533
      .cursor/rules/qr-code-implementation-guide.mdc
  4. 18
      README.md
  5. 23
      capacitor.config.json
  6. 78
      doc/DEEP_LINKS.md
  7. 2
      doc/openssl_signing_console.rst
  8. 805
      doc/qr-code-implementation-guide.md
  9. 0
      doc/web-push.md
  10. 507
      docs/camera-implementation.md
  11. 275
      package-lock.json
  12. 156
      qr-code-implementation-guide.md
  13. 4
      scripts/openssl_signing_console.sh
  14. 518
      src/components/ImageMethodDialog.vue
  15. 256
      src/components/PhotoDialog.vue
  16. 103
      src/interfaces/deepLinks.ts
  17. 21
      src/interfaces/give.ts
  18. 0
      src/libs/capacitor/app.ts
  19. 2
      src/libs/fontawesome.ts
  20. 2
      src/main.capacitor.ts
  21. 2
      src/main.common.ts
  22. 120
      src/services/QRScanner/WebInlineQRScanner.ts
  23. 20
      src/services/QRScanner/types.ts
  24. 4
      src/services/deepLinks.ts
  25. 188
      src/services/platforms/WebPlatformService.ts
  26. 103
      src/types/deepLinks.ts
  27. 25
      src/types/index.ts
  28. 110
      src/views/AccountViewView.vue
  29. 130
      src/views/ContactQRScanShowView.vue
  30. 2
      src/views/DeepLinkErrorView.vue

2
.cursor/rules/architectural_decision_record.mdc

@ -31,7 +31,7 @@ src/
├── electron/ # Electron-specific code ├── electron/ # Electron-specific code
├── constants/ # Application constants ├── constants/ # Application constants
├── db/ # Database related code ├── db/ # Database related code
├── interfaces/ # TypeScript interfaces ├── interfaces/ # TypeScript interfaces and type definitions
└── assets/ # Static assets └── assets/ # Static assets
``` ```

177
.cursor/rules/qr-code-handling-rule.mdc

@ -1,177 +0,0 @@
---
description:
globs:
alwaysApply: true
---
# QR Code Handling Rule
## Architecture Overview
The QR code scanning functionality follows a platform-agnostic design using a factory pattern that provides different implementations for web and mobile platforms.
### Core Components
1. **Factory Pattern**
- `QRScannerFactory` - Creates appropriate scanner instance based on platform
- Common interface `QRScannerService` implemented by all scanners
- Platform detection via Capacitor and build flags
2. **Platform-Specific Implementations**
- `CapacitorQRScanner` - Native mobile implementation using MLKit
- `WebInlineQRScanner` - Web browser implementation using MediaDevices API
- `QRScannerDialog.vue` - Shared UI component
## Mobile Implementation (Capacitor)
### Technology Stack
- Uses `@capacitor-mlkit/barcode-scanning` plugin
- Configured in `capacitor.config.ts`
- Native camera access through platform APIs
### Key Features
- Direct camera access via native APIs
- Optimized for mobile performance
- Supports both iOS and Android
- Real-time QR code detection
- Back camera preferred for scanning
### Configuration
```typescript
MLKitBarcodeScanner: {
formats: ['QR_CODE'],
detectorSize: 1.0,
lensFacing: 'back',
googleBarcodeScannerModuleInstallState: true
}
```
### Permissions Handling
1. Check permissions via `BarcodeScanner.checkPermissions()`
2. Request permissions if needed
3. Handle permission states (granted/denied)
4. Graceful fallbacks for permission issues
## Web Implementation
### Technology Stack
- Uses browser's MediaDevices API
- Vue.js components for UI
- EventEmitter for stream management
### Key Features
- Browser-based camera access
- Inline camera preview
- Responsive design
- Cross-browser compatibility
- Progressive enhancement
### Permissions Handling
1. Uses browser's permission API
2. MediaDevices API for camera access
3. Handles secure context requirements
4. Provides user feedback for permission states
## Shared Features
### Error Handling
1. Permission denied scenarios
2. Device compatibility checks
3. Camera access failures
4. QR code validation
5. Network connectivity issues
### User Experience
1. Clear feedback during scanning
2. Loading states
3. Error messages
4. Success confirmations
5. Camera preview
### Security
1. HTTPS requirement for web
2. Permission validation
3. Data validation
4. Safe error handling
## Usage Guidelines
### Platform Detection
```typescript
const isNative = QRScannerFactory.isNativePlatform();
if (isNative) {
// Use native scanner
} else {
// Use web scanner
}
```
### Implementation Example
```typescript
const scanner = QRScannerFactory.getInstance();
await scanner.checkPermissions();
await scanner.startScan();
scanner.addListener({
onScan: (result) => {
// Handle scan result
},
onError: (error) => {
// Handle error
}
});
```
### Best Practices
1. Always check permissions before starting scan
2. Clean up resources after scanning
3. Handle all error cases
4. Provide clear user feedback
5. Test on multiple devices/browsers
## Platform-Specific Notes
### Mobile (Capacitor)
1. Use native camera API when available
2. Handle device rotation
3. Support both front/back cameras
4. Manage system permissions properly
5. Handle app lifecycle events
### Web
1. Check browser compatibility
2. Handle secure context requirement
3. Manage memory usage
4. Clean up MediaStream
5. Handle tab visibility changes
## Testing Requirements
1. Test on multiple devices
2. Verify permission flows
3. Check error handling
4. Validate cleanup
5. Verify cross-platform behavior
## Service Interface
```typescript
interface QRScannerService {
checkPermissions(): Promise<boolean>;
requestPermissions(): Promise<boolean>;
isSupported(): Promise<boolean>;
startScan(options?: QRScannerOptions): Promise<void>;
stopScan(): Promise<void>;
addListener(listener: ScanListener): void;
onStream(callback: (stream: MediaStream | null) => void): void;
cleanup(): Promise<void>;
}
interface ScanListener {
onScan: (result: string) => void;
onError?: (error: Error) => void;
}
interface QRScannerOptions {
camera?: "front" | "back";
showPreview?: boolean;
playSound?: boolean;
}

533
.cursor/rules/qr-code-implementation-guide.mdc

@ -1,533 +0,0 @@
---
description:
globs:
alwaysApply: true
---
# QR Code Implementation Guide
## Directory Structure
```
src/
├── services/
│ └── QRScanner/
│ ├── types.ts # Core interfaces and types
│ ├── QRScannerFactory.ts # Factory for creating scanner instances
│ ├── CapacitorQRScanner.ts # Mobile implementation using MLKit
│ ├── WebInlineQRScanner.ts # Web implementation using MediaDevices API
│ └── interfaces.ts # Additional interfaces
├── components/
│ └── QRScanner/
│ └── QRScannerDialog.vue # Shared UI component
```
## Core Interfaces
```typescript
// types.ts
export interface ScanListener {
onScan: (result: string) => void;
onError?: (error: Error) => void;
}
export interface QRScannerOptions {
camera?: "front" | "back";
showPreview?: boolean;
playSound?: boolean;
}
export interface QRScannerService {
checkPermissions(): Promise<boolean>;
requestPermissions(): Promise<boolean>;
isSupported(): Promise<boolean>;
startScan(options?: QRScannerOptions): Promise<void>;
stopScan(): Promise<void>;
addListener(listener: ScanListener): void;
onStream(callback: (stream: MediaStream | null) => void): void;
cleanup(): Promise<void>;
}
```
## Configuration Files
### Vite Configuration
```typescript
// vite.config.common.mts
export function createBuildConfig(mode: string) {
return {
define: {
'process.env.VITE_PLATFORM': JSON.stringify(mode),
'process.env.VITE_PWA_ENABLED': JSON.stringify(!isNative),
__IS_MOBILE__: JSON.stringify(isCapacitor),
__USE_QR_READER__: JSON.stringify(!isCapacitor)
}
};
}
```
### Capacitor Configuration
```typescript
// capacitor.config.ts
const config: CapacitorConfig = {
plugins: {
MLKitBarcodeScanner: {
formats: ['QR_CODE'],
detectorSize: 1.0,
lensFacing: 'back',
googleBarcodeScannerModuleInstallState: true
}
}
};
```
## Implementation Steps
1. **Install Dependencies**
```bash
npm install @capacitor-mlkit/barcode-scanning
```
2. **Create Core Types**
Create the interface files as shown above.
3. **Implement Factory**
```typescript
// QRScannerFactory.ts
export class QRScannerFactory {
private static instance: QRScannerService | null = null;
private static isNativePlatform(): boolean {
const capacitorNative = Capacitor.isNativePlatform();
const isMobile = typeof __IS_MOBILE__ !== "undefined" ? __IS_MOBILE__ : capacitorNative;
const platform = Capacitor.getPlatform();
// Always use native scanner on Android/iOS
if (platform === "android" || platform === "ios") {
return true;
}
// For other platforms, use native if available
return capacitorNative || isMobile;
}
static getInstance(): QRScannerService {
if (!this.instance) {
const isNative = this.isNativePlatform();
if (isNative) {
this.instance = new CapacitorQRScanner();
} else {
this.instance = new WebInlineQRScanner();
}
}
return this.instance!;
}
static async cleanup(): Promise<void> {
if (this.instance) {
await this.instance.cleanup();
this.instance = null;
}
}
}
```
4. **Implement Mobile Scanner**
```typescript
// CapacitorQRScanner.ts
export class CapacitorQRScanner implements QRScannerService {
private scanListener: ScanListener | null = null;
private isScanning = false;
private listenerHandles: Array<() => Promise<void>> = [];
private cleanupPromise: Promise<void> | null = null;
async checkPermissions(): Promise<boolean> {
try {
const { camera } = await BarcodeScanner.checkPermissions();
return camera === "granted";
} catch (error) {
logger.error("Error checking camera permissions:", error);
return false;
}
}
async requestPermissions(): Promise<boolean> {
try {
if (await this.checkPermissions()) {
return true;
}
const { camera } = await BarcodeScanner.requestPermissions();
return camera === "granted";
} catch (error) {
logger.error("Error requesting camera permissions:", error);
return false;
}
}
async isSupported(): Promise<boolean> {
try {
const { supported } = await BarcodeScanner.isSupported();
return supported;
} catch (error) {
logger.error("Error checking scanner support:", error);
return false;
}
}
async startScan(options?: QRScannerOptions): Promise<void> {
if (this.isScanning) return;
if (this.cleanupPromise) {
await this.cleanupPromise;
}
try {
if (!(await this.checkPermissions())) {
const granted = await this.requestPermissions();
if (!granted) {
throw new Error("Camera permission denied");
}
}
if (!(await this.isSupported())) {
throw new Error("QR scanning not supported on this device");
}
this.isScanning = true;
const scanOptions: StartScanOptions = {
formats: [BarcodeFormat.QrCode],
lensFacing: options?.camera === "front" ? LensFacing.Front : LensFacing.Back,
};
const handle = await BarcodeScanner.addListener("barcodeScanned", (result) => {
if (this.scanListener && result.barcode?.rawValue) {
this.scanListener.onScan(result.barcode.rawValue);
}
});
this.listenerHandles.push(handle.remove);
await BarcodeScanner.startScan(scanOptions);
} catch (error) {
this.isScanning = false;
await this.cleanup();
this.scanListener?.onError?.(error instanceof Error ? error : new Error(String(error)));
throw error;
}
}
async stopScan(): Promise<void> {
if (!this.isScanning) return;
this.isScanning = false;
try {
await BarcodeScanner.stopScan();
} catch (error) {
logger.error("Error stopping scan:", error);
throw error;
}
}
addListener(listener: ScanListener): void {
this.scanListener = listener;
}
onStream(callback: (stream: MediaStream | null) => void): void {
// No-op for native scanner
callback(null);
}
async cleanup(): Promise<void> {
await this.stopScan();
for (const handle of this.listenerHandles) {
await handle();
}
this.listenerHandles = [];
this.scanListener = null;
}
}
```
5. **Implement Web Scanner**
```typescript
// WebInlineQRScanner.ts
export class WebInlineQRScanner implements QRScannerService {
private scanListener: ScanListener | null = null;
private isScanning = false;
private stream: MediaStream | null = null;
private events = new EventEmitter();
constructor(private options?: QRScannerOptions) {}
async checkPermissions(): Promise<boolean> {
try {
const permissions = await navigator.permissions.query({
name: "camera" as PermissionName,
});
return permissions.state === "granted";
} catch (error) {
logger.error("Error checking camera permissions:", error);
return false;
}
}
async requestPermissions(): Promise<boolean> {
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: {
facingMode: "environment",
width: { ideal: 1280 },
height: { ideal: 720 },
},
});
stream.getTracks().forEach(track => track.stop());
return true;
} catch (error) {
logger.error("Error requesting camera permissions:", error);
return false;
}
}
async isSupported(): Promise<boolean> {
return 'mediaDevices' in navigator && 'getUserMedia' in navigator.mediaDevices;
}
async startScan(): Promise<void> {
if (this.isScanning) return;
try {
this.isScanning = true;
this.stream = await navigator.mediaDevices.getUserMedia({
video: {
facingMode: "environment",
width: { ideal: 1280 },
height: { ideal: 720 },
},
});
this.events.emit("stream", this.stream);
} catch (error) {
this.isScanning = false;
const wrappedError = error instanceof Error ? error : new Error(String(error));
this.scanListener?.onError?.(wrappedError);
throw wrappedError;
}
}
async stopScan(): Promise<void> {
if (!this.isScanning) return;
try {
if (this.stream) {
this.stream.getTracks().forEach(track => track.stop());
this.stream = null;
}
this.events.emit("stream", null);
} catch (error) {
logger.error("Error stopping scan:", error);
throw error;
} finally {
this.isScanning = false;
}
}
addListener(listener: ScanListener): void {
this.scanListener = listener;
}
onStream(callback: (stream: MediaStream | null) => void): void {
this.events.on("stream", callback);
}
async cleanup(): Promise<void> {
try {
await this.stopScan();
this.events.removeAllListeners();
} catch (error) {
logger.error("Error during cleanup:", error);
}
}
}
```
## Usage Example
```typescript
// Example usage in a Vue component
import { QRScannerFactory } from '@/services/QRScanner/QRScannerFactory';
export default defineComponent({
async mounted() {
const scanner = QRScannerFactory.getInstance();
try {
// Check and request permissions
if (!(await scanner.checkPermissions())) {
const granted = await scanner.requestPermissions();
if (!granted) {
throw new Error('Camera permission denied');
}
}
// Add scan listener
scanner.addListener({
onScan: (result) => {
console.log('QR Code scanned:', result);
},
onError: (error) => {
console.error('Scan error:', error);
}
});
// Start scanning
await scanner.startScan({
camera: 'back',
showPreview: true
});
// Handle stream for preview
scanner.onStream((stream) => {
if (stream) {
// Update video element with stream
this.videoElement.srcObject = stream;
}
});
} catch (error) {
console.error('Failed to start scanner:', error);
}
},
async beforeUnmount() {
// Clean up scanner
await QRScannerFactory.cleanup();
}
});
```
## Best Practices
1. **Error Handling**
- Always implement error handlers in scan listeners
- Handle permission denials gracefully
- Provide user feedback for errors
- Clean up resources on errors
2. **Resource Management**
- Always call cleanup when done
- Stop camera streams properly
- Remove event listeners
- Handle component unmounting
3. **Performance**
- Use appropriate camera resolution
- Clean up resources promptly
- Handle platform-specific optimizations
- Monitor memory usage
4. **Security**
- Require HTTPS for web implementation
- Validate scanned data
- Handle permissions properly
- Sanitize user input
5. **Testing**
- Test on multiple devices
- Verify permission flows
- Check error scenarios
- Validate cleanup
- Test cross-platform behavior
## Platform-Specific Notes
### Mobile (Capacitor)
- Uses MLKit for optimal performance
- Handles native permissions
- Supports both iOS and Android
- Uses back camera by default
- Handles device rotation
- Provides native UI for scanning
### Web
- Uses MediaDevices API
- Requires HTTPS for camera access
- Handles browser compatibility
- Manages memory and resources
- Provides fallback UI
- Uses vue-qrcode-reader for web scanning
## Testing
1. **Unit Tests**
- Test factory pattern
- Test platform detection
- Test error handling
- Test cleanup procedures
- Test permission flows
2. **Integration Tests**
- Test camera access
- Test QR code detection
- Test cross-platform behavior
- Test UI components
- Test error scenarios
3. **E2E Tests**
- Test complete scanning flow
- Test permission handling
- Test cross-platform compatibility
- Test error recovery
- Test cleanup procedures
## Best Practices
1. **Error Handling**
- Always handle permission errors gracefully
- Provide clear error messages to users
- Implement proper cleanup on errors
- Log errors for debugging
2. **Performance**
- Clean up resources when not in use
- Handle device rotation properly
- Optimize camera usage
- Manage memory efficiently
3. **Security**
- Request minimum required permissions
- Handle sensitive data securely
- Validate scanned data
- Implement proper cleanup
4. **User Experience**
- Provide clear feedback
- Handle edge cases gracefully
- Support both platforms seamlessly
- Implement proper loading states
## Troubleshooting
1. **Common Issues**
- Camera permissions denied
- Device not supported
- Scanner not working
- Memory leaks
- UI glitches
2. **Solutions**
- Check permissions
- Verify device support
- Debug scanner implementation
- Monitor memory usage
- Test UI components
## Maintenance
1. **Regular Updates**
- Keep dependencies updated
- Monitor platform changes
- Update documentation
- Review security patches
2. **Performance Monitoring**
- Track memory usage
- Monitor camera performance
- Check error rates
- Analyze user feedback

18
README.md

@ -46,6 +46,24 @@ To add a Font Awesome icon, add to main.ts and reference with `font-awesome` ele
* If you are deploying in a subdirectory, add it to `publicPath` in vue.config.js, eg: `publicPath: "/app/time-tracker/",` * If you are deploying in a subdirectory, add it to `publicPath` in vue.config.js, eg: `publicPath: "/app/time-tracker/",`
### Code Organization
The project uses a centralized approach to type definitions and interfaces:
* `src/interfaces/` - Contains all TypeScript interfaces and type definitions
* `deepLinks.ts` - Deep linking type system and Zod validation schemas
* `give.ts` - Give-related interfaces and type definitions
* `claims.ts` - Claim-related interfaces and verifiable credentials
* `common.ts` - Shared interfaces and utility types
* Other domain-specific interface files
Key principles:
- All interfaces and types are defined in the interfaces folder
- Zod schemas are used for runtime validation and type generation
- Domain-specific interfaces are separated into their own files
- Common interfaces are shared through `common.ts`
- Type definitions are generated from Zod schemas where possible
### Kudos ### Kudos
Gifts make the world go 'round! Gifts make the world go 'round!

23
capacitor.config.json

@ -1,6 +1,21 @@
{ {
"appId": "com.brownspank.timesafari", "appId": "app.timesafari",
"appName": "TimeSafari", "appName": "TimeSafari",
"webDir": "dist", "webDir": "dist",
"bundledWebRuntime": false "bundledWebRuntime": false,
"server": {
"cleartext": true
},
"plugins": {
"App": {
"appUrlOpen": {
"handlers": [
{
"url": "timesafari://*",
"autoVerify": true
}
]
}
}
}
} }

78
docs/DEEP_LINKS.md → doc/DEEP_LINKS.md

@ -9,21 +9,95 @@ The deep linking system uses a multi-layered type safety approach:
- Enforces parameter requirements - Enforces parameter requirements
- Sanitizes input data - Sanitizes input data
- Provides detailed validation errors - Provides detailed validation errors
- Generates TypeScript types automatically
2. **TypeScript Types** 2. **TypeScript Types**
- Generated from Zod schemas - Generated from Zod schemas using `z.infer`
- Ensures compile-time type safety - Ensures compile-time type safety
- Provides IDE autocompletion - Provides IDE autocompletion
- Catches type errors during development - Catches type errors during development
- Maintains single source of truth for types
3. **Router Integration** 3. **Router Integration**
- Type-safe parameter passing - Type-safe parameter passing
- Route-specific parameter validation - Route-specific parameter validation
- Query parameter type checking - Query parameter type checking
- Automatic type inference for route parameters
## Type System Implementation
### Zod Schema to TypeScript Type Generation
```typescript
// Define the schema
const claimSchema = z.object({
id: z.string(),
view: z.enum(["details", "certificate", "raw"]).optional()
});
// TypeScript type is automatically generated
type ClaimParams = z.infer<typeof claimSchema>;
// Equivalent to:
// type ClaimParams = {
// id: string;
// view?: "details" | "certificate" | "raw";
// }
```
### Type Safety Layers
1. **Schema Definition**
```typescript
// src/interfaces/deepLinks.ts
export const deepLinkSchemas = {
claim: z.object({
id: z.string(),
view: z.enum(["details", "certificate", "raw"]).optional()
}),
// Other route schemas...
};
```
2. **Type Generation**
```typescript
// Types are automatically generated from schemas
export type DeepLinkParams = {
[K in keyof typeof deepLinkSchemas]: z.infer<(typeof deepLinkSchemas)[K]>;
};
```
3. **Runtime Validation**
```typescript
// In DeepLinkHandler
const result = deepLinkSchemas.claim.safeParse(params);
if (!result.success) {
// Handle validation errors
console.error(result.error);
}
```
### Error Handling Types
```typescript
export interface DeepLinkError extends Error {
code: string;
details?: unknown;
}
// Usage in error handling
try {
await handler.handleDeepLink(url);
} catch (error) {
if (error instanceof DeepLinkError) {
// Type-safe error handling
console.error(error.code, error.message);
}
}
```
## Implementation Files ## Implementation Files
- `src/types/deepLinks.ts`: Type definitions and validation schemas - `src/interfaces/deepLinks.ts`: Type definitions and validation schemas
- `src/services/deepLinks.ts`: Deep link processing service - `src/services/deepLinks.ts`: Deep link processing service
- `src/main.capacitor.ts`: Capacitor integration - `src/main.capacitor.ts`: Capacitor integration

2
openssl_signing_console.rst → doc/openssl_signing_console.rst

@ -1,6 +1,6 @@
JWT Creation & Verification JWT Creation & Verification
To run this in a script, see ./openssl_signing_console.sh To run this in a script, see /scripts/openssl_signing_console.sh
Prerequisites: openssl, jq Prerequisites: openssl, jq

805
doc/qr-code-implementation-guide.md

@ -0,0 +1,805 @@
# QR Code Implementation Guide
## Overview
This document describes the QR code scanning and generation implementation in the TimeSafari application. The system uses a platform-agnostic design with specific implementations for web and mobile platforms.
## Architecture
### Directory Structure
```
src/
├── services/
│ └── QRScanner/
│ ├── types.ts # Core interfaces and types
│ ├── QRScannerFactory.ts # Factory for creating scanner instances
│ ├── CapacitorQRScanner.ts # Mobile implementation using MLKit
│ ├── WebInlineQRScanner.ts # Web implementation using MediaDevices API
│ └── interfaces.ts # Additional interfaces
├── components/
│ └── QRScanner/
│ └── QRScannerDialog.vue # Shared UI component
└── views/
├── ContactQRScanView.vue # Dedicated scanning view
└── ContactQRScanShowView.vue # Combined QR display and scanning view
```
### Core Components
1. **Factory Pattern**
- `QRScannerFactory` - Creates appropriate scanner instance based on platform
- Common interface `QRScannerService` implemented by all scanners
- Platform detection via Capacitor and build flags
2. **Platform-Specific Implementations**
- `CapacitorQRScanner` - Native mobile implementation using MLKit
- `WebInlineQRScanner` - Web browser implementation using MediaDevices API
- `QRScannerDialog.vue` - Shared UI component
3. **View Components**
- `ContactQRScanView` - Dedicated view for scanning QR codes
- `ContactQRScanShowView` - Combined view for displaying and scanning QR codes
## Implementation Details
### Core Interfaces
```typescript
interface QRScannerService {
checkPermissions(): Promise<boolean>;
requestPermissions(): Promise<boolean>;
isSupported(): Promise<boolean>;
startScan(options?: QRScannerOptions): Promise<void>;
stopScan(): Promise<void>;
addListener(listener: ScanListener): void;
onStream(callback: (stream: MediaStream | null) => void): void;
cleanup(): Promise<void>;
getAvailableCameras(): Promise<MediaDeviceInfo[]>;
switchCamera(deviceId: string): Promise<void>;
getCurrentCamera(): Promise<MediaDeviceInfo | null>;
}
interface ScanListener {
onScan: (result: string) => void;
onError?: (error: Error) => void;
}
interface QRScannerOptions {
camera?: "front" | "back";
showPreview?: boolean;
playSound?: boolean;
}
```
### Platform-Specific Implementations
#### Mobile (Capacitor)
- Uses `@capacitor-mlkit/barcode-scanning`
- Native camera access through platform APIs
- Optimized for mobile performance
- Supports both iOS and Android
- Real-time QR code detection
- Back camera preferred for scanning
Configuration:
```typescript
// capacitor.config.ts
const config: CapacitorConfig = {
plugins: {
MLKitBarcodeScanner: {
formats: ['QR_CODE'],
detectorSize: 1.0,
lensFacing: 'back',
googleBarcodeScannerModuleInstallState: true,
// Additional camera options
cameraOptions: {
quality: 0.8,
allowEditing: false,
resultType: 'uri',
sourceType: 'CAMERA',
saveToGallery: false
}
}
}
};
```
#### Web
- Uses browser's MediaDevices API
- Vue.js components for UI
- EventEmitter for stream management
- Browser-based camera access
- Inline camera preview
- Responsive design
- Cross-browser compatibility
### View Components
#### ContactQRScanView
- Dedicated view for scanning QR codes
- Full-screen camera interface
- Simple UI focused on scanning
- Used primarily on native platforms
- Streamlined scanning experience
#### ContactQRScanShowView
- Combined view for QR code display and scanning
- Shows user's own QR code
- Handles user registration status
- Provides options to copy contact information
- Platform-specific scanning implementation:
- Native: Button to navigate to ContactQRScanView
- Web: Built-in scanning functionality
### QR Code Workflow
1. **Initiation**
- User selects "Scan QR Code" option
- Platform-specific scanner is initialized
- Camera permissions are verified
- Appropriate scanner component is loaded
2. **Platform-Specific Implementation**
- Web: Uses `qrcode-stream` for real-time scanning
- Native: Uses `@capacitor-mlkit/barcode-scanning`
3. **Scanning Process**
- Camera stream initialization
- Real-time frame analysis
- QR code detection and decoding
- Validation of QR code format
- Processing of contact information
4. **Contact Processing**
- Decryption of contact data
- Validation of user information
- Verification of timestamp
- Check for duplicate contacts
- Processing of shared data
## Build Configuration
### Common Vite Configuration
```typescript
// vite.config.common.mts
export async function createBuildConfig(mode: string) {
const isCapacitor = mode === "capacitor";
return defineConfig({
define: {
'process.env.VITE_PLATFORM': JSON.stringify(mode),
'process.env.VITE_PWA_ENABLED': JSON.stringify(!isNative),
__IS_MOBILE__: JSON.stringify(isCapacitor),
__USE_QR_READER__: JSON.stringify(!isCapacitor)
},
optimizeDeps: {
include: [
'@capacitor-mlkit/barcode-scanning',
'vue-qrcode-reader'
]
}
});
}
```
### Platform-Specific Builds
```json
{
"scripts": {
"build:web": "vite build --config vite.config.web.mts",
"build:capacitor": "vite build --config vite.config.capacitor.mts",
"build:all": "npm run build:web && npm run build:capacitor"
}
}
```
## Error Handling
### Common Error Scenarios
1. No camera found
2. Permission denied
3. Camera in use by another application
4. HTTPS required
5. Browser compatibility issues
6. Invalid QR code format
7. Expired QR codes
8. Duplicate contact attempts
9. Network connectivity issues
### Error Response
- User-friendly error messages
- Troubleshooting tips
- Clear instructions for resolution
- Platform-specific guidance
## Security Considerations
### QR Code Security
- Encryption of contact data
- Timestamp validation
- Version checking
- User verification
- Rate limiting for scans
### Data Protection
- Secure transmission of contact data
- Validation of QR code authenticity
- Prevention of duplicate scans
- Protection against malicious codes
- Secure storage of contact information
## Best Practices
### Camera Access
1. Always check for camera availability
2. Request permissions explicitly
3. Handle all error conditions
4. Provide clear user feedback
5. Implement proper cleanup
### Performance
1. Optimize camera resolution
2. Implement proper resource cleanup
3. Handle camera switching efficiently
4. Manage memory usage
5. Battery usage optimization
### User Experience
1. Clear visual feedback
2. Camera preview
3. Scanning status indicators
4. Error messages
5. Success confirmations
6. Intuitive camera controls
7. Smooth camera switching
8. Responsive UI feedback
## Testing
### Test Scenarios
1. Permission handling
2. Camera switching
3. Error conditions
4. Platform compatibility
5. Performance metrics
6. QR code detection
7. Contact processing
8. Security validation
### Test Environment
- Multiple browsers
- iOS and Android devices
- Various network conditions
- Different camera configurations
## Dependencies
### Key Packages
- `@capacitor-mlkit/barcode-scanning`
- `qrcode-stream`
- `vue-qrcode-reader`
- Platform-specific camera APIs
## Maintenance
### Regular Updates
- Keep dependencies updated
- Monitor platform changes
- Update documentation
- Review security patches
### Performance Monitoring
- Track memory usage
- Monitor camera performance
- Check error rates
- Analyze user feedback
## Camera Handling
### Camera Switching Implementation
The QR scanner supports camera switching on both mobile and desktop platforms through a unified interface.
#### Platform-Specific Implementations
1. **Mobile (Capacitor)**
- Uses `@capacitor-mlkit/barcode-scanning`
- Supports front/back camera switching
- Native camera access through platform APIs
- Optimized for mobile performance
```typescript
// CapacitorQRScanner.ts
async startScan(options?: QRScannerOptions): Promise<void> {
const scanOptions: StartScanOptions = {
formats: [BarcodeFormat.QrCode],
lensFacing: options?.camera === "front" ?
LensFacing.Front : LensFacing.Back
};
await BarcodeScanner.startScan(scanOptions);
}
```
2. **Web (Desktop)**
- Uses browser's MediaDevices API
- Supports multiple camera devices
- Dynamic camera enumeration
- Real-time camera switching
```typescript
// WebInlineQRScanner.ts
async getAvailableCameras(): Promise<MediaDeviceInfo[]> {
const devices = await navigator.mediaDevices.enumerateDevices();
return devices.filter(device => device.kind === 'videoinput');
}
async switchCamera(deviceId: string): Promise<void> {
// Stop current stream
await this.stopScan();
// Start new stream with selected camera
this.stream = await navigator.mediaDevices.getUserMedia({
video: {
deviceId: { exact: deviceId },
width: { ideal: 1280 },
height: { ideal: 720 }
}
});
// Update video and restart scanning
if (this.video) {
this.video.srcObject = this.stream;
await this.video.play();
}
this.scanQRCode();
}
```
### Core Interfaces
```typescript
interface QRScannerService {
// ... existing methods ...
/** Get available cameras */
getAvailableCameras(): Promise<MediaDeviceInfo[]>;
/** Switch to a specific camera */
switchCamera(deviceId: string): Promise<void>;
/** Get current camera info */
getCurrentCamera(): Promise<MediaDeviceInfo | null>;
}
interface QRScannerOptions {
/** Camera to use ('front' or 'back' for mobile) */
camera?: "front" | "back";
/** Whether to show a preview of the camera feed */
showPreview?: boolean;
/** Whether to play a sound on successful scan */
playSound?: boolean;
}
```
### UI Components
The camera switching UI adapts to the platform:
1. **Mobile Interface**
- Simple toggle button for front/back cameras
- Positioned in bottom-right corner
- Clear visual feedback during switching
- Native camera controls
```vue
<button
v-if="isNativePlatform"
@click="toggleMobileCamera"
class="camera-switch-btn"
>
<font-awesome icon="camera-rotate" />
Switch Camera
</button>
```
2. **Desktop Interface**
- Dropdown menu with all available cameras
- Camera labels and device IDs
- Real-time camera switching
- Responsive design
```vue
<select
v-model="selectedCameraId"
@change="onCameraChange"
class="camera-select-dropdown"
>
<option
v-for="camera in availableCameras"
:key="camera.deviceId"
:value="camera.deviceId"
>
{{ camera.label || `Camera ${camera.deviceId.slice(0, 4)}` }}
</option>
</select>
```
### Error Handling
The camera switching implementation includes comprehensive error handling:
1. **Common Error Scenarios**
- Camera in use by another application
- Permission denied during switch
- Device not available
- Stream initialization failure
- Camera switch timeout
2. **Error Response**
```typescript
private async handleCameraSwitch(deviceId: string): Promise<void> {
try {
this.updateCameraState("initializing", "Switching camera...");
await this.switchCamera(deviceId);
this.updateCameraState("active", "Camera switched successfully");
} catch (error) {
this.updateCameraState("error", "Failed to switch camera");
throw error;
}
}
```
3. **User Feedback**
- Visual indicators during switching
- Error notifications
- Camera state updates
- Permission request dialogs
### State Management
The camera system maintains several states:
1. **Camera States**
```typescript
type CameraState =
| "initializing" // Camera is being initialized
| "ready" // Camera is ready to use
| "active" // Camera is actively streaming
| "in_use" // Camera is in use by another application
| "permission_denied" // Camera permission was denied
| "not_found" // No camera found on device
| "error" // Generic error state
| "off"; // Camera is off
```
2. **State Transitions**
- Initialization → Ready
- Ready → Active
- Active → Switching
- Switching → Active/Error
- Any state → Off (on cleanup)
### Best Practices
1. **Camera Access**
- Always check permissions before switching
- Handle camera busy states
- Implement proper cleanup
- Monitor camera state changes
2. **Performance**
- Optimize camera resolution
- Handle stream switching efficiently
- Manage memory usage
- Implement proper cleanup
3. **User Experience**
- Clear visual feedback
- Smooth camera transitions
- Intuitive camera controls
- Responsive UI updates
- Accessible camera selection
4. **Security**
- Secure camera access
- Permission management
- Device validation
- Stream security
### Testing
1. **Test Scenarios**
- Camera switching on both platforms
- Permission handling
- Error conditions
- Multiple camera devices
- Camera busy states
- Stream initialization
- UI responsiveness
2. **Test Environment**
- Multiple mobile devices
- Various desktop browsers
- Different camera configurations
- Network conditions
- Permission states
### Capacitor Implementation Details
#### MLKit Barcode Scanner Configuration
1. **Plugin Setup**
```typescript
// capacitor.config.ts
const config: CapacitorConfig = {
plugins: {
MLKitBarcodeScanner: {
formats: ['QR_CODE'],
detectorSize: 1.0,
lensFacing: 'back',
googleBarcodeScannerModuleInstallState: true,
// Additional camera options
cameraOptions: {
quality: 0.8,
allowEditing: false,
resultType: 'uri',
sourceType: 'CAMERA',
saveToGallery: false
}
}
}
};
```
2. **Camera Management**
```typescript
// CapacitorQRScanner.ts
export class CapacitorQRScanner implements QRScannerService {
private currentLensFacing: LensFacing = LensFacing.Back;
async getAvailableCameras(): Promise<MediaDeviceInfo[]> {
// On mobile, we have two fixed cameras
return [
{
deviceId: 'back',
label: 'Back Camera',
kind: 'videoinput'
},
{
deviceId: 'front',
label: 'Front Camera',
kind: 'videoinput'
}
] as MediaDeviceInfo[];
}
async switchCamera(deviceId: string): Promise<void> {
if (!this.isScanning) return;
const newLensFacing = deviceId === 'front' ?
LensFacing.Front : LensFacing.Back;
// Stop current scan
await this.stopScan();
// Update lens facing
this.currentLensFacing = newLensFacing;
// Restart scan with new camera
await this.startScan({
camera: deviceId as 'front' | 'back'
});
}
async getCurrentCamera(): Promise<MediaDeviceInfo | null> {
return {
deviceId: this.currentLensFacing === LensFacing.Front ? 'front' : 'back',
label: this.currentLensFacing === LensFacing.Front ?
'Front Camera' : 'Back Camera',
kind: 'videoinput'
} as MediaDeviceInfo;
}
}
```
3. **Camera State Management**
```typescript
// CapacitorQRScanner.ts
private async handleCameraState(): Promise<void> {
try {
// Check if camera is available
const { camera } = await BarcodeScanner.checkPermissions();
if (camera === 'denied') {
this.updateCameraState('permission_denied');
return;
}
// Check if camera is in use
const isInUse = await this.isCameraInUse();
if (isInUse) {
this.updateCameraState('in_use');
return;
}
this.updateCameraState('ready');
} catch (error) {
this.updateCameraState('error', error.message);
}
}
private async isCameraInUse(): Promise<boolean> {
try {
// Try to start a test scan
await BarcodeScanner.startScan({
formats: [BarcodeFormat.QrCode],
lensFacing: this.currentLensFacing
});
// If successful, stop it immediately
await BarcodeScanner.stopScan();
return false;
} catch (error) {
return error.message.includes('camera in use');
}
}
```
4. **Error Handling**
```typescript
// CapacitorQRScanner.ts
private async handleCameraError(error: Error): Promise<void> {
switch (error.name) {
case 'CameraPermissionDenied':
this.updateCameraState('permission_denied');
break;
case 'CameraInUse':
this.updateCameraState('in_use');
break;
case 'CameraUnavailable':
this.updateCameraState('not_found');
break;
default:
this.updateCameraState('error', error.message);
}
}
```
#### Platform-Specific Considerations
1. **iOS Implementation**
- Camera permissions in Info.plist
- Privacy descriptions
- Camera usage description
- Background camera access
```xml
<!-- ios/App/App/Info.plist -->
<key>NSCameraUsageDescription</key>
<string>We need access to your camera to scan QR codes</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>We need access to save scanned QR codes</string>
```
2. **Android Implementation**
- Camera permissions in AndroidManifest.xml
- Runtime permission handling
- Camera features declaration
- Hardware feature requirements
```xml
<!-- android/app/src/main/AndroidManifest.xml -->
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera" />
<uses-feature android:name="android.hardware.camera.autofocus" />
```
3. **Platform-Specific Features**
- iOS: Camera orientation handling
- Android: Camera resolution optimization
- Both: Battery usage optimization
- Both: Memory management
```typescript
// Platform-specific optimizations
private getPlatformSpecificOptions(): StartScanOptions {
const baseOptions: StartScanOptions = {
formats: [BarcodeFormat.QrCode],
lensFacing: this.currentLensFacing
};
if (Capacitor.getPlatform() === 'ios') {
return {
...baseOptions,
// iOS-specific options
cameraOptions: {
quality: 0.7, // Lower quality for better performance
allowEditing: false,
resultType: 'uri'
}
};
} else if (Capacitor.getPlatform() === 'android') {
return {
...baseOptions,
// Android-specific options
cameraOptions: {
quality: 0.8,
allowEditing: false,
resultType: 'uri',
saveToGallery: false
}
};
}
return baseOptions;
}
```
#### Performance Optimization
1. **Battery Usage**
```typescript
// CapacitorQRScanner.ts
private optimizeBatteryUsage(): void {
// Reduce scan frequency when battery is low
if (this.isLowBattery()) {
this.scanInterval = 2000; // 2 seconds between scans
} else {
this.scanInterval = 1000; // 1 second between scans
}
}
private isLowBattery(): boolean {
// Check battery level if available
if (Capacitor.isPluginAvailable('Battery')) {
const { level } = await Battery.getBatteryLevel();
return level < 0.2; // 20% or lower
}
return false;
}
```
2. **Memory Management**
```typescript
// CapacitorQRScanner.ts
private async cleanupResources(): Promise<void> {
// Stop scanning
await this.stopScan();
// Clear any stored camera data
this.currentLensFacing = LensFacing.Back;
// Remove listeners
this.listenerHandles.forEach(handle => handle());
this.listenerHandles = [];
// Reset state
this.isScanning = false;
this.updateCameraState('off');
}
```
#### Testing on Capacitor
1. **Device Testing**
- Test on multiple iOS devices
- Test on multiple Android devices
- Test different camera configurations
- Test with different screen sizes
- Test with different OS versions
2. **Camera Testing**
- Test front camera switching
- Test back camera switching
- Test camera permissions
- Test camera in use scenarios
- Test low light conditions
- Test different QR code sizes
- Test different QR code distances
3. **Performance Testing**
- Battery usage monitoring
- Memory usage monitoring
- Camera switching speed
- QR code detection speed
- App responsiveness
- Background/foreground transitions

0
web-push.md → doc/web-push.md

507
docs/camera-implementation.md

@ -1,507 +0,0 @@
# Camera Implementation Documentation
## Overview
This document describes how camera functionality is implemented across the TimeSafari application. The application uses cameras for several purposes:
1. QR Code scanning for contact sharing and verification
2. Photo capture for gift records
3. Profile photo management
4. Shared photo handling
5. Image upload and processing
## Components
### QRScannerDialog.vue
Primary component for QR code scanning in web browsers.
**Key Features:**
- Uses `qrcode-stream` for web-based QR scanning
- Supports both front and back cameras
- Provides real-time camera status feedback
- Implements error handling with user-friendly messages
- Includes camera switching functionality
**Camera Access Flow:**
1. Checks for camera API availability
2. Enumerates available video devices
3. Requests camera permissions
4. Initializes camera stream with preferred settings
5. Handles various error conditions with specific messages
### PhotoDialog.vue
Component for photo capture and selection.
**Key Features:**
- Cross-platform photo capture interface
- Image cropping capabilities
- File selection fallback
- Unified interface for different platforms
- Progress feedback during upload
- Comprehensive error handling
**Camera Access Flow:**
1. User initiates photo capture
2. Platform-specific camera access is requested
3. Image is captured or selected
4. Optional cropping is performed
5. Image is processed and uploaded
6. URL is returned to caller
### ImageMethodDialog.vue
Component for selecting image input method.
**Key Features:**
- Multiple input methods (camera, file upload, URL)
- Unified interface for image selection
- Integration with PhotoDialog for processing
- Support for image cropping
- URL-based image handling
**Camera Access Flow:**
1. User selects camera option
2. PhotoDialog is opened for capture
3. Captured image is processed
4. Image is returned to parent component
### SharedPhotoView.vue
Component for handling shared photos.
**Key Features:**
- Processes incoming shared photos
- Options to use photo for gifts or profile
- Image preview and confirmation
- Server upload integration
- Temporary storage management
**Photo Processing Flow:**
1. Photo is shared to application
2. Stored temporarily in IndexedDB
3. User chooses usage (gift/profile)
4. Image is processed accordingly
5. Server upload is performed
### ContactQRScanShowView.vue
Component for QR code scanning in contact sharing.
**Key Features:**
- QR code scanning interface
- Camera controls (start/stop)
- Platform-specific implementations
- Error handling and status feedback
**Camera Access Flow:**
1. User initiates scanning
2. Camera permissions are checked
3. Camera stream is initialized
4. QR codes are detected in real-time
5. Results are processed
## Services
### QRScanner Services
#### WebDialogQRScanner
Web-based implementation of QR scanning.
**Key Methods:**
- `checkPermissions()`: Verifies camera permission status
- `requestPermissions()`: Requests camera access
- `isSupported()`: Checks for camera API support
- Handles various error conditions with specific messages
#### CapacitorQRScanner
Native implementation using Capacitor's MLKit.
**Key Features:**
- Uses `@capacitor-mlkit/barcode-scanning`
- Supports both front and back cameras
- Implements permission management
- Provides continuous scanning capability
### Platform Services
#### WebPlatformService
Web-specific implementation of platform features.
**Camera Capabilities:**
- Uses HTML5 file input with capture attribute for mobile
- Uses getUserMedia API for desktop webcam access
- Falls back to file selection if camera unavailable
- Processes captured images for consistent format
- Handles both mobile and desktop browser environments
#### CapacitorPlatformService
Native implementation using Capacitor.
**Camera Features:**
- Uses `Camera.getPhoto()` for native camera access
- Supports image editing
- Configures high-quality image capture
- Handles base64 image processing
- Provides native camera UI
#### ElectronPlatformService
Desktop implementation (currently unimplemented).
**Status:**
- Camera functionality not yet implemented
- Planned to use Electron's media APIs
- Will support desktop camera access
## Camera Usage Scenarios
### Gift Photo Capture
**Implementation:**
- Uses PhotoDialog for capture/selection
- Supports multiple input methods
- Optional image cropping
- Server upload with authentication
- Integration with gift records
**Flow:**
1. User initiates photo capture from gift details
2. ImageMethodDialog presents input options
3. PhotoDialog handles capture/selection
4. Image is processed and uploaded
5. URL is attached to gift record
### Profile Photo Management
**Implementation:**
- Uses same PhotoDialog component
- Enforces square aspect ratio
- Requires image cropping
- Updates user profile settings
- Handles profile image updates
**Flow:**
1. User initiates profile photo update
2. PhotoDialog opens with cropping enabled
3. Image is captured/selected
4. User crops to square aspect ratio
5. Image is uploaded and profile updated
### Shared Photo Processing
**Implementation:**
- Handles incoming shared photos
- Temporary storage in IndexedDB
- Options for photo usage
- Server upload integration
- Cleanup after processing
**Flow:**
1. Photo is shared to application
2. Stored temporarily in IndexedDB
3. SharedPhotoView presents options
4. User chooses usage (gift/profile)
5. Image is processed accordingly
### QR Code Scanning
**Implementation:**
- Platform-specific scanning components
- Real-time camera feed processing
- QR code detection and validation
- Contact information processing
- Error handling and retry
**Flow:**
1. User initiates QR scanning
2. Camera permissions are checked
3. Camera stream is initialized
4. QR codes are detected
5. Contact information is processed
### QR Code Workflow
**Implementation Details:**
The QR code scanning workflow is implemented across multiple components and services to provide a seamless experience for contact sharing and verification. The system supports both web-based and native implementations through platform-specific services.
#### QR Code Generation
**Contact QR Codes:**
- Generated using `qrcode.vue` component
- Contains encrypted contact information
- Includes user ID and verification data
- Supports offline sharing
- Implements error correction
**QR Code Format:**
```json
{
"type": "contact",
"userId": "encrypted_user_id",
"timestamp": "creation_time",
"version": "qr_code_version",
"data": "encrypted_contact_data"
}
```
#### QR Code Scanning Workflow
**1. Initiation:**
- User selects "Scan QR Code" option
- Platform-specific scanner is initialized
- Camera permissions are verified
- Appropriate scanner component is loaded
**2. Platform-Specific Implementation:**
*Web Implementation:*
- Uses `qrcode-stream` for real-time scanning
- Supports both front and back cameras
- Implements continuous scanning
- Provides visual feedback for scanning status
- Handles browser compatibility issues
*Native Implementation (Capacitor):*
- Uses `@capacitor-mlkit/barcode-scanning`
- Leverages native camera capabilities
- Provides optimized scanning performance
- Supports multiple barcode formats
- Implements native permission handling
**3. Scanning Process:**
- Camera stream is initialized
- Real-time frame analysis begins
- QR codes are detected and decoded
- Validation of QR code format
- Processing of contact information
**4. Contact Processing:**
- Decryption of contact data
- Validation of user information
- Verification of timestamp
- Check for duplicate contacts
- Processing of shared data
**5. Error Handling:**
- Invalid QR code format
- Expired QR codes
- Duplicate contact attempts
- Network connectivity issues
- Permission denials
- Camera access problems
**6. Success Flow:**
- Contact information is extracted
- User is prompted for confirmation
- Contact is added to user's list
- Success notification is displayed
- Camera resources are cleaned up
#### Security Measures
**QR Code Security:**
- Encryption of contact data
- Timestamp validation
- Version checking
- User verification
- Rate limiting for scans
**Data Protection:**
- Secure transmission of contact data
- Validation of QR code authenticity
- Prevention of duplicate scans
- Protection against malicious codes
- Secure storage of contact information
#### User Experience
**Scanning Interface:**
- Clear visual feedback
- Camera preview
- Scanning status indicators
- Error messages
- Success confirmations
**Accessibility:**
- Support for different screen sizes
- Clear instructions
- Error recovery options
- Alternative input methods
- Offline capability
#### Performance Considerations
**Optimization:**
- Efficient camera resource usage
- Quick QR code detection
- Minimal processing overhead
- Battery usage optimization
- Memory management
**Platform-Specific Optimizations:**
- Web: Optimized for browser performance
- Native: Leverages device capabilities
- Desktop: Efficient resource usage
- Mobile: Battery and performance balance
## Platform-Specific Considerations
### iOS
- Requires `NSCameraUsageDescription` in Info.plist
- Supports both front and back cameras
- Implements proper permission handling
- Uses native camera UI through Capacitor
- Handles photo library access
### Android
- Requires camera permissions in manifest
- Supports both front and back cameras
- Handles permission requests through Capacitor
- Uses native camera UI
- Manages photo library access
### Web
- Requires HTTPS for camera access
- Implements fallback mechanisms
- Handles browser compatibility issues
- Uses getUserMedia API on desktop
- Uses file input with capture on mobile
- Supports multiple input methods
## Error Handling
### Common Error Scenarios
1. No camera found
2. Permission denied
3. Camera in use by another application
4. HTTPS required
5. Browser compatibility issues
6. Upload failures
7. Image processing errors
### Error Response
- User-friendly error messages
- Troubleshooting tips
- Clear instructions for resolution
- Platform-specific guidance
- Graceful fallbacks
## Security Considerations
### Permission Management
- Explicit permission requests
- Permission state tracking
- Graceful handling of denied permissions
- Platform-specific permission handling
- Secure permission storage
### Data Handling
- Secure image processing
- Proper cleanup of camera resources
- No persistent storage of camera data
- Secure server upload
- Temporary storage management
## Best Practices
### Camera Access
1. Always check for camera availability
2. Request permissions explicitly
3. Handle all error conditions
4. Provide clear user feedback
5. Implement proper cleanup
6. Use platform-specific optimizations
### Performance
1. Optimize camera resolution
2. Implement proper resource cleanup
3. Handle camera switching efficiently
4. Manage memory usage
5. Optimize image processing
6. Handle upload efficiently
### User Experience
1. Clear status indicators
2. Intuitive camera controls
3. Helpful error messages
4. Smooth camera switching
5. Responsive UI feedback
6. Platform-appropriate UI
## Future Improvements
### Planned Enhancements
1. Implement Electron camera support
2. Add advanced camera features
3. Improve error handling
4. Enhance user feedback
5. Optimize performance
6. Add image compression options
### Known Issues
1. Electron camera implementation pending
2. Some browser compatibility limitations
3. Platform-specific quirks to address
4. Mobile browser camera access limitations
5. Image upload performance on slow connections
## Dependencies
### Key Packages
- `@capacitor-mlkit/barcode-scanning`
- `qrcode-stream`
- `vue-picture-cropper`
- `@capacitor/camera`
- Platform-specific camera APIs
## Testing
### Test Scenarios
1. Permission handling
2. Camera switching
3. Error conditions
4. Platform compatibility
5. Performance metrics
6. Upload scenarios
7. Image processing
### Test Environment
- Multiple browsers
- iOS and Android devices
- Desktop platforms
- Various network conditions
- Different camera configurations

275
package-lock.json

@ -6465,9 +6465,9 @@
} }
}, },
"node_modules/@ipld/dag-pb/node_modules/multiformats": { "node_modules/@ipld/dag-pb/node_modules/multiformats": {
"version": "13.3.3", "version": "13.3.5",
"resolved": "https://registry.npmjs.org/multiformats/-/multiformats-13.3.3.tgz", "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-13.3.5.tgz",
"integrity": "sha512-TlaFCzs3NHNzMpwiGwRYehnnhHlZcWfptygFekshlb9xCyO09GfN+9881+VBENCdRnKOeqmMxDCbupNecV8xRQ==" "integrity": "sha512-dXsVGtaekmpKMHUngnXkPpXnJU9h8ee2+P85kTETViXcDkQjkWLrEkj/b5pJ23ZhvBlicr9eq3B9IJOa28R70w=="
}, },
"node_modules/@isaacs/cliui": { "node_modules/@isaacs/cliui": {
"version": "8.0.2", "version": "8.0.2",
@ -7610,9 +7610,9 @@
} }
}, },
"node_modules/@rollup/rollup-android-arm-eabi": { "node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.40.2", "version": "4.41.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.40.2.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.41.0.tgz",
"integrity": "sha512-JkdNEq+DFxZfUwxvB58tHMHBHVgX23ew41g1OQinthJ+ryhdRk67O31S7sYw8u2lTjHUPFxwar07BBt1KHp/hg==", "integrity": "sha512-KxN+zCjOYHGwCl4UCtSfZ6jrq/qi88JDUtiEFk8LELEHq2Egfc/FgW+jItZiOLRuQfb/3xJSgFuNPC9jzggX+A==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@ -7623,9 +7623,9 @@
] ]
}, },
"node_modules/@rollup/rollup-android-arm64": { "node_modules/@rollup/rollup-android-arm64": {
"version": "4.40.2", "version": "4.41.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.40.2.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.41.0.tgz",
"integrity": "sha512-13unNoZ8NzUmnndhPTkWPWbX3vtHodYmy+I9kuLxN+F+l+x3LdVF7UCu8TWVMt1POHLh6oDHhnOA04n8oJZhBw==", "integrity": "sha512-yDvqx3lWlcugozax3DItKJI5j05B0d4Kvnjx+5mwiUpWramVvmAByYigMplaoAQ3pvdprGCTCE03eduqE/8mPQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -7636,9 +7636,9 @@
] ]
}, },
"node_modules/@rollup/rollup-darwin-arm64": { "node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.40.2", "version": "4.41.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.40.2.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.41.0.tgz",
"integrity": "sha512-Gzf1Hn2Aoe8VZzevHostPX23U7N5+4D36WJNHK88NZHCJr7aVMG4fadqkIf72eqVPGjGc0HJHNuUaUcxiR+N/w==", "integrity": "sha512-2KOU574vD3gzcPSjxO0eyR5iWlnxxtmW1F5CkNOHmMlueKNCQkxR6+ekgWyVnz6zaZihpUNkGxjsYrkTJKhkaw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -7649,9 +7649,9 @@
] ]
}, },
"node_modules/@rollup/rollup-darwin-x64": { "node_modules/@rollup/rollup-darwin-x64": {
"version": "4.40.2", "version": "4.41.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.40.2.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.41.0.tgz",
"integrity": "sha512-47N4hxa01a4x6XnJoskMKTS8XZ0CZMd8YTbINbi+w03A2w4j1RTlnGHOz/P0+Bg1LaVL6ufZyNprSg+fW5nYQQ==", "integrity": "sha512-gE5ACNSxHcEZyP2BA9TuTakfZvULEW4YAOtxl/A/YDbIir/wPKukde0BNPlnBiP88ecaN4BJI2TtAd+HKuZPQQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -7662,9 +7662,9 @@
] ]
}, },
"node_modules/@rollup/rollup-freebsd-arm64": { "node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.40.2", "version": "4.41.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.40.2.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.41.0.tgz",
"integrity": "sha512-8t6aL4MD+rXSHHZUR1z19+9OFJ2rl1wGKvckN47XFRVO+QL/dUSpKA2SLRo4vMg7ELA8pzGpC+W9OEd1Z/ZqoQ==", "integrity": "sha512-GSxU6r5HnWij7FoSo7cZg3l5GPg4HFLkzsFFh0N/b16q5buW1NAWuCJ+HMtIdUEi6XF0qH+hN0TEd78laRp7Dg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -7675,9 +7675,9 @@
] ]
}, },
"node_modules/@rollup/rollup-freebsd-x64": { "node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.40.2", "version": "4.41.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.40.2.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.41.0.tgz",
"integrity": "sha512-C+AyHBzfpsOEYRFjztcYUFsH4S7UsE9cDtHCtma5BK8+ydOZYgMmWg1d/4KBytQspJCld8ZIujFMAdKG1xyr4Q==", "integrity": "sha512-KGiGKGDg8qLRyOWmk6IeiHJzsN/OYxO6nSbT0Vj4MwjS2XQy/5emsmtoqLAabqrohbgLWJ5GV3s/ljdrIr8Qjg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -7688,9 +7688,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm-gnueabihf": { "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.40.2", "version": "4.41.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.40.2.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.41.0.tgz",
"integrity": "sha512-de6TFZYIvJwRNjmW3+gaXiZ2DaWL5D5yGmSYzkdzjBDS3W+B9JQ48oZEsmMvemqjtAFzE16DIBLqd6IQQRuG9Q==", "integrity": "sha512-46OzWeqEVQyX3N2/QdiU/CMXYDH/lSHpgfBkuhl3igpZiaB3ZIfSjKuOnybFVBQzjsLwkus2mjaESy8H41SzvA==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@ -7701,9 +7701,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm-musleabihf": { "node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.40.2", "version": "4.41.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.40.2.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.41.0.tgz",
"integrity": "sha512-urjaEZubdIkacKc930hUDOfQPysezKla/O9qV+O89enqsqUmQm8Xj8O/vh0gHg4LYfv7Y7UsE3QjzLQzDYN1qg==", "integrity": "sha512-lfgW3KtQP4YauqdPpcUZHPcqQXmTmH4nYU0cplNeW583CMkAGjtImw4PKli09NFi2iQgChk4e9erkwlfYem6Lg==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@ -7714,9 +7714,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm64-gnu": { "node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.40.2", "version": "4.41.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.40.2.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.41.0.tgz",
"integrity": "sha512-KlE8IC0HFOC33taNt1zR8qNlBYHj31qGT1UqWqtvR/+NuCVhfufAq9fxO8BMFC22Wu0rxOwGVWxtCMvZVLmhQg==", "integrity": "sha512-nn8mEyzMbdEJzT7cwxgObuwviMx6kPRxzYiOl6o/o+ChQq23gfdlZcUNnt89lPhhz3BYsZ72rp0rxNqBSfqlqw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -7727,9 +7727,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm64-musl": { "node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.40.2", "version": "4.41.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.40.2.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.41.0.tgz",
"integrity": "sha512-j8CgxvfM0kbnhu4XgjnCWJQyyBOeBI1Zq91Z850aUddUmPeQvuAy6OiMdPS46gNFgy8gN1xkYyLgwLYZG3rBOg==", "integrity": "sha512-l+QK99je2zUKGd31Gh+45c4pGDAqZSuWQiuRFCdHYC2CSiO47qUWsCcenrI6p22hvHZrDje9QjwSMAFL3iwXwQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -7740,9 +7740,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-loongarch64-gnu": { "node_modules/@rollup/rollup-linux-loongarch64-gnu": {
"version": "4.40.2", "version": "4.41.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.40.2.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.41.0.tgz",
"integrity": "sha512-Ybc/1qUampKuRF4tQXc7G7QY9YRyeVSykfK36Y5Qc5dmrIxwFhrOzqaVTNoZygqZ1ZieSWTibfFhQ5qK8jpWxw==", "integrity": "sha512-WbnJaxPv1gPIm6S8O/Wg+wfE/OzGSXlBMbOe4ie+zMyykMOeqmgD1BhPxZQuDqwUN+0T/xOFtL2RUWBspnZj3w==",
"cpu": [ "cpu": [
"loong64" "loong64"
], ],
@ -7753,9 +7753,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-powerpc64le-gnu": { "node_modules/@rollup/rollup-linux-powerpc64le-gnu": {
"version": "4.40.2", "version": "4.41.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.40.2.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.41.0.tgz",
"integrity": "sha512-3FCIrnrt03CCsZqSYAOW/k9n625pjpuMzVfeI+ZBUSDT3MVIFDSPfSUgIl9FqUftxcUXInvFah79hE1c9abD+Q==", "integrity": "sha512-eRDWR5t67/b2g8Q/S8XPi0YdbKcCs4WQ8vklNnUYLaSWF+Cbv2axZsp4jni6/j7eKvMLYCYdcsv8dcU+a6QNFg==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
@ -7766,9 +7766,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-riscv64-gnu": { "node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.40.2", "version": "4.41.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.40.2.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.41.0.tgz",
"integrity": "sha512-QNU7BFHEvHMp2ESSY3SozIkBPaPBDTsfVNGx3Xhv+TdvWXFGOSH2NJvhD1zKAT6AyuuErJgbdvaJhYVhVqrWTg==", "integrity": "sha512-TWrZb6GF5jsEKG7T1IHwlLMDRy2f3DPqYldmIhnA2DVqvvhY2Ai184vZGgahRrg8k9UBWoSlHv+suRfTN7Ua4A==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
@ -7779,9 +7779,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-riscv64-musl": { "node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.40.2", "version": "4.41.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.40.2.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.41.0.tgz",
"integrity": "sha512-5W6vNYkhgfh7URiXTO1E9a0cy4fSgfE4+Hl5agb/U1sa0kjOLMLC1wObxwKxecE17j0URxuTrYZZME4/VH57Hg==", "integrity": "sha512-ieQljaZKuJpmWvd8gW87ZmSFwid6AxMDk5bhONJ57U8zT77zpZ/TPKkU9HpnnFrM4zsgr4kiGuzbIbZTGi7u9A==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
@ -7792,9 +7792,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-s390x-gnu": { "node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.40.2", "version": "4.41.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.40.2.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.41.0.tgz",
"integrity": "sha512-B7LKIz+0+p348JoAL4X/YxGx9zOx3sR+o6Hj15Y3aaApNfAshK8+mWZEf759DXfRLeL2vg5LYJBB7DdcleYCoQ==", "integrity": "sha512-/L3pW48SxrWAlVsKCN0dGLB2bi8Nv8pr5S5ocSM+S0XCn5RCVCXqi8GVtHFsOBBCSeR+u9brV2zno5+mg3S4Aw==",
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
@ -7805,9 +7805,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-x64-gnu": { "node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.40.2", "version": "4.41.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.40.2.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.41.0.tgz",
"integrity": "sha512-lG7Xa+BmBNwpjmVUbmyKxdQJ3Q6whHjMjzQplOs5Z+Gj7mxPtWakGHqzMqNER68G67kmCX9qX57aRsW5V0VOng==", "integrity": "sha512-XMLeKjyH8NsEDCRptf6LO8lJk23o9wvB+dJwcXMaH6ZQbbkHu2dbGIUindbMtRN6ux1xKi16iXWu6q9mu7gDhQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -7818,9 +7818,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-x64-musl": { "node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.40.2", "version": "4.41.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.40.2.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.41.0.tgz",
"integrity": "sha512-tD46wKHd+KJvsmije4bUskNuvWKFcTOIM9tZ/RrmIvcXnbi0YK/cKS9FzFtAm7Oxi2EhV5N2OpfFB348vSQRXA==", "integrity": "sha512-m/P7LycHZTvSQeXhFmgmdqEiTqSV80zn6xHaQ1JSqwCtD1YGtwEK515Qmy9DcB2HK4dOUVypQxvhVSy06cJPEg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -7831,9 +7831,9 @@
] ]
}, },
"node_modules/@rollup/rollup-win32-arm64-msvc": { "node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.40.2", "version": "4.41.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.40.2.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.41.0.tgz",
"integrity": "sha512-Bjv/HG8RRWLNkXwQQemdsWw4Mg+IJ29LK+bJPW2SCzPKOUaMmPEppQlu/Fqk1d7+DX3V7JbFdbkh/NMmurT6Pg==", "integrity": "sha512-4yodtcOrFHpbomJGVEqZ8fzD4kfBeCbpsUy5Pqk4RluXOdsWdjLnjhiKy2w3qzcASWd04fp52Xz7JKarVJ5BTg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -7844,9 +7844,9 @@
] ]
}, },
"node_modules/@rollup/rollup-win32-ia32-msvc": { "node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.40.2", "version": "4.41.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.40.2.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.41.0.tgz",
"integrity": "sha512-dt1llVSGEsGKvzeIO76HToiYPNPYPkmjhMHhP00T9S4rDern8P2ZWvWAQUEJ+R1UdMWJ/42i/QqJ2WV765GZcA==", "integrity": "sha512-tmazCrAsKzdkXssEc65zIE1oC6xPHwfy9d5Ta25SRCDOZS+I6RypVVShWALNuU9bxIfGA0aqrmzlzoM5wO5SPQ==",
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
@ -7857,9 +7857,9 @@
] ]
}, },
"node_modules/@rollup/rollup-win32-x64-msvc": { "node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.40.2", "version": "4.41.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.40.2.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.41.0.tgz",
"integrity": "sha512-bwspbWB04XJpeElvsp+DCylKfF4trJDa2Y9Go8O6A7YLX2LIKGcNK/CYImJN6ZP4DcuOHB4Utl3iCbnR62DudA==", "integrity": "sha512-h1J+Yzjo/X+0EAvR2kIXJDuTuyT7drc+t2ALY0nIcGPbTatNOf0VWdhEA2Z4AAjv6X1NJV7SYo5oCTYRJhSlVA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -8737,9 +8737,9 @@
} }
}, },
"node_modules/@types/leaflet": { "node_modules/@types/leaflet": {
"version": "1.9.17", "version": "1.9.18",
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.17.tgz", "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.18.tgz",
"integrity": "sha512-IJ4K6t7I3Fh5qXbQ1uwL3CFVbCi6haW9+53oLWgdKlLP7EaS21byWFJxxqOx9y8I0AP0actXSJLVMbyvxhkUTA==", "integrity": "sha512-ht2vsoPjezor5Pmzi5hdsA7F++v5UGq9OlUduWHmMZiuQGIpJ2WS5+Gg9HaAA79gNh1AIPtCqhzejcIZ3lPzXQ==",
"devOptional": true, "devOptional": true,
"dependencies": { "dependencies": {
"@types/geojson": "*" "@types/geojson": "*"
@ -8764,9 +8764,9 @@
"dev": true "dev": true
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "20.17.47", "version": "20.17.48",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.47.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.48.tgz",
"integrity": "sha512-3dLX0Upo1v7RvUimvxLeXqwrfyKxUINk0EAM83swP2mlSUcwV73sZy8XhNz8bcZ3VbsfQyC/y6jRdL5tgCNpDQ==", "integrity": "sha512-KpSfKOHPsiSC4IkZeu2LsusFwExAIVGkhG1KkbaBMLwau0uMhj0fCrvyg9ddM2sAvd+gtiBJLir4LAw1MNMIaw==",
"dependencies": { "dependencies": {
"undici-types": "~6.19.2" "undici-types": "~6.19.2"
} }
@ -9461,9 +9461,9 @@
} }
}, },
"node_modules/@veramo/did-provider-peer/node_modules/did-jwt-vc": { "node_modules/@veramo/did-provider-peer/node_modules/did-jwt-vc": {
"version": "4.0.12", "version": "4.0.13",
"resolved": "https://registry.npmjs.org/did-jwt-vc/-/did-jwt-vc-4.0.12.tgz", "resolved": "https://registry.npmjs.org/did-jwt-vc/-/did-jwt-vc-4.0.13.tgz",
"integrity": "sha512-xhQ8tY6tanrgzkhKmoSt3A/XkInufMo73qSJU1cXWxfYpMpYYmldvaxvJm2nqMjCly276ajP6LNeXgkYg9elRw==", "integrity": "sha512-T1IUneS7Rgpao8dOeZy7dMUvAvcLLn7T8YlWRk/8HsEpaVLDx5NrjRfbfDJU8FL8CI8aBIAhoDnPQO3PNV+BWg==",
"dependencies": { "dependencies": {
"did-jwt": "^8.0.0", "did-jwt": "^8.0.0",
"did-resolver": "^4.1.0" "did-resolver": "^4.1.0"
@ -9486,9 +9486,9 @@
} }
}, },
"node_modules/@veramo/did-provider-peer/node_modules/multiformats": { "node_modules/@veramo/did-provider-peer/node_modules/multiformats": {
"version": "13.3.3", "version": "13.3.5",
"resolved": "https://registry.npmjs.org/multiformats/-/multiformats-13.3.3.tgz", "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-13.3.5.tgz",
"integrity": "sha512-TlaFCzs3NHNzMpwiGwRYehnnhHlZcWfptygFekshlb9xCyO09GfN+9881+VBENCdRnKOeqmMxDCbupNecV8xRQ==" "integrity": "sha512-dXsVGtaekmpKMHUngnXkPpXnJU9h8ee2+P85kTETViXcDkQjkWLrEkj/b5pJ23ZhvBlicr9eq3B9IJOa28R70w=="
}, },
"node_modules/@veramo/did-resolver": { "node_modules/@veramo/did-resolver": {
"version": "5.6.0", "version": "5.6.0",
@ -13901,9 +13901,9 @@
} }
}, },
"node_modules/electron-to-chromium": { "node_modules/electron-to-chromium": {
"version": "1.5.154", "version": "1.5.155",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.154.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.155.tgz",
"integrity": "sha512-G4VCFAyKbp1QJ+sWdXYIRYsPGvlV5sDACfCmoMFog3rjm1syLhI41WXm/swZypwCIWIm4IFLWzHY14joWMQ5Fw==", "integrity": "sha512-ps5KcGGmwL8VaeJlvlDlu4fORQpv3+GIcF5I3f9tUKUlJ/wsysh6HU8P5L1XWRYeXfA0oJd4PyM8ds8zTFf6Ng==",
"devOptional": true "devOptional": true
}, },
"node_modules/elementtree": { "node_modules/elementtree": {
@ -14568,9 +14568,9 @@
} }
}, },
"node_modules/ethers": { "node_modules/ethers": {
"version": "6.14.0", "version": "6.14.1",
"resolved": "https://registry.npmjs.org/ethers/-/ethers-6.14.0.tgz", "resolved": "https://registry.npmjs.org/ethers/-/ethers-6.14.1.tgz",
"integrity": "sha512-KgHwltNSMdbrGWEyKkM0Rt2s+u1nDH/5BVDQakLinzGEJi4bWindBzZSCC4gKsbZjwDTI6ex/8suR9Ihbmz4IQ==", "integrity": "sha512-JnFiPFi3sK2Z6y7jZ3qrafDMwiXmU+6cNZ0M+kPq+mTy9skqEzwqAdFW3nb/em2xjlIVXX6Lz8ID6i3LmS4+fQ==",
"funding": [ "funding": [
{ {
"type": "individual", "type": "individual",
@ -14653,9 +14653,9 @@
"integrity": "sha512-XWwnNNFCuuSQ0m3r3C4LE3EiORltHd9M05pq6FOlVeiophzRbMo50Sbz1ehl8K3Z+jw9+vmgnXefY1hz8X+2wA==" "integrity": "sha512-XWwnNNFCuuSQ0m3r3C4LE3EiORltHd9M05pq6FOlVeiophzRbMo50Sbz1ehl8K3Z+jw9+vmgnXefY1hz8X+2wA=="
}, },
"node_modules/ethr-did": { "node_modules/ethr-did": {
"version": "3.0.34", "version": "3.0.35",
"resolved": "https://registry.npmjs.org/ethr-did/-/ethr-did-3.0.34.tgz", "resolved": "https://registry.npmjs.org/ethr-did/-/ethr-did-3.0.35.tgz",
"integrity": "sha512-0NloieyCPi6iRebLRufFns00sRZJ46GB+Oc/thu3hqIc/7rOUQjNEQmUbSTo2OTEIW3FOIuaAVo2eh58HQ9SwA==", "integrity": "sha512-vWTGIcdnzyTeahNw25P4eQEMo6gVQEVEg0Kit8spDPB6neUAk5HaJXfxG9i8gKPJBOgyVNkMQ/aPOgVhnSig3w==",
"dependencies": { "dependencies": {
"did-jwt": "^8.0.0", "did-jwt": "^8.0.0",
"did-resolver": "^4.1.0", "did-resolver": "^4.1.0",
@ -21089,9 +21089,9 @@
"optional": true "optional": true
}, },
"node_modules/papaparse": { "node_modules/papaparse": {
"version": "5.5.2", "version": "5.5.3",
"resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.2.tgz", "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.3.tgz",
"integrity": "sha512-PZXg8UuAc4PcVwLosEEDYjPyfWnTEhOrUfdv+3Bx+NuAb+5NhDmXzg5fHWmdCh1mP5p7JAZfFr3IMQfcntNAdA==" "integrity": "sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A=="
}, },
"node_modules/parent-module": { "node_modules/parent-module": {
"version": "1.0.1", "version": "1.0.1",
@ -21750,9 +21750,9 @@
} }
}, },
"node_modules/protons-runtime/node_modules/multiformats": { "node_modules/protons-runtime/node_modules/multiformats": {
"version": "13.3.3", "version": "13.3.5",
"resolved": "https://registry.npmjs.org/multiformats/-/multiformats-13.3.3.tgz", "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-13.3.5.tgz",
"integrity": "sha512-TlaFCzs3NHNzMpwiGwRYehnnhHlZcWfptygFekshlb9xCyO09GfN+9881+VBENCdRnKOeqmMxDCbupNecV8xRQ==" "integrity": "sha512-dXsVGtaekmpKMHUngnXkPpXnJU9h8ee2+P85kTETViXcDkQjkWLrEkj/b5pJ23ZhvBlicr9eq3B9IJOa28R70w=="
}, },
"node_modules/protons-runtime/node_modules/uint8arrays": { "node_modules/protons-runtime/node_modules/uint8arrays": {
"version": "5.1.0", "version": "5.1.0",
@ -23241,9 +23241,9 @@
} }
}, },
"node_modules/rollup": { "node_modules/rollup": {
"version": "4.40.2", "version": "4.41.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.40.2.tgz", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.41.0.tgz",
"integrity": "sha512-tfUOg6DTP4rhQ3VjOO6B4wyrJnGOX85requAXvqYTHsOgb2TFJdZ3aWpT8W2kPoypSGP7dZUyzxJ9ee4buM5Fg==", "integrity": "sha512-HqMFpUbWlf/tvcxBFNKnJyzc7Lk+XO3FGc3pbNBLqEbOz0gPLRgcrlS3UF4MfUrVlstOaP/q0kM6GVvi+LrLRg==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"@types/estree": "1.0.7" "@types/estree": "1.0.7"
@ -23256,26 +23256,26 @@
"npm": ">=8.0.0" "npm": ">=8.0.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"@rollup/rollup-android-arm-eabi": "4.40.2", "@rollup/rollup-android-arm-eabi": "4.41.0",
"@rollup/rollup-android-arm64": "4.40.2", "@rollup/rollup-android-arm64": "4.41.0",
"@rollup/rollup-darwin-arm64": "4.40.2", "@rollup/rollup-darwin-arm64": "4.41.0",
"@rollup/rollup-darwin-x64": "4.40.2", "@rollup/rollup-darwin-x64": "4.41.0",
"@rollup/rollup-freebsd-arm64": "4.40.2", "@rollup/rollup-freebsd-arm64": "4.41.0",
"@rollup/rollup-freebsd-x64": "4.40.2", "@rollup/rollup-freebsd-x64": "4.41.0",
"@rollup/rollup-linux-arm-gnueabihf": "4.40.2", "@rollup/rollup-linux-arm-gnueabihf": "4.41.0",
"@rollup/rollup-linux-arm-musleabihf": "4.40.2", "@rollup/rollup-linux-arm-musleabihf": "4.41.0",
"@rollup/rollup-linux-arm64-gnu": "4.40.2", "@rollup/rollup-linux-arm64-gnu": "4.41.0",
"@rollup/rollup-linux-arm64-musl": "4.40.2", "@rollup/rollup-linux-arm64-musl": "4.41.0",
"@rollup/rollup-linux-loongarch64-gnu": "4.40.2", "@rollup/rollup-linux-loongarch64-gnu": "4.41.0",
"@rollup/rollup-linux-powerpc64le-gnu": "4.40.2", "@rollup/rollup-linux-powerpc64le-gnu": "4.41.0",
"@rollup/rollup-linux-riscv64-gnu": "4.40.2", "@rollup/rollup-linux-riscv64-gnu": "4.41.0",
"@rollup/rollup-linux-riscv64-musl": "4.40.2", "@rollup/rollup-linux-riscv64-musl": "4.41.0",
"@rollup/rollup-linux-s390x-gnu": "4.40.2", "@rollup/rollup-linux-s390x-gnu": "4.41.0",
"@rollup/rollup-linux-x64-gnu": "4.40.2", "@rollup/rollup-linux-x64-gnu": "4.41.0",
"@rollup/rollup-linux-x64-musl": "4.40.2", "@rollup/rollup-linux-x64-musl": "4.41.0",
"@rollup/rollup-win32-arm64-msvc": "4.40.2", "@rollup/rollup-win32-arm64-msvc": "4.41.0",
"@rollup/rollup-win32-ia32-msvc": "4.40.2", "@rollup/rollup-win32-ia32-msvc": "4.41.0",
"@rollup/rollup-win32-x64-msvc": "4.40.2", "@rollup/rollup-win32-x64-msvc": "4.41.0",
"fsevents": "~2.3.2" "fsevents": "~2.3.2"
} }
}, },
@ -25050,13 +25050,12 @@
} }
}, },
"node_modules/synckit": { "node_modules/synckit": {
"version": "0.11.5", "version": "0.11.6",
"resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.5.tgz", "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.6.tgz",
"integrity": "sha512-frqvfWyDA5VPVdrWfH24uM6SI/O8NLpVbIIJxb8t/a3YGsp4AW9CYgSKC0OaSEfexnp7Y1pVh2Y6IHO8ggGDmA==", "integrity": "sha512-2pR2ubZSV64f/vqm9eLPz/KOvR9Dm+Co/5ChLgeHl0yEDRc6h5hXHoxEQH8Y5Ljycozd3p1k5TTSVdzYGkPvLw==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"@pkgr/core": "^0.2.4", "@pkgr/core": "^0.2.4"
"tslib": "^2.8.1"
}, },
"engines": { "engines": {
"node": "^14.18.0 || >=16.0.0" "node": "^14.18.0 || >=16.0.0"
@ -25065,12 +25064,6 @@
"url": "https://opencollective.com/synckit" "url": "https://opencollective.com/synckit"
} }
}, },
"node_modules/synckit/node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true
},
"node_modules/tailwindcss": { "node_modules/tailwindcss": {
"version": "3.4.17", "version": "3.4.17",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz",
@ -25267,13 +25260,13 @@
} }
}, },
"node_modules/terser": { "node_modules/terser": {
"version": "5.39.1", "version": "5.39.2",
"resolved": "https://registry.npmjs.org/terser/-/terser-5.39.1.tgz", "resolved": "https://registry.npmjs.org/terser/-/terser-5.39.2.tgz",
"integrity": "sha512-Mm6+uad0ZuDtcV8/4uOZQDQ8RuiC5Pu+iZRedJtF7yA/27sPL7d++In/AJKpWZlU3SYMPPkVfwetn6sgZ66pUA==", "integrity": "sha512-yEPUmWve+VA78bI71BW70Dh0TuV4HHd+I5SHOAfS1+QBOmvmCiiffgjR8ryyEd3KIfvPGFqoADt8LdQ6XpXIvg==",
"devOptional": true, "devOptional": true,
"dependencies": { "dependencies": {
"@jridgewell/source-map": "^0.3.3", "@jridgewell/source-map": "^0.3.3",
"acorn": "^8.8.2", "acorn": "^8.14.0",
"commander": "^2.20.0", "commander": "^2.20.0",
"source-map-support": "~0.5.20" "source-map-support": "~0.5.20"
}, },
@ -25989,9 +25982,9 @@
} }
}, },
"node_modules/uint8-varint/node_modules/multiformats": { "node_modules/uint8-varint/node_modules/multiformats": {
"version": "13.3.3", "version": "13.3.5",
"resolved": "https://registry.npmjs.org/multiformats/-/multiformats-13.3.3.tgz", "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-13.3.5.tgz",
"integrity": "sha512-TlaFCzs3NHNzMpwiGwRYehnnhHlZcWfptygFekshlb9xCyO09GfN+9881+VBENCdRnKOeqmMxDCbupNecV8xRQ==" "integrity": "sha512-dXsVGtaekmpKMHUngnXkPpXnJU9h8ee2+P85kTETViXcDkQjkWLrEkj/b5pJ23ZhvBlicr9eq3B9IJOa28R70w=="
}, },
"node_modules/uint8-varint/node_modules/uint8arrays": { "node_modules/uint8-varint/node_modules/uint8arrays": {
"version": "5.1.0", "version": "5.1.0",
@ -26010,9 +26003,9 @@
} }
}, },
"node_modules/uint8arraylist/node_modules/multiformats": { "node_modules/uint8arraylist/node_modules/multiformats": {
"version": "13.3.3", "version": "13.3.5",
"resolved": "https://registry.npmjs.org/multiformats/-/multiformats-13.3.3.tgz", "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-13.3.5.tgz",
"integrity": "sha512-TlaFCzs3NHNzMpwiGwRYehnnhHlZcWfptygFekshlb9xCyO09GfN+9881+VBENCdRnKOeqmMxDCbupNecV8xRQ==" "integrity": "sha512-dXsVGtaekmpKMHUngnXkPpXnJU9h8ee2+P85kTETViXcDkQjkWLrEkj/b5pJ23ZhvBlicr9eq3B9IJOa28R70w=="
}, },
"node_modules/uint8arraylist/node_modules/uint8arrays": { "node_modules/uint8arraylist/node_modules/uint8arrays": {
"version": "5.1.0", "version": "5.1.0",
@ -27445,15 +27438,15 @@
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="
}, },
"node_modules/yaml": { "node_modules/yaml": {
"version": "2.7.1", "version": "2.8.0",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.1.tgz", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.0.tgz",
"integrity": "sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ==", "integrity": "sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==",
"dev": true, "dev": true,
"bin": { "bin": {
"yaml": "bin.mjs" "yaml": "bin.mjs"
}, },
"engines": { "engines": {
"node": ">= 14" "node": ">= 14.6"
} }
}, },
"node_modules/yargs": { "node_modules/yargs": {
@ -27601,9 +27594,9 @@
} }
}, },
"node_modules/zod": { "node_modules/zod": {
"version": "3.24.4", "version": "3.25.7",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.24.4.tgz", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.7.tgz",
"integrity": "sha512-OdqJE9UDRPwWsrHjLN2F8bPxvwJBK22EHLWtanu0LSYr5YqzsaaW3RMgmjwr8Rypg5k+meEJdSPXJZXE/yqOMg==", "integrity": "sha512-YGdT1cVRmKkOg6Sq7vY7IkxdphySKnXhaUmFI4r4FcuFVNgpCb9tZfNwXbT6BPjD5oz0nubFsoo9pIqKrDcCvg==",
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"
} }

156
qr-code-implementation-guide.md

@ -1,156 +0,0 @@
## Build Configuration
### Common Vite Configuration
```typescript
// vite.config.common.mts
export async function createBuildConfig(mode: string) {
const isCapacitor = mode === "capacitor";
return defineConfig({
build: {
rollupOptions: {
output: {
manualChunks: {
'vue-vendor': ['vue', 'vue-router', 'vue-facing-decorator']
}
}
}
},
define: {
__USE_QR_READER__: JSON.stringify(!isCapacitor),
__IS_MOBILE__: JSON.stringify(isCapacitor),
},
optimizeDeps: {
include: [
'@capacitor-mlkit/barcode-scanning',
'vue-qrcode-reader'
]
},
resolve: {
alias: {
'@capacitor/app': path.resolve(__dirname, 'node_modules/@capacitor/app')
}
}
});
}
```
### Web-Specific Configuration
```typescript
// vite.config.web.mts
import { defineConfig, mergeConfig } from "vite";
import { createBuildConfig } from "./vite.config.common.mts";
export default defineConfig(async () => {
const baseConfig = await createBuildConfig('web');
return mergeConfig(baseConfig, {
define: {
__USE_QR_READER__: true,
__IS_MOBILE__: false,
}
});
});
```
### Capacitor-Specific Configuration
```typescript
// vite.config.capacitor.mts
import { defineConfig, mergeConfig } from "vite";
import { createBuildConfig } from "./vite.config.common.mts";
export default defineConfig(async () => {
const baseConfig = await createBuildConfig('capacitor');
return mergeConfig(baseConfig, {
define: {
__USE_QR_READER__: false,
__IS_MOBILE__: true,
},
build: {
rollupOptions: {
external: ['vue-qrcode-reader'], // Exclude web QR reader from mobile builds
output: {
entryFileNames: '[name]-mobile.js',
chunkFileNames: '[name]-mobile.js',
assetFileNames: '[name]-mobile.[ext]'
}
}
}
});
});
```
### Build Scripts
Add these scripts to your `package.json`:
```json
{
"scripts": {
"build:web": "vite build --config vite.config.web.mts",
"build:capacitor": "vite build --config vite.config.capacitor.mts",
"build:all": "npm run build:web && npm run build:capacitor"
}
}
```
### Environment Variables
Create a `.env` file:
```bash
# QR Scanner Configuration
VITE_QR_SCANNER_ENABLED=true
VITE_DEFAULT_CAMERA=back
```
### Build Process
1. **Web Build**
```bash
npm run build:web
```
This will:
- Include vue-qrcode-reader
- Set __USE_QR_READER__ to true
- Set __IS_MOBILE__ to false
- Build for web browsers
2. **Capacitor Build**
```bash
npm run build:capacitor
```
This will:
- Exclude vue-qrcode-reader
- Set __USE_QR_READER__ to false
- Set __IS_MOBILE__ to true
- Build for mobile platforms
3. **Build Both**
```bash
npm run build:all
```
### Important Notes
1. **Dependencies**
- Ensure all QR-related dependencies are properly listed in package.json
- Use exact versions to avoid compatibility issues
- Consider using peer dependencies for shared libraries
2. **Bundle Size**
- Web build includes vue-qrcode-reader (~100KB)
- Mobile build includes @capacitor-mlkit/barcode-scanning (~50KB)
- Consider using dynamic imports for lazy loading
3. **Platform Detection**
- Build flags determine which implementation to use
- Runtime checks provide fallback options
- Environment variables can override defaults
4. **Performance**
- Mobile builds optimize for native performance
- Web builds include necessary polyfills
- Chunk splitting improves load times
5. **Debugging**
- Source maps are enabled for development
- Build artifacts are properly named for identification
- Console logs help track initialization

4
openssl_signing_console.sh → scripts/openssl_signing_console.sh

@ -4,9 +4,9 @@
# #
# Prerequisites: openssl, jq # Prerequisites: openssl, jq
# #
# Usage: source ./openssl_signing_console.sh # Usage: source /scripts/openssl_signing_console.sh
# #
# For a more complete explanation, see ./openssl_signing_console.rst # For a more complete explanation, see /doc/openssl_signing_console.rst
# Generate a key and extract the public part # Generate a key and extract the public part

518
src/components/ImageMethodDialog.vue

@ -1,77 +1,181 @@
<template> <template>
<div v-if="visible" class="dialog-overlay z-[60]"> <div v-if="visible" class="dialog-overlay z-[60]">
<div class="dialog relative"> <div class="dialog relative">
<div class="text-lg text-center font-light relative z-50"> <div class="text-lg text-center font-bold relative">
<h1 id="ViewHeading" class="text-center font-bold">
<span v-if="uploading">Uploading Image&hellip;</span>
<span v-else-if="blob">Crop Image</span>
<span v-else-if="showCameraPreview">Upload Image</span>
<span v-else>Add Photo</span>
</h1>
<div <div
id="ViewHeading" class="text-2xl text-center px-1 py-0.5 leading-none absolute -right-1 top-0"
class="text-center font-bold absolute top-0 left-0 right-0 px-4 py-0.5 bg-black/50 text-white leading-none"
>
Add Photo
</div>
<div
class="text-lg text-center px-2 py-0.5 leading-none absolute right-0 top-0 text-white"
@click="close()" @click="close()"
> >
<font-awesome icon="xmark" class="w-[1em]"></font-awesome> <font-awesome icon="xmark" class="w-[1em]"></font-awesome>
</div> </div>
</div> </div>
<div> <div class="mt-4">
<div class="text-center mt-8"> <template v-if="isRegistered">
<template v-if="isRegistered"> <div v-if="!blob">
<div> <div
<font-awesome class="border-b border-dashed border-slate-300 text-orange-400 mb-4 font-bold text-sm"
icon="camera" >
class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-2 rounded-md" <span class="block w-fit mx-auto -mb-2.5 bg-white px-2">
@click="openPhotoDialog()" Take a photo with your camera
/> </span>
</div> </div>
<div class="mt-4"> <div
<input type="file" @change="uploadImageFile" /> v-if="showCameraPreview"
class="camera-preview relative flex bg-black overflow-hidden mb-4"
>
<div class="camera-container w-full h-full relative">
<video
ref="videoElement"
class="camera-video w-full h-full object-cover"
autoplay
playsinline
muted
></video>
<button
class="absolute bottom-4 left-1/2 -translate-x-1/2 bg-white text-slate-800 p-3 rounded-full text-2xl leading-none"
@click="capturePhoto"
>
<font-awesome icon="camera" class="w-[1em]" />
</button>
</div>
</div>
<div
class="border-b border-dashed border-slate-300 text-orange-400 mt-4 mb-4 font-bold text-sm"
>
<span class="block w-fit mx-auto -mb-2.5 bg-white px-2">
OR choose a file from your device
</span>
</div> </div>
<div class="mt-4"> <div class="mt-4">
<span class="mt-2"> <input
... or paste a URL: type="file"
<input v-model="imageUrl" type="text" class="border-2" /> class="w-full file:text-center file:bg-gradient-to-b file:from-slate-400 file:to-slate-700 file:shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] file:text-white file:px-3 file:py-2 file:rounded-md file:border-none file:cursor-pointer file:me-2"
@change="uploadImageFile"
/>
</div>
<div
class="border-b border-dashed border-slate-300 text-orange-400 mt-4 mb-4 font-bold text-sm"
>
<span class="block w-fit mx-auto -mb-2.5 bg-white px-2">
OR paste an image URL
</span> </span>
<span class="ml-2"> </div>
<font-awesome <div class="flex items-center gap-2 mt-4">
v-if="imageUrl" <input
icon="check" v-model="imageUrl"
class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-2 rounded-md cursor-pointer" type="text"
@click="acceptUrl" class="block w-full rounded border border-slate-400 px-4 py-2"
/> placeholder="https://example.com/image.jpg"
<!-- so that there's no shifting when it becomes visible --> />
<font-awesome <button
v-else v-if="imageUrl"
icon="check" class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-3 py-2 rounded-md cursor-pointer"
class="text-white bg-white px-2 py-2" @click="acceptUrl"
>
<font-awesome icon="check" class="fa-fw" />
</button>
</div>
</div>
<div v-else>
<div v-if="uploading" class="flex justify-center">
<font-awesome
icon="spinner"
class="fa-spin fa-3x text-center block px-12 py-12"
/>
</div>
<div v-else>
<div v-if="crop">
<VuePictureCropper
:box-style="{
backgroundColor: '#f8f8f8',
margin: 'auto',
}"
:img="createBlobURL(blob)"
:options="{
viewMode: 1,
dragMode: 'crop',
aspectRatio: 1 / 1,
}"
class="max-h-[90vh] max-w-[90vw] object-contain"
/> />
</span> </div>
<div v-else>
<div class="flex justify-center">
<img
:src="createBlobURL(blob)"
class="mt-2 rounded max-h-[90vh] max-w-[90vw] object-contain"
/>
</div>
</div>
<div
:class="[
'grid gap-2 mt-2',
showRetry ? 'grid-cols-2' : 'grid-cols-1',
]"
>
<button
class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white py-2 px-3 rounded-md"
@click="uploadImage"
>
<span>Upload</span>
</button>
<button
v-if="showRetry"
class="bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white py-2 px-3 rounded-md"
@click="retryImage"
>
<span>Retry</span>
</button>
</div>
</div> </div>
</template> </div>
<template v-else> </template>
<div class="text-center text-lg text-slate-500 py-12">Register to Upload a Photo</div> <template v-else>
</template> <div
</div> id="noticeBeforeUpload"
class="bg-amber-200 text-amber-900 border-amber-500 border-dashed border text-center rounded-md overflow-hidden px-4 py-3"
role="alert"
aria-live="polite"
>
<p class="mb-2">
Before you can upload a photo, a friend needs to register you.
</p>
<router-link
:to="{ name: 'contact-qr' }"
class="inline-block text-md uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md"
>
Share Your Info
</router-link>
</div>
</template>
</div> </div>
</div> </div>
</div> </div>
<PhotoDialog ref="photoDialog" />
</template> </template>
<script lang="ts"> <script lang="ts">
import axios from "axios"; import axios from "axios";
import { ref } from "vue"; import { ref } from "vue";
import { Component, Vue } from "vue-facing-decorator"; import { Component, Vue } from "vue-facing-decorator";
import VuePictureCropper, { cropper } from "vue-picture-cropper";
import PhotoDialog from "../components/PhotoDialog.vue"; import { DEFAULT_IMAGE_API_SERVER, NotificationIface } from "../constants/app";
import { NotificationIface } from "../constants/app"; import { retrieveSettingsForActiveAccount } from "../db/index";
import { accessToken } from "../libs/crypto";
import { logger } from "../utils/logger";
import { PlatformServiceFactory } from "../services/PlatformServiceFactory";
const inputImageFileNameRef = ref<Blob>(); const inputImageFileNameRef = ref<Blob>();
@Component({ @Component({
components: { PhotoDialog }, components: { VuePictureCropper },
props: { props: {
isRegistered: { isRegistered: {
type: Boolean, type: Boolean,
@ -82,38 +186,97 @@ const inputImageFileNameRef = ref<Blob>();
export default class ImageMethodDialog extends Vue { export default class ImageMethodDialog extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void; $notify!: (notification: NotificationIface, timeout?: number) => void;
claimType: string; /** Active DID for user authentication */
activeDid = "";
/** Current image blob being processed */
blob?: Blob;
/** Type of claim for the image */
claimType: string = "";
/** Whether to show cropping interface */
crop: boolean = false; crop: boolean = false;
/** Name of the selected file */
fileName?: string;
/** Callback function to set image URL after upload */
imageCallback: (imageUrl?: string) => void = () => {}; imageCallback: (imageUrl?: string) => void = () => {};
/** URL for image input */
imageUrl?: string; imageUrl?: string;
/** Whether to show retry button */
showRetry = true;
/** Upload progress state */
uploading = false;
/** Dialog visibility state */
visible = false; visible = false;
/** Whether to show camera preview */
showCameraPreview = false;
/** Camera stream reference */
private cameraStream: MediaStream | null = null;
private platformService = PlatformServiceFactory.getInstance();
URL = window.URL || window.webkitURL;
private platformCapabilities = this.platformService.getCapabilities();
/**
* Lifecycle hook: Initializes component and retrieves user settings
* @throws {Error} When settings retrieval fails
*/
async mounted() {
console.log("ImageMethodDialog mounted");
try {
const settings = await retrieveSettingsForActiveAccount();
this.activeDid = settings.activeDid || "";
} catch (error: unknown) {
logger.error("Error retrieving settings from database:", error);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text:
error instanceof Error
? error.message
: "There was an error retrieving your settings.",
},
-1,
);
}
}
/**
* Lifecycle hook: Cleans up camera stream when component is destroyed
*/
beforeDestroy() {
this.stopCameraPreview();
}
open(setImageFn: (arg: string) => void, claimType: string, crop?: boolean) { open(setImageFn: (arg: string) => void, claimType: string, crop?: boolean) {
this.claimType = claimType; this.claimType = claimType;
this.crop = !!crop; this.crop = !!crop;
this.imageCallback = setImageFn; this.imageCallback = setImageFn;
this.visible = true; this.visible = true;
}
openPhotoDialog(blob?: Blob, fileName?: string) { // Start camera preview immediately if not on mobile
this.visible = false; if (!this.platformCapabilities.isMobile) {
this.startCameraPreview();
(this.$refs.photoDialog as PhotoDialog).open( }
this.imageCallback,
this.claimType,
this.crop,
blob,
fileName,
);
} }
async uploadImageFile(event: Event) { async uploadImageFile(event: Event) {
this.visible = false; const target = event.target as HTMLInputElement;
if (!target.files) return;
inputImageFileNameRef.value = event.target.files[0]; inputImageFileNameRef.value = target.files[0];
// https://developer.mozilla.org/en-US/docs/Web/API/File
// ... plus it has a `type` property from my testing
const file = inputImageFileNameRef.value; const file = inputImageFileNameRef.value;
if (file != null) { if (file != null) {
const reader = new FileReader(); const reader = new FileReader();
@ -123,7 +286,9 @@ export default class ImageMethodDialog extends Vue {
const blob = new Blob([new Uint8Array(data)], { const blob = new Blob([new Uint8Array(data)], {
type: file.type, type: file.type,
}); });
this.openPhotoDialog(blob, file.name as string); this.blob = blob;
this.fileName = file.name;
this.showRetry = false;
} }
}; };
reader.readAsArrayBuffer(file as Blob); reader.readAsArrayBuffer(file as Blob);
@ -131,21 +296,16 @@ export default class ImageMethodDialog extends Vue {
} }
async acceptUrl() { async acceptUrl() {
this.visible = false;
if (this.crop) { if (this.crop) {
try { try {
const urlBlobResponse: Blob = await axios.get(this.imageUrl as string, { const urlBlobResponse = await axios.get(this.imageUrl as string, {
responseType: "blob", // This ensures the data is returned as a Blob responseType: "blob",
}); });
const fullUrl = new URL(this.imageUrl as string); const fullUrl = new URL(this.imageUrl as string);
const fileName = fullUrl.pathname.split("/").pop() as string; const fileName = fullUrl.pathname.split("/").pop() as string;
(this.$refs.photoDialog as PhotoDialog).open( this.blob = urlBlobResponse.data as Blob;
this.imageCallback, this.fileName = fileName;
this.claimType, this.showRetry = false;
this.crop,
urlBlobResponse.data as Blob,
fileName,
);
} catch (error) { } catch (error) {
this.$notify( this.$notify(
{ {
@ -159,11 +319,219 @@ export default class ImageMethodDialog extends Vue {
} }
} else { } else {
this.imageCallback(this.imageUrl); this.imageCallback(this.imageUrl);
this.close();
} }
} }
close() { close() {
this.visible = false; this.visible = false;
this.stopCameraPreview();
const bottomNav = document.querySelector("#QuickNav") as HTMLElement;
if (bottomNav) {
bottomNav.style.display = "";
}
this.blob = undefined;
this.showCameraPreview = false;
}
async startCameraPreview() {
logger.debug("startCameraPreview called");
logger.debug("Current showCameraPreview state:", this.showCameraPreview);
logger.debug("Platform capabilities:", this.platformCapabilities);
if (this.platformCapabilities.isMobile) {
logger.debug("Using platform service for mobile device");
try {
const result = await this.platformService.takePicture();
this.blob = result.blob;
this.fileName = result.fileName;
} catch (error) {
logger.error("Error taking picture:", error);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Failed to take picture. Please try again.",
},
5000,
);
}
return;
}
logger.debug("Starting camera preview for desktop browser");
try {
this.showCameraPreview = true;
await this.$nextTick();
const stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: "environment" },
});
this.cameraStream = stream;
await this.$nextTick();
const videoElement = this.$refs.videoElement as HTMLVideoElement;
if (videoElement) {
videoElement.srcObject = stream;
await new Promise((resolve) => {
videoElement.onloadedmetadata = () => {
videoElement.play().then(() => {
resolve(true);
});
};
});
}
} catch (error) {
logger.error("Error starting camera preview:", error);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Failed to access camera. Please try again.",
},
5000,
);
this.showCameraPreview = false;
}
}
stopCameraPreview() {
if (this.cameraStream) {
this.cameraStream.getTracks().forEach((track) => track.stop());
this.cameraStream = null;
}
this.showCameraPreview = false;
}
async capturePhoto() {
if (!this.cameraStream) return;
try {
const videoElement = this.$refs.videoElement as HTMLVideoElement;
const canvas = document.createElement("canvas");
canvas.width = videoElement.videoWidth;
canvas.height = videoElement.videoHeight;
const ctx = canvas.getContext("2d");
ctx?.drawImage(videoElement, 0, 0, canvas.width, canvas.height);
canvas.toBlob(
(blob) => {
if (blob) {
this.blob = blob;
this.fileName = `photo_${Date.now()}.jpg`;
this.showRetry = true;
this.stopCameraPreview();
}
},
"image/jpeg",
0.95,
);
} catch (error) {
logger.error("Error capturing photo:", error);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Failed to capture photo. Please try again.",
},
5000,
);
}
}
private createBlobURL(blob: Blob): string {
return URL.createObjectURL(blob);
}
async retryImage() {
this.blob = undefined;
if (!this.platformCapabilities.isMobile) {
await this.startCameraPreview();
}
}
async uploadImage() {
this.uploading = true;
if (this.crop) {
this.blob = (await cropper?.getBlob()) || undefined;
}
const token = await accessToken(this.activeDid);
const headers = {
Authorization: "Bearer " + token,
};
const formData = new FormData();
if (!this.blob) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "There was an error finding the picture. Please try again.",
},
5000,
);
this.uploading = false;
return;
}
formData.append("image", this.blob, this.fileName || "photo.jpg");
formData.append("claimType", this.claimType);
try {
if (
window.location.hostname === "localhost" &&
!DEFAULT_IMAGE_API_SERVER.includes("localhost")
) {
logger.log(
"Using shared image API server, so only users on that server can play with images.",
);
}
const response = await axios.post(
DEFAULT_IMAGE_API_SERVER + "/image",
formData,
{ headers },
);
this.uploading = false;
this.close();
this.imageCallback(response.data.url as string);
} catch (error: unknown) {
let errorMessage = "There was an error saving the picture.";
if (axios.isAxiosError(error)) {
const status = error.response?.status;
const data = error.response?.data;
if (status === 401) {
errorMessage = "Authentication failed. Please try logging in again.";
} else if (status === 413) {
errorMessage = "Image file is too large. Please try a smaller image.";
} else if (status === 415) {
errorMessage =
"Unsupported image format. Please try a different image.";
} else if (status && status >= 500) {
errorMessage = "Server error. Please try again later.";
} else if (data?.message) {
errorMessage = data.message;
}
}
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: errorMessage,
},
5000,
);
this.uploading = false;
this.blob = undefined;
}
} }
} }
</script> </script>
@ -189,5 +557,9 @@ export default class ImageMethodDialog extends Vue {
border-radius: 0.5rem; border-radius: 0.5rem;
width: 100%; width: 100%;
max-width: 700px; max-width: 700px;
max-height: 90vh;
overflow: hidden;
display: flex;
flex-direction: column;
} }
</style> </style>

256
src/components/PhotoDialog.vue

@ -15,15 +15,16 @@ PhotoDialog.vue */
<div class="text-lg text-center font-light relative z-50"> <div class="text-lg text-center font-light relative z-50">
<div <div
id="ViewHeading" id="ViewHeading"
class="text-center font-bold absolute top-0 left-0 right-0 px-4 py-0.5 bg-black/50 text-white leading-none" class="text-center font-bold absolute top-0 inset-x-0 px-4 py-2 bg-black/50 text-white leading-none pointer-events-none"
> >
<span v-if="uploading"> Uploading... </span> <span v-if="uploading"> Uploading... </span>
<span v-else-if="blob"> Look Good? </span> <span v-else-if="blob"> Look Good? </span>
<span v-else-if="showCameraPreview"> Take Photo </span>
<span v-else> Say "Cheese"! </span> <span v-else> Say "Cheese"! </span>
</div> </div>
<div <div
class="text-lg text-center px-2 py-0.5 leading-none absolute right-0 top-0 text-white" class="text-lg text-center px-2 py-2 leading-none absolute right-0 top-0 text-white cursor-pointer"
@click="close()" @click="close()"
> >
<font-awesome icon="xmark" class="w-[1em]"></font-awesome> <font-awesome icon="xmark" class="w-[1em]"></font-awesome>
@ -47,7 +48,7 @@ PhotoDialog.vue */
:options="{ :options="{
viewMode: 1, viewMode: 1,
dragMode: 'crop', dragMode: 'crop',
aspectRatio: 9 / 9, aspectRatio: 1 / 1,
}" }"
class="max-h-[90vh] max-w-[90vw] object-contain" class="max-h-[90vh] max-w-[90vw] object-contain"
/> />
@ -60,32 +61,45 @@ PhotoDialog.vue */
/> />
</div> </div>
</div> </div>
<div class="absolute bottom-[1rem] left-[1rem] px-2 py-1"> <div class="grid grid-cols-2 gap-2 mt-2">
<button <button
class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white py-1 px-2 rounded-md" class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white py-2 px-3 rounded-md"
@click="uploadImage" @click="uploadImage"
> >
<span>Upload</span> <span>Upload</span>
</button> </button>
</div>
<div
v-if="showRetry"
class="absolute bottom-[1rem] right-[1rem] px-2 py-1"
>
<button <button
class="bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white py-1 px-2 rounded-md" v-if="showRetry"
class="bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white py-2 px-3 rounded-md"
@click="retryImage" @click="retryImage"
> >
<span>Retry</span> <span>Retry</span>
</button> </button>
</div> </div>
</div> </div>
<div v-else-if="showCameraPreview" class="camera-preview">
<div class="camera-container">
<video
ref="videoElement"
class="camera-video"
autoplay
playsinline
muted
></video>
<button
class="absolute bottom-4 left-1/2 -translate-x-1/2 bg-white text-slate-800 p-3 rounded-full text-2xl leading-none"
@click="capturePhoto"
>
<font-awesome icon="camera" class="w-[1em]" />
</button>
</div>
</div>
<div v-else> <div v-else>
<div class="flex flex-col items-center justify-center gap-4 p-4"> <div class="flex flex-col items-center justify-center gap-4 p-4">
<button <button
v-if="isRegistered" v-if="isRegistered"
class="bg-blue-500 hover:bg-blue-700 text-white font-bold p-3 rounded-full text-2xl leading-none" class="bg-blue-500 hover:bg-blue-700 text-white font-bold p-3 rounded-full text-2xl leading-none"
@click="takePhoto" @click="startCameraPreview"
> >
<font-awesome icon="camera" class="w-[1em]" /> <font-awesome icon="camera" class="w-[1em]" />
</button> </button>
@ -142,20 +156,29 @@ export default class PhotoDialog extends Vue {
/** Dialog visibility state */ /** Dialog visibility state */
visible = false; visible = false;
/** Whether to show camera preview */
showCameraPreview = false;
/** Camera stream reference */
private cameraStream: MediaStream | null = null;
private platformService = PlatformServiceFactory.getInstance(); private platformService = PlatformServiceFactory.getInstance();
URL = window.URL || window.webkitURL; URL = window.URL || window.webkitURL;
isRegistered = false; isRegistered = false;
private platformCapabilities = this.platformService.getCapabilities();
/** /**
* Lifecycle hook: Initializes component and retrieves user settings * Lifecycle hook: Initializes component and retrieves user settings
* @throws {Error} When settings retrieval fails * @throws {Error} When settings retrieval fails
*/ */
async mounted() { async mounted() {
logger.log("PhotoDialog mounted");
try { try {
const settings = await retrieveSettingsForActiveAccount(); const settings = await retrieveSettingsForActiveAccount();
this.activeDid = settings.activeDid || ""; this.activeDid = settings.activeDid || "";
this.isRegistered = !!settings.isRegistered; this.isRegistered = !!settings.isRegistered;
logger.log("isRegistered:", this.isRegistered);
} catch (error: unknown) { } catch (error: unknown) {
logger.error("Error retrieving settings from database:", error); logger.error("Error retrieving settings from database:", error);
this.$notify( this.$notify(
@ -173,6 +196,13 @@ export default class PhotoDialog extends Vue {
} }
} }
/**
* Lifecycle hook: Cleans up camera stream when component is destroyed
*/
beforeDestroy() {
this.stopCameraPreview();
}
/** /**
* Opens the photo dialog with specified configuration * Opens the photo dialog with specified configuration
* @param setImageFn - Callback function to handle image URL after upload * @param setImageFn - Callback function to handle image URL after upload
@ -181,7 +211,7 @@ export default class PhotoDialog extends Vue {
* @param blob - Optional existing image blob * @param blob - Optional existing image blob
* @param inputFileName - Optional filename for the image * @param inputFileName - Optional filename for the image
*/ */
open( async open(
setImageFn: (arg: string) => void, setImageFn: (arg: string) => void,
claimType: string, claimType: string,
crop?: boolean, crop?: boolean,
@ -204,6 +234,10 @@ export default class PhotoDialog extends Vue {
this.blob = undefined; this.blob = undefined;
this.fileName = undefined; this.fileName = undefined;
this.showRetry = true; this.showRetry = true;
// Start camera preview automatically if no blob is provided
if (!this.platformCapabilities.isMobile) {
await this.startCameraPreview();
}
} }
} }
@ -211,7 +245,12 @@ export default class PhotoDialog extends Vue {
* Closes the photo dialog and resets state * Closes the photo dialog and resets state
*/ */
close() { close() {
logger.debug(
"Dialog closing, current showCameraPreview:",
this.showCameraPreview,
);
this.visible = false; this.visible = false;
this.stopCameraPreview();
const bottomNav = document.querySelector("#QuickNav") as HTMLElement; const bottomNav = document.querySelector("#QuickNav") as HTMLElement;
if (bottomNav) { if (bottomNav) {
bottomNav.style.display = ""; bottomNav.style.display = "";
@ -219,6 +258,154 @@ export default class PhotoDialog extends Vue {
this.blob = undefined; this.blob = undefined;
} }
/**
* Starts the camera preview
*/
async startCameraPreview() {
logger.debug("startCameraPreview called");
logger.debug("Current showCameraPreview state:", this.showCameraPreview);
logger.debug("Platform capabilities:", this.platformCapabilities);
// If we're on a mobile device or using Capacitor, use the platform service
if (this.platformCapabilities.isMobile) {
logger.debug("Using platform service for mobile device");
try {
const result = await this.platformService.takePicture();
this.blob = result.blob;
this.fileName = result.fileName;
} catch (error) {
logger.error("Error taking picture:", error);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Failed to take picture. Please try again.",
},
5000,
);
}
return;
}
// For desktop web browsers, use our custom preview
logger.debug("Starting camera preview for desktop browser");
try {
// Set state before requesting camera access
this.showCameraPreview = true;
logger.debug("showCameraPreview set to:", this.showCameraPreview);
// Force a re-render
await this.$nextTick();
logger.debug(
"After nextTick, showCameraPreview is:",
this.showCameraPreview,
);
logger.debug("Requesting camera access...");
const stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: "environment" },
});
logger.debug("Camera access granted, setting up video element");
this.cameraStream = stream;
// Force another re-render after getting the stream
await this.$nextTick();
logger.debug(
"After getting stream, showCameraPreview is:",
this.showCameraPreview,
);
const videoElement = this.$refs.videoElement as HTMLVideoElement;
if (videoElement) {
logger.debug("Video element found, setting srcObject");
videoElement.srcObject = stream;
// Wait for video to be ready
await new Promise((resolve) => {
videoElement.onloadedmetadata = () => {
logger.debug("Video metadata loaded");
videoElement.play().then(() => {
logger.debug("Video playback started");
resolve(true);
});
};
});
} else {
logger.error("Video element not found");
}
} catch (error) {
logger.error("Error starting camera preview:", error);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Failed to access camera. Please try again.",
},
5000,
);
this.showCameraPreview = false;
}
}
/**
* Stops the camera preview and cleans up resources
*/
stopCameraPreview() {
logger.debug(
"Stopping camera preview, current showCameraPreview:",
this.showCameraPreview,
);
if (this.cameraStream) {
this.cameraStream.getTracks().forEach((track) => track.stop());
this.cameraStream = null;
}
this.showCameraPreview = false;
logger.debug(
"After stopping, showCameraPreview is:",
this.showCameraPreview,
);
}
/**
* Captures a photo from the camera preview
*/
async capturePhoto() {
if (!this.cameraStream) return;
try {
const videoElement = this.$refs.videoElement as HTMLVideoElement;
const canvas = document.createElement("canvas");
canvas.width = videoElement.videoWidth;
canvas.height = videoElement.videoHeight;
const ctx = canvas.getContext("2d");
ctx?.drawImage(videoElement, 0, 0, canvas.width, canvas.height);
canvas.toBlob(
(blob) => {
if (blob) {
this.blob = blob;
this.fileName = `photo_${Date.now()}.jpg`;
this.stopCameraPreview();
}
},
"image/jpeg",
0.95,
);
} catch (error) {
logger.error("Error capturing photo:", error);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Failed to capture photo. Please try again.",
},
5000,
);
}
}
/** /**
* Captures a photo using device camera * Captures a photo using device camera
* @throws {Error} When camera access fails * @throws {Error} When camera access fails
@ -275,10 +462,13 @@ export default class PhotoDialog extends Vue {
} }
/** /**
* Resets the current image selection * Resets the current image selection and restarts camera preview
*/ */
async retryImage() { async retryImage() {
this.blob = undefined; this.blob = undefined;
if (!this.platformCapabilities.isMobile) {
await this.startCameraPreview();
}
} }
/** /**
@ -422,5 +612,43 @@ export default class PhotoDialog extends Vue {
border-radius: 0.5rem; border-radius: 0.5rem;
width: 100%; width: 100%;
max-width: 700px; max-width: 700px;
max-height: 90vh;
overflow: hidden;
display: flex;
flex-direction: column;
}
/* Camera preview styling */
.camera-preview {
flex: 1;
background-color: #000;
overflow: hidden;
position: relative;
}
.camera-container {
width: 100%;
height: 100%;
position: relative;
}
.camera-video {
width: 100%;
height: 100%;
object-fit: cover;
}
.capture-button {
position: absolute;
bottom: 1rem;
left: 50%;
transform: translateX(-50%);
background: linear-gradient(to bottom, #60a5fa, #2563eb);
color: white;
padding: 0.75rem 1.5rem;
border-radius: 9999px;
box-shadow: inset 0 -1px 0 0 rgba(0, 0, 0, 0.5);
border: none;
cursor: pointer;
} }
</style> </style>

103
src/interfaces/deepLinks.ts

@ -1,11 +1,106 @@
/** /**
* @file Deep Link Interface Definitions * @file Deep Link Type Definitions and Validation Schemas
* @author Matthew Raymer * @author Matthew Raymer
* *
* Defines the core interfaces for the deep linking system. * This file defines the type system and validation schemas for deep linking in the TimeSafari app.
* These interfaces are used across the deep linking implementation * It uses Zod for runtime validation while providing TypeScript types for compile-time checking.
* to ensure type safety and consistent error handling. *
* Type Strategy:
* 1. Define base URL schema to validate the fundamental deep link structure
* 2. Define route-specific parameter schemas with exact validation rules
* 3. Generate TypeScript types from Zod schemas for type safety
* 4. Export both schemas and types for use in deep link handling
*
* Usage:
* - Import schemas for runtime validation in deep link handlers
* - Import types for type-safe parameter handling in components
* - Use DeepLinkParams type for type-safe access to route parameters
*
* @example
* // Runtime validation
* const params = deepLinkSchemas.claim.parse({ id: "123", view: "details" });
*
* // Type-safe parameter access
* function handleClaimParams(params: DeepLinkParams["claim"]) {
* // TypeScript knows params.id exists and params.view is optional
* }
*/ */
import { z } from "zod";
// Add a union type of all valid route paths
export const VALID_DEEP_LINK_ROUTES = [
"user-profile",
"project-details",
"onboard-meeting-setup",
"invite-one-accept",
"contact-import",
"confirm-gift",
"claim",
"claim-cert",
"claim-add-raw",
"contact-edit",
"contacts",
"did",
] as const;
// Create a type from the array
export type DeepLinkRoute = (typeof VALID_DEEP_LINK_ROUTES)[number];
// Update your schema definitions to use this type
export const baseUrlSchema = z.object({
scheme: z.literal("timesafari"),
path: z.string(),
queryParams: z.record(z.string()).optional(),
});
// Use the type to ensure route validation
export const routeSchema = z.enum(VALID_DEEP_LINK_ROUTES);
// Parameter validation schemas for each route type
export const deepLinkSchemas = {
"user-profile": z.object({
id: z.string(),
}),
"project-details": z.object({
id: z.string(),
}),
"onboard-meeting-setup": z.object({
id: z.string(),
}),
"invite-one-accept": z.object({
id: z.string(),
}),
"contact-import": z.object({
jwt: z.string(),
}),
"confirm-gift": z.object({
id: z.string(),
}),
claim: z.object({
id: z.string(),
}),
"claim-cert": z.object({
id: z.string(),
}),
"claim-add-raw": z.object({
id: z.string(),
claim: z.string().optional(),
claimJwtId: z.string().optional(),
}),
"contact-edit": z.object({
did: z.string(),
}),
contacts: z.object({
contacts: z.string(), // JSON string of contacts array
}),
did: z.object({
did: z.string(),
}),
};
export type DeepLinkParams = {
[K in keyof typeof deepLinkSchemas]: z.infer<(typeof deepLinkSchemas)[K]>;
};
export interface DeepLinkError extends Error { export interface DeepLinkError extends Error {
code: string; code: string;

21
src/interfaces/give.ts

@ -0,0 +1,21 @@
import { GiveSummaryRecord } from "./records";
// Common interface for contact information
export interface ContactInfo {
known: boolean;
displayName: string;
profileImageUrl?: string;
}
// Define the contact information fields
interface GiveContactInfo {
giver: ContactInfo;
issuer: ContactInfo;
receiver: ContactInfo;
providerPlanName?: string;
recipientProjectName?: string;
image?: string;
}
// Combine GiveSummaryRecord with contact information using intersection type
export type GiveRecordWithContactInfo = GiveSummaryRecord & GiveContactInfo;

0
src/lib/capacitor/app.ts → src/libs/capacitor/app.ts

2
src/lib/fontawesome.ts → src/libs/fontawesome.ts

@ -54,6 +54,7 @@ import {
faHandHoldingDollar, faHandHoldingDollar,
faHandHoldingHeart, faHandHoldingHeart,
faHouseChimney, faHouseChimney,
faImage,
faImagePortrait, faImagePortrait,
faLeftRight, faLeftRight,
faLightbulb, faLightbulb,
@ -135,6 +136,7 @@ library.add(
faHandHoldingDollar, faHandHoldingDollar,
faHandHoldingHeart, faHandHoldingHeart,
faHouseChimney, faHouseChimney,
faImage,
faImagePortrait, faImagePortrait,
faLeftRight, faLeftRight,
faLightbulb, faLightbulb,

2
src/main.capacitor.ts

@ -29,7 +29,7 @@
*/ */
import { initializeApp } from "./main.common"; import { initializeApp } from "./main.common";
import { App } from "./lib/capacitor/app"; import { App } from "./libs/capacitor/app";
import router from "./router"; import router from "./router";
import { handleApiError } from "./services/api"; import { handleApiError } from "./services/api";
import { AxiosError } from "axios"; import { AxiosError } from "axios";

2
src/main.common.ts

@ -6,7 +6,7 @@ import axios from "axios";
import VueAxios from "vue-axios"; import VueAxios from "vue-axios";
import Notifications from "notiwind"; import Notifications from "notiwind";
import "./assets/styles/tailwind.css"; import "./assets/styles/tailwind.css";
import { FontAwesomeIcon } from "./lib/fontawesome"; import { FontAwesomeIcon } from "./libs/fontawesome";
import Camera from "simple-vue-camera"; import Camera from "simple-vue-camera";
import { logger } from "./utils/logger"; import { logger } from "./utils/logger";

120
src/services/QRScanner/WebInlineQRScanner.ts

@ -1,4 +1,10 @@
import { QRScannerService, ScanListener, QRScannerOptions } from "./types"; import {
QRScannerService,
ScanListener,
QRScannerOptions,
CameraState,
CameraStateListener,
} from "./types";
import { logger } from "@/utils/logger"; import { logger } from "@/utils/logger";
import { EventEmitter } from "events"; import { EventEmitter } from "events";
import jsQR from "jsqr"; import jsQR from "jsqr";
@ -21,6 +27,9 @@ export class WebInlineQRScanner implements QRScannerService {
private readonly TARGET_FPS = 15; // Target 15 FPS for scanning private readonly TARGET_FPS = 15; // Target 15 FPS for scanning
private readonly FRAME_INTERVAL = 1000 / 15; // ~67ms between frames private readonly FRAME_INTERVAL = 1000 / 15; // ~67ms between frames
private lastFrameTime = 0; private lastFrameTime = 0;
private cameraStateListeners: Set<CameraStateListener> = new Set();
private currentState: CameraState = "off";
private currentStateMessage?: string;
constructor(private options?: QRScannerOptions) { constructor(private options?: QRScannerOptions) {
// Generate a short random ID for this scanner instance // Generate a short random ID for this scanner instance
@ -43,8 +52,41 @@ export class WebInlineQRScanner implements QRScannerService {
); );
} }
private updateCameraState(state: CameraState, message?: string) {
this.currentState = state;
this.currentStateMessage = message;
this.cameraStateListeners.forEach((listener) => {
try {
listener.onStateChange(state, message);
logger.info(
`[WebInlineQRScanner:${this.id}] Camera state changed to: ${state}`,
{
state,
message,
},
);
} catch (error) {
logger.error(
`[WebInlineQRScanner:${this.id}] Error in camera state listener:`,
error,
);
}
});
}
addCameraStateListener(listener: CameraStateListener): void {
this.cameraStateListeners.add(listener);
// Immediately notify the new listener of current state
listener.onStateChange(this.currentState, this.currentStateMessage);
}
removeCameraStateListener(listener: CameraStateListener): void {
this.cameraStateListeners.delete(listener);
}
async checkPermissions(): Promise<boolean> { async checkPermissions(): Promise<boolean> {
try { try {
this.updateCameraState("initializing", "Checking camera permissions...");
logger.error( logger.error(
`[WebInlineQRScanner:${this.id}] Checking camera permissions...`, `[WebInlineQRScanner:${this.id}] Checking camera permissions...`,
); );
@ -55,7 +97,9 @@ export class WebInlineQRScanner implements QRScannerService {
`[WebInlineQRScanner:${this.id}] Permission state:`, `[WebInlineQRScanner:${this.id}] Permission state:`,
permissions.state, permissions.state,
); );
return permissions.state === "granted"; const granted = permissions.state === "granted";
this.updateCameraState(granted ? "ready" : "permission_denied");
return granted;
} catch (error) { } catch (error) {
logger.error( logger.error(
`[WebInlineQRScanner:${this.id}] Error checking camera permissions:`, `[WebInlineQRScanner:${this.id}] Error checking camera permissions:`,
@ -64,12 +108,17 @@ export class WebInlineQRScanner implements QRScannerService {
stack: error instanceof Error ? error.stack : undefined, stack: error instanceof Error ? error.stack : undefined,
}, },
); );
this.updateCameraState("error", "Error checking camera permissions");
return false; return false;
} }
} }
async requestPermissions(): Promise<boolean> { async requestPermissions(): Promise<boolean> {
try { try {
this.updateCameraState(
"initializing",
"Requesting camera permissions...",
);
logger.error( logger.error(
`[WebInlineQRScanner:${this.id}] Requesting camera permissions...`, `[WebInlineQRScanner:${this.id}] Requesting camera permissions...`,
); );
@ -107,9 +156,7 @@ export class WebInlineQRScanner implements QRScannerService {
}, },
}); });
logger.error( this.updateCameraState("ready", "Camera permissions granted");
`[WebInlineQRScanner:${this.id}] Camera stream obtained successfully`,
);
// Stop the test stream immediately // Stop the test stream immediately
stream.getTracks().forEach((track) => { stream.getTracks().forEach((track) => {
@ -124,25 +171,19 @@ export class WebInlineQRScanner implements QRScannerService {
} catch (error) { } catch (error) {
const wrappedError = const wrappedError =
error instanceof Error ? error : new Error(String(error)); error instanceof Error ? error : new Error(String(error));
logger.error(
`[WebInlineQRScanner:${this.id}] Error requesting camera permissions:`,
{
error: wrappedError.message,
stack: wrappedError.stack,
name: wrappedError.name,
},
);
// Provide more specific error messages // Update state based on error type
if ( if (
wrappedError.name === "NotFoundError" || wrappedError.name === "NotFoundError" ||
wrappedError.name === "DevicesNotFoundError" wrappedError.name === "DevicesNotFoundError"
) { ) {
this.updateCameraState("not_found", "No camera found on this device");
throw new Error("No camera found on this device"); throw new Error("No camera found on this device");
} else if ( } else if (
wrappedError.name === "NotAllowedError" || wrappedError.name === "NotAllowedError" ||
wrappedError.name === "PermissionDeniedError" wrappedError.name === "PermissionDeniedError"
) { ) {
this.updateCameraState("permission_denied", "Camera access denied");
throw new Error( throw new Error(
"Camera access denied. Please grant camera permission and try again", "Camera access denied. Please grant camera permission and try again",
); );
@ -150,8 +191,13 @@ export class WebInlineQRScanner implements QRScannerService {
wrappedError.name === "NotReadableError" || wrappedError.name === "NotReadableError" ||
wrappedError.name === "TrackStartError" wrappedError.name === "TrackStartError"
) { ) {
this.updateCameraState(
"in_use",
"Camera is in use by another application",
);
throw new Error("Camera is in use by another application"); throw new Error("Camera is in use by another application");
} else { } else {
this.updateCameraState("error", wrappedError.message);
throw new Error(`Camera error: ${wrappedError.message}`); throw new Error(`Camera error: ${wrappedError.message}`);
} }
} }
@ -390,6 +436,7 @@ export class WebInlineQRScanner implements QRScannerService {
this.isScanning = true; this.isScanning = true;
this.scanAttempts = 0; this.scanAttempts = 0;
this.lastScanTime = Date.now(); this.lastScanTime = Date.now();
this.updateCameraState("initializing", "Starting camera...");
logger.error(`[WebInlineQRScanner:${this.id}] Starting scan`); logger.error(`[WebInlineQRScanner:${this.id}] Starting scan`);
// Get camera stream // Get camera stream
@ -404,6 +451,8 @@ export class WebInlineQRScanner implements QRScannerService {
}, },
}); });
this.updateCameraState("active", "Camera is active");
logger.error(`[WebInlineQRScanner:${this.id}] Camera stream obtained:`, { logger.error(`[WebInlineQRScanner:${this.id}] Camera stream obtained:`, {
tracks: this.stream.getTracks().map((t) => ({ tracks: this.stream.getTracks().map((t) => ({
kind: t.kind, kind: t.kind,
@ -431,11 +480,20 @@ export class WebInlineQRScanner implements QRScannerService {
this.isScanning = false; this.isScanning = false;
const wrappedError = const wrappedError =
error instanceof Error ? error : new Error(String(error)); error instanceof Error ? error : new Error(String(error));
logger.error(`[WebInlineQRScanner:${this.id}] Error starting scan:`, {
error: wrappedError.message, // Update state based on error type
stack: wrappedError.stack, if (
name: wrappedError.name, wrappedError.name === "NotReadableError" ||
}); wrappedError.name === "TrackStartError"
) {
this.updateCameraState(
"in_use",
"Camera is in use by another application",
);
} else {
this.updateCameraState("error", wrappedError.message);
}
if (this.scanListener?.onError) { if (this.scanListener?.onError) {
this.scanListener.onError(wrappedError); this.scanListener.onError(wrappedError);
} }
@ -492,14 +550,12 @@ export class WebInlineQRScanner implements QRScannerService {
`[WebInlineQRScanner:${this.id}] Stream stopped event emitted`, `[WebInlineQRScanner:${this.id}] Stream stopped event emitted`,
); );
} catch (error) { } catch (error) {
const wrappedError = logger.error(
error instanceof Error ? error : new Error(String(error)); `[WebInlineQRScanner:${this.id}] Error stopping scan:`,
logger.error(`[WebInlineQRScanner:${this.id}] Error stopping scan:`, { error,
error: wrappedError.message, );
stack: wrappedError.stack, this.updateCameraState("error", "Error stopping camera");
name: wrappedError.name, throw error;
});
throw wrappedError;
} finally { } finally {
this.isScanning = false; this.isScanning = false;
logger.error(`[WebInlineQRScanner:${this.id}] Scan stopped successfully`); logger.error(`[WebInlineQRScanner:${this.id}] Scan stopped successfully`);
@ -541,10 +597,12 @@ export class WebInlineQRScanner implements QRScannerService {
`[WebInlineQRScanner:${this.id}] Cleanup completed successfully`, `[WebInlineQRScanner:${this.id}] Cleanup completed successfully`,
); );
} catch (error) { } catch (error) {
logger.error(`[WebInlineQRScanner:${this.id}] Error during cleanup:`, { logger.error(
error: error instanceof Error ? error.message : String(error), `[WebInlineQRScanner:${this.id}] Error during cleanup:`,
stack: error instanceof Error ? error.stack : undefined, error,
}); );
this.updateCameraState("error", "Error during cleanup");
throw error;
} }
} }
} }

20
src/services/QRScanner/types.ts

@ -22,6 +22,20 @@ export interface QRScannerOptions {
playSound?: boolean; playSound?: boolean;
} }
export type CameraState =
| "initializing" // Camera is being initialized
| "ready" // Camera is ready to use
| "active" // Camera is actively streaming
| "in_use" // Camera is in use by another application
| "permission_denied" // Camera permission was denied
| "not_found" // No camera found on device
| "error" // Generic error state
| "off"; // Camera is off/stopped
export interface CameraStateListener {
onStateChange: (state: CameraState, message?: string) => void;
}
/** /**
* Interface for QR scanner service implementations * Interface for QR scanner service implementations
*/ */
@ -44,6 +58,12 @@ export interface QRScannerService {
/** Add a listener for scan events */ /** Add a listener for scan events */
addListener(listener: ScanListener): void; addListener(listener: ScanListener): void;
/** Add a listener for camera state changes */
addCameraStateListener(listener: CameraStateListener): void;
/** Remove a camera state listener */
removeCameraStateListener(listener: CameraStateListener): void;
/** Clean up scanner resources */ /** Clean up scanner resources */
cleanup(): Promise<void>; cleanup(): Promise<void>;
} }

4
src/services/deepLinks.ts

@ -7,7 +7,7 @@
* *
* Architecture: * Architecture:
* 1. DeepLinkHandler class encapsulates all deep link processing logic * 1. DeepLinkHandler class encapsulates all deep link processing logic
* 2. Uses Zod schemas from types/deepLinks for parameter validation * 2. Uses Zod schemas from interfaces/deepLinks for parameter validation
* 3. Provides consistent error handling and logging * 3. Provides consistent error handling and logging
* 4. Maps validated parameters to Vue router calls * 4. Maps validated parameters to Vue router calls
* *
@ -51,7 +51,7 @@ import {
baseUrlSchema, baseUrlSchema,
routeSchema, routeSchema,
DeepLinkRoute, DeepLinkRoute,
} from "../types/deepLinks"; } from "../interfaces/deepLinks";
import { logConsoleAndDb } from "../db"; import { logConsoleAndDb } from "../db";
import type { DeepLinkError } from "../interfaces/deepLinks"; import type { DeepLinkError } from "../interfaces/deepLinks";

188
src/services/platforms/WebPlatformService.ts

@ -80,7 +80,9 @@ export class WebPlatformService implements PlatformService {
*/ */
async takePicture(): Promise<ImageResult> { async takePicture(): Promise<ImageResult> {
const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent); const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
const hasGetUserMedia = !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia); const hasGetUserMedia = !!(
navigator.mediaDevices && navigator.mediaDevices.getUserMedia
);
// If on mobile, use file input with capture attribute (existing behavior) // If on mobile, use file input with capture attribute (existing behavior)
if (isMobile || !hasGetUserMedia) { if (isMobile || !hasGetUserMedia) {
@ -113,107 +115,121 @@ export class WebPlatformService implements PlatformService {
} }
// Desktop: Use getUserMedia for webcam capture // Desktop: Use getUserMedia for webcam capture
return new Promise(async (resolve, reject) => { return new Promise((resolve, reject) => {
let stream: MediaStream | null = null; let stream: MediaStream | null = null;
let video: HTMLVideoElement | null = null; let video: HTMLVideoElement | null = null;
let captureButton: HTMLButtonElement | null = null; let captureButton: HTMLButtonElement | null = null;
let overlay: HTMLDivElement | null = null; let overlay: HTMLDivElement | null = null;
let cleanup = () => { const cleanup = () => {
if (stream) { if (stream) {
stream.getTracks().forEach((track) => track.stop()); stream.getTracks().forEach((track) => track.stop());
} }
if (video && video.parentNode) video.parentNode.removeChild(video); if (video && video.parentNode) video.parentNode.removeChild(video);
if (captureButton && captureButton.parentNode) captureButton.parentNode.removeChild(captureButton); if (captureButton && captureButton.parentNode)
if (overlay && overlay.parentNode) overlay.parentNode.removeChild(overlay); captureButton.parentNode.removeChild(captureButton);
if (overlay && overlay.parentNode)
overlay.parentNode.removeChild(overlay);
}; };
try {
stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: "user" } });
// Create overlay for video and button
overlay = document.createElement("div");
overlay.style.position = "fixed";
overlay.style.top = "0";
overlay.style.left = "0";
overlay.style.width = "100vw";
overlay.style.height = "100vh";
overlay.style.background = "rgba(0,0,0,0.8)";
overlay.style.display = "flex";
overlay.style.flexDirection = "column";
overlay.style.justifyContent = "center";
overlay.style.alignItems = "center";
overlay.style.zIndex = "9999";
video = document.createElement("video"); // Move async operations inside Promise body
video.autoplay = true; navigator.mediaDevices
video.playsInline = true; .getUserMedia({
video.style.maxWidth = "90vw"; video: { facingMode: "user" },
video.style.maxHeight = "70vh"; })
video.srcObject = stream; .then((mediaStream) => {
overlay.appendChild(video); stream = mediaStream;
// Create overlay for video and button
overlay = document.createElement("div");
overlay.style.position = "fixed";
overlay.style.top = "0";
overlay.style.left = "0";
overlay.style.width = "100vw";
overlay.style.height = "100vh";
overlay.style.background = "rgba(0,0,0,0.8)";
overlay.style.display = "flex";
overlay.style.flexDirection = "column";
overlay.style.justifyContent = "center";
overlay.style.alignItems = "center";
overlay.style.zIndex = "9999";
captureButton = document.createElement("button"); video = document.createElement("video");
captureButton.textContent = "Capture Photo"; video.autoplay = true;
captureButton.style.marginTop = "2rem"; video.playsInline = true;
captureButton.style.padding = "1rem 2rem"; video.style.maxWidth = "90vw";
captureButton.style.fontSize = "1.2rem"; video.style.maxHeight = "70vh";
captureButton.style.background = "#2563eb"; video.srcObject = stream;
captureButton.style.color = "white"; overlay.appendChild(video);
captureButton.style.border = "none";
captureButton.style.borderRadius = "0.5rem";
captureButton.style.cursor = "pointer";
overlay.appendChild(captureButton);
document.body.appendChild(overlay); captureButton = document.createElement("button");
captureButton.textContent = "Capture Photo";
captureButton.style.marginTop = "2rem";
captureButton.style.padding = "1rem 2rem";
captureButton.style.fontSize = "1.2rem";
captureButton.style.background = "#2563eb";
captureButton.style.color = "white";
captureButton.style.border = "none";
captureButton.style.borderRadius = "0.5rem";
captureButton.style.cursor = "pointer";
overlay.appendChild(captureButton);
captureButton.onclick = async () => { document.body.appendChild(overlay);
try {
// Create a canvas to capture the frame captureButton.onclick = () => {
const canvas = document.createElement("canvas"); try {
canvas.width = video!.videoWidth; // Create a canvas to capture the frame
canvas.height = video!.videoHeight; const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d"); canvas.width = video!.videoWidth;
ctx?.drawImage(video!, 0, 0, canvas.width, canvas.height); canvas.height = video!.videoHeight;
canvas.toBlob((blob) => { const ctx = canvas.getContext("2d");
ctx?.drawImage(video!, 0, 0, canvas.width, canvas.height);
canvas.toBlob(
(blob) => {
cleanup();
if (blob) {
resolve({
blob,
fileName: `photo_${Date.now()}.jpg`,
});
} else {
reject(new Error("Failed to capture image from webcam"));
}
},
"image/jpeg",
0.95,
);
} catch (err) {
cleanup(); cleanup();
if (blob) { reject(err);
resolve({ }
blob, };
fileName: `photo_${Date.now()}.jpg`, })
.catch((error) => {
cleanup();
logger.error("Error accessing webcam:", error);
// Fallback to file input
const input = document.createElement("input");
input.type = "file";
input.accept = "image/*";
input.onchange = (e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (file) {
this.processImageFile(file)
.then((blob) => {
resolve({
blob,
fileName: file.name || "photo.jpg",
});
})
.catch((error) => {
logger.error("Error processing fallback image:", error);
reject(new Error("Failed to process fallback image"));
}); });
} else { } else {
reject(new Error("Failed to capture image from webcam")); reject(new Error("No image selected"));
}
}, "image/jpeg", 0.95);
} catch (err) {
cleanup();
reject(err);
}
};
} catch (error) {
cleanup();
logger.error("Error accessing webcam:", error);
// Fallback to file input
const input = document.createElement("input");
input.type = "file";
input.accept = "image/*";
input.onchange = async (e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (file) {
try {
const blob = await this.processImageFile(file);
resolve({
blob,
fileName: file.name || "photo.jpg",
});
} catch (error) {
logger.error("Error processing fallback image:", error);
reject(new Error("Failed to process fallback image"));
} }
} else { };
reject(new Error("No image selected")); input.click();
} });
};
input.click();
}
}); });
} }

103
src/types/deepLinks.ts

@ -1,103 +0,0 @@
/**
* @file Deep Link Type Definitions and Validation Schemas
* @author Matthew Raymer
*
* This file defines the type system and validation schemas for deep linking in the TimeSafari app.
* It uses Zod for runtime validation while providing TypeScript types for compile-time checking.
*
* Type Strategy:
* 1. Define base URL schema to validate the fundamental deep link structure
* 2. Define route-specific parameter schemas with exact validation rules
* 3. Generate TypeScript types from Zod schemas for type safety
* 4. Export both schemas and types for use in deep link handling
*
* Usage:
* - Import schemas for runtime validation in deep link handlers
* - Import types for type-safe parameter handling in components
* - Use DeepLinkParams type for type-safe access to route parameters
*
* @example
* // Runtime validation
* const params = deepLinkSchemas.claim.parse({ id: "123", view: "details" });
*
* // Type-safe parameter access
* function handleClaimParams(params: DeepLinkParams["claim"]) {
* // TypeScript knows params.id exists and params.view is optional
* }
*/
import { z } from "zod";
// Add a union type of all valid route paths
export const VALID_DEEP_LINK_ROUTES = [
"user-profile",
"project-details",
"onboard-meeting-setup",
"invite-one-accept",
"contact-import",
"confirm-gift",
"claim",
"claim-cert",
"claim-add-raw",
"contact-edit",
"contacts",
"did",
] as const;
// Create a type from the array
export type DeepLinkRoute = (typeof VALID_DEEP_LINK_ROUTES)[number];
// Update your schema definitions to use this type
export const baseUrlSchema = z.object({
scheme: z.literal("timesafari"),
path: z.string(),
queryParams: z.record(z.string()).optional(),
});
// Use the type to ensure route validation
export const routeSchema = z.enum(VALID_DEEP_LINK_ROUTES);
// Parameter validation schemas for each route type
export const deepLinkSchemas = {
"user-profile": z.object({
id: z.string(),
}),
"project-details": z.object({
id: z.string(),
}),
"onboard-meeting-setup": z.object({
id: z.string(),
}),
"invite-one-accept": z.object({
id: z.string(),
}),
"contact-import": z.object({
jwt: z.string(),
}),
"confirm-gift": z.object({
id: z.string(),
}),
claim: z.object({
id: z.string(),
}),
"claim-cert": z.object({
id: z.string(),
}),
"claim-add-raw": z.object({
id: z.string(),
claim: z.string().optional(),
claimJwtId: z.string().optional(),
}),
"contact-edit": z.object({
did: z.string(),
}),
contacts: z.object({
contacts: z.string(), // JSON string of contacts array
}),
did: z.object({
did: z.string(),
}),
};
export type DeepLinkParams = {
[K in keyof typeof deepLinkSchemas]: z.infer<(typeof deepLinkSchemas)[K]>;
};

25
src/types/index.ts

@ -1,25 +0,0 @@
import { GiveSummaryRecord, GiveVerifiableCredential } from "../interfaces";
export interface GiveRecordWithContactInfo extends GiveSummaryRecord {
jwtId: string;
fullClaim: GiveVerifiableCredential;
giver: {
known: boolean;
displayName: string;
profileImageUrl?: string;
};
issuer: {
known: boolean;
displayName: string;
profileImageUrl?: string;
};
receiver: {
known: boolean;
displayName: string;
profileImageUrl?: string;
};
providerPlanName?: string;
recipientProjectName?: string;
description: string;
image?: string;
}

110
src/views/AccountViewView.vue

@ -3,7 +3,12 @@
<TopMessage /> <TopMessage />
<!-- CONTENT --> <!-- CONTENT -->
<main id="Content" class="p-6 pb-24 max-w-3xl mx-auto" role="main" aria-label="Account Profile"> <main
id="Content"
class="p-6 pb-24 max-w-3xl mx-auto"
role="main"
aria-label="Account Profile"
>
<!-- Heading --> <!-- Heading -->
<h1 id="ViewHeading" class="text-4xl text-center font-light"> <h1 id="ViewHeading" class="text-4xl text-center font-light">
Your Identity Your Identity
@ -78,31 +83,28 @@
:icon-size="96" :icon-size="96"
:profile-image-url="profileImageUrl" :profile-image-url="profileImageUrl"
class="inline-block align-text-bottom border border-slate-300 rounded" class="inline-block align-text-bottom border border-slate-300 rounded"
@click="showLargeIdenticonUrl = profileImageUrl"
role="button" role="button"
aria-label="View profile image in large size" aria-label="View profile image in large size"
tabindex="0" tabindex="0"
@click="showLargeIdenticonUrl = profileImageUrl"
/> />
<font-awesome <font-awesome
icon="trash-can" icon="trash-can"
class="text-red-500 fa-fw ml-8 mt-8 w-12 h-12" class="text-red-500 fa-fw ml-8 mt-8 w-12 h-12"
@click="confirmDeleteImage"
role="button" role="button"
aria-label="Delete profile image" aria-label="Delete profile image"
tabindex="0" tabindex="0"
@click="confirmDeleteImage"
/> />
</span> </span>
<div v-else class="text-center"> <div v-else class="text-center">
<template v-if="isRegistered"> <template v-if="isRegistered">
<div class="inline-block text-md bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md" @click="openImageDialog()"> <div
<font-awesome class="inline-block text-md bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md"
icon="user" @click="openImageDialog()"
class="fa-fw" >
/> <font-awesome icon="user" class="fa-fw" />
<font-awesome <font-awesome icon="camera" class="fa-fw" />
icon="camera"
class="fa-fw"
/>
</div> </div>
</template> </template>
<template v-else> <template v-else>
@ -124,7 +126,10 @@
</div> </div>
</template> </template>
</div> </div>
<ImageMethodDialog ref="imageMethodDialog" :isRegistered="isRegistered" /> <ImageMethodDialog
ref="imageMethodDialog"
:is-registered="isRegistered"
/>
</div> </div>
<div class="mt-4"> <div class="mt-4">
<div class="flex justify-center text-center text-sm leading-tight mb-1"> <div class="flex justify-center text-center text-sm leading-tight mb-1">
@ -171,14 +176,20 @@
<code class="truncate" aria-label="Your DID">{{ activeDid }}</code> <code class="truncate" aria-label="Your DID">{{ activeDid }}</code>
<button <button
class="ml-2" class="ml-2"
aria-label="Copy DID to clipboard"
@click=" @click="
doCopyTwoSecRedo(activeDid, () => (showDidCopy = !showDidCopy)) doCopyTwoSecRedo(activeDid, () => (showDidCopy = !showDidCopy))
" "
aria-label="Copy DID to clipboard"
> >
<font-awesome icon="copy" class="text-slate-400 fa-fw" aria-hidden="true"></font-awesome> <font-awesome
icon="copy"
class="text-slate-400 fa-fw"
aria-hidden="true"
></font-awesome>
</button> </button>
<span v-show="showDidCopy" role="status" aria-live="polite">Copied</span> <span v-show="showDidCopy" role="status" aria-live="polite"
>Copied</span
>
</div> </div>
<div class="text-blue-500 text-sm font-bold"> <div class="text-blue-500 text-sm font-bold">
@ -201,8 +212,8 @@
aria-live="polite" aria-live="polite"
> >
<p class="mb-2"> <p class="mb-2">
Before you can publicly announce a new project or time Before you can publicly announce a new project or time commitment, a
commitment, a friend needs to register you. friend needs to register you.
</p> </p>
<router-link <router-link
:to="{ name: 'contact-qr' }" :to="{ name: 'contact-qr' }"
@ -224,19 +235,22 @@
Reminder Notification Reminder Notification
<button <button
class="text-slate-400 fa-fw cursor-pointer" class="text-slate-400 fa-fw cursor-pointer"
@click.stop="showReminderNotificationInfo"
aria-label="Learn more about reminder notifications" aria-label="Learn more about reminder notifications"
@click.stop="showReminderNotificationInfo"
> >
<font-awesome icon="question-circle" aria-hidden="true"></font-awesome> <font-awesome
icon="question-circle"
aria-hidden="true"
></font-awesome>
</button> </button>
</div> </div>
<div <div
class="relative ml-2 cursor-pointer" class="relative ml-2 cursor-pointer"
@click="showReminderNotificationChoice()"
role="switch" role="switch"
:aria-checked="notifyingReminder" :aria-checked="notifyingReminder"
aria-label="Toggle reminder notifications" aria-label="Toggle reminder notifications"
tabindex="0" tabindex="0"
@click="showReminderNotificationChoice()"
> >
<!-- input --> <!-- input -->
<input v-model="notifyingReminder" type="checkbox" class="sr-only" /> <input v-model="notifyingReminder" type="checkbox" class="sr-only" />
@ -297,7 +311,9 @@
class="bg-slate-100 rounded-md overflow-hidden px-4 py-4 mt-8 mb-8" class="bg-slate-100 rounded-md overflow-hidden px-4 py-4 mt-8 mb-8"
aria-labelledby="searchLocationHeading" aria-labelledby="searchLocationHeading"
> >
<h2 id="searchLocationHeading" class="mb-2 font-bold">Location for Searches</h2> <h2 id="searchLocationHeading" class="mb-2 font-bold">
Location for Searches
</h2>
<router-link <router-link
:to="{ name: 'search-area' }" :to="{ name: 'search-area' }"
class="block w-full text-center bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md" class="block w-full text-center bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md"
@ -316,8 +332,8 @@
Public Profile Public Profile
<button <button
class="text-slate-400 fa-fw cursor-pointer" class="text-slate-400 fa-fw cursor-pointer"
@click="showProfileInfo"
aria-label="Learn more about public profile" aria-label="Learn more about public profile"
@click="showProfileInfo"
> >
<font-awesome icon="circle-info" aria-hidden="true"></font-awesome> <font-awesome icon="circle-info" aria-hidden="true"></font-awesome>
</button> </button>
@ -408,9 +424,18 @@
> >
<h2 id="usageLimitsHeading" class="mb-2 font-bold">Usage Limits</h2> <h2 id="usageLimitsHeading" class="mb-2 font-bold">Usage Limits</h2>
<!-- show spinner if loading limits --> <!-- show spinner if loading limits -->
<div v-if="loadingLimits" class="text-center" role="status" aria-live="polite"> <div
v-if="loadingLimits"
class="text-center"
role="status"
aria-live="polite"
>
Checking&hellip; Checking&hellip;
<font-awesome icon="spinner" class="fa-spin" aria-hidden="true"></font-awesome> <font-awesome
icon="spinner"
class="fa-spin"
aria-hidden="true"
></font-awesome>
</div> </div>
<div class="mb-4 text-center"> <div class="mb-4 text-center">
{{ limitsMessage }} {{ limitsMessage }}
@ -468,9 +493,13 @@
class="text-blue-500 text-sm font-semibold mb-3 cursor-pointer" class="text-blue-500 text-sm font-semibold mb-3 cursor-pointer"
@click="showAdvanced = !showAdvanced" @click="showAdvanced = !showAdvanced"
> >
{{ showAdvanced ? 'Hide Advanced Settings' : 'Show Advanced Settings' }} {{ showAdvanced ? "Hide Advanced Settings" : "Show Advanced Settings" }}
</h3> </h3>
<section v-if="showAdvanced || showGeneralAdvanced" id="sectionAdvanced" aria-labelledby="advancedHeading"> <section
v-if="showAdvanced || showGeneralAdvanced"
id="sectionAdvanced"
aria-labelledby="advancedHeading"
>
<h2 id="advancedHeading" class="sr-only">Advanced Settings</h2> <h2 id="advancedHeading" class="sr-only">Advanced Settings</h2>
<p class="text-rose-600 mb-8"> <p class="text-rose-600 mb-8">
Beware: the features here can be confusing and even change data in ways Beware: the features here can be confusing and even change data in ways
@ -642,8 +671,14 @@
<div id="sectionClaimServer"> <div id="sectionClaimServer">
<h2 class="text-slate-500 text-sm font-bold mt-4">Claim Server</h2> <h2 class="text-slate-500 text-sm font-bold mt-4">Claim Server</h2>
<div class="px-4 py-4" role="group" aria-labelledby="claimServerHeading"> <div
<h3 id="claimServerHeading" class="sr-only">Claim Server Configuration</h3> class="px-4 py-4"
role="group"
aria-labelledby="claimServerHeading"
>
<h3 id="claimServerHeading" class="sr-only">
Claim Server Configuration
</h3>
<label for="apiServerInput" class="sr-only">API Server URL</label> <label for="apiServerInput" class="sr-only">API Server URL</label>
<input <input
id="apiServerInput" id="apiServerInput"
@ -653,18 +688,15 @@
aria-describedby="apiServerDescription" aria-describedby="apiServerDescription"
placeholder="Enter API server URL" placeholder="Enter API server URL"
/> />
<div <div id="apiServerDescription" class="sr-only" role="tooltip">
id="apiServerDescription" Enter the URL for the claim server. You can use the buttons below to
class="sr-only" quickly set common server URLs.
role="tooltip"
>
Enter the URL for the claim server. You can use the buttons below to quickly set common server URLs.
</div> </div>
<button <button
v-if="apiServerInput != apiServer" v-if="apiServerInput != apiServer"
class="w-full px-4 rounded bg-yellow-500 border border-slate-400" class="w-full px-4 rounded bg-yellow-500 border border-slate-400"
@click="onClickSaveApiServer()"
aria-label="Save API server URL" aria-label="Save API server URL"
@click="onClickSaveApiServer()"
> >
<font-awesome <font-awesome
icon="floppy-disk" icon="floppy-disk"
@ -676,22 +708,22 @@
<div class="mt-2" role="group" aria-label="Quick server selection"> <div class="mt-2" role="group" aria-label="Quick server selection">
<button <button
class="px-3 rounded bg-slate-200 border border-slate-400" class="px-3 rounded bg-slate-200 border border-slate-400"
@click="apiServerInput = AppConstants.PROD_ENDORSER_API_SERVER"
aria-label="Use production server URL" aria-label="Use production server URL"
@click="apiServerInput = AppConstants.PROD_ENDORSER_API_SERVER"
> >
Use Prod Use Prod
</button> </button>
<button <button
class="px-3 rounded bg-slate-200 border border-slate-400" class="px-3 rounded bg-slate-200 border border-slate-400"
@click="apiServerInput = AppConstants.TEST_ENDORSER_API_SERVER"
aria-label="Use test server URL" aria-label="Use test server URL"
@click="apiServerInput = AppConstants.TEST_ENDORSER_API_SERVER"
> >
Use Test Use Test
</button> </button>
<button <button
class="px-3 rounded bg-slate-200 border border-slate-400" class="px-3 rounded bg-slate-200 border border-slate-400"
@click="apiServerInput = AppConstants.LOCAL_ENDORSER_API_SERVER"
aria-label="Use local server URL" aria-label="Use local server URL"
@click="apiServerInput = AppConstants.LOCAL_ENDORSER_API_SERVER"
> >
Use Local Use Local
</button> </button>

130
src/views/ContactQRScanShowView.vue

@ -28,7 +28,7 @@
class="bg-amber-200 text-amber-900 border-amber-500 border-dashed border text-center rounded-md overflow-hidden px-4 py-3 my-4" class="bg-amber-200 text-amber-900 border-amber-500 border-dashed border text-center rounded-md overflow-hidden px-4 py-3 my-4"
> >
<p class="mb-2"> <p class="mb-2">
<b>Note:</b> your identity currently does <b>not</b> include a name. <b>Note:</b> your identity currently does <b>not</b> include a name.
</p> </p>
<button <button
class="inline-block text-md uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md" class="inline-block text-md uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md"
@ -88,7 +88,7 @@
class="absolute top-0 left-0 right-0 bg-black bg-opacity-50 text-white text-sm text-center py-2 z-10" class="absolute top-0 left-0 right-0 bg-black bg-opacity-50 text-white text-sm text-center py-2 z-10"
> >
<div <div
v-if="isInitializing" v-if="cameraState === 'initializing'"
class="flex items-center justify-center space-x-2" class="flex items-center justify-center space-x-2"
> >
<svg <svg
@ -112,10 +112,10 @@
3.042 1.135 5.824 3 7.938l3-2.647z" 3.042 1.135 5.824 3 7.938l3-2.647z"
></path> ></path>
</svg> </svg>
<span>{{ initializationStatus }}</span> <span>{{ cameraStateMessage || "Initializing camera..." }}</span>
</div> </div>
<p <p
v-else-if="isScanning" v-else-if="cameraState === 'active'"
class="flex items-center justify-center space-x-2" class="flex items-center justify-center space-x-2"
> >
<span <span
@ -125,8 +125,19 @@
</p> </p>
<p v-else-if="error" class="text-red-400">Error: {{ error }}</p> <p v-else-if="error" class="text-red-400">Error: {{ error }}</p>
<p v-else class="flex items-center justify-center space-x-2"> <p v-else class="flex items-center justify-center space-x-2">
<span class="inline-block w-2 h-2 bg-blue-500 rounded-full"></span> <span
<span>Ready to scan</span> :class="{
'inline-block w-2 h-2 rounded-full': true,
'bg-green-500': cameraState === 'ready',
'bg-yellow-500': cameraState === 'in_use',
'bg-red-500':
cameraState === 'error' ||
cameraState === 'permission_denied' ||
cameraState === 'not_found',
'bg-blue-500': cameraState === 'off',
}"
></span>
<span>{{ cameraStateMessage || "Ready to scan" }}</span>
</p> </p>
</div> </div>
@ -202,6 +213,7 @@ import { retrieveAccountMetadata } from "../libs/util";
import { Router } from "vue-router"; import { Router } from "vue-router";
import { logger } from "../utils/logger"; import { logger } from "../utils/logger";
import { QRScannerFactory } from "@/services/QRScanner/QRScannerFactory"; import { QRScannerFactory } from "@/services/QRScanner/QRScannerFactory";
import { CameraState } from "@/services/QRScanner/types";
interface QRScanResult { interface QRScanResult {
rawValue?: string; rawValue?: string;
@ -239,7 +251,8 @@ export default class ContactQRScanShow extends Vue {
initializationStatus = "Initializing camera..."; initializationStatus = "Initializing camera...";
useQRReader = __USE_QR_READER__; useQRReader = __USE_QR_READER__;
preferredCamera: "user" | "environment" = "environment"; preferredCamera: "user" | "environment" = "environment";
cameraStatus = "Initializing"; cameraState: CameraState = "off";
cameraStateMessage?: string;
ETHR_DID_PREFIX = ETHR_DID_PREFIX; ETHR_DID_PREFIX = ETHR_DID_PREFIX;
@ -303,19 +316,71 @@ export default class ContactQRScanShow extends Vue {
try { try {
this.error = null; this.error = null;
this.isScanning = true; this.isScanning = true;
this.isInitializing = true;
this.initializationStatus = "Initializing camera...";
this.lastScannedValue = ""; this.lastScannedValue = "";
this.lastScanTime = 0; this.lastScanTime = 0;
const scanner = QRScannerFactory.getInstance(); const scanner = QRScannerFactory.getInstance();
// Add camera state listener
scanner.addCameraStateListener({
onStateChange: (state, message) => {
this.cameraState = state;
this.cameraStateMessage = message;
// Update UI based on camera state
switch (state) {
case "in_use":
this.error = "Camera is in use by another application";
this.isScanning = false;
this.$notify(
{
group: "alert",
type: "warning",
title: "Camera in Use",
text: "Please close other applications using the camera and try again",
},
5000,
);
break;
case "permission_denied":
this.error = "Camera permission denied";
this.isScanning = false;
this.$notify(
{
group: "alert",
type: "warning",
title: "Camera Access Required",
text: "Please grant camera permission to scan QR codes",
},
5000,
);
break;
case "not_found":
this.error = "No camera found";
this.isScanning = false;
this.$notify(
{
group: "alert",
type: "warning",
title: "No Camera",
text: "No camera was found on this device",
},
5000,
);
break;
case "error":
this.error = this.cameraStateMessage || "Camera error";
this.isScanning = false;
break;
}
},
});
// Check if scanning is supported first // Check if scanning is supported first
if (!(await scanner.isSupported())) { if (!(await scanner.isSupported())) {
this.error = this.error =
"Camera access requires HTTPS. Please use a secure connection."; "Camera access requires HTTPS. Please use a secure connection.";
this.isScanning = false; this.isScanning = false;
this.isInitializing = false;
this.$notify( this.$notify(
{ {
group: "alert", group: "alert",
@ -328,40 +393,11 @@ export default class ContactQRScanShow extends Vue {
return; return;
} }
// Check permissions first
if (!(await scanner.checkPermissions())) {
this.initializationStatus = "Requesting camera permission...";
const granted = await scanner.requestPermissions();
if (!granted) {
this.error = "Camera permission denied";
this.isScanning = false;
this.isInitializing = false;
// Show notification for better visibility
this.$notify(
{
group: "alert",
type: "warning",
title: "Camera Access Required",
text: "Camera permission denied",
},
5000,
);
return;
}
}
// For native platforms, use the scanner service
scanner.addListener({
onScan: this.onScanDetect,
onError: this.onScanError,
});
// Start scanning // Start scanning
await scanner.startScan(); await scanner.startScan();
} catch (error) { } catch (error) {
this.error = error instanceof Error ? error.message : String(error); this.error = error instanceof Error ? error.message : String(error);
this.isScanning = false; this.isScanning = false;
this.isInitializing = false;
logger.error("Error starting scan:", { logger.error("Error starting scan:", {
error: error instanceof Error ? error.message : String(error), error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined, stack: error instanceof Error ? error.stack : undefined,
@ -828,12 +864,12 @@ export default class ContactQRScanShow extends Vue {
try { try {
await promise; await promise;
this.isInitializing = false; this.isInitializing = false;
this.cameraStatus = "Ready"; this.cameraState = "ready";
} catch (error) { } catch (error) {
const wrappedError = const wrappedError =
error instanceof Error ? error : new Error(String(error)); error instanceof Error ? error : new Error(String(error));
this.error = wrappedError.message; this.error = wrappedError.message;
this.cameraStatus = "Error"; this.cameraState = "error";
this.isInitializing = false; this.isInitializing = false;
logger.error("Error during QR scanner initialization:", { logger.error("Error during QR scanner initialization:", {
error: wrappedError.message, error: wrappedError.message,
@ -843,17 +879,17 @@ export default class ContactQRScanShow extends Vue {
} }
onCameraOn(): void { onCameraOn(): void {
this.cameraStatus = "Active"; this.cameraState = "active";
this.isInitializing = false; this.isInitializing = false;
} }
onCameraOff(): void { onCameraOff(): void {
this.cameraStatus = "Off"; this.cameraState = "off";
} }
onDetect(result: unknown): void { onDetect(result: unknown): void {
this.isScanning = true; this.isScanning = true;
this.cameraStatus = "Detecting"; this.cameraState = "detecting";
try { try {
let rawValue: string | undefined; let rawValue: string | undefined;
if ( if (
@ -874,7 +910,7 @@ export default class ContactQRScanShow extends Vue {
this.handleError(error); this.handleError(error);
} finally { } finally {
this.isScanning = false; this.isScanning = false;
this.cameraStatus = "Active"; this.cameraState = "active";
} }
} }
@ -897,12 +933,12 @@ export default class ContactQRScanShow extends Vue {
const wrappedError = const wrappedError =
error instanceof Error ? error : new Error(String(error)); error instanceof Error ? error : new Error(String(error));
this.error = wrappedError.message; this.error = wrappedError.message;
this.cameraStatus = "Error"; this.cameraState = "error";
} }
onError(error: Error): void { onError(error: Error): void {
this.error = error.message; this.error = error.message;
this.cameraStatus = "Error"; this.cameraState = "error";
logger.error("QR code scan error:", { logger.error("QR code scan error:", {
error: error.message, error: error.message,
stack: error.stack, stack: error.stack,

2
src/views/DeepLinkErrorView.vue

@ -41,7 +41,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted } from "vue"; import { computed, onMounted } from "vue";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import { VALID_DEEP_LINK_ROUTES } from "../types/deepLinks"; import { VALID_DEEP_LINK_ROUTES } from "../interfaces/deepLinks";
import { logConsoleAndDb } from "../db"; import { logConsoleAndDb } from "../db";
import { logger } from "../utils/logger"; import { logger } from "../utils/logger";

Loading…
Cancel
Save