elysia/lib/page/FollowListPage.dart
2025-11-04 09:53:47 +08:00

727 lines
24 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'package:elysia/page/child/EditorRobotPage.dart';
import 'package:elysia/page/child/RobotProfilePage.dart';
import 'package:elysia/page/child/RobotSquarePage.dart';
import 'package:elysia/plugin/HiverCache.dart';
import 'package:elysia/plugin/RouteAnimation.dart';
import 'package:elysia/plugin/Toast.dart';
import 'package:flutter/material.dart';
import 'package:shimmer/shimmer.dart';
import 'package:flutter_slidable/flutter_slidable.dart';
import '../bean/Mappable.dart';
import '../plugin/C.dart';
import '../plugin/CacheAavatar.dart';
import '../plugin/HTTP.dart';
import 'package:oktoast/oktoast.dart';
import '../plugin/LoadingOverlay.dart';
class FollowListPage extends StatefulWidget {
const FollowListPage({Key? key}) : super(key: key);
static Function(bool)? flushData;
static Function(int)? delete;
@override
State<FollowListPage> createState() => _FollowListPageState();
}
class _FollowListPageState extends State<FollowListPage>
with AutomaticKeepAliveClientMixin {
List<FollowItem> _list = [];
bool _loading = true;
@override
void initState() {
super.initState();
FollowListPage.flushData = (status) {
_list = [];
_loading = true;
fetchFollowList(status);
};
FollowListPage.delete = (id) {
_removeById(id);
};
fetchFollowList(false);
}
Future<void> fetchFollowList(bool status) async {
try {
_loading = false;
List<int> cacheId = [];
List<FollowItem> cacheData =
await HiverCache.getCache(
C.HIVE_CACHE_FETCH_FOLLOW,
FollowItem.fromJson,
) ??
[];
setState(() {
_list = cacheData;
});
if (_list.length == 0 || status) {
_loading = true;
} else {
if (!status) {
for (FollowItem followItem in cacheData) {
cacheId.add(followItem.robotId);
}
}
}
dynamic result = await HTTP
.create("${C.BASE_URL}/robot/follow-list")
.setHeader(C.TOKEN)
.setParam({"cacheId": cacheId})
.setRequestType(RequestType.GET)
.execute();
if (result['code'] == 200) {
setState(() {
if (status) {
_list = [];
}
//判定是否有要删除的ID
List<dynamic> excludeIdList = result['data']["excludeIdList"] ?? [];
if (excludeIdList != null || excludeIdList.length != 0) {
for (dynamic id in excludeIdList) {
int index = -1;
for (int i = 0; i < _list.length; i++) {
if (_list[i].robotId.toString() == id.toString()) {
index = i;
break;
}
}
if (index != -1) {
_list.removeAt(index);
}
}
}
List<Map<String, dynamic>> listOfMap =
(result['data']["data"] as List).cast<Map<String, dynamic>>();
if (listOfMap.length != 0) {
for (Map<String, dynamic> d in listOfMap) {
_list.add(FollowItem.fromJson(d));
}
}
_loading = false;
HiverCache.cache(C.HIVE_CACHE_FETCH_FOLLOW, _list);
});
} else {
setState(() => _loading = false);
}
} catch (e) {
setState(() => _loading = false);
}
}
void _removeAt(int index) {
setState(() {
_list.removeAt(index);
HiverCache.cache(C.HIVE_CACHE_FETCH_FOLLOW, _list);
});
}
void _removeById(int id) {
int index = -1;
for (int i = 0; i < _list.length; i++) {
if (_list[i].robotId == id) {
index = i;
break;
}
}
if (index != -1) {
_removeAt(index);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Column(
children: [
SizedBox(
child: Column(
children: [
Padding(
padding: EdgeInsets.symmetric(horizontal: 5),
child: GestureDetector(
onTap: () async {
bool status = await Navigator.push(
context,
RouteAnimation(RobotSquarePage(), Offset(1, 0)),
);
if (status) {
setState(() {
_list = [];
_loading = true;
fetchFollowList(false);
});
}
},
child: Container(
decoration: BoxDecoration(
color: Colors.grey[200],
borderRadius: BorderRadius.circular(5),
),
child: Padding(
padding: EdgeInsets.symmetric(
horizontal: 10,
vertical: 5,
),
child: Row(
children: [
// Avatar带圆角和 Shimmer
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: SizedBox(
width: 48,
height: 50,
child: Container(
decoration: BoxDecoration(color: Colors.blue),
child: Icon(Icons.group, color: Colors.white),
),
),
),
const SizedBox(width: 12),
// 名称与其它信息(这里仅显示 name
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"机器人广场",
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
],
),
),
Expanded(child: SizedBox()),
Icon(Icons.chevron_right, color: Colors.grey),
],
),
),
),
),
),
SizedBox(height: 10),
Divider(height: 1, thickness: 1, color: Colors.grey.shade300),
],
),
),
SizedBox(height: 10),
Expanded(
child: _loading
? ListView.builder(
itemCount: 10,
itemBuilder: (context, index) => const ShimmerFollowTile(),
)
: ClipRect(
child: ListView.builder(
itemCount: _list.length,
itemBuilder: (context, index) {
final item = _list[index];
return FollowTile(
item: item,
onDelete: () => unfollowRobot(index),
onClick: () {
Navigator.push(
context,
RouteAnimation(
RobotProfilePage(
robotId: item.robotId,
name: item.name,
avatar: item.avatar,
systemPrompt: item.systemPrompt,
creatorUsername: "开发中...",
template: "开发中...",
description: item.describe,
followList: true,
),
Offset(1, 0),
),
);
},
);
},
),
),
),
],
),
);
}
Future<void> unfollowRobot(int index) async {
LoadingOverlay.show(context: context, barrierColor: Colors.black54);
try {
dynamic result = await HTTP
.create("${C.BASE_URL}/robot/unfollow/${_list[index].robotId}")
.setHeader(C.TOKEN)
.setRequestType(RequestType.GET)
.execute();
LoadingOverlay.hide();
if (result["code"] == 200) {
_removeAt(index);
}
} catch (e) {
LoadingOverlay.hide();
}
}
@override
bool get wantKeepAlive => true;
}
/// 数据模型
class FollowItem extends Mappable<FollowItem> {
final int robotId;
final String name;
final String avatar; // 头像 URL
final bool creator;
final String systemPrompt;
final String describe;
FollowItem({
required this.robotId,
required this.name,
required this.avatar,
required this.systemPrompt,
required this.creator,
required this.describe,
});
factory FollowItem.fromJson(Map<String, dynamic> json) {
return FollowItem(
robotId: json['robotId'] ?? -1,
name: json['name'] ?? '',
avatar: json['avatar'] ?? '',
describe: json['describe'] ?? '',
systemPrompt: json['systemPrompt'] ?? '',
creator: json['creator'] == 1 || json['creator'] == true,
);
}
@override
Map<String, dynamic> toMap() {
return {
"robotId": robotId,
"name": name,
"avatar": avatar,
"describe": describe,
"systemPrompt": systemPrompt,
"creator": creator,
};
}
}
/// 真正的数据行(支持 Slidable 删除)
class FollowTile extends StatelessWidget {
final FollowItem item;
final VoidCallback? onDelete;
final VoidCallback? onClick;
const FollowTile({Key? key, required this.item, this.onDelete, this.onClick})
: super(key: key);
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.symmetric(vertical: 2),
child: Slidable(
key: ValueKey(item.robotId),
endActionPane: ActionPane(
motion: const DrawerMotion(),
extentRatio: 0.25,
children: [
SlidableAction(
onPressed: (ctx) {
if (onDelete != null) onDelete!(); // ✅ 这里会正确调用 unfollowRobot
},
backgroundColor: Colors.amber,
foregroundColor: Colors.white,
icon: Icons.heart_broken,
label: '取消关注',
spacing: 4,
borderRadius: BorderRadius.circular(5),
autoClose: true,
)
],
),
child: GestureDetector(
onTap: () {
if (onClick != null) onClick!();
},
child: Stack(
children: [
Padding(
padding: EdgeInsets.symmetric(horizontal: 5),
child: Container(
decoration: BoxDecoration(
color: Colors.grey[200],
borderRadius: BorderRadius.circular(5),
),
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 10, vertical: 5),
child: Row(
children: [
// Avatar带圆角和 Shimmer
CacheAvatar(url: item.avatar),
const SizedBox(width: 12),
// 名称与其它信息(这里仅显示 name
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.name,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
SizedBox(height: 5),
Text(
item.describe,
style: const TextStyle(
fontSize: 12,
color: Colors.grey,
),
),
],
),
),
],
),
),
),
),
if (item.creator)
Positioned(
right: 5,
top: 0,
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: Colors.orange,
borderRadius: BorderRadius.only(
topRight: Radius.circular(5),
bottomLeft: Radius.circular(5),
),
),
child: const Icon(
Icons.person, // 人头图标
size: 14,
color: Colors.white,
),
),
),
],
),
),
),
);
}
}
/// Shimmer 占位行(加载时显示)
class ShimmerFollowTile extends StatelessWidget {
const ShimmerFollowTile({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
// 高度与真实行相似,保持视觉一致
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
child: Row(
children: [
Shimmer.fromColors(
baseColor: Colors.grey.shade300,
highlightColor: Colors.grey.shade100,
child: Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Shimmer.fromColors(
baseColor: Colors.grey.shade300,
highlightColor: Colors.grey.shade100,
child: Container(
height: 16,
width: double.infinity,
color: Colors.white,
),
),
const SizedBox(height: 8),
Shimmer.fromColors(
baseColor: Colors.grey.shade300,
highlightColor: Colors.grey.shade100,
child: Container(
height: 14,
width: MediaQuery.of(context).size.width * 0.5,
color: Colors.white,
),
),
],
),
),
],
),
);
}
}
class ChatRoomDialog extends StatefulWidget {
final List<Map<String, String>> options; // 下拉选择的数据
final Function(String roomName, String value) onConfirm;
const ChatRoomDialog({
Key? key,
required this.options,
required this.onConfirm,
}) : super(key: key);
@override
State<ChatRoomDialog> createState() => _ChatRoomDialogState();
}
class _ChatRoomDialogState extends State<ChatRoomDialog> {
final TextEditingController _roomController = TextEditingController();
String? _selectedValue;
@override
void initState() {
super.initState();
if (widget.options.isNotEmpty) {
_selectedValue = widget.options[0]['value'];
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
return Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
elevation: 0,
backgroundColor: Colors.transparent,
insetPadding: EdgeInsets.symmetric(horizontal: 20, vertical: 24),
child: _dialogContent(context, theme, isDark),
);
}
Widget _dialogContent(BuildContext context, ThemeData theme, bool isDark) {
return Container(
padding: EdgeInsets.all(24),
decoration: BoxDecoration(
color: theme.colorScheme.surface,
borderRadius: BorderRadius.circular(24),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.2),
blurRadius: 20,
spreadRadius: 0,
offset: Offset(0, 8),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Title
Text(
"创建聊天室",
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w700,
color: theme.colorScheme.onSurface,
),
),
SizedBox(height: 8),
Text(
"设置您的聊天室名称并选择AI引擎",
style: TextStyle(
fontSize: 14,
color: theme.colorScheme.onSurface.withOpacity(0.6),
),
),
SizedBox(height: 24),
// Chat room name field
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"聊天室名称",
style: TextStyle(
fontWeight: FontWeight.w500,
color: theme.colorScheme.onSurface.withOpacity(0.8),
fontSize: 14,
),
),
SizedBox(height: 8),
Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: theme.colorScheme.primary.withOpacity(0.1),
blurRadius: 4,
offset: Offset(0, 2),
),
],
),
child: TextField(
controller: _roomController,
decoration: InputDecoration(
hintText: "请输入聊天室名称",
filled: true,
fillColor: isDark
? theme.colorScheme.surfaceVariant
: Colors.grey[50],
contentPadding: EdgeInsets.symmetric(
horizontal: 16,
vertical: 14,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide(
color: theme.colorScheme.primary,
width: 1.5,
),
),
),
),
),
],
),
SizedBox(height: 20),
// Engine selection
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"选择引擎",
style: TextStyle(
fontWeight: FontWeight.w500,
color: theme.colorScheme.onSurface.withOpacity(0.8),
fontSize: 14,
),
),
SizedBox(height: 8),
Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
color: isDark
? theme.colorScheme.surfaceVariant
: Colors.grey[50],
boxShadow: [
BoxShadow(
color: theme.colorScheme.primary.withOpacity(0.1),
blurRadius: 4,
offset: Offset(0, 2),
),
],
),
padding: EdgeInsets.symmetric(horizontal: 12),
child: DropdownButtonHideUnderline(
child: DropdownButton<String>(
value: _selectedValue,
isExpanded: true,
icon: Icon(
Icons.keyboard_arrow_down_rounded,
color: theme.colorScheme.onSurface.withOpacity(0.6),
),
borderRadius: BorderRadius.circular(16),
dropdownColor: isDark
? theme.colorScheme.surfaceVariant
: Colors.white,
style: TextStyle(
color: theme.colorScheme.onSurface,
fontSize: 16,
),
items: widget.options.map((option) {
return DropdownMenuItem<String>(
value: option['value'],
child: Padding(
padding: EdgeInsets.symmetric(vertical: 8),
child: Text(option['key'] ?? ''),
),
);
}).toList(),
onChanged: (val) {
setState(() {
_selectedValue = val;
});
},
selectedItemBuilder: (BuildContext context) {
return widget.options.map<Widget>((item) {
return Align(
alignment: Alignment.centerLeft,
child: Text(
item['key'] ?? '',
style: TextStyle(
color: theme.colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
),
);
}).toList();
},
),
),
),
],
),
SizedBox(height: 32),
// Confirm button
SizedBox(
width: double.infinity,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
padding: EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
backgroundColor: theme.colorScheme.primary,
foregroundColor: theme.colorScheme.onPrimary,
elevation: 0,
shadowColor: Colors.transparent,
),
onPressed: () {
if (_roomController.text.isEmpty || _selectedValue == null) {
showToast("请填写完整信息", position: ToastPosition.center);
return;
}
widget.onConfirm(_roomController.text, _selectedValue!);
Navigator.of(context).pop();
},
child: Text(
"创建聊天室",
style: TextStyle(fontWeight: FontWeight.w600, fontSize: 16),
),
),
),
],
),
);
}
}