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 createState() => _HomeShellState(); } class _HomeShellState extends State { int _tab = 0; int _msgBadge = 0; bool _otaOnce = false; Timer? _badgeDebounce; Timer? _callPoll; String _ringingId = ''; bool _checkingCall = false; final _declinedCalls = {}; late List _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 _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? 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 _showIncoming(Map 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 _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 _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 _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( 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 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 _openIncomingCallFromPush(Map extras) async { final callId = (extras['callId'] ?? '').isNotEmpty ? extras['callId']! : (extras['bizId'] ?? ''); try { Map? call; if (callId.isNotEmpty) { final raw = await widget.api.get('/im/calls/$callId'); if (raw is Map) { final row = Map.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 extras) => extras['conversationId'] ?? ''; Future _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, ), ); } }