效果图

里面的逻辑还需要更深层的去理清楚
具体涉及的类:UI层,分组类,HTTP请求类,会话列表数据类,侧边栏管理类,存储类
UI层
Dart
late SideMenuManager _sideMenuManager; //侧边栏
StateSetter? _menuStateSetter;// 侧边栏内部的状态更新函数,用于在侧边栏关闭时刷新菜单
String? _longPressedSessionId;// 当前长按的会话ID
final Map<String, GlobalKey> _itemKeys = {};// 存储每个列表项的 GlobalKey
double? _buttonTop;// 悬浮按钮的位置
//========================初始化侧边栏==========================
void _initSideMenu(){
//创建侧边栏管理器
_sideMenuManager = SideMenuManager(
context: context,
vsync: this,
menuContentBuilder: _buildMenuContent,
);
// 注册关闭回调:当侧边栏关闭时,清除删除按钮状态
_sideMenuManager.onClosed = () {
if (mounted) {
_longPressedSessionId = null; //清除长按状态
_buttonTop = null; //清除删除按钮位置
_menuStateSetter?.call(() {}); //刷新菜单UI
}
};
// 为每个会话创建 GlobalKey,用于获取位置
for (var session in _sessionList) {
_itemKeys[session.id!] = GlobalKey();
}
}
// ========================= 获取会话列表 ================================
Future<void> getMessageList({bool isLoadMore = false}) async {
if (isLoadMore && (_isLoadingMoreSessionsList || !_hasMoreSessionsList)) {
return;
}
if (!isLoadMore) {
Log.d('首次加载会话列表');
// 首次加载,重置分页状态
_currentChatListPage = 1;
_hasMoreSessionsList = true;
setState(() {
_isLoadingSession = true;
});
} else {
Log.d('非首次加载会话列表');
setState(() {
_isLoadingMoreSessionsList = true;
});
}
try {
UserData? userData = Caches.caches.getUserData();
if (userData != null && userData.accessToken != null) {
Log.d("请求会话列表");
List<MessageListModel> newSessions = await PetHttp.getMessageList(
page: _currentChatListPage,
pageSize: 20,
);
if (newSessions.isEmpty) {
Log.d('没有更多数据');
_hasMoreSessionsList = false;
} else {
if (isLoadMore) {
// 加载更多,合并列表
_sessionList.addAll(newSessions);
} else {
// 首次加载
_sessionList = newSessions;
userData.setSessions(_sessionList);
}
// 重新分组
setState(() {
_groupedSessions = SessionGroupHelper.groupByDate(
_sessionList,
todayText: todayText,
yesterdayText: yesterdayText,
monthText: monthText,
dayText: dayText,
);
_isLoadingSession = false;
_isLoadingMoreSessionsList = false;
});
_menuStateSetter?.call(() {}); //刷新菜单UI
// 判断是否还有更多数据
_hasMoreSessionsList = newSessions.length == 20;
if (_hasMoreSessionsList) {
_currentChatListPage++;
}
}
}
} catch (e) {
Log.d("获取会话列表失败: $e");
} finally {
if (mounted) {
setState(() {
_isLoadingSession = false;
_isLoadingMoreSessionsList = false;
});
}
}
}
// ================================= 侧边栏内容 ================================
Widget _buildMenuContent(BuildContext context) {
return StatefulBuilder( //StatefulBuilder局部状态管理
builder: (context, setStateMenu) {
// 保存 setStateMenu 的引用
_menuStateSetter = setStateMenu;
final scrollController = ScrollController(); //创建滚动控制器
// 添加滚动监听
scrollController.addListener(() {
//防重复检查
if (_isLoadingMoreSessionsList) return;
//scrollController.position.pixels:当前已滚动的距离,scrollController.position.maxScrollExtent - 100 :最大可滚动的距离
if (scrollController.position.pixels >= scrollController.position.maxScrollExtent - 100) { //判断是否接近底部
if (!_isLoadingMoreSessionsList && _hasMoreSessionsList) {
getMessageList(isLoadMore: true); //加载更多数据
}
}
});
return Stack(
children: [
Column(
children: [
const SizedBox(height: 50),
//开启新对话
GestureDetector(
onTap: onStartNewChat,
child: Container(
height: 32,
width: 290,
decoration: BoxDecoration(
color: const Color(0xFF2187EF).withOpacity(0.1),
borderRadius: BorderRadius.circular(10),
),
margin: const EdgeInsets.symmetric(horizontal: 20),
child: Center(
child: Text(
startNewChat,
style: const TextStyle(color: Colors.black, fontSize: 12),
),
),
),
),
//会话列表
Expanded(
child: _isLoadingSession
? const Center(child: CircularProgressIndicator()) //首次加载中
: _sessionList.isEmpty
? Center(child: Text(noChatRecord, style: const TextStyle(color: Colors.grey))) //空列表
: ListView.builder(
controller: scrollController,
itemCount: _groupedSessions.length + (_isLoadingMoreSessionsList ? 1 : 0),
itemBuilder: (context, groupIndex) {
//最后一个item显示加载动画
if (groupIndex == _groupedSessions.length) {
return const Padding(
padding: EdgeInsets.all(16.0),
child: Center(
child: SizedBox(
height: 24,
width: 24,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
);
}
//正常显示分组
final group = _groupedSessions[groupIndex];
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
//日期标题
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
child: Text(
group.date,
style: const TextStyle(
fontSize: 12,
color: Colors.grey,
fontWeight: FontWeight.w500,
),
),
),
//该日期下的所有会话
...group.sessions.map((session) => _buildSessionItem(session, setStateMenu)),
//分割线,最后一个分组不显示
if (groupIndex != _groupedSessions.length - 1)
const Divider(height: 1, thickness: 0.5, indent: 20),
],
);
},
),
),
],
),
// 悬浮删除按钮
if (_longPressedSessionId != null && _buttonTop != null)
Positioned(
left: 70,
right: 70,
top: _buttonTop!,
child: _buildFloatingDeleteButton(setStateMenu),
),
],
);
},
);
}
//==============================侧边栏子项====================================
Widget _buildSessionItem(MessageListModel sessionListModel, StateSetter setStateMenu) {
// 确保有 GlobalKey
if (!_itemKeys.containsKey(sessionListModel.id)) {
_itemKeys[sessionListModel.id] = GlobalKey();
}
final itemKey = _itemKeys[sessionListModel.id];
final isLongPressed = _longPressedSessionId == sessionListModel.id;
return Container(
key: itemKey,
child: InkWell(
onTap: () {
if (isLongPressed) {
// 如果处于长按状态,点击取消选中
setState(() {
_longPressedSessionId = null;
_buttonTop = null;
});
setStateMenu(() {}); // 刷新菜单
} else {
// 切换会话时,重置分页并加载
_currentChatPage = 1;
_hasMoreSessions = true;
getHistoricalMessage(sessionListModel.id, isLoadMore: false);
}
},
onLongPress: () {
// 长按显示删除按钮
setState(() {
_longPressedSessionId = sessionListModel.id;
});
setStateMenu(() {}); // 立即刷新显示删除按钮
// 延迟计算位置,确保布局完成
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && _longPressedSessionId == sessionListModel.id) {
final RenderBox? renderBox = itemKey?.currentContext?.findRenderObject() as RenderBox?;
if (renderBox != null) {
final position = renderBox.localToGlobal(Offset.zero);
final itemBottom = position.dy + renderBox.size.height;
setStateMenu(() {
_buttonTop = itemBottom + 8;
});
}
}
});
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
decoration: BoxDecoration(
color: isLongPressed ? const Color(0xFF2187EF).withOpacity(0.1) : Colors.transparent,
border: Border(
bottom: BorderSide(color: Colors.grey.withOpacity(0.1), width: 0.5),
),
),
child: Row(
children: [
Expanded(
child: Text(
sessionListModel.title,
style: TextStyle(
fontSize: 14,
color: isLongPressed ? const Color(0xFF2187EF) : Colors.black,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
),
);
}
//=============================悬浮删除按钮=================================
Widget _buildFloatingDeleteButton(StateSetter setStateMenu) {
return Material(
elevation: 4,
borderRadius: BorderRadius.circular(8),
child: Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: InkWell(
onTap: () async {
final deletedSessionId = _longPressedSessionId; // 改个更清晰的名字
if (deletedSessionId != null) {
bool result = await PetHttp.deleteSession(deletedSessionId);
Log.d("删除会话的结果为:$result");
if(result){
setState(() {
// 从列表中删除
_sessionList.removeWhere((session) => session.id == deletedSessionId);
_itemKeys.remove(deletedSessionId);
// 重新分组
_groupedSessions = SessionGroupHelper.groupByDate(
_sessionList,
todayText: todayText,
yesterdayText: yesterdayText,
monthText: monthText,
dayText: dayText,
);
// 清除长按状态
_longPressedSessionId = null;
_buttonTop = null;
});
// 刷新侧边栏 UI
setStateMenu(() {});
// 保存到缓存
UserData? userData = Caches.caches.getUserData();
if (userData != null) {
userData.setSessions(_sessionList);
Caches.caches.saveUserData(userData);
}
// 如果删除的是当前正在聊天的会话,清空聊天记录
if (deletedSessionId == this.sessionId) {
setState(() {
itemList.clear();
this.sessionId = "";
_isNewChat = true;
});
}
Fluttertoast.showToast(msg: deleteSuccess);
}
}else{
Fluttertoast.showToast(msg: deleteFail);
}
},
borderRadius: BorderRadius.circular(8),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.delete_outline, size: 18, color: Color(0xFFFF4D4F)),
const SizedBox(width: 8),
Text(
S.current.delete,
style: const TextStyle(color: Color(0xFFFF4D4F), fontSize: 13),
),
],
),
),
),
),
);
}
分组类
Dart
import '../http/message_list_model.dart';
/// 分组后的会话数据结构
class GroupedSession {
final String date; // 日期标题(如:今天、昨天、2026-04-20)
final List<MessageListModel> sessions; //该日期下的会话列表
GroupedSession({
required this.date,
required this.sessions,
});
}
/// 会话列表分组工具类
class SessionGroupHelper {
//============================将会话列表按日期分组============================
static List<GroupedSession> groupByDate(
List<MessageListModel> sessions, {
String todayText = "今天",
String yesterdayText = "昨天",
String monthText = "月",
String dayText = "日",
}) {
//1.空列表检查
if (sessions.isEmpty) return [];
//2.创建分组用的Map,日期 + 会话列表数据列表
final Map<String, List<MessageListModel>> grouped = {};
//3.遍历所有会话,按日期分组
for (var session in sessions) {
//获取日期键
final dateKey = _getDateKey(session.updatedAt);
//如果该日期还没有创建分组,先创建空列表
if (!grouped.containsKey(dateKey)) {
grouped[dateKey] = [];
}
//将会话添加到对应日期的列表中
grouped[dateKey]!.add(session);
}
// 4.转换为列表并按日期倒序排序(最新的在前),Map转为List
//Map.entries返回包含所有键值对的可迭代对象。每个元素是一个MapEntry对象
final entries = grouped.entries.toList(); //这里toList()是因为后面需要.sort()方法排序
//5.对列表进行日期倒序排序
entries.sort((a, b) => b.key.compareTo(a.key));
//6.将map转换为GroupedSession列表
return entries.map((entry) {
// 按更新时间排序每个分组内的会话(最新的在前)
entry.value.sort((a, b) => b.updatedAt.compareTo(a.updatedAt));
return GroupedSession(
date: _getDateTitle(entry.key, todayText, yesterdayText, monthText, dayText), //获取日期
sessions: entry.value,
);
}).toList();
}
//====================获取日期键值(用于分组,格式:yyyy-MM-dd)==================
static String _getDateKey(DateTime date) {
return "${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}";
}
//==============================获取显示的日期标题=============================
static String _getDateTitle(
String dateKey,
String todayText,
String yesterdayText,
String monthText,
String dayText,
) {
final parts = dateKey.split('-');
if (parts.length != 3) return dateKey;
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
final yesterday = today.subtract(const Duration(days: 1));
final targetDate = DateTime(
int.parse(parts[0]),
int.parse(parts[1]),
int.parse(parts[2]),
);
if (targetDate == today) return todayText;
if (targetDate == yesterday) return yesterdayText;
return "${targetDate.month}$monthText${targetDate.day}$dayText";
}
}
HTTP请求类
Dart
static Future<List<MessageListModel>> getMessageList({int page = 1,int pageSize = 20}) async {
final param = {"page":page,"page_size":pageSize};
final rs = await _requestGet(_apiMessageList, param);
if (rs.isSuccess()) {
final data = rs.data;
if (data != null && data is List) {
Log.d(rs.toString());
return data.map((item) => MessageListModel.fromJson(item)).toList();
}
}
return [];
}
会话列表数据类
Dart
///会话列表数据类
class MessageListModel {
final String id;
final String title; //标题
DateTime createdAt; //创建时间
DateTime updatedAt; //更新时间
MessageListModel({
required this.id,
required this.title,
required this.createdAt,
required this.updatedAt,
});
//=========================从 JSON 创建对象==========================
factory MessageListModel.fromJson(Map<String, dynamic> json) {
return MessageListModel(
id: json["id"] ?? "",
title: json["title"] ?? "",
createdAt: DateTime.tryParse(json["created_at"] ?? "") ?? DateTime.now(),
updatedAt: DateTime.tryParse(json["updated_at"] ?? "") ?? DateTime.now(),
);
}
//===============================转换为 JSON============================
Map<String, dynamic> toJson() {
return {
"id": id,
"title": title,
"created_at": createdAt.toIso8601String(),
"updated_at": updatedAt.toIso8601String(),
};
}
//===========================复制并修改==================================
MessageListModel copyWith({
String? id,
String? title,
DateTime? createdAt,
DateTime? updatedAt,
}) {
return MessageListModel(
id: id ?? this.id,
title: title ?? this.title,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
);
}
@override
String toString() {
return 'ChatSession{id: $id, title: $title, createdAt: $createdAt, updatedAt: $updatedAt}';
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is MessageListModel && other.id == id;
}
@override
int get hashCode => id.hashCode;
}
侧边栏管理类
Dart
import 'package:flutter/material.dart';
///侧边栏管理类
class SideMenuManager {
OverlayEntry? _overlayEntry;//overLay 层条目,用于显示/移除菜单
bool _isMenuOpen = false; //菜单是否打开
late AnimationController _slideController; //动画控制器
late Animation<Offset> _slideAnimation; //滑动动画
final BuildContext context; //构建上下文
final TickerProvider vsync; // 添加 vsync 参数
late final Widget Function(BuildContext) menuContentBuilder; //菜单内容构建器
//回调
VoidCallback? onClosed; // 添加关闭回调
//构造函数
SideMenuManager({
required this.context,
required this.vsync, // 接收 TickerProvider
required this.menuContentBuilder,
}) {
_initAnimation(); //初始化动画
}
//============================初始化动画=================================
void _initAnimation() {
//创建动画控制器
_slideController = AnimationController(
vsync: vsync, // 使用传入的 vsync
duration: const Duration(milliseconds: 300),
);
//创建滑动动画
_slideAnimation = Tween<Offset>(
begin: const Offset(-1, 0),
end: Offset.zero,
).animate(CurvedAnimation(
parent: _slideController,
curve: Curves.easeOutCubic,
));
}
//===========================展示侧边栏===============================
void show() {
//防止重复打开
if (_isMenuOpen) return;
_isMenuOpen = true;
//创建overlayEntry
_overlayEntry = OverlayEntry(
builder: (context) => Stack(
children: [
// 背景遮罩
Positioned.fill(
child: GestureDetector(
onTap: close, //关闭方法
child: AnimatedOpacity(
opacity: _isMenuOpen ? 1.0 : 0.0,
duration: const Duration(milliseconds: 300),
child: Container(
color: Colors.black.withOpacity(0.5),
),
),
),
),
// 侧边菜单
SlideTransition(
position: _slideAnimation, //动画
child: Align(
alignment: Alignment.centerLeft,
child: Container(
width: MediaQuery.of(context).size.width * 0.8, //屏幕宽度
height: double.infinity,
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.only(
topRight: Radius.circular(20),
bottomRight: Radius.circular(20),
),
),
child: menuContentBuilder(context), //填充外部传入的菜单内容
),
),
),
],
),
);
//插入到overlay层
Overlay.of(context).insert(_overlayEntry!);
_slideController.forward();
}
//==============================关闭菜单=================================
void close() async {
if (!_isMenuOpen) return;
await _slideController.reverse();
_overlayEntry?.remove();
_overlayEntry = null;
_isMenuOpen = false;
// 触发关闭回调
onClosed?.call();
}
void dispose() {
_slideController.dispose();
_overlayEntry?.remove();
_overlayEntry = null;
}
bool get isOpen => _isMenuOpen;
//==========================更新侧边栏内容=====================================
void updateContent(Widget Function(BuildContext) newContentBuilder) {
// 更新构建器
menuContentBuilder = newContentBuilder;
// 如果菜单是打开的,刷新显示
if (_isMenuOpen) {
// 重新构建 overlay 内容
_overlayEntry?.markNeedsBuild();
}
}
}