feat: 实现日历提醒 in-app fallback 机制及通知服务重构
This commit is contained in:
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user