- 修复设置子页面/帮助页/喂养指南顶部渐隐区域过大(12%→5%)导致首行文字过淡 - 修复4首预设音乐(卡皮巴拉系列)因ID3标签过大导致进度条无法拖动 - 修复notification_page中notif.detail→notif.content字段名错误 - 新增测试生成的故事和音频文件 Co-authored-by: Cursor <cursoragent@cursor.com>
396 lines
14 KiB
Dart
396 lines
14 KiB
Dart
import 'package:flutter/material.dart';
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
import 'package:airhub_app/theme/design_tokens.dart';
|
||
import 'package:airhub_app/widgets/animated_gradient_background.dart';
|
||
import 'package:airhub_app/features/notification/domain/entities/app_notification.dart';
|
||
import 'package:airhub_app/features/notification/presentation/controllers/notification_controller.dart';
|
||
|
||
/// 消息通知页面 — 接入真实 API
|
||
class NotificationPage extends ConsumerStatefulWidget {
|
||
const NotificationPage({super.key});
|
||
|
||
@override
|
||
ConsumerState<NotificationPage> createState() => _NotificationPageState();
|
||
}
|
||
|
||
class _NotificationPageState extends ConsumerState<NotificationPage> {
|
||
/// 当前展开的通知 id(null 表示全部折叠)
|
||
int? _expandedId;
|
||
|
||
void _toggleNotification(AppNotification notif) {
|
||
setState(() {
|
||
if (_expandedId == notif.id) {
|
||
_expandedId = null;
|
||
} else {
|
||
_expandedId = notif.id;
|
||
// Mark as read when expanded
|
||
if (!notif.isRead) {
|
||
ref.read(notificationControllerProvider.notifier).markAsRead(notif.id);
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final notificationsAsync = ref.watch(notificationControllerProvider);
|
||
|
||
return Scaffold(
|
||
backgroundColor: Colors.transparent,
|
||
body: Stack(
|
||
children: [
|
||
const AnimatedGradientBackground(),
|
||
Column(
|
||
children: [
|
||
_buildHeader(context),
|
||
Expanded(
|
||
child: notificationsAsync.when(
|
||
loading: () => const Center(
|
||
child: CircularProgressIndicator(color: Colors.white),
|
||
),
|
||
error: (error, _) => Center(
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Text(
|
||
'加载失败',
|
||
style: TextStyle(
|
||
color: Colors.white.withOpacity(0.8),
|
||
fontSize: 16,
|
||
),
|
||
),
|
||
const SizedBox(height: 12),
|
||
GestureDetector(
|
||
onTap: () => ref.read(notificationControllerProvider.notifier).refresh(),
|
||
child: Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
|
||
decoration: BoxDecoration(
|
||
color: Colors.white.withOpacity(0.2),
|
||
borderRadius: BorderRadius.circular(20),
|
||
),
|
||
child: const Text('重试', style: TextStyle(color: Colors.white)),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
data: (notifications) {
|
||
if (notifications.isEmpty) {
|
||
return _buildEmptyState();
|
||
}
|
||
return ShaderMask(
|
||
shaderCallback: (Rect rect) {
|
||
return const LinearGradient(
|
||
begin: Alignment.topCenter,
|
||
end: Alignment.bottomCenter,
|
||
colors: [
|
||
Colors.transparent,
|
||
Colors.black,
|
||
Colors.black,
|
||
Colors.transparent,
|
||
],
|
||
stops: [0.0, 0.03, 0.95, 1.0],
|
||
).createShader(rect);
|
||
},
|
||
blendMode: BlendMode.dstIn,
|
||
child: ListView.builder(
|
||
padding: EdgeInsets.only(
|
||
top: 8,
|
||
left: 20,
|
||
right: 20,
|
||
bottom: 40 + MediaQuery.of(context).padding.bottom,
|
||
),
|
||
itemCount: notifications.length,
|
||
itemBuilder: (context, index) {
|
||
return _buildNotificationCard(notifications[index]);
|
||
},
|
||
),
|
||
);
|
||
},
|
||
),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildHeader(BuildContext context) {
|
||
return Container(
|
||
padding: EdgeInsets.only(
|
||
top: MediaQuery.of(context).padding.top + 20,
|
||
left: AppSpacing.lg,
|
||
right: AppSpacing.lg,
|
||
bottom: AppSpacing.md,
|
||
),
|
||
child: Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
children: [
|
||
GestureDetector(
|
||
onTap: () => Navigator.pop(context),
|
||
child: Container(
|
||
width: 40,
|
||
height: 40,
|
||
decoration: BoxDecoration(
|
||
color: AppColors.iconBtnBg,
|
||
borderRadius: BorderRadius.circular(AppRadius.button),
|
||
),
|
||
child: const Icon(
|
||
Icons.arrow_back_ios_new,
|
||
color: AppColors.textPrimary,
|
||
size: 18,
|
||
),
|
||
),
|
||
),
|
||
Text('消息通知', style: AppTextStyles.title),
|
||
const SizedBox(width: 40),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildEmptyState() {
|
||
return Center(
|
||
child: Column(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
Container(
|
||
width: 100,
|
||
height: 100,
|
||
decoration: const BoxDecoration(
|
||
color: Color(0x1A9CA3AF),
|
||
shape: BoxShape.circle,
|
||
),
|
||
child: const Icon(
|
||
Icons.notifications_none_rounded,
|
||
size: 48,
|
||
color: AppColors.textSecondary,
|
||
),
|
||
),
|
||
const SizedBox(height: 20),
|
||
const Text(
|
||
'暂无新消息',
|
||
style: TextStyle(
|
||
fontSize: 16,
|
||
fontWeight: FontWeight.w500,
|
||
color: AppColors.textSecondary,
|
||
),
|
||
),
|
||
const SizedBox(height: 8),
|
||
const Text(
|
||
'新的通知会在这里显示',
|
||
style: TextStyle(
|
||
fontSize: 13,
|
||
color: AppColors.textHint,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildNotificationCard(AppNotification notif) {
|
||
final isExpanded = _expandedId == notif.id;
|
||
final isUnread = !notif.isRead;
|
||
final timeStr = _formatTime(notif.createdAt);
|
||
|
||
return Padding(
|
||
padding: const EdgeInsets.only(bottom: 12),
|
||
child: AnimatedContainer(
|
||
duration: const Duration(milliseconds: 300),
|
||
curve: Curves.easeInOut,
|
||
decoration: BoxDecoration(
|
||
color: isExpanded
|
||
? const Color(0xD9FFFFFF)
|
||
: const Color(0xB3FFFFFF),
|
||
borderRadius: BorderRadius.circular(16),
|
||
border: Border.all(color: const Color(0x66FFFFFF)),
|
||
boxShadow: const [AppShadows.card],
|
||
),
|
||
child: Column(
|
||
children: [
|
||
GestureDetector(
|
||
onTap: () => _toggleNotification(notif),
|
||
behavior: HitTestBehavior.opaque,
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(16),
|
||
child: Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
_buildNotifIcon(notif),
|
||
const SizedBox(width: 14),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
children: [
|
||
Expanded(
|
||
child: Text(
|
||
notif.title,
|
||
style: const TextStyle(
|
||
fontSize: 15,
|
||
fontWeight: FontWeight.w600,
|
||
color: AppColors.textPrimary,
|
||
),
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
Text(
|
||
timeStr,
|
||
style: const TextStyle(
|
||
fontSize: 12,
|
||
color: AppColors.textSecondary,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 4),
|
||
Text(
|
||
notif.description,
|
||
style: const TextStyle(
|
||
fontSize: 13,
|
||
color: Color(0xFF6B7280),
|
||
height: 1.5,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
Column(
|
||
children: [
|
||
AnimatedRotation(
|
||
turns: isExpanded ? 0.25 : 0,
|
||
duration: const Duration(milliseconds: 300),
|
||
child: const Icon(
|
||
Icons.chevron_right,
|
||
color: AppColors.textHint,
|
||
size: 20,
|
||
),
|
||
),
|
||
if (isUnread) ...[
|
||
const SizedBox(height: 6),
|
||
Container(
|
||
width: 8,
|
||
height: 8,
|
||
decoration: const BoxDecoration(
|
||
color: AppColors.notificationDot,
|
||
shape: BoxShape.circle,
|
||
),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
|
||
// ── 展开详情区域(只动画高度,宽度始终满宽,避免文字竖排) ──
|
||
ClipRect(
|
||
child: AnimatedSize(
|
||
duration: const Duration(milliseconds: 300),
|
||
curve: Curves.easeInOut,
|
||
child: isExpanded
|
||
? Container(
|
||
width: double.infinity,
|
||
decoration: const BoxDecoration(
|
||
color: Color(0x80F9FAFB),
|
||
border: Border(
|
||
top: BorderSide(
|
||
color: Color(0x0D000000),
|
||
),
|
||
),
|
||
),
|
||
padding: const EdgeInsets.all(20),
|
||
child: Text(
|
||
notif.content,
|
||
style: const TextStyle(
|
||
fontSize: 14,
|
||
color: Color(0xFF374151),
|
||
height: 1.7,
|
||
),
|
||
),
|
||
)
|
||
: const SizedBox(width: double.infinity, height: 0),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildNotifIcon(AppNotification notif) {
|
||
final isSystem = notif.type == 'system';
|
||
final isActivity = notif.type == 'activity';
|
||
|
||
IconData icon;
|
||
if (isSystem) {
|
||
icon = Icons.info_outline;
|
||
} else if (isActivity) {
|
||
icon = Icons.card_giftcard;
|
||
} else {
|
||
icon = Icons.devices;
|
||
}
|
||
|
||
return Container(
|
||
width: 40,
|
||
height: 40,
|
||
decoration: BoxDecoration(
|
||
color: isSystem
|
||
? const Color(0xFFEFF6FF)
|
||
: isActivity
|
||
? const Color(0xFFFFF7ED)
|
||
: const Color(0xFFF0FDF4),
|
||
borderRadius: BorderRadius.circular(20),
|
||
),
|
||
alignment: Alignment.center,
|
||
child: notif.imageUrl.isNotEmpty
|
||
? ClipRRect(
|
||
borderRadius: BorderRadius.circular(20),
|
||
child: Image.network(
|
||
notif.imageUrl,
|
||
width: 40,
|
||
height: 40,
|
||
fit: BoxFit.cover,
|
||
errorBuilder: (_, __, ___) => Icon(
|
||
icon,
|
||
size: 20,
|
||
color: isSystem ? const Color(0xFF3B82F6) : const Color(0xFFF97316),
|
||
),
|
||
),
|
||
)
|
||
: Icon(
|
||
icon,
|
||
size: 20,
|
||
color: isSystem
|
||
? const Color(0xFF3B82F6)
|
||
: isActivity
|
||
? const Color(0xFFF97316)
|
||
: const Color(0xFF22C55E),
|
||
),
|
||
);
|
||
}
|
||
|
||
String _formatTime(String createdAt) {
|
||
try {
|
||
final dt = DateTime.parse(createdAt);
|
||
final now = DateTime.now();
|
||
final diff = now.difference(dt);
|
||
|
||
if (diff.inMinutes < 60) return '${diff.inMinutes}分钟前';
|
||
if (diff.inHours < 24) return '${diff.inHours}小时前';
|
||
if (diff.inDays < 2) return '昨天';
|
||
if (diff.inDays < 7) return '${diff.inDays}天前';
|
||
return '${dt.month}月${dt.day}日';
|
||
} catch (_) {
|
||
return createdAt;
|
||
}
|
||
}
|
||
}
|