This commit is contained in:
zhutao
2025-09-08 15:30:09 +08:00
parent 193d29b0ce
commit 4623094bad
15 changed files with 374 additions and 246 deletions

View File

@@ -1,9 +1,9 @@
class PlanStepDto {
num? id;
int? id;
String? stepIcon;
String? stepContent;
String? stepExplain;
num? stepStatus;
int? stepStatus;
PlanStepDto({this.id, this.stepIcon, this.stepContent, this.stepExplain, this.stepStatus});

View File

@@ -2,6 +2,8 @@ import 'package:plan/api/dto/plan_detail_dto.dart';
import 'package:plan/api/dto/plan_item_dto.dart';
import 'package:plan/api/network/request.dart';
import '../../data/models/plan_acttion_type.dart';
///初始化计划
Future<int> initPlanApi(String need, int agentId) async {
var res = await Request().post("/plan/init", {
@@ -56,3 +58,28 @@ Future<void> deletePlanApi(int planId) async {
"plan_id": planId,
});
}
///编辑用户计划步骤
Future<void> editPlanStepApi(
int planId, {
required PlanActionType act,
int? stepId,
String? content,
String? explain,
}) async {
await Request().post("/plan/edit_plan_steps_info", {
"plan_id": planId,
"act": act.value,
"step_id": stepId,
"step_content": content,
"step_explain": explain,
});
}
///修改步骤顺序
Future<void> editPlanStepOrderApi(int planId,List<PlanStepDto> list) async {
await Request().post("/plan/change_plan_steps", {
"plan_id": planId,
"steps": list.map((e) => e.toJson()).toList(),
});
}

View File

@@ -0,0 +1,10 @@
enum PlanActionType {
delete(1), //删除
complete(2), //完成
edit(3), //编辑
completeAll(99); //全部完成
final int value;
const PlanActionType(this.value);
}

View File

@@ -12,7 +12,7 @@ class PlanFormCard extends StatefulWidget {
}
class _PlanFormCardState extends State<PlanFormCard> {
final TextEditingController _inputController = TextEditingController(text: "");
final TextEditingController _inputController = TextEditingController(text: "刷牙");
void _handSubmit() {
if (_inputController.text.isEmpty) {

View File

@@ -0,0 +1,80 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:plan/api/endpoints/plan_api.dart';
import 'package:plan/widgets/ui_kit/popup/popup_action.dart';
import 'package:remixicon/remixicon.dart';
import '../../widgets/edit_desc_dialog.dart';
import '../viewmodel/plan_detail_store.dart';
class BarActions extends StatefulWidget {
final PlanDetailStore store;
const BarActions({super.key, required this.store});
@override
State<BarActions> createState() => _BarActionsState();
}
class _BarActionsState extends State<BarActions> {
///popup菜单
void _onPopupActionSelected(String value) {
if (value == 'edit_step') {
widget.store.setEdit(true);
} else if (value == 'edit_desc') {
showEditDescDialog(
context,
value: widget.store.planDetail.summary ?? "",
onConfirm: (value) async {
widget.store.updatePlanDetail((dto) {
dto.summary = value;
});
await editPlanSummaryApi(int.parse(widget.store.planId), value);
},
);
}
}
///取消编辑
void _cancelEdit() {
widget.store.setEdit(false);
}
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min, // 关键Row 只占实际内容宽度
children: [
AnimatedSwitcher(
duration: Duration(milliseconds: 300),
transitionBuilder: (child, animation) {
// 仅使用渐变动画
return FadeTransition(
opacity: animation,
child: child,
);
},
child: widget.store.isEdit
? InkWell(
onTap: _cancelEdit,
child: Icon(RemixIcons.check_fill),
)
: PopupAction(
onSelected: _onPopupActionSelected,
items: [
PopupMenuItem(
value: 'edit_step',
child: Text("编辑步骤"),
),
PopupMenuItem(
value: 'edit_desc',
child: Text("编辑摘要"),
),
],
child: Icon(RemixIcons.more_fill),
),
),
],
);
}
}

View File

@@ -1,12 +1,10 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:plan/page/plan/detail/navigation/bar_actions.dart';
import 'package:plan/page/plan/detail/viewmodel/plan_detail_store.dart';
import 'package:plan/theme/decorations/app_shadows.dart';
import 'package:plan/widgets/ui_kit/popup/popup_action.dart';
import 'package:provider/provider.dart';
import 'package:remixicon/remixicon.dart';
import '../widgets/edit_desc_dialog.dart';
import 'widgets/avatar_card.dart';
import 'widgets/coach_message.dart';
import 'widgets/plan_list.dart';
@@ -28,7 +26,6 @@ class PlanDetailPage extends StatefulWidget {
}
class _PlanDetailPageState extends State<PlanDetailPage> {
bool _isEdit = false;
final ScrollController scrollController = ScrollController();
///store对象
@@ -44,28 +41,6 @@ class _PlanDetailPageState extends State<PlanDetailPage> {
);
}
///popup菜单
void _onPopupActionSelected(String value) {
if (value == 'edit_step') {
setState(() {
_isEdit = true;
});
} else if (value == 'edit_desc') {
showEditDescDialog(
context,
value: "你好",
onConfirm: (value) {},
);
}
}
///取消编辑
void _cancelEdit() {
setState(() {
_isEdit = false;
});
}
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider<PlanDetailStore>(
@@ -83,9 +58,8 @@ class _PlanDetailPageState extends State<PlanDetailPage> {
child: ScrollBox(
child: Container(
decoration: shadowDecoration,
child: CustomScrollView(
controller: scrollController,
slivers: [
child: Column(
children: [
CoachMessage(),
PlanList(),
SuggestedTitle(),
@@ -104,40 +78,7 @@ class _PlanDetailPageState extends State<PlanDetailPage> {
backgroundColor: Colors.white,
navigationBar: CupertinoNavigationBar(
middle: Text(store.planDetail.summary ?? ""),
trailing: Row(
mainAxisSize: MainAxisSize.min, // 关键Row 只占实际内容宽度
children: [
AnimatedSwitcher(
duration: Duration(milliseconds: 300),
transitionBuilder: (child, animation) {
// 仅使用渐变动画
return FadeTransition(
opacity: animation,
child: child,
);
},
child: _isEdit
? InkWell(
onTap: _cancelEdit,
child: Icon(RemixIcons.check_fill),
)
: PopupAction(
onSelected: _onPopupActionSelected,
items: [
PopupMenuItem(
value: 'edit_step',
child: Text("编辑步骤"),
),
PopupMenuItem(
value: 'edit_desc',
child: Text("编辑摘要"),
),
],
child: Icon(RemixIcons.more_fill),
),
),
],
),
trailing: BarActions(store: store),
),
child: child!,
);

View File

@@ -14,7 +14,7 @@ class PlanDetailStore extends ChangeNotifier {
//如果没有id进行初始化
if (planId == "0") {
createPlan();
}else{
} else {
//获取详情
getPlanDetail();
}
@@ -42,6 +42,15 @@ class PlanDetailStore extends ChangeNotifier {
///计划详情
PlanDetailDto planDetail = PlanDetailDto(summary: "计划详情");
///是否正在编辑
bool isEdit = false;
///切换编辑模式
void setEdit(bool value) {
isEdit = value;
notifyListeners();
}
///流请求工具
StreamUtils streamUtils = StreamUtils();
@@ -49,7 +58,6 @@ class PlanDetailStore extends ChangeNotifier {
void createPlan() async {
var id = await initPlanApi(planContent, 1);
planId = id.toString();
// planId = "3";
///生成摘要---------------------------
String summary = "";
@@ -92,9 +100,12 @@ class PlanDetailStore extends ChangeNotifier {
.where((e) => e.contains("[NEXT]"))
.map((e) => e.replaceAll("[NEXT]", "").trim())
.toList();
// 直接覆盖 stepsList
planDetail.stepsList = sentences.map((s) => PlanStepDto(stepContent: s)).toList();
planDetail.stepsList = sentences
.asMap()
.entries
.map((e) => PlanStepDto(id: e.key, stepContent: e.value))
.toList();
notifyListeners();
},
);
@@ -159,4 +170,10 @@ class PlanDetailStore extends ChangeNotifier {
planDetail = await getPlanDetailApi(planId);
notifyListeners();
}
///更新计划详情
void updatePlanDetail(void Function(PlanDetailDto dto) updater) {
updater(planDetail);
notifyListeners();
}
}

View File

@@ -14,23 +14,21 @@ class _CoachMessageState extends State<CoachMessage> {
Widget build(BuildContext context) {
var store = context.read<PlanDetailStore>();
if (store.planContent.isEmpty) {
return SliverToBoxAdapter();
return SizedBox();
}
return SliverToBoxAdapter(
child: Container(
padding: EdgeInsets.all(20),
child: Column(
children: [
Text(
"你的教练正在拆分",
style: Theme.of(context).textTheme.bodyMedium,
),
Text(
'"${store.planContent}"',
style: Theme.of(context).textTheme.titleMedium,
),
],
),
return Container(
padding: EdgeInsets.all(20),
child: Column(
children: [
Text(
"你的教练正在拆分",
style: Theme.of(context).textTheme.bodyMedium,
),
Text(
'"${store.planContent}"',
style: Theme.of(context).textTheme.titleMedium,
),
],
),
);
}

View File

@@ -1,5 +1,9 @@
import 'package:animated_reorderable_list/animated_reorderable_list.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:plan/api/dto/plan_detail_dto.dart';
import 'package:plan/api/endpoints/plan_api.dart';
import 'package:plan/data/models/plan_acttion_type.dart';
import 'package:plan/page/plan/detail/viewmodel/plan_detail_store.dart';
import 'package:plan/utils/common.dart';
import 'package:plan/widgets/business/delete_row_item.dart';
@@ -14,143 +18,197 @@ class PlanList extends StatefulWidget {
}
class _PlanListState extends State<PlanList> {
final GlobalKey<SliverAnimatedListState> _listKey = GlobalKey<SliverAnimatedListState>();
List<PlanStepDto> _stepsList = [];
@override
void initState() {
super.initState();
_initListen();
}
void _initListen() {
final store = context.read<PlanDetailStore>();
//先初始化,只在详情时才会有意义
_stepsList = store.planDetail.stepsList;
store.addListener(() {
final newList = store.planDetail.stepsList;
///删除步骤
void _handDelete(int id) async {
var store = context.read<PlanDetailStore>();
store.updatePlanDetail((dto) {
dto.stepsList = dto.stepsList.where((element) => element.id != id).toList();
});
await editPlanStepApi(
int.parse(store.planId),
act: PlanActionType.delete,
stepId: id,
);
}
// 找出新增的 item
if (newList.length > _stepsList.length) {
final addedItems = newList.sublist(_stepsList.length);
///确认排序
void _confirmSort(List<PlanStepDto> list) async {
var store = context.read<PlanDetailStore>();
store.updatePlanDetail((dto) {
dto.stepsList = list;
});
await editPlanStepOrderApi(int.parse(store.planId), list);
}
for (var item in addedItems) {
final index = _stepsList.length;
_stepsList.add(item);
///确认完成或者取消
void _handComplete(int id) async {
HapticFeedback.vibrate();
var store = context.read<PlanDetailStore>();
// 插入动画
_listKey.currentState?.insertItem(
index,
duration: Duration(milliseconds: 300),
store.updatePlanDetail((dto) {
dto.stepsList = dto.stepsList.map((step) {
if (step.id == id) {
// 只更新匹配的项
step.stepStatus = step.stepStatus == 2 ? 0 : 2;
editPlanStepApi(
int.parse(store.planId),
act: PlanActionType.complete,
stepId: id,
);
}
} else {
setState(() {
_stepsList = store.planDetail.stepsList;
});
}
return step;
}).toList();
});
}
@override
Widget build(BuildContext context) {
return SliverAnimatedList(
key: _listKey,
initialItemCount: _stepsList.length,
itemBuilder: (_, index, animation) {
//动画
final curvedAnimation = CurvedAnimation(
parent: animation,
curve: Curves.easeInOut,
);
return PlanItem(
step: _stepsList[index],
curvedAnimation: curvedAnimation,
onDelete: (id) {},
final isEdit = context.select<PlanDetailStore, bool>((s) => s.isEdit);
return Selector<PlanDetailStore, List<String>>(
selector: (_, store) => store.planDetail.stepsList
.map((e) => '${e.id}_${e.stepExplain}_${e.stepStatus}')
.toList(),
builder: (context, list, _) {
final list = context.read<PlanDetailStore>().planDetail.stepsList;
return AnimatedReorderableListView(
items: list,
physics: const NeverScrollableScrollPhysics(),
shrinkWrap: true,
itemBuilder: (BuildContext context, int index) {
var item = list[index];
return PlanItem(
key: ValueKey('${item.id ?? index}_${item.hashCode}'),
step: item,
isEdit: isEdit,
onDelete: _handDelete,
onComplete: _handComplete,
);
},
enterTransition: [SlideInDown()],
exitTransition: [SlideInUp()],
insertDuration: const Duration(milliseconds: 300),
removeDuration: const Duration(milliseconds: 300),
dragStartDelay: const Duration(milliseconds: 300),
onReorder: (int oldIndex, int newIndex) {
setState(() {
final item = list.removeAt(oldIndex);
list.insert(newIndex, item);
_confirmSort(list);
});
},
nonDraggableItems: isEdit ? <PlanStepDto>[] : list,
buildDefaultDragHandles: false,
longPressDraggable: false,
isSameItem: (a, b) => a.stepContent == b.stepContent,
);
},
);
}
}
///计划item
class PlanItem extends StatelessWidget {
final PlanStepDto step;
final bool showEdit;
final CurvedAnimation curvedAnimation;
final bool isEdit;
final Function(int) onDelete;
final Function(int) onComplete;
const PlanItem({
super.key,
required this.step,
required this.curvedAnimation,
this.showEdit = false,
this.isEdit = false,
required this.onDelete,
required this.onComplete,
});
@override
Widget build(BuildContext context) {
return FadeTransition(
opacity: curvedAnimation,
child: SizeTransition(
sizeFactor: curvedAnimation,
axis: Axis.vertical,
child: Container(
padding: EdgeInsets.symmetric(horizontal: 20, vertical: 10),
child: DeleteRowItem(
showDelete: showEdit,
onDelete: () {},
builder: (_, animate) {
return [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
margin: EdgeInsets.only(bottom: 3),
child: Text(
step.stepContent ?? "",
style: Theme.of(context).textTheme.bodyMedium,
),
return GestureDetector(
onTap: () {
if (!isEdit) {
onComplete(step.id!);
}
},
child: Container(
padding: EdgeInsets.symmetric(horizontal: 20, vertical: 10),
color: Colors.white,
child: DeleteRowItem(
showDelete: isEdit,
onDelete: () {
onDelete(step.id!);
},
builder: (_, animate) {
return [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
margin: EdgeInsets.only(bottom: 3),
child: Text(
step.stepContent ?? "",
style: stepTextStyle(context, Theme.of(context).textTheme.bodyMedium),
),
AnimatedSwitcher(
duration: Duration(milliseconds: 300),
transitionBuilder: (child, animation) {
return FadeTransition(
opacity: animation,
child: SizeTransition(
sizeFactor: animation,
axis: Axis.vertical,
child: child,
),
);
},
child: getNotEmpty(step.stepExplain) == null
? SizedBox.shrink(key: ValueKey("empty"))
: Text(
step.stepExplain!,
style: Theme.of(context).textTheme.labelSmall,
key: ValueKey(step.stepExplain),
),
),
],
),
),
SizeTransition(
axis: Axis.horizontal,
sizeFactor: animate,
child: Container(
margin: EdgeInsets.only(left: 10),
child: Opacity(
opacity: 0.4,
child: Icon(RemixIcons.menu_line),
),
AnimatedSwitcher(
duration: Duration(milliseconds: 300),
transitionBuilder: (child, animation) {
return FadeTransition(
opacity: animation,
child: SizeTransition(
sizeFactor: animation,
axis: Axis.vertical,
child: child,
),
);
},
child: getNotEmpty(step.stepExplain) == null
? SizedBox.shrink(key: ValueKey("empty"))
: Text(
step.stepExplain!,
style: stepTextStyle(
context,
Theme.of(context).textTheme.labelSmall,
),
key: ValueKey(step.stepExplain),
),
),
],
),
),
SizeTransition(
axis: Axis.horizontal,
sizeFactor: animate,
child: Container(
alignment: Alignment.center,
margin: EdgeInsets.only(left: 10),
child: Opacity(
opacity: 0.4,
child: Icon(RemixIcons.menu_line),
),
),
];
},
),
),
];
},
),
),
);
}
TextStyle? stepTextStyle(BuildContext context, TextStyle? baseStyle) {
if (step.stepStatus == 2) {
return baseStyle?.copyWith(
decoration: TextDecoration.lineThrough,
decorationThickness: 3,
decorationColor: Theme.of(context).colorScheme.onSurfaceVariant,
color: Theme.of(context).colorScheme.onSurfaceVariant,
);
}
return baseStyle;
}
}

View File

@@ -23,8 +23,9 @@ class ScrollBox extends StatelessWidget {
radius: const Radius.circular(5),
),
child: Scrollbar(
controller: scrollController,
child: child,
child: SingleChildScrollView(
child: child,
),
),
);
}

View File

@@ -9,51 +9,48 @@ class SuggestedTitle extends StatelessWidget {
@override
Widget build(BuildContext context) {
var list = context.select<PlanDetailStore, List<String>>(
(store) => store.planDetail.suggestionsList,
);
if (list.isEmpty) {
return const SliverToBoxAdapter(
child: SizedBox(),
);
}
return SliverToBoxAdapter(
child: TweenAnimationBuilder<double>(
tween: Tween(begin: 0, end: 1), // 从透明到完全显示
duration: const Duration(milliseconds: 300),
builder: (context, value, child) {
return Opacity(
opacity: value,
child: child,
);
},
child: Container(
margin: const EdgeInsets.only(top: 20, bottom: 5),
child: Opacity(
opacity: 0.6,
child: Row(
spacing: 20,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 15,
height: 1,
color: Theme.of(context).colorScheme.surfaceContainerHigh,
),
Text(
"额外建议",
style: Theme.of(context).textTheme.titleSmall,
),
Container(
width: 15,
height: 1,
color: Theme.of(context).colorScheme.surfaceContainerHigh,
),
],
return Consumer<PlanDetailStore>(
builder: (context, store, _) {
if (store.planDetail.suggestionsList.isEmpty) {
return SizedBox();
}
return TweenAnimationBuilder<double>(
tween: Tween(begin: 0, end: 1), // 从透明到完全显示
duration: const Duration(milliseconds: 300),
builder: (context, value, child) {
return Opacity(
opacity: value,
child: child,
);
},
child: Container(
margin: const EdgeInsets.only(top: 20, bottom: 5),
child: Opacity(
opacity: 0.6,
child: Row(
spacing: 20,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 15,
height: 1,
color: Theme.of(context).colorScheme.surfaceContainerHigh,
),
Text(
"额外建议",
style: Theme.of(context).textTheme.titleSmall,
),
Container(
width: 15,
height: 1,
color: Theme.of(context).colorScheme.surfaceContainerHigh,
),
],
),
),
),
),
),
);
},
);
}
}
@@ -67,13 +64,14 @@ class SuggestedList extends StatefulWidget {
}
class _SuggestedListState extends State<SuggestedList> {
final GlobalKey<SliverAnimatedListState> _listKey = GlobalKey<SliverAnimatedListState>();
final GlobalKey<AnimatedListState> _listKey = GlobalKey<AnimatedListState>();
List<String> _suggestionsList = [];
@override
void initState() {
super.initState();
_initListen();
WidgetsBinding.instance.addPostFrameCallback((_) => _initListen());
}
void _initListen() {
@@ -82,7 +80,6 @@ class _SuggestedListState extends State<SuggestedList> {
_suggestionsList = store.planDetail.suggestionsList;
store.addListener(() {
final newList = store.planDetail.suggestionsList;
// 找出新增的 item
if (newList.length > _suggestionsList.length) {
final addedItems = newList.sublist(_suggestionsList.length);
@@ -107,8 +104,10 @@ class _SuggestedListState extends State<SuggestedList> {
@override
Widget build(BuildContext context) {
return SliverAnimatedList(
return AnimatedList(
key: _listKey,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
initialItemCount: _suggestionsList.length,
itemBuilder: (_, index, animation) {
final curvedAnimation = CurvedAnimation(

View File

@@ -2,10 +2,8 @@ import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:plan/api/dto/plan_item_dto.dart';
import 'package:plan/api/endpoints/plan_api.dart';
import 'package:remixicon/remixicon.dart';
import 'widgets/history_item.dart';
import '../../../widgets/ui_kit/popup/popup_action.dart';
import 'widgets/loading_box.dart';
class PlanHistoryPage extends StatefulWidget {
@@ -38,23 +36,11 @@ class _PlanHistoryPageState extends State<PlanHistoryPage> {
});
}
///popup事件
void _onPopupActionSelected(String value) {
switch (value) {
case 'edit':
setState(() {
_isDelete = !_isDelete;
});
break;
}
}
///确认删除
void _confirmDelete(int id) {
print(_record.map((e) => e.toJson()));
setState(() {
_record.removeWhere((element) => element.id == id);
print(_record.map((e) => e.toJson()));
});
}

View File

@@ -89,6 +89,7 @@ class _DeleteRowItemState extends State<DeleteRowItem> with TickerProviderStateM
@override
Widget build(BuildContext context) {
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
SizeTransition(
axis: Axis.horizontal,
@@ -98,6 +99,7 @@ class _DeleteRowItemState extends State<DeleteRowItem> with TickerProviderStateM
onTap: _handleDelete,
child: Container(
margin: EdgeInsets.only(right: 10),
alignment: Alignment.centerRight,
child: Icon(
RemixIcons.indeterminate_circle_fill,
color: Colors.red,

View File

@@ -1,6 +1,14 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
animated_reorderable_list:
dependency: "direct main"
description:
name: animated_reorderable_list
sha256: "5de5cca556a8c9c8f7b65234ae4b683593dc6e167db498744a5e389302f24d13"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.3.0"
args:
dependency: transitive
description:

View File

@@ -30,6 +30,7 @@ dependencies:
flutter_image_compress: ^2.4.0
cached_network_image: ^3.4.1
intl: ^0.19.0
animated_reorderable_list: ^1.3.0
dev_dependencies:
flutter_test: