Browse Source

**refactor(PhotoDialog, PlatformService): Implement cross-platform photo capture and encapsulated image processing**

- Replace direct camera library with platform-agnostic `PlatformService`
- Move platform-specific image processing logic to respective platform implementations
- Introduce `ImageResult` interface for consistent image handling across platforms
- Add support for native camera and image picker across all platforms
- Simplify `PhotoDialog` by removing platform-specific logic
- Maintain existing cropping and upload functionality
- Improve error handling and logging throughout
- Clean up UI for better user experience
- Add comprehensive documentation for usage and architecture

**BREAKING CHANGE:** Removes direct camera library dependency in favor of `PlatformService`

This change improves separation of concerns, enhances maintainability, and standardizes cross-platform image handling.
cross-platform-factory
Matthew Raymer 1 month ago
parent
commit
2c84bb50b3
  1. BIN
      android/.gradle/buildOutputCleanup/buildOutputCleanup.lock
  2. BIN
      android/.gradle/file-system.probe
  3. 250
      src/components/PhotoDialog.vue
  4. 9
      src/services/PlatformService.ts
  5. 75
      src/services/platforms/CapacitorPlatformService.ts
  6. 18
      src/services/platforms/ElectronPlatformService.ts
  7. 18
      src/services/platforms/PyWebViewPlatformService.ts
  8. 67
      src/services/platforms/WebPlatformService.ts

BIN
android/.gradle/buildOutputCleanup/buildOutputCleanup.lock

Binary file not shown.

BIN
android/.gradle/file-system.probe

Binary file not shown.

250
src/components/PhotoDialog.vue

