This commit is contained in:
zhutao
2025-09-05 18:00:26 +08:00
parent 70aa3e6ab6
commit 193d29b0ce
23 changed files with 1071 additions and 338 deletions

View File

@@ -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<PlanDetailPage> {
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<PlanDetailPage> {
Widget build(BuildContext context) {
return ChangeNotifierProvider<PlanDetailStore>(
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<PlanDetailStore>(
child: SafeArea(
child: Column(
children: [
@@ -110,33 +84,12 @@ class _PlanDetailPageState extends State<PlanDetailPage> {
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<PlanDetailPage> {
],
),
),
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!,
);
},
),
);
}

View File

@@ -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<String> 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<String> 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<String> sentences = text
.split("\n")
.where((e) => e.contains("[NEXT]"))
.map((e) => e.replaceAll("[NEXT]", "").trim())
.toList();
planDetail.suggestionsList = sentences;
notifyListeners();
},
);
await savePlan();
}
///保存计划
Future<void> savePlan() async {
await savePlanApi(
planId: planId,
summary: planDetail.summary ?? "",
dialog: planDetail.dialog ?? "",
steps: planDetail.stepsList,
suggestions: planDetail.suggestionsList,
);
EasyLoading.showToast("计划创建成功");
}
///获取详情
Future<void> getPlanDetail() async {
planDetail = await getPlanDetailApi(planId);
notifyListeners();
}
}

View File

@@ -15,9 +15,13 @@ class _AvatarCardState extends State<AvatarCard> with SingleTickerProviderStateM
late AnimationController _controller;
late Animation<double> _animation;
///对话框值
String _dialog = "";
@override
void initState() {
super.initState();
//初始化动画
_controller = AnimationController(
vsync: this,
duration: Duration(milliseconds: 300),
@@ -28,6 +32,7 @@ class _AvatarCardState extends State<AvatarCard> with SingleTickerProviderStateM
curve: Curves.easeInOut,
),
);
_initDialog();
}
@override
@@ -36,8 +41,31 @@ class _AvatarCardState extends State<AvatarCard> with SingleTickerProviderStateM
_controller.dispose();
}
///初始化是否显示对话
void _initDialog() {
WidgetsBinding.instance.addPostFrameCallback((_) {
final store = context.read<PlanDetailStore>();
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<PlanDetailStore>();
if (store.planDetail.dialog == null) {
return;
}
setState(() {
store.showRoleTalk = !store.showRoleTalk;
if (store.showRoleTalk) {
@@ -71,10 +99,7 @@ class _AvatarCardState extends State<AvatarCard> 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,

View File

@@ -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<PlanItem> createState() => _PlanItemState();
}
class _PlanItemState extends State<PlanItem> 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;
}

View File

@@ -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<PlanList> createState() => _PlanListState();
}
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;
// 找出新增的 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),
),
),
),
];
},
),
),
),
);
}
}

View File

@@ -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,
),
);

View File

@@ -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<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,
),
],
),
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<SuggestedList> createState() => _SuggestedListState();
}
class _SuggestedListState extends State<SuggestedList> {
final GlobalKey<SliverAnimatedListState> _listKey = GlobalKey<SliverAnimatedListState>();
List<String> _suggestionsList = [];
@override
void initState() {
super.initState();
_initListen();
}
void _initListen() {
final store = context.read<PlanDetailStore>();
//先初始化,只在详情时才会有意义
_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,
),
),
),
],
),
],
),
),
);
}