feat: make share work for native android

This commit is contained in:
Johannes Millan 2025-10-22 15:08:18 +02:00
parent 552636a780
commit 242bc25ac4
11 changed files with 261 additions and 34 deletions

View file

@ -15,6 +15,7 @@
<option value="$PROJECT_DIR$/../node_modules/@capacitor/app/android" /> <option value="$PROJECT_DIR$/../node_modules/@capacitor/app/android" />
<option value="$PROJECT_DIR$/../node_modules/@capacitor/filesystem/android" /> <option value="$PROJECT_DIR$/../node_modules/@capacitor/filesystem/android" />
<option value="$PROJECT_DIR$/../node_modules/@capacitor/local-notifications/android" /> <option value="$PROJECT_DIR$/../node_modules/@capacitor/local-notifications/android" />
<option value="$PROJECT_DIR$/../node_modules/@capacitor/share/android" />
<option value="$PROJECT_DIR$/../node_modules/@capawesome/capacitor-android-dark-mode-support/android" /> <option value="$PROJECT_DIR$/../node_modules/@capawesome/capacitor-android-dark-mode-support/android" />
<option value="$PROJECT_DIR$/../node_modules/@capawesome/capacitor-background-task/android" /> <option value="$PROJECT_DIR$/../node_modules/@capawesome/capacitor-background-task/android" />
</set> </set>

View file

@ -12,6 +12,7 @@ dependencies {
implementation project(':capacitor-app') implementation project(':capacitor-app')
implementation project(':capacitor-filesystem') implementation project(':capacitor-filesystem')
implementation project(':capacitor-local-notifications') implementation project(':capacitor-local-notifications')
implementation project(':capacitor-share')
implementation project(':capawesome-capacitor-android-dark-mode-support') implementation project(':capawesome-capacitor-android-dark-mode-support')
implementation project(':capawesome-capacitor-background-task') implementation project(':capawesome-capacitor-background-task')

View file

@ -11,6 +11,9 @@ project(':capacitor-filesystem').projectDir = new File('../node_modules/@capacit
include ':capacitor-local-notifications' include ':capacitor-local-notifications'
project(':capacitor-local-notifications').projectDir = new File('../node_modules/@capacitor/local-notifications/android') project(':capacitor-local-notifications').projectDir = new File('../node_modules/@capacitor/local-notifications/android')
include ':capacitor-share'
project(':capacitor-share').projectDir = new File('../node_modules/@capacitor/share/android')
include ':capawesome-capacitor-android-dark-mode-support' include ':capawesome-capacitor-android-dark-mode-support'
project(':capawesome-capacitor-android-dark-mode-support').projectDir = new File('../node_modules/@capawesome/capacitor-android-dark-mode-support/android') project(':capawesome-capacitor-android-dark-mode-support').projectDir = new File('../node_modules/@capawesome/capacitor-android-dark-mode-support/android')

11
package-lock.json generated
View file

@ -49,6 +49,7 @@
"@capacitor/core": "^7.4.3", "@capacitor/core": "^7.4.3",
"@capacitor/filesystem": "^7.1.1", "@capacitor/filesystem": "^7.1.1",
"@capacitor/local-notifications": "^7.0.1", "@capacitor/local-notifications": "^7.0.1",
"@capacitor/share": "^7.0.2",
"@capawesome/capacitor-android-dark-mode-support": "^7.0.0", "@capawesome/capacitor-android-dark-mode-support": "^7.0.0",
"@capawesome/capacitor-background-task": "^7.0.1", "@capawesome/capacitor-background-task": "^7.0.1",
"@csstools/stylelint-formatter-github": "^1.0.0", "@csstools/stylelint-formatter-github": "^1.0.0",
@ -4617,6 +4618,16 @@
"@capacitor/core": ">=7.0.0" "@capacitor/core": ">=7.0.0"
} }
}, },
"node_modules/@capacitor/share": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@capacitor/share/-/share-7.0.2.tgz",
"integrity": "sha512-VyNPo/9831xnL17IMDeft5yNdBjoKNb451P95sRcr69hulRDqHc+kndqOVaMXnaA6IyBdWnnFv/n1HUf4cXpGw==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"@capacitor/core": ">=7.0.0"
}
},
"node_modules/@capacitor/synapse": { "node_modules/@capacitor/synapse": {
"version": "1.0.2", "version": "1.0.2",
"dev": true, "dev": true,

View file

@ -168,6 +168,7 @@
"@capacitor/core": "^7.4.3", "@capacitor/core": "^7.4.3",
"@capacitor/filesystem": "^7.1.1", "@capacitor/filesystem": "^7.1.1",
"@capacitor/local-notifications": "^7.0.1", "@capacitor/local-notifications": "^7.0.1",
"@capacitor/share": "^7.0.2",
"@capawesome/capacitor-android-dark-mode-support": "^7.0.0", "@capawesome/capacitor-android-dark-mode-support": "^7.0.0",
"@capawesome/capacitor-background-task": "^7.0.1", "@capawesome/capacitor-background-task": "^7.0.1",
"@csstools/stylelint-formatter-github": "^1.0.0", "@csstools/stylelint-formatter-github": "^1.0.0",

View file

@ -25,11 +25,18 @@
} }
<button <button
(click)="copyTasksAsMarkdown()" (click)="shareTasksAsMarkdown()"
mat-menu-item mat-menu-item
> >
<mat-icon>content_copy</mat-icon> <mat-icon>{{ shareSupport === 'none' ? 'content_copy' : 'ios_share' }}</mat-icon>
<span class="text">{{ T.MH.COPY_TASK_LIST_MARKDOWN | translate }}</span> <span class="text">
{{
(shareSupport === 'none'
? T.MH.COPY_TASK_LIST_MARKDOWN
: T.MH.SHARE_TASK_LIST_MARKDOWN
) | translate
}}
</span>
</button> </button>
<button <button

