From 70aa3e6ab64456c2a4b01666b1f966893f20a4e8 Mon Sep 17 00:00:00 2001 From: zhutao <1812073942@qq.com> Date: Thu, 4 Sep 2025 23:24:48 +0800 Subject: [PATCH] =?UTF-8?q?=E8=AF=A6=E6=83=85=E9=87=8C=E7=9A=84=E5=AF=B9?= =?UTF-8?q?=E8=AF=9D=E6=A1=86=E5=88=87=E6=8D=A2=E5=8A=A8=E7=94=BBok?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/api/dto/plan_detail_dto.dart | 68 ++++++++++ lib/api/endpoints/plan_api.dart | 10 ++ lib/page/my/widget/avatar_name.dart | 4 +- lib/page/plan/detail/plan_detail_page.dart | 60 +++++---- .../detail/viewmodel/plan_detail_store.dart | 24 ++++ lib/page/plan/detail/widgets/avatar_card.dart | 85 ++++++++++-- .../plan/detail/widgets/coach_message.dart | 37 ++++++ lib/utils/stream.dart | 121 ++++++++++++++++++ 8 files changed, 368 insertions(+), 41 deletions(-) create mode 100644 lib/api/dto/plan_detail_dto.dart create mode 100644 lib/api/endpoints/plan_api.dart create mode 100644 lib/page/plan/detail/widgets/coach_message.dart create mode 100644 lib/utils/stream.dart diff --git a/lib/api/dto/plan_detail_dto.dart b/lib/api/dto/plan_detail_dto.dart new file mode 100644 index 0000000..cf6eb0d --- /dev/null +++ b/lib/api/dto/plan_detail_dto.dart @@ -0,0 +1,68 @@ +class PlanStepDto { + num? id; + String? stepIcon; + String? stepContent; + String? stepExplain; + num? stepStatus; + + PlanStepDto({this.id, this.stepIcon, this.stepContent, this.stepExplain, this.stepStatus}); + + Map toJson() { + final map = {}; + map["id"] = id; + map["step_icon"] = stepIcon; + map["step_content"] = stepContent; + map["step_explain"] = stepExplain; + map["step_status"] = stepStatus; + return map; + } + + PlanStepDto.fromJson(dynamic json) { + id = json["id"]; + stepIcon = json["step_icon"]; + stepContent = json["step_content"]; + stepExplain = json["step_explain"]; + stepStatus = json["step_status"]; + } +} + +class PlanDetailDto { + String? agentName; + String? summary; + String? dialog; + List stepsList; + List suggestionsList; + + PlanDetailDto({ + this.agentName, + this.summary, + this.dialog, + List? stepsList, + List? suggestionsList, + }) : stepsList = stepsList ?? [], + suggestionsList = suggestionsList ?? []; + + factory PlanDetailDto.fromJson(Map json) { + return PlanDetailDto( + agentName: json["agent_name"], + summary: json["summary"], + dialog: json["dialog"], + stepsList: json["steps"] != null + ? (json["steps"] as List).map((v) => PlanStepDto.fromJson(v)).toList() + : [], + suggestionsList: json["suggestions"] != null + ? List.from(json["suggestions"]) + : [], + ); + } + + Map toJson() { + return { + "agent_name": agentName, + "summary": summary, + "dialog": dialog, + "steps": stepsList.map((v) => v.toJson()).toList(), + "suggestions": suggestionsList, + }; + } +} diff --git a/lib/api/endpoints/plan_api.dart b/lib/api/endpoints/plan_api.dart new file mode 100644 index 0000000..1ee6c2a --- /dev/null +++ b/lib/api/endpoints/plan_api.dart @@ -0,0 +1,10 @@ +import 'package:plan/api/network/request.dart'; + +///初始化计划 +Future initPlanApi(String need, int agentId) async { + var res = await Request().post("/plan/init", { + "user_need": need, + "agent_id": agentId, + }); + return res['plan_id']; +} diff --git a/lib/page/my/widget/avatar_name.dart b/lib/page/my/widget/avatar_name.dart index 6a54bd6..2c7afd5 100644 --- a/lib/page/my/widget/avatar_name.dart +++ b/lib/page/my/widget/avatar_name.dart @@ -41,8 +41,8 @@ class _AvatarNameState extends State { setState(() { _isEdit = false; }); - var res = await updateUserInfoApi(name: value); - widget.onUpdate(res); + widget.onUpdate(UserInfo(name: value)); + await updateUserInfoApi(name: value); } ///选择图片 diff --git a/lib/page/plan/detail/plan_detail_page.dart b/lib/page/plan/detail/plan_detail_page.dart index ea1a063..6946734 100644 --- a/lib/page/plan/detail/plan_detail_page.dart +++ b/lib/page/plan/detail/plan_detail_page.dart @@ -8,9 +8,8 @@ import 'package:remixicon/remixicon.dart'; import '../widgets/edit_desc_dialog.dart'; import 'widgets/avatar_card.dart'; -import 'widgets/plan_item.dart'; +import 'widgets/coach_message.dart'; import 'widgets/scroll_box.dart'; -import 'widgets/suggested.dart'; class PlanDetailPage extends StatefulWidget { final String? id; @@ -55,7 +54,11 @@ class _PlanDetailPageState extends State { Widget build(BuildContext context) { return ChangeNotifierProvider( create: (_) { - return PlanDetailStore(); + return PlanDetailStore( + planId: widget.id.toString(), + planContent: widget.planName ?? "", + showRoleTalk: widget.planName == null, + ); }, child: CupertinoPageScaffold( backgroundColor: Colors.white, @@ -108,31 +111,32 @@ class _PlanDetailPageState extends State { decoration: shadowDecoration, child: CustomScrollView( slivers: [ - 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, - ), + 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, + // ), ], ), ), diff --git a/lib/page/plan/detail/viewmodel/plan_detail_store.dart b/lib/page/plan/detail/viewmodel/plan_detail_store.dart index 47cd381..b82907e 100644 --- a/lib/page/plan/detail/viewmodel/plan_detail_store.dart +++ b/lib/page/plan/detail/viewmodel/plan_detail_store.dart @@ -1,6 +1,15 @@ import 'package:flutter/cupertino.dart'; +import 'package:plan/api/dto/plan_detail_dto.dart'; +import 'package:plan/api/endpoints/plan_api.dart'; class PlanDetailStore extends ChangeNotifier { + ///构造函数 + PlanDetailStore({ + this.planContent = "", + this.planId = "", + bool showRoleTalk = true, + }) : _showRoleTalk = showRoleTalk; + ///角色话语是否显示 bool _showRoleTalk = true; @@ -10,4 +19,19 @@ class PlanDetailStore extends ChangeNotifier { _showRoleTalk = value; notifyListeners(); } + + ///计划的内容,只有新增时才会有 + String planContent = ""; + + ///计划id + String planId = ""; + + ///计划详情 + PlanDetailDto planDetail = PlanDetailDto(); + + ///创建计划 + void createPlan() async { + var id = await initPlanApi(planContent, 1); + planId = id.toString(); + } } diff --git a/lib/page/plan/detail/widgets/avatar_card.dart b/lib/page/plan/detail/widgets/avatar_card.dart index d1d324a..561920e 100644 --- a/lib/page/plan/detail/widgets/avatar_card.dart +++ b/lib/page/plan/detail/widgets/avatar_card.dart @@ -1,6 +1,9 @@ import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; import 'package:remixicon/remixicon.dart'; +import '../viewmodel/plan_detail_store.dart'; + class AvatarCard extends StatefulWidget { const AvatarCard({super.key}); @@ -9,23 +12,35 @@ class AvatarCard extends StatefulWidget { } class _AvatarCardState extends State with SingleTickerProviderStateMixin { - bool _isShow = false; - late AnimationController _controller; + late Animation _animation; @override void initState() { super.initState(); _controller = AnimationController( vsync: this, - duration: Duration(milliseconds: 400), + duration: Duration(milliseconds: 300), + ); + _animation = Tween(begin: 0, end: 1).animate( + CurvedAnimation( + parent: _controller, + curve: Curves.easeInOut, + ), ); } + @override + void dispose() { + super.dispose(); + _controller.dispose(); + } + void _toggleShow() { + var store = context.read(); setState(() { - _isShow = !_isShow; - if (_isShow) { + store.showRoleTalk = !store.showRoleTalk; + if (store.showRoleTalk) { _controller.forward(); } else { _controller.reverse(); @@ -41,11 +56,10 @@ class _AvatarCardState extends State with SingleTickerProviderStateM offset: Offset(0, 50), child: Column( children: [ - Transform( - alignment: Alignment.center, - transform: Matrix4.identity()..translate(40.0, 40.0)..scale(0.2), + BothSizeTransition( + animation: _animation, + offset: Offset(50, 50), child: Container( - color: Colors.red, padding: EdgeInsets.only(bottom: 10), child: InkWell( onTap: _toggleShow, @@ -84,10 +98,14 @@ class _AvatarCardState extends State with SingleTickerProviderStateM Positioned( top: 20, child: Transform.translate( - offset: Offset(50, -10), + offset: Offset(40, -10), child: GestureDetector( onTap: _toggleShow, - child: Icon(RemixIcons.message_2_line, size: 26), + child: BothSizeTransition( + animation: ReverseAnimation(_animation), + offset: Offset(-50, -50), + child: Icon(RemixIcons.message_2_line), + ), ), ), ), @@ -101,6 +119,7 @@ class _AvatarCardState extends State with SingleTickerProviderStateM } } +///聊天气泡三角 class BubblePainter extends CustomPainter { @override void paint(Canvas canvas, Size size) { @@ -135,3 +154,47 @@ class BubblePainter extends CustomPainter { return true; } } + +///缩放动画 +class BothSizeTransition extends StatelessWidget { + final Animation animation; + final Widget child; + final Offset offset; + + const BothSizeTransition({ + super.key, + required this.animation, + this.offset = Offset.zero, + required this.child, + }); + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: animation, + builder: (_, child) { + return Align( + alignment: Alignment(0.7, 1), + child: Opacity( + opacity: animation.value, + child: Transform( + alignment: Alignment(0.5, 1), + transform: Matrix4.identity() + ..translate( + offset.dx * (1 - animation.value), + offset.dy * (1 - animation.value), + ) + ..scale(animation.value, 1.0), + child: SizeTransition( + sizeFactor: animation, + axis: Axis.vertical, + child: child, + ), + ), + ), + ); + }, + child: child, + ); + } +} diff --git a/lib/page/plan/detail/widgets/coach_message.dart b/lib/page/plan/detail/widgets/coach_message.dart new file mode 100644 index 0000000..60fd4d7 --- /dev/null +++ b/lib/page/plan/detail/widgets/coach_message.dart @@ -0,0 +1,37 @@ +import 'package:flutter/material.dart'; +import 'package:plan/page/plan/detail/viewmodel/plan_detail_store.dart'; +import 'package:provider/provider.dart'; + +class CoachMessage extends StatefulWidget { + const CoachMessage({super.key}); + + @override + State createState() => _CoachMessageState(); +} + +class _CoachMessageState extends State { + @override + Widget build(BuildContext context) { + var store = context.read(); + if (store.planContent.isEmpty) { + return SliverToBoxAdapter(); + } + 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, + ), + ], + ), + ), + ); + } +} diff --git a/lib/utils/stream.dart b/lib/utils/stream.dart new file mode 100644 index 0000000..e92ce42 --- /dev/null +++ b/lib/utils/stream.dart @@ -0,0 +1,121 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:dio/dio.dart'; +import 'package:flutter_easyloading/flutter_easyloading.dart'; +import 'package:logger/logger.dart'; + +import '../config/env.dart'; +import '../providers/app_store.dart'; + +///封装流式请求 +class StreamUtils { + var logger = Logger(); + Dio dio = Dio(); + + ///发送流式请求 + /// - [url] 请求的URL + /// - [data] 请求的数据,默认为空 + /// - [onCall] 可选的回调函数,用于处理每个数据块 + /// - [onEnd] 可选的回调函数,用于处理请求结束 + /// - [onError] 可选的回调函数,用于处理请求错误 + Future sendStream( + String url, { + Map data = const {}, + Function(Map chunk)? onCall, + Function()? onEnd, + Function()? onError, + }) async { + String token = await AppStore.getToken(); + Response response = await dio.post( + "${Config.baseUrl()}$url", + data: data, + options: Options( + responseType: ResponseType.stream, + headers: { + 'Content-Type': 'application/json', + 'Authorization': "Bearer $token", + }, + ), + ); + if (await _isValidResponse(response)) { + onError?.call(); + return; + } + //数据 + var bufferText = ""; //吐出来的内容 + + //处理响应 + response.data?.stream + .transform(_unit8Transformer()) + .transform(utf8.decoder) + .listen( + (chunk) { + List chunkList = chunk.split("\n"); + for (var element in chunkList) { + String streamStr = element.replaceAll("data: ", '').trim(); + try { + if (streamStr.isNotEmpty) { + if (streamStr == '[DONE]') { + // 流结束标志,忽略不处理 + return; + } + Map dataJSON = jsonDecode(streamStr); + //提取响应数据 + Map choices = dataJSON['choices'][0]; + //提取文字 + var word = choices['delta']['content']; + //回调 + if (word != null) { + bufferText += word; + } + onCall?.call({...choices, "text": bufferText}); + } + } catch (e) { + onError?.call(); + logger.e("流错误响应: $e"); + } + } + }, + onDone: () { + onEnd?.call(); + }, + onError: (error) { + onError?.call(); + logger.e("流错误: $error"); + }, + ); + } + + /// 将Uint8List转换为List + StreamTransformer> _unit8Transformer() { + StreamTransformer> unit8Transformer = StreamTransformer.fromHandlers( + handleData: (data, sink) { + sink.add(List.from(data)); + }, + ); + return unit8Transformer; + } + + // 判断是否是正常请求 + Future _isValidResponse(Response response) async { + final contentType = response.headers.value('content-type') ?? ''; + if (contentType.contains('application/json')) { + final data = response.data; + if (data is ResponseBody) { + // 把流里的所有字节收集起来 + final bytes = await data.stream.fold>( + [], + (previous, element) => previous..addAll(element), + ); + var res = jsonDecode(utf8.decode(bytes)); + if (res['code'] == 0) { + EasyLoading.showToast(res['message']); + } + } + return true; + } + return false; + } +}