Flutter---侧边栏会话列表

效果图

里面的逻辑还需要更深层的去理清楚

具体涉及的类: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();
    }
  }
}
相关推荐
G_dou_1 小时前
Flutter+OpenHarmony实战:Calculator 计算器项目
flutter
小小小小小鹿1 小时前
# Vibe Coding 实战:Flutter 滑动列表上的花式动效
flutter·vibecoding
西西学代码1 小时前
Flutter---登录弹窗
flutter
G_dou_2 小时前
# Flutter+OpenHarmony 实战:ToDo待办清单
flutter·harmonyos
不爱吃糖的程序媛10 小时前
Flutter 三方库适配鸿蒙教程
flutter·华为·harmonyos
2501_9197490314 小时前
鸿蒙 Flutter 实战:video_compress 3.1.4 适配 3.27-ohos 全流程
flutter·华为·harmonyos
h64648564h16 小时前
Flutter 国际化(i18n)全指南:一键切换中/英/日多语言
前端·javascript·flutter
kTR2hD1qb21 小时前
Flutter 复杂拖拽排序实战:同源排序 + 跨容器拖拽完整落地
flutter
jingling5551 天前
Flutter | Dio网络请求实战
android·开发语言·前端·flutter