mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-01-22 18:30:09 +00:00
feat: make share work for native android
This commit is contained in:
parent
552636a780
commit
242bc25ac4
11 changed files with 261 additions and 34 deletions
1
android/.idea/gradle.xml
generated
1
android/.idea/gradle.xml
generated
|
|
@ -15,6 +15,7 @@
|
|||
<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/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-background-task/android" />
|
||||
</set>
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ dependencies {
|
|||
implementation project(':capacitor-app')
|
||||
implementation project(':capacitor-filesystem')
|
||||
implementation project(':capacitor-local-notifications')
|
||||
implementation project(':capacitor-share')
|
||||
implementation project(':capawesome-capacitor-android-dark-mode-support')
|
||||
implementation project(':capawesome-capacitor-background-task')
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,9 @@ project(':capacitor-filesystem').projectDir = new File('../node_modules/@capacit
|
|||
include ':capacitor-local-notifications'
|
||||
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'
|
||||
project(':capawesome-capacitor-android-dark-mode-support').projectDir = new File('../node_modules/@capawesome/capacitor-android-dark-mode-support/android')
|
||||
|
||||
|
|
|
|||
11
package-lock.json
generated
11
package-lock.json
generated
|
|
@ -49,6 +49,7 @@
|
|||
"@capacitor/core": "^7.4.3",
|
||||
"@capacitor/filesystem": "^7.1.1",
|
||||
"@capacitor/local-notifications": "^7.0.1",
|
||||
"@capacitor/share": "^7.0.2",
|
||||
"@capawesome/capacitor-android-dark-mode-support": "^7.0.0",
|
||||
"@capawesome/capacitor-background-task": "^7.0.1",
|
||||
"@csstools/stylelint-formatter-github": "^1.0.0",
|
||||
|
|
@ -4617,6 +4618,16 @@
|
|||
"@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": {
|
||||
"version": "1.0.2",
|
||||
"dev": true,
|
||||
|
|
|
|||
|
|
@ -168,6 +168,7 @@
|
|||
"@capacitor/core": "^7.4.3",
|
||||
"@capacitor/filesystem": "^7.1.1",
|
||||
"@capacitor/local-notifications": "^7.0.1",
|
||||
"@capacitor/share": "^7.0.2",
|
||||
"@capawesome/capacitor-android-dark-mode-support": "^7.0.0",
|
||||
"@capawesome/capacitor-background-task": "^7.0.1",
|
||||
"@csstools/stylelint-formatter-github": "^1.0.0",
|
||||
|
|
|
|||
|
|
@ -25,11 +25,18 @@
|
|||
}
|
||||
|
||||
<button
|
||||
(click)="copyTasksAsMarkdown()"
|
||||
(click)="shareTasksAsMarkdown()"
|
||||
mat-menu-item
|
||||
>
|
||||
<mat-icon>content_copy</mat-icon>
|
||||
<span class="text">{{ T.MH.COPY_TASK_LIST_MARKDOWN | translate }}</span>
|
||||
<mat-icon>{{ shareSupport === 'none' ? 'content_copy' : 'ios_share' }}</mat-icon>
|
||||
<span class="text">
|
||||
{{
|
||||
(shareSupport === 'none'
|
||||
? T.MH.COPY_TASK_LIST_MARKDOWN
|
||||
: T.MH.SHARE_TASK_LIST_MARKDOWN
|
||||
) | translate
|
||||
}}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -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 { T } from 'src/app/t.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 { SnackService } from '../../core/snack/snack.service';
|
||||
import { WorkContextMarkdownService } from '../../features/work-context/work-context-markdown.service';
|
||||
import { ShareService, ShareSupport } from '../../core/share/share.service';
|
||||
|
||||
@Component({
|
||||
selector: 'work-context-menu',
|
||||
|
|
@ -25,7 +33,7 @@ import { WorkContextMarkdownService } from '../../features/work-context/work-con
|
|||
imports: [RouterLink, RouterModule, MatMenuItem, TranslatePipe, MatIcon],
|
||||
standalone: true,
|
||||
})
|
||||
export class WorkContextMenuComponent {
|
||||
export class WorkContextMenuComponent implements OnInit {
|
||||
private _matDialog = inject(MatDialog);
|
||||
private _tagService = inject(TagService);
|
||||
private _projectService = inject(ProjectService);
|
||||
|
|
@ -33,6 +41,8 @@ export class WorkContextMenuComponent {
|
|||
private _router = inject(Router);
|
||||
private _snackService = inject(SnackService);
|
||||
private _markdownService = inject(WorkContextMarkdownService);
|
||||
private _shareService = inject(ShareService);
|
||||
private _cd = inject(ChangeDetectorRef);
|
||||
|
||||
// TODO: Skipped for migration because:
|
||||
// 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;
|
||||
isForProject: boolean = true;
|
||||
base: string = 'project';
|
||||
shareSupport: ShareSupport = 'none';
|
||||
|
||||
// TODO: Skipped for migration because:
|
||||
// Accessor inputs cannot be migrated as they are too complex.
|
||||
|
|
@ -50,6 +61,11 @@ export class WorkContextMenuComponent {
|
|||
this.base = this.isForProject ? 'project' : 'tag';
|
||||
}
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
const support = await this._shareService.getShareSupport();
|
||||
this._setShareSupport(support);
|
||||
}
|
||||
|
||||
async deleteTag(): Promise<void> {
|
||||
const tag = await this._tagService
|
||||
.getTagById$(this.contextId)
|
||||
|
|
@ -99,25 +115,58 @@ export class WorkContextMenuComponent {
|
|||
|
||||
protected readonly INBOX_PROJECT = INBOX_PROJECT;
|
||||
|
||||
async copyTasksAsMarkdown(): Promise<void> {
|
||||
const result = await this._markdownService.copyTasksAsMarkdown(
|
||||
this.contextId,
|
||||
this.isForProject,
|
||||
);
|
||||
async shareTasksAsMarkdown(): Promise<void> {
|
||||
const { status, markdown, contextTitle } =
|
||||
await this._markdownService.getMarkdownForContext(
|
||||
this.contextId,
|
||||
this.isForProject,
|
||||
);
|
||||
|
||||
if (result === 'copied') {
|
||||
this._snackService.open(T.GLOBAL_SNACK.COPY_TO_CLIPPBOARD);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result === 'empty') {
|
||||
if (status === 'empty' || !markdown) {
|
||||
this._snackService.open(T.GLOBAL_SNACK.NO_TASKS_TO_COPY);
|
||||
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({
|
||||
msg: 'Failed to copy to clipboard. Please copy manually.',
|
||||
msg: T.GLOBAL_SNACK.SHARE_FAILED,
|
||||
type: 'ERROR',
|
||||
});
|
||||
this._setShareSupport('none');
|
||||
}
|
||||
|
||||
private _setShareSupport(support: ShareSupport): void {
|
||||
this.shareSupport = support;
|
||||
this._cd.markForCheck();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
111
src/app/core/share/share.service.ts
Normal file
111
src/app/core/share/share.service.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
|
|
@ -18,26 +18,55 @@ export class WorkContextMarkdownService {
|
|||
contextId: string,
|
||||
isProjectContext: boolean,
|
||||
): 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';
|
||||
}
|
||||
|
||||
const markdown = this._buildMarkdownChecklist(tasks);
|
||||
const isSuccess = await this._copyToClipboard(markdown);
|
||||
|
||||
const isSuccess = await this.copyMarkdownText(markdown);
|
||||
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(
|
||||
contextId: string,
|
||||
isProjectContext: boolean,
|
||||
): Promise<TaskWithSubTasks[]> {
|
||||
const ids = await this._getTaskIds(contextId, isProjectContext);
|
||||
): Promise<{ tasks: TaskWithSubTasks[]; contextTitle: string | null }> {
|
||||
const { ids, contextTitle } = await this._getTaskIds(contextId, isProjectContext);
|
||||
|
||||
if (!ids.length) {
|
||||
return [];
|
||||
return { tasks: [], contextTitle };
|
||||
}
|
||||
|
||||
const tasks =
|
||||
|
|
@ -46,31 +75,37 @@ export class WorkContextMarkdownService {
|
|||
.pipe(first())
|
||||
.toPromise()) || [];
|
||||
|
||||
return tasks.filter((task): task is TaskWithSubTasks => !!task);
|
||||
return {
|
||||
tasks: tasks.filter((task): task is TaskWithSubTasks => !!task),
|
||||
contextTitle,
|
||||
};
|
||||
}
|
||||
|
||||
private async _getTaskIds(
|
||||
contextId: string,
|
||||
isProjectContext: boolean,
|
||||
): Promise<string[]> {
|
||||
): Promise<{ ids: string[]; contextTitle: string | null }> {
|
||||
if (isProjectContext) {
|
||||
const project = await this._projectService.getByIdOnce$(contextId).toPromise();
|
||||
if (!project) {
|
||||
return [];
|
||||
return { ids: [], contextTitle: null };
|
||||
}
|
||||
return this._uniqueIds([
|
||||
...(project.taskIds || []),
|
||||
...(project.backlogTaskIds || []),
|
||||
]);
|
||||
return {
|
||||
ids: this._uniqueIds([
|
||||
...(project.taskIds || []),
|
||||
...(project.backlogTaskIds || []),
|
||||
]),
|
||||
contextTitle: project.title,
|
||||
};
|
||||
}
|
||||
|
||||
const tag = await this._tagService.getTagById$(contextId).pipe(first()).toPromise();
|
||||
|
||||
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[] {
|
||||
|
|
|
|||
|
|
@ -1967,6 +1967,9 @@ const T = {
|
|||
GLOBAL_SNACK: {
|
||||
COPY_TO_CLIPPBOARD: 'GLOBAL_SNACK.COPY_TO_CLIPPBOARD',
|
||||
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',
|
||||
FILE_DOWNLOADED: 'GLOBAL_SNACK.FILE_DOWNLOADED',
|
||||
FILE_DOWNLOADED_BTN: 'GLOBAL_SNACK.FILE_DOWNLOADED_BTN',
|
||||
|
|
@ -2034,6 +2037,7 @@ const T = {
|
|||
TOGGLE_SHOW_NOTES: 'MH.TOGGLE_SHOW_NOTES',
|
||||
TOGGLE_TRACK_TIME: 'MH.TOGGLE_TRACK_TIME',
|
||||
TRIGGER_SYNC: 'MH.TRIGGER_SYNC',
|
||||
SHARE_TASK_LIST_MARKDOWN: 'MH.SHARE_TASK_LIST_MARKDOWN',
|
||||
COPY_TASK_LIST_MARKDOWN: 'MH.COPY_TASK_LIST_MARKDOWN',
|
||||
WORKLOG: 'MH.WORKLOG',
|
||||
SIDE_PANEL_MENU: 'MH.SIDE_PANEL_MENU',
|
||||
|
|
|
|||
|
|
@ -1938,6 +1938,9 @@
|
|||
"GLOBAL_SNACK": {
|
||||
"COPY_TO_CLIPPBOARD": "Copied to clipboard",
|
||||
"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",
|
||||
"FILE_DOWNLOADED": "{{fileName}} downloaded",
|
||||
"FILE_DOWNLOADED_BTN": "Open folder",
|
||||
|
|
@ -2004,6 +2007,7 @@
|
|||
"TOGGLE_SHOW_NOTES": "Show/Hide Project Notes",
|
||||
"TOGGLE_TRACK_TIME": "Start/Stop tracking time",
|
||||
"TRIGGER_SYNC": "Sync!",
|
||||
"SHARE_TASK_LIST_MARKDOWN": "Share Task List",
|
||||
"COPY_TASK_LIST_MARKDOWN": "Copy to Clipboard",
|
||||
"WORKLOG": "Worklog",
|
||||
"SIDE_PANEL_MENU": "Side Panel Menu"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue