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:
hailin 2026-02-27 19:33:51 -08:00
parent 2684a81383
commit 1d1c60e2a2
13 changed files with 451 additions and 2 deletions

View File

@ -0,0 +1,5 @@
-- Migration: add requiresForceRead to notifications
-- 为通知表添加"是否需要强制弹窗阅读"字段
-- 管理员可在创建通知时配置此字段,标记为 true 的通知将在用户打开 App 时强制弹窗展示
ALTER TABLE "notifications" ADD COLUMN "requiresForceRead" BOOLEAN NOT NULL DEFAULT false;

View File

@ -60,6 +60,7 @@ model Notification {
imageUrl String? // 可选的图片URL imageUrl String? // 可选的图片URL
linkUrl String? // 可选的跳转链接 linkUrl String? // 可选的跳转链接
isEnabled Boolean @default(true) // 是否启用 isEnabled Boolean @default(true) // 是否启用
requiresForceRead Boolean @default(false) // 是否需要强制弹窗阅读(管理员可配置)
publishedAt DateTime? // 发布时间null表示草稿 publishedAt DateTime? // 发布时间null表示草稿
expiresAt DateTime? // 过期时间null表示永不过期 expiresAt DateTime? // 过期时间null表示永不过期
createdAt DateTime @default(now()) createdAt DateTime @default(now())

View File

@ -74,6 +74,7 @@ export class AdminNotificationController {
targetConfig, targetConfig,
imageUrl: dto.imageUrl, imageUrl: dto.imageUrl,
linkUrl: dto.linkUrl, linkUrl: dto.linkUrl,
requiresForceRead: dto.requiresForceRead,
publishedAt: dto.publishedAt ? new Date(dto.publishedAt) : null, publishedAt: dto.publishedAt ? new Date(dto.publishedAt) : null,
expiresAt: dto.expiresAt ? new Date(dto.expiresAt) : null, expiresAt: dto.expiresAt ? new Date(dto.expiresAt) : null,
createdBy: 'admin', // TODO: 从认证信息获取 createdBy: 'admin', // TODO: 从认证信息获取
@ -149,6 +150,7 @@ export class AdminNotificationController {
imageUrl: dto.imageUrl, imageUrl: dto.imageUrl,
linkUrl: dto.linkUrl, linkUrl: dto.linkUrl,
isEnabled: dto.isEnabled, isEnabled: dto.isEnabled,
requiresForceRead: dto.requiresForceRead,
publishedAt: dto.publishedAt !== undefined publishedAt: dto.publishedAt !== undefined
? dto.publishedAt ? dto.publishedAt
? new Date(dto.publishedAt) ? new Date(dto.publishedAt)

View File

@ -70,6 +70,10 @@ export class CreateNotificationDto {
@IsString() @IsString()
linkUrl?: string; linkUrl?: string;
@IsOptional()
@IsBoolean()
requiresForceRead?: boolean;
@IsOptional() @IsOptional()
@IsDateString() @IsDateString()
publishedAt?: string; publishedAt?: string;
@ -120,6 +124,10 @@ export class UpdateNotificationDto {
@IsBoolean() @IsBoolean()
isEnabled?: boolean; isEnabled?: boolean;
@IsOptional()
@IsBoolean()
requiresForceRead?: boolean;
@IsOptional() @IsOptional()
@IsDateString() @IsDateString()
publishedAt?: string; publishedAt?: string;

View File

@ -31,6 +31,7 @@ export class NotificationResponseDto {
imageUrl: string | null; imageUrl: string | null;
linkUrl: string | null; linkUrl: string | null;
isEnabled: boolean; isEnabled: boolean;
requiresForceRead: boolean;
publishedAt: string | null; publishedAt: string | null;
expiresAt: string | null; expiresAt: string | null;
createdAt: string; createdAt: string;
@ -47,6 +48,7 @@ export class NotificationResponseDto {
imageUrl: entity.imageUrl, imageUrl: entity.imageUrl,
linkUrl: entity.linkUrl, linkUrl: entity.linkUrl,
isEnabled: entity.isEnabled, isEnabled: entity.isEnabled,
requiresForceRead: entity.requiresForceRead,
publishedAt: entity.publishedAt?.toISOString() ?? null, publishedAt: entity.publishedAt?.toISOString() ?? null,
expiresAt: entity.expiresAt?.toISOString() ?? null, expiresAt: entity.expiresAt?.toISOString() ?? null,
createdAt: entity.createdAt.toISOString(), createdAt: entity.createdAt.toISOString(),
@ -68,6 +70,7 @@ export class UserNotificationResponseDto {
publishedAt: string | null; publishedAt: string | null;
isRead: boolean; isRead: boolean;
readAt: string | null; readAt: string | null;
requiresForceRead: boolean;
static fromEntity(item: NotificationWithReadStatus): UserNotificationResponseDto { static fromEntity(item: NotificationWithReadStatus): UserNotificationResponseDto {
return { return {
@ -81,6 +84,7 @@ export class UserNotificationResponseDto {
publishedAt: item.notification.publishedAt?.toISOString() ?? null, publishedAt: item.notification.publishedAt?.toISOString() ?? null,
isRead: item.isRead, isRead: item.isRead,
readAt: item.readAt?.toISOString() ?? null, readAt: item.readAt?.toISOString() ?? null,
requiresForceRead: item.notification.requiresForceRead,
}; };
} }
} }

View File

@ -23,6 +23,8 @@ export class NotificationEntity {
public readonly imageUrl: string | null, public readonly imageUrl: string | null,
public readonly linkUrl: string | null, public readonly linkUrl: string | null,
public readonly isEnabled: boolean, public readonly isEnabled: boolean,
/** 是否需要强制弹窗阅读(由管理员创建时配置) */
public readonly requiresForceRead: boolean,
public readonly publishedAt: Date | null, public readonly publishedAt: Date | null,
public readonly expiresAt: Date | null, public readonly expiresAt: Date | null,
public readonly createdAt: Date, public readonly createdAt: Date,
@ -99,6 +101,7 @@ export class NotificationEntity {
targetConfig?: NotificationTarget | null; targetConfig?: NotificationTarget | null;
imageUrl?: string | null; imageUrl?: string | null;
linkUrl?: string | null; linkUrl?: string | null;
requiresForceRead?: boolean;
publishedAt?: Date | null; publishedAt?: Date | null;
expiresAt?: Date | null; expiresAt?: Date | null;
createdBy: string; createdBy: string;
@ -128,6 +131,7 @@ export class NotificationEntity {
params.imageUrl ?? null, params.imageUrl ?? null,
params.linkUrl ?? null, params.linkUrl ?? null,
true, true,
params.requiresForceRead ?? false,
params.publishedAt ?? null, params.publishedAt ?? null,
params.expiresAt ?? null, params.expiresAt ?? null,
now, now,
@ -149,6 +153,7 @@ export class NotificationEntity {
imageUrl?: string | null; imageUrl?: string | null;
linkUrl?: string | null; linkUrl?: string | null;
isEnabled?: boolean; isEnabled?: boolean;
requiresForceRead?: boolean;
publishedAt?: Date | null; publishedAt?: Date | null;
expiresAt?: Date | null; expiresAt?: Date | null;
}): NotificationEntity { }): NotificationEntity {
@ -163,6 +168,7 @@ export class NotificationEntity {
params.imageUrl !== undefined ? params.imageUrl : this.imageUrl, params.imageUrl !== undefined ? params.imageUrl : this.imageUrl,
params.linkUrl !== undefined ? params.linkUrl : this.linkUrl, params.linkUrl !== undefined ? params.linkUrl : this.linkUrl,
params.isEnabled ?? this.isEnabled, params.isEnabled ?? this.isEnabled,
params.requiresForceRead ?? this.requiresForceRead,
params.publishedAt !== undefined ? params.publishedAt : this.publishedAt, params.publishedAt !== undefined ? params.publishedAt : this.publishedAt,
params.expiresAt !== undefined ? params.expiresAt : this.expiresAt, params.expiresAt !== undefined ? params.expiresAt : this.expiresAt,
this.createdAt, this.createdAt,

View File

@ -56,6 +56,7 @@ export class NotificationMapper {
prisma.imageUrl, prisma.imageUrl,
prisma.linkUrl, prisma.linkUrl,
prisma.isEnabled, prisma.isEnabled,
prisma.requiresForceRead,
prisma.publishedAt, prisma.publishedAt,
prisma.expiresAt, prisma.expiresAt,
prisma.createdAt, prisma.createdAt,
@ -78,6 +79,7 @@ export class NotificationMapper {
imageUrl: entity.imageUrl, imageUrl: entity.imageUrl,
linkUrl: entity.linkUrl, linkUrl: entity.linkUrl,
isEnabled: entity.isEnabled, isEnabled: entity.isEnabled,
requiresForceRead: entity.requiresForceRead,
publishedAt: entity.publishedAt, publishedAt: entity.publishedAt,
expiresAt: entity.expiresAt, expiresAt: entity.expiresAt,
createdAt: entity.createdAt, createdAt: entity.createdAt,

View File

@ -84,6 +84,7 @@ export default function NotificationsPage() {
userIds: '', userIds: '',
imageUrl: '', imageUrl: '',
linkUrl: '', linkUrl: '',
requiresForceRead: false,
publishedAt: '', publishedAt: '',
expiresAt: '', expiresAt: '',
}); });
@ -135,6 +136,7 @@ export default function NotificationsPage() {
userIds: '', userIds: '',
imageUrl: '', imageUrl: '',
linkUrl: '', linkUrl: '',
requiresForceRead: false,
publishedAt: '', publishedAt: '',
expiresAt: '', expiresAt: '',
}); });
@ -156,6 +158,7 @@ export default function NotificationsPage() {
userIds: notification.targetConfig?.accountSequences?.join('\n') || '', userIds: notification.targetConfig?.accountSequences?.join('\n') || '',
imageUrl: notification.imageUrl || '', imageUrl: notification.imageUrl || '',
linkUrl: notification.linkUrl || '', linkUrl: notification.linkUrl || '',
requiresForceRead: notification.requiresForceRead ?? false,
publishedAt: notification.publishedAt ? notification.publishedAt.slice(0, 16) : '', publishedAt: notification.publishedAt ? notification.publishedAt.slice(0, 16) : '',
expiresAt: notification.expiresAt ? notification.expiresAt.slice(0, 16) : '', expiresAt: notification.expiresAt ? notification.expiresAt.slice(0, 16) : '',
}); });
@ -210,6 +213,7 @@ export default function NotificationsPage() {
targetConfig, targetConfig,
imageUrl: formData.imageUrl.trim() || undefined, imageUrl: formData.imageUrl.trim() || undefined,
linkUrl: formData.linkUrl.trim() || undefined, linkUrl: formData.linkUrl.trim() || undefined,
requiresForceRead: formData.requiresForceRead,
publishedAt: formData.publishedAt ? new Date(formData.publishedAt).toISOString() : undefined, publishedAt: formData.publishedAt ? new Date(formData.publishedAt).toISOString() : undefined,
expiresAt: formData.expiresAt ? new Date(formData.expiresAt).toISOString() : undefined, expiresAt: formData.expiresAt ? new Date(formData.expiresAt).toISOString() : undefined,
}; };
@ -360,6 +364,14 @@ export default function NotificationsPage() {
<> ({notification.targetConfig.accountSequences.length})</> <> ({notification.targetConfig.accountSequences.length})</>
)} )}
</span> </span>
{notification.requiresForceRead && (
<span
className={styles.notifications__tag}
style={{ backgroundColor: '#fee2e2', color: '#b91c1c', borderColor: '#fca5a5' }}
>
</span>
)}
{!notification.isEnabled && ( {!notification.isEnabled && (
<span className={styles.notifications__disabled}></span> <span className={styles.notifications__disabled}></span>
)} )}
@ -614,6 +626,24 @@ export default function NotificationsPage() {
placeholder="https://..." placeholder="https://..."
/> />
</div> </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> </div>
</Modal> </Modal>

View File

@ -34,6 +34,7 @@ export interface NotificationItem {
imageUrl: string | null; imageUrl: string | null;
linkUrl: string | null; linkUrl: string | null;
isEnabled: boolean; isEnabled: boolean;
requiresForceRead: boolean;
publishedAt: string | null; publishedAt: string | null;
expiresAt: string | null; expiresAt: string | null;
createdAt: string; createdAt: string;
@ -52,6 +53,7 @@ export interface CreateNotificationRequest {
}; };
imageUrl?: string; imageUrl?: string;
linkUrl?: string; linkUrl?: string;
requiresForceRead?: boolean;
publishedAt?: string; publishedAt?: string;
expiresAt?: string; expiresAt?: string;
} }
@ -70,6 +72,7 @@ export interface UpdateNotificationRequest {
imageUrl?: string | null; imageUrl?: string | null;
linkUrl?: string | null; linkUrl?: string | null;
isEnabled?: boolean; isEnabled?: boolean;
requiresForceRead?: boolean;
publishedAt?: string | null; publishedAt?: string | null;
expiresAt?: string | null; expiresAt?: string | null;
} }

