diff --git a/assets/image/empty_data.png b/assets/image/empty_data.png index 3d3f30a..aaacf7d 100644 Binary files a/assets/image/empty_data.png and b/assets/image/empty_data.png differ diff --git a/lib/api/dto/plan_detail_dto.dart b/lib/api/dto/plan_detail_dto.dart index cf6eb0d..76defbd 100644 --- a/lib/api/dto/plan_detail_dto.dart +++ b/lib/api/dto/plan_detail_dto.dart @@ -9,7 +9,7 @@ class PlanStepDto { Map toJson() { final map = {}; - map["id"] = id; + map["id"] = id ?? 0; map["step_icon"] = stepIcon; map["step_content"] = stepContent; map["step_explain"] = stepExplain; diff --git a/lib/api/dto/plan_item_dto.dart b/lib/api/dto/plan_item_dto.dart new file mode 100644 index 0000000..ad6a5ef --- /dev/null +++ b/lib/api/dto/plan_item_dto.dart @@ -0,0 +1,41 @@ +class PlanItemDto { + int? id; + String? agentName; + String? summary; + int? completedSteps; + int? totalSteps; + int? planStatus; + String? createdAt; + + PlanItemDto({ + this.id, + this.agentName, + this.summary, + this.completedSteps, + this.totalSteps, + this.planStatus, + this.createdAt, + }); + + Map toJson() { + final map = {}; + map["plan_id"] = id; + map["agent_name"] = agentName; + map["summary"] = summary; + map["completed_steps"] = completedSteps; + map["total_steps"] = totalSteps; + map["plan_status"] = planStatus; + map["created_at"] = createdAt; + return map; + } + + PlanItemDto.fromJson(dynamic json) { + id = json["plan_id"] ?? 0; + agentName = json["agent_name"] ?? ""; + summary = json["summary"] ?? ""; + completedSteps = json["completed_steps"] ?? 0; + totalSteps = json["total_steps"] ?? 0; + planStatus = json["plan_status"] ?? 0; + createdAt = json["created_at"] ?? ""; + } +} diff --git a/lib/api/endpoints/plan_api.dart b/lib/api/endpoints/plan_api.dart index 1ee6c2a..2cdb583 100644 --- a/lib/api/endpoints/plan_api.dart +++ b/lib/api/endpoints/plan_api.dart @@ -1,3 +1,5 @@ +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'; ///初始化计划 @@ -8,3 +10,49 @@ Future initPlanApi(String need, int agentId) async { }); return res['plan_id']; } + +///保存用户计划 +Future savePlanApi({ + required String planId, + required String summary, + required String dialog, + required List steps, + required List suggestions, +}) async { + await Request().post("/plan/save_plan", { + "plan_id": planId, + "summary": summary, + "dialog": dialog, + "steps": steps.map((e) => e.toJson()).toList(), + "suggestions": suggestions, + }); +} + +///获取计划列表 +Future> getPlanListApi() async { + var res = await Request().get("/plan/plan_list"); + return res['list'].map((e) => PlanItemDto.fromJson(e)).toList(); +} + +///编辑计划摘要 +Future editPlanSummaryApi(int planId, String summary) async { + await Request().post("/plan/edit_plan_summary", { + "plan_id": planId, + "summary": summary, + }); +} + +///获取计划详情 +Future getPlanDetailApi(String planId) async { + var res = await Request().get("/plan/plan_detail", { + "plan_id": planId, + }); + return PlanDetailDto.fromJson(res); +} + +///删除计划 +Future deletePlanApi(int planId) async { + await Request().get("/plan/delete_plan", { + "plan_id": planId, + }); +} diff --git a/lib/page/home/widget/plan_form_card.dart b/lib/page/home/widget/plan_form_card.dart index f98af57..5fa893c 100644 --- a/lib/page/home/widget/plan_form_card.dart +++ b/lib/page/home/widget/plan_form_card.dart @@ -12,14 +12,14 @@ class PlanFormCard extends StatefulWidget { } class _PlanFormCardState extends State { - final TextEditingController _inputController = TextEditingController(); + final TextEditingController _inputController = TextEditingController(text: ""); void _handSubmit() { if (_inputController.text.isEmpty) { return; } context.push( - RoutePaths.planDetail(), + RoutePaths.planDetail(0), extra: { "name": _inputController.text, }, diff --git a/lib/page/plan/detail/plan_detail_page.dart b/lib/page/plan/detail/plan_detail_page.dart index 6946734..0e89108 100644 --- a/lib/page/plan/detail/plan_detail_page.dart +++ b/lib/page/plan/detail/plan_detail_page.dart @@ -9,7 +9,9 @@ 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'; import 'widgets/scroll_box.dart'; +import 'widgets/suggested.dart'; class PlanDetailPage extends StatefulWidget { final String? id; @@ -27,6 +29,20 @@ class PlanDetailPage extends StatefulWidget { class _PlanDetailPageState extends State { bool _isEdit = false; + final ScrollController scrollController = ScrollController(); + + ///store对象 + late PlanDetailStore store; + + @override + void initState() { + super.initState(); + store = PlanDetailStore( + planId: widget.id.toString(), + planContent: widget.planName ?? "", + scrollController: scrollController, + ); + } ///popup菜单 void _onPopupActionSelected(String value) { @@ -54,51 +70,9 @@ class _PlanDetailPageState extends State { Widget build(BuildContext context) { return ChangeNotifierProvider( create: (_) { - return PlanDetailStore( - planId: widget.id.toString(), - planContent: widget.planName ?? "", - showRoleTalk: widget.planName == null, - ); + return store; }, - child: CupertinoPageScaffold( - backgroundColor: Colors.white, - navigationBar: CupertinoNavigationBar( - middle: Text('计划详情'), - 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), - ), - ), - ], - ), - ), + child: Consumer( child: SafeArea( child: Column( children: [ @@ -110,33 +84,12 @@ class _PlanDetailPageState extends State { child: Container( decoration: shadowDecoration, child: CustomScrollView( + controller: scrollController, slivers: [ CoachMessage(), - // SliverToBoxAdapter( - // child: SizedBox(height: 20), - // ), - // SliverList.builder( - // itemBuilder: (BuildContext context, int index) { - // return PlanItem( - // showEdit: _isEdit, - // title: "测试 ${index + 1}", - // desc: "测测 ${index + 1}", - // onDelete: (id) {}, - // ); - // }, - // itemCount: 10, - // ), - // SliverToBoxAdapter( - // child: SuggestedTitle(), - // ), - // SliverList.builder( - // itemBuilder: (BuildContext context, int index) { - // return SuggestedItem( - // title: "测试", - // ); - // }, - // itemCount: 5, - // ), + PlanList(), + SuggestedTitle(), + SuggestedList(), ], ), ), @@ -146,6 +99,49 @@ class _PlanDetailPageState extends State { ], ), ), + builder: (context, store, child) { + return CupertinoPageScaffold( + 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), + ), + ), + ], + ), + ), + child: child!, + ); + }, ), ); } diff --git a/lib/page/plan/detail/viewmodel/plan_detail_store.dart b/lib/page/plan/detail/viewmodel/plan_detail_store.dart index b82907e..5fc31e5 100644 --- a/lib/page/plan/detail/viewmodel/plan_detail_store.dart +++ b/lib/page/plan/detail/viewmodel/plan_detail_store.dart @@ -1,17 +1,30 @@ import 'package:flutter/cupertino.dart'; +import 'package:flutter_easyloading/flutter_easyloading.dart'; import 'package:plan/api/dto/plan_detail_dto.dart'; import 'package:plan/api/endpoints/plan_api.dart'; +import 'package:plan/utils/stream.dart'; class PlanDetailStore extends ChangeNotifier { ///构造函数 PlanDetailStore({ this.planContent = "", this.planId = "", - bool showRoleTalk = true, - }) : _showRoleTalk = showRoleTalk; + required this.scrollController, + }) { + //如果没有id进行初始化 + if (planId == "0") { + createPlan(); + }else{ + //获取详情 + getPlanDetail(); + } + } + + ///滚动控制器 + ScrollController scrollController; ///角色话语是否显示 - bool _showRoleTalk = true; + bool _showRoleTalk = false; bool get showRoleTalk => _showRoleTalk; @@ -27,11 +40,123 @@ class PlanDetailStore extends ChangeNotifier { String planId = ""; ///计划详情 - PlanDetailDto planDetail = PlanDetailDto(); + PlanDetailDto planDetail = PlanDetailDto(summary: "计划详情"); + + ///流请求工具 + StreamUtils streamUtils = StreamUtils(); ///创建计划 void createPlan() async { var id = await initPlanApi(planContent, 1); planId = id.toString(); + // planId = "3"; + + ///生成摘要--------------------------- + String summary = ""; + streamUtils.sendStream( + "/plan/make_summary", + data: {"plan_id": planId}, + onCall: (chunk) { + summary = chunk['text']; + }, + onEnd: () { + planDetail.summary = summary; + notifyListeners(); + }, + ); + + /// 生成对白------------------------------- + String dialog = ""; + await streamUtils.sendStream( + "/plan/make_dialog", + data: {"plan_id": planId}, + onCall: (chunk) { + dialog = chunk['text']; + }, + onEnd: () { + planDetail.dialog = dialog; + notifyListeners(); + }, + ); + + /// 生成步骤------------------------------- + await streamUtils.sendStream( + "/plan/make_step", + data: {"plan_id": planId}, + onCall: (chunk) { + final text = chunk['text'].toString(); + + // 拆分出完整句子(以 [NEXT] 结尾) + List sentences = text + .split("\n") + .where((e) => e.contains("[NEXT]")) + .map((e) => e.replaceAll("[NEXT]", "").trim()) + .toList(); + + // 直接覆盖 stepsList + planDetail.stepsList = sentences.map((s) => PlanStepDto(stepContent: s)).toList(); + notifyListeners(); + }, + ); + + /// 生成步骤解释 ------------------------------- + await streamUtils.sendStream( + "/plan/make_step_explain", + data: { + "plan_id": planId, + "steps": planDetail.stepsList.map((e) => e.toJson()).toList(), + }, + onCall: (chunk) { + final text = chunk['text'].toString(); + List sentences = text + .split("\n") + .where((e) => e.contains("[NEXT]")) + .map((e) => e.replaceAll("[NEXT]", "").trim()) + .toList(); + for (int i = 0; i < sentences.length; i++) { + planDetail.stepsList[i] = planDetail.stepsList[i]..stepExplain = sentences[i]; + } + notifyListeners(); + }, + onEnd: () {}, + ); + + /// 生成建议 ------------------ + await streamUtils.sendStream( + "/plan/make_suggestion", + data: { + "plan_id": planId, + "steps": planDetail.stepsList.map((e) => e.toJson()).toList(), + }, + onCall: (chunk) { + final text = chunk['text'].toString(); + List sentences = text + .split("\n") + .where((e) => e.contains("[NEXT]")) + .map((e) => e.replaceAll("[NEXT]", "").trim()) + .toList(); + planDetail.suggestionsList = sentences; + notifyListeners(); + }, + ); + await savePlan(); + } + + ///保存计划 + Future savePlan() async { + await savePlanApi( + planId: planId, + summary: planDetail.summary ?? "", + dialog: planDetail.dialog ?? "", + steps: planDetail.stepsList, + suggestions: planDetail.suggestionsList, + ); + EasyLoading.showToast("计划创建成功"); + } + + ///获取详情 + Future getPlanDetail() async { + planDetail = await getPlanDetailApi(planId); + notifyListeners(); } } diff --git a/lib/page/plan/detail/widgets/avatar_card.dart b/lib/page/plan/detail/widgets/avatar_card.dart index 561920e..f67d821 100644 --- a/lib/page/plan/detail/widgets/avatar_card.dart +++ b/lib/page/plan/detail/widgets/avatar_card.dart @@ -15,9 +15,13 @@ class _AvatarCardState extends State with SingleTickerProviderStateM late AnimationController _controller; late Animation _animation; + ///对话框值 + String _dialog = ""; + @override void initState() { super.initState(); + //初始化动画 _controller = AnimationController( vsync: this, duration: Duration(milliseconds: 300), @@ -28,6 +32,7 @@ class _AvatarCardState extends State with SingleTickerProviderStateM curve: Curves.easeInOut, ), ); + _initDialog(); } @override @@ -36,8 +41,31 @@ class _AvatarCardState extends State with SingleTickerProviderStateM _controller.dispose(); } + ///初始化是否显示对话 + void _initDialog() { + WidgetsBinding.instance.addPostFrameCallback((_) { + final store = context.read(); + + void listener() { + if (store.planDetail.dialog != null && !store.showRoleTalk) { + setState(() { + _dialog = store.planDetail.dialog!; + }); + _toggleShow(); + store.removeListener(listener); + } + } + + store.addListener(listener); + }); + } + + ///切换显示show void _toggleShow() { var store = context.read(); + if (store.planDetail.dialog == null) { + return; + } setState(() { store.showRoleTalk = !store.showRoleTalk; if (store.showRoleTalk) { @@ -71,10 +99,7 @@ class _AvatarCardState extends State with SingleTickerProviderStateM borderRadius: BorderRadius.circular(3), border: Border.all(color: Colors.black, width: 1), ), - child: Text( - "好的,让我们把学习软件开发这个目标分解成最简单的小步骤,这样你明天就能轻松开始行动", - style: TextStyle(fontSize: 12), - ), + child: Text(_dialog, style: TextStyle(fontSize: 12)), ), Positioned( bottom: 0, diff --git a/lib/page/plan/detail/widgets/plan_item.dart b/lib/page/plan/detail/widgets/plan_item.dart deleted file mode 100644 index d7799a7..0000000 --- a/lib/page/plan/detail/widgets/plan_item.dart +++ /dev/null @@ -1,71 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:plan/widgets/business/delete_row_item.dart'; -import 'package:remixicon/remixicon.dart'; - -class PlanItem extends StatefulWidget { - final String title; - final String desc; - final bool showEdit; - final Function(int) onDelete; - - const PlanItem({ - super.key, - required this.title, - required this.desc, - this.showEdit = false, - required this.onDelete, - }); - - @override - State createState() => _PlanItemState(); -} - -class _PlanItemState extends State with AutomaticKeepAliveClientMixin { - @override - Widget build(BuildContext context) { - super.build(context); - return Container( - padding: EdgeInsets.symmetric(horizontal: 20, vertical: 10), - child: DeleteRowItem( - showDelete: widget.showEdit, - onDelete: () {}, - builder: (_, animate) { - return [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - margin: EdgeInsets.only(bottom: 3), - child: Text( - "完成以上步骤后,给自己倒杯水休息一下。", - style: Theme.of(context).textTheme.bodyMedium, - ), - ), - Text( - "就从这里开始,写下基本信息只需要2分钟,这是最简单的一部", - style: Theme.of(context).textTheme.labelSmall, - ), - ], - ), - ), - SizeTransition( - axis: Axis.horizontal, - sizeFactor: animate, - child: Container( - margin: EdgeInsets.only(left: 10), - child: Opacity( - opacity: 0.4, - child: Icon(RemixIcons.menu_line), - ), - ), - ), - ]; - }, - ), - ); - } - - @override - bool get wantKeepAlive => true; -} diff --git a/lib/page/plan/detail/widgets/plan_list.dart b/lib/page/plan/detail/widgets/plan_list.dart new file mode 100644 index 0000000..b3e8969 --- /dev/null +++ b/lib/page/plan/detail/widgets/plan_list.dart @@ -0,0 +1,156 @@ +import 'package:flutter/material.dart'; +import 'package:plan/api/dto/plan_detail_dto.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'; +import 'package:provider/provider.dart'; +import 'package:remixicon/remixicon.dart'; + +class PlanList extends StatefulWidget { + const PlanList({super.key}); + + @override + State createState() => _PlanListState(); +} + +class _PlanListState extends State { + final GlobalKey _listKey = GlobalKey(); + List _stepsList = []; + + @override + void initState() { + super.initState(); + _initListen(); + } + + void _initListen() { + final store = context.read(); + //先初始化,只在详情时才会有意义 + _stepsList = store.planDetail.stepsList; + store.addListener(() { + final newList = store.planDetail.stepsList; + + // 找出新增的 item + if (newList.length > _stepsList.length) { + final addedItems = newList.sublist(_stepsList.length); + + for (var item in addedItems) { + final index = _stepsList.length; + _stepsList.add(item); + + // 插入动画 + _listKey.currentState?.insertItem( + index, + duration: Duration(milliseconds: 300), + ); + } + } else { + setState(() { + _stepsList = store.planDetail.stepsList; + }); + } + }); + } + + @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) {}, + ); + }, + ); + } +} + +class PlanItem extends StatelessWidget { + final PlanStepDto step; + final bool showEdit; + final CurvedAnimation curvedAnimation; + final Function(int) onDelete; + + const PlanItem({ + super.key, + required this.step, + required this.curvedAnimation, + this.showEdit = false, + required this.onDelete, + }); + + @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, + ), + ), + 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), + ), + ), + ), + ]; + }, + ), + ), + ), + ); + } +} diff --git a/lib/page/plan/detail/widgets/scroll_box.dart b/lib/page/plan/detail/widgets/scroll_box.dart index 1ee966f..364c40c 100644 --- a/lib/page/plan/detail/widgets/scroll_box.dart +++ b/lib/page/plan/detail/widgets/scroll_box.dart @@ -1,21 +1,29 @@ import 'package:flutter/material.dart'; class ScrollBox extends StatelessWidget { + final ScrollController? scrollController; final Widget child; - const ScrollBox({super.key, required this.child}); + const ScrollBox({ + super.key, + required this.child, + this.scrollController, + }); @override Widget build(BuildContext context) { return ScrollbarTheme( data: ScrollbarThemeData( - thumbColor: WidgetStateProperty.all(Theme.of(context).colorScheme.surfaceContainerHigh), + thumbColor: WidgetStateProperty.all( + Theme.of(context).colorScheme.surfaceContainerHigh, + ), thickness: WidgetStateProperty.all(3), crossAxisMargin: 3, mainAxisMargin: 2, radius: const Radius.circular(5), ), child: Scrollbar( + controller: scrollController, child: child, ), ); diff --git a/lib/page/plan/detail/widgets/suggested.dart b/lib/page/plan/detail/widgets/suggested.dart index c7b30a6..6e8a7a8 100644 --- a/lib/page/plan/detail/widgets/suggested.dart +++ b/lib/page/plan/detail/widgets/suggested.dart @@ -1,4 +1,6 @@ import 'package:flutter/material.dart'; +import 'package:plan/page/plan/detail/viewmodel/plan_detail_store.dart'; +import 'package:provider/provider.dart'; import 'package:remixicon/remixicon.dart'; ///模块标题 @@ -7,66 +9,165 @@ class SuggestedTitle extends StatelessWidget { @override Widget build(BuildContext context) { - return 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, + var list = context.select>( + (store) => store.planDetail.suggestionsList, + ); + if (list.isEmpty) { + return const SliverToBoxAdapter( + child: SizedBox(), + ); + } + return SliverToBoxAdapter( + child: TweenAnimationBuilder( + 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, + ), + ], ), - Text( - "额外建议", - style: Theme.of(context).textTheme.titleSmall, - ), - Container( - width: 15, - height: 1, - color: Theme.of(context).colorScheme.surfaceContainerHigh, - ), - ], + ), ), ), ); } } -class SuggestedItem extends StatelessWidget { - final String title; +///额外建议列表 +class SuggestedList extends StatefulWidget { + const SuggestedList({super.key}); - const SuggestedItem({super.key, required this.title}); + @override + State createState() => _SuggestedListState(); +} + +class _SuggestedListState extends State { + final GlobalKey _listKey = GlobalKey(); + List _suggestionsList = []; + + @override + void initState() { + super.initState(); + _initListen(); + } + + void _initListen() { + final store = context.read(); + //先初始化,只在详情时才会有意义 + _suggestionsList = store.planDetail.suggestionsList; + store.addListener(() { + final newList = store.planDetail.suggestionsList; + + // 找出新增的 item + if (newList.length > _suggestionsList.length) { + final addedItems = newList.sublist(_suggestionsList.length); + + for (var item in addedItems) { + final index = _suggestionsList.length; + _suggestionsList.add(item); + + // 插入动画 + _listKey.currentState?.insertItem( + index, + duration: Duration(milliseconds: 300), + ); + } + } else { + setState(() { + _suggestionsList = store.planDetail.suggestionsList; + }); + } + }); + } @override Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - spacing: 10, - children: [ - Transform.translate( - offset: const Offset(0, 5), - child: Icon( - RemixIcons.lightbulb_flash_fill, - color: Color(0xfff2a529), - size: 18, - ), - ), - Expanded( - child: Opacity( - opacity: 0.5, - child: Text( - title, - style: Theme.of(context).textTheme.bodyMedium, + return SliverAnimatedList( + key: _listKey, + initialItemCount: _suggestionsList.length, + itemBuilder: (_, index, animation) { + final curvedAnimation = CurvedAnimation( + parent: animation, + curve: Curves.easeInOut, + ); + return SuggestedItem( + title: _suggestionsList[index], + curvedAnimation: curvedAnimation, + ); + }, + ); + } +} + +///建议item +class SuggestedItem extends StatelessWidget { + final String title; + final CurvedAnimation curvedAnimation; + + const SuggestedItem({ + super.key, + required this.title, + required this.curvedAnimation, + }); + + @override + Widget build(BuildContext context) { + return FadeTransition( + opacity: curvedAnimation, + child: SizeTransition( + sizeFactor: curvedAnimation, + axis: Axis.vertical, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 10, + children: [ + Transform.translate( + offset: const Offset(0, 5), + child: Icon( + RemixIcons.lightbulb_flash_fill, + color: Color(0xfff2a529), + size: 18, + ), ), - ), + Expanded( + child: Opacity( + opacity: 0.5, + child: Text( + title, + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + ), + ], ), - ], + ), ), ); } diff --git a/lib/page/plan/history/plan_history_page.dart b/lib/page/plan/history/plan_history_page.dart index ecc103b..201b6c5 100644 --- a/lib/page/plan/history/plan_history_page.dart +++ b/lib/page/plan/history/plan_history_page.dart @@ -1,9 +1,12 @@ 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 { const PlanHistoryPage({super.key}); @@ -16,12 +19,23 @@ class _PlanHistoryPageState extends State { ///是否显示删除 bool _isDelete = false; + ///数据 + List _record = []; + bool _isInit = true; + + @override + void initState() { + super.initState(); + _onRefresh(); + } + ///刷新 Future _onRefresh() async { - //模拟网络请求 - await Future.delayed(Duration(milliseconds: 1000)); - //结束刷新 - return Future.value(true); + var list = await getPlanListApi(); + setState(() { + _record = list; + _isInit = false; + }); } ///popup事件 @@ -36,7 +50,14 @@ class _PlanHistoryPageState extends State { } ///确认删除 - void _confirmDelete(int id) {} + void _confirmDelete(int id) { + print(_record.map((e) => e.toJson())); + setState(() { + _record.removeWhere((element) => element.id == id); + print(_record.map((e) => e.toJson())); + }); + + } @override Widget build(BuildContext context) { @@ -44,19 +65,6 @@ class _PlanHistoryPageState extends State { backgroundColor: Theme.of(context).colorScheme.surfaceContainer, navigationBar: CupertinoNavigationBar( middle: const Text("计划历史"), - trailing: PopupAction( - onSelected: _onPopupActionSelected, - items: [ - PopupMenuItem( - value: 'edit', - child: Text( - _isDelete ? "完成" : "编辑", - style: TextStyle(color: Colors.black), - ), - ), - ], - child: Icon(RemixIcons.more_fill), - ), ), child: SafeArea( child: CustomScrollView( @@ -69,36 +77,28 @@ class _PlanHistoryPageState extends State { onRefresh: _onRefresh, ), //列表 - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.all(15), - child: Container( - padding: EdgeInsets.symmetric(horizontal: 15), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(10), - ), - child: CustomScrollView( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - slivers: [ - SliverList.separated( - itemBuilder: (BuildContext context, int index) { - return HistoryItem( - showDelete: _isDelete, - onDelete: _confirmDelete, - ); - }, - separatorBuilder: (BuildContext context, int index) { - return Divider( - height: 1, - color: Theme.of(context).colorScheme.surfaceContainer, - ); - }, - itemCount: 5, - ), - ], - ), + LoadingBox( + loading: _isInit, + isEmpty: _record.isEmpty, + child: ClipRRect( + child: CustomScrollView( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + slivers: [ + SliverList.separated( + itemBuilder: (BuildContext context, int index) { + return HistoryItem( + item: _record[index], + showDelete: _isDelete, + onDelete: _confirmDelete, + ); + }, + separatorBuilder: (BuildContext context, int index) { + return SizedBox(height: 15); + }, + itemCount: _record.length, + ), + ], ), ), ), diff --git a/lib/page/plan/history/widgets/history_item.dart b/lib/page/plan/history/widgets/history_item.dart index b288388..9253ad3 100644 --- a/lib/page/plan/history/widgets/history_item.dart +++ b/lib/page/plan/history/widgets/history_item.dart @@ -1,15 +1,23 @@ +import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; +import 'package:plan/api/dto/plan_item_dto.dart'; +import 'package:plan/api/endpoints/plan_api.dart'; import 'package:plan/router/config/route_paths.dart'; +import 'package:plan/utils/format.dart'; import 'package:plan/widgets/business/delete_row_item.dart'; import 'package:remixicon/remixicon.dart'; +import '../../widgets/edit_desc_dialog.dart'; + class HistoryItem extends StatefulWidget { + final PlanItemDto item; final bool showDelete; final Function(int) onDelete; const HistoryItem({ super.key, + required this.item, this.showDelete = false, required this.onDelete, }); @@ -19,73 +27,139 @@ class HistoryItem extends StatefulWidget { } class _HistoryItemState extends State { + late PlanItemDto _data; + @override void initState() { super.initState(); + _data = widget.item; + } + + @override + void didUpdateWidget(covariant HistoryItem oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.item != widget.item) { + setState(() { + _data = widget.item; + }); + } } ///跳转详情 void _goDetail() { - context.push(RoutePaths.planDetail(1)); + context.push(RoutePaths.planDetail(_data.id)); + } + + ///编辑摘要 + void _handEdit() { + context.pop(); + showEditDescDialog( + context, + value: _data.summary ?? "", + onConfirm: (value) async { + setState(() { + _data.summary = value; + }); + await editPlanSummaryApi(_data.id!, value); + }, + ); + } + + ///删除计划 + void _handDelete() async { + context.pop(); + widget.onDelete(_data.id!); + await deletePlanApi(_data.id!); } @override Widget build(BuildContext context) { - return GestureDetector( - onTap: _goDetail, - child: Container( - width: double.infinity, - padding: EdgeInsets.symmetric(vertical: 15), - color: Colors.white, - child: DeleteRowItem( - showDelete: widget.showDelete, - onDelete: () { - widget.onDelete(0); - }, - builder: (_, __) { - return [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - margin: EdgeInsets.only(bottom: 5), - child: Text("开始学习软件开发"), - ), - Container( - margin: EdgeInsets.only(bottom: 5), - child: Text( - "创建于 2025/9/3 9:40:51 教练:W教练", - style: Theme.of(context).textTheme.labelSmall, + return CupertinoContextMenu( + actions: [ + // 编辑摘要 + CupertinoContextMenuAction( + onPressed: _handEdit, + child: _actionItem( + "Edit Summary", + CupertinoIcons.pencil, + ), + ), + CupertinoContextMenuAction( + isDestructiveAction: true, + onPressed: _handDelete, + child: _actionItem( + "Delete Summary", + CupertinoIcons.delete, + ), + ), + ], + child: GestureDetector( + onTap: _goDetail, + child: Container( + width: 400, + margin: EdgeInsets.symmetric(horizontal: 15), + padding: EdgeInsets.symmetric(horizontal: 15), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(10), + ), + child: Container( + padding: EdgeInsets.symmetric(vertical: 15), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + margin: EdgeInsets.only(bottom: 5), + child: Text(_data.summary ?? ""), ), - ), - Row( - spacing: 10, - children: [ - Expanded( - child: LinearProgressIndicator( - value: 0.5, - borderRadius: BorderRadius.circular(5), + Container( + margin: const EdgeInsets.only(bottom: 5), + child: Text( + "${formatDateUS(_data.createdAt!, withTime: true)} · Coach ${_data.agentName ?? ""}", + style: Theme.of(context).textTheme.labelSmall, // 小号文字 + ), + ), + Row( + spacing: 10, + children: [ + Expanded( + child: LinearProgressIndicator( + value: (_data.completedSteps! / _data.totalSteps!), + borderRadius: BorderRadius.circular(5), + ), ), - ), - Text( - "0/7", - style: Theme.of(context).textTheme.labelSmall, - ), - ], - ), - ], + Text( + "${_data.completedSteps}/${_data.totalSteps}", + style: Theme.of(context).textTheme.labelSmall, + ), + ], + ), + ], + ), ), - ), - Icon( - RemixIcons.arrow_right_s_line, - size: 30, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ]; - }, + Icon( + RemixIcons.arrow_right_s_line, + size: 30, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ], + ), + ), ), ), ); } + + Widget _actionItem(String title, IconData icon) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(title), + Icon(icon, size: 20), // 右边图标 + ], + ); + } } diff --git a/lib/page/plan/history/widgets/loading_box.dart b/lib/page/plan/history/widgets/loading_box.dart new file mode 100644 index 0000000..a0c6e46 --- /dev/null +++ b/lib/page/plan/history/widgets/loading_box.dart @@ -0,0 +1,48 @@ +import 'package:flutter/cupertino.dart'; +import 'package:plan/widgets/ui_kit/empty/index.dart'; + +class LoadingBox extends StatelessWidget { + final bool loading; + final bool isEmpty; + final Widget child; + + const LoadingBox({ + super.key, + required this.loading, + required this.isEmpty, + required this.child, + }); + + @override + Widget build(BuildContext context) { + ///加载中 + if (loading) { + return SliverFillRemaining( + child: Center( + child: CupertinoActivityIndicator( + radius: 14, + ), + ), + ); + } + + ///空状态 + if (isEmpty) { + return SliverFillRemaining( + child: Empty( + title: "No data", + ), + ); + } + + ///正常 + return SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 15), + child: Container( + child: child, + ), + ), + ); + } +} diff --git a/lib/page/plan/widgets/edit_desc_dialog.dart b/lib/page/plan/widgets/edit_desc_dialog.dart index d9be202..8fce841 100644 --- a/lib/page/plan/widgets/edit_desc_dialog.dart +++ b/lib/page/plan/widgets/edit_desc_dialog.dart @@ -20,10 +20,14 @@ void showEditDescDialog( title: Text("编辑摘要"), content: Padding( padding: EdgeInsets.only(top: 15), - child: CupertinoTextField( - controller: controller, - focusNode: focusNode, - placeholder: "edit...", + child: Container( + constraints: BoxConstraints(minHeight: 40, maxHeight: 80), + child: CupertinoTextField( + controller: controller, + maxLines: null, + expands: true, + keyboardType: TextInputType.multiline, + ), ), ), actions: [ @@ -36,8 +40,10 @@ void showEditDescDialog( CupertinoDialogAction( isDefaultAction: true, onPressed: () { - context.pop(); - onConfirm(controller.text); + if (controller.text.trim().isNotEmpty) { + context.pop(); + onConfirm(controller.text); + } }, child: Text('确认'), ), diff --git a/lib/page/test/test_page.dart b/lib/page/test/test_page.dart new file mode 100644 index 0000000..7d8c974 --- /dev/null +++ b/lib/page/test/test_page.dart @@ -0,0 +1,132 @@ +import 'package:flutter/material.dart'; + +class TestPage extends StatefulWidget { + const TestPage({super.key}); + + @override + State createState() => _TestPageState(); +} + +class _TestPageState extends State { + final GlobalKey _listKey = GlobalKey(); + List _list = [ + Item("测试1", ""), + Item("测试2", ""), + Item("测试4", ""), + Item("测试5", ""), + ]; + + void _add() { + _list.add(Item("测试${_list.length + 1}", "")); + _listKey.currentState?.insertItem( + _list.length - 1, + duration: Duration(milliseconds: 200), + ); + } + + void _remove() { + final index = _list.length - 1; // 删除的索引 + final removedItem = _list.removeAt(index); // 移除元素 + + _listKey.currentState?.removeItem( + index, + (context, animation) { + final curvedAnimation = CurvedAnimation( + parent: animation, + curve: Curves.easeInOut, // 这里设置曲线 + ); + return buildItem(removedItem, curvedAnimation); + }, + duration: Duration(milliseconds: 200), + ); + } + void _update() { + if (_list.isNotEmpty) { + setState(() { + _list[0] = Item("测试1", "这这是新描述这是新描述这是新描述这是新描述这是新描述这是新描述这是新描述这是新描述这是新描述是新描述"); // 改变 desc + }); + } + } + + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text("长度${_list.length}"), + actions: [ + ElevatedButton( + onPressed: _update, + child: Text("更新子元素"), + ), + ElevatedButton( + onPressed: _add, + child: Text("增加"), + ), + ElevatedButton( + onPressed: _remove, + child: Text("减少"), + ), + ], + ), + body: AnimatedList( + key: _listKey, + itemBuilder: (_, index, animation) { + final item = _list[index]; + // 新增元素的动画 + final curvedAnimation = CurvedAnimation( + parent: animation, + curve: Curves.easeInOut, + ); + return buildItem(item, curvedAnimation); + }, + initialItemCount: _list.length, + ), + ); + } + + Widget buildItem(Item item, CurvedAnimation curvedAnimation) { + return FadeTransition( + opacity: curvedAnimation, + child: SizeTransition( + sizeFactor: curvedAnimation, + axis: Axis.vertical, + child: Container( + padding: EdgeInsets.all(15), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text("序号${item.title}"), + AnimatedSwitcher( + duration: Duration(milliseconds: 300), + transitionBuilder: (child, animation) { + return FadeTransition( + opacity: animation, + child: SizeTransition( + sizeFactor: animation, + axis: Axis.vertical, + child: child, + ), + ); + }, + child: item.desc.isEmpty + ? SizedBox.shrink(key: ValueKey("empty")) + : Text( + "描述${item.desc}", + key: ValueKey(item.desc), // ValueKey 用于 AnimatedSwitcher 判断变化 + ), + ) + ], + ), + ), + ), + ); + } +} + +class Item { + final String title; + final String desc; + + Item(this.title, this.desc); +} diff --git a/lib/router/config/route_paths.dart b/lib/router/config/route_paths.dart index 3d5ac1b..fefeb53 100644 --- a/lib/router/config/route_paths.dart +++ b/lib/router/config/route_paths.dart @@ -21,4 +21,6 @@ class RoutePaths { ///计划详情页 static String planDetail([int? id]) => id != null ? "/planDetail/$id" : "/planDetail/:id"; + + static const test = "/test"; } diff --git a/lib/router/modules/base.dart b/lib/router/modules/base.dart index 97d2f5c..cdae644 100644 --- a/lib/router/modules/base.dart +++ b/lib/router/modules/base.dart @@ -1,4 +1,5 @@ import 'package:plan/page/home/home_page.dart'; +import 'package:plan/page/test/test_page.dart'; import '../../page/system/agree/agree_page.dart'; import '../../page/system/login/login_code_page.dart'; @@ -40,4 +41,10 @@ List baseRoutes = [ return HomePage(); }, ), + RouteType( + path: RoutePaths.test, + child: (state) { + return TestPage(); + }, + ), ]; diff --git a/lib/utils/format.dart b/lib/utils/format.dart index 9f1080e..1655b98 100644 --- a/lib/utils/format.dart +++ b/lib/utils/format.dart @@ -1,35 +1,53 @@ -/// 格式化日期时间 -String formatDateUS(dynamic date, [String format = 'MM/DD/YYYY hh:mm:ss a']) { - DateTime dateTime; - if (date is String) { - dateTime = DateTime.tryParse(date) ?? DateTime.now(); - } else if (date is DateTime) { - dateTime = date; + +// /// 格式化日期时间 +// String formatDateUS(dynamic date, [String format = 'MM/DD/YYYY hh:mm:ss a']) { +// DateTime dateTime; +// +// if (date is String) { +// dateTime = DateTime.tryParse(date) ?? DateTime.now(); +// } else if (date is DateTime) { +// dateTime = date; +// } else { +// dateTime = DateTime.now(); +// } +// +// final yyyy = dateTime.year.toString(); +// final MM = dateTime.month.toString().padLeft(2, '0'); +// final dd = dateTime.day.toString().padLeft(2, '0'); +// +// // 12小时制 +// final hour12 = (dateTime.hour % 12 == 0 ? 12 : dateTime.hour % 12).toString().padLeft(2, '0'); +// final HH = dateTime.hour.toString().padLeft(2, '0'); // 24小时制备用 +// final mm = dateTime.minute.toString().padLeft(2, '0'); +// final ss = dateTime.second.toString().padLeft(2, '0'); +// final ampm = dateTime.hour >= 12 ? 'PM' : 'AM'; +// +// String result = format +// .replaceFirst(RegExp('YYYY'), yyyy) +// .replaceFirst(RegExp('MM'), MM) +// .replaceFirst(RegExp('DD'), dd) +// .replaceFirst(RegExp('hh'), hour12) +// .replaceFirst(RegExp('HH'), HH) +// .replaceFirst(RegExp('mm'), mm) +// .replaceFirst(RegExp('ss'), ss) +// .replaceFirst(RegExp('a'), ampm); +// +// return result; +// } + + +import 'package:intl/intl.dart'; + +String formatDateUS(String dateStr, {bool withTime = false}) { + // 假设后端返回的格式是 "2025-09-05T15:25:00Z" + final date = DateTime.parse(dateStr); + + if (withTime) { + return DateFormat("MMM d, yyyy 'at' h:mm a", 'en_US').format(date.toLocal()); + // 输出: Sep 5, 2025 at 11:25 PM (取本地时区) } else { - dateTime = DateTime.now(); + return DateFormat("MMM d, yyyy", 'en_US').format(date.toLocal()); + // 输出: Sep 5, 2025 } - - final yyyy = dateTime.year.toString(); - final MM = dateTime.month.toString().padLeft(2, '0'); - final dd = dateTime.day.toString().padLeft(2, '0'); - - // 12小时制 - final hour12 = (dateTime.hour % 12 == 0 ? 12 : dateTime.hour % 12).toString().padLeft(2, '0'); - final HH = dateTime.hour.toString().padLeft(2, '0'); // 24小时制备用 - final mm = dateTime.minute.toString().padLeft(2, '0'); - final ss = dateTime.second.toString().padLeft(2, '0'); - final ampm = dateTime.hour >= 12 ? 'PM' : 'AM'; - - String result = format - .replaceFirst(RegExp('YYYY'), yyyy) - .replaceFirst(RegExp('MM'), MM) - .replaceFirst(RegExp('DD'), dd) - .replaceFirst(RegExp('hh'), hour12) - .replaceFirst(RegExp('HH'), HH) - .replaceFirst(RegExp('mm'), mm) - .replaceFirst(RegExp('ss'), ss) - .replaceFirst(RegExp('a'), ampm); - - return result; -} +} \ No newline at end of file diff --git a/lib/utils/stream.dart b/lib/utils/stream.dart index e92ce42..a9805db 100644 --- a/lib/utils/stream.dart +++ b/lib/utils/stream.dart @@ -45,7 +45,7 @@ class StreamUtils { } //数据 var bufferText = ""; //吐出来的内容 - + final completer = Completer(); //处理响应 response.data?.stream .transform(_unit8Transformer()) @@ -63,6 +63,9 @@ class StreamUtils { } Map dataJSON = jsonDecode(streamStr); //提取响应数据 + if (dataJSON['choices'].length == 0) { + continue; + } Map choices = dataJSON['choices'][0]; //提取文字 var word = choices['delta']['content']; @@ -80,12 +83,17 @@ class StreamUtils { }, onDone: () { onEnd?.call(); + completer.complete(); }, onError: (error) { onError?.call(); logger.e("流错误: $error"); + if (!completer.isCompleted) { + completer.completeError(error); + } }, ); + return completer.future; } /// 将Uint8List转换为List diff --git a/pubspec.lock b/pubspec.lock index 8877517..7c80337 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -456,6 +456,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "0.2.2" + intl: + dependency: "direct main" + description: + name: intl + sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.19.0" leak_tracker: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 2d12372..d3904cf 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -29,6 +29,7 @@ dependencies: sign_in_with_apple: ^7.0.1 flutter_image_compress: ^2.4.0 cached_network_image: ^3.4.1 + intl: ^0.19.0 dev_dependencies: flutter_test: