Compare commits
28 Commits
Author | SHA1 | Date |
---|---|---|
|
e865913f72 | 4 weeks ago |
|
72de271f6c | 4 weeks ago |
|
2055097cf2 | 4 weeks ago |
|
6b38b1a347 | 4 weeks ago |
|
ca455e9593 | 1 month ago |
|
5ada70b05e | 1 month ago |
|
4f9b146a66 | 1 month ago |
|
2b638ce2a7 | 1 month ago |
|
0b528af2a6 | 1 month ago |
|
008211bc21 | 1 month ago |
|
6955a36458 | 1 month ago |
|
ba079ea983 | 1 month ago |
|
d7b3c5ec9d | 1 month ago |
|
d83a25f47e | 1 month ago |
|
fb40dc0ff7 | 1 month ago |
|
d03fa55001 | 1 month ago |
|
c8eff4d39e | 1 month ago |
|
b8a7771edf | 1 month ago |
|
5d845fb112 | 1 month ago |
|
660f2170de | 1 month ago |
|
94bd649003 | 1 month ago |
|
b2d628cfeb | 1 month ago |
|
00e52f8dca | 1 month ago |
|
073ce24f43 | 1 month ago |
|
2c84bb50b3 | 1 month ago |
|
abf18835f6 | 1 month ago |
|
f72562804d | 1 month ago |
|
bdc5ffafc1 | 1 month ago |
@ -0,0 +1,148 @@ |
|||
--- |
|||
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 Vite config flags: `__USE_QR_READER__` and `__IS_MOBILE__` |
|||
|
|||
2. **Platform-Specific Implementations** |
|||
- `CapacitorQRScanner` - Native mobile implementation |
|||
- `WebDialogQRScanner` - Web browser implementation |
|||
- `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 `vue-qrcode-reader` library |
|||
- Browser's MediaDevices API |
|||
- Vue.js dialog component |
|||
|
|||
### Key Features |
|||
- Browser-based camera access |
|||
- Fallback UI for unsupported browsers |
|||
- 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 |
|||
if (__IS_MOBILE__ || Capacitor.isNativePlatform()) { |
|||
// Use native scanner |
|||
} else if (__USE_QR_READER__) { |
|||
// Use web scanner |
|||
} |
|||
``` |
|||
|
|||
### Implementation Example |
|||
```typescript |
|||
const scanner = QRScannerFactory.getInstance(); |
|||
await scanner.checkPermissions(); |
|||
await scanner.startScan(); |
|||
scanner.addListener({ |
|||
onScan: (result) => { |
|||
// Handle scan result |
|||
} |
|||
}); |
|||
``` |
|||
|
|||
### 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 |
@ -0,0 +1,568 @@ |
|||
--- |
|||
description: |
|||
globs: |
|||
alwaysApply: true |
|||
--- |
|||
# QR Code Implementation Guide |
|||
|
|||
## Directory Structure |
|||
|
|||
``` |
|||
src/ |
|||
├── components/ |
|||
│ └── QRScanner/ |
|||
│ ├── types.ts |
|||
│ ├── factory.ts |
|||
│ ├── CapacitorScanner.ts |
|||
│ ├── WebDialogScanner.ts |
|||
│ └── QRScannerDialog.vue |
|||
├── services/ |
|||
│ └── QRScanner/ |
|||
│ ├── types.ts |
|||
│ ├── QRScannerFactory.ts |
|||
│ ├── CapacitorQRScanner.ts |
|||
│ └── WebDialogQRScanner.ts |
|||
``` |
|||
|
|||
## Core Interfaces |
|||
|
|||
```typescript |
|||
// types.ts |
|||
export interface ScanListener { |
|||
onScan: (result: string) => void; |
|||
onError?: (error: Error) => void; |
|||
} |
|||
|
|||
export interface QRScannerService { |
|||
checkPermissions(): Promise<boolean>; |
|||
requestPermissions(): Promise<boolean>; |
|||
isSupported(): Promise<boolean>; |
|||
startScan(): Promise<void>; |
|||
stopScan(): Promise<void>; |
|||
addListener(listener: ScanListener): void; |
|||
cleanup(): Promise<void>; |
|||
} |
|||
``` |
|||
|
|||
## Configuration Files |
|||
|
|||
### Vite Configuration |
|||
```typescript |
|||
// vite.config.ts |
|||
export default defineConfig({ |
|||
define: { |
|||
__USE_QR_READER__: JSON.stringify(!isMobile), |
|||
__IS_MOBILE__: JSON.stringify(isMobile), |
|||
}, |
|||
build: { |
|||
rollupOptions: { |
|||
external: isMobile ? ['vue-qrcode-reader'] : [], |
|||
} |
|||
} |
|||
}); |
|||
``` |
|||
|
|||
### 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 vue-qrcode-reader |
|||
``` |
|||
|
|||
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; |
|||
|
|||
static getInstance(): QRScannerService { |
|||
if (!this.instance) { |
|||
if (__IS_MOBILE__ || Capacitor.isNativePlatform()) { |
|||
this.instance = new CapacitorQRScanner(); |
|||
} else if (__USE_QR_READER__) { |
|||
this.instance = new WebDialogQRScanner(); |
|||
} else { |
|||
throw new Error('No QR scanner implementation available'); |
|||
} |
|||
} |
|||
return this.instance; |
|||
} |
|||
|
|||
static async cleanup() { |
|||
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>> = []; |
|||
|
|||
async checkPermissions() { |
|||
try { |
|||
const { camera } = await BarcodeScanner.checkPermissions(); |
|||
return camera === 'granted'; |
|||
} catch (error) { |
|||
logger.error('Error checking camera permissions:', error); |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
// Implement other interface methods... |
|||
} |
|||
``` |
|||
|
|||
5. **Implement Web Scanner** |
|||
```typescript |
|||
// WebDialogQRScanner.ts |
|||
export class WebDialogQRScanner implements QRScannerService { |
|||
private dialogInstance: App | null = null; |
|||
private dialogComponent: InstanceType<typeof QRScannerDialog> | null = null; |
|||
private scanListener: ScanListener | null = null; |
|||
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; |
|||
} |
|||
} |
|||
|
|||
// Implement other interface methods... |
|||
} |
|||
``` |
|||
|
|||
6. **Create Dialog Component** |
|||
```vue |
|||
<!-- QRScannerDialog.vue --> |
|||
<template> |
|||
<div v-if="visible" class="dialog-overlay z-[60]"> |
|||
<div class="dialog relative"> |
|||
<!-- Dialog content --> |
|||
<div v-if="useQRReader"> |
|||
<qrcode-stream |
|||
class="w-full max-w-lg mx-auto" |
|||
@detect="onScanDetect" |
|||
@error="onScanError" |
|||
/> |
|||
</div> |
|||
<div v-else> |
|||
<!-- Mobile camera button --> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</template> |
|||
|
|||
<script lang="ts"> |
|||
@Component({ |
|||
components: { QrcodeStream } |
|||
}) |
|||
export default class QRScannerDialog extends Vue { |
|||
// Implementation... |
|||
} |
|||
</script> |
|||
``` |
|||
|
|||
## Usage Example |
|||
|
|||
```typescript |
|||
// In your component |
|||
async function scanQRCode() { |
|||
const scanner = QRScannerFactory.getInstance(); |
|||
|
|||
if (!(await scanner.checkPermissions())) { |
|||
const granted = await scanner.requestPermissions(); |
|||
if (!granted) { |
|||
throw new Error('Camera permission denied'); |
|||
} |
|||
} |
|||
|
|||
scanner.addListener({ |
|||
onScan: (result) => { |
|||
console.log('Scanned:', result); |
|||
}, |
|||
onError: (error) => { |
|||
console.error('Scan error:', error); |
|||
} |
|||
}); |
|||
|
|||
await scanner.startScan(); |
|||
} |
|||
|
|||
// Cleanup when done |
|||
onUnmounted(() => { |
|||
QRScannerFactory.cleanup(); |
|||
}); |
|||
``` |
|||
|
|||
## 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 |
|||
|
|||
### Web |
|||
- Uses MediaDevices API |
|||
- Requires HTTPS for camera access |
|||
- Handles browser compatibility |
|||
- Manages memory and resources |
|||
- Provides fallback UI |
|||
|
|||
## Testing |
|||
|
|||
1. **Unit Tests** |
|||
- Test factory pattern |
|||
- Test platform detection |
|||
- Test error handling |
|||
- Test cleanup procedures |
|||
|
|||
2. **Integration Tests** |
|||
- Test permission flows |
|||
- Test camera access |
|||
- Test QR code detection |
|||
- Test cross-platform behavior |
|||
|
|||
3. **E2E Tests** |
|||
- Test full scanning workflow |
|||
- Test UI feedback |
|||
- Test error scenarios |
|||
- Test platform differences |
|||
|
|||
## Common Issues and Solutions |
|||
|
|||
1. **Permission Handling** |
|||
- Always check permissions first |
|||
- Provide clear user feedback |
|||
- Handle denial gracefully |
|||
- Implement retry logic |
|||
|
|||
2. **Resource Management** |
|||
- Clean up after scanning |
|||
- Handle component unmounting |
|||
- Release camera resources |
|||
- Clear event listeners |
|||
|
|||
3. **Error Handling** |
|||
- Log errors appropriately |
|||
- Provide user feedback |
|||
- Implement fallbacks |
|||
- Handle edge cases |
|||
|
|||
4. **Performance** |
|||
- Optimize camera preview |
|||
- Handle memory usage |
|||
- Manage battery impact |
|||
- Consider device capabilities |
|||
|
|||
# QR Code Implementation Guide |
|||
|
|||
## Directory Structure |
|||
|
|||
``` |
|||
src/ |
|||
├── components/ |
|||
│ └── QRScanner/ |
|||
│ ├── types.ts |
|||
│ ├── factory.ts |
|||
│ ├── CapacitorScanner.ts |
|||
│ ├── WebDialogScanner.ts |
|||
│ └── QRScannerDialog.vue |
|||
├── services/ |
|||
│ └── QRScanner/ |
|||
│ ├── types.ts |
|||
│ ├── QRScannerFactory.ts |
|||
│ ├── CapacitorQRScanner.ts |
|||
│ └── WebDialogQRScanner.ts |
|||
``` |
|||
|
|||
## Core Interfaces |
|||
|
|||
```typescript |
|||
// types.ts |
|||
export interface ScanListener { |
|||
onScan: (result: string) => void; |
|||
onError?: (error: Error) => void; |
|||
} |
|||
|
|||
export interface QRScannerService { |
|||
checkPermissions(): Promise<boolean>; |
|||
requestPermissions(): Promise<boolean>; |
|||
isSupported(): Promise<boolean>; |
|||
startScan(): Promise<void>; |
|||
stopScan(): Promise<void>; |
|||
addListener(listener: ScanListener): void; |
|||
cleanup(): Promise<void>; |
|||
} |
|||
``` |
|||
|
|||
## Configuration Files |
|||
|
|||
### Vite Configuration |
|||
```typescript |
|||
// vite.config.ts |
|||
export default defineConfig({ |
|||
define: { |
|||
__USE_QR_READER__: JSON.stringify(!isMobile), |
|||
__IS_MOBILE__: JSON.stringify(isMobile), |
|||
}, |
|||
build: { |
|||
rollupOptions: { |
|||
external: isMobile ? ['vue-qrcode-reader'] : [], |
|||
} |
|||
} |
|||
}); |
|||
``` |
|||
|
|||
### 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 vue-qrcode-reader |
|||
``` |
|||
|
|||
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; |
|||
|
|||
static getInstance(): QRScannerService { |
|||
if (!this.instance) { |
|||
if (__IS_MOBILE__ || Capacitor.isNativePlatform()) { |
|||
this.instance = new CapacitorQRScanner(); |
|||
} else if (__USE_QR_READER__) { |
|||
this.instance = new WebDialogQRScanner(); |
|||
} else { |
|||
throw new Error('No QR scanner implementation available'); |
|||
} |
|||
} |
|||
return this.instance; |
|||
} |
|||
|
|||
static async cleanup() { |
|||
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>> = []; |
|||
|
|||
async checkPermissions() { |
|||
try { |
|||
const { camera } = await BarcodeScanner.checkPermissions(); |
|||
return camera === 'granted'; |
|||
} catch (error) { |
|||
logger.error('Error checking camera permissions:', error); |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
// Implement other interface methods... |
|||
} |
|||
``` |
|||
|
|||
5. **Implement Web Scanner** |
|||
```typescript |
|||
// WebDialogQRScanner.ts |
|||
export class WebDialogQRScanner implements QRScannerService { |
|||
private dialogInstance: App | null = null; |
|||
private dialogComponent: InstanceType<typeof QRScannerDialog> | null = null; |
|||
private scanListener: ScanListener | null = null; |
|||
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; |
|||
} |
|||
} |
|||
|
|||
// Implement other interface methods... |
|||
} |
|||
``` |
|||
|
|||
6. **Create Dialog Component** |
|||
```vue |
|||
<!-- QRScannerDialog.vue --> |
|||
<template> |
|||
<div v-if="visible" class="dialog-overlay z-[60]"> |
|||
<div class="dialog relative"> |
|||
<!-- Dialog content --> |
|||
<div v-if="useQRReader"> |
|||
<qrcode-stream |
|||
class="w-full max-w-lg mx-auto" |
|||
@detect="onScanDetect" |
|||
@error="onScanError" |
|||
/> |
|||
</div> |
|||
<div v-else> |
|||
<!-- Mobile camera button --> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</template> |
|||
|
|||
<script lang="ts"> |
|||
@Component({ |
|||
components: { QrcodeStream } |
|||
}) |
|||
export default class QRScannerDialog extends Vue { |
|||
// Implementation... |
|||
} |
|||
</script> |
|||
``` |
|||
|
|||
## Usage Example |
|||
|
|||
```typescript |
|||
// In your component |
|||
async function scanQRCode() { |
|||
const scanner = QRScannerFactory.getInstance(); |
|||
|
|||
if (!(await scanner.checkPermissions())) { |
|||
const granted = await scanner.requestPermissions(); |
|||
if (!granted) { |
|||
throw new Error('Camera permission denied'); |
|||
} |
|||
} |
|||
|
|||
scanner.addListener({ |
|||
onScan: (result) => { |
|||
console.log('Scanned:', result); |
|||
}, |
|||
onError: (error) => { |
|||
console.error('Scan error:', error); |
|||
} |
|||
}); |
|||
|
|||
await scanner.startScan(); |
|||
} |
|||
|
|||
// Cleanup when done |
|||
onUnmounted(() => { |
|||
QRScannerFactory.cleanup(); |
|||
}); |
|||
``` |
|||
|
|||
## 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 |
|||
|
|||
### Web |
|||
- Uses MediaDevices API |
|||
- Requires HTTPS for camera access |
|||
- Handles browser compatibility |
|||
- Manages memory and resources |
|||
- Provides fallback UI |
|||
|
|||
## Testing |
|||
|
|||
1. **Unit Tests** |
|||
- Test factory pattern |
|||
- Test platform detection |
|||
- Test error handling |
|||
- Test cleanup procedures |
|||
|
|||
2. **Integration Tests** |
|||
- Test permission flows |
|||
- Test camera access |
|||
- Test QR code detection |
|||
- Test cross-platform behavior |
|||
|
|||
3. **E2E Tests** |
|||
- Test full scanning workflow |
|||
- Test UI feedback |
|||
- Test error scenarios |
|||
- Test platform differences |
|||
|
|||
## Common Issues and Solutions |
|||
|
|||
1. **Permission Handling** |
|||
- Always check permissions first |
|||
- Provide clear user feedback |
|||
- Handle denial gracefully |
|||
- Implement retry logic |
|||
|
|||
2. **Resource Management** |
|||
- Clean up after scanning |
|||
- Handle component unmounting |
|||
- Release camera resources |
|||
- Clear event listeners |
|||
|
|||
3. **Error Handling** |
|||
- Log errors appropriately |
|||
- Provide user feedback |
|||
- Implement fallbacks |
|||
- Handle edge cases |
|||
|
|||
4. **Performance** |
|||
- Optimize camera preview |
|||
- Handle memory usage |
|||
- Manage battery impact |
|||
- Consider device capabilities |
@ -0,0 +1,276 @@ |
|||
--- |
|||
description: |
|||
globs: |
|||
alwaysApply: true |
|||
--- |
|||
--- |
|||
description: |
|||
globs: |
|||
alwaysApply: true |
|||
--- |
|||
# Time Safari Context |
|||
|
|||
## Project Overview |
|||
|
|||
Time Safari is an application designed to foster community building through gifts, gratitude, and collaborative projects. The app should make it extremely easy and intuitive for users of any age and capability to recognize contributions, build trust networks, and organize collective action. It is built on services that preserve privacy and data sovereignty. |
|||
|
|||
The ultimate goals of Time Safari are two-fold: |
|||
|
|||
1. **Connect** Make it easy, rewarding, and non-threatening for people to connect with others who have similar interests, and to initiate activities together. This helps people accomplish and learn from other individuals in less-structured environments; moreover, it helps them discover who they want to continue to support and with whom they want to maintain relationships. |
|||
|
|||
2. **Reveal** Widely advertise the great support and rewards that are being given and accepted freely, especially non-monetary ones. Using visuals and text, display the kind of impact that gifts are making in the lives of others. Also show useful and engaging reports of project statistics and personal accomplishments. |
|||
|
|||
|
|||
## Core Approaches |
|||
|
|||
Time Safari should help everyday users build meaningful connections and organize collective efforts by: |
|||
|
|||
1. **Recognizing Contributions**: Creating permanent, verifiable records of gifts and contributions people give to each other and their communities. |
|||
|
|||
2. **Facilitating Collaboration**: Making it ridiculously easy for people to ask for or propose help on projects and interests that matter to them. |
|||
|
|||
3. **Building Trust Networks**: Enabling users to maintain their network and activity visibility. Developing reputation through verified contributions and references, which can be selectively shown to others outside the network. |
|||
|
|||
4. **Preserving Privacy**: Ensuring personal identifiers are only shared with explicitly authorized contacts, allowing private individuals including children to participate safely. |
|||
|
|||
5. **Engaging Content**: Displaying people's records in compelling stories, and highlighting those projects that are lifting people's lives long-term, both in physical support and in emotional-spiritual-creative thriving. |
|||
|
|||
|
|||
## Technical Foundation |
|||
|
|||
This application is built on a privacy-preserving claims architecture (via endorser.ch) with these key characteristics: |
|||
|
|||
- **Decentralized Identifiers (DIDs)**: User identities are based on public/private key pairs stored on their devices |
|||
- **Cryptographic Verification**: All claims and confirmations are cryptographically signed |
|||
- **User-Controlled Visibility**: Users explicitly control who can see their identifiers and data |
|||
- **Merkle-Chained Claims**: Claims are cryptographically chained for verification and integrity |
|||
- **Native and Web App**: Works on Capacitor (iOS, Android), Desktop (Electron and CEFPython), and web browsers |
|||
|
|||
## User Journey |
|||
|
|||
The typical progression of usage follows these stages: |
|||
|
|||
1. **Gratitude & Recognition**: Users begin by expressing and recording gratitude for gifts received, building a foundation of acknowledgment. |
|||
|
|||
2. **Project Proposals**: Users propose projects and ideas, reaching out to connect with others who share similar interests. |
|||
|
|||
3. **Action Triggers**: Offers of help serve as triggers and motivations to execute proposed projects, moving from ideas to action. |
|||
|
|||
## Context for LLM Development |
|||
|
|||
When developing new functionality for Time Safari, consider these design principles: |
|||
|
|||
1. **Accessibility First**: Features should be usable by non-technical users with minimal learning curve. |
|||
|
|||
2. **Privacy by Design**: All features must respect user privacy and data sovereignty. |
|||
|
|||
3. **Progressive Enhancement**: Core functionality should work across all devices, with richer experiences where supported. |
|||
|
|||
4. **Voluntary Collaboration**: The system should enable but never coerce participation. |
|||
|
|||
5. **Trust Building**: Features should help build verifiable trust between users. |
|||
|
|||
6. **Network Effects**: Consider how features scale as more users join the platform. |
|||
|
|||
7. **Low Resource Requirements**: The system should be lightweight enough to run on inexpensive devices users already own. |
|||
|
|||
## Use Cases to Support |
|||
|
|||
LLM development should focus on enhancing these key use cases: |
|||
|
|||
1. **Community Building**: Tools that help people find others with shared interests and values. |
|||
|
|||
2. **Project Coordination**: Features that make it easy to propose collaborative projects and to submit suggestions and offers to existing ones. |
|||
|
|||
3. **Reputation Building**: Methods for users to showcase their contributions and reliability, in contexts where they explicitly reveal that information. |
|||
|
|||
4. **Governance Experimentation**: Features that facilitate decision-making and collective governance. |
|||
|
|||
## Constraints |
|||
|
|||
When developing new features, be mindful of these constraints: |
|||
|
|||
1. **Privacy Preservation**: User identifiers must remain private except when explicitly shared. |
|||
|
|||
2. **Platform Limitations**: Features must work within the constraints of the target app platforms, while aiming to leverage the best platform technology available. |
|||
|
|||
3. **Endorser API Limitations**: Backend features are constrained by the endorser.ch API capabilities. |
|||
|
|||
4. **Performance on Low-End Devices**: The application should remain performant on older/simpler devices. |
|||
|
|||
5. **Offline-First When Possible**: Key functionality should work offline when feasible. |
|||
|
|||
## Project Technologies |
|||
|
|||
- Typescript using ES6 classes using vue-facing-decorator |
|||
- TailwindCSS |
|||
- Vite Build Tool |
|||
- Playwright E2E testing |
|||
- IndexDB |
|||
- Camera, Image uploads, QR Code reader, ... |
|||
|
|||
## Mobile Features |
|||
|
|||
- Deep Linking |
|||
- Local Notifications via a custom Capacitor plugin |
|||
|
|||
## Project Architecture |
|||
|
|||
- The application must work on web browser, PWA (Progressive Web Application), desktop via Electron, and mobile via Capacitor |
|||
- Building for each platform is managed via Vite |
|||
|
|||
## Core Development Principles |
|||
|
|||
### DRY development |
|||
- **Code Reuse** |
|||
- Extract common functionality into utility functions |
|||
- Create reusable components for UI patterns |
|||
- Implement service classes for shared business logic |
|||
- Use mixins for cross-cutting concerns |
|||
- Leverage TypeScript interfaces for shared type definitions |
|||
|
|||
- **Component Patterns** |
|||
- Create base components for common UI elements |
|||
- Implement higher-order components for shared behavior |
|||
- Use slot patterns for flexible component composition |
|||
- Create composable services for business logic |
|||
- Implement factory patterns for component creation |
|||
|
|||
- **State Management** |
|||
- Centralize state in Pinia stores |
|||
- Use computed properties for derived state |
|||
- Implement shared state selectors |
|||
- Create reusable state mutations |
|||
- Use action creators for common operations |
|||
|
|||
- **Error Handling** |
|||
- Implement centralized error handling |
|||
- Create reusable error components |
|||
- Use error boundary components |
|||
- Implement consistent error logging |
|||
- Create error type definitions |
|||
|
|||
- **Type Definitions** |
|||
- Create shared interfaces for common data structures |
|||
- Use type aliases for complex types |
|||
- Implement generic types for reusable components |
|||
- Create utility types for common patterns |
|||
- Use discriminated unions for state management |
|||
|
|||
- **API Integration** |
|||
- Create reusable API client classes |
|||
- Implement request/response interceptors |
|||
- Use consistent error handling patterns |
|||
- Create type-safe API endpoints |
|||
- Implement caching strategies |
|||
|
|||
- **Platform Services** |
|||
- Abstract platform-specific code behind interfaces |
|||
- Create platform-agnostic service layers |
|||
- Implement feature detection |
|||
- Use dependency injection for services |
|||
- Create service factories |
|||
|
|||
- **Testing** |
|||
- Create reusable test utilities |
|||
- Implement test factories |
|||
- Use shared test configurations |
|||
- Create reusable test helpers |
|||
- Implement consistent test patterns |
|||
|
|||
### SOLID Principles |
|||
- **Single Responsibility**: Each class/component should have only one reason to change |
|||
- Components should focus on one specific feature (e.g., QR scanning, DID management) |
|||
- Services should handle one type of functionality (e.g., platform services, crypto services) |
|||
- Utilities should provide focused helper functions |
|||
|
|||
- **Open/Closed**: Software entities should be open for extension but closed for modification |
|||
- Use interfaces for service definitions |
|||
- Implement plugin architecture for platform-specific features |
|||
- Allow component behavior extension through props and events |
|||
|
|||
- **Liskov Substitution**: Objects should be replaceable with their subtypes |
|||
- Platform services should work consistently across web/mobile |
|||
- Authentication providers should be interchangeable |
|||
- Storage implementations should be swappable |
|||
|
|||
- **Interface Segregation**: Clients shouldn't depend on interfaces they don't use |
|||
- Break down large service interfaces into smaller, focused ones |
|||
- Component props should be minimal and purposeful |
|||
- Event emissions should be specific and targeted |
|||
|
|||
- **Dependency Inversion**: High-level modules shouldn't depend on low-level modules |
|||
- Use dependency injection for services |
|||
- Abstract platform-specific code behind interfaces |
|||
- Implement factory patterns for component creation |
|||
|
|||
### Law of Demeter |
|||
- Components should only communicate with immediate dependencies |
|||
- Avoid chaining method calls (e.g., `this.service.getUser().getProfile().getName()`) |
|||
- Use mediator patterns for complex component interactions |
|||
- Implement facade patterns for subsystem access |
|||
- Keep component communication through defined events and props |
|||
|
|||
### Composition over Inheritance |
|||
- Prefer building components through composition |
|||
- Use mixins for shared functionality |
|||
- Implement feature toggles through props |
|||
- Create higher-order components for common patterns |
|||
- Use service composition for complex features |
|||
|
|||
### Interface Segregation |
|||
- Define clear interfaces for services |
|||
- Keep component APIs minimal and focused |
|||
- Split large interfaces into smaller, specific ones |
|||
- Use TypeScript interfaces for type definitions |
|||
- Implement role-based interfaces for different use cases |
|||
|
|||
### Fail Fast |
|||
- Validate inputs early in the process |
|||
- Use TypeScript strict mode |
|||
- Implement comprehensive error handling |
|||
- Add runtime checks for critical operations |
|||
- Use assertions for development-time validation |
|||
|
|||
### Principle of Least Astonishment |
|||
- Follow Vue.js conventions consistently |
|||
- Use familiar naming patterns |
|||
- Implement predictable component behaviors |
|||
- Maintain consistent error handling |
|||
- Keep UI interactions intuitive |
|||
|
|||
### Information Hiding |
|||
- Encapsulate implementation details |
|||
- Use private class members |
|||
- Implement proper access modifiers |
|||
- Hide complex logic behind simple interfaces |
|||
- Use TypeScript's access modifiers effectively |
|||
|
|||
### Single Source of Truth |
|||
- Use Pinia for state management |
|||
- Maintain one source for user data |
|||
- Centralize configuration management |
|||
- Use computed properties for derived state |
|||
- Implement proper state synchronization |
|||
|
|||
### Principle of Least Privilege |
|||
- Implement proper access control |
|||
- Use minimal required permissions |
|||
- Follow privacy-by-design principles |
|||
- Restrict component access to necessary data |
|||
- Implement proper authentication/authorization |
|||
|
|||
### Continuous Integration/Continuous Deployment (CI/CD) |
|||
- Automated testing on every commit |
|||
- Consistent build process across platforms |
|||
- Automated deployment pipelines |
|||
- Quality gates for code merging |
|||
- Environment-specific configurations |
|||
|
|||
This expanded documentation provides: |
|||
1. Clear principles for development |
|||
2. Practical implementation guidelines |
|||
3. Real-world examples |
|||
4. TypeScript integration |
|||
5. Best practices for Time Safari |
|||
|
@ -1,2 +1,2 @@ |
|||
#Fri Mar 21 07:27:50 UTC 2025 |
|||
gradle.version=8.2.1 |
|||
#Wed Apr 09 09:01:13 UTC 2025 |
|||
gradle.version=8.11.1 |
|||
|
@ -0,0 +1,28 @@ |
|||
{ |
|||
"project_info": { |
|||
"project_number": "123456789000", |
|||
"project_id": "timesafari-app", |
|||
"storage_bucket": "timesafari-app.appspot.com" |
|||
}, |
|||
"client": [ |
|||
{ |
|||
"client_info": { |
|||
"mobilesdk_app_id": "1:123456789000:android:1234567890abcdef", |
|||
"android_client_info": { |
|||
"package_name": "app.timesafari.app" |
|||
} |
|||
}, |
|||
"oauth_client": [], |
|||
"api_key": [ |
|||
{ |
|||
"current_key": "AIzaSyDummyKeyForBuildPurposesOnly12345" |
|||
} |
|||
], |
|||
"services": { |
|||
"appinvite_service": { |
|||
"other_platform_oauth_client": [] |
|||
} |
|||
} |
|||
} |
|||
] |
|||
} |
@ -1,17 +0,0 @@ |
|||
<!DOCTYPE html> |
|||
<html lang=""> |
|||
<head> |
|||
<meta charset="utf-8"> |
|||
<meta http-equiv="X-UA-Compatible" content="IE=edge"> |
|||
<meta name="viewport" content="width=device-width,initial-scale=1.0"> |
|||
<link rel="icon" href="/favicon.ico"> |
|||
<title>TimeSafari</title> |
|||
<script type="module" crossorigin src="/assets/index-CZMUlUNO.js"></script> |
|||
</head> |
|||
<body> |
|||
<noscript> |
|||
<strong>We're sorry but TimeSafari doesn't work properly without JavaScript enabled. Please enable it to continue.</strong> |
|||
</noscript> |
|||
<div id="app"></div> |
|||
</body> |
|||
</html> |
After Width: | Height: | Size: 4.6 KiB |
After Width: | Height: | Size: 7.3 KiB |
After Width: | Height: | Size: 2.1 KiB |
After Width: | Height: | Size: 4.3 KiB |
After Width: | Height: | Size: 6.9 KiB |
After Width: | Height: | Size: 10 KiB |
After Width: | Height: | Size: 12 KiB |
After Width: | Height: | Size: 17 KiB |
After Width: | Height: | Size: 18 KiB |
After Width: | Height: | Size: 23 KiB |
@ -1,23 +1,16 @@ |
|||
App/build |
|||
App/Pods |
|||
App/output |
|||
App/App/public |
|||
DerivedData |
|||
xcuserdata |
|||
*.xcuserstate |
|||
App/Pods |
|||
|
|||
# Cordova plugins for Capacitor |
|||
capacitor-cordova-ios-plugins |
|||
App/*.xcodeproj/xcuserdata/ |
|||
App/*.xcworkspace/xcuserdata/ |
|||
App/*/public |
|||
|
|||
# Generated Config files |
|||
App/App/capacitor.config.json |
|||
App/App/config.xml |
|||
App/*/capacitor.config.json |
|||
App/*/config.xml |
|||
|
|||
# User-specific Xcode files |
|||
App/App.xcodeproj/xcuserdata/*.xcuserdatad/ |
|||
App/App.xcodeproj/*.xcuserstate |
|||
# Cordova plugins for Capacitor |
|||
capacitor-cordova-ios-plugins |
|||
|
|||
fastlane/report.xml |
|||
fastlane/Preview.html |
|||
fastlane/screenshots |
|||
fastlane/test_output |
|||
DerivedData |
|||
|
@ -1,8 +0,0 @@ |
|||
<?xml version="1.0" encoding="UTF-8"?> |
|||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> |
|||
<plist version="1.0"> |
|||
<dict> |
|||
<key>IDEDidComputeMac32BitWarning</key> |
|||
<true/> |
|||
</dict> |
|||
</plist> |
@ -1,28 +1,52 @@ |
|||
PODS: |
|||
- Capacitor (6.2.0): |
|||
- Capacitor (6.2.1): |
|||
- CapacitorCordova |
|||
- CapacitorApp (6.0.2): |
|||
- Capacitor |
|||
- CapacitorCordova (6.2.0) |
|||
- CapacitorCamera (6.1.2): |
|||
- Capacitor |
|||
- CapacitorCordova (6.2.1) |
|||
- CapacitorFilesystem (6.0.3): |
|||
- Capacitor |
|||
- CapacitorShare (6.0.3): |
|||
- Capacitor |
|||
- CapawesomeCapacitorFilePicker (6.2.0): |
|||
- Capacitor |
|||
|
|||
DEPENDENCIES: |
|||
- "Capacitor (from `../../node_modules/@capacitor/ios`)" |
|||
- "CapacitorApp (from `../../node_modules/@capacitor/app`)" |
|||
- "CapacitorCamera (from `../../node_modules/@capacitor/camera`)" |
|||
- "CapacitorCordova (from `../../node_modules/@capacitor/ios`)" |
|||
- "CapacitorFilesystem (from `../../node_modules/@capacitor/filesystem`)" |
|||
- "CapacitorShare (from `../../node_modules/@capacitor/share`)" |
|||
- "CapawesomeCapacitorFilePicker (from `../../node_modules/@capawesome/capacitor-file-picker`)" |
|||
|
|||
EXTERNAL SOURCES: |
|||
Capacitor: |
|||
:path: "../../node_modules/@capacitor/ios" |
|||
CapacitorApp: |
|||
:path: "../../node_modules/@capacitor/app" |
|||
CapacitorCamera: |
|||
:path: "../../node_modules/@capacitor/camera" |
|||
CapacitorCordova: |
|||
:path: "../../node_modules/@capacitor/ios" |
|||
CapacitorFilesystem: |
|||
:path: "../../node_modules/@capacitor/filesystem" |
|||
CapacitorShare: |
|||
:path: "../../node_modules/@capacitor/share" |
|||
CapawesomeCapacitorFilePicker: |
|||
:path: "../../node_modules/@capawesome/capacitor-file-picker" |
|||
|
|||
SPEC CHECKSUMS: |
|||
Capacitor: 05d35014f4425b0740fc8776481f6a369ad071bf |
|||
Capacitor: c95400d761e376be9da6be5a05f226c0e865cebf |
|||
CapacitorApp: e1e6b7d05e444d593ca16fd6d76f2b7c48b5aea7 |
|||
CapacitorCordova: b33e7f4aa4ed105dd43283acdd940964374a87d9 |
|||
CapacitorCamera: 9bc7b005d0e6f1d5f525b8137045b60cffffce79 |
|||
CapacitorCordova: 8d93e14982f440181be7304aa9559ca631d77fff |
|||
CapacitorFilesystem: 59270a63c60836248812671aa3b15df673fbaf74 |
|||
CapacitorShare: d2a742baec21c8f3b92b361a2fbd2401cdd8288e |
|||
CapawesomeCapacitorFilePicker: c40822f0a39f86855321943c7829d52bca7f01bd |
|||
|
|||
PODFILE CHECKSUM: 4233f5c5f414604460ff96d372542c311b0fb7a8 |
|||
PODFILE CHECKSUM: 1e9280368fd410520414f5741bf8fdfe7847b965 |
|||
|
|||
COCOAPODS: 1.16.2 |
|||
|
@ -0,0 +1,156 @@ |
|||
## 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 |
@ -0,0 +1,22 @@ |
|||
#!/bin/bash |
|||
|
|||
# Clean the public directory |
|||
rm -rf android/app/src/main/assets/public/* |
|||
|
|||
# Copy web assets |
|||
cp -r dist/* android/app/src/main/assets/public/ |
|||
|
|||
# Ensure the directory structure exists |
|||
mkdir -p android/app/src/main/assets/public/assets |
|||
|
|||
# Copy the main index file |
|||
cp dist/index.html android/app/src/main/assets/public/ |
|||
|
|||
# Copy all assets |
|||
cp -r dist/assets/* android/app/src/main/assets/public/assets/ |
|||
|
|||
# Copy other necessary files |
|||
cp dist/favicon.ico android/app/src/main/assets/public/ |
|||
cp dist/robots.txt android/app/src/main/assets/public/ |
|||
|
|||
echo "Web assets copied successfully!" |
@ -0,0 +1,22 @@ |
|||
#!/bin/bash |
|||
|
|||
# Create directories if they don't exist |
|||
mkdir -p android/app/src/main/res/mipmap-mdpi |
|||
mkdir -p android/app/src/main/res/mipmap-hdpi |
|||
mkdir -p android/app/src/main/res/mipmap-xhdpi |
|||
mkdir -p android/app/src/main/res/mipmap-xxhdpi |
|||
mkdir -p android/app/src/main/res/mipmap-xxxhdpi |
|||
|
|||
# Generate placeholder icons using ImageMagick |
|||
convert -size 48x48 xc:blue -gravity center -pointsize 20 -fill white -annotate 0 "TS" android/app/src/main/res/mipmap-mdpi/ic_launcher.png |
|||
convert -size 72x72 xc:blue -gravity center -pointsize 30 -fill white -annotate 0 "TS" android/app/src/main/res/mipmap-hdpi/ic_launcher.png |
|||
convert -size 96x96 xc:blue -gravity center -pointsize 40 -fill white -annotate 0 "TS" android/app/src/main/res/mipmap-xhdpi/ic_launcher.png |
|||
convert -size 144x144 xc:blue -gravity center -pointsize 60 -fill white -annotate 0 "TS" android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png |
|||
convert -size 192x192 xc:blue -gravity center -pointsize 80 -fill white -annotate 0 "TS" android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png |
|||
|
|||
# Copy to round versions |
|||
cp android/app/src/main/res/mipmap-mdpi/ic_launcher.png android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png |
|||
cp android/app/src/main/res/mipmap-hdpi/ic_launcher.png android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png |
|||
cp android/app/src/main/res/mipmap-xhdpi/ic_launcher.png android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png |
|||
cp android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png |
|||
cp android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png |
@ -0,0 +1,196 @@ |
|||
/** * Data Export Section Component * * Provides UI and functionality for |
|||
exporting user data and backing up identifier seeds. * Includes buttons for seed |
|||
backup and database export, with platform-specific download instructions. * * |
|||
@component * @displayName DataExportSection * @example * ```vue * |
|||
<DataExportSection :active-did="currentDid" /> |
|||
* ``` */ |
|||
|
|||
<template> |
|||
<div |
|||
id="sectionDataExport" |
|||
class="bg-slate-100 rounded-md overflow-hidden px-4 py-4 mt-8 mb-8" |
|||
> |
|||
<div class="mb-2 font-bold">Data Export</div> |
|||
<router-link |
|||
v-if="activeDid" |
|||
:to="{ name: 'seed-backup' }" |
|||
class="block w-full text-center 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-1.5 py-2 rounded-md mb-2 mt-2" |
|||
> |
|||
Backup Identifier Seed |
|||
</router-link> |
|||
|
|||
<button |
|||
:class="computedStartDownloadLinkClassNames()" |
|||
class="block w-full text-center 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-1.5 py-2 rounded-md" |
|||
@click="exportDatabase()" |
|||
> |
|||
Download Settings & Contacts |
|||
<br /> |
|||
(excluding Identifier Data) |
|||
</button> |
|||
<a |
|||
ref="downloadLink" |
|||
:class="computedDownloadLinkClassNames()" |
|||
class="block w-full text-center text-md bg-gradient-to-b from-green-500 to-green-800 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md mb-6" |
|||
> |
|||
If no download happened yet, click again here to download now. |
|||
</a> |
|||
<div v-if="platformCapabilities.needsFileHandlingInstructions" class="mt-4"> |
|||
<p> |
|||
After the download, you can save the file in your preferred storage |
|||
location. |
|||
</p> |
|||
<ul> |
|||
<li |
|||
v-if="platformCapabilities.isIOS" |
|||
class="list-disc list-outside ml-4" |
|||
> |
|||
On iOS: You will be prompted to choose a location to save your backup |
|||
file. |
|||
</li> |
|||
<li |
|||
v-if="platformCapabilities.isMobile && !platformCapabilities.isIOS" |
|||
class="list-disc list-outside ml-4" |
|||
> |
|||
On Android: You will be prompted to choose a location to save your |
|||
backup file. |
|||
</li> |
|||
</ul> |
|||
</div> |
|||
</div> |
|||
</template> |
|||
|
|||
<script lang="ts"> |
|||
import { Component, Prop, Vue } from "vue-facing-decorator"; |
|||
import { NotificationIface } from "../constants/app"; |
|||
import { db } from "../db/index"; |
|||
import { logger } from "../utils/logger"; |
|||
import { PlatformServiceFactory } from "../services/PlatformServiceFactory"; |
|||
import { |
|||
PlatformService, |
|||
PlatformCapabilities, |
|||
} from "../services/PlatformService"; |
|||
|
|||
/** |
|||
* @vue-component |
|||
* Data Export Section Component |
|||
* Handles database export and seed backup functionality with platform-specific behavior |
|||
*/ |
|||
@Component |
|||
export default class DataExportSection extends Vue { |
|||
/** |
|||
* Notification function injected by Vue |
|||
* Used to show success/error messages to the user |
|||
*/ |
|||
$notify!: (notification: NotificationIface, timeout?: number) => void; |
|||
|
|||
/** |
|||
* Active DID (Decentralized Identifier) of the user |
|||
* Controls visibility of seed backup option |
|||
* @required |
|||
*/ |
|||
@Prop({ required: true }) readonly activeDid!: string; |
|||
|
|||
/** |
|||
* URL for the database export download |
|||
* Created and revoked dynamically during export process |
|||
* Only used in web platform |
|||
*/ |
|||
downloadUrl = ""; |
|||
|
|||
/** |
|||
* Platform service instance for platform-specific operations |
|||
*/ |
|||
private platformService: PlatformService = |
|||
PlatformServiceFactory.getInstance(); |
|||
|
|||
/** |
|||
* Platform capabilities for the current platform |
|||
*/ |
|||
private get platformCapabilities(): PlatformCapabilities { |
|||
return this.platformService.getCapabilities(); |
|||
} |
|||
|
|||
/** |
|||
* Lifecycle hook to clean up resources |
|||
* Revokes object URL when component is unmounted (web platform only) |
|||
*/ |
|||
beforeUnmount() { |
|||
if (this.downloadUrl && this.platformCapabilities.hasFileDownload) { |
|||
URL.revokeObjectURL(this.downloadUrl); |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Exports the database to a JSON file |
|||
* Uses platform-specific methods for saving the exported data |
|||
* Shows success/error notifications to user |
|||
* |
|||
* @throws {Error} If export fails |
|||
* @emits {Notification} Success or error notification |
|||
*/ |
|||
public async exportDatabase() { |
|||
try { |
|||
const blob = await db.export({ prettyJson: true }); |
|||
const fileName = `${db.name}-backup.json`; |
|||
|
|||
if (this.platformCapabilities.hasFileDownload) { |
|||
// Web platform: Use download link |
|||
this.downloadUrl = URL.createObjectURL(blob); |
|||
const downloadAnchor = this.$refs.downloadLink as HTMLAnchorElement; |
|||
downloadAnchor.href = this.downloadUrl; |
|||
downloadAnchor.download = fileName; |
|||
downloadAnchor.click(); |
|||
setTimeout(() => URL.revokeObjectURL(this.downloadUrl), 1000); |
|||
} else if (this.platformCapabilities.hasFileSystem) { |
|||
// Native platform: Write to app directory |
|||
const content = await blob.text(); |
|||
await this.platformService.writeAndShareFile(fileName, content); |
|||
} |
|||
|
|||
this.$notify( |
|||
{ |
|||
group: "alert", |
|||
type: "success", |
|||
title: "Export Successful", |
|||
text: this.platformCapabilities.hasFileDownload |
|||
? "See your downloads directory for the backup. It is in the Dexie format." |
|||
: "Please choose a location to save your backup file.", |
|||
}, |
|||
-1, |
|||
); |
|||
} catch (error) { |
|||
logger.error("Export Error:", error); |
|||
this.$notify( |
|||
{ |
|||
group: "alert", |
|||
type: "danger", |
|||
title: "Export Error", |
|||
text: "There was an error exporting the data.", |
|||
}, |
|||
3000, |
|||
); |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Computes class names for the initial download button |
|||
* @returns Object with 'hidden' class when download is in progress (web platform only) |
|||
*/ |
|||
public computedStartDownloadLinkClassNames() { |
|||
return { |
|||
hidden: this.downloadUrl && this.platformCapabilities.hasFileDownload, |
|||
}; |
|||
} |
|||
|
|||
/** |
|||
* Computes class names for the secondary download link |
|||
* @returns Object with 'hidden' class when no download is available or not on web platform |
|||
*/ |
|||
public computedDownloadLinkClassNames() { |
|||
return { |
|||
hidden: !this.downloadUrl || !this.platformCapabilities.hasFileDownload, |
|||
}; |
|||
} |
|||
} |
|||
</script> |
@ -0,0 +1,158 @@ |
|||
<!-- QRScannerDialog.vue --> |
|||
<template> |
|||
<div |
|||
v-if="visible && !isNativePlatform" |
|||
class="dialog-overlay z-[60] fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center" |
|||
> |
|||
<div |
|||
class="dialog relative bg-white rounded-lg shadow-xl max-w-lg w-full mx-4" |
|||
> |
|||
<!-- Header --> |
|||
<div class="p-4 border-b border-gray-200"> |
|||
<h3 class="text-lg font-medium text-gray-900">Scan QR Code</h3> |
|||
<button |
|||
class="absolute top-4 right-4 text-gray-400 hover:text-gray-500" |
|||
aria-label="Close dialog" |
|||
@click="close" |
|||
> |
|||
<svg |
|||
class="h-6 w-6" |
|||
fill="none" |
|||
viewBox="0 0 24 24" |
|||
stroke="currentColor" |
|||
> |
|||
<path |
|||
stroke-linecap="round" |
|||
stroke-linejoin="round" |
|||
stroke-width="2" |
|||
d="M6 18L18 6M6 6l12 12" |
|||
/> |
|||
</svg> |
|||
</button> |
|||
</div> |
|||
|
|||
<!-- Scanner --> |
|||
<div class="p-4"> |
|||
<div v-if="useQRReader && !isNativePlatform" class="relative aspect-square"> |
|||
<qrcode-stream |
|||
:camera="options?.camera === 'front' ? 'user' : 'environment'" |
|||
@decode="onDecode" |
|||
@init="onInit" |
|||
/> |
|||
<div |
|||
class="absolute inset-0 border-2 border-blue-500 opacity-50 pointer-events-none" |
|||
></div> |
|||
</div> |
|||
<div v-else class="text-center py-8"> |
|||
<p class="text-gray-500"> |
|||
{{ isNativePlatform ? 'Using native camera scanner...' : 'QR code scanning is not supported in this browser.' }} |
|||
</p> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- Footer --> |
|||
<div class="p-4 border-t border-gray-200"> |
|||
<p v-if="error" class="text-red-500 text-sm mb-4">{{ error }}</p> |
|||
<div class="flex justify-end"> |
|||
<button |
|||
class="px-4 py-2 bg-gray-100 text-gray-700 rounded-md hover:bg-gray-200" |
|||
@click="close" |
|||
> |
|||
Cancel |
|||
</button> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</template> |
|||
|
|||
<script lang="ts"> |
|||
import { Component, Prop, Vue } from "vue-facing-decorator"; |
|||
import { QrcodeStream } from "vue-qrcode-reader"; |
|||
import { QRScannerOptions } from "@/services/QRScanner/types"; |
|||
import { logger } from "@/utils/logger"; |
|||
import { Capacitor } from "@capacitor/core"; |
|||
|
|||
@Component({ |
|||
components: { |
|||
QrcodeStream, |
|||
}, |
|||
}) |
|||
export default class QRScannerDialog extends Vue { |
|||
@Prop({ type: Function, required: true }) onScan!: (result: string) => void; |
|||
@Prop({ type: Function }) onError?: (error: Error) => void; |
|||
@Prop({ type: Object }) options?: QRScannerOptions; |
|||
|
|||
visible = true; |
|||
error: string | null = null; |
|||
useQRReader = __USE_QR_READER__; |
|||
isNativePlatform = Capacitor.isNativePlatform() || __IS_MOBILE__ || Capacitor.getPlatform() === 'android' || Capacitor.getPlatform() === 'ios'; |
|||
|
|||
created() { |
|||
logger.log('QRScannerDialog platform detection:', { |
|||
capacitorNative: Capacitor.isNativePlatform(), |
|||
isMobile: __IS_MOBILE__, |
|||
platform: Capacitor.getPlatform(), |
|||
useQRReader: this.useQRReader, |
|||
isNativePlatform: this.isNativePlatform |
|||
}); |
|||
|
|||
// If on native platform, close immediately and don't initialize web scanner |
|||
if (this.isNativePlatform) { |
|||
logger.log('Closing QR dialog on native platform'); |
|||
this.$nextTick(() => this.close()); |
|||
} |
|||
} |
|||
|
|||
async onInit(promise: Promise<void>): Promise<void> { |
|||
// Don't initialize on mobile platforms |
|||
if (this.isNativePlatform) { |
|||
logger.log('Skipping web scanner initialization on native platform'); |
|||
return; |
|||
} |
|||
|
|||
try { |
|||
await promise; |
|||
this.error = null; |
|||
} catch (error) { |
|||
this.error = error instanceof Error ? error.message : String(error); |
|||
if (this.onError) { |
|||
this.onError(error instanceof Error ? error : new Error(String(error))); |
|||
} |
|||
logger.error("Error initializing QR scanner:", error); |
|||
} |
|||
} |
|||
|
|||
onDecode(result: string): void { |
|||
try { |
|||
this.onScan(result); |
|||
this.close(); |
|||
} catch (error) { |
|||
this.error = error instanceof Error ? error.message : String(error); |
|||
if (this.onError) { |
|||
this.onError(error instanceof Error ? error : new Error(String(error))); |
|||
} |
|||
logger.error("Error handling QR scan result:", error); |
|||
} |
|||
} |
|||
|
|||
async close(): Promise<void> { |
|||
this.visible = false; |
|||
await this.$nextTick(); |
|||
if (this.$el && this.$el.parentNode) { |
|||
this.$el.parentNode.removeChild(this.$el); |
|||
} |
|||
} |
|||
} |
|||
</script> |
|||
|
|||
<style scoped> |
|||
.dialog-overlay { |
|||
backdrop-filter: blur(4px); |
|||
} |
|||
|
|||
.qrcode-stream { |
|||
width: 100%; |
|||
height: 100%; |
|||
} |
|||
</style> |
@ -0,0 +1,4 @@ |
|||
/// <reference types="vite/client" />
|
|||
|
|||
declare const __USE_QR_READER__: boolean; |
|||
declare const __IS_MOBILE__: boolean; |
@ -0,0 +1,101 @@ |
|||
/** |
|||
* Represents the result of an image capture or selection operation. |
|||
* Contains both the image data as a Blob and the associated filename. |
|||
*/ |
|||
export interface ImageResult { |
|||
/** The image data as a Blob object */ |
|||
blob: Blob; |
|||
/** The filename associated with the image */ |
|||
fileName: string; |
|||
} |
|||
|
|||
/** |
|||
* Platform capabilities interface defining what features are available |
|||
* on the current platform implementation |
|||
*/ |
|||
export interface PlatformCapabilities { |
|||
/** Whether the platform supports native file system access */ |
|||
hasFileSystem: boolean; |
|||
/** Whether the platform supports native camera access */ |
|||
hasCamera: boolean; |
|||
/** Whether the platform is a mobile device */ |
|||
isMobile: boolean; |
|||
/** Whether the platform is iOS specifically */ |
|||
isIOS: boolean; |
|||
/** Whether the platform supports native file download */ |
|||
hasFileDownload: boolean; |
|||
/** Whether the platform requires special file handling instructions */ |
|||
needsFileHandlingInstructions: boolean; |
|||
} |
|||
|
|||
/** |
|||
* Platform-agnostic interface for handling platform-specific operations. |
|||
* Provides a common API for file system operations, camera interactions, |
|||
* and platform detection across different platforms (web, mobile, desktop). |
|||
*/ |
|||
export interface PlatformService { |
|||
// Platform capabilities
|
|||
/** |
|||
* Gets the current platform's capabilities |
|||
* @returns Object describing what features are available on this platform |
|||
*/ |
|||
getCapabilities(): PlatformCapabilities; |
|||
|
|||
// File system operations
|
|||
/** |
|||
* Reads the contents of a file at the specified path. |
|||
* @param path - The path to the file to read |
|||
* @returns Promise resolving to the file contents as a string |
|||
*/ |
|||
readFile(path: string): Promise<string>; |
|||
|
|||
/** |
|||
* Writes content to a file at the specified path. |
|||
* @param path - The path where the file should be written |
|||
* @param content - The content to write to the file |
|||
* @returns Promise that resolves when the write is complete |
|||
*/ |
|||
writeFile(path: string, content: string): Promise<void>; |
|||
|
|||
/** |
|||
* Writes content to a file at the specified path and shares it. |
|||
* @param fileName - The filename of the file to write |
|||
* @param content - The content to write to the file |
|||
* @returns Promise that resolves when the write is complete |
|||
*/ |
|||
writeAndShareFile(fileName: string, content: string): Promise<void>; |
|||
|
|||
/** |
|||
* Deletes a file at the specified path. |
|||
* @param path - The path to the file to delete |
|||
* @returns Promise that resolves when the deletion is complete |
|||
*/ |
|||
deleteFile(path: string): Promise<void>; |
|||
|
|||
/** |
|||
* Lists all files in the specified directory. |
|||
* @param directory - The directory path to list |
|||
* @returns Promise resolving to an array of filenames |
|||
*/ |
|||
listFiles(directory: string): Promise<string[]>; |
|||
|
|||
// Camera operations
|
|||
/** |
|||
* Activates the device camera to take a picture. |
|||
* @returns Promise resolving to the captured image result |
|||
*/ |
|||
takePicture(): Promise<ImageResult>; |
|||
|
|||
/** |
|||
* Opens a file picker to select an existing image. |
|||
* @returns Promise resolving to the selected image result |
|||
*/ |
|||
pickImage(): Promise<ImageResult>; |
|||
|
|||
/** |
|||
* Handles deep link URLs for the application. |
|||
* @param url - The deep link URL to handle |
|||
* @returns Promise that resolves when the deep link has been handled |
|||
*/ |
|||
handleDeepLink(url: string): Promise<void>; |
|||
} |
@ -0,0 +1,58 @@ |
|||
import { PlatformService } from "./PlatformService"; |
|||
import { WebPlatformService } from "./platforms/WebPlatformService"; |
|||
import { CapacitorPlatformService } from "./platforms/CapacitorPlatformService"; |
|||
import { ElectronPlatformService } from "./platforms/ElectronPlatformService"; |
|||
import { PyWebViewPlatformService } from "./platforms/PyWebViewPlatformService"; |
|||
|
|||
/** |
|||
* Factory class for creating platform-specific service implementations. |
|||
* Implements the Singleton pattern to ensure only one instance of PlatformService exists. |
|||
* |
|||
* The factory determines which platform implementation to use based on the VITE_PLATFORM |
|||
* environment variable. Supported platforms are: |
|||
* - capacitor: Mobile platform using Capacitor |
|||
* - electron: Desktop platform using Electron |
|||
* - pywebview: Python WebView implementation |
|||
* - web: Default web platform (fallback) |
|||
* |
|||
* @example |
|||
* ```typescript
|
|||
* const platformService = PlatformServiceFactory.getInstance(); |
|||
* await platformService.takePicture(); |
|||
* ``` |
|||
*/ |
|||
export class PlatformServiceFactory { |
|||
private static instance: PlatformService | null = null; |
|||
|
|||
/** |
|||
* Gets or creates the singleton instance of PlatformService. |
|||
* Creates the appropriate platform-specific implementation based on environment. |
|||
* |
|||
* @returns {PlatformService} The singleton instance of PlatformService |
|||
*/ |
|||
public static getInstance(): PlatformService { |
|||
if (PlatformServiceFactory.instance) { |
|||
return PlatformServiceFactory.instance; |
|||
} |
|||
|
|||
const platform = process.env.VITE_PLATFORM || "web"; |
|||
|
|||
switch (platform) { |
|||
case "capacitor": |
|||
PlatformServiceFactory.instance = new CapacitorPlatformService(); |
|||
break; |
|||
case "electron": |
|||
PlatformServiceFactory.instance = new ElectronPlatformService(); |
|||
break; |
|||
case "pywebview": |
|||
PlatformServiceFactory.instance = new PyWebViewPlatformService(); |
|||
break; |
|||
case "web": |
|||
default: |
|||
PlatformServiceFactory.instance = new WebPlatformService(); |
|||
break; |
|||
} |
|||
|
|||
return PlatformServiceFactory.instance; |
|||
} |
|||
} |
@ -0,0 +1,118 @@ |
|||
import { |
|||
BarcodeScanner, |
|||
BarcodeFormat, |
|||
StartScanOptions, |
|||
LensFacing, |
|||
} from "@capacitor-mlkit/barcode-scanning"; |
|||
import { QRScannerService, ScanListener, QRScannerOptions } from "./types"; |
|||
import { logger } from "@/utils/logger"; |
|||
|
|||
export class CapacitorQRScanner implements QRScannerService { |
|||
private scanListener: ScanListener | null = null; |
|||
private isScanning = false; |
|||
|
|||
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 { |
|||
// First check if we already have permissions
|
|||
if (await this.checkPermissions()) { |
|||
return true; |
|||
} |
|||
|
|||
// Request permissions if we don't have them
|
|||
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; |
|||
} |
|||
|
|||
try { |
|||
// Ensure we have permissions before starting
|
|||
logger.log('Checking camera permissions...'); |
|||
if (!(await this.checkPermissions())) { |
|||
logger.log('Requesting camera permissions...'); |
|||
const granted = await this.requestPermissions(); |
|||
if (!granted) { |
|||
throw new Error("Camera permission denied"); |
|||
} |
|||
} |
|||
|
|||
// Check if scanning is supported
|
|||
logger.log('Checking scanner support...'); |
|||
if (!(await this.isSupported())) { |
|||
throw new Error("QR scanning not supported on this device"); |
|||
} |
|||
|
|||
logger.log('Starting MLKit scanner...'); |
|||
this.isScanning = true; |
|||
|
|||
const scanOptions: StartScanOptions = { |
|||
formats: [BarcodeFormat.QrCode], |
|||
lensFacing: |
|||
options?.camera === "front" ? LensFacing.Front : LensFacing.Back, |
|||
}; |
|||
|
|||
logger.log('Scanner options:', scanOptions); |
|||
const result = await BarcodeScanner.scan(scanOptions); |
|||
logger.log('Scan result:', result); |
|||
|
|||
if (result.barcodes.length > 0) { |
|||
this.scanListener?.onScan(result.barcodes[0].rawValue); |
|||
} |
|||
} catch (error) { |
|||
logger.error("Error during QR scan:", error); |
|||
this.scanListener?.onError?.(error as Error); |
|||
} finally { |
|||
this.isScanning = false; |
|||
} |
|||
} |
|||
|
|||
async stopScan(): Promise<void> { |
|||
if (!this.isScanning) { |
|||
return; |
|||
} |
|||
|
|||
try { |
|||
await BarcodeScanner.stopScan(); |
|||
this.isScanning = false; |
|||
} catch (error) { |
|||
logger.error("Error stopping QR scan:", error); |
|||
this.scanListener?.onError?.(error as Error); |
|||
} |
|||
} |
|||
|
|||
addListener(listener: ScanListener): void { |
|||
this.scanListener = listener; |
|||
} |
|||
|
|||
async cleanup(): Promise<void> { |
|||
await this.stopScan(); |
|||
this.scanListener = null; |
|||
} |
|||
} |
@ -0,0 +1,63 @@ |
|||
import { Capacitor } from "@capacitor/core"; |
|||
import { QRScannerService } from "./types"; |
|||
import { CapacitorQRScanner } from "./CapacitorQRScanner"; |
|||
import { WebDialogQRScanner } from "./WebDialogQRScanner"; |
|||
import { logger } from "@/utils/logger"; |
|||
|
|||
/** |
|||
* Factory class for creating QR scanner instances based on platform |
|||
*/ |
|||
export class QRScannerFactory { |
|||
private static instance: QRScannerService | null = null; |
|||
|
|||
private static isNativePlatform(): boolean { |
|||
const capacitorNative = Capacitor.isNativePlatform(); |
|||
const isMobile = __IS_MOBILE__; |
|||
const platform = Capacitor.getPlatform(); |
|||
|
|||
logger.log('Platform detection:', { |
|||
capacitorNative, |
|||
isMobile, |
|||
platform, |
|||
userAgent: navigator.userAgent |
|||
}); |
|||
|
|||
// Force native scanner on Android/iOS
|
|||
if (platform === 'android' || platform === 'ios') { |
|||
return true; |
|||
} |
|||
|
|||
return capacitorNative || isMobile; |
|||
} |
|||
|
|||
/** |
|||
* Get a QR scanner instance appropriate for the current platform |
|||
*/ |
|||
static getInstance(): QRScannerService { |
|||
if (!this.instance) { |
|||
const isNative = this.isNativePlatform(); |
|||
logger.log(`Creating QR scanner for platform: ${isNative ? 'native' : 'web'}`); |
|||
|
|||
if (isNative) { |
|||
logger.log('Using native MLKit scanner'); |
|||
this.instance = new CapacitorQRScanner(); |
|||
} else if (__USE_QR_READER__) { |
|||
logger.log('Using web QR scanner'); |
|||
this.instance = new WebDialogQRScanner(); |
|||
} else { |
|||
throw new Error("No QR scanner implementation available for this platform"); |
|||
} |
|||
} |
|||
return this.instance!; // We know it's not null here
|
|||
} |
|||
|
|||
/** |
|||
* Clean up the current scanner instance |
|||
*/ |
|||
static async cleanup(): Promise<void> { |
|||
if (this.instance) { |
|||
await this.instance.cleanup(); |
|||
this.instance = null; |
|||
} |
|||
} |
|||
} |
@ -0,0 +1,108 @@ |
|||
import { createApp, App } from "vue"; |
|||
import { QRScannerService, ScanListener, QRScannerOptions } from "./types"; |
|||
import QRScannerDialog from "@/components/QRScanner/QRScannerDialog.vue"; |
|||
import { logger } from "@/utils/logger"; |
|||
|
|||
export class WebDialogQRScanner implements QRScannerService { |
|||
private dialogInstance: App | null = null; |
|||
private dialogComponent: InstanceType<typeof QRScannerDialog> | null = null; |
|||
private scanListener: ScanListener | null = null; |
|||
private isScanning = false; |
|||
|
|||
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: true }); |
|||
stream.getTracks().forEach((track) => track.stop()); |
|||
return true; |
|||
} catch (error) { |
|||
logger.error("Error requesting camera permissions:", error); |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
async isSupported(): Promise<boolean> { |
|||
return !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia); |
|||
} |
|||
|
|||
async startScan(): Promise<void> { |
|||
if (this.isScanning) { |
|||
return; |
|||
} |
|||
|
|||
try { |
|||
this.isScanning = true; |
|||
|
|||
// Create and mount dialog component
|
|||
const container = document.createElement("div"); |
|||
document.body.appendChild(container); |
|||
|
|||
this.dialogInstance = createApp(QRScannerDialog, { |
|||
onScan: (result: string) => { |
|||
if (this.scanListener) { |
|||
this.scanListener.onScan(result); |
|||
} |
|||
}, |
|||
onError: (error: Error) => { |
|||
if (this.scanListener?.onError) { |
|||
this.scanListener.onError(error); |
|||
} |
|||
}, |
|||
options: this.options, |
|||
}); |
|||
|
|||
this.dialogComponent = this.dialogInstance.mount(container).$refs |
|||
.dialog as InstanceType<typeof QRScannerDialog>; |
|||
} catch (error) { |
|||
this.isScanning = false; |
|||
if (this.scanListener?.onError) { |
|||
this.scanListener.onError( |
|||
error instanceof Error ? error : new Error(String(error)), |
|||
); |
|||
} |
|||
logger.error("Error starting scan:", error); |
|||
} |
|||
} |
|||
|
|||
async stopScan(): Promise<void> { |
|||
if (!this.isScanning) { |
|||
return; |
|||
} |
|||
|
|||
try { |
|||
if (this.dialogComponent) { |
|||
await this.dialogComponent.close(); |
|||
} |
|||
if (this.dialogInstance) { |
|||
this.dialogInstance.unmount(); |
|||
} |
|||
this.isScanning = false; |
|||
} catch (error) { |
|||
logger.error("Error stopping scan:", error); |
|||
} |
|||
} |
|||
|
|||
addListener(listener: ScanListener): void { |
|||
this.scanListener = listener; |
|||
} |
|||
|
|||
async cleanup(): Promise<void> { |
|||
await this.stopScan(); |
|||
this.dialogComponent = null; |
|||
this.dialogInstance = null; |
|||
this.scanListener = null; |
|||
} |
|||
} |
@ -0,0 +1,49 @@ |
|||
// QR Scanner Service Types
|
|||
|
|||
/** |
|||
* Listener interface for QR code scan events |
|||
*/ |
|||
export interface ScanListener { |
|||
/** Called when a QR code is successfully scanned */ |
|||
onScan: (result: string) => void; |
|||
/** Called when an error occurs during scanning */ |
|||
onError?: (error: Error) => void; |
|||
} |
|||
|
|||
/** |
|||
* Options for configuring the QR scanner |
|||
*/ |
|||
export interface QRScannerOptions { |
|||
/** Camera to use ('front' or 'back') */ |
|||
camera?: "front" | "back"; |
|||
/** Whether to show a preview of the camera feed */ |
|||
showPreview?: boolean; |
|||
/** Whether to play a sound on successful scan */ |
|||
playSound?: boolean; |
|||
} |
|||
|
|||
/** |
|||
* Interface for QR scanner service implementations |
|||
*/ |
|||
export interface QRScannerService { |
|||
/** Check if camera permissions are granted */ |
|||
checkPermissions(): Promise<boolean>; |
|||
|
|||
/** Request camera permissions from the user */ |
|||
requestPermissions(): Promise<boolean>; |
|||
|
|||
/** Check if QR scanning is supported on this device */ |
|||
isSupported(): Promise<boolean>; |
|||
|
|||
/** Start scanning for QR codes */ |
|||
startScan(options?: QRScannerOptions): Promise<void>; |
|||
|
|||
/** Stop scanning for QR codes */ |
|||
stopScan(): Promise<void>; |
|||
|
|||
/** Add a listener for scan events */ |
|||
addListener(listener: ScanListener): void; |
|||
|
|||
/** Clean up scanner resources */ |
|||
cleanup(): Promise<void>; |
|||
} |
@ -0,0 +1,473 @@ |
|||
import { |
|||
ImageResult, |
|||
PlatformService, |
|||
PlatformCapabilities, |
|||
} from "../PlatformService"; |
|||
import { Filesystem, Directory, Encoding } from "@capacitor/filesystem"; |
|||
import { Camera, CameraResultType, CameraSource } from "@capacitor/camera"; |
|||
import { Share } from "@capacitor/share"; |
|||
import { logger } from "../../utils/logger"; |
|||
|
|||
/** |
|||
* Platform service implementation for Capacitor (mobile) platform. |
|||
* Provides native mobile functionality through Capacitor plugins for: |
|||
* - File system operations |
|||
* - Camera and image picker |
|||
* - Platform-specific features |
|||
*/ |
|||
export class CapacitorPlatformService implements PlatformService { |
|||
/** |
|||
* Gets the capabilities of the Capacitor platform |
|||
* @returns Platform capabilities object |
|||
*/ |
|||
getCapabilities(): PlatformCapabilities { |
|||
return { |
|||
hasFileSystem: true, |
|||
hasCamera: true, |
|||
isMobile: true, |
|||
isIOS: /iPad|iPhone|iPod/.test(navigator.userAgent), |
|||
hasFileDownload: false, |
|||
needsFileHandlingInstructions: true, |
|||
}; |
|||
} |
|||
|
|||
/** |
|||
* Checks and requests storage permissions if needed |
|||
* @returns Promise that resolves when permissions are granted |
|||
* @throws Error if permissions are denied |
|||
*/ |
|||
private async checkStoragePermissions(): Promise<void> { |
|||
try { |
|||
const logData = { |
|||
platform: this.getCapabilities().isIOS ? "iOS" : "Android", |
|||
timestamp: new Date().toISOString(), |
|||
}; |
|||
logger.log( |
|||
"Checking storage permissions", |
|||
JSON.stringify(logData, null, 2), |
|||
); |
|||
|
|||
if (this.getCapabilities().isIOS) { |
|||
// iOS uses different permission model
|
|||
return; |
|||
} |
|||
|
|||
// Try to access a test directory to check permissions
|
|||
try { |
|||
await Filesystem.stat({ |
|||
path: "/storage/emulated/0/Download", |
|||
directory: Directory.Documents, |
|||
}); |
|||
logger.log( |
|||
"Storage permissions already granted", |
|||
JSON.stringify({ timestamp: new Date().toISOString() }, null, 2), |
|||
); |
|||
return; |
|||
} catch (error: unknown) { |
|||
const err = error as Error; |
|||
const errorLogData = { |
|||
error: { |
|||
message: err.message, |
|||
name: err.name, |
|||
stack: err.stack, |
|||
}, |
|||
timestamp: new Date().toISOString(), |
|||
}; |
|||
|
|||
// "File does not exist" is expected and not a permission error
|
|||
if (err.message === "File does not exist") { |
|||
logger.log( |
|||
"Directory does not exist (expected), proceeding with write", |
|||
JSON.stringify(errorLogData, null, 2), |
|||
); |
|||
return; |
|||
} |
|||
|
|||
// Check for actual permission errors
|
|||
if ( |
|||
err.message.includes("permission") || |
|||
err.message.includes("access") |
|||
) { |
|||
logger.log( |
|||
"Permission check failed, requesting permissions", |
|||
JSON.stringify(errorLogData, null, 2), |
|||
); |
|||
|
|||
// The Filesystem plugin will automatically request permissions when needed
|
|||
// We just need to try the operation again
|
|||
try { |
|||
await Filesystem.stat({ |
|||
path: "/storage/emulated/0/Download", |
|||
directory: Directory.Documents, |
|||
}); |
|||
logger.log( |
|||
"Storage permissions granted after request", |
|||
JSON.stringify({ timestamp: new Date().toISOString() }, null, 2), |
|||
); |
|||
return; |
|||
} catch (retryError: unknown) { |
|||
const retryErr = retryError as Error; |
|||
throw new Error( |
|||
`Failed to obtain storage permissions: ${retryErr.message}`, |
|||
); |
|||
} |
|||
} |
|||
|
|||
// For any other error, log it but don't treat as permission error
|
|||
logger.log( |
|||
"Unexpected error during permission check", |
|||
JSON.stringify(errorLogData, null, 2), |
|||
); |
|||
return; |
|||
} |
|||
} catch (error: unknown) { |
|||
const err = error as Error; |
|||
const errorLogData = { |
|||
error: { |
|||
message: err.message, |
|||
name: err.name, |
|||
stack: err.stack, |
|||
}, |
|||
timestamp: new Date().toISOString(), |
|||
}; |
|||
logger.error( |
|||
"Error checking/requesting permissions", |
|||
JSON.stringify(errorLogData, null, 2), |
|||
); |
|||
throw new Error(`Failed to obtain storage permissions: ${err.message}`); |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Reads a file from the app's data directory. |
|||
* @param path - Relative path to the file in the app's data directory |
|||
* @returns Promise resolving to the file contents as string |
|||
* @throws Error if file cannot be read or doesn't exist |
|||
*/ |
|||
async readFile(path: string): Promise<string> { |
|||
const file = await Filesystem.readFile({ |
|||
path, |
|||
directory: Directory.Data, |
|||
}); |
|||
if (file.data instanceof Blob) { |
|||
return await file.data.text(); |
|||
} |
|||
return file.data; |
|||
} |
|||
|
|||
/** |
|||
* Writes content to a file in the app's safe storage and offers sharing. |
|||
* |
|||
* Platform-specific behavior: |
|||
* - Saves to app's Documents directory |
|||
* - Offers sharing functionality to move file elsewhere |
|||
* |
|||
* The method handles: |
|||
* 1. Writing to app-safe storage |
|||
* 2. Sharing the file with user's preferred app |
|||
* 3. Error handling and logging |
|||
* |
|||
* @param fileName - The name of the file to create (e.g. "backup.json") |
|||
* @param content - The content to write to the file |
|||
* |
|||
* @throws Error if: |
|||
* - File writing fails |
|||
* - Sharing fails |
|||
* |
|||
* @example |
|||
* ```typescript
|
|||
* // Save and share a JSON file
|
|||
* await platformService.writeFile( |
|||
* "backup.json", |
|||
* JSON.stringify(data) |
|||
* ); |
|||
* ``` |
|||
*/ |
|||
async writeFile(fileName: string, content: string): Promise<void> { |
|||
try { |
|||
const logData = { |
|||
targetFileName: fileName, |
|||
contentLength: content.length, |
|||
platform: this.getCapabilities().isIOS ? "iOS" : "Android", |
|||
timestamp: new Date().toISOString(), |
|||
}; |
|||
logger.log( |
|||
"Starting writeFile operation", |
|||
JSON.stringify(logData, null, 2), |
|||
); |
|||
|
|||
// For Android, we need to handle content URIs differently
|
|||
if (this.getCapabilities().isIOS) { |
|||
// Write to app's Documents directory for iOS
|
|||
const writeResult = await Filesystem.writeFile({ |
|||
path: fileName, |
|||
data: content, |
|||
directory: Directory.Data, |
|||
encoding: Encoding.UTF8, |
|||
}); |
|||
|
|||
const writeSuccessLogData = { |
|||
path: writeResult.uri, |
|||
timestamp: new Date().toISOString(), |
|||
}; |
|||
logger.log( |
|||
"File write successful", |
|||
JSON.stringify(writeSuccessLogData, null, 2), |
|||
); |
|||
|
|||
// Offer to share the file
|
|||
try { |
|||
await Share.share({ |
|||
title: "TimeSafari Backup", |
|||
text: "Here is your TimeSafari backup file.", |
|||
url: writeResult.uri, |
|||
dialogTitle: "Share your backup", |
|||
}); |
|||
|
|||
logger.log( |
|||
"Share dialog shown", |
|||
JSON.stringify({ timestamp: new Date().toISOString() }, null, 2), |
|||
); |
|||
} catch (shareError) { |
|||
// Log share error but don't fail the operation
|
|||
logger.error( |
|||
"Share dialog failed", |
|||
JSON.stringify( |
|||
{ |
|||
error: shareError, |
|||
timestamp: new Date().toISOString(), |
|||
}, |
|||
null, |
|||
2, |
|||
), |
|||
); |
|||
} |
|||
} else { |
|||
// For Android, first write to app's Documents directory
|
|||
const writeResult = await Filesystem.writeFile({ |
|||
path: fileName, |
|||
data: content, |
|||
directory: Directory.Data, |
|||
encoding: Encoding.UTF8, |
|||
}); |
|||
|
|||
const writeSuccessLogData = { |
|||
path: writeResult.uri, |
|||
timestamp: new Date().toISOString(), |
|||
}; |
|||
logger.log( |
|||
"File write successful to app storage", |
|||
JSON.stringify(writeSuccessLogData, null, 2), |
|||
); |
|||
|
|||
// Then share the file to let user choose where to save it
|
|||
try { |
|||
await Share.share({ |
|||
title: "TimeSafari Backup", |
|||
text: "Here is your TimeSafari backup file.", |
|||
url: writeResult.uri, |
|||
dialogTitle: "Save your backup", |
|||
}); |
|||
|
|||
logger.log( |
|||
"Share dialog shown for Android", |
|||
JSON.stringify({ timestamp: new Date().toISOString() }, null, 2), |
|||
); |
|||
} catch (shareError) { |
|||
// Log share error but don't fail the operation
|
|||
logger.error( |
|||
"Share dialog failed for Android", |
|||
JSON.stringify( |
|||
{ |
|||
error: shareError, |
|||
timestamp: new Date().toISOString(), |
|||
}, |
|||
null, |
|||
2, |
|||
), |
|||
); |
|||
} |
|||
} |
|||
} catch (error: unknown) { |
|||
const err = error as Error; |
|||
const finalErrorLogData = { |
|||
error: { |
|||
message: err.message, |
|||
name: err.name, |
|||
stack: err.stack, |
|||
}, |
|||
timestamp: new Date().toISOString(), |
|||
}; |
|||
logger.error( |
|||
"Error in writeFile operation:", |
|||
JSON.stringify(finalErrorLogData, null, 2), |
|||
); |
|||
throw new Error(`Failed to save file: ${err.message}`); |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Writes content to a file in the device's app-private storage. |
|||
* Then shares the file using the system share dialog. |
|||
* |
|||
* Works on both Android and iOS without needing external storage permissions. |
|||
* |
|||
* @param fileName - The name of the file to create (e.g. "backup.json") |
|||
* @param content - The content to write to the file |
|||
*/ |
|||
async writeAndShareFile(fileName: string, content: string): Promise<void> { |
|||
const timestamp = new Date().toISOString(); |
|||
const logData = { |
|||
action: 'writeAndShareFile', |
|||
fileName, |
|||
contentLength: content.length, |
|||
timestamp, |
|||
}; |
|||
logger.log('[CapacitorPlatformService]', JSON.stringify(logData, null, 2)); |
|||
|
|||
try { |
|||
const { uri } = await Filesystem.writeFile({ |
|||
path: fileName, |
|||
data: content, |
|||
directory: Directory.Data, |
|||
encoding: Encoding.UTF8, |
|||
recursive: true, |
|||
}); |
|||
|
|||
logger.log('[CapacitorPlatformService] File write successful:', { uri, timestamp: new Date().toISOString() }); |
|||
|
|||
await Share.share({ |
|||
title: 'TimeSafari Backup', |
|||
text: 'Here is your backup file.', |
|||
url: uri, |
|||
dialogTitle: 'Share your backup file', |
|||
}); |
|||
} catch (error) { |
|||
const err = error as Error; |
|||
const errLog = { |
|||
message: err.message, |
|||
stack: err.stack, |
|||
timestamp: new Date().toISOString(), |
|||
}; |
|||
logger.error('[CapacitorPlatformService] Error writing or sharing file:', JSON.stringify(errLog, null, 2)); |
|||
throw new Error(`Failed to write or share file: ${err.message}`); |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Deletes a file from the app's data directory. |
|||
* @param path - Relative path to the file to delete |
|||
* @throws Error if deletion fails or file doesn't exist |
|||
*/ |
|||
async deleteFile(path: string): Promise<void> { |
|||
await Filesystem.deleteFile({ |
|||
path, |
|||
directory: Directory.Data, |
|||
}); |
|||
} |
|||
|
|||
/** |
|||
* Lists files in the specified directory within app's data directory. |
|||
* @param directory - Relative path to the directory to list |
|||
* @returns Promise resolving to array of filenames |
|||
* @throws Error if directory cannot be read or doesn't exist |
|||
*/ |
|||
async listFiles(directory: string): Promise<string[]> { |
|||
const result = await Filesystem.readdir({ |
|||
path: directory, |
|||
directory: Directory.Data, |
|||
}); |
|||
return result.files.map((file) => |
|||
typeof file === "string" ? file : file.name, |
|||
); |
|||
} |
|||
|
|||
/** |
|||
* Opens the device camera to take a picture. |
|||
* Configures camera for high quality images with editing enabled. |
|||
* @returns Promise resolving to the captured image data |
|||
* @throws Error if camera access fails or user cancels |
|||
*/ |
|||
async takePicture(): Promise<ImageResult> { |
|||
try { |
|||
const image = await Camera.getPhoto({ |
|||
quality: 90, |
|||
allowEditing: true, |
|||
resultType: CameraResultType.Base64, |
|||
source: CameraSource.Camera, |
|||
}); |
|||
|
|||
const blob = await this.processImageData(image.base64String); |
|||
return { |
|||
blob, |
|||
fileName: `photo_${Date.now()}.${image.format || "jpg"}`, |
|||
}; |
|||
} catch (error) { |
|||
logger.error("Error taking picture with Capacitor:", error); |
|||
throw new Error("Failed to take picture"); |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Opens the device photo gallery to pick an existing image. |
|||
* Configures picker for high quality images with editing enabled. |
|||
* @returns Promise resolving to the selected image data |
|||
* @throws Error if gallery access fails or user cancels |
|||
*/ |
|||
async pickImage(): Promise<ImageResult> { |
|||
try { |
|||
const image = await Camera.getPhoto({ |
|||
quality: 90, |
|||
allowEditing: true, |
|||
resultType: CameraResultType.Base64, |
|||
source: CameraSource.Photos, |
|||
}); |
|||
|
|||
const blob = await this.processImageData(image.base64String); |
|||
return { |
|||
blob, |
|||
fileName: `photo_${Date.now()}.${image.format || "jpg"}`, |
|||
}; |
|||
} catch (error) { |
|||
logger.error("Error picking image with Capacitor:", error); |
|||
throw new Error("Failed to pick image"); |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Converts base64 image data to a Blob. |
|||
* @param base64String - Base64 encoded image data |
|||
* @returns Promise resolving to image Blob |
|||
* @throws Error if conversion fails |
|||
*/ |
|||
private async processImageData(base64String?: string): Promise<Blob> { |
|||
if (!base64String) { |
|||
throw new Error("No image data received"); |
|||
} |
|||
|
|||
// Convert base64 to blob
|
|||
const byteCharacters = atob(base64String); |
|||
const byteArrays = []; |
|||
for (let offset = 0; offset < byteCharacters.length; offset += 512) { |
|||
const slice = byteCharacters.slice(offset, offset + 512); |
|||
const byteNumbers = new Array(slice.length); |
|||
for (let i = 0; i < slice.length; i++) { |
|||
byteNumbers[i] = slice.charCodeAt(i); |
|||
} |
|||
const byteArray = new Uint8Array(byteNumbers); |
|||
byteArrays.push(byteArray); |
|||
} |
|||
return new Blob(byteArrays, { type: "image/jpeg" }); |
|||
} |
|||
|
|||
/** |
|||
* Handles deep link URLs for the application. |
|||
* Note: Capacitor handles deep links automatically. |
|||
* @param _url - The deep link URL (unused) |
|||
*/ |
|||
async handleDeepLink(_url: string): Promise<void> { |
|||
// Capacitor handles deep links automatically
|
|||
// This is just a placeholder for the interface
|
|||
return Promise.resolve(); |
|||
} |
|||
} |
@ -0,0 +1,111 @@ |
|||
import { |
|||
ImageResult, |
|||
PlatformService, |
|||
PlatformCapabilities, |
|||
} from "../PlatformService"; |
|||
import { logger } from "../../utils/logger"; |
|||
|
|||
/** |
|||
* Platform service implementation for Electron (desktop) platform. |
|||
* Note: This is a placeholder implementation with most methods currently unimplemented. |
|||
* Implements the PlatformService interface but throws "Not implemented" errors for most operations. |
|||
* |
|||
* @remarks |
|||
* This service is intended for desktop application functionality through Electron. |
|||
* Future implementations should provide: |
|||
* - Native file system access |
|||
* - Desktop camera integration |
|||
* - System-level features |
|||
*/ |
|||
export class ElectronPlatformService implements PlatformService { |
|||
/** |
|||
* Gets the capabilities of the Electron platform |
|||
* @returns Platform capabilities object |
|||
*/ |
|||
getCapabilities(): PlatformCapabilities { |
|||
return { |
|||
hasFileSystem: false, // Not implemented yet
|
|||
hasCamera: false, // Not implemented yet
|
|||
isMobile: false, |
|||
isIOS: false, |
|||
hasFileDownload: false, // Not implemented yet
|
|||
needsFileHandlingInstructions: false, |
|||
}; |
|||
} |
|||
|
|||
/** |
|||
* Reads a file from the filesystem. |
|||
* @param _path - Path to the file to read |
|||
* @returns Promise that should resolve to file contents |
|||
* @throws Error with "Not implemented" message |
|||
* @todo Implement file reading using Electron's file system API |
|||
*/ |
|||
async readFile(_path: string): Promise<string> { |
|||
throw new Error("Not implemented"); |
|||
} |
|||
|
|||
/** |
|||
* Writes content to a file. |
|||
* @param _path - Path where to write the file |
|||
* @param _content - Content to write to the file |
|||
* @throws Error with "Not implemented" message |
|||
* @todo Implement file writing using Electron's file system API |
|||
*/ |
|||
async writeFile(_path: string, _content: string): Promise<void> { |
|||
throw new Error("Not implemented"); |
|||
} |
|||
|
|||
/** |
|||
* Deletes a file from the filesystem. |
|||
* @param _path - Path to the file to delete |
|||
* @throws Error with "Not implemented" message |
|||
* @todo Implement file deletion using Electron's file system API |
|||
*/ |
|||
async deleteFile(_path: string): Promise<void> { |
|||
throw new Error("Not implemented"); |
|||
} |
|||
|
|||
/** |
|||
* Lists files in the specified directory. |
|||
* @param _directory - Path to the directory to list |
|||
* @returns Promise that should resolve to array of filenames |
|||
* @throws Error with "Not implemented" message |
|||
* @todo Implement directory listing using Electron's file system API |
|||
*/ |
|||
async listFiles(_directory: string): Promise<string[]> { |
|||
throw new Error("Not implemented"); |
|||
} |
|||
|
|||
/** |
|||
* Should open system camera to take a picture. |
|||
* @returns Promise that should resolve to captured image data |
|||
* @throws Error with "Not implemented" message |
|||
* @todo Implement camera access using Electron's media APIs |
|||
*/ |
|||
async takePicture(): Promise<ImageResult> { |
|||
logger.error("takePicture not implemented in Electron platform"); |
|||
throw new Error("Not implemented"); |
|||
} |
|||
|
|||
/** |
|||
* Should open system file picker for selecting an image. |
|||
* @returns Promise that should resolve to selected image data |
|||
* @throws Error with "Not implemented" message |
|||
* @todo Implement file picker using Electron's dialog API |
|||
*/ |
|||
async pickImage(): Promise<ImageResult> { |
|||
logger.error("pickImage not implemented in Electron platform"); |
|||
throw new Error("Not implemented"); |
|||
} |
|||
|
|||
/** |
|||
* Should handle deep link URLs for the desktop application. |
|||
* @param _url - The deep link URL to handle |
|||
* @throws Error with "Not implemented" message |
|||
* @todo Implement deep link handling using Electron's protocol handler |
|||
*/ |
|||
async handleDeepLink(_url: string): Promise<void> { |
|||
logger.error("handleDeepLink not implemented in Electron platform"); |
|||
throw new Error("Not implemented"); |
|||
} |
|||
} |
@ -0,0 +1,112 @@ |
|||
import { |
|||
ImageResult, |
|||
PlatformService, |
|||
PlatformCapabilities, |
|||
} from "../PlatformService"; |
|||
import { logger } from "../../utils/logger"; |
|||
|
|||
/** |
|||
* Platform service implementation for PyWebView platform. |
|||
* Note: This is a placeholder implementation with most methods currently unimplemented. |
|||
* Implements the PlatformService interface but throws "Not implemented" errors for most operations. |
|||
* |
|||
* @remarks |
|||
* This service is intended for Python-based desktop applications using pywebview. |
|||
* Future implementations should provide: |
|||
* - Integration with Python backend file operations |
|||
* - System camera access through Python |
|||
* - Native system dialogs via pywebview |
|||
* - Python-JavaScript bridge functionality |
|||
*/ |
|||
export class PyWebViewPlatformService implements PlatformService { |
|||
/** |
|||
* Gets the capabilities of the PyWebView platform |
|||
* @returns Platform capabilities object |
|||
*/ |
|||
getCapabilities(): PlatformCapabilities { |
|||
return { |
|||
hasFileSystem: false, // Not implemented yet
|
|||
hasCamera: false, // Not implemented yet
|
|||
isMobile: false, |
|||
isIOS: false, |
|||
hasFileDownload: false, // Not implemented yet
|
|||
needsFileHandlingInstructions: false, |
|||
}; |
|||
} |
|||
|
|||
/** |
|||
* Reads a file using the Python backend. |
|||
* @param _path - Path to the file to read |
|||
* @returns Promise that should resolve to file contents |
|||
* @throws Error with "Not implemented" message |
|||
* @todo Implement file reading through pywebview's Python-JavaScript bridge |
|||
*/ |
|||
async readFile(_path: string): Promise<string> { |
|||
throw new Error("Not implemented"); |
|||
} |
|||
|
|||
/** |
|||
* Writes content to a file using the Python backend. |
|||
* @param _path - Path where to write the file |
|||
* @param _content - Content to write to the file |
|||
* @throws Error with "Not implemented" message |
|||
* @todo Implement file writing through pywebview's Python-JavaScript bridge |
|||
*/ |
|||
async writeFile(_path: string, _content: string): Promise<void> { |
|||
throw new Error("Not implemented"); |
|||
} |
|||
|
|||
/** |
|||
* Deletes a file using the Python backend. |
|||
* @param _path - Path to the file to delete |
|||
* @throws Error with "Not implemented" message |
|||
* @todo Implement file deletion through pywebview's Python-JavaScript bridge |
|||
*/ |
|||
async deleteFile(_path: string): Promise<void> { |
|||
throw new Error("Not implemented"); |
|||
} |
|||
|
|||
/** |
|||
* Lists files in the specified directory using the Python backend. |
|||
* @param _directory - Path to the directory to list |
|||
* @returns Promise that should resolve to array of filenames |
|||
* @throws Error with "Not implemented" message |
|||
* @todo Implement directory listing through pywebview's Python-JavaScript bridge |
|||
*/ |
|||
async listFiles(_directory: string): Promise<string[]> { |
|||
throw new Error("Not implemented"); |
|||
} |
|||
|
|||
/** |
|||
* Should open system camera through Python backend. |
|||
* @returns Promise that should resolve to captured image data |
|||
* @throws Error with "Not implemented" message |
|||
* @todo Implement camera access using Python's camera libraries |
|||
*/ |
|||
async takePicture(): Promise<ImageResult> { |
|||
logger.error("takePicture not implemented in PyWebView platform"); |
|||
throw new Error("Not implemented"); |
|||
} |
|||
|
|||
/** |
|||
* Should open system file picker through pywebview. |
|||
* @returns Promise that should resolve to selected image data |
|||
* @throws Error with "Not implemented" message |
|||
* @todo Implement file picker using pywebview's file dialog API |
|||
*/ |
|||
async pickImage(): Promise<ImageResult> { |
|||
logger.error("pickImage not implemented in PyWebView platform"); |
|||
throw new Error("Not implemented"); |
|||
} |
|||
|
|||
/** |
|||
* Should handle deep link URLs through the Python backend. |
|||
* @param _url - The deep link URL to handle |
|||
* @throws Error with "Not implemented" message |
|||
* @todo Implement deep link handling using Python's URL handling capabilities |
|||
*/ |
|||
async handleDeepLink(_url: string): Promise<void> { |
|||
logger.error("handleDeepLink not implemented in PyWebView platform"); |
|||
throw new Error("Not implemented"); |
|||
} |
|||
} |
@ -0,0 +1,231 @@ |
|||
import { |
|||
ImageResult, |
|||
PlatformService, |
|||
PlatformCapabilities, |
|||
} from "../PlatformService"; |
|||
import { logger } from "../../utils/logger"; |
|||
|
|||
/** |
|||
* Platform service implementation for web browser platform. |
|||
* Implements the PlatformService interface with web-specific functionality. |
|||
* |
|||
* @remarks |
|||
* This service provides web-based implementations for: |
|||
* - Image capture using the browser's file input |
|||
* - Image selection from local filesystem |
|||
* - Image processing and conversion |
|||
* |
|||
* Note: File system operations are not available in the web platform |
|||
* due to browser security restrictions. These methods throw appropriate errors. |
|||
*/ |
|||
export class WebPlatformService implements PlatformService { |
|||
/** |
|||
* Gets the capabilities of the web platform |
|||
* @returns Platform capabilities object |
|||
*/ |
|||
getCapabilities(): PlatformCapabilities { |
|||
return { |
|||
hasFileSystem: false, |
|||
hasCamera: true, // Through file input with capture
|
|||
isMobile: /iPhone|iPad|iPod|Android/i.test(navigator.userAgent), |
|||
isIOS: /iPad|iPhone|iPod/.test(navigator.userAgent), |
|||
hasFileDownload: true, |
|||
needsFileHandlingInstructions: false, |
|||
}; |
|||
} |
|||
|
|||
/** |
|||
* Not supported in web platform. |
|||
* @param _path - Unused path parameter |
|||
* @throws Error indicating file system access is not available |
|||
*/ |
|||
async readFile(_path: string): Promise<string> { |
|||
throw new Error("File system access not available in web platform"); |
|||
} |
|||
|
|||
/** |
|||
* Not supported in web platform. |
|||
* @param _path - Unused path parameter |
|||
* @param _content - Unused content parameter |
|||
* @throws Error indicating file system access is not available |
|||
*/ |
|||
async writeFile(_path: string, _content: string): Promise<void> { |
|||
throw new Error("File system access not available in web platform"); |
|||
} |
|||
|
|||
/** |
|||
* Not supported in web platform. |
|||
* @param _path - Unused path parameter |
|||
* @throws Error indicating file system access is not available |
|||
*/ |
|||
async deleteFile(_path: string): Promise<void> { |
|||
throw new Error("File system access not available in web platform"); |
|||
} |
|||
|
|||
/** |
|||
* Not supported in web platform. |
|||
* @param _directory - Unused directory parameter |
|||
* @throws Error indicating file system access is not available |
|||
*/ |
|||
async listFiles(_directory: string): Promise<string[]> { |
|||
throw new Error("File system access not available in web platform"); |
|||
} |
|||
|
|||
/** |
|||
* Opens a file input dialog configured for camera capture. |
|||
* Creates a temporary file input element to access the device camera. |
|||
* |
|||
* @returns Promise resolving to the captured image data |
|||
* @throws Error if image capture fails or no image is selected |
|||
* |
|||
* @remarks |
|||
* Uses the 'capture' attribute to prefer the device camera. |
|||
* Falls back to file selection if camera is not available. |
|||
* Processes the captured image to ensure consistent format. |
|||
*/ |
|||
async takePicture(): Promise<ImageResult> { |
|||
return new Promise((resolve, reject) => { |
|||
const input = document.createElement("input"); |
|||
input.type = "file"; |
|||
input.accept = "image/*"; |
|||
input.capture = "environment"; |
|||
|
|||
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 camera image:", error); |
|||
reject(new Error("Failed to process camera image")); |
|||
} |
|||
} else { |
|||
reject(new Error("No image captured")); |
|||
} |
|||
}; |
|||
|
|||
input.click(); |
|||
}); |
|||
} |
|||
|
|||
/** |
|||
* Opens a file input dialog for selecting an image file. |
|||
* Creates a temporary file input element to access local files. |
|||
* |
|||
* @returns Promise resolving to the selected image data |
|||
* @throws Error if image processing fails or no image is selected |
|||
* |
|||
* @remarks |
|||
* Allows selection of any image file type. |
|||
* Processes the selected image to ensure consistent format. |
|||
*/ |
|||
async pickImage(): Promise<ImageResult> { |
|||
return new Promise((resolve, reject) => { |
|||
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 picked image:", error); |
|||
reject(new Error("Failed to process picked image")); |
|||
} |
|||
} else { |
|||
reject(new Error("No image selected")); |
|||
} |
|||
}; |
|||
|
|||
input.click(); |
|||
}); |
|||
} |
|||
|
|||
/** |
|||
* Processes an image file to ensure consistent format. |
|||
* Converts the file to a data URL and then to a Blob. |
|||
* |
|||
* @param file - The image File object to process |
|||
* @returns Promise resolving to processed image Blob |
|||
* @throws Error if file reading or conversion fails |
|||
* |
|||
* @remarks |
|||
* This method ensures consistent image format across different |
|||
* input sources by converting through data URL to Blob. |
|||
*/ |
|||
private async processImageFile(file: File): Promise<Blob> { |
|||
return new Promise((resolve, reject) => { |
|||
const reader = new FileReader(); |
|||
reader.onload = (event) => { |
|||
const dataUrl = event.target?.result as string; |
|||
// Convert to blob to ensure consistent format
|
|||
fetch(dataUrl) |
|||
.then((res) => res.blob()) |
|||
.then((blob) => resolve(blob)) |
|||
.catch((error) => { |
|||
logger.error("Error converting data URL to blob:", error); |
|||
reject(error); |
|||
}); |
|||
}; |
|||
reader.onerror = (error) => { |
|||
logger.error("Error reading file:", error); |
|||
reject(error); |
|||
}; |
|||
reader.readAsDataURL(file); |
|||
}); |
|||
} |
|||
|
|||
/** |
|||
* Checks if running on Capacitor platform. |
|||
* @returns false, as this is not Capacitor |
|||
*/ |
|||
isCapacitor(): boolean { |
|||
return false; |
|||
} |
|||
|
|||
/** |
|||
* Checks if running on Electron platform. |
|||
* @returns false, as this is not Electron |
|||
*/ |
|||
isElectron(): boolean { |
|||
return false; |
|||
} |
|||
|
|||
/** |
|||
* Checks if running on PyWebView platform. |
|||
* @returns false, as this is not PyWebView |
|||
*/ |
|||
isPyWebView(): boolean { |
|||
return false; |
|||
} |
|||
|
|||
/** |
|||
* Checks if running on web platform. |
|||
* @returns true, as this is the web implementation |
|||
*/ |
|||
isWeb(): boolean { |
|||
return true; |
|||
} |
|||
|
|||
/** |
|||
* Handles deep link URLs in the web platform. |
|||
* Deep links are handled through URL parameters in the web environment. |
|||
* |
|||
* @param _url - The deep link URL to handle (unused in web implementation) |
|||
* @returns Promise that resolves immediately as web handles URLs naturally |
|||
*/ |
|||
async handleDeepLink(_url: string): Promise<void> { |
|||
// Web platform can handle deep links through URL parameters
|
|||
return Promise.resolve(); |
|||
} |
|||
} |
@ -1,35 +1,31 @@ |
|||
{ |
|||
"compilerOptions": { |
|||
"target": "ES2020", // Latest ECMAScript features that are widely supported by modern browsers |
|||
"useDefineForClassFields": true, |
|||
"module": "ESNext", // Use ES modules |
|||
"strict": true, // Enable all strict type checking options |
|||
"lib": ["ES2020", "DOM", "DOM.Iterable"], |
|||
"skipLibCheck": true, |
|||
"moduleResolution": "bundler", |
|||
"allowImportingTsExtensions": true, |
|||
"resolveJsonModule": true, |
|||
"isolatedModules": true, |
|||
"noEmit": true, |
|||
"jsx": "preserve", // Preserves JSX to be transformed by Babel or another transpiler |
|||
"moduleResolution": "node", // Use Node.js style module resolution |
|||
"strict": true, // Enable all strict type checking options |
|||
"noUnusedLocals": true, |
|||
"noUnusedParameters": true, |
|||
"noFallthroughCasesInSwitch": true, |
|||
"baseUrl": ".", |
|||
"experimentalDecorators": true, |
|||
"esModuleInterop": true, // Enables compatibility with CommonJS modules for default imports |
|||
"allowSyntheticDefaultImports": true, // Allow default imports from modules with no default export |
|||
"forceConsistentCasingInFileNames": true, // Disallow inconsistently-cased references to the same file |
|||
"useDefineForClassFields": true, |
|||
"sourceMap": true, |
|||
"baseUrl": "./src", // Base directory to resolve non-relative module names |
|||
"paths": { |
|||
"@/components/*": ["components/*"], |
|||
"@/views/*": ["views/*"], |
|||
"@/db/*": ["db/*"], |
|||
"@/libs/*": ["libs/*"], |
|||
"@/constants/*": ["constants/*"], |
|||
"@/store/*": ["store/*"] |
|||
}, |
|||
"lib": ["ES2020", "dom", "dom.iterable"], // Include typings for ES2020 and DOM APIs |
|||
"@/*": ["src/*"] |
|||
} |
|||
}, |
|||
"include": [ |
|||
"src/**/*.ts", |
|||
"src/**/*.d.ts", |
|||
"src/**/*.tsx", |
|||
"src/**/*.vue", |
|||
"test-playwright/**/*.ts", |
|||
"test-playwright/**/*.tsx" |
|||
"src/**/*.vue" |
|||
], |
|||
"exclude": [ |
|||
"node_modules" |
|||
] |
|||
"references": [{ "path": "./tsconfig.node.json" }] |
|||
} |
|||
|
@ -0,0 +1,10 @@ |
|||
{ |
|||
"compilerOptions": { |
|||
"composite": true, |
|||
"skipLibCheck": true, |
|||
"module": "ESNext", |
|||
"moduleResolution": "bundler", |
|||
"allowSyntheticDefaultImports": true |
|||
}, |
|||
"include": ["vite.config.*"] |
|||
} |