feat(notification): 新增强制阅读弹窗功能(管理员可配置 requiresForceRead)
## 功能概述
在不影响任何现有业务的前提下,新增"强制阅读弹窗"功能:
- 管理员创建通知时可勾选「需要强制弹窗阅读」
- App 冷启动进入主页 或 从后台切回前台时自动触发检查
- 存在未读且标记 requiresForceRead=true 的通知时,依次逐条弹窗
- 用户无法通过点击背景或返回键关闭弹窗(强制阅读)
- 最后一条通知弹窗底部显示 checkbox「我已经阅读并知晓」
- 未勾选时"确定"按钮置灰禁用
- 勾选后"确定"变为金色可点击,点击后所有弹窗消失
- 全部看完后仅对已展示的强制阅读通知按 ID 逐一标记已读
(不影响普通未读通知的 badge 计数)
## 涉及改动
### 后端 admin-service
- `prisma/schema.prisma`
- Notification 模型新增字段 `requiresForceRead Boolean @default(false)`
- `prisma/migrations/20260227100000_add_requires_force_read_to_notifications/migration.sql`
- 手动创建 SQL migration(本地无 DATABASE_URL 环境)
- 部署时需在服务器执行 `npx prisma migrate deploy`
- `src/domain/entities/notification.entity.ts`
- 实体类构造器新增 `requiresForceRead`
- create() / update() 方法均支持该字段,默认值 false
- `src/infrastructure/persistence/mappers/notification.mapper.ts`
- toDomain() 从 Prisma 记录读取 requiresForceRead
- toPersistence() 写入 requiresForceRead
- `src/api/dto/request/notification.dto.ts`
- CreateNotificationDto / UpdateNotificationDto 各新增可选字段 requiresForceRead
- `src/api/dto/response/notification.dto.ts`
- NotificationResponseDto(管理端)新增 requiresForceRead
- UserNotificationResponseDto(移动端)新增 requiresForceRead
- `src/api/controllers/notification.controller.ts`
- create() / update() 透传 requiresForceRead 到 entity
### 前端 admin-web
- `src/services/notificationService.ts`
- NotificationItem / CreateNotificationRequest / UpdateNotificationRequest 新增 requiresForceRead
- `src/app/(dashboard)/notifications/page.tsx`
- 通知列表:requiresForceRead=true 时显示红色「强制阅读」标签
- 创建/编辑表单:新增 checkbox「需要强制弹窗阅读」及说明文字
- form state / submit payload / edit 初始化均包含 requiresForceRead
### 移动端 mobile-app
- `lib/core/services/notification_service.dart`
- NotificationItem 新增字段 requiresForceRead(默认 false,fromJson 安全读取)
- `lib/features/notification/presentation/pages/notification_inbox_page.dart`
- markAsRead / markAllAsRead 重建 NotificationItem 时保留 requiresForceRead
- `lib/features/notification/presentation/widgets/force_read_notification_dialog.dart`(新建)
- 单条强制阅读弹窗组件
- 顶部显示通知类型图标 + 进度「1/3」
- 可滚动内容区展示完整通知
- 非最后条:「下一条 ▶」按钮(始终可点)
- 最后一条:checkbox + 「确定」(勾选后才可点)
- barrierDismissible: false + PopScope(canPop: false),无法逃出
- `lib/features/home/presentation/pages/home_shell_page.dart`
- 新增状态:_isShowingForceReadDialog(实例,防重入)
_lastForceReadDialogShownAt(静态,60秒冷却)
- 新增方法 _checkAndShowForceReadDialog():
Guard 1: 防重入锁
Guard 2: 60秒冷却(防回前台闪弹)
Guard 3: 检查用户已登录
Guard 4: 检查无其他弹窗在显示
弹窗期间同时设置 _isShowingDialog=true,阻止后台合同/KYC检查并发
全部看完后仅标记 forceReadList 中的通知为已读,再 refresh() 刷新 badge
- initState addPostFrameCallback 中新增调用
- didChangeAppLifecycleState resumed 分支中新增调用
- resetContractCheckState() 中重置 _lastForceReadDialogShownAt(账号切换隔离)
## 安全与兼容性
- API 调用失败时静默返回,不阻断用户进入 App
- 仅对 requiresForceRead=true 的通知弹窗,普通通知完全不受影响
- 与现有合同弹窗、KYC弹窗、维护弹窗、更新弹窗无冲突
- 静态冷却变量在账号切换时重置,避免新账号被旧账号冷却影响
- badge 准确:仅标记已展示的强制通知,不动其他未读通知计数
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
2684a81383
commit
1d1c60e2a2
|
|
@ -0,0 +1,5 @@
|
|||
-- Migration: add requiresForceRead to notifications
|
||||
-- 为通知表添加"是否需要强制弹窗阅读"字段
|
||||
-- 管理员可在创建通知时配置此字段,标记为 true 的通知将在用户打开 App 时强制弹窗展示
|
||||
|
||||
ALTER TABLE "notifications" ADD COLUMN "requiresForceRead" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
|
@ -59,7 +59,8 @@ model Notification {
|
|||
targetLogic TargetLogic @default(ANY) @map("target_logic") // 多标签匹配逻辑
|
||||
imageUrl String? // 可选的图片URL
|
||||
linkUrl String? // 可选的跳转链接
|
||||
isEnabled Boolean @default(true) // 是否启用
|
||||
isEnabled Boolean @default(true) // 是否启用
|
||||
requiresForceRead Boolean @default(false) // 是否需要强制弹窗阅读(管理员可配置)
|
||||
publishedAt DateTime? // 发布时间(null表示草稿)
|
||||
expiresAt DateTime? // 过期时间(null表示永不过期)
|
||||
createdAt DateTime @default(now())
|
||||
|
|
|
|||
|
|
@ -74,6 +74,7 @@ export class AdminNotificationController {
|
|||
targetConfig,
|
||||
imageUrl: dto.imageUrl,
|
||||
linkUrl: dto.linkUrl,
|
||||
requiresForceRead: dto.requiresForceRead,
|
||||
publishedAt: dto.publishedAt ? new Date(dto.publishedAt) : null,
|
||||
expiresAt: dto.expiresAt ? new Date(dto.expiresAt) : null,
|
||||
createdBy: 'admin', // TODO: 从认证信息获取
|
||||
|
|
@ -149,6 +150,7 @@ export class AdminNotificationController {
|
|||
imageUrl: dto.imageUrl,
|
||||
linkUrl: dto.linkUrl,
|
||||
isEnabled: dto.isEnabled,
|
||||
requiresForceRead: dto.requiresForceRead,
|
||||
publishedAt: dto.publishedAt !== undefined
|
||||
? dto.publishedAt
|
||||
? new Date(dto.publishedAt)
|
||||
|
|
|
|||
|
|
@ -70,6 +70,10 @@ export class CreateNotificationDto {
|
|||
@IsString()
|
||||
linkUrl?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
requiresForceRead?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
publishedAt?: string;
|
||||
|
|
@ -120,6 +124,10 @@ export class UpdateNotificationDto {
|
|||
@IsBoolean()
|
||||
isEnabled?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
requiresForceRead?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
publishedAt?: string;
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ export class NotificationResponseDto {
|
|||
imageUrl: string | null;
|
||||
linkUrl: string | null;
|
||||
isEnabled: boolean;
|
||||
requiresForceRead: boolean;
|
||||
publishedAt: string | null;
|
||||
expiresAt: string | null;
|
||||
createdAt: string;
|
||||
|
|
@ -47,6 +48,7 @@ export class NotificationResponseDto {
|
|||
imageUrl: entity.imageUrl,
|
||||
linkUrl: entity.linkUrl,
|
||||
isEnabled: entity.isEnabled,
|
||||
requiresForceRead: entity.requiresForceRead,
|
||||
publishedAt: entity.publishedAt?.toISOString() ?? null,
|
||||
expiresAt: entity.expiresAt?.toISOString() ?? null,
|
||||
createdAt: entity.createdAt.toISOString(),
|
||||
|
|
@ -68,6 +70,7 @@ export class UserNotificationResponseDto {
|
|||
publishedAt: string | null;
|
||||
isRead: boolean;
|
||||
readAt: string | null;
|
||||
requiresForceRead: boolean;
|
||||
|
||||
static fromEntity(item: NotificationWithReadStatus): UserNotificationResponseDto {
|
||||
return {
|
||||
|
|
@ -81,6 +84,7 @@ export class UserNotificationResponseDto {
|
|||
publishedAt: item.notification.publishedAt?.toISOString() ?? null,
|
||||
isRead: item.isRead,
|
||||
readAt: item.readAt?.toISOString() ?? null,
|
||||
requiresForceRead: item.notification.requiresForceRead,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ export class NotificationEntity {
|
|||
public readonly imageUrl: string | null,
|
||||
public readonly linkUrl: string | null,
|
||||
public readonly isEnabled: boolean,
|
||||
/** 是否需要强制弹窗阅读(由管理员创建时配置) */
|
||||
public readonly requiresForceRead: boolean,
|
||||
public readonly publishedAt: Date | null,
|
||||
public readonly expiresAt: Date | null,
|
||||
public readonly createdAt: Date,
|
||||
|
|
@ -99,6 +101,7 @@ export class NotificationEntity {
|
|||
targetConfig?: NotificationTarget | null;
|
||||
imageUrl?: string | null;
|
||||
linkUrl?: string | null;
|
||||
requiresForceRead?: boolean;
|
||||
publishedAt?: Date | null;
|
||||
expiresAt?: Date | null;
|
||||
createdBy: string;
|
||||
|
|
@ -128,6 +131,7 @@ export class NotificationEntity {
|
|||
params.imageUrl ?? null,
|
||||
params.linkUrl ?? null,
|
||||
true,
|
||||
params.requiresForceRead ?? false,
|
||||
params.publishedAt ?? null,
|
||||
params.expiresAt ?? null,
|
||||
now,
|
||||
|
|
@ -149,6 +153,7 @@ export class NotificationEntity {
|
|||
imageUrl?: string | null;
|
||||
linkUrl?: string | null;
|
||||
isEnabled?: boolean;
|
||||
requiresForceRead?: boolean;
|
||||
publishedAt?: Date | null;
|
||||
expiresAt?: Date | null;
|
||||
}): NotificationEntity {
|
||||
|
|
@ -163,6 +168,7 @@ export class NotificationEntity {
|
|||
params.imageUrl !== undefined ? params.imageUrl : this.imageUrl,
|
||||
params.linkUrl !== undefined ? params.linkUrl : this.linkUrl,
|
||||
params.isEnabled ?? this.isEnabled,
|
||||
params.requiresForceRead ?? this.requiresForceRead,
|
||||
params.publishedAt !== undefined ? params.publishedAt : this.publishedAt,
|
||||
params.expiresAt !== undefined ? params.expiresAt : this.expiresAt,
|
||||
this.createdAt,
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ export class NotificationMapper {
|
|||
prisma.imageUrl,
|
||||
prisma.linkUrl,
|
||||
prisma.isEnabled,
|
||||
prisma.requiresForceRead,
|
||||
prisma.publishedAt,
|
||||
prisma.expiresAt,
|
||||
prisma.createdAt,
|
||||
|
|
@ -78,6 +79,7 @@ export class NotificationMapper {
|
|||
imageUrl: entity.imageUrl,
|
||||
linkUrl: entity.linkUrl,
|
||||
isEnabled: entity.isEnabled,
|
||||
requiresForceRead: entity.requiresForceRead,
|
||||
publishedAt: entity.publishedAt,
|
||||
expiresAt: entity.expiresAt,
|
||||
createdAt: entity.createdAt,
|
||||
|
|
|
|||
|
|
@ -84,6 +84,7 @@ export default function NotificationsPage() {
|
|||
userIds: '',
|
||||
imageUrl: '',
|
||||
linkUrl: '',
|
||||
requiresForceRead: false,
|
||||
publishedAt: '',
|
||||
expiresAt: '',
|
||||
});
|
||||
|
|
@ -135,6 +136,7 @@ export default function NotificationsPage() {
|
|||
userIds: '',
|
||||
imageUrl: '',
|
||||
linkUrl: '',
|
||||
requiresForceRead: false,
|
||||
publishedAt: '',
|
||||
expiresAt: '',
|
||||
});
|
||||
|
|
@ -156,6 +158,7 @@ export default function NotificationsPage() {
|
|||
userIds: notification.targetConfig?.accountSequences?.join('\n') || '',
|
||||
imageUrl: notification.imageUrl || '',
|
||||
linkUrl: notification.linkUrl || '',
|
||||
requiresForceRead: notification.requiresForceRead ?? false,
|
||||
publishedAt: notification.publishedAt ? notification.publishedAt.slice(0, 16) : '',
|
||||
expiresAt: notification.expiresAt ? notification.expiresAt.slice(0, 16) : '',
|
||||
});
|
||||
|
|
@ -210,6 +213,7 @@ export default function NotificationsPage() {
|
|||
targetConfig,
|
||||
imageUrl: formData.imageUrl.trim() || undefined,
|
||||
linkUrl: formData.linkUrl.trim() || undefined,
|
||||
requiresForceRead: formData.requiresForceRead,
|
||||
publishedAt: formData.publishedAt ? new Date(formData.publishedAt).toISOString() : undefined,
|
||||
expiresAt: formData.expiresAt ? new Date(formData.expiresAt).toISOString() : undefined,
|
||||
};
|
||||
|
|
@ -360,6 +364,14 @@ export default function NotificationsPage() {
|
|||
<> ({notification.targetConfig.accountSequences.length}个用户)</>
|
||||
)}
|
||||
</span>
|
||||
{notification.requiresForceRead && (
|
||||
<span
|
||||
className={styles.notifications__tag}
|
||||
style={{ backgroundColor: '#fee2e2', color: '#b91c1c', borderColor: '#fca5a5' }}
|
||||
>
|
||||
强制阅读
|
||||
</span>
|
||||
)}
|
||||
{!notification.isEnabled && (
|
||||
<span className={styles.notifications__disabled}>已禁用</span>
|
||||
)}
|
||||
|
|
@ -614,6 +626,24 @@ export default function NotificationsPage() {
|
|||
placeholder="https://..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.notifications__formGroup}>
|
||||
<label
|
||||
style={{ display: 'flex', alignItems: 'center', gap: '10px', cursor: 'pointer' }}
|
||||
onClick={() => setFormData({ ...formData, requiresForceRead: !formData.requiresForceRead })}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.requiresForceRead}
|
||||
onChange={(e) => setFormData({ ...formData, requiresForceRead: e.target.checked })}
|
||||
style={{ width: '16px', height: '16px', cursor: 'pointer' }}
|
||||
/>
|
||||
<span>需要强制弹窗阅读</span>
|
||||
</label>
|
||||
<span className={styles.notifications__formHint}>
|
||||
勾选后,用户打开 App 时会强制弹窗展示此消息,用户必须勾选"我已经阅读并知晓"后才能关闭
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ export interface NotificationItem {
|
|||
imageUrl: string | null;
|
||||
linkUrl: string | null;
|
||||
isEnabled: boolean;
|
||||
requiresForceRead: boolean;
|
||||
publishedAt: string | null;
|
||||
expiresAt: string | null;
|
||||
createdAt: string;
|
||||
|
|
@ -52,6 +53,7 @@ export interface CreateNotificationRequest {
|
|||
};
|
||||
imageUrl?: string;
|
||||
linkUrl?: string;
|
||||
requiresForceRead?: boolean;
|
||||
publishedAt?: string;
|
||||
expiresAt?: string;
|
||||
}
|
||||
|
|
@ -70,6 +72,7 @@ export interface UpdateNotificationRequest {
|
|||
imageUrl?: string | null;
|
||||
linkUrl?: string | null;
|
||||
isEnabled?: boolean;
|
||||
requiresForceRead?: boolean;
|
||||
publishedAt?: string | null;
|
||||
expiresAt?: string | null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,6 +31,9 @@ class NotificationItem {
|
|||
final bool isRead;
|
||||
final DateTime? readAt;
|
||||
|
||||
/// 是否需要强制弹窗阅读(由管理员创建通知时配置)
|
||||
final bool requiresForceRead;
|
||||
|
||||
NotificationItem({
|
||||
required this.id,
|
||||
required this.title,
|
||||
|
|
@ -42,6 +45,7 @@ class NotificationItem {
|
|||
this.publishedAt,
|
||||
required this.isRead,
|
||||
this.readAt,
|
||||
this.requiresForceRead = false,
|
||||
});
|
||||
|
||||
factory NotificationItem.fromJson(Map<String, dynamic> json) {
|
||||
|
|
@ -58,6 +62,7 @@ class NotificationItem {
|
|||
: null,
|
||||
isRead: json['isRead'] ?? false,
|
||||
readAt: json['readAt'] != null ? DateTime.tryParse(json['readAt']) : null,
|
||||
requiresForceRead: json['requiresForceRead'] ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,12 +6,15 @@ import 'package:go_router/go_router.dart';
|
|||
import '../../../../core/theme/app_colors.dart';
|
||||
import '../../../../core/di/injection_container.dart';
|
||||
import '../../../../core/services/contract_check_service.dart';
|
||||
import '../../../../core/services/notification_service.dart';
|
||||
import '../../../../core/updater/update_service.dart';
|
||||
import '../../../../core/providers/maintenance_provider.dart';
|
||||
import '../../../../core/providers/notification_badge_provider.dart';
|
||||
import '../../../../routes/route_paths.dart';
|
||||
import '../../../../routes/app_router.dart';
|
||||
import '../../../../bootstrap.dart';
|
||||
import '../../../../core/providers/notification_badge_provider.dart';
|
||||
import '../../../auth/presentation/providers/auth_provider.dart';
|
||||
import '../../../notification/presentation/widgets/force_read_notification_dialog.dart';
|
||||
import '../widgets/bottom_nav_bar.dart';
|
||||
|
||||
class HomeShellPage extends ConsumerStatefulWidget {
|
||||
|
|
@ -40,6 +43,12 @@ class _HomeShellPageState extends ConsumerState<HomeShellPage>
|
|||
/// 是否正在显示弹窗(防止重复弹窗)
|
||||
bool _isShowingDialog = false;
|
||||
|
||||
/// 是否正在显示强制阅读通知弹窗
|
||||
bool _isShowingForceReadDialog = false;
|
||||
|
||||
/// 上次显示强制阅读弹窗的时间(60 秒内不重复触发)
|
||||
static DateTime? _lastForceReadDialogShownAt;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
|
@ -49,6 +58,7 @@ class _HomeShellPageState extends ConsumerState<HomeShellPage>
|
|||
_checkMaintenanceStatus();
|
||||
_checkForUpdateIfNeeded();
|
||||
_checkContractsAndKyc();
|
||||
_checkAndShowForceReadDialog();
|
||||
// 启动后台定时检查
|
||||
_startBackgroundContractCheck();
|
||||
// 启动维护状态定时检查
|
||||
|
|
@ -74,6 +84,8 @@ class _HomeShellPageState extends ConsumerState<HomeShellPage>
|
|||
_scheduleNextContractCheck();
|
||||
// 恢复维护检查定时器
|
||||
_startMaintenanceCheck();
|
||||
// 从后台恢复时检查是否有需要强制阅读的通知
|
||||
_checkAndShowForceReadDialog();
|
||||
} else if (state == AppLifecycleState.paused) {
|
||||
// 进入后台时取消定时器
|
||||
_contractCheckTimer?.cancel();
|
||||
|
|
@ -364,9 +376,90 @@ class _HomeShellPageState extends ConsumerState<HomeShellPage>
|
|||
_isShowingDialog = false;
|
||||
}
|
||||
|
||||
/// 检查并展示需要强制阅读的未读通知(逐条弹出)
|
||||
///
|
||||
/// 仅展示标记了 [requiresForceRead] 且尚未读的通知。
|
||||
/// 用户必须逐条查看,最后一条勾选"我已经阅读并知晓"后方可关闭。
|
||||
/// 全部确认后逐条调用 markAsRead(按 ID)并刷新真实未读数量角标。
|
||||
Future<void> _checkAndShowForceReadDialog() async {
|
||||
// 防护 1:已有强制阅读弹窗正在展示
|
||||
if (_isShowingForceReadDialog) return;
|
||||
|
||||
// 防护 2:60 秒内不重复触发(防止快速切后台后立刻重弹)
|
||||
if (_lastForceReadDialogShownAt != null &&
|
||||
DateTime.now().difference(_lastForceReadDialogShownAt!) <
|
||||
const Duration(seconds: 60)) return;
|
||||
|
||||
// 防护 3:用户未登录
|
||||
final authState = ref.read(authProvider);
|
||||
final userSerialNum = authState.userSerialNum;
|
||||
if (userSerialNum == null) return;
|
||||
|
||||
// 防护 4:其他系统弹窗(合同/KYC/升级)正在展示
|
||||
if (_isShowingDialog) return;
|
||||
|
||||
// 获取未读且需要强制阅读的通知
|
||||
List<NotificationItem> forceReadList;
|
||||
try {
|
||||
final notifService = ref.read(notificationServiceProvider);
|
||||
final response = await notifService.getNotifications(
|
||||
userSerialNum: userSerialNum,
|
||||
limit: 20,
|
||||
);
|
||||
forceReadList = response.notifications
|
||||
.where((n) => !n.isRead && n.requiresForceRead)
|
||||
.toList();
|
||||
} catch (_) {
|
||||
// API 失败时静默处理,不阻断用户使用
|
||||
return;
|
||||
}
|
||||
|
||||
if (forceReadList.isEmpty || !mounted) return;
|
||||
|
||||
_isShowingForceReadDialog = true;
|
||||
_isShowingDialog = true; // 防止后台合同/KYC 检查在此期间叠加弹窗
|
||||
_lastForceReadDialogShownAt = DateTime.now();
|
||||
|
||||
// 逐条展示强制阅读弹窗
|
||||
for (int i = 0; i < forceReadList.length; i++) {
|
||||
if (!mounted) break;
|
||||
await ForceReadNotificationDialog.show(
|
||||
context: context,
|
||||
notification: forceReadList[i],
|
||||
currentIndex: i + 1,
|
||||
totalCount: forceReadList.length,
|
||||
);
|
||||
}
|
||||
|
||||
_isShowingDialog = false;
|
||||
_isShowingForceReadDialog = false;
|
||||
|
||||
// 全部通知看完后,仅标记本次展示的通知为已读(不影响其他未读普通通知)
|
||||
if (mounted) {
|
||||
try {
|
||||
final notifService = ref.read(notificationServiceProvider);
|
||||
final currentUserSerialNum = ref.read(authProvider).userSerialNum;
|
||||
if (currentUserSerialNum != null) {
|
||||
for (final n in forceReadList) {
|
||||
await notifService.markAsRead(
|
||||
userSerialNum: currentUserSerialNum,
|
||||
notificationId: n.id,
|
||||
);
|
||||
}
|
||||
// 刷新真实未读数量,而非直接清零(避免误清其他未读普通通知的 badge)
|
||||
ref.read(notificationBadgeProvider.notifier).refresh();
|
||||
}
|
||||
} catch (_) {
|
||||
// 标记失败时静默处理;下次打开 App 仍会重新检查
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 重置合同检查状态(用于用户切换账号时)
|
||||
static void resetContractCheckState() {
|
||||
_hasCheckedContracts = false;
|
||||
// 同时重置强制阅读弹窗冷却时间,确保新账号可以立即检查
|
||||
_lastForceReadDialogShownAt = null;
|
||||
}
|
||||
|
||||
int _getCurrentIndex(BuildContext context) {
|
||||
|
|
|
|||
|
|
@ -104,6 +104,7 @@ class _NotificationInboxPageState extends ConsumerState<NotificationInboxPage> {
|
|||
publishedAt: notification.publishedAt,
|
||||
isRead: true,
|
||||
readAt: DateTime.now(),
|
||||
requiresForceRead: notification.requiresForceRead,
|
||||
);
|
||||
_unreadCount = (_unreadCount - 1).clamp(0, _unreadCount);
|
||||
}
|
||||
|
|
@ -141,6 +142,7 @@ class _NotificationInboxPageState extends ConsumerState<NotificationInboxPage> {
|
|||
publishedAt: n.publishedAt,
|
||||
isRead: true,
|
||||
readAt: DateTime.now(),
|
||||
requiresForceRead: n.requiresForceRead,
|
||||
);
|
||||
}
|
||||
return n;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,288 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import '../../../../core/services/notification_service.dart';
|
||||
|
||||
/// 强制阅读通知弹窗
|
||||
///
|
||||
/// 用于在用户打开 App 时强制展示标记了 [requiresForceRead] 的未读通知。
|
||||
/// 用户必须逐条查看,并在最后一条勾选"我已经阅读并知晓"后才能关闭。
|
||||
class ForceReadNotificationDialog extends StatefulWidget {
|
||||
final NotificationItem notification;
|
||||
final int currentIndex;
|
||||
final int totalCount;
|
||||
|
||||
const ForceReadNotificationDialog._({
|
||||
super.key,
|
||||
required this.notification,
|
||||
required this.currentIndex,
|
||||
required this.totalCount,
|
||||
});
|
||||
|
||||
/// 显示单条强制阅读弹窗。
|
||||
///
|
||||
/// [currentIndex] 和 [totalCount] 用于显示进度(如 "2/5")。
|
||||
/// 当 [currentIndex] == [totalCount] 时为最后一条,显示 checkbox + 确定按钮。
|
||||
static Future<void> show({
|
||||
required BuildContext context,
|
||||
required NotificationItem notification,
|
||||
required int currentIndex,
|
||||
required int totalCount,
|
||||
}) {
|
||||
return showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
barrierColor: const Color(0x99000000),
|
||||
builder: (context) => ForceReadNotificationDialog._(
|
||||
notification: notification,
|
||||
currentIndex: currentIndex,
|
||||
totalCount: totalCount,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
State<ForceReadNotificationDialog> createState() =>
|
||||
_ForceReadNotificationDialogState();
|
||||
}
|
||||
|
||||
class _ForceReadNotificationDialogState
|
||||
extends State<ForceReadNotificationDialog> {
|
||||
bool _isAcknowledged = false;
|
||||
|
||||
bool get _isLast => widget.currentIndex == widget.totalCount;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PopScope(
|
||||
canPop: false,
|
||||
child: Dialog(
|
||||
backgroundColor: Colors.transparent,
|
||||
insetPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 32),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 420, maxHeight: 580),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.18),
|
||||
blurRadius: 24,
|
||||
offset: const Offset(0, 8),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// ── 顶部标题栏 ──
|
||||
_buildHeader(),
|
||||
const Divider(height: 1, color: Color(0xFFEEEEEE)),
|
||||
|
||||
// ── 通知标题 ──
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 14, 20, 6),
|
||||
child: Text(
|
||||
widget.notification.title,
|
||||
style: const TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Color(0xFF3E2723),
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// ── 可滚动内容区 ──
|
||||
Flexible(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(20, 4, 20, 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.notification.content,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: Color(0xFF5D4037),
|
||||
height: 1.7,
|
||||
),
|
||||
),
|
||||
if (widget.notification.publishedAt != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
_formatTime(widget.notification.publishedAt!),
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Color(0xFFBDBDBD),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const Divider(height: 1, color: Color(0xFFEEEEEE)),
|
||||
|
||||
// ── 底部操作区 ──
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 14, 20, 20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// 最后一条才显示 checkbox
|
||||
if (_isLast) ...[
|
||||
_buildAcknowledgeCheckbox(),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
_buildActionButton(context),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 16, 16, 14),
|
||||
child: Row(
|
||||
children: [
|
||||
// 类型图标圆圈
|
||||
Container(
|
||||
width: 38,
|
||||
height: 38,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFFF8E1),
|
||||
borderRadius: BorderRadius.circular(19),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
widget.notification.typeIcon,
|
||||
style: const TextStyle(fontSize: 18),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
|
||||
// 类型名称
|
||||
Expanded(
|
||||
child: Text(
|
||||
widget.notification.typeName,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF5D4037),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 进度指示器(多条时才显示)
|
||||
if (widget.totalCount > 1)
|
||||
Container(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFFF8E1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: const Color(0xFFD4AF37).withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
'${widget.currentIndex}/${widget.totalCount}',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Color(0xFFD4AF37),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAcknowledgeCheckbox() {
|
||||
return InkWell(
|
||||
onTap: () => setState(() => _isAcknowledged = !_isAcknowledged),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: Checkbox(
|
||||
value: _isAcknowledged,
|
||||
onChanged: (v) =>
|
||||
setState(() => _isAcknowledged = v ?? false),
|
||||
activeColor: const Color(0xFFD4AF37),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
const Expanded(
|
||||
child: Text(
|
||||
'我已经阅读并知晓',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Color(0xFF5D4037),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildActionButton(BuildContext context) {
|
||||
// 非最后一条:始终可点"下一条";最后一条:需要勾选 checkbox
|
||||
final isEnabled = !_isLast || _isAcknowledged;
|
||||
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
height: 48,
|
||||
child: ElevatedButton(
|
||||
onPressed: isEnabled ? () => Navigator.of(context).pop() : null,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFFD4AF37),
|
||||
foregroundColor: Colors.white,
|
||||
disabledBackgroundColor:
|
||||
const Color(0xFFD4AF37).withValues(alpha: 0.35),
|
||||
disabledForegroundColor: Colors.white.withValues(alpha: 0.6),
|
||||
elevation: isEnabled ? 2 : 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
_isLast ? '确定' : '下一条 ▶',
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatTime(DateTime time) {
|
||||
return '${time.year}-'
|
||||
'${time.month.toString().padLeft(2, '0')}-'
|
||||
'${time.day.toString().padLeft(2, '0')} '
|
||||
'${time.hour.toString().padLeft(2, '0')}:'
|
||||
'${time.minute.toString().padLeft(2, '0')}';
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue