diff --git a/apps/lib/features/calendar/reminders/ui/reminder_overlay.dart b/apps/lib/features/calendar/reminders/ui/reminder_overlay.dart new file mode 100644 index 0000000..5d0c6ce --- /dev/null +++ b/apps/lib/features/calendar/reminders/ui/reminder_overlay.dart @@ -0,0 +1,208 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +import 'package:social_app/core/theme/design_tokens.dart'; +import 'package:social_app/shared/widgets/app_button.dart'; +import 'package:social_app/features/calendar/reminders/reminder_queue_manager.dart'; +import 'package:social_app/features/calendar/reminders/models/reminder_payload.dart'; + +class ReminderOverlay extends StatelessWidget { + const ReminderOverlay({ + super.key, + required this.queueManager, + required this.onComplete, + required this.onSnooze, + required this.onArchive, + }); + + final ReminderQueueManager queueManager; + final VoidCallback onComplete; + final void Function(int minutes) onSnooze; + final VoidCallback onArchive; + + @override + Widget build(BuildContext context) { + final payload = queueManager.currentPayload; + + return Scaffold( + backgroundColor: AppColors.white, + body: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.lg), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const Spacer(), + Text( + payload?.title ?? '', + style: Theme.of(context).textTheme.headlineSmall, + textAlign: TextAlign.center, + ), + const SizedBox(height: AppSpacing.md), + Text( + DateFormat('HH:mm').format(DateTime.now()), + style: Theme.of(context).textTheme.titleLarge, + ), + const Spacer(), + _buildBottomButtons(context), + const SizedBox(height: AppSpacing.xxl), + ], + ), + ), + ), + ); + } + + Widget _buildBottomButtons(BuildContext context) { + return Row( + children: [ + Expanded( + child: _SnoozeButton( + onSnooze: (minutes) { + onSnooze(minutes); + queueManager.dequeueCurrent(); + onComplete(); + }, + ), + ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: AppButton( + text: '完成', + onPressed: () { + onArchive(); + queueManager.dequeueCurrent(); + onComplete(); + }, + ), + ), + ], + ); + } +} + +class _SnoozeButton extends StatefulWidget { + const _SnoozeButton({required this.onSnooze}); + + final void Function(int minutes) onSnooze; + + @override + State<_SnoozeButton> createState() => _SnoozeButtonState(); +} + +class _SnoozeButtonState extends State<_SnoozeButton> { + final LayerLink _layerLink = LayerLink(); + OverlayEntry? _overlayEntry; + + @override + void dispose() { + _removeOverlay(); + super.dispose(); + } + + void _removeOverlay() { + _overlayEntry?.remove(); + _overlayEntry = null; + } + + void _showOverlay() { + _removeOverlay(); + + final overlay = Overlay.of(context); + final renderBox = context.findRenderObject() as RenderBox; + final size = renderBox.size; + + _overlayEntry = OverlayEntry( + builder: (context) => Stack( + children: [ + Positioned( + width: size.width, + child: CompositedTransformFollower( + link: _layerLink, + showWhenUnlinked: false, + offset: Offset(0, size.height + AppSpacing.xs), + child: Material( + elevation: 4, + borderRadius: BorderRadius.circular(AppRadius.md), + color: AppColors.white, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _SnoozeOption( + label: '5 分钟', + onTap: () { + _removeOverlay(); + widget.onSnooze(5); + }, + ), + Divider(height: 1, color: AppColors.border), + _SnoozeOption( + label: '15 分钟', + onTap: () { + _removeOverlay(); + widget.onSnooze(15); + }, + ), + ], + ), + ), + ), + ), + Positioned.fill( + child: GestureDetector( + onTap: _removeOverlay, + behavior: HitTestBehavior.opaque, + child: Container(color: Colors.transparent), + ), + ), + ], + ), + ); + + overlay.insert(_overlayEntry!); + } + + @override + Widget build(BuildContext context) { + return CompositedTransformTarget( + link: _layerLink, + child: AppButton( + text: '稍后提醒', + isOutlined: true, + onPressed: () { + if (_overlayEntry == null) { + _showOverlay(); + } else { + _removeOverlay(); + } + }, + ), + ); + } +} + +class _SnoozeOption extends StatelessWidget { + const _SnoozeOption({required this.label, required this.onTap}); + + final String label; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(AppRadius.md), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.lg, + vertical: AppSpacing.md, + ), + child: Text( + label, + style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w500), + ), + ), + ); + } +} diff --git a/apps/test/features/calendar/reminders/reminder_overlay_test.dart b/apps/test/features/calendar/reminders/reminder_overlay_test.dart new file mode 100644 index 0000000..0763108 --- /dev/null +++ b/apps/test/features/calendar/reminders/reminder_overlay_test.dart @@ -0,0 +1,106 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:social_app/features/calendar/reminders/ui/reminder_overlay.dart'; +import 'package:social_app/features/calendar/reminders/reminder_queue_manager.dart'; +import 'package:social_app/features/calendar/reminders/models/reminder_payload.dart'; + +void main() { + group('ReminderOverlay', () { + late ReminderQueueManager queueManager; + + setUp(() { + SharedPreferences.setMockInitialValues({}); + queueManager = ReminderQueueManager(); + }); + + testWidgets('显示日程标题和当前时间', (tester) async { + final payload = ReminderPayload( + eventId: '1', + title: 'Test Meeting', + startAt: DateTime(2026, 3, 20, 10, 0), + endAt: DateTime(2026, 3, 20, 11, 0), + timezone: 'Asia/Shanghai', + mode: ReminderPayloadMode.single, + ); + queueManager.enqueueFromClick(payload); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ReminderOverlay( + queueManager: queueManager, + onComplete: () {}, + onSnooze: (minutes) {}, + onArchive: () {}, + ), + ), + ), + ); + + expect(find.text('Test Meeting'), findsOneWidget); + }); + + testWidgets('点击完成按钮触发归档', (tester) async { + bool archiveCalled = false; + final payload = ReminderPayload( + eventId: '1', + title: 'Test Meeting', + startAt: DateTime(2026, 3, 20, 10, 0), + endAt: DateTime(2026, 3, 20, 11, 0), + timezone: 'Asia/Shanghai', + mode: ReminderPayloadMode.single, + ); + queueManager.enqueueFromClick(payload); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ReminderOverlay( + queueManager: queueManager, + onComplete: () {}, + onSnooze: (minutes) {}, + onArchive: () => archiveCalled = true, + ), + ), + ), + ); + + await tester.tap(find.text('完成')); + await tester.pump(); + + expect(archiveCalled, true); + }); + + testWidgets('点击稍后提醒显示下拉选项', (tester) async { + final payload = ReminderPayload( + eventId: '1', + title: 'Test Meeting', + startAt: DateTime(2026, 3, 20, 10, 0), + endAt: DateTime(2026, 3, 20, 11, 0), + timezone: 'Asia/Shanghai', + mode: ReminderPayloadMode.single, + ); + queueManager.enqueueFromClick(payload); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ReminderOverlay( + queueManager: queueManager, + onComplete: () {}, + onSnooze: (minutes) {}, + onArchive: () {}, + ), + ), + ), + ); + + await tester.tap(find.text('稍后提醒')); + await tester.pumpAndSettle(); + + expect(find.text('5 分钟'), findsOneWidget); + expect(find.text('15 分钟'), findsOneWidget); + }); + }); +}