76f266645d
含 Phase 1.1 IM seq 排序、断线重连、多端已读同步与微信式语音转文字 UI。 排除 node_modules、构建产物、安装包与 .env 密钥。 Co-authored-by: Cursor <cursoragent@cursor.com>
412 lines
12 KiB
Dart
412 lines
12 KiB
Dart
import 'package:flutter/material.dart';
|
|
|
|
import '../api/oa_client.dart';
|
|
import '../session/session.dart';
|
|
import '../theme.dart';
|
|
import 'wecom.dart';
|
|
|
|
const kDeskBg = Color(0xFFEDEDED);
|
|
const kDeskSide = Color(0xFFF7F7F7);
|
|
const kDeskPane = Colors.white;
|
|
const kDeskActive = Color(0xFFE8F3FF);
|
|
|
|
class DesktopSidebar extends StatelessWidget {
|
|
const DesktopSidebar({
|
|
super.key,
|
|
required this.session,
|
|
required this.api,
|
|
required this.index,
|
|
required this.onChanged,
|
|
required this.items,
|
|
});
|
|
|
|
final SessionStore session;
|
|
final OaClient api;
|
|
final int index;
|
|
final ValueChanged<int> onChanged;
|
|
final List<(IconData, IconData, String, int)> items;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return ColoredBox(
|
|
color: kDeskSide,
|
|
child: SafeArea(
|
|
child: SizedBox(
|
|
width: 210,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 14, 16, 10),
|
|
child: Row(
|
|
children: [
|
|
SquareAvatar(
|
|
label: session.displayName,
|
|
size: 36,
|
|
fileId: '${session.user['avatarFileId'] ?? ''}',
|
|
api: api,
|
|
),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: Text(
|
|
session.displayName,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: kInk),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const Divider(height: 1, color: kLine),
|
|
const SizedBox(height: 6),
|
|
Expanded(
|
|
child: ListView(
|
|
padding: const EdgeInsets.symmetric(horizontal: 10),
|
|
children: [
|
|
for (var i = 0; i < items.length; i++)
|
|
_NavItem(
|
|
active: index == i,
|
|
icon: index == i ? items[i].$2 : items[i].$1,
|
|
label: items[i].$3,
|
|
badge: items[i].$4,
|
|
onTap: () => onChanged(i),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _NavItem extends StatelessWidget {
|
|
const _NavItem({
|
|
required this.active,
|
|
required this.icon,
|
|
required this.label,
|
|
required this.badge,
|
|
required this.onTap,
|
|
});
|
|
|
|
final bool active;
|
|
final IconData icon;
|
|
final String label;
|
|
final int badge;
|
|
final VoidCallback onTap;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Padding(
|
|
padding: const EdgeInsets.only(bottom: 4),
|
|
child: Material(
|
|
color: active ? kDeskActive : Colors.transparent,
|
|
borderRadius: BorderRadius.circular(8),
|
|
child: InkWell(
|
|
onTap: onTap,
|
|
borderRadius: BorderRadius.circular(8),
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
|
child: Row(
|
|
children: [
|
|
Stack(
|
|
clipBehavior: Clip.none,
|
|
children: [
|
|
Icon(icon, size: 20, color: active ? kBind : const Color(0xFF646464)),
|
|
if (badge > 0)
|
|
Positioned(right: -8, top: -4, child: UnreadBadge(badge)),
|
|
],
|
|
),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: Text(
|
|
label,
|
|
style: TextStyle(
|
|
fontSize: 14,
|
|
color: active ? kBind : const Color(0xFF323232),
|
|
fontWeight: active ? FontWeight.w600 : FontWeight.w400,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class DesktopPaneHeader extends StatelessWidget {
|
|
const DesktopPaneHeader({
|
|
super.key,
|
|
required this.title,
|
|
this.actions = const [],
|
|
this.leading,
|
|
this.bottom,
|
|
});
|
|
|
|
final String title;
|
|
final List<Widget> actions;
|
|
final Widget? leading;
|
|
final Widget? bottom;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return ColoredBox(
|
|
color: kDeskPane,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
SizedBox(
|
|
height: 52,
|
|
child: Row(
|
|
children: [
|
|
if (leading != null) leading!,
|
|
Padding(
|
|
padding: EdgeInsets.only(left: leading == null ? 16 : 4),
|
|
child: Text(title, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: kInk)),
|
|
),
|
|
const Spacer(),
|
|
...actions,
|
|
const SizedBox(width: 8),
|
|
],
|
|
),
|
|
),
|
|
if (bottom != null) bottom!,
|
|
const Divider(height: 1, color: kLine),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class DesktopSearchBar extends StatelessWidget {
|
|
const DesktopSearchBar({super.key, required this.hint, this.onChanged, this.controller});
|
|
final String hint;
|
|
final ValueChanged<String>? onChanged;
|
|
final TextEditingController? controller;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
height: 34,
|
|
decoration: BoxDecoration(color: const Color(0xFFF3F3F3), borderRadius: BorderRadius.circular(6)),
|
|
padding: const EdgeInsets.symmetric(horizontal: 10),
|
|
child: Row(
|
|
children: [
|
|
const Icon(Icons.search, size: 18, color: kMute),
|
|
const SizedBox(width: 6),
|
|
Expanded(
|
|
child: TextField(
|
|
controller: controller,
|
|
onChanged: onChanged,
|
|
style: const TextStyle(fontSize: 14),
|
|
decoration: InputDecoration(
|
|
hintText: hint,
|
|
isDense: true,
|
|
border: InputBorder.none,
|
|
enabledBorder: InputBorder.none,
|
|
focusedBorder: InputBorder.none,
|
|
contentPadding: EdgeInsets.zero,
|
|
hintStyle: const TextStyle(color: Color(0xFFB2B2B2), fontSize: 14),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class DesktopAppCard extends StatelessWidget {
|
|
const DesktopAppCard({
|
|
super.key,
|
|
required this.label,
|
|
required this.desc,
|
|
required this.icon,
|
|
required this.color,
|
|
required this.onTap,
|
|
});
|
|
|
|
final String label;
|
|
final String desc;
|
|
final IconData icon;
|
|
final Color color;
|
|
final VoidCallback onTap;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Material(
|
|
color: Colors.white,
|
|
borderRadius: BorderRadius.circular(10),
|
|
child: InkWell(
|
|
onTap: onTap,
|
|
borderRadius: BorderRadius.circular(10),
|
|
child: Padding(
|
|
padding: const EdgeInsets.fromLTRB(14, 14, 14, 12),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Container(
|
|
width: 40,
|
|
height: 40,
|
|
decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(10)),
|
|
child: Icon(icon, color: Colors.white, size: 22),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(label, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: kInk)),
|
|
const SizedBox(height: 4),
|
|
Text(desc, maxLines: 2, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 12, color: kMute, height: 1.35)),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class DesktopEmptyPane extends StatelessWidget {
|
|
const DesktopEmptyPane({super.key, this.hint = '选择左侧会话开始聊天'});
|
|
|
|
final String hint;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return ColoredBox(
|
|
color: const Color(0xFFF5F5F5),
|
|
child: Center(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Opacity(
|
|
opacity: 0.12,
|
|
child: Container(
|
|
width: 120,
|
|
height: 120,
|
|
decoration: BoxDecoration(color: kBind, borderRadius: BorderRadius.circular(28)),
|
|
child: const Icon(Icons.apartment, size: 64, color: Colors.white),
|
|
),
|
|
),
|
|
const SizedBox(height: 20),
|
|
Text(hint, style: const TextStyle(color: kMute, fontSize: 14)),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class DesktopTabBar extends StatelessWidget {
|
|
const DesktopTabBar({
|
|
super.key,
|
|
required this.tabs,
|
|
required this.active,
|
|
required this.onSelect,
|
|
required this.onClose,
|
|
});
|
|
|
|
final List<(String key, String title, IconData icon, Color color)> tabs;
|
|
final int active;
|
|
final ValueChanged<int> onSelect;
|
|
final ValueChanged<int> onClose;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return ColoredBox(
|
|
color: kDeskPane,
|
|
child: Column(
|
|
children: [
|
|
SizedBox(
|
|
height: 44,
|
|
child: ListView(
|
|
scrollDirection: Axis.horizontal,
|
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
|
children: [
|
|
for (var i = 0; i < tabs.length; i++)
|
|
_TabChip(
|
|
title: tabs[i].$2,
|
|
icon: tabs[i].$3,
|
|
color: tabs[i].$4,
|
|
active: active == i,
|
|
closable: i > 0,
|
|
onTap: () => onSelect(i),
|
|
onClose: () => onClose(i),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const Divider(height: 1, color: kLine),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _TabChip extends StatelessWidget {
|
|
const _TabChip({
|
|
required this.title,
|
|
required this.icon,
|
|
required this.color,
|
|
required this.active,
|
|
required this.closable,
|
|
required this.onTap,
|
|
required this.onClose,
|
|
});
|
|
|
|
final String title;
|
|
final IconData icon;
|
|
final Color color;
|
|
final bool active;
|
|
final bool closable;
|
|
final VoidCallback onTap;
|
|
final VoidCallback onClose;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Padding(
|
|
padding: const EdgeInsets.only(right: 4, top: 6, bottom: 6),
|
|
child: Material(
|
|
color: active ? const Color(0xFFF3F6FB) : Colors.transparent,
|
|
borderRadius: BorderRadius.circular(8),
|
|
child: InkWell(
|
|
onTap: onTap,
|
|
borderRadius: BorderRadius.circular(8),
|
|
child: Padding(
|
|
padding: EdgeInsets.fromLTRB(10, 6, closable ? 4 : 10, 6),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(icon, size: 16, color: color),
|
|
const SizedBox(width: 6),
|
|
Text(title, style: TextStyle(fontSize: 13, color: active ? kInk : kMute, fontWeight: active ? FontWeight.w600 : FontWeight.w400)),
|
|
if (closable) ...[
|
|
const SizedBox(width: 4),
|
|
InkWell(
|
|
onTap: onClose,
|
|
borderRadius: BorderRadius.circular(10),
|
|
child: const Padding(
|
|
padding: EdgeInsets.all(2),
|
|
child: Icon(Icons.close, size: 14, color: kMute),
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|