View file

@ -1,4 +1,11 @@
import { ChangeDetectionStrategy, Component, inject, Input } from '@angular/core'; import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
OnInit,
inject,
Input,
} from '@angular/core';
import { WorkContextType } from '../../features/work-context/work-context.model'; import { WorkContextType } from '../../features/work-context/work-context.model';
import { T } from 'src/app/t.const'; import { T } from 'src/app/t.const';
import { TODAY_TAG } from '../../features/tag/tag.const'; import { TODAY_TAG } from '../../features/tag/tag.const';
@ -16,6 +23,7 @@ import { MatIcon } from '@angular/material/icon';
import { INBOX_PROJECT } from '../../features/project/project.const'; import { INBOX_PROJECT } from '../../features/project/project.const';
import { SnackService } from '../../core/snack/snack.service'; import { SnackService } from '../../core/snack/snack.service';
import { WorkContextMarkdownService } from '../../features/work-context/work-context-markdown.service'; import { WorkContextMarkdownService } from '../../features/work-context/work-context-markdown.service';
import { ShareService, ShareSupport } from '../../core/share/share.service';
@Component({ @Component({
selector: 'work-context-menu', selector: 'work-context-menu',
@ -25,7 +33,7 @@ import { WorkContextMarkdownService } from '../../features/work-context/work-con
imports: [RouterLink, RouterModule, MatMenuItem, TranslatePipe, MatIcon], imports: [RouterLink, RouterModule, MatMenuItem, TranslatePipe, MatIcon],
standalone: true, standalone: true,
}) })
export class WorkContextMenuComponent { export class WorkContextMenuComponent implements OnInit {
private _matDialog = inject(MatDialog); private _matDialog = inject(MatDialog);
private _tagService = inject(TagService); private _tagService = inject(TagService);
private _projectService = inject(ProjectService); private _projectService = inject(ProjectService);
@ -33,6 +41,8 @@ export class WorkContextMenuComponent {
private _router = inject(Router); private _router = inject(Router);
private _snackService = inject(SnackService); private _snackService = inject(SnackService);
private _markdownService = inject(WorkContextMarkdownService); private _markdownService = inject(WorkContextMarkdownService);
private _shareService = inject(ShareService);
private _cd = inject(ChangeDetectorRef);
// TODO: Skipped for migration because: // TODO: Skipped for migration because:
// This input is used in a control flow expression (e.g. `@if` or `*ngIf`) // This input is used in a control flow expression (e.g. `@if` or `*ngIf`)
@ -42,6 +52,7 @@ export class WorkContextMenuComponent {
TODAY_TAG_ID: string = TODAY_TAG.id as string; TODAY_TAG_ID: string = TODAY_TAG.id as string;
isForProject: boolean = true; isForProject: boolean = true;
base: string = 'project'; base: string = 'project';
shareSupport: ShareSupport = 'none';
// TODO: Skipped for migration because: // TODO: Skipped for migration because:
// Accessor inputs cannot be migrated as they are too complex. // Accessor inputs cannot be migrated as they are too complex.
@ -50,6 +61,11 @@ export class WorkContextMenuComponent {
this.base = this.isForProject ? 'project' : 'tag'; this.base = this.isForProject ? 'project' : 'tag';
} }
async ngOnInit(): Promise<void> {
const support = await this._shareService.getShareSupport();
this._setShareSupport(support);
}
async deleteTag(): Promise<void> { async deleteTag(): Promise<void> {
const tag = await this._tagService const tag = await this._tagService
.getTagById$(this.contextId) .getTagById$(this.contextId)
@ -99,25 +115,58 @@ export class WorkContextMenuComponent {
protected readonly INBOX_PROJECT = INBOX_PROJECT; protected readonly INBOX_PROJECT = INBOX_PROJECT;
async copyTasksAsMarkdown(): Promise<void> { async shareTasksAsMarkdown(): Promise<void> {
const result = await this._markdownService.copyTasksAsMarkdown( const { status, markdown, contextTitle } =
this.contextId, await this._markdownService.getMarkdownForContext(
this.isForProject, this.contextId,
); this.isForProject,
);
if (result === 'copied') { if (status === 'empty' || !markdown) {
this._snackService.open(T.GLOBAL_SNACK.COPY_TO_CLIPPBOARD);
return;
}
if (result === 'empty') {
this._snackService.open(T.GLOBAL_SNACK.NO_TASKS_TO_COPY); this._snackService.open(T.GLOBAL_SNACK.NO_TASKS_TO_COPY);
return; return;
} }
const shareResult = await this._shareService.shareText({
title: contextTitle ?? 'Super Productivity',
text: markdown,
});
if (shareResult === 'shared') {
if (this.shareSupport === 'none') {
const support = await this._shareService.getShareSupport();
this._setShareSupport(support);
}
return;
}
if (shareResult === 'cancelled') {
return;
}
const didCopy = await this._markdownService.copyMarkdownText(markdown);
if (didCopy) {
if (shareResult === 'unavailable') {
this._snackService.open(T.GLOBAL_SNACK.SHARE_UNAVAILABLE_FALLBACK);
this._setShareSupport('none');
} else if (shareResult === 'failed') {
this._snackService.open(T.GLOBAL_SNACK.SHARE_FAILED_FALLBACK);
this._setShareSupport('none');
} else {
this._snackService.open(T.GLOBAL_SNACK.COPY_TO_CLIPPBOARD);
}
return;
}
this._snackService.open({ this._snackService.open({
msg: 'Failed to copy to clipboard. Please copy manually.', msg: T.GLOBAL_SNACK.SHARE_FAILED,
type: 'ERROR', type: 'ERROR',
}); });
this._setShareSupport('none');
}
private _setShareSupport(support: ShareSupport): void {
this.shareSupport = support;
this._cd.markForCheck();
} }
} }

View file

@ -0,0 +1,111 @@
import { Injectable } from '@angular/core';
import { Capacitor } from '@capacitor/core';
import { Share } from '@capacitor/share';
export type ShareOutcome = 'shared' | 'cancelled' | 'unavailable' | 'failed';
export type ShareSupport = 'native' | 'web' | 'none';
interface ShareParams {
title?: string | null;
text: string;
}
@Injectable({
providedIn: 'root',
})
export class ShareService {
private _shareSupportPromise?: Promise<ShareSupport>;
async shareText({ title, text }: ShareParams): Promise<ShareOutcome> {
if (!text) {
return 'failed';
}
if (typeof window === 'undefined') {
return 'unavailable';
}
const support = await this.getShareSupport();
if (support === 'native') {
try {
await Share.share({
title: title ?? undefined,
text,
});
return 'shared';
} catch (err) {
if (this._isCancelled(err)) {
return 'cancelled';
}
console.error('Native share failed:', err);
return 'failed';
}
}
if (support === 'web' && typeof navigator.share === 'function') {
try {
await navigator.share({
title: title ?? undefined,
text,
});
return 'shared';
} catch (err) {
if (this._isCancelled(err)) {
return 'cancelled';
}
console.error('Web share failed:', err);
return 'failed';
}
}
return 'unavailable';
}
async getShareSupport(): Promise<ShareSupport> {
if (typeof window === 'undefined') {
return 'none';
}
if (!this._shareSupportPromise) {
this._shareSupportPromise = this._detectShareSupport();
}
return this._shareSupportPromise;
}
private _isCancelled(err: unknown): boolean {
if (!err) {
return false;
}
const message = (err as Error)?.message ?? '';
const name = (err as Error)?.name ?? '';
return (
name === 'AbortError' ||
name === 'NotAllowedError' ||
message.toLowerCase().includes('cancel') ||
message.toLowerCase().includes('abort')
);
}
private async _detectShareSupport(): Promise<ShareSupport> {
if (Capacitor.isNativePlatform()) {
try {
const canShare = await Share.canShare();
if (canShare.value) {
return 'native';
}
} catch (err) {
console.warn('Share.canShare failed:', err);
}
return 'none';
}
if (typeof navigator !== 'undefined' && typeof navigator.share === 'function') {
return 'web';
}
return 'none';
}
}

View file

@ -18,26 +18,55 @@ export class WorkContextMarkdownService {
contextId: string, contextId: string,
isProjectContext: boolean, isProjectContext: boolean,
): Promise<'copied' | 'empty' | 'failed'> { ): Promise<'copied' | 'empty' | 'failed'> {
const tasks = await this._loadTasks(contextId, isProjectContext); const { status, markdown } = await this.getMarkdownForContext(
contextId,
isProjectContext,
);
if (!tasks.length) { if (status === 'empty' || !markdown) {
return 'empty'; return 'empty';
} }
const markdown = this._buildMarkdownChecklist(tasks); const isSuccess = await this.copyMarkdownText(markdown);
const isSuccess = await this._copyToClipboard(markdown);
return isSuccess ? 'copied' : 'failed'; return isSuccess ? 'copied' : 'failed';
} }
async getMarkdownForContext(
contextId: string,
isProjectContext: boolean,
): Promise<{
status: 'empty' | 'ok';
markdown?: string;
contextTitle?: string | null;
}> {
const { tasks, contextTitle } = await this._loadTasks(contextId, isProjectContext);
if (!tasks.length) {
return { status: 'empty', contextTitle };
}
return {
status: 'ok',
markdown: this._buildMarkdownChecklist(tasks),
contextTitle,
};
}
async copyMarkdownText(markdown: string): Promise<boolean> {
if (!markdown) {
return false;
}
return this._copyToClipboard(markdown);
}
private async _loadTasks( private async _loadTasks(
contextId: string, contextId: string,
isProjectContext: boolean, isProjectContext: boolean,
): Promise<TaskWithSubTasks[]> { ): Promise<{ tasks: TaskWithSubTasks[]; contextTitle: string | null }> {
const ids = await this._getTaskIds(contextId, isProjectContext); const { ids, contextTitle } = await this._getTaskIds(contextId, isProjectContext);
if (!ids.length) { if (!ids.length) {
return []; return { tasks: [], contextTitle };
} }
const tasks = const tasks =
@ -46,31 +75,37 @@ export class WorkContextMarkdownService {
.pipe(first()) .pipe(first())
.toPromise()) || []; .toPromise()) || [];
return tasks.filter((task): task is TaskWithSubTasks => !!task); return {
tasks: tasks.filter((task): task is TaskWithSubTasks => !!task),
contextTitle,
};
} }
private async _getTaskIds( private async _getTaskIds(
contextId: string, contextId: string,
isProjectContext: boolean, isProjectContext: boolean,
): Promise<string[]> { ): Promise<{ ids: string[]; contextTitle: string | null }> {
if (isProjectContext) { if (isProjectContext) {
const project = await this._projectService.getByIdOnce$(contextId).toPromise(); const project = await this._projectService.getByIdOnce$(contextId).toPromise();
if (!project) { if (!project) {
return []; return { ids: [], contextTitle: null };
} }
return this._uniqueIds([ return {
...(project.taskIds || []), ids: this._uniqueIds([
...(project.backlogTaskIds || []), ...(project.taskIds || []),
]); ...(project.backlogTaskIds || []),
]),
contextTitle: project.title,
};
} }
const tag = await this._tagService.getTagById$(contextId).pipe(first()).toPromise(); const tag = await this._tagService.getTagById$(contextId).pipe(first()).toPromise();
if (!tag) { if (!tag) {
return []; return { ids: [], contextTitle: null };
} }
return this._uniqueIds(tag.taskIds || []); return { ids: this._uniqueIds(tag.taskIds || []), contextTitle: tag.title };
} }
private _uniqueIds(ids: (string | null | undefined)[]): string[] { private _uniqueIds(ids: (string | null | undefined)[]): string[] {

View file

@ -1967,6 +1967,9 @@ const T = {
GLOBAL_SNACK: { GLOBAL_SNACK: {
COPY_TO_CLIPPBOARD: 'GLOBAL_SNACK.COPY_TO_CLIPPBOARD', COPY_TO_CLIPPBOARD: 'GLOBAL_SNACK.COPY_TO_CLIPPBOARD',
NO_TASKS_TO_COPY: 'GLOBAL_SNACK.NO_TASKS_TO_COPY', NO_TASKS_TO_COPY: 'GLOBAL_SNACK.NO_TASKS_TO_COPY',
SHARE_UNAVAILABLE_FALLBACK: 'GLOBAL_SNACK.SHARE_UNAVAILABLE_FALLBACK',
SHARE_FAILED_FALLBACK: 'GLOBAL_SNACK.SHARE_FAILED_FALLBACK',
SHARE_FAILED: 'GLOBAL_SNACK.SHARE_FAILED',
ERR_COMPRESSION: 'GLOBAL_SNACK.ERR_COMPRESSION', ERR_COMPRESSION: 'GLOBAL_SNACK.ERR_COMPRESSION',
FILE_DOWNLOADED: 'GLOBAL_SNACK.FILE_DOWNLOADED', FILE_DOWNLOADED: 'GLOBAL_SNACK.FILE_DOWNLOADED',
FILE_DOWNLOADED_BTN: 'GLOBAL_SNACK.FILE_DOWNLOADED_BTN', FILE_DOWNLOADED_BTN: 'GLOBAL_SNACK.FILE_DOWNLOADED_BTN',
@ -2034,6 +2037,7 @@ const T = {
TOGGLE_SHOW_NOTES: 'MH.TOGGLE_SHOW_NOTES', TOGGLE_SHOW_NOTES: 'MH.TOGGLE_SHOW_NOTES',
TOGGLE_TRACK_TIME: 'MH.TOGGLE_TRACK_TIME', TOGGLE_TRACK_TIME: 'MH.TOGGLE_TRACK_TIME',
TRIGGER_SYNC: 'MH.TRIGGER_SYNC', TRIGGER_SYNC: 'MH.TRIGGER_SYNC',
SHARE_TASK_LIST_MARKDOWN: 'MH.SHARE_TASK_LIST_MARKDOWN',
COPY_TASK_LIST_MARKDOWN: 'MH.COPY_TASK_LIST_MARKDOWN', COPY_TASK_LIST_MARKDOWN: 'MH.COPY_TASK_LIST_MARKDOWN',
WORKLOG: 'MH.WORKLOG', WORKLOG: 'MH.WORKLOG',
SIDE_PANEL_MENU: 'MH.SIDE_PANEL_MENU', SIDE_PANEL_MENU: 'MH.SIDE_PANEL_MENU',

View file

@ -1938,6 +1938,9 @@
"GLOBAL_SNACK": { "GLOBAL_SNACK": {
"COPY_TO_CLIPPBOARD": "Copied to clipboard", "COPY_TO_CLIPPBOARD": "Copied to clipboard",
"NO_TASKS_TO_COPY": "No tasks to copy", "NO_TASKS_TO_COPY": "No tasks to copy",
"SHARE_UNAVAILABLE_FALLBACK": "Copied to clipboard.",
"SHARE_FAILED_FALLBACK": "Sharing failed. Copied to clipboard instead.",
"SHARE_FAILED": "Sharing failed. Please copy manually.",
"ERR_COMPRESSION": "Error for compression interface", "ERR_COMPRESSION": "Error for compression interface",
"FILE_DOWNLOADED": "{{fileName}} downloaded", "FILE_DOWNLOADED": "{{fileName}} downloaded",
"FILE_DOWNLOADED_BTN": "Open folder", "FILE_DOWNLOADED_BTN": "Open folder",
@ -2004,6 +2007,7 @@
"TOGGLE_SHOW_NOTES": "Show/Hide Project Notes", "TOGGLE_SHOW_NOTES": "Show/Hide Project Notes",
"TOGGLE_TRACK_TIME": "Start/Stop tracking time", "TOGGLE_TRACK_TIME": "Start/Stop tracking time",
"TRIGGER_SYNC": "Sync!", "TRIGGER_SYNC": "Sync!",
"SHARE_TASK_LIST_MARKDOWN": "Share Task List",
"COPY_TASK_LIST_MARKDOWN": "Copy to Clipboard", "COPY_TASK_LIST_MARKDOWN": "Copy to Clipboard",
"WORKLOG": "Worklog", "WORKLOG": "Worklog",
"SIDE_PANEL_MENU": "Side Panel Menu" "SIDE_PANEL_MENU": "Side Panel Menu"