|
@@ -1,11 +1,15 @@
|
|
|
-import { Injectable, inject } from '@angular/core';
|
|
|
|
|
|
|
+import { Injectable, inject, signal } from '@angular/core';
|
|
|
import { registerPlugin } from '@capacitor/core';
|
|
import { registerPlugin } from '@capacitor/core';
|
|
|
import { isNativeApp } from './api/platform-proxy.service';
|
|
import { isNativeApp } from './api/platform-proxy.service';
|
|
|
import { DeepLinkService } from './deep-link.service';
|
|
import { DeepLinkService } from './deep-link.service';
|
|
|
|
|
+import { UiMToastService } from '../ui/mobile/components/toast/toast';
|
|
|
|
|
|
|
|
/** 通知渠道 id(Android 8+ 必须先创建同名渠道,否则通知被静默丢弃) */
|
|
/** 通知渠道 id(Android 8+ 必须先创建同名渠道,否则通知被静默丢弃) */
|
|
|
const NOTIFY_CHANNEL_ID = 'fmode-default';
|
|
const NOTIFY_CHANNEL_ID = 'fmode-default';
|
|
|
|
|
|
|
|
|
|
+/** 「已问过通知权限」的持久化标记(设备级,登出不清) */
|
|
|
|
|
+const PERM_PROMPTED_KEY = 'fmode.permPrompted';
|
|
|
|
|
+
|
|
|
/** 自建原生通知插件(android/.../FmodeNotifyPlugin.java)的 TS 形状 */
|
|
/** 自建原生通知插件(android/.../FmodeNotifyPlugin.java)的 TS 形状 */
|
|
|
interface FmodeNotifyPlugin {
|
|
interface FmodeNotifyPlugin {
|
|
|
show(o: { id?: number; title: string; body: string; deepLink?: string }): Promise<{ id: number }>;
|
|
show(o: { id?: number; title: string; body: string; deepLink?: string }): Promise<{ id: number }>;
|
|
@@ -65,11 +69,21 @@ export interface AppNotice {
|
|
|
@Injectable({ providedIn: 'root' })
|
|
@Injectable({ providedIn: 'root' })
|
|
|
export class AppNotificationService {
|
|
export class AppNotificationService {
|
|
|
private readonly deepLink = inject(DeepLinkService);
|
|
private readonly deepLink = inject(DeepLinkService);
|
|
|
|
|
+ /** 轻提示(权限状态变化时给反馈) */
|
|
|
|
|
+ private readonly toast = inject(UiMToastService);
|
|
|
|
|
|
|
|
/** 是否已尝试过初始化(避免每次都走一遍插件加载) */
|
|
/** 是否已尝试过初始化(避免每次都走一遍插件加载) */
|
|
|
private initialized = false;
|
|
private initialized = false;
|
|
|
- /** 用户是否授予了通知权限(`null` = 还没问过) */
|
|
|
|
|
- private granted: boolean | null = null;
|
|
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 通知权限状态(`null` = 还没查过)。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 🔴 必须是 signal(2026-11-15 改)。原来是个普通字段且**永不清除**,
|
|
|
|
|
+ * 导致用户去系统设置开了权限、回到 App 还是 false —— UI 完全感知不到变化。
|
|
|
|
|
+ * 改成 signal 后「我的」页和引导层都能订阅,状态一变就跟着更新。
|
|
|
|
|
+ */
|
|
|
|
|
+ private readonly granted = signal<boolean | null>(null);
|
|
|
|
|
+ /** 供 UI 订阅的只读视图 */
|
|
|
|
|
+ readonly grantedState = this.granted.asReadonly();
|
|
|
/** key → 通知 id 的映射(覆盖而非叠加) */
|
|
/** key → 通知 id 的映射(覆盖而非叠加) */
|
|
|
private readonly idsByKey = new Map<string, number>();
|
|
private readonly idsByKey = new Map<string, number>();
|
|
|
private nextId = 1000;
|
|
private nextId = 1000;
|
|
@@ -104,6 +118,25 @@ export class AppNotificationService {
|
|
|
const target = this.deepLink.toRoute(pending.deepLink);
|
|
const target = this.deepLink.toRoute(pending.deepLink);
|
|
|
if (target) void this.deepLink.navigate(target);
|
|
if (target) void this.deepLink.navigate(target);
|
|
|
}
|
|
}
|
|
|
|
|
+
|
|
|
|
|
+ /*
|
|
|
|
|
+ * 回到前台时刷新权限状态(2026-11-15)。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 🔴 为什么必须有:用户在我们引导下跳去系统设置页开启通知,
|
|
|
|
|
+ * 再返回 App —— 没有任何事件告诉我们「他开了没」。
|
|
|
|
|
+ * 不刷新的话,界面永远显示「未开启」,用户会以为操作失败了。
|
|
|
|
|
+ *
|
|
|
|
|
+ * Android 上 Activity.onResume 会触发 `appStateChange{isActive:true}`,
|
|
|
|
|
+ * 系统设置页返回正好命中这个时机。
|
|
|
|
|
+ */
|
|
|
|
|
+ const { App } = await import('@capacitor/app');
|
|
|
|
|
+ await App.addListener('appStateChange', ({ isActive }) => {
|
|
|
|
|
+ if (!isActive) return;
|
|
|
|
|
+ void this.refreshGranted();
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ /* 启动时也先查一次,让 UI 一开始就有准确状态 */
|
|
|
|
|
+ void this.isGranted(true);
|
|
|
} catch {
|
|
} catch {
|
|
|
/* 插件不可用:通知是增强能力,失败不能让 App 起不来 */
|
|
/* 插件不可用:通知是增强能力,失败不能让 App 起不来 */
|
|
|
}
|
|
}
|
|
@@ -145,19 +178,70 @@ export class AppNotificationService {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/** 当前是否已授权(不发请求,只查询;未知时返回 null) */
|
|
/** 当前是否已授权(不发请求,只查询;未知时返回 null) */
|
|
|
- async isGranted(): Promise<boolean | null> {
|
|
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 登录后是否该弹「开启通知」引导(2026-11-15)
|
|
|
|
|
+ *
|
|
|
|
|
+ * 规则:
|
|
|
|
|
+ * · 非原生(浏览器)→ 不问
|
|
|
|
|
+ * · 已授权 → 不问
|
|
|
|
|
+ * · **问过且被拒绝** → 不再自动问(只在「我的」页保留入口)
|
|
|
|
|
+ *
|
|
|
|
|
+ * 🔴 「问过」要持久化:只放内存的话,用户每次冷启动都会被问一遍,
|
|
|
|
|
+ * 很快从「友好引导」变成「骚扰」——那是把人推去点拒绝的最快方式。
|
|
|
|
|
+ * 用一个 localStorage 标记,登出时不清(这是设备级偏好,不是账号级)。
|
|
|
|
|
+ */
|
|
|
|
|
+ shouldPromptPermission(): boolean {
|
|
|
|
|
+ if (!isNativeApp()) return false;
|
|
|
|
|
+ try {
|
|
|
|
|
+ if (localStorage.getItem(PERM_PROMPTED_KEY) === '1') return false;
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ /* 隐私模式读不到 → 当作没问过 */
|
|
|
|
|
+ }
|
|
|
|
|
+ return true;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 标记「已问过」,之后不再自动弹 */
|
|
|
|
|
+ markPrompted(): void {
|
|
|
|
|
+ try {
|
|
|
|
|
+ localStorage.setItem(PERM_PROMPTED_KEY, '1');
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ /* 忽略 */
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 查询权限状态。
|
|
|
|
|
+ *
|
|
|
|
|
+ * @param force 为 true 时**绕过缓存**重新问系统(2026-11-15 新增)。
|
|
|
|
|
+ * 用户从系统设置回来必须用 force,否则永远读到旧值。
|
|
|
|
|
+ */
|
|
|
|
|
+ async isGranted(force = false): Promise<boolean | null> {
|
|
|
if (!isNativeApp()) return null;
|
|
if (!isNativeApp()) return null;
|
|
|
- if (this.granted !== null) return this.granted;
|
|
|
|
|
|
|
+ if (!force && this.granted() !== null) return this.granted();
|
|
|
try {
|
|
try {
|
|
|
const { LocalNotifications } = await import('@capacitor/local-notifications');
|
|
const { LocalNotifications } = await import('@capacitor/local-notifications');
|
|
|
const cur = await LocalNotifications.checkPermissions();
|
|
const cur = await LocalNotifications.checkPermissions();
|
|
|
- this.granted = cur.display === 'granted';
|
|
|
|
|
- return this.granted;
|
|
|
|
|
|
|
+ this.granted.set(cur.display === 'granted');
|
|
|
|
|
+ return this.granted();
|
|
|
} catch {
|
|
} catch {
|
|
|
return null;
|
|
return null;
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 强制刷新权限状态(用户从系统设置返回时调用)。
|
|
|
|
|
+ *
|
|
|
|
|
+ * @returns 刷新后的状态;若**从 false 变成 true**,顺便弹一条成功提示。
|
|
|
|
|
+ */
|
|
|
|
|
+ async refreshGranted(): Promise<boolean | null> {
|
|
|
|
|
+ const before = this.granted();
|
|
|
|
|
+ const now = await this.isGranted(true);
|
|
|
|
|
+ if (before === false && now === true) {
|
|
|
|
|
+ this.toast.success('通知权限已开启,任务完成后会提醒你');
|
|
|
|
|
+ }
|
|
|
|
|
+ return now;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
/**
|
|
/**
|
|
|
* 申请通知权限。
|
|
* 申请通知权限。
|
|
|
*
|
|
*
|
|
@@ -165,24 +249,30 @@ export class AppNotificationService {
|
|
|
*/
|
|
*/
|
|
|
async request(): Promise<boolean> {
|
|
async request(): Promise<boolean> {
|
|
|
if (!isNativeApp()) return false;
|
|
if (!isNativeApp()) return false;
|
|
|
- if (this.granted !== null) return this.granted;
|
|
|
|
|
|
|
+ /*
|
|
|
|
|
+ * 🔴 只在**已授权**时短路(2026-11-15 修正)。
|
|
|
|
|
+ * 原来写的是 `if (this.granted !== null) return this.granted;` ——
|
|
|
|
|
+ * 只要 `isGranted()` 先跑过并写入了 false,`request()` 就永远直接返回 false,
|
|
|
|
|
+ * **连系统弹窗都不会弹**。这是个独立的 bug,会把用户彻底挡在门外。
|
|
|
|
|
+ */
|
|
|
|
|
+ if (this.granted() === true) return true;
|
|
|
try {
|
|
try {
|
|
|
const { LocalNotifications } = await import('@capacitor/local-notifications');
|
|
const { LocalNotifications } = await import('@capacitor/local-notifications');
|
|
|
const cur = await LocalNotifications.checkPermissions();
|
|
const cur = await LocalNotifications.checkPermissions();
|
|
|
if (cur.display === 'granted') {
|
|
if (cur.display === 'granted') {
|
|
|
- this.granted = true;
|
|
|
|
|
|
|
+ this.granted.set(true);
|
|
|
return true;
|
|
return true;
|
|
|
}
|
|
}
|
|
|
/*
|
|
/*
|
|
|
* 已被拒绝过一次 → 系统不再弹窗,直接返回 denied。
|
|
* 已被拒绝过一次 → 系统不再弹窗,直接返回 denied。
|
|
|
- * 此时 `this.granted` 会记成 false,上层可用 `isGranted()` 区分
|
|
|
|
|
- * 「还没问过」与「问过被拒」,从而决定是否给引导提示。
|
|
|
|
|
|
|
+ * 上层可用 `isGranted()` 区分「还没问过」与「问过被拒」,
|
|
|
|
|
+ * 从而决定是弹窗还是引导去设置页。
|
|
|
*/
|
|
*/
|
|
|
const res = await LocalNotifications.requestPermissions();
|
|
const res = await LocalNotifications.requestPermissions();
|
|
|
- this.granted = res.display === 'granted';
|
|
|
|
|
- return this.granted;
|
|
|
|
|
|
|
+ this.granted.set(res.display === 'granted');
|
|
|
|
|
+ return this.granted() === true;
|
|
|
} catch {
|
|
} catch {
|
|
|
- this.granted = false;
|
|
|
|
|
|
|
+ this.granted.set(false);
|
|
|
return false;
|
|
return false;
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|