Initial commit: 风影 OA 全栈源码(API/Web/Flutter/IM)
含 Phase 1.1 IM seq 排序、断线重连、多端已读同步与微信式语音转文字 UI。 排除 node_modules、构建产物、安装包与 .env 密钥。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../api/oa_client.dart';
|
||||
import '../theme.dart';
|
||||
|
||||
class AuthImage extends StatefulWidget {
|
||||
const AuthImage({
|
||||
super.key,
|
||||
required this.api,
|
||||
required this.fileId,
|
||||
this.width,
|
||||
this.height,
|
||||
this.fit = BoxFit.cover,
|
||||
this.placeholder,
|
||||
});
|
||||
final OaClient api;
|
||||
final String fileId;
|
||||
final double? width;
|
||||
final double? height;
|
||||
final BoxFit fit;
|
||||
final Widget? placeholder;
|
||||
|
||||
@override
|
||||
State<AuthImage> createState() => _AuthImageState();
|
||||
}
|
||||
|
||||
class _AuthImageState extends State<AuthImage> {
|
||||
Uint8List? _bytes;
|
||||
bool _fail = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_bytes = widget.api.peekFile(widget.fileId);
|
||||
if (_bytes == null) _load();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant AuthImage oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.fileId != widget.fileId) {
|
||||
_bytes = widget.api.peekFile(widget.fileId);
|
||||
_fail = false;
|
||||
if (_bytes == null) _load();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
try {
|
||||
final b = await widget.api.fileBytes(widget.fileId);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_bytes = b;
|
||||
_fail = b == null;
|
||||
});
|
||||
} catch (_) {
|
||||
if (mounted) setState(() => _fail = true);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_bytes != null) {
|
||||
return Image.memory(
|
||||
_bytes!,
|
||||
width: widget.width,
|
||||
height: widget.height,
|
||||
fit: widget.fit,
|
||||
gaplessPlayback: true,
|
||||
filterQuality: FilterQuality.medium,
|
||||
);
|
||||
}
|
||||
return SizedBox(
|
||||
width: widget.width,
|
||||
height: widget.height,
|
||||
child: widget.placeholder ??
|
||||
ColoredBox(
|
||||
color: const Color(0xFFEDEDED),
|
||||
child: _fail ? const Icon(Icons.broken_image_outlined, color: kMute, size: 18) : const SizedBox.shrink(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../app_config.dart';
|
||||
import '../device/device_bridge.dart';
|
||||
import '../theme.dart';
|
||||
|
||||
class BeianFooter extends StatelessWidget {
|
||||
const BeianFooter({super.key});
|
||||
|
||||
Future<void> _open(String url) async {
|
||||
try {
|
||||
await DeviceBridge.openUrl(url);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const style = TextStyle(color: kMute, fontSize: 11, height: 1.6);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
||||
child: Column(
|
||||
children: [
|
||||
const Text(AppConfig.company, textAlign: TextAlign.center, style: style),
|
||||
Wrap(
|
||||
alignment: WrapAlignment.center,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () => _open(AppConfig.icpUrl),
|
||||
child: const Text(AppConfig.icp, style: style),
|
||||
),
|
||||
const Text(' | ', style: style),
|
||||
GestureDetector(
|
||||
onTap: () => _open('tel:${AppConfig.phone}'),
|
||||
child: const Text('联系电话 ${AppConfig.phone}', style: style),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../theme.dart';
|
||||
import '../labels.dart';
|
||||
|
||||
/// 与 Web `ApplyBucketBar` 对齐:Segmented + 选中态。
|
||||
class BucketBar extends StatelessWidget {
|
||||
const BucketBar({super.key, required this.value, required this.onChanged, this.extra = const []});
|
||||
|
||||
final String value;
|
||||
final ValueChanged<String> onChanged;
|
||||
final List<(String, String)> extra;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final items = <(String, String)>[
|
||||
('', '全部'),
|
||||
('pending', '待审批'),
|
||||
('approved', '已通过'),
|
||||
('rejected', '已驳回'),
|
||||
...extra,
|
||||
];
|
||||
return SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
|
||||
child: Row(
|
||||
children: [
|
||||
for (final it in items)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: ChoiceChip(
|
||||
label: Text(it.$2),
|
||||
selected: value == it.$1,
|
||||
showCheckmark: false,
|
||||
visualDensity: VisualDensity.compact,
|
||||
selectedColor: kBind.withValues(alpha: 0.16),
|
||||
onSelected: (_) => onChanged(it.$1),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class EmptyHint extends StatelessWidget {
|
||||
const EmptyHint(this.text, {super.key});
|
||||
final String text;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Text(text, style: const TextStyle(color: kMute)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class KvTile extends StatelessWidget {
|
||||
const KvTile({super.key, required this.title, this.subtitle, this.trailing, this.onTap, this.badge, this.status});
|
||||
final String title;
|
||||
final String? subtitle;
|
||||
final Widget? trailing;
|
||||
final VoidCallback? onTap;
|
||||
final int? badge;
|
||||
final dynamic status;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: Colors.white,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 12, 0),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
decoration: const BoxDecoration(border: Border(bottom: BorderSide(color: kLine, width: 0.5))),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title, maxLines: 2, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 16, color: kInk)),
|
||||
if (subtitle != null && subtitle!.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Text(subtitle!, maxLines: 2, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 13, color: kMute)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 2),
|
||||
child: Column(
|
||||
children: [
|
||||
if (status != null) StatusDot(status),
|
||||
if (badge != null && badge! > 0)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 6),
|
||||
child: CircleAvatar(
|
||||
radius: 10,
|
||||
backgroundColor: kDanger,
|
||||
child: Text('$badge', style: const TextStyle(color: Colors.white, fontSize: 10)),
|
||||
),
|
||||
),
|
||||
if (trailing != null) trailing!,
|
||||
if (onTap != null) const Icon(Icons.chevron_right, color: Color(0xFFC0C0C0), size: 20),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
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),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../api/oa_client.dart';
|
||||
import '../theme.dart';
|
||||
import 'auth_image.dart';
|
||||
|
||||
/// 公告 HTML:按块渲染标题、段落、列表、加粗,不依赖 flutter_html。
|
||||
class HtmlBody extends StatelessWidget {
|
||||
const HtmlBody({super.key, required this.html, this.api});
|
||||
final String html;
|
||||
final OaClient? api;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final blocks = parseHtmlBlocks(html);
|
||||
if (blocks.isEmpty) {
|
||||
return const Text('暂无正文', style: TextStyle(color: kMute, fontSize: 15));
|
||||
}
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
for (final b in blocks) ...[
|
||||
if (b.kind == 'img' && b.src != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: _img(b.src!),
|
||||
)
|
||||
else
|
||||
Padding(
|
||||
padding: EdgeInsets.only(bottom: b.kind.startsWith('h') ? 10 : 12, left: b.kind == 'li' ? 12 : 0),
|
||||
child: Text.rich(
|
||||
TextSpan(children: b.spans),
|
||||
style: _style(b.kind),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
TextStyle _style(String kind) {
|
||||
switch (kind) {
|
||||
case 'h1':
|
||||
return const TextStyle(fontSize: 22, fontWeight: FontWeight.w700, color: kInk, height: 1.4);
|
||||
case 'h2':
|
||||
return const TextStyle(fontSize: 18, fontWeight: FontWeight.w700, color: kInk, height: 1.4);
|
||||
case 'h3':
|
||||
return const TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: kInk, height: 1.45);
|
||||
case 'li':
|
||||
return const TextStyle(fontSize: 16, color: kInk, height: 1.7);
|
||||
default:
|
||||
return const TextStyle(fontSize: 16, color: kInk, height: 1.7);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _img(String src) {
|
||||
final m = RegExp(r'/files/([0-9a-f-]{36})', caseSensitive: false).firstMatch(src);
|
||||
if (m != null && api != null) {
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: AuthImage(api: api!, fileId: m.group(1)!, fit: BoxFit.contain),
|
||||
);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
}
|
||||
|
||||
class HtmlBlock {
|
||||
HtmlBlock(this.kind, this.spans, {this.src});
|
||||
final String kind;
|
||||
final List<InlineSpan> spans;
|
||||
final String? src;
|
||||
}
|
||||
|
||||
List<HtmlBlock> parseHtmlBlocks(String raw) {
|
||||
var s = raw.trim();
|
||||
if (s.isEmpty) return [];
|
||||
s = s.replaceAll(RegExp(r'<(script|style)[^>]*>[\s\S]*?</\1>', caseSensitive: false), '');
|
||||
if (!RegExp(r'<[a-z][\s\S]*>', caseSensitive: false).hasMatch(s)) {
|
||||
return s
|
||||
.split(RegExp(r'\n+'))
|
||||
.where((e) => e.trim().isNotEmpty)
|
||||
.map((e) => HtmlBlock('p', _inline(e.trim())))
|
||||
.toList();
|
||||
}
|
||||
s = s
|
||||
.replaceAll(RegExp(r'<br\s*/?>', caseSensitive: false), '\n')
|
||||
.replaceAll(RegExp(r'</(p|div|h1|h2|h3|li|tr|blockquote)>', caseSensitive: false), '\n')
|
||||
.replaceAll(RegExp(r'<(p|div|blockquote)[^>]*>', caseSensitive: false), '\n')
|
||||
.replaceAll(RegExp(r'<h1[^>]*>', caseSensitive: false), '\n#h1#')
|
||||
.replaceAll(RegExp(r'<h2[^>]*>', caseSensitive: false), '\n#h2#')
|
||||
.replaceAll(RegExp(r'<h3[^>]*>', caseSensitive: false), '\n#h3#')
|
||||
.replaceAll(RegExp(r'<li[^>]*>', caseSensitive: false), '\n#li#')
|
||||
.replaceAll(RegExp(r'</?(ul|ol)[^>]*>', caseSensitive: false), '\n')
|
||||
.replaceAll(RegExp(r'<(strong|b)[^>]*>', caseSensitive: false), '«b»')
|
||||
.replaceAll(RegExp(r'</(strong|b)>', caseSensitive: false), '«/b»');
|
||||
final imgRe = RegExp(r'<img[^>]*src=["'']([^"'']+)["''][^>]*>', caseSensitive: false);
|
||||
s = s.replaceAllMapped(imgRe, (m) => '\n#img#${m.group(1)}#\n');
|
||||
s = s.replaceAll(RegExp(r'<[^>]+>'), '');
|
||||
s = decodeEntities(s);
|
||||
final out = <HtmlBlock>[];
|
||||
for (final line in s.split('\n')) {
|
||||
final t = line.trim();
|
||||
if (t.isEmpty) continue;
|
||||
if (t.startsWith('#img#') && t.endsWith('#')) {
|
||||
out.add(HtmlBlock('img', const [], src: t.substring(5, t.length - 1)));
|
||||
continue;
|
||||
}
|
||||
var kind = 'p';
|
||||
var text = t;
|
||||
if (t.startsWith('#h1#')) {
|
||||
kind = 'h1';
|
||||
text = t.substring(4);
|
||||
} else if (t.startsWith('#h2#')) {
|
||||
kind = 'h2';
|
||||
text = t.substring(4);
|
||||
} else if (t.startsWith('#h3#')) {
|
||||
kind = 'h3';
|
||||
text = t.substring(4);
|
||||
} else if (t.startsWith('#li#')) {
|
||||
kind = 'li';
|
||||
text = '• ${t.substring(4)}';
|
||||
}
|
||||
text = text.trim();
|
||||
if (text.isEmpty) continue;
|
||||
out.add(HtmlBlock(kind, _inline(text)));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
List<InlineSpan> _inline(String text) {
|
||||
final out = <InlineSpan>[];
|
||||
final re = RegExp(r'«b»(.*?)«/b»');
|
||||
var i = 0;
|
||||
for (final m in re.allMatches(text)) {
|
||||
if (m.start > i) out.add(TextSpan(text: text.substring(i, m.start)));
|
||||
out.add(TextSpan(text: m.group(1), style: const TextStyle(fontWeight: FontWeight.w700)));
|
||||
i = m.end;
|
||||
}
|
||||
if (i < text.length) out.add(TextSpan(text: text.substring(i)));
|
||||
if (out.isEmpty) out.add(TextSpan(text: text));
|
||||
return out;
|
||||
}
|
||||
|
||||
String decodeEntities(String s) {
|
||||
return s
|
||||
.replaceAll(' ', ' ')
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll(''', "'")
|
||||
.replaceAllMapped(RegExp(r'&#(\d+);'), (m) {
|
||||
final n = int.tryParse(m.group(1)!);
|
||||
return n == null ? m.group(0)! : String.fromCharCode(n);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../api/oa_client.dart';
|
||||
import '../device/device_bridge.dart';
|
||||
import '../theme.dart';
|
||||
import 'tencent_map_thumb.dart';
|
||||
|
||||
class LocationViewPage extends StatelessWidget {
|
||||
const LocationViewPage({
|
||||
super.key,
|
||||
required this.api,
|
||||
required this.title,
|
||||
required this.address,
|
||||
this.lat,
|
||||
this.lng,
|
||||
});
|
||||
|
||||
final OaClient api;
|
||||
final String title;
|
||||
final String address;
|
||||
final double? lat;
|
||||
final double? lng;
|
||||
|
||||
Future<void> _openMaps(BuildContext context) async {
|
||||
if (lat == null || lng == null) return;
|
||||
final la = lat!;
|
||||
final ln = lng!;
|
||||
final q = Uri.encodeComponent(title.isNotEmpty ? title : '$la,$ln');
|
||||
final urls = [
|
||||
'https://uri.amap.com/marker?position=$ln,$la&name=$q',
|
||||
'geo:$la,$ln?q=$la,$ln($q)',
|
||||
];
|
||||
for (final u in urls) {
|
||||
try {
|
||||
await DeviceBridge.openUrl(u);
|
||||
return;
|
||||
} catch (_) {}
|
||||
}
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('无法打开地图')));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
appBar: AppBar(title: const Text('位置')),
|
||||
body: ListView(
|
||||
children: [
|
||||
if (lat != null && lng != null)
|
||||
TencentMapThumb(api: api, lat: lat!, lng: lng!, width: MediaQuery.sizeOf(context).width, height: 220),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
child: Text(title.isEmpty ? '位置' : title, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600)),
|
||||
),
|
||||
if (address.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||
child: Text(address, style: const TextStyle(fontSize: 14, color: kMute)),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: FilledButton.icon(
|
||||
onPressed: lat == null ? null : () => _openMaps(context),
|
||||
icon: const Icon(Icons.map_outlined),
|
||||
label: const Text('用地图打开'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../api/oa_client.dart';
|
||||
import '../theme.dart';
|
||||
|
||||
class TencentMapThumb extends StatefulWidget {
|
||||
const TencentMapThumb({
|
||||
super.key,
|
||||
required this.api,
|
||||
required this.lat,
|
||||
required this.lng,
|
||||
this.height = 110,
|
||||
this.width,
|
||||
});
|
||||
final OaClient api;
|
||||
final double lat;
|
||||
final double lng;
|
||||
final double height;
|
||||
final double? width;
|
||||
|
||||
@override
|
||||
State<TencentMapThumb> createState() => _TencentMapThumbState();
|
||||
}
|
||||
|
||||
class _TencentMapThumbState extends State<TencentMapThumb> {
|
||||
Uint8List? _bytes;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant TencentMapThumb oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.lat != widget.lat || oldWidget.lng != widget.lng) _load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
final b = await widget.api.getBytes('/office/geo/static-map', query: {
|
||||
'lat': '${widget.lat}',
|
||||
'lng': '${widget.lng}',
|
||||
});
|
||||
if (!mounted) return;
|
||||
setState(() => _bytes = b);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_bytes != null) {
|
||||
return Image.memory(_bytes!, width: widget.width, height: widget.height, fit: BoxFit.cover);
|
||||
}
|
||||
return SizedBox(
|
||||
width: widget.width,
|
||||
height: widget.height,
|
||||
child: const ColoredBox(
|
||||
color: Color(0xFFE8F5E9),
|
||||
child: Center(child: Icon(Icons.place, color: kWeGreen, size: 32)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../theme.dart';
|
||||
|
||||
/// 微信式语音消息:气泡 + 下方独立白底转写框。
|
||||
class VoiceMessageBlock extends StatelessWidget {
|
||||
const VoiceMessageBlock({
|
||||
super.key,
|
||||
required this.mine,
|
||||
required this.seconds,
|
||||
required this.playing,
|
||||
this.transcript,
|
||||
this.loading = false,
|
||||
this.onTap,
|
||||
this.onLongPress,
|
||||
this.bubbleColor,
|
||||
this.iconColor,
|
||||
this.readLabel,
|
||||
this.bottomMargin = 8,
|
||||
});
|
||||
|
||||
final bool mine;
|
||||
final int seconds;
|
||||
final bool playing;
|
||||
final String? transcript;
|
||||
final bool loading;
|
||||
final VoidCallback? onTap;
|
||||
final VoidCallback? onLongPress;
|
||||
final Color? bubbleColor;
|
||||
final Color? iconColor;
|
||||
final String? readLabel;
|
||||
final double bottomMargin;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bubble = mine ? (bubbleColor ?? kBubbleMine) : Colors.white;
|
||||
final ink = iconColor ?? kInk;
|
||||
final width = (80 + seconds * 4).clamp(80, 180).toDouble();
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: mine ? CrossAxisAlignment.end : CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: onTap,
|
||||
onLongPress: onLongPress,
|
||||
child: Container(
|
||||
margin: EdgeInsets.only(bottom: bottomMargin),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9),
|
||||
constraints: BoxConstraints(maxWidth: 280, minWidth: width),
|
||||
decoration: BoxDecoration(
|
||||
color: bubble,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment:
|
||||
mine ? CrossAxisAlignment.end : CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: width,
|
||||
child: Row(
|
||||
mainAxisAlignment:
|
||||
mine ? MainAxisAlignment.end : MainAxisAlignment.start,
|
||||
children: [
|
||||
if (!mine) ...[
|
||||
Icon(
|
||||
playing
|
||||
? Icons.stop_circle_outlined
|
||||
: Icons.volume_up_outlined,
|
||||
size: 20,
|
||||
color: ink,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text('$seconds″',
|
||||
style: TextStyle(fontSize: 16, color: ink)),
|
||||
] else ...[
|
||||
Text('$seconds″',
|
||||
style: TextStyle(fontSize: 16, color: ink)),
|
||||
const SizedBox(width: 6),
|
||||
Icon(
|
||||
playing
|
||||
? Icons.stop_circle_outlined
|
||||
: Icons.volume_up_outlined,
|
||||
size: 20,
|
||||
color: ink,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
if (readLabel != null)
|
||||
Text(readLabel!,
|
||||
style: const TextStyle(fontSize: 10, color: kMute)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (loading) _transcriptBox(const Text('转文字中…', style: TextStyle(fontSize: 14, color: kMute)))
|
||||
else if (transcript != null && transcript!.isNotEmpty)
|
||||
_transcriptBox(Text(
|
||||
transcript!,
|
||||
style: const TextStyle(fontSize: 15, color: kInk, height: 1.35),
|
||||
)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _transcriptBox(Widget child) {
|
||||
return Container(
|
||||
margin: EdgeInsets.only(bottom: bottomMargin),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
||||
constraints: const BoxConstraints(maxWidth: 280),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(color: const Color(0xFFE5E5E5)),
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,540 @@
|
||||
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();
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user