diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 687c319e7d..e0b5f30816 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -21,6 +21,17 @@ android:theme="@style/AppTheme" android:usesCleartextTraffic="true"> + + + + + = 20" + } + }, "node_modules/methods": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", diff --git a/src/app/core/share/share.model.ts b/src/app/core/share/share.model.ts index 619a31143f..719115d87a 100644 --- a/src/app/core/share/share.model.ts +++ b/src/app/core/share/share.model.ts @@ -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; +} diff --git a/src/app/core/share/share.service.ts b/src/app/core/share/share.service.ts index 89569c2568..317e190ff6 100644 --- a/src/app/core/share/share.service.ts +++ b/src/app/core/share/share.service.ts @@ -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 { + 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 { 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 { + 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 { + return new Promise((resolve) => { + canvas.toBlob((blob) => resolve(blob), 'image/png', 1.0); + }); + } + + private async _shareCanvasViaNative( + base64: string, + filename: string, + title: string, + ): Promise { + 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 { + 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 { + 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 { + 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 { if (await this._isCapacitorShareAvailable()) { return 'native'; @@ -642,15 +1095,24 @@ export class ShareService { } private async _getCapacitorSharePlugin(): Promise { - 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; diff --git a/src/app/features/metric/activity-heatmap/activity-heatmap.component.ts b/src/app/features/metric/activity-heatmap/activity-heatmap.component.ts index 18f2a8a223..634e00ff96 100644 --- a/src/app/features/metric/activity-heatmap/activity-heatmap.component.ts +++ b/src/app/features/metric/activity-heatmap/activity-heatmap.component.ts @@ -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); - } } diff --git a/src/app/features/metric/lazy-chart/lazy-chart.component.ts b/src/app/features/metric/lazy-chart/lazy-chart.component.ts index dfdc33db92..2385ed8cab 100644 --- a/src/app/features/metric/lazy-chart/lazy-chart.component.ts +++ b/src/app/features/metric/lazy-chart/lazy-chart.component.ts @@ -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); - } }