mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-08-02 12:32:14 +00:00
fix: sharing on android
This commit is contained in:
parent
d5d106225a
commit
5fac5193e6
6 changed files with 593 additions and 98 deletions
|
|
@ -21,6 +21,17 @@
|
|||
android:theme="@style/AppTheme"
|
||||
android:usesCleartextTraffic="true">
|
||||
|
||||
<!-- FileProvider for secure file sharing via Share plugin -->
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
|
||||
<!-- Set FullscreenActivity as the default launcher -->
|
||||
<activity
|
||||
android:name=".FullscreenActivity"
|
||||
|
|
|
|||
14
package-lock.json
generated
14
package-lock.json
generated
|
|
@ -19111,6 +19111,20 @@
|
|||
"uuid": "^11.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/mermaid/node_modules/marked": {
|
||||
"version": "16.4.1",
|
||||
"resolved": "https://registry.npmjs.org/marked/-/marked-16.4.1.tgz",
|
||||
"integrity": "sha512-ntROs7RaN3EvWfy3EZi14H4YxmT6A5YvywfhO+0pm+cH/dnSQRmdAmoFIc3B9aiwTehyk7pESH4ofyBY+V5hZg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"bin": {
|
||||
"marked": "bin/marked.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/methods": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
|
||||
|
|
|
|||
|
|
@ -25,7 +25,8 @@ export type ShareTarget =
|
|||
| 'email'
|
||||
| 'mastodon'
|
||||
| 'clipboard-text'
|
||||
| 'native';
|
||||
| 'native'
|
||||
| 'download';
|
||||
|
||||
/**
|
||||
* Result of a share operation
|
||||
|
|
@ -39,6 +40,10 @@ export interface ShareResult {
|
|||
target?: ShareTarget;
|
||||
/** Whether native share was attempted */
|
||||
usedNative?: boolean;
|
||||
/** Optional local filesystem path for download results */
|
||||
path?: string;
|
||||
/** Optional URI for native downloads */
|
||||
uri?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -66,3 +71,30 @@ export interface ShareDialogOptions {
|
|||
/** Pre-selected Mastodon instance */
|
||||
mastodonInstance?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for sharing a canvas element as an image
|
||||
*/
|
||||
export interface ShareCanvasImageParams {
|
||||
/** Canvas element that should be shared */
|
||||
canvas: HTMLCanvasElement;
|
||||
/** Output filename including extension */
|
||||
filename?: string;
|
||||
/** Optional title to use for share dialogs */
|
||||
shareTitle?: string;
|
||||
/** Optional tagline configuration appended below the canvas */
|
||||
tagline?: ShareCanvasTagline;
|
||||
/** Disable download fallback when sharing is unavailable */
|
||||
fallbackToDownload?: boolean;
|
||||
/** Sharing mode, defaults to auto */
|
||||
mode?: 'auto' | 'download-only';
|
||||
}
|
||||
|
||||
export interface ShareCanvasTagline {
|
||||
/** Tagline text rendered below the canvas image */
|
||||
text: string;
|
||||
/** Optional tagline area height; defaults to 48 */
|
||||
height?: number;
|
||||
/** Optional text color */
|
||||
color?: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,17 @@
|
|||
import { Injectable, inject } from '@angular/core';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
import { Directory, Filesystem } from '@capacitor/filesystem';
|
||||
import { IS_ANDROID_WEB_VIEW } from '../../util/is-android-web-view';
|
||||
import { SnackService } from '../snack/snack.service';
|
||||
import { SharePayload, ShareResult, ShareTarget, ShareTargetConfig } from './share.model';
|
||||
import {
|
||||
ShareCanvasImageParams,
|
||||
ShareCanvasTagline,
|
||||
SharePayload,
|
||||
ShareResult,
|
||||
ShareTarget,
|
||||
ShareTargetConfig,
|
||||
} from './share.model';
|
||||
|
||||
const FALLBACK_SHARE_URL = 'https://super-productivity.com';
|
||||
|
||||
|
|
@ -54,6 +62,54 @@ export class ShareService {
|
|||
return 'failed';
|
||||
}
|
||||
|
||||
canOpenDownloadResult(result: ShareResult): boolean {
|
||||
if (!result) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined' && result.path && (window as any).ea?.openPath) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (Capacitor.isNativePlatform() || IS_ANDROID_WEB_VIEW) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined' && (result.uri || result.path)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async openDownloadResult(result: ShareResult): Promise<void> {
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { uri, path } = result;
|
||||
|
||||
if (typeof window !== 'undefined' && path && (window as any).ea?.openPath) {
|
||||
try {
|
||||
(window as any).ea.openPath(path);
|
||||
return;
|
||||
} catch (error) {
|
||||
console.warn('Failed to open path via Electron bridge:', error);
|
||||
}
|
||||
}
|
||||
|
||||
const candidate = uri ?? path;
|
||||
|
||||
if (typeof window !== 'undefined' && candidate) {
|
||||
try {
|
||||
window.open(candidate, '_blank', 'noopener');
|
||||
return;
|
||||
} catch (error) {
|
||||
console.warn('Failed to open download in new window:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async getShareSupport(): Promise<ShareSupport> {
|
||||
if (typeof window === 'undefined') {
|
||||
return 'none';
|
||||
|
|
@ -115,6 +171,66 @@ export class ShareService {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Share a canvas element as an image, supporting native, web, and download fallbacks.
|
||||
*/
|
||||
async shareCanvasImage(params: ShareCanvasImageParams): Promise<ShareResult> {
|
||||
if (typeof window === 'undefined' || !params?.canvas) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Canvas not available',
|
||||
};
|
||||
}
|
||||
|
||||
const filename = this._sanitizeFilename(params.filename ?? 'shared-image.png');
|
||||
const shareTitle = params.shareTitle ?? filename;
|
||||
const canvasForExport = this._prepareCanvasForShare(params.canvas, params.tagline);
|
||||
const dataUrl = canvasForExport.toDataURL('image/png', 1.0);
|
||||
const base64 = this._extractBase64(dataUrl);
|
||||
const blob = await this._canvasToBlob(canvasForExport);
|
||||
if (!blob) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Failed to export image',
|
||||
};
|
||||
}
|
||||
|
||||
if (params.mode === 'download-only') {
|
||||
return this._saveCanvasDownload({
|
||||
blob,
|
||||
base64,
|
||||
filename,
|
||||
dataUrl,
|
||||
});
|
||||
}
|
||||
|
||||
if (base64) {
|
||||
const nativeResult = await this._shareCanvasViaNative(base64, filename, shareTitle);
|
||||
if (nativeResult.success || nativeResult.error === 'Share cancelled') {
|
||||
return nativeResult;
|
||||
}
|
||||
}
|
||||
|
||||
const webResult = await this._shareCanvasViaWeb(blob, filename, shareTitle, dataUrl);
|
||||
if (webResult.success || webResult.error === 'Share cancelled') {
|
||||
return webResult;
|
||||
}
|
||||
|
||||
if (params.fallbackToDownload !== false) {
|
||||
return this._saveCanvasDownload({
|
||||
blob,
|
||||
base64,
|
||||
filename,
|
||||
dataUrl,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: 'Share not available',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to use native share (Android, Web Share API).
|
||||
* Public API for dialog component.
|
||||
|
|
@ -617,6 +733,343 @@ export class ShareService {
|
|||
return text.replace(/\p{Extended_Pictographic}|\uFE0F|\uFE0E|\u200D/gu, '');
|
||||
}
|
||||
|
||||
private _prepareCanvasForShare(
|
||||
canvas: HTMLCanvasElement,
|
||||
tagline?: ShareCanvasTagline,
|
||||
): HTMLCanvasElement {
|
||||
if (!tagline?.text) {
|
||||
return canvas;
|
||||
}
|
||||
|
||||
const taglineHeight = Math.max(1, tagline.height ?? 48);
|
||||
const newCanvas = document.createElement('canvas');
|
||||
newCanvas.width = canvas.width;
|
||||
newCanvas.height = canvas.height + taglineHeight;
|
||||
|
||||
const ctx = newCanvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
return canvas;
|
||||
}
|
||||
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(0, 0, newCanvas.width, newCanvas.height);
|
||||
ctx.drawImage(canvas, 0, 0);
|
||||
|
||||
ctx.fillStyle = tagline.color ?? 'rgba(0, 0, 0, 0.6)';
|
||||
const baseFontSize = Math.max(20, Math.round(newCanvas.width / 40));
|
||||
ctx.font = `${baseFontSize}px system-ui, -apple-system, sans-serif`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
const taglineOffset = taglineHeight / 2;
|
||||
const taglineY = canvas.height + taglineOffset;
|
||||
ctx.fillText(tagline.text, newCanvas.width / 2, taglineY);
|
||||
|
||||
return newCanvas;
|
||||
}
|
||||
|
||||
private _canvasToBlob(canvas: HTMLCanvasElement): Promise<Blob | null> {
|
||||
return new Promise((resolve) => {
|
||||
canvas.toBlob((blob) => resolve(blob), 'image/png', 1.0);
|
||||
});
|
||||
}
|
||||
|
||||
private async _shareCanvasViaNative(
|
||||
base64: string,
|
||||
filename: string,
|
||||
title: string,
|
||||
): Promise<ShareResult> {
|
||||
const sharePlugin = await this._getCapacitorSharePlugin();
|
||||
if (!sharePlugin) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Native share not available',
|
||||
};
|
||||
}
|
||||
|
||||
const sanitizedName = this._sanitizeFilename(filename);
|
||||
const relativePath = `shared-images/${Date.now()}-${sanitizedName}`;
|
||||
let fileUrl: string | null = null;
|
||||
let resolvedUri: string | null = null;
|
||||
|
||||
try {
|
||||
const writeResult = await Filesystem.writeFile({
|
||||
path: relativePath,
|
||||
data: base64,
|
||||
directory: Directory.Cache,
|
||||
recursive: true,
|
||||
});
|
||||
|
||||
resolvedUri =
|
||||
writeResult.uri ??
|
||||
(
|
||||
await Filesystem.getUri({
|
||||
directory: Directory.Cache,
|
||||
path: relativePath,
|
||||
})
|
||||
).uri;
|
||||
|
||||
if (!resolvedUri) {
|
||||
throw new Error('Failed to resolve native share uri');
|
||||
}
|
||||
|
||||
fileUrl = resolvedUri.startsWith('file://')
|
||||
? resolvedUri
|
||||
: resolvedUri.startsWith('/')
|
||||
? `file://${resolvedUri}`
|
||||
: `file:///${resolvedUri}`;
|
||||
|
||||
console.debug('[ShareService] shareCanvasViaNative', {
|
||||
resolvedUri,
|
||||
fileUrl,
|
||||
relativePath,
|
||||
});
|
||||
|
||||
try {
|
||||
const stat = await Filesystem.stat({
|
||||
path: relativePath,
|
||||
directory: Directory.Cache,
|
||||
});
|
||||
console.debug('[ShareService] shareCanvasViaNative stat', stat);
|
||||
} catch (statError) {
|
||||
console.warn('[ShareService] stat failed for shared image', statError);
|
||||
}
|
||||
|
||||
const canShare = (await sharePlugin.canShare?.())?.value ?? true;
|
||||
if (!canShare) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Native share not available',
|
||||
};
|
||||
}
|
||||
|
||||
await sharePlugin.share({
|
||||
title,
|
||||
text: '',
|
||||
files: [fileUrl],
|
||||
dialogTitle: 'Share via',
|
||||
});
|
||||
|
||||
this._snackService.open('Shared successfully!');
|
||||
this._scheduleCacheCleanup(relativePath);
|
||||
return {
|
||||
success: true,
|
||||
usedNative: true,
|
||||
target: 'native',
|
||||
};
|
||||
} catch (error: any) {
|
||||
if (error?.name === 'AbortError' || /Share canceled/i.test(error?.message)) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Share cancelled',
|
||||
};
|
||||
}
|
||||
console.warn('Native image share failed:', error, {
|
||||
fileUrl: fileUrl ?? 'n/a',
|
||||
resolvedUri: resolvedUri ?? 'n/a',
|
||||
relativePath,
|
||||
});
|
||||
this._scheduleCacheCleanup(relativePath);
|
||||
return {
|
||||
success: false,
|
||||
error: 'Native share failed',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async _shareCanvasViaWeb(
|
||||
blob: Blob,
|
||||
filename: string,
|
||||
title: string,
|
||||
dataUrl: string,
|
||||
): Promise<ShareResult> {
|
||||
if (typeof navigator === 'undefined') {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Share not available',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
if (typeof File !== 'undefined') {
|
||||
const file = new File([blob], filename, { type: 'image/png' });
|
||||
if (navigator.canShare && navigator.canShare({ files: [file] })) {
|
||||
await navigator.share({
|
||||
files: [file],
|
||||
title,
|
||||
});
|
||||
this._snackService.open('Shared successfully!');
|
||||
return {
|
||||
success: true,
|
||||
usedNative: true,
|
||||
target: 'native',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof navigator.share === 'function') {
|
||||
await navigator.share({
|
||||
title,
|
||||
url: dataUrl,
|
||||
});
|
||||
this._snackService.open('Shared successfully!');
|
||||
return {
|
||||
success: true,
|
||||
usedNative: true,
|
||||
target: 'native',
|
||||
};
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error?.name === 'AbortError') {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Share cancelled',
|
||||
};
|
||||
}
|
||||
console.warn('Web Share API failed:', error);
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: 'Share not available',
|
||||
};
|
||||
}
|
||||
|
||||
private async _saveCanvasDownload({
|
||||
blob,
|
||||
base64,
|
||||
filename,
|
||||
dataUrl,
|
||||
}: {
|
||||
blob: Blob;
|
||||
base64?: string | null;
|
||||
filename: string;
|
||||
dataUrl: string;
|
||||
}): Promise<ShareResult> {
|
||||
if (base64 && (Capacitor.isNativePlatform() || IS_ANDROID_WEB_VIEW)) {
|
||||
const sanitizedName = this._sanitizeFilename(filename);
|
||||
const relativePath = `shared-images/${Date.now()}-${sanitizedName}`;
|
||||
try {
|
||||
const writeResult = await Filesystem.writeFile({
|
||||
path: relativePath,
|
||||
data: base64,
|
||||
directory: Directory.Documents,
|
||||
recursive: true,
|
||||
});
|
||||
|
||||
const uri =
|
||||
writeResult.uri ??
|
||||
(
|
||||
await Filesystem.getUri({
|
||||
directory: Directory.Documents,
|
||||
path: relativePath,
|
||||
})
|
||||
).uri;
|
||||
|
||||
const storedPath = `Documents/${relativePath}`;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
target: 'download',
|
||||
usedNative: true,
|
||||
path: storedPath,
|
||||
uri,
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn('Native download failed, falling back to browser download:', error);
|
||||
}
|
||||
}
|
||||
|
||||
const downloaded = this._downloadBlob(blob, filename);
|
||||
if (downloaded) {
|
||||
return {
|
||||
success: true,
|
||||
target: 'download',
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined' && dataUrl) {
|
||||
try {
|
||||
window.open(dataUrl, '_blank');
|
||||
return {
|
||||
success: true,
|
||||
target: 'download',
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn('Opening image in new tab failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: 'Download failed',
|
||||
};
|
||||
}
|
||||
|
||||
private _downloadBlob(blob: Blob, filename: string): boolean {
|
||||
if (typeof document === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = filename;
|
||||
document.body.appendChild(anchor);
|
||||
try {
|
||||
anchor.click();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn('Browser download failed:', error);
|
||||
return false;
|
||||
} finally {
|
||||
document.body.removeChild(anchor);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
|
||||
private async _cleanupCacheFile(relativePath: string): Promise<void> {
|
||||
if (!relativePath) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await Filesystem.deleteFile({
|
||||
path: relativePath,
|
||||
directory: Directory.Cache,
|
||||
});
|
||||
} catch (cleanupError) {
|
||||
console.warn('Failed to cleanup shared file:', cleanupError);
|
||||
}
|
||||
}
|
||||
|
||||
private _scheduleCacheCleanup(relativePath: string): void {
|
||||
if (!relativePath) {
|
||||
return;
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
void this._cleanupCacheFile(relativePath);
|
||||
}, 15_000);
|
||||
}
|
||||
|
||||
private _sanitizeFilename(filename: string): string {
|
||||
const trimmed = filename.trim() || 'shared-image.png';
|
||||
const sanitized = trimmed.replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||
if (!sanitized.toLowerCase().endsWith('.png')) {
|
||||
return `${sanitized}.png`;
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
private _extractBase64(dataUrl: string): string | null {
|
||||
const commaIndex = dataUrl.indexOf(',');
|
||||
if (commaIndex === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return dataUrl.slice(commaIndex + 1);
|
||||
}
|
||||
|
||||
private async _detectShareSupport(): Promise<ShareSupport> {
|
||||
if (await this._isCapacitorShareAvailable()) {
|
||||
return 'native';
|
||||
|
|
@ -642,15 +1095,24 @@ export class ShareService {
|
|||
}
|
||||
|
||||
private async _getCapacitorSharePlugin(): Promise<any | null> {
|
||||
if (!Capacitor.isNativePlatform() || typeof window === 'undefined') {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const win = window as any;
|
||||
const sharePlugin = win.Capacitor?.Plugins?.Share;
|
||||
|
||||
if (sharePlugin && typeof sharePlugin.share === 'function') {
|
||||
return sharePlugin;
|
||||
if (Capacitor.isNativePlatform()) {
|
||||
const nativePlugin = win.Capacitor?.Plugins?.Share;
|
||||
if (nativePlugin && typeof nativePlugin.share === 'function') {
|
||||
return nativePlugin;
|
||||
}
|
||||
}
|
||||
|
||||
if (IS_ANDROID_WEB_VIEW) {
|
||||
const webViewPlugin = win.Capacitor?.Plugins?.Share;
|
||||
if (webViewPlugin && typeof webViewPlugin.share === 'function') {
|
||||
return webViewPlugin;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import { MatTooltip } from '@angular/material/tooltip';
|
|||
import { MatIcon } from '@angular/material/icon';
|
||||
import { SnackService } from '../../../core/snack/snack.service';
|
||||
import { GlobalConfigService } from '../../config/global-config.service';
|
||||
import { ShareService } from '../../../core/share/share.service';
|
||||
|
||||
interface DayData {
|
||||
date: Date;
|
||||
|
|
@ -48,6 +49,7 @@ export class ActivityHeatmapComponent {
|
|||
private readonly _taskArchiveService = inject(TaskArchiveService);
|
||||
private readonly _snackService = inject(SnackService);
|
||||
private readonly _globalConfigService = inject(GlobalConfigService);
|
||||
private readonly _shareService = inject(ShareService);
|
||||
|
||||
T: typeof T = T;
|
||||
weeks: WeekData[] = [];
|
||||
|
|
@ -450,35 +452,43 @@ export class ActivityHeatmapComponent {
|
|||
const contextTitle = this._activeWorkContextTitle();
|
||||
const canvas = this._renderToCanvas(data, contextTitle);
|
||||
|
||||
// Convert to blob
|
||||
const blob: Blob | null = await new Promise((resolve) => {
|
||||
canvas.toBlob((b) => resolve(b), 'image/png', 1.0);
|
||||
const result = await this._shareService.shareCanvasImage({
|
||||
canvas,
|
||||
filename: 'activity-heatmap.png',
|
||||
shareTitle: 'Activity Heatmap',
|
||||
});
|
||||
|
||||
if (!blob) {
|
||||
throw new Error('Failed to generate image');
|
||||
}
|
||||
|
||||
const file = new File([blob], 'activity-heatmap.png', { type: 'image/png' });
|
||||
|
||||
// Try native share API first
|
||||
if (navigator.canShare && navigator.canShare({ files: [file] })) {
|
||||
await navigator.share({
|
||||
files: [file],
|
||||
title: 'Activity Heatmap',
|
||||
if (result.success) {
|
||||
if (result.target === 'download') {
|
||||
const message = result.path
|
||||
? `Heatmap saved to ${result.path}`
|
||||
: 'Heatmap saved to device storage';
|
||||
const canOpen = this._shareService.canOpenDownloadResult(result);
|
||||
const actionConfig = canOpen
|
||||
? {
|
||||
actionStr: T.GLOBAL_SNACK.FILE_DOWNLOADED_BTN,
|
||||
actionFn: () => {
|
||||
void this._shareService.openDownloadResult(result);
|
||||
},
|
||||
}
|
||||
: {};
|
||||
this._snackService.open({
|
||||
type: 'SUCCESS',
|
||||
msg: message,
|
||||
isSkipTranslate: true,
|
||||
...actionConfig,
|
||||
});
|
||||
}
|
||||
} else if (result.error && result.error !== 'Share cancelled') {
|
||||
console.error('Share failed:', result.error);
|
||||
this._snackService.open({
|
||||
type: 'ERROR',
|
||||
msg: 'Failed to share heatmap',
|
||||
});
|
||||
} else {
|
||||
// Fallback: Download the file
|
||||
this._downloadFile(blob, 'activity-heatmap.png');
|
||||
}
|
||||
|
||||
this._snackService.open({
|
||||
type: 'SUCCESS',
|
||||
msg: 'Heatmap shared successfully',
|
||||
});
|
||||
} catch (error: any) {
|
||||
// User cancelled or error occurred
|
||||
if (error?.name !== 'AbortError') {
|
||||
const isAbort = error?.name === 'AbortError' || error?.error === 'Share cancelled';
|
||||
if (!isAbort) {
|
||||
console.error('Share failed:', error);
|
||||
this._snackService.open({
|
||||
type: 'ERROR',
|
||||
|
|
@ -629,15 +639,4 @@ export class ActivityHeatmapComponent {
|
|||
ctx.closePath();
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
private _downloadFile(blob: Blob, filename: string): void {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,9 @@ import { MatIconButton } from '@angular/material/button';
|
|||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { ShareService } from '../../../core/share/share.service';
|
||||
import { SnackService } from '../../../core/snack/snack.service';
|
||||
import { T } from '../../../t.const';
|
||||
|
||||
interface ChartClickEvent {
|
||||
active: ActiveElement[];
|
||||
|
|
@ -120,6 +123,8 @@ export class LazyChartComponent implements OnInit, OnDestroy {
|
|||
|
||||
private readonly chartLoaderService = inject(ChartLazyLoaderService);
|
||||
private readonly cdr = inject(ChangeDetectorRef);
|
||||
private readonly shareService = inject(ShareService);
|
||||
private readonly snackService = inject(SnackService);
|
||||
|
||||
isLoaded = false;
|
||||
isSharing = false;
|
||||
|
|
@ -205,26 +210,39 @@ export class LazyChartComponent implements OnInit, OnDestroy {
|
|||
this.cdr.markForCheck();
|
||||
|
||||
try {
|
||||
const decoratedCanvas = this._createCanvasWithTagline(this.chartInstance.canvas);
|
||||
|
||||
const blob: Blob | null = await new Promise((resolve) => {
|
||||
decoratedCanvas.toBlob((b) => resolve(b), 'image/png', 1.0);
|
||||
const result = await this.shareService.shareCanvasImage({
|
||||
canvas: this.chartInstance.canvas,
|
||||
filename: this.shareFileName,
|
||||
shareTitle: this.shareFileName,
|
||||
tagline: {
|
||||
text: 'With the Super Productivity App',
|
||||
},
|
||||
});
|
||||
|
||||
if (!blob) {
|
||||
throw new Error('Failed to export chart');
|
||||
if (result.success && result.target === 'download') {
|
||||
const message = result.path
|
||||
? `Chart image saved to ${result.path}`
|
||||
: 'Chart image saved to device storage';
|
||||
const canOpen = this.shareService.canOpenDownloadResult(result);
|
||||
const actionConfig = canOpen
|
||||
? {
|
||||
actionStr: T.GLOBAL_SNACK.FILE_DOWNLOADED_BTN,
|
||||
actionFn: () => {
|
||||
void this.shareService.openDownloadResult(result);
|
||||
},
|
||||
}
|
||||
: {};
|
||||
|
||||
this.snackService.open({
|
||||
type: 'SUCCESS',
|
||||
msg: message,
|
||||
isSkipTranslate: true,
|
||||
...actionConfig,
|
||||
});
|
||||
}
|
||||
|
||||
const filename = this.shareFileName?.trim() || 'chart.png';
|
||||
const file = new File([blob], filename, { type: 'image/png' });
|
||||
|
||||
if (navigator.canShare && navigator.canShare({ files: [file] })) {
|
||||
await navigator.share({
|
||||
files: [file],
|
||||
title: filename,
|
||||
});
|
||||
} else {
|
||||
this._downloadFile(blob, filename);
|
||||
if (!result.success && result.error && result.error !== 'Share cancelled') {
|
||||
console.error('Share failed:', result.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Share failed:', error);
|
||||
|
|
@ -233,45 +251,4 @@ export class LazyChartComponent implements OnInit, OnDestroy {
|
|||
this.cdr.markForCheck();
|
||||
}
|
||||
}
|
||||
|
||||
private _createCanvasWithTagline(sourceCanvas: HTMLCanvasElement): HTMLCanvasElement {
|
||||
const taglineHeight = 48;
|
||||
const newCanvas = document.createElement('canvas');
|
||||
newCanvas.width = sourceCanvas.width;
|
||||
newCanvas.height = sourceCanvas.height + taglineHeight;
|
||||
|
||||
const ctx = newCanvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
return sourceCanvas;
|
||||
}
|
||||
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(0, 0, newCanvas.width, newCanvas.height);
|
||||
ctx.drawImage(sourceCanvas, 0, 0);
|
||||
|
||||
const shareLabel = `With the Super Productivity App`;
|
||||
|
||||
ctx.fillStyle = 'rgba(0, 0, 0, 0.6)';
|
||||
|
||||
const baseFontSize = Math.max(20, Math.round(newCanvas.width / 40));
|
||||
ctx.font = `${baseFontSize}px system-ui, -apple-system, sans-serif`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
const taglineOffset = taglineHeight / 2;
|
||||
const taglineY = sourceCanvas.height + taglineOffset;
|
||||
ctx.fillText(shareLabel, newCanvas.width / 2, taglineY);
|
||||
|
||||
return newCanvas;
|
||||
}
|
||||
|
||||
private _downloadFile(blob: Blob, filename: string): void {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = filename;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
document.body.removeChild(anchor);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue