feat: 实现日历提醒 in-app fallback 机制及通知服务重构

This commit is contained in:
zl-q
2026-03-20 01:30:34 +08:00
parent 7fd536e976
commit d574128815
55 changed files with 4565 additions and 647 deletions
@@ -59,6 +59,14 @@ class TodoApi {
return TodoResponse.fromJson(response.data);
}
Future<void> updateTodoPriority(String id, int priority) async {
try {
await _client.patch('$_prefix/$id', data: {'priority': priority});
} catch (_) {
// Ignore response parsing errors, just need to know if request succeeded
}
}
Future<TodoResponse> completeTodo(String id) async {
final response = await _client.post('$_prefix/$id/complete', data: {});
return TodoResponse.fromJson(response.data);
@@ -12,6 +12,7 @@ import '../../../../shared/widgets/full_screen_loading.dart';
import '../../../../shared/widgets/toast/toast.dart';
import '../../../../shared/widgets/toast/toast_type.dart';
import '../../../calendar/data/calendar_api.dart';
import '../../../calendar/data/models/schedule_item_model.dart';
import '../../data/todo_api.dart';
class TodoEditScreen extends StatefulWidget {
@@ -88,6 +89,7 @@ class _TodoEditScreenState extends State<TodoEditScreen> {
..clear()
..addAll(todo?.scheduleItems.map((item) => item.id) ?? const []);
_scheduleItems = scheduleItems
.where((item) => item.status == ScheduleStatus.active)
.map(
(item) => _ScheduleItemSimple(
id: item.id,
@@ -115,22 +117,29 @@ class _TodoEditScreenState extends State<TodoEditScreen> {
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppColors.todoBg,
resizeToAvoidBottomInset: false,
body: SafeArea(
child: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [AppColors.homeBackgroundTop, AppColors.todoBg],
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () => FocusScope.of(context).unfocus(),
child: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [AppColors.homeBackgroundTop, AppColors.todoBg],
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
BackTitlePageHeader(
title: widget.isCreateMode ? '新建待办' : '编辑待办',
),
Expanded(child: _buildBody()),
_buildBottomAction(),
],
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
BackTitlePageHeader(title: widget.isCreateMode ? '新建待办' : '编辑待办'),
Expanded(child: _buildBody()),
_buildBottomAction(),
],
),
),
),
@@ -406,11 +415,6 @@ class _TodoEditScreenState extends State<TodoEditScreen> {
if (!mounted) {
return;
}
Toast.show(
context,
widget.isCreateMode ? '待办已创建' : '待办已更新',
type: ToastType.success,
);
context.pop(true);
} catch (error) {
if (!mounted) {
@@ -15,6 +15,7 @@ import '../../../../shared/widgets/toast/toast_type.dart';
import '../../../calendar/ui/calendar_state_manager.dart';
import '../../../calendar/ui/widgets/bottom_dock.dart';
import '../../data/todo_api.dart';
import '../widgets/todo_drag_item.dart';
class TodoQuadrantsScreen extends StatefulWidget {
const TodoQuadrantsScreen({super.key});
@@ -32,6 +33,78 @@ class _TodoQuadrantsScreenState extends State<TodoQuadrantsScreen> {
bool _loadingTodosRequest = false;
String? _error;
String? _draggingTodoId;
int? _dragTargetQuadrant;
int? _dragInsertIndex;
bool get _isDragging => _draggingTodoId != null;
void _onDragStart(String todoId) {
setState(() {
_draggingTodoId = todoId;
_dragTargetQuadrant = null;
_dragInsertIndex = null;
});
}
void _onDragEnd() {
setState(() {
_draggingTodoId = null;
_dragTargetQuadrant = null;
_dragInsertIndex = null;
});
}
void _onDragEnterQuadrant(int quadrant) {
setState(() {
_dragTargetQuadrant = quadrant;
});
}
void _onDragUpdateInsertIndex(int index) {
setState(() {
_dragInsertIndex = index;
});
}
Future<void> _onDrop(
String todoId,
int targetQuadrant,
int insertIndex,
) async {
final previousTodos = List<TodoResponse>.from(_todos);
try {
final todo = _todos.firstWhere((t) => t.id == todoId);
final sourceQuadrant = todo.priority;
if (sourceQuadrant == targetQuadrant) {
_onDragEnd();
return;
}
setState(() {
final index = _todos.indexWhere((t) => t.id == todoId);
if (index != -1) {
_todos[index] = _todos[index].copyWith(priority: targetQuadrant);
}
});
await _todoApi.updateTodoPriority(todoId, targetQuadrant);
} catch (e) {
if (!mounted) return;
setState(() {
_todos = previousTodos;
});
Toast.show(context, '移动失败', type: ToastType.error);
} finally {
if (mounted) _onDragEnd();
}
}
void _onDragLeave() {
// 清除高亮
}
@override
void initState() {
super.initState();
@@ -140,7 +213,7 @@ class _TodoQuadrantsScreenState extends State<TodoQuadrantsScreen> {
child: Column(
children: [
_buildHeader(),
Expanded(child: _buildContent()),
Expanded(child: _buildContent(withScroll: true)),
_buildBottomDock(),
],
),
@@ -205,7 +278,7 @@ class _TodoQuadrantsScreenState extends State<TodoQuadrantsScreen> {
);
}
Widget _buildContent() {
Widget _buildContent({bool withScroll = false}) {
if (_isLoading) {
return const FullScreenLoading();
}
@@ -214,58 +287,71 @@ class _TodoQuadrantsScreenState extends State<TodoQuadrantsScreen> {
return ErrorRetrySurface(message: '加载失败: $_error', onRetry: _loadTodos);
}
return Stack(
Widget content = Column(
mainAxisSize: MainAxisSize.min,
children: [
RefreshIndicator.noSpinner(
onRefresh: _onPullRefresh,
child: Padding(
padding: const EdgeInsets.only(
left: 16,
right: 16,
top: 4,
bottom: 96,
),
child: ListView(
children: [
_buildQuadrant(
title: '重要紧急',
textColor: AppColors.g1Text,
dividerColor: AppColors.g1Divider,
borderColor: AppColors.g1Border,
items: _importantUrgent,
onComplete: _completeTodo,
onTap: _navigateToDetail,
),
const SizedBox(height: 12),
_buildQuadrant(
title: '紧急不重要',
textColor: AppColors.g2Text,
dividerColor: AppColors.g2Divider,
borderColor: AppColors.g2Border,
items: _urgentNotImportant,
onComplete: _completeTodo,
onTap: _navigateToDetail,
),
const SizedBox(height: 12),
_buildQuadrant(
title: '重要不紧急',
textColor: AppColors.g3Text,
dividerColor: AppColors.g3Divider,
borderColor: AppColors.g3Border,
items: _importantNotUrgent,
onComplete: _completeTodo,
onTap: _navigateToDetail,
),
],
),
),
_buildQuadrant(
title: '重要紧急',
textColor: AppColors.g1Text,
dividerColor: AppColors.g1Divider,
borderColor: AppColors.g1Border,
items: _importantUrgent,
quadrantValue: 1,
onComplete: _completeTodo,
onTap: _navigateToDetail,
),
Align(
alignment: Alignment.topCenter,
child: AppPullRefreshFeedback(visible: _isPullRefreshing),
const SizedBox(height: 12),
_buildQuadrant(
title: '紧急不重要',
textColor: AppColors.g2Text,
dividerColor: AppColors.g2Divider,
borderColor: AppColors.g2Border,
items: _urgentNotImportant,
quadrantValue: 3,
onComplete: _completeTodo,
onTap: _navigateToDetail,
),
const SizedBox(height: 12),
_buildQuadrant(
title: '重要不紧急',
textColor: AppColors.g3Text,
dividerColor: AppColors.g3Divider,
borderColor: AppColors.g3Border,
items: _importantNotUrgent,
quadrantValue: 2,
onComplete: _completeTodo,
onTap: _navigateToDetail,
),
],
);
if (withScroll) {
return Stack(
children: [
RefreshIndicator.noSpinner(
onRefresh: _onPullRefresh,
child: SingleChildScrollView(
padding: const EdgeInsets.only(
left: 16,
right: 16,
top: 4,
bottom: 96,
),
child: content,
),
),
Align(
alignment: Alignment.topCenter,
child: AppPullRefreshFeedback(visible: _isPullRefreshing),
),
],
);
}
return Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 16),
child: content,
);
}
Widget _buildQuadrant({
@@ -274,73 +360,132 @@ class _TodoQuadrantsScreenState extends State<TodoQuadrantsScreen> {
required Color dividerColor,
required Color borderColor,
required List<TodoResponse> items,
required int quadrantValue,
required Future<void> Function(TodoResponse) onComplete,
required void Function(TodoResponse) onTap,
}) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: AppColors.todoCardBg,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: borderColor, width: 1),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
title,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 15,
fontWeight: FontWeight.w700,
color: textColor,
),
),
Text(
'${items.length}',
style: TextStyle(
fontFamily: 'Inter',
fontSize: 12,
fontWeight: FontWeight.w700,
color: textColor,
),
),
],
),
const SizedBox(height: 8),
_buildQuadrantHeader(title, textColor, items.length),
Container(height: 1, color: dividerColor),
const SizedBox(height: 8),
if (items.isEmpty)
const Padding(
padding: EdgeInsets.symmetric(vertical: 16),
child: Center(
child: Text(
'暂无待办',
style: TextStyle(
fontFamily: 'Inter',
fontSize: 13,
color: AppColors.slate400,
Padding(
padding: const EdgeInsets.fromLTRB(6, 0, 6, 8),
child: DragTarget<String>(
onWillAcceptWithDetails: (details) {
_onDragEnterQuadrant(quadrantValue);
return true;
},
onAcceptWithDetails: (details) {
final parts = details.data.split(':');
final todoId = parts[0];
_onDrop(todoId, quadrantValue, 0);
},
onLeave: (_) {
_onDragLeave();
},
builder: (context, candidateData, rejectedData) {
final isDragOver = candidateData.isNotEmpty;
return Container(
decoration: BoxDecoration(
color: isDragOver
? AppColors.blue50.withValues(alpha: 0.3)
: Colors.transparent,
borderRadius: BorderRadius.circular(8),
border: isDragOver
? Border.all(color: AppColors.blue400, width: 2)
: null,
),
),
),
)
else
...items.map(
(item) => _TodoItemWidget(
item: item,
onComplete: () => onComplete(item),
onTap: () => onTap(item),
),
child: items.isEmpty
? SizedBox(
height: 60,
child: Center(
child: Text(
'暂无待办',
style: TextStyle(
fontFamily: 'Inter',
fontSize: 13,
color: AppColors.slate400,
),
),
),
)
: _buildQuadrantItemList(
items,
quadrantValue,
onComplete,
onTap,
),
);
},
),
),
],
),
);
}
Widget _buildQuadrantHeader(String title, Color textColor, int itemCount) {
return Padding(
padding: const EdgeInsets.fromLTRB(10, 10, 10, 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
title,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 15,
fontWeight: FontWeight.w700,
color: textColor,
),
),
Text(
'${itemCount}',
style: TextStyle(
fontFamily: 'Inter',
fontSize: 12,
fontWeight: FontWeight.w700,
color: textColor,
),
),
],
),
);
}
Widget _buildQuadrantItemList(
List<TodoResponse> items,
int quadrantValue,
Future<void> Function(TodoResponse) onComplete,
void Function(TodoResponse) onTap,
) {
return Column(
mainAxisSize: MainAxisSize.min,
children: items.map((item) {
return TodoDragItem(
todo: item,
quadrant: quadrantValue,
onDragStarted: () => _onDragStart(item.id),
onDragEnd: _onDragEnd,
child: _TodoItemWidget(
item: item,
onComplete: () => onComplete(item),
onTap: () => onTap(item),
),
);
}).toList(),
);
}
Widget _buildBottomDock() {
return BottomDock(
activeTab: DockTab.todo,
@@ -0,0 +1,74 @@
import 'package:flutter/material.dart';
import 'package:social_app/core/theme/design_tokens.dart';
import 'package:social_app/features/todo/data/todo_api.dart';
class TodoDragItem extends StatelessWidget {
final TodoResponse todo;
final int quadrant;
final VoidCallback onDragStarted;
final VoidCallback onDragEnd;
final Widget child;
const TodoDragItem({
super.key,
required this.todo,
required this.quadrant,
required this.onDragStarted,
required this.onDragEnd,
required this.child,
});
@override
Widget build(BuildContext context) {
return LongPressDraggable<String>(
data: '${todo.id}:$quadrant',
delay: const Duration(milliseconds: 150),
feedback: Material(
elevation: 8,
borderRadius: BorderRadius.circular(AppRadius.md),
child: Transform.scale(
scale: 1.03,
child: SizedBox(width: 280, child: _buildDragFeedback()),
),
),
childWhenDragging: AnimatedOpacity(
duration: const Duration(milliseconds: 100),
opacity: 0.3,
child: child,
),
onDragStarted: onDragStarted,
onDragEnd: (_) => onDragEnd(),
child: child,
);
}
Widget _buildDragFeedback() {
return Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
vertical: AppSpacing.sm,
),
decoration: BoxDecoration(
color: AppColors.white,
borderRadius: BorderRadius.circular(AppRadius.md),
boxShadow: [
BoxShadow(
color: AppColors.slate400.withValues(alpha: 0.3),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
child: Text(
todo.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: AppColors.slate700,
),
),
);
}
}