View File

@ -31,6 +31,9 @@ class NotificationItem {
final bool isRead; final bool isRead;
final DateTime? readAt; final DateTime? readAt;
///
final bool requiresForceRead;
NotificationItem({ NotificationItem({
required this.id, required this.id,
required this.title, required this.title,
@ -42,6 +45,7 @@ class NotificationItem {
this.publishedAt, this.publishedAt,
required this.isRead, required this.isRead,
this.readAt, this.readAt,
this.requiresForceRead = false,
}); });
factory NotificationItem.fromJson(Map<String, dynamic> json) { factory NotificationItem.fromJson(Map<String, dynamic> json) {
@ -58,6 +62,7 @@ class NotificationItem {
: null, : null,
isRead: json['isRead'] ?? false, isRead: json['isRead'] ?? false,
readAt: json['readAt'] != null ? DateTime.tryParse(json['readAt']) : null, readAt: json['readAt'] != null ? DateTime.tryParse(json['readAt']) : null,
requiresForceRead: json['requiresForceRead'] ?? false,
); );
} }

View File

@ -6,12 +6,15 @@ import 'package:go_router/go_router.dart';
import '../../../../core/theme/app_colors.dart'; import '../../../../core/theme/app_colors.dart';
import '../../../../core/di/injection_container.dart'; import '../../../../core/di/injection_container.dart';
import '../../../../core/services/contract_check_service.dart'; import '../../../../core/services/contract_check_service.dart';
import '../../../../core/services/notification_service.dart';
import '../../../../core/updater/update_service.dart'; import '../../../../core/updater/update_service.dart';
import '../../../../core/providers/maintenance_provider.dart'; import '../../../../core/providers/maintenance_provider.dart';
import '../../../../core/providers/notification_badge_provider.dart';
import '../../../../routes/route_paths.dart'; import '../../../../routes/route_paths.dart';
import '../../../../routes/app_router.dart'; import '../../../../routes/app_router.dart';
import '../../../../bootstrap.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'; import '../widgets/bottom_nav_bar.dart';
class HomeShellPage extends ConsumerStatefulWidget { class HomeShellPage extends ConsumerStatefulWidget {
@ -40,6 +43,12 @@ class _HomeShellPageState extends ConsumerState<HomeShellPage>
/// ///
bool _isShowingDialog = false; bool _isShowingDialog = false;
///
bool _isShowingForceReadDialog = false;
/// 60
static DateTime? _lastForceReadDialogShownAt;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@ -49,6 +58,7 @@ class _HomeShellPageState extends ConsumerState<HomeShellPage>
_checkMaintenanceStatus(); _checkMaintenanceStatus();
_checkForUpdateIfNeeded(); _checkForUpdateIfNeeded();
_checkContractsAndKyc(); _checkContractsAndKyc();
_checkAndShowForceReadDialog();
// //
_startBackgroundContractCheck(); _startBackgroundContractCheck();
// //
@ -74,6 +84,8 @@ class _HomeShellPageState extends ConsumerState<HomeShellPage>
_scheduleNextContractCheck(); _scheduleNextContractCheck();
// //
_startMaintenanceCheck(); _startMaintenanceCheck();
//
_checkAndShowForceReadDialog();
} else if (state == AppLifecycleState.paused) { } else if (state == AppLifecycleState.paused) {
// //
_contractCheckTimer?.cancel(); _contractCheckTimer?.cancel();
@ -364,9 +376,90 @@ class _HomeShellPageState extends ConsumerState<HomeShellPage>
_isShowingDialog = false; _isShowingDialog = false;
} }
///
///
/// [requiresForceRead]
/// "我已经阅读并知晓"
/// markAsRead ID
Future<void> _checkAndShowForceReadDialog() async {
// 1
if (_isShowingForceReadDialog) return;
// 260
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() { static void resetContractCheckState() {
_hasCheckedContracts = false; _hasCheckedContracts = false;
//
_lastForceReadDialogShownAt = null;
} }
int _getCurrentIndex(BuildContext context) { int _getCurrentIndex(BuildContext context) {

View File

@ -104,6 +104,7 @@ class _NotificationInboxPageState extends ConsumerState<NotificationInboxPage> {
publishedAt: notification.publishedAt, publishedAt: notification.publishedAt,
isRead: true, isRead: true,
readAt: DateTime.now(), readAt: DateTime.now(),
requiresForceRead: notification.requiresForceRead,
); );
_unreadCount = (_unreadCount - 1).clamp(0, _unreadCount); _unreadCount = (_unreadCount - 1).clamp(0, _unreadCount);
} }
@ -141,6 +142,7 @@ class _NotificationInboxPageState extends ConsumerState<NotificationInboxPage> {
publishedAt: n.publishedAt, publishedAt: n.publishedAt,
isRead: true, isRead: true,
readAt: DateTime.now(), readAt: DateTime.now(),
requiresForceRead: n.requiresForceRead,
); );
} }
return n; return n;

View File

@ -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')}';
}
}