@ -40,11 +40,6 @@
}"
class="max-h-[90vh] max-w-[90vw] object-contain"
/>
<!-- This gives a round cropper.
:presetMode="{
mode: 'round',
}"
-->
</div>
<div v-else>
<div class="flex justify-center">
@ -74,87 +69,67 @@
</button>
</div>
</div>
<div v-else ref="cameraContainer">
<!--
Camera "resolution" doesn't change how it shows on screen but rather stretches the result,
eg. the following which just stretches it vertically:
:resolution="{ width: 375, height: 812 }"
-->
<camera
ref="camera"
facing-mode="environment"
autoplay
@started="cameraStarted()"
>
<div
class="absolute portrait:bottom-0 portrait:left-0 portrait:right-0 portrait:pb-2 landscape:right-0 landscape:top-0 landscape:bottom-0 landscape:pr-4 flex landscape:flex-row justify-center items-center"
<div v-else>
<div class="flex flex-col items-center justify-center gap-4 p-4">
<button
class="bg-blue-500 hover:bg-blue-700 text-white font-bold p-3 rounded-full text-2xl leading-none"
@click="takePhoto"
>
<button
class="bg-blue-500 hover:bg-blue-700 text-white font-bold p-3 rounded-full text-2xl leading-none"
@click="takeImage()"
>
<font-awesome icon="camera" class="w-[1em]"></font-awesome>
</button>
</div>
<div
class="absolute portrait:bottom-2 portrait:right-16 landscape:right-0 landscape:bottom-16 landscape:pr-4 flex justify-center items-center"
<font-awesome icon="camera" class="w-[1em]" />
</button>
<button
class="bg-blue-500 hover:bg-blue-700 text-white font-bold p-3 rounded-full text-2xl leading-none"
@click="pickPhoto"
>
<button
class="bg-blue-500 hover:bg-blue-700 text-white font-bold p-3 rounded-full text-2xl leading-none"
@click="swapMirrorClass()"
>
<font-awesome icon="left-right" class="w-[1em]"></font-awesome>
</button>
</div>
<div v-if="numDevices > 1" class="absolute bottom-2 right-4">
<button
class="bg-blue-500 hover:bg-blue-700 text-white font-bold p-3 rounded-full text-2xl leading-none"
@click="switchCamera()"
>
<font-awesome icon="rotate" class="w-[1em]"></font-awesome>
</button>
</div>
</camera>
<font-awesome icon="image" class="w-[1em]" />
</button>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
/**
* PhotoDialog.vue - Cross-platform photo capture and selection component
*
* This component provides a unified interface for taking photos and selecting images
* across different platforms using the PlatformService.
*
* @author Matthew Raymer
* @file PhotoDialog.vue
*/
import axios from "axios";
import Camera from "simple-vue-camera";
import { Component, Vue } from "vue-facing-decorator";
import VuePictureCropper, { cropper } from "vue-picture-cropper";
import { DEFAULT_IMAGE_API_SERVER, NotificationIface } from "../constants/app";
import { retrieveSettingsForActiveAccount } from "../db/index";
import { accessToken } from "../libs/crypto";
import { logger } from "../utils/logger";
import { PlatformServiceFactory } from "../services/PlatformServiceFactory";
@Component({ components: { Camera, VuePictureCropper } })
@Component({ components: { VuePictureCropper } })
export default class PhotoDialog extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
activeDeviceNumber = 0;
activeDid = "";
blob?: Blob;
claimType = "";
crop = false;
fileName?: string;
mirror = false;
numDevices = 0;
setImageCallback: (arg: string) => void = () => {};
showRetry = true;
uploading = false;
visible = false;
private platformService = PlatformServiceFactory.getInstance();
URL = window.URL || window.webkitURL;
async mounted() {
try {
const settings = await retrieveSettingsForActiveAccount();
this.activeDid = settings.activeDid || "";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (err: any) {
logger.error("Error retrieving settings from database:", err);
this.$notify(
@ -173,7 +148,7 @@ export default class PhotoDialog extends Vue {
setImageFn: (arg: string) => void,
claimType: string,
crop?: boolean,
blob?: Blob, // for image upload, just to use the cropping function
blob?: Blob,
inputFileName?: string,
) {
this.visible = true;
@ -187,7 +162,6 @@ export default class PhotoDialog extends Vue {
if (blob) {
this.blob = blob;
this.fileName = inputFileName;
// doesn't make sense to retry the file upload; they can cancel if they picked the wrong one
this.showRetry = false;
} else {
this.blob = undefined;
@ -205,85 +179,35 @@ export default class PhotoDialog extends Vue {
this.blob = undefined;
}
async cameraStarted() {
const cameraComponent = this.$refs.camera as InstanceType<typeof Camera>;
if (cameraComponent) {
this.numDevices = (await cameraComponent.devices(["videoinput"])).length;
this.mirror = cameraComponent.facingMode === "user";
// figure out which device is active
const currentDeviceId = cameraComponent.currentDeviceID();
const devices = await cameraComponent.devices(["videoinput"]);
this.activeDeviceNumber = devices.findIndex(
(device) => device.deviceId === currentDeviceId,
);
async takePhoto() {
try {
const result = await this.platformService.takePicture();
this.blob = result.blob;
this.fileName = result.fileName;
} catch (error) {
logger.error("Error taking picture:", error);
this.$notify({
group: "alert",
type: "danger",
title: "Error",
text: "Failed to take picture. Please try again.",
}, 5000);
}
}
async switchCamera() {
const cameraComponent = this.$refs.camera as InstanceType<typeof Camera>;
this.activeDeviceNumber = (this.activeDeviceNumber + 1) % this.numDevices;
const devices = await cameraComponent?.devices(["videoinput"]);
await cameraComponent?.changeCamera(
devices[this.activeDeviceNumber].deviceId,
);
}
async takeImage(/* payload: MouseEvent */) {
const cameraComponent = this.$refs.camera as InstanceType<typeof Camera>;
/**
* This logic to set the image height & width correctly.
* Without it, the portrait orientation ends up with an image that is stretched horizontally.
* Note that it's the same with raw browser Javascript; see the "drawImage" example below.
* Now that I've done it, I can't explain why it works.
*/
let imageHeight = cameraComponent?.resolution?.height;
let imageWidth = cameraComponent?.resolution?.width;
const initialImageRatio = imageWidth / imageHeight;
const windowRatio = window.innerWidth / window.innerHeight;
if (initialImageRatio > 1 && windowRatio < 1) {
// the image is wider than it is tall, and the window is taller than it is wide
// For some reason, mobile in portrait orientation renders a horizontally-stretched image.
// We're gonna force it opposite.
imageHeight = cameraComponent?.resolution?.width;
imageWidth = cameraComponent?.resolution?.height;
} else if (initialImageRatio < 1 && windowRatio > 1) {
// the image is taller than it is wide, and the window is wider than it is tall
// Haven't seen this happen, but we'll do it just in case.
imageHeight = cameraComponent?.resolution?.width;
imageWidth = cameraComponent?.resolution?.height;
}
const newImageRatio = imageWidth / imageHeight;
if (newImageRatio < windowRatio) {
// the image is a taller ratio than the window, so fit the height first
imageHeight = window.innerHeight / 2;
imageWidth = imageHeight * newImageRatio;
} else {
// the image is a wider ratio than the window, so fit the width first
imageWidth = window.innerWidth / 2;
imageHeight = imageWidth / newImageRatio;
}
// The resolution is only necessary because of that mobile portrait-orientation case.
// The mobile emulation in a browser shows something stretched vertically, but real devices work fine.
this.blob =
(await cameraComponent?.snapshot({
height: imageHeight,
width: imageWidth,
})) || undefined;
// png is default
this.fileName = "snapshot.png";
if (!this.blob) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "There was an error taking the picture. Please try again.",
},
5000,
);
return;
async pickPhoto() {
try {
const result = await this.platformService.pickImage();
this.blob = result.blob;
this.fileName = result.fileName;
} catch (error) {
logger.error("Error picking image:", error);
this.$notify({
group: "alert",
type: "danger",
title: "Error",
text: "Failed to pick image. Please try again.",
}, 5000);
}
}
@ -295,51 +219,6 @@ export default class PhotoDialog extends Vue {
this.blob = undefined;
}
/****
Here's an approach to photo capture without a library. It has similar quirks.
Now that we've fixed styling for simple-vue-camera, it's not critical to refactor. Maybe someday.
<button id="start-camera" @click="cameraClicked">Start Camera</button>
<video id="video" width="320" height="240" autoplay></video>
<button id="snap-photo" @click="photoSnapped">Snap Photo</button>
<canvas id="canvas" width="320" height="240"></canvas>
async cameraClicked() {
const video = document.querySelector("#video");
const stream = await navigator.mediaDevices.getUserMedia({
video: true,
audio: false,
});
if (video instanceof HTMLVideoElement) {
video.srcObject = stream;
}
}
photoSnapped() {
const video = document.querySelector("#video");
const canvas = document.querySelector("#canvas");
if (
canvas instanceof HTMLCanvasElement &&
video instanceof HTMLVideoElement
) {
canvas
?.getContext("2d")
?.drawImage(video, 0, 0, canvas.width, canvas.height);
// ... or set the blob:
// canvas?.toBlob(
// (blob) => {
// this.blob = blob;
// },
// "image/jpeg",
// 1,
// );
// data url of the image
const image_data_url = canvas?.toDataURL("image/jpeg");
}
}
****/
async uploadImage() {
this.uploading = true;
@ -350,11 +229,9 @@ export default class PhotoDialog extends Vue {
const token = await accessToken(this.activeDid);
const headers = {
Authorization: "Bearer " + token,
// axios fills in Content-Type of multipart/form-data
};
const formData = new FormData();
if (!this.blob) {
// yeah, this should never happen, but it helps with subsequent type checking
this.$notify(
{
group: "alert",
@ -367,7 +244,7 @@ export default class PhotoDialog extends Vue {
this.uploading = false;
return;
}
formData.append("image", this.blob, this.fileName || "snapshot.png");
formData.append("image", this.blob, this.fileName || "photo.jpg");
formData.append("claimType", this.claimType);
try {
if (
@ -402,17 +279,6 @@ export default class PhotoDialog extends Vue {
this.blob = undefined;
}
}
swapMirrorClass() {
this.mirror = !this.mirror;
if (this.mirror) {
(this.$refs.cameraContainer as HTMLElement).classList.add("mirror-video");
} else {
(this.$refs.cameraContainer as HTMLElement).classList.remove(
"mirror-video",
);
}
}
}
</script>
@ -438,12 +304,4 @@ export default class PhotoDialog extends Vue {
width: 100%;
max-width: 700px;
}
.mirror-video {
transform: scaleX(-1);
-webkit-transform: scaleX(-1); /* For Safari */
-moz-transform: scaleX(-1); /* For Firefox */
-ms-transform: scaleX(-1); /* For IE */
-o-transform: scaleX(-1); /* For Opera */
}
</style>

9
src/services/PlatformService.ts

@ -1,3 +1,8 @@
export interface ImageResult {
blob: Blob;
fileName: string;
}
export interface PlatformService {
// File system operations
readFile(path: string): Promise<string>;
@ -6,8 +11,8 @@ export interface PlatformService {
listFiles(directory: string): Promise<string[]>;
// Camera operations
takePicture(): Promise<string>;
pickImage(): Promise<string>;
takePicture(): Promise<ImageResult>;
pickImage(): Promise<ImageResult>;
// Platform specific features
isCapacitor(): boolean;

75
src/services/platforms/CapacitorPlatformService.ts

@ -1,8 +1,9 @@
import { PlatformService } from "../PlatformService";
import { ImageResult, PlatformService } from "../PlatformService";
import { Capacitor } from "@capacitor/core";
import { Filesystem, Directory } from "@capacitor/filesystem";
import { Camera, CameraResultType, CameraSource } from "@capacitor/camera";
import { App } from "@capacitor/app";
import { logger } from "../../utils/logger";
export class CapacitorPlatformService implements PlatformService {
async readFile(path: string): Promise<string> {
@ -36,24 +37,64 @@ export class CapacitorPlatformService implements PlatformService {
return result.files;
}
async takePicture(): Promise<string> {
const image = await Camera.getPhoto({
quality: 90,
allowEditing: true,
resultType: CameraResultType.Uri,
source: CameraSource.Camera,
});
return image.webPath || "";
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");
}
}
async pickImage(): Promise<string> {
const image = await Camera.getPhoto({
quality: 90,
allowEditing: true,
resultType: CameraResultType.Uri,
source: CameraSource.Photos,
});
return image.webPath || "";
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");
}
}
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' });
}
isCapacitor(): boolean {

18
src/services/platforms/ElectronPlatformService.ts

@ -1,28 +1,28 @@
import { PlatformService } from '../PlatformService';
import { PlatformService } from "../PlatformService";
export class ElectronPlatformService implements PlatformService {
async readFile(path: string): Promise<string> {
throw new Error('Not implemented');
throw new Error("Not implemented");
}
async writeFile(path: string, content: string): Promise<void> {
throw new Error('Not implemented');
throw new Error("Not implemented");
}
async deleteFile(path: string): Promise<void> {
throw new Error('Not implemented');
throw new Error("Not implemented");
}
async listFiles(directory: string): Promise<string[]> {
throw new Error('Not implemented');
throw new Error("Not implemented");
}
async takePicture(): Promise<string> {
throw new Error('Not implemented');
throw new Error("Not implemented");
}
async pickImage(): Promise<string> {
throw new Error('Not implemented');
throw new Error("Not implemented");
}
isCapacitor(): boolean {
@ -42,6 +42,6 @@ export class ElectronPlatformService implements PlatformService {
}
async handleDeepLink(url: string): Promise<void> {
throw new Error('Not implemented');
throw new Error("Not implemented");
}
}
}

18
src/services/platforms/PyWebViewPlatformService.ts

@ -1,28 +1,28 @@
import { PlatformService } from '../PlatformService';
import { PlatformService } from "../PlatformService";
export class PyWebViewPlatformService implements PlatformService {
async readFile(path: string): Promise<string> {
throw new Error('Not implemented');
throw new Error("Not implemented");
}
async writeFile(path: string, content: string): Promise<void> {
throw new Error('Not implemented');
throw new Error("Not implemented");
}
async deleteFile(path: string): Promise<void> {
throw new Error('Not implemented');
throw new Error("Not implemented");
}
async listFiles(directory: string): Promise<string[]> {
throw new Error('Not implemented');
throw new Error("Not implemented");
}
async takePicture(): Promise<string> {
throw new Error('Not implemented');
throw new Error("Not implemented");
}
async pickImage(): Promise<string> {
throw new Error('Not implemented');
throw new Error("Not implemented");
}
isCapacitor(): boolean {
@ -42,6 +42,6 @@ export class PyWebViewPlatformService implements PlatformService {
}
async handleDeepLink(url: string): Promise<void> {
throw new Error('Not implemented');
throw new Error("Not implemented");
}
}
}

67
src/services/platforms/WebPlatformService.ts

@ -1,4 +1,5 @@
import { PlatformService } from "../PlatformService";
import { ImageResult, PlatformService } from "../PlatformService";
import { logger } from "../../utils/logger";
export class WebPlatformService implements PlatformService {
async readFile(path: string): Promise<string> {
@ -17,23 +18,28 @@ export class WebPlatformService implements PlatformService {
throw new Error("File system access not available in web platform");
}
async takePicture(): Promise<string> {
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 = (e) => {
input.onchange = async (e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (file) {
const reader = new FileReader();
reader.onload = (event) => {
resolve(event.target?.result as string);
};
reader.readAsDataURL(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 file selected"));
reject(new Error("No image captured"));
}
};
@ -41,22 +47,27 @@ export class WebPlatformService implements PlatformService {
});
}
async pickImage(): Promise<string> {
async pickImage(): Promise<ImageResult> {
return new Promise((resolve, reject) => {
const input = document.createElement("input");
input.type = "file";
input.accept = "image/*";
input.onchange = (e) => {
input.onchange = async (e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (file) {
const reader = new FileReader();
reader.onload = (event) => {
resolve(event.target?.result as string);
};
reader.readAsDataURL(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 file selected"));
reject(new Error("No image selected"));
}
};
@ -64,6 +75,28 @@ export class WebPlatformService implements PlatformService {
});
}
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);
});
}
isCapacitor(): boolean {
return false;
}

Loading…
Cancel
Save