76f266645d
含 Phase 1.1 IM seq 排序、断线重连、多端已读同步与微信式语音转文字 UI。 排除 node_modules、构建产物、安装包与 .env 密钥。 Co-authored-by: Cursor <cursoragent@cursor.com>
406 lines
13 KiB
Dart
406 lines
13 KiB
Dart
import 'dart:async';
|
|
import 'dart:io';
|
|
|
|
import 'package:flutter/material.dart';
|
|
|
|
import '../api/oa_client.dart';
|
|
import '../device/push_bridge.dart';
|
|
import '../ota/updater.dart';
|
|
import '../nav/notice_route.dart';
|
|
import '../pages/call_page.dart';
|
|
import '../pages/attendance_page.dart';
|
|
import '../pages/chat_page.dart';
|
|
import '../pages/directory_page.dart';
|
|
import '../pages/messages_page.dart';
|
|
import '../pages/mine_page.dart';
|
|
import '../pages/scan_login_page.dart';
|
|
import '../pages/system_notice_page.dart';
|
|
import '../pages/workbench_page.dart';
|
|
import '../session/session.dart';
|
|
import '../im/im_lifecycle.dart';
|
|
import '../desktop/desk_platform.dart';
|
|
import '../desktop/desk_shell.dart';
|
|
import '../theme.dart';
|
|
import '../widgets/wecom.dart';
|
|
|
|
class HomeShell extends StatefulWidget {
|
|
const HomeShell({super.key, required this.session, required this.api});
|
|
final SessionStore session;
|
|
final OaClient api;
|
|
|
|
@override
|
|
State<HomeShell> createState() => _HomeShellState();
|
|
}
|
|
|
|
class _HomeShellState extends State<HomeShell> {
|
|
int _tab = 0;
|
|
int _msgBadge = 0;
|
|
bool _otaOnce = false;
|
|
Timer? _badgeDebounce;
|
|
Timer? _callPoll;
|
|
String _ringingId = '';
|
|
bool _checkingCall = false;
|
|
final _declinedCalls = <String>{};
|
|
late List<Widget> _pages;
|
|
ImLifecycle? _imLife;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_imLife = ImLifecycle(widget.session)..start();
|
|
widget.session.im.onLoggedIn = () {
|
|
_refreshBadge();
|
|
};
|
|
_pages = [
|
|
MessagesPage(session: widget.session, api: widget.api),
|
|
WorkbenchPage(session: widget.session, api: widget.api),
|
|
AttendancePage(api: widget.api),
|
|
DirectoryPage(session: widget.session, api: widget.api, embedded: true),
|
|
MinePage(session: widget.session, api: widget.api),
|
|
];
|
|
widget.session.im.addListener(_onIm);
|
|
_refreshBadge();
|
|
PushBridge.listen(_openFromPush);
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
_checkOta();
|
|
_bootPush();
|
|
_checkIncoming();
|
|
});
|
|
_callPoll =
|
|
Timer.periodic(const Duration(seconds: 8), (_) => _checkIncoming());
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_badgeDebounce?.cancel();
|
|
_callPoll?.cancel();
|
|
widget.session.im.onLoggedIn = null;
|
|
_imLife?.stop();
|
|
widget.session.im.removeListener(_onIm);
|
|
PushBridge.stop();
|
|
super.dispose();
|
|
}
|
|
|
|
void _onIm() {
|
|
final inbox = widget.session.im.inbox;
|
|
if (inbox.isNotEmpty) {
|
|
final last = inbox.first;
|
|
if (last.kind == 'readSync') {
|
|
_refreshBadge();
|
|
return;
|
|
}
|
|
}
|
|
_badgeDebounce?.cancel();
|
|
_badgeDebounce = Timer(const Duration(milliseconds: 1200), _refreshBadge);
|
|
final hit = widget.session.im.inbox.where((e) => e.kind == 'call').toList();
|
|
if (hit.isNotEmpty) _checkIncoming();
|
|
}
|
|
|
|
Future<void> _checkIncoming() async {
|
|
if (!mounted || _ringingId.isNotEmpty || _checkingCall) return;
|
|
_checkingCall = true;
|
|
try {
|
|
final rows = asMaps(await widget.api.get('/im/calls/incoming'));
|
|
final me = widget.session.userId;
|
|
Map<String, dynamic>? ring;
|
|
for (final r in rows) {
|
|
if ('${r['initiatorId']}' != me && '${r['status']}' == 'ringing') {
|
|
ring = r;
|
|
break;
|
|
}
|
|
}
|
|
if (ring == null) return;
|
|
final id = '${ring['id'] ?? ''}';
|
|
if (id.isEmpty || id == _ringingId || _declinedCalls.contains(id)) return;
|
|
final created = DateTime.tryParse('${ring['createdAt'] ?? ''}');
|
|
if (created != null &&
|
|
DateTime.now().difference(created).inSeconds > 45) {
|
|
_declinedCalls.add(id);
|
|
return;
|
|
}
|
|
await _showIncoming(ring);
|
|
} catch (_) {
|
|
} finally {
|
|
_checkingCall = false;
|
|
}
|
|
}
|
|
|
|
Future<void> _showIncoming(Map<String, dynamic> call,
|
|
{String fallbackName = '同事'}) async {
|
|
final id = '${call['id'] ?? ''}';
|
|
if (!mounted ||
|
|
id.isEmpty ||
|
|
_ringingId.isNotEmpty ||
|
|
_declinedCalls.contains(id)) return;
|
|
_ringingId = id;
|
|
await openCallPage(
|
|
context,
|
|
session: widget.session,
|
|
api: widget.api,
|
|
call: call,
|
|
peerName: '${call['fromName'] ?? call['title'] ?? fallbackName}',
|
|
incoming: true,
|
|
);
|
|
_declinedCalls.add(id);
|
|
if (_declinedCalls.length > 40) _declinedCalls.remove(_declinedCalls.first);
|
|
if (mounted) _ringingId = '';
|
|
}
|
|
|
|
Future<void> _refreshBadge() async {
|
|
var n = 0;
|
|
try {
|
|
final data = await widget.api.get('/im/conversations');
|
|
for (final r in asMaps(data)) {
|
|
n += (r['unread'] as num?)?.toInt() ?? 0;
|
|
}
|
|
} catch (_) {}
|
|
if (mounted && n != _msgBadge) setState(() => _msgBadge = n);
|
|
}
|
|
|
|
Future<void> _bootPush() async {
|
|
if (Platform.isWindows) return;
|
|
await PushBridge.requestFirstLoginPermissions();
|
|
final launch = await PushBridge.consumeLaunch();
|
|
if (launch != null && mounted) _openFromPush(launch);
|
|
// 通知点击先进入来电页,厂商 token 注册放到后台,不能阻塞接听。
|
|
unawaited(PushBridge.register(widget.api));
|
|
await _promptOem();
|
|
}
|
|
|
|
Future<void> _promptOem() async {
|
|
final st = await PushBridge.oemStatus();
|
|
if (st == null || !mounted) return;
|
|
if (st['prompted'] == true) return;
|
|
await PushBridge.requestBattery();
|
|
final title = '${st['title'] ?? '后台运行与通知'}';
|
|
final hint = '${st['hint'] ?? '请允许通知和后台运行,以便及时收到消息。'}';
|
|
final go = await showDialog<bool>(
|
|
context: context,
|
|
builder: (ctx) => AlertDialog(
|
|
title: Text(title),
|
|
content: Text(hint),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(ctx, false),
|
|
child: const Text('稍后')),
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(ctx, true),
|
|
child: const Text('去设置')),
|
|
],
|
|
),
|
|
);
|
|
await PushBridge.markOemPrompted();
|
|
if (go == true) {
|
|
await PushBridge.requestBattery();
|
|
await PushBridge.openOemKeepAlive();
|
|
}
|
|
}
|
|
|
|
String _openedPush = '';
|
|
|
|
void _openFromPush(Map<String, String> extras) {
|
|
if (extras['kind'] == 'qr-login') {
|
|
final ticket = extras['ticket'] ?? '';
|
|
if (ticket.isEmpty) return;
|
|
Navigator.of(context).push(
|
|
MaterialPageRoute(
|
|
builder: (_) => ScanLoginPage(api: widget.api, ticket: ticket),
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
final cid = extras['conversationId'] ?? '';
|
|
final kind = extras['kind'] ?? '';
|
|
final key = '$cid|$kind|${extras['callId'] ?? extras['bizId'] ?? ''}';
|
|
if (key == _openedPush) return;
|
|
_openedPush = key;
|
|
if (kind == 'call') {
|
|
unawaited(_openIncomingCallFromPush(extras));
|
|
return;
|
|
}
|
|
if (kind == 'todo' ||
|
|
kind == 'approval' ||
|
|
((extras['bizType'] ?? '').isNotEmpty && kind != 'chat')) {
|
|
openNoticeTarget(
|
|
context,
|
|
widget.api,
|
|
session: widget.session,
|
|
kind: kind,
|
|
bizType: extras['bizType'] ?? '',
|
|
bizId: extras['bizId'] ?? '',
|
|
title: extras['title'] ?? '系统通知',
|
|
);
|
|
return;
|
|
}
|
|
if (cid.isEmpty) {
|
|
if (mounted) setState(() => _tab = 0);
|
|
return;
|
|
}
|
|
Navigator.of(context).push(
|
|
MaterialPageRoute(
|
|
builder: (_) => extras['peerId'] == '0'
|
|
? SystemNoticePage(
|
|
session: widget.session, api: widget.api, conversationId: cid)
|
|
: ChatPage(
|
|
session: widget.session,
|
|
api: widget.api,
|
|
conversationId: cid,
|
|
peerName: extras['fromName'] ?? extras['title'] ?? '聊天'),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _openIncomingCallFromPush(Map<String, String> extras) async {
|
|
final callId = (extras['callId'] ?? '').isNotEmpty
|
|
? extras['callId']!
|
|
: (extras['bizId'] ?? '');
|
|
try {
|
|
Map<String, dynamic>? call;
|
|
if (callId.isNotEmpty) {
|
|
final raw = await widget.api.get('/im/calls/$callId');
|
|
if (raw is Map) {
|
|
final row = Map<String, dynamic>.from(raw);
|
|
if ('${row['status'] ?? ''}' == 'ringing' &&
|
|
'${row['memberStatus'] ?? ''}' == 'invited') {
|
|
call = row;
|
|
}
|
|
}
|
|
} else {
|
|
final rows = asMaps(await widget.api.get('/im/calls/incoming'));
|
|
for (final row in rows) {
|
|
if ('${row['status'] ?? ''}' == 'ringing') {
|
|
call = row;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
if (call != null && mounted) {
|
|
call['fromName'] ??= extras['fromName'];
|
|
await _showIncoming(call, fallbackName: extras['fromName'] ?? '同事');
|
|
return;
|
|
}
|
|
} catch (_) {}
|
|
// 通话已被接听、拒绝或超时,点击历史通知只回到对应聊天,不再显示错误的接听页。
|
|
if (mounted && cidOrEmpty(extras).isNotEmpty) {
|
|
Navigator.of(context).push(MaterialPageRoute(
|
|
builder: (_) => ChatPage(
|
|
session: widget.session,
|
|
api: widget.api,
|
|
conversationId: cidOrEmpty(extras),
|
|
peerName: extras['fromName'] ?? extras['title'] ?? '聊天',
|
|
),
|
|
));
|
|
}
|
|
}
|
|
|
|
String cidOrEmpty(Map<String, String> extras) =>
|
|
extras['conversationId'] ?? '';
|
|
|
|
Future<void> _checkOta() async {
|
|
if (_otaOnce) return;
|
|
_otaOnce = true;
|
|
if (isDesktopPlatform) return;
|
|
final rel = await OtaUpdater(widget.api).fetch();
|
|
if (rel != null && rel.newer && mounted) {
|
|
await OtaUpdater(widget.api).prompt(context, rel);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
if (isDesktopPlatform) {
|
|
return DeskShell(session: widget.session, api: widget.api);
|
|
}
|
|
final wide = MediaQuery.sizeOf(context).width >= 960;
|
|
final barItems = <(IconData, IconData, String, int)>[
|
|
(Icons.chat_bubble_outline, Icons.chat_bubble, '消息', _msgBadge),
|
|
(Icons.apps_outlined, Icons.apps, '工作台', 0),
|
|
(Icons.location_on_outlined, Icons.location_on, '打卡', 0),
|
|
(Icons.account_tree_outlined, Icons.account_tree, '通讯录', 0),
|
|
(Icons.person_outline, Icons.person, '我', 0),
|
|
];
|
|
final body = IndexedStack(index: _tab, children: _pages);
|
|
if (wide) {
|
|
return Scaffold(
|
|
backgroundColor: kPaper,
|
|
body: Row(
|
|
children: [
|
|
ColoredBox(
|
|
color: const Color(0xFFF7F7F7),
|
|
child: SafeArea(
|
|
child: SizedBox(
|
|
width: 88,
|
|
child: Column(
|
|
children: [
|
|
const SizedBox(height: 16),
|
|
SquareAvatar(
|
|
label: widget.session.displayName,
|
|
size: 48,
|
|
fileId: '${widget.session.user['avatarFileId'] ?? ''}',
|
|
api: widget.api,
|
|
),
|
|
const SizedBox(height: 20),
|
|
for (var i = 0; i < barItems.length; i++)
|
|
InkWell(
|
|
onTap: () => setState(() {
|
|
_tab = i;
|
|
if (i == 0) widget.session.im.bump();
|
|
}),
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
|
child: Column(
|
|
children: [
|
|
Stack(
|
|
clipBehavior: Clip.none,
|
|
children: [
|
|
Icon(
|
|
_tab == i
|
|
? barItems[i].$2
|
|
: barItems[i].$1,
|
|
color: _tab == i
|
|
? kBind
|
|
: const Color(0xFF646464)),
|
|
if (barItems[i].$4 > 0)
|
|
Positioned(
|
|
right: -10,
|
|
top: -4,
|
|
child: UnreadBadge(barItems[i].$4)),
|
|
],
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(barItems[i].$3,
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
color: _tab == i
|
|
? kBind
|
|
: const Color(0xFF646464))),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
const VerticalDivider(width: 1, color: kLine),
|
|
Expanded(child: body),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
return Scaffold(
|
|
backgroundColor: kPaper,
|
|
body: body,
|
|
bottomNavigationBar: WxTabBar(
|
|
index: _tab,
|
|
onChanged: (i) {
|
|
setState(() => _tab = i);
|
|
if (i == 0) widget.session.im.bump();
|
|
},
|
|
items: barItems,
|
|
),
|
|
);
|
|
}
|
|
}
|