76f266645d
含 Phase 1.1 IM seq 排序、断线重连、多端已读同步与微信式语音转文字 UI。 排除 node_modules、构建产物、安装包与 .env 密钥。 Co-authored-by: Cursor <cursoragent@cursor.com>
541 lines
18 KiB
Dart
541 lines
18 KiB
Dart
import 'package:flutter/material.dart';
|
|
|
|
import '../api/oa_client.dart';
|
|
import '../theme.dart';
|
|
import '../widgets/auth_image.dart';
|
|
|
|
class WxHeader extends StatelessWidget {
|
|
const WxHeader({super.key, required this.title, this.leading, this.actions = const []});
|
|
final String title;
|
|
final Widget? leading;
|
|
final List<Widget> actions;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return ColoredBox(
|
|
color: kHeader,
|
|
child: SafeArea(
|
|
bottom: false,
|
|
child: SizedBox(
|
|
height: 48,
|
|
child: Row(
|
|
children: [
|
|
SizedBox(width: 48, child: leading ?? const SizedBox.shrink()),
|
|
Expanded(
|
|
child: Text(
|
|
title,
|
|
textAlign: TextAlign.center,
|
|
style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600, color: kInk),
|
|
),
|
|
),
|
|
SizedBox(
|
|
width: actions.isEmpty ? 48 : null,
|
|
child: Row(mainAxisAlignment: MainAxisAlignment.end, children: actions),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class WxSearchBar extends StatelessWidget {
|
|
const WxSearchBar({super.key, this.hint = '搜索', this.onChanged, this.onTap, this.readonly = false});
|
|
final String hint;
|
|
final ValueChanged<String>? onChanged;
|
|
final VoidCallback? onTap;
|
|
final bool readonly;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final field = Container(
|
|
height: 36,
|
|
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: readonly
|
|
? Text(hint, style: const TextStyle(color: Color(0xFFB2B2B2), fontSize: 15))
|
|
: TextField(
|
|
onChanged: onChanged,
|
|
decoration: InputDecoration(
|
|
hintText: hint,
|
|
isDense: true,
|
|
filled: true,
|
|
fillColor: Colors.transparent,
|
|
border: InputBorder.none,
|
|
enabledBorder: InputBorder.none,
|
|
focusedBorder: InputBorder.none,
|
|
contentPadding: EdgeInsets.zero,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
return ColoredBox(
|
|
color: Colors.white,
|
|
child: Padding(
|
|
padding: const EdgeInsets.fromLTRB(12, 4, 12, 10),
|
|
child: onTap == null ? field : GestureDetector(onTap: onTap, child: field),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class SquareAvatar extends StatelessWidget {
|
|
const SquareAvatar({super.key, required this.label, this.size = 44, this.color, this.icon, this.fileId, this.api});
|
|
final String label;
|
|
final double size;
|
|
final Color? color;
|
|
final IconData? icon;
|
|
final String? fileId;
|
|
final OaClient? api;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final bg = color ?? _colorOf(label);
|
|
final id = (fileId ?? '').trim();
|
|
return Container(
|
|
width: size,
|
|
height: size,
|
|
decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(6)),
|
|
alignment: Alignment.center,
|
|
clipBehavior: Clip.antiAlias,
|
|
child: id.isNotEmpty && api != null
|
|
? AuthImage(api: api!, fileId: id, width: size, height: size, fit: BoxFit.cover)
|
|
: icon != null
|
|
? Icon(icon, color: Colors.white, size: size * 0.5)
|
|
: Text(
|
|
label.isEmpty ? '?' : String.fromCharCodes(label.runes.take(1)),
|
|
style: TextStyle(color: Colors.white, fontSize: size * 0.38, fontWeight: FontWeight.w600),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
Color _colorOf(String s) {
|
|
const palette = [
|
|
Color(0xFF267EF0),
|
|
Color(0xFF07C160),
|
|
Color(0xFFFA9D3B),
|
|
Color(0xFF6267F2),
|
|
Color(0xFF10AEFF),
|
|
Color(0xFFE75D5D),
|
|
Color(0xFF00B578),
|
|
Color(0xFF8B5CF6),
|
|
];
|
|
return palette[s.hashCode.abs() % palette.length];
|
|
}
|
|
|
|
class UnreadBadge extends StatelessWidget {
|
|
const UnreadBadge(this.count, {super.key, this.dot = false});
|
|
final int count;
|
|
final bool dot;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
if (count <= 0 && !dot) return const SizedBox.shrink();
|
|
if (dot || count <= 0) {
|
|
return Container(width: 8, height: 8, decoration: const BoxDecoration(color: kDanger, shape: BoxShape.circle));
|
|
}
|
|
final text = count > 99 ? '99+' : '$count';
|
|
return Container(
|
|
constraints: const BoxConstraints(minWidth: 18, minHeight: 18),
|
|
padding: const EdgeInsets.symmetric(horizontal: 5),
|
|
decoration: BoxDecoration(color: kDanger, borderRadius: BorderRadius.circular(9)),
|
|
alignment: Alignment.center,
|
|
child: Text(text, style: const TextStyle(color: Colors.white, fontSize: 11, fontWeight: FontWeight.w600)),
|
|
);
|
|
}
|
|
}
|
|
|
|
class ConvTile extends StatelessWidget {
|
|
const ConvTile({
|
|
super.key,
|
|
required this.title,
|
|
this.preview,
|
|
this.time,
|
|
this.unread = 0,
|
|
this.muted = false,
|
|
this.avatarLabel,
|
|
this.avatarColor,
|
|
this.avatarIcon,
|
|
this.avatarFileId,
|
|
this.api,
|
|
this.onTap,
|
|
this.selected = false,
|
|
});
|
|
final String title;
|
|
final String? preview;
|
|
final String? time;
|
|
final int unread;
|
|
final bool muted;
|
|
final String? avatarLabel;
|
|
final Color? avatarColor;
|
|
final IconData? avatarIcon;
|
|
final String? avatarFileId;
|
|
final OaClient? api;
|
|
final VoidCallback? onTap;
|
|
final bool selected;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return InkWell(
|
|
onTap: onTap,
|
|
child: Container(
|
|
color: selected ? const Color(0xFFE8F3FF) : Colors.white,
|
|
padding: const EdgeInsets.fromLTRB(12, 10, 12, 0),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
SquareAvatar(label: avatarLabel ?? title, color: avatarColor, icon: avatarIcon, fileId: avatarFileId, api: api),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: Container(
|
|
padding: const EdgeInsets.only(bottom: 10),
|
|
decoration: const BoxDecoration(border: Border(bottom: BorderSide(color: kLine, width: 0.5))),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(title, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 16, color: kInk)),
|
|
),
|
|
if (time != null && time!.isNotEmpty)
|
|
Text(time!, style: const TextStyle(fontSize: 12, color: Color(0xFFB2B2B2))),
|
|
],
|
|
),
|
|
const SizedBox(height: 4),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
preview ?? '',
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: const TextStyle(fontSize: 13, color: kMute),
|
|
),
|
|
),
|
|
if (muted) const Padding(padding: EdgeInsets.only(left: 6), child: Icon(Icons.notifications_off_outlined, size: 14, color: kMute)),
|
|
if (unread > 0) Padding(padding: const EdgeInsets.only(left: 6), child: UnreadBadge(unread)),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class GroupCard extends StatelessWidget {
|
|
const GroupCard({super.key, required this.title, required this.child, this.trailing});
|
|
final String title;
|
|
final Widget child;
|
|
final Widget? trailing;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
margin: const EdgeInsets.fromLTRB(12, 0, 12, 12),
|
|
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(8)),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(14, 12, 12, 4),
|
|
child: Row(
|
|
children: [
|
|
Expanded(child: Text(title, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: kInk))),
|
|
if (trailing != null) trailing!,
|
|
],
|
|
),
|
|
),
|
|
child,
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class AppGrid extends StatelessWidget {
|
|
const AppGrid({super.key, required this.items});
|
|
final List<AppGridItem> items;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return GridView.count(
|
|
crossAxisCount: 4,
|
|
shrinkWrap: true,
|
|
physics: const NeverScrollableScrollPhysics(),
|
|
padding: const EdgeInsets.fromLTRB(4, 4, 4, 12),
|
|
childAspectRatio: 0.92,
|
|
children: [
|
|
for (final it in items)
|
|
InkWell(
|
|
onTap: it.onTap,
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Container(
|
|
width: 44,
|
|
height: 44,
|
|
decoration: BoxDecoration(color: it.color, borderRadius: BorderRadius.circular(10)),
|
|
child: Icon(it.icon, color: Colors.white, size: 24),
|
|
),
|
|
const SizedBox(height: 6),
|
|
Text(
|
|
it.label,
|
|
textAlign: TextAlign.center,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: const TextStyle(fontSize: 12, color: kInk),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class AppGridItem {
|
|
const AppGridItem({required this.label, required this.icon, required this.color, required this.onTap});
|
|
final String label;
|
|
final IconData icon;
|
|
final Color color;
|
|
final VoidCallback onTap;
|
|
}
|
|
|
|
class CellGroup extends StatelessWidget {
|
|
const CellGroup({super.key, required this.children});
|
|
final List<Widget> children;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
margin: const EdgeInsets.fromLTRB(12, 0, 12, 12),
|
|
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(8)),
|
|
child: Column(children: children),
|
|
);
|
|
}
|
|
}
|
|
|
|
class Cell extends StatelessWidget {
|
|
const Cell({super.key, required this.title, this.subtitle, this.leading, this.trailing, this.onTap, this.showLine = true});
|
|
final String title;
|
|
final String? subtitle;
|
|
final Widget? leading;
|
|
final Widget? trailing;
|
|
final VoidCallback? onTap;
|
|
final bool showLine;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return InkWell(
|
|
onTap: onTap,
|
|
child: Padding(
|
|
padding: const EdgeInsets.only(left: 16),
|
|
child: Container(
|
|
padding: const EdgeInsets.fromLTRB(0, 14, 12, 14),
|
|
decoration: showLine ? const BoxDecoration(border: Border(bottom: BorderSide(color: kLine, width: 0.5))) : null,
|
|
child: Row(
|
|
children: [
|
|
if (leading != null) ...[leading!, const SizedBox(width: 12)],
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(title, style: const TextStyle(fontSize: 16, color: kInk)),
|
|
if (subtitle != null && subtitle!.isNotEmpty)
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 2),
|
|
child: Text(subtitle!, style: const TextStyle(fontSize: 12, color: kMute)),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
if (trailing != null) trailing!,
|
|
if (onTap != null) const Icon(Icons.chevron_right, color: Color(0xFFC0C0C0), size: 20),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class WxTabBar extends StatelessWidget {
|
|
const WxTabBar({super.key, required this.index, required this.onChanged, required this.items});
|
|
final int index;
|
|
final ValueChanged<int> onChanged;
|
|
final List<(IconData, IconData, String, int)> items;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return ColoredBox(
|
|
color: const Color(0xFFF7F7F7),
|
|
child: SafeArea(
|
|
top: false,
|
|
child: SizedBox(
|
|
height: 54,
|
|
child: Row(
|
|
children: [
|
|
for (var i = 0; i < items.length; i++)
|
|
Expanded(
|
|
child: InkWell(
|
|
onTap: () => onChanged(i),
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Stack(
|
|
clipBehavior: Clip.none,
|
|
children: [
|
|
Icon(index == i ? items[i].$2 : items[i].$1, size: 24, color: index == i ? kBind : const Color(0xFF646464)),
|
|
if (items[i].$4 > 0)
|
|
Positioned(right: -10, top: -4, child: UnreadBadge(items[i].$4)),
|
|
],
|
|
),
|
|
const SizedBox(height: 2),
|
|
Text(items[i].$3, style: TextStyle(fontSize: 10, color: index == i ? kBind : const Color(0xFF646464))),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<T?> showWxSheet<T>(BuildContext context, List<(String, T)> actions) {
|
|
return showModalBottomSheet<T>(
|
|
context: context,
|
|
backgroundColor: Colors.transparent,
|
|
builder: (ctx) => Padding(
|
|
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Container(
|
|
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(12)),
|
|
child: Column(
|
|
children: [
|
|
for (var i = 0; i < actions.length; i++)
|
|
InkWell(
|
|
onTap: () => Navigator.pop(ctx, actions[i].$2),
|
|
child: Container(
|
|
width: double.infinity,
|
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
|
decoration: i == 0 ? null : const BoxDecoration(border: Border(top: BorderSide(color: kLine, width: 0.5))),
|
|
alignment: Alignment.center,
|
|
child: Text(actions[i].$1, style: const TextStyle(fontSize: 17, color: kInk)),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Material(
|
|
color: Colors.white,
|
|
borderRadius: BorderRadius.circular(12),
|
|
child: InkWell(
|
|
onTap: () => Navigator.pop(ctx),
|
|
borderRadius: BorderRadius.circular(12),
|
|
child: const SizedBox(
|
|
width: double.infinity,
|
|
height: 52,
|
|
child: Center(child: Text('取消', style: TextStyle(fontSize: 17, color: kBind, fontWeight: FontWeight.w500))),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// 微信风格的页内提示:不遮挡输入框和系统导航区域。
|
|
void showWxToast(BuildContext context, String message, {bool error = false}) {
|
|
final overlay = Overlay.maybeOf(context);
|
|
if (overlay == null || message.trim().isEmpty) return;
|
|
late final OverlayEntry entry;
|
|
entry = OverlayEntry(
|
|
builder: (ctx) => Positioned(
|
|
top: MediaQuery.paddingOf(ctx).top + 54,
|
|
left: 32,
|
|
right: 32,
|
|
child: IgnorePointer(
|
|
child: Material(
|
|
color: Colors.white,
|
|
elevation: 8,
|
|
shadowColor: Colors.black26,
|
|
borderRadius: BorderRadius.circular(10),
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 11),
|
|
child: Row(
|
|
children: [
|
|
Icon(error ? Icons.error_outline : Icons.check_circle_outline,
|
|
size: 20, color: error ? kDanger : kWeGreen),
|
|
const SizedBox(width: 9),
|
|
Expanded(
|
|
child: Text(message,
|
|
textAlign: TextAlign.center,
|
|
style: const TextStyle(fontSize: 14, color: kInk)),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
overlay.insert(entry);
|
|
Future<void>.delayed(const Duration(milliseconds: 1800), () {
|
|
if (entry.mounted) entry.remove();
|
|
});
|
|
}
|
|
|
|
void showPlusMenu(BuildContext context, List<(IconData, String, VoidCallback)> items) {
|
|
final box = context.findRenderObject() as RenderBox?;
|
|
final overlay = Overlay.of(context).context.findRenderObject() as RenderBox?;
|
|
final pos = box != null && overlay != null
|
|
? RelativeRect.fromRect(
|
|
Rect.fromPoints(box.localToGlobal(Offset(box.size.width - 8, box.size.height), ancestor: overlay), box.localToGlobal(box.size.bottomRight(Offset.zero), ancestor: overlay)),
|
|
Offset.zero & overlay.size,
|
|
)
|
|
: const RelativeRect.fromLTRB(80, 80, 16, 0);
|
|
showMenu<int>(
|
|
context: context,
|
|
position: pos,
|
|
color: const Color(0xFF4C4C4C),
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
|
items: [
|
|
for (var i = 0; i < items.length; i++)
|
|
PopupMenuItem(
|
|
value: i,
|
|
child: Row(
|
|
children: [
|
|
Icon(items[i].$1, color: Colors.white, size: 20),
|
|
const SizedBox(width: 12),
|
|
Text(items[i].$2, style: const TextStyle(color: Colors.white, fontSize: 15)),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
).then((i) {
|
|
if (i != null) items[i].$3();
|
|
});
|
|
}
|