diff --git a/mobile/README.md b/mobile/README.md new file mode 100644 index 0000000..6705ef1 --- /dev/null +++ b/mobile/README.md @@ -0,0 +1,86 @@ +# 畅联 · 手机端(Flutter) + +公司内部通讯 App 的手机端源码。骨架配色走企业微信式办公风(主色蓝 `#3B87F5`),聊天手感照微信。本目录只含 Dart 源码与 `pubspec.yaml`,不含 android/ios 平台脚手架。 + +## 生成平台脚手架 + +本仓库不带 `android/`、`ios/` 目录。在 `mobile/` 目录下执行一次即可生成: + +```bash +cd mobile +flutter create --platforms=android,ios . +flutter pub get +flutter run +``` + +生成后还需要按需补充平台权限声明(Flutter 不会自动加): + +- `android/app/src/main/AndroidManifest.xml`:录音(`RECORD_AUDIO`)、网络(`INTERNET`,默认有)、读写外部存储(发文件用,按 targetSdk 版本适配)。 +- `ios/Runner/Info.plist`:`NSMicrophoneUsageDescription`(语音消息与通话)、`NSPhotoLibraryUsageDescription`(发图片)。 + +## 修改服务器地址 + +所有服务器地址集中在 `lib/config.dart`,改 `serverHost`(内网 IP/域名)和对应端口常量即可: + +| 常量 | 用途 | 默认值 | +| --- | --- | --- | +| `apiAddr` | OpenIM API | `http://192.168.200.11:10002` | +| `wsAddr` | OpenIM WebSocket | `ws://192.168.200.11:10001` | +| `livekitUrl` | LiveKit 语音通话 | `ws://192.168.200.11:17880`(注意已重映射,非默认 7880) | +| `authApiBase` | 公司账号登录后端 | `http://192.168.200.11:10010` | + +## 登录接口契约(后端:任务 B-60 account-service,已上线) + +### 1. 账号登录 + +``` +POST {authApiBase}/api/login +Content-Type: application/json + +{"staffNo": "工号", "password": "...", "platformID": 1} +``` + +- `platformID`:1 = iOS,2 = Android。 +- 响应统一包络 `{"code": 0, "msg": "", "data": {...}}`(业务失败也是 HTTP 200 + `code != 0`,HTTP 401/400 时同样带 `msg`)。 +- 成功 `data`:`{"userID": "...", "nickname": "...", "imToken": "...", "expireTimeSeconds": ...}` + - `userID` / `imToken` 直接传给 OpenIM SDK 的 `login`(OpenIM 的登录 token 由后端用 OpenIM 管理端接口换取)。 +- 失败:`msg` 会原样展示给用户。 + +App 登录流程:`AuthApi.login` → 拿到 userID/imToken → `IMService.login`(SDK `initSDK` 在 App 启动时已执行一次)→ 进主页。userID/imToken 存 `shared_preferences`,下次启动自动登录;SDK 回调 `onUserTokenExpired` / `onKickedOffline` 时清凭证回登录页。 + +### 2. 语音通话 token + +官方样板工程的 LiveKit token 取自 open-im-chat 业务服务端(`/user/rtc/get_token`,端口 10008),本项目服务端裁剪时移除了 open-im-chat,由 account-service 提供等价接口: + +``` +POST {authApiBase}/api/rtc_token +Authorization: Bearer {登录返回的 imToken} +Content-Type: application/json + +{"room": "房间号", "identity": "当前用户 userID"} +``` + +- 成功 `data`:`{"token": "LiveKit 访问 token"}`(App 使用 `config.dart` 中的 `livekitUrl` 作为连接地址;后端用与 livekit 容器同一对 `LIVEKIT_API_KEY` / `LIVEKIT_API_SECRET` 签发 HS256 JWT)。 +- 后端会先拿 `Authorization` 里的 IM token 调 OpenIM `parse_token` 校验身份,且要求 `identity` 与 token 对应的 userID 一致。 +- 通话信令(呼叫/接听/拒绝/取消/挂断)走 OpenIM 自定义消息(customType 200-204 的仅在线消息),与官方样板工程协议一致,不需要后端参与。 + +## 代码结构 + +``` +lib/ + main.dart 入口:主题、路由、启动页(自动登录)、被踢下线/来电处理 + config.dart 服务器地址集中配置 + theme.dart 颜色、字号阶梯(4 级)、间距常量(4 的倍数) + models/signaling.dart 通话信令数据模型(与官方样板一致) + utils/format.dart 时间格式化、文件大小、会话预览文案 + services/ + im_service.dart OpenIM SDK 封装:init/login/监听/会话列表(ChangeNotifier) + auth_api.dart 公司账号登录 + LiveKit token 的 HTTP 客户端(dio) + call_service.dart 一对一语音通话:信令 + LiveKit 房间 + screens/ 登录、主页三 Tab(消息/通讯录/我的)、聊天、通话等页面 + widgets/ 头像、消息气泡、输入栏、四态视图 +``` + +## 测试账号 + +员工账号由管理员在 account-service 管理页批量导入(工号 + 姓名 + 初始密码),App 端不开放自助注册。联调时请管理员先导入测试名单。 diff --git a/mobile/lib/config.dart b/mobile/lib/config.dart new file mode 100644 index 0000000..2b1e2b4 --- /dev/null +++ b/mobile/lib/config.dart @@ -0,0 +1,33 @@ +/// 服务器地址集中配置。 +/// +/// 服务器换了地址只需要改这一个文件: +/// 1. 改下面的 [serverHost] 为新的服务器内网 IP(或域名); +/// 2. 如果端口也被重新映射,改对应端口常量即可。 +library; + +/// 服务器内网地址(不带协议、不带端口) +const String serverHost = '192.168.200.11'; + +/// OpenIM API 端口(服务端 docker 映射,默认 10002) +const int apiPort = 10002; + +/// OpenIM WebSocket 端口(默认 10001) +const int wsPort = 10001; + +/// LiveKit 信令端口(注意:服务端已重映射为 17880,不是默认的 7880) +const int livekitPort = 17880; + +/// 公司账号登录后端端口(B-60 account-server,docker 默认映射 10010) +const int authApiPort = 10010; + +/// OpenIM API 地址,例:http://192.168.200.11:10002 +const String apiAddr = 'http://$serverHost:$apiPort'; + +/// OpenIM 消息长连接地址,例:ws://192.168.200.11:10001 +const String wsAddr = 'ws://$serverHost:$wsPort'; + +/// LiveKit 连接地址,例:ws://192.168.200.11:17880 +const String livekitUrl = 'ws://$serverHost:$livekitPort'; + +/// 公司账号登录后端地址,例:http://192.168.200.11:10010 +const String authApiBase = 'http://$serverHost:$authApiPort'; diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart new file mode 100644 index 0000000..e8d1fa5 --- /dev/null +++ b/mobile/lib/main.dart @@ -0,0 +1,113 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; + +import 'nav.dart'; +import 'screens/call_screen.dart'; +import 'screens/home_screen.dart'; +import 'screens/login_screen.dart'; +import 'services/call_service.dart'; +import 'services/im_service.dart'; +import 'theme.dart'; + +void main() { + WidgetsFlutterBinding.ensureInitialized(); + runApp(const ChanglianApp()); +} + +class ChanglianApp extends StatefulWidget { + const ChanglianApp({super.key}); + + @override + State createState() => _ChanglianAppState(); +} + +class _ChanglianAppState extends State { + @override + void initState() { + super.initState(); + // 被踢下线 / token 失效:清凭证、回登录页 + IMService.instance.onForceLogout = () async { + await LoginScreen.clearCredential(); + navigatorKey.currentState?.pushAndRemoveUntil( + MaterialPageRoute(builder: (_) => const LoginScreen()), + (route) => false, + ); + }; + // 来电时弹出通话界面 + CallService.instance.onIncomingCall = () { + navigatorKey.currentState?.push( + MaterialPageRoute(builder: (_) => const CallScreen(), fullscreenDialog: true), + ); + }; + } + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: '畅联', + navigatorKey: navigatorKey, + theme: buildAppTheme(), + debugShowCheckedModeBanner: false, + localizationsDelegates: const [ + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: const [Locale('zh', 'CN')], + locale: const Locale('zh', 'CN'), + home: const SplashGate(), + ); + } +} + +/// 启动页:初始化 SDK,有本地凭证则自动登录进主页,否则进登录页 +class SplashGate extends StatefulWidget { + const SplashGate({super.key}); + + @override + State createState() => _SplashGateState(); +} + +class _SplashGateState extends State { + @override + void initState() { + super.initState(); + _boot(); + } + + Future _boot() async { + final im = IMService.instance; + try { + await im.init(); + final credential = await LoginScreen.readCredential(); + if (credential != null) { + await im.login(userID: credential.$1, token: credential.$2); + CallService.instance.start(); + if (!mounted) return; + Navigator.of(context).pushReplacement(MaterialPageRoute(builder: (_) => const HomeScreen())); + return; + } + } catch (_) { + // 自动登录失败(token 失效等):清凭证回登录页 + await LoginScreen.clearCredential(); + } + if (!mounted) return; + Navigator.of(context).pushReplacement(MaterialPageRoute(builder: (_) => const LoginScreen())); + } + + @override + Widget build(BuildContext context) { + return const Scaffold( + body: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text('畅联', style: TextStyle(fontSize: 32, fontWeight: FontWeight.w600, color: AppColors.primary)), + SizedBox(height: AppGap.x6), + SizedBox(width: 28, height: 28, child: CircularProgressIndicator(strokeWidth: 3, color: AppColors.primary)), + ], + ), + ), + ); + } +} diff --git a/mobile/lib/models/signaling.dart b/mobile/lib/models/signaling.dart new file mode 100644 index 0000000..4c94649 --- /dev/null +++ b/mobile/lib/models/signaling.dart @@ -0,0 +1,69 @@ +/// 通话信令的数据模型。 +/// 与官方样板工程 openim_common 的 InvitationInfo 字段保持一致, +/// 保证和 PC 端 / 其他端互通。 +class InvitationInfo { + /// 邀请者 userID + String? inviterUserID; + + /// 被邀请者 userID 列表,单聊只有一个元素 + List? inviteeUserIDList; + + /// 群聊时为群 ID,单聊为空 + String? groupID; + + /// 房间 ID,必须唯一 + String? roomID; + + /// 邀请超时时间(秒) + int? timeout; + + /// 发起时间 + int? initiateTime; + + /// video 或者 audio(本项目只做 audio) + String? mediaType; + + /// 会话类型:1 为单聊 + int? sessionType; + + /// 发起方平台 + int? platformID; + + InvitationInfo({ + this.inviterUserID, + this.inviteeUserIDList, + this.groupID, + this.roomID, + this.timeout, + this.initiateTime, + this.mediaType, + this.sessionType, + this.platformID, + }); + + InvitationInfo.fromJson(Map json) { + inviterUserID = json['inviterUserID']; + inviteeUserIDList = json['inviteeUserIDList']?.cast(); + groupID = json['groupID']; + roomID = json['roomID']; + timeout = json['timeout']; + initiateTime = json['initiateTime']; + mediaType = json['mediaType']; + sessionType = json['sessionType']; + platformID = json['platformID']; + } + + Map toJson() { + final data = {}; + data['inviterUserID'] = inviterUserID; + data['inviteeUserIDList'] = inviteeUserIDList; + data['groupID'] = groupID; + data['roomID'] = roomID; + data['timeout'] = timeout; + data['initiateTime'] = initiateTime; + data['mediaType'] = mediaType; + data['sessionType'] = sessionType; + data['platformID'] = platformID; + return data; + } +} diff --git a/mobile/lib/nav.dart b/mobile/lib/nav.dart new file mode 100644 index 0000000..90abe81 --- /dev/null +++ b/mobile/lib/nav.dart @@ -0,0 +1,4 @@ +import 'package:flutter/material.dart'; + +/// 全局导航键:被踢下线回登录页、来电弹通话界面、通话结束提示都要用它 +final GlobalKey navigatorKey = GlobalKey(); diff --git a/mobile/lib/screens/add_friend_screen.dart b/mobile/lib/screens/add_friend_screen.dart new file mode 100644 index 0000000..3eba499 --- /dev/null +++ b/mobile/lib/screens/add_friend_screen.dart @@ -0,0 +1,88 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_openim_sdk/flutter_openim_sdk.dart'; + +import '../theme.dart'; + +/// 添加同事:输入对方工号(userID)发送好友申请 +class AddFriendScreen extends StatefulWidget { + const AddFriendScreen({super.key}); + + @override + State createState() => _AddFriendScreenState(); +} + +class _AddFriendScreenState extends State { + final TextEditingController _idCtrl = TextEditingController(); + bool _sending = false; + + @override + void dispose() { + _idCtrl.dispose(); + super.dispose(); + } + + Future _send() async { + final userID = _idCtrl.text.trim(); + if (userID.isEmpty) { + _toast('请输入对方工号'); + return; + } + setState(() => _sending = true); + try { + await OpenIM.iMManager.friendshipManager.addFriend(userID: userID, reason: '你好,我想加你为同事'); + if (!mounted) return; + _toast('申请已发送,等对方通过'); + Navigator.of(context).pop(); + } catch (_) { + _toast('发送失败,请确认工号正确、网络正常'); + } finally { + if (mounted) setState(() => _sending = false); + } + } + + void _toast(String text) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(text), duration: const Duration(seconds: 2))); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('添加同事')), + body: Padding( + padding: const EdgeInsets.all(AppGap.x4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextField( + controller: _idCtrl, + decoration: InputDecoration( + hintText: '请输入对方工号', + hintStyle: const TextStyle(color: AppColors.textSecondary), + filled: true, + fillColor: AppColors.searchBg, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide.none, + ), + ), + ), + const SizedBox(height: AppGap.x6), + SizedBox( + height: 48, + child: FilledButton( + onPressed: _sending ? null : _send, + style: FilledButton.styleFrom( + backgroundColor: AppColors.primary, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + child: _sending + ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)) + : const Text('发送申请', style: TextStyle(fontSize: AppFont.body)), + ), + ), + ], + ), + ), + ); + } +} diff --git a/mobile/lib/screens/call_screen.dart b/mobile/lib/screens/call_screen.dart new file mode 100644 index 0000000..1549373 --- /dev/null +++ b/mobile/lib/screens/call_screen.dart @@ -0,0 +1,185 @@ +import 'package:flutter/material.dart'; + +import '../nav.dart'; +import '../services/call_service.dart'; +import '../theme.dart'; +import '../utils/format.dart'; +import '../widgets/avatar.dart'; + +/// 一对一语音通话界面:呼出 / 来电 / 通话中三种状态。 +/// 界面只读 CallService 的状态,信令与房间管理都在 service 里。 +class CallScreen extends StatefulWidget { + const CallScreen({super.key}); + + @override + State createState() => _CallScreenState(); +} + +class _CallScreenState extends State { + CallService get _call => CallService.instance; + + @override + void initState() { + super.initState(); + // 通话状态变化时刷新界面;回到空闲态时关闭页面 + _call.addListener(_onCallChanged); + } + + @override + void dispose() { + _call.removeListener(_onCallChanged); + super.dispose(); + } + + void _onCallChanged() { + if (!mounted) return; + if (_call.phase == CallPhase.idle) { + final hint = _call.takeEndHint(); + Navigator.of(context).maybePop(); + if (hint != null) { + // 页面关闭后,用全局导航键把提示弹到底层页面上 + WidgetsBinding.instance.addPostFrameCallback((_) { + final ctx = navigatorKey.currentContext; + if (ctx != null) { + ScaffoldMessenger.maybeOf(ctx)?.showSnackBar( + SnackBar(content: Text(hint), duration: const Duration(seconds: 2)), + ); + } + }); + } + return; + } + setState(() {}); + } + + @override + Widget build(BuildContext context) { + final phase = _call.phase; + return Scaffold( + backgroundColor: const Color(0xFF2B3038), + body: SafeArea( + child: Column( + children: [ + const SizedBox(height: AppGap.x12), + NameAvatar(name: _call.peerName, size: 88), + const SizedBox(height: AppGap.x4), + Text( + _call.peerName, + style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w600, color: Colors.white), + ), + const SizedBox(height: AppGap.x2), + Text( + _statusText(phase), + style: const TextStyle(fontSize: AppFont.sub, color: Color(0xFFB0B6BF)), + ), + const Spacer(), + _buttons(phase), + const SizedBox(height: AppGap.x12), + ], + ), + ), + ); + } + + String _statusText(CallPhase phase) { + switch (phase) { + case CallPhase.outgoing: + return '正在呼叫,等待对方接听…'; + case CallPhase.incoming: + return '邀请你语音通话'; + case CallPhase.incall: + return FormatUtils.callDuration(_call.callSeconds); + case CallPhase.idle: + return '通话已结束'; + } + } + + Widget _buttons(CallPhase phase) { + switch (phase) { + case CallPhase.outgoing: + return _roundButton(icon: Icons.call_end, color: AppColors.danger, label: '取消', onTap: _call.leave); + case CallPhase.incoming: + return Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + _roundButton(icon: Icons.call_end, color: AppColors.danger, label: '拒绝', onTap: _call.reject), + _roundButton(icon: Icons.call, color: const Color(0xFF2BA245), label: '接听', onTap: _call.accept), + ], + ); + case CallPhase.incall: + return Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + _toggleButton( + icon: _call.micOn ? Icons.mic : Icons.mic_off, + label: _call.micOn ? '静音' : '已静音', + active: !_call.micOn, + onTap: _call.toggleMic, + ), + _roundButton(icon: Icons.call_end, color: AppColors.danger, label: '挂断', onTap: _call.hangup), + _toggleButton( + icon: Icons.volume_up, + label: _call.speakerOn ? '免提开' : '免提', + active: _call.speakerOn, + onTap: _call.toggleSpeaker, + ), + ], + ); + case CallPhase.idle: + return const SizedBox.shrink(); + } + } + + Widget _roundButton({ + required IconData icon, + required Color color, + required String label, + required VoidCallback onTap, + }) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(32), + child: Container( + width: 64, + height: 64, + decoration: BoxDecoration(color: color, shape: BoxShape.circle), + child: Icon(icon, size: 30, color: Colors.white), + ), + ), + const SizedBox(height: AppGap.x2), + Text(label, style: const TextStyle(fontSize: AppFont.sub, color: Color(0xFFB0B6BF))), + ], + ); + } + + Widget _toggleButton({ + required IconData icon, + required String label, + required bool active, + required VoidCallback onTap, + }) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(32), + child: Container( + width: 64, + height: 64, + decoration: BoxDecoration( + color: active ? Colors.white : const Color(0xFF3A4048), + shape: BoxShape.circle, + ), + child: Icon(icon, size: 28, color: active ? AppColors.textPrimary : Colors.white), + ), + ), + const SizedBox(height: AppGap.x2), + Text(label, style: const TextStyle(fontSize: AppFont.sub, color: Color(0xFFB0B6BF))), + ], + ); + } +} diff --git a/mobile/lib/screens/chat_screen.dart b/mobile/lib/screens/chat_screen.dart new file mode 100644 index 0000000..3711537 --- /dev/null +++ b/mobile/lib/screens/chat_screen.dart @@ -0,0 +1,414 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:dio/dio.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_openim_sdk/flutter_openim_sdk.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:just_audio/just_audio.dart'; +import 'package:open_filex/open_filex.dart'; +import 'package:path_provider/path_provider.dart'; + +import '../services/call_service.dart'; +import '../services/im_service.dart'; +import '../theme.dart'; +import '../utils/format.dart'; +import '../widgets/chat_input_bar.dart'; +import '../widgets/message_bubble.dart'; +import 'call_screen.dart'; + +/// 单聊 / 群聊聊天页(效果图 m2) +class ChatScreen extends StatefulWidget { + final ConversationInfo conversation; + + const ChatScreen({super.key, required this.conversation}); + + @override + State createState() => _ChatScreenState(); +} + +class _ChatScreenState extends State { + /// 消息列表,新消息在前(配合 reverse 列表) + final List _messages = []; + final ScrollController _scroll = ScrollController(); + final AudioPlayer _player = AudioPlayer(); + final Dio _dio = Dio(); + + StreamSubscription? _msgSub; + StreamSubscription? _revokeSub; + + bool _loadingHistory = false; + bool _hasMore = true; + String? _playingVoiceId; + + static const int _pageSize = 30; + + /// 相邻消息间隔超过 5 分钟显示时间分隔条 + static const int _dividerGapMillis = 5 * 60 * 1000; + + ConversationInfo get _conv => widget.conversation; + bool get _isGroup => + _conv.conversationType == ConversationType.superGroup || _conv.conversationType == ConversationType.group; + String get _peerID => _conv.userID ?? ''; + String get _groupID => _conv.groupID ?? ''; + String get _title => _conv.showName ?? ''; + + @override + void initState() { + super.initState(); + _loadHistory(first: true); + IMService.instance.markConversationRead(_conv.conversationID); + _msgSub = IMService.instance.onNewMessage.listen(_onNewMessage); + _revokeSub = IMService.instance.onMessageRevoked.listen(_onRevoked); + _scroll.addListener(() { + // 滚动到顶部加载更早的历史消息 + if (_scroll.position.pixels >= _scroll.position.maxScrollExtent - 40) { + _loadHistory(); + } + }); + } + + @override + void dispose() { + _msgSub?.cancel(); + _revokeSub?.cancel(); + _scroll.dispose(); + _player.dispose(); + super.dispose(); + } + + // ---------------- 历史消息 ---------------- + + Future _loadHistory({bool first = false}) async { + if (_loadingHistory || (!_hasMore && !first)) return; + _loadingHistory = true; + try { + final result = await OpenIM.iMManager.messageManager.getAdvancedHistoryMessageList( + conversationID: _conv.conversationID, + count: _pageSize, + startMsg: first ? null : (_messages.isEmpty ? null : _messages.last), + ); + final list = result.messageList ?? []; + if (!mounted) return; + setState(() { + if (first) _messages.clear(); + _messages.addAll(list); + if (list.length < _pageSize || result.isEnd == true) _hasMore = false; + }); + } catch (_) { + if (first && mounted) _toast('消息没加载出来,请检查网络'); + } finally { + _loadingHistory = false; + } + } + + // ---------------- 新消息 / 撤回 ---------------- + + bool _belongsHere(Message msg) { + if (_isGroup) return msg.groupID == _groupID; + final myID = IMService.instance.currentUserID; + // 对方发来的,或自己在别的设备发的 + return (msg.sendID == _peerID && msg.recvID == myID) || (msg.sendID == myID && msg.recvID == _peerID); + } + + void _onNewMessage(Message msg) { + if (!_belongsHere(msg)) return; + if (!mounted) return; + setState(() { + final idx = _messages.indexWhere((m) => m.clientMsgID == msg.clientMsgID); + if (idx >= 0) { + _messages[idx] = msg; + } else { + _messages.insert(0, msg); + } + }); + IMService.instance.markConversationRead(_conv.conversationID); + } + + void _onRevoked(String clientMsgID) { + final idx = _messages.indexWhere((m) => m.clientMsgID == clientMsgID); + if (idx < 0 || !mounted) return; + setState(() => _messages.removeAt(idx)); + _toast('对方撤回了一条消息'); + } + + // ---------------- 发送 ---------------- + + Future _sendMessage(Message message) async { + setState(() { + message.status = MessageStatus.sending; + _messages.insert(0, message); + }); + if (_scroll.hasClients) _scroll.jumpTo(0); + try { + final sent = await OpenIM.iMManager.messageManager.sendMessage( + message: message, + userID: _isGroup ? null : _peerID, + groupID: _isGroup ? _groupID : null, + offlinePushInfo: OfflinePushInfo(), + ); + if (!mounted) return; + setState(() => message.status = sent.status ?? MessageStatus.succeeded); + } catch (_) { + if (!mounted) return; + setState(() => message.status = MessageStatus.failed); + } + } + + /// 点红色感叹号重发 + Future _resend(Message message) async { + setState(() => message.status = MessageStatus.sending); + try { + final sent = await OpenIM.iMManager.messageManager.sendMessage( + message: message, + userID: _isGroup ? null : _peerID, + groupID: _isGroup ? _groupID : null, + offlinePushInfo: OfflinePushInfo(), + ); + if (!mounted) return; + setState(() => message.status = sent.status ?? MessageStatus.succeeded); + } catch (_) { + if (!mounted) return; + setState(() => message.status = MessageStatus.failed); + } + } + + Future _sendText(String text) async { + try { + final message = await OpenIM.iMManager.messageManager.createTextMessage(text: text); + _sendMessage(message); + } catch (_) { + _toast('消息没发出去,请重试'); + } + } + + Future _sendVoice(String path, int duration) async { + try { + final message = await OpenIM.iMManager.messageManager.createSoundMessageFromFullPath( + soundPath: path, + duration: duration, + ); + _sendMessage(message); + } catch (_) { + _toast('语音没发出去,请重试'); + } + } + + Future _pickAndSendImage() async { + try { + final picked = await ImagePicker().pickImage(source: ImageSource.gallery, imageQuality: 80); + if (picked == null) return; + final message = await OpenIM.iMManager.messageManager.createImageMessageFromFullPath(imagePath: picked.path); + _sendMessage(message); + } catch (_) { + _toast('图片没发出去,请重试'); + } + } + + Future _pickAndSendFile() async { + try { + final result = await FilePicker.platform.pickFiles(); + final file = result?.files.single; + if (file == null || file.path == null) return; + final message = await OpenIM.iMManager.messageManager.createFileMessageFromFullPath( + filePath: file.path!, + fileName: file.name, + ); + _sendMessage(message); + } catch (_) { + _toast('文件没发出去,请重试'); + } + } + + // ---------------- 语音播放 / 文件打开 ---------------- + + Future _playVoice(Message message) async { + try { + // 再点一次同一条语音 = 停止 + if (_playingVoiceId == message.clientMsgID && _player.playing) { + await _player.stop(); + if (mounted) setState(() => _playingVoiceId = null); + return; + } + final localPath = message.soundElem?.soundPath; + final url = message.soundElem?.sourceUrl; + if (localPath != null && localPath.isNotEmpty && File(localPath).existsSync()) { + await _player.setFilePath(localPath); + } else if (url != null && url.isNotEmpty) { + await _player.setUrl(url); + } else { + _toast('语音文件找不到了'); + return; + } + if (mounted) setState(() => _playingVoiceId = message.clientMsgID); + // 播放后去掉未读红点 + setState(() => message.isRead = true); + await _player.play(); + if (mounted) setState(() => _playingVoiceId = null); + } catch (_) { + if (mounted) setState(() => _playingVoiceId = null); + _toast('语音播放失败'); + } + } + + Future _openFile(Message message) async { + final localPath = message.fileElem?.filePath; + final url = message.fileElem?.sourceUrl; + final fileName = message.fileElem?.fileName ?? 'file'; + try { + String path; + if (localPath != null && localPath.isNotEmpty && File(localPath).existsSync()) { + path = localPath; + } else if (url != null && url.isNotEmpty) { + _toast('正在下载,请稍候'); + final dir = await getTemporaryDirectory(); + path = '${dir.path}/$fileName'; + await _dio.download(url, path); + } else { + _toast('文件找不到了'); + return; + } + final result = await OpenFilex.open(path); + if (result.type != ResultType.done) { + _toast('没有能打开这个文件的应用'); + } + } catch (_) { + _toast('文件打不开,请稍后再试'); + } + } + + // ---------------- 语音通话 ---------------- + + Future _startVoiceCall() async { + final error = await CallService.instance.startCall(peerUserID: _peerID, peerName: _title); + if (!mounted) return; + if (error != null) { + _toast(error); + return; + } + Navigator.of(context).push(MaterialPageRoute(builder: (_) => const CallScreen(), fullscreenDialog: true)); + } + + void _toast(String text) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(text), duration: const Duration(seconds: 2))); + } + + // ---------------- 界面 ---------------- + + @override + Widget build(BuildContext context) { + final self = IMService.instance.selfInfo; + return Scaffold( + backgroundColor: AppColors.chatBg, + appBar: AppBar( + title: Text(_title), + actions: [ + if (!_isGroup) + IconButton( + icon: const Icon(Icons.more_horiz), + onPressed: () { + showModalBottomSheet( + context: context, + builder: (ctx) => SafeArea( + child: ListTile( + leading: const Icon(Icons.phone_outlined), + title: const Text('语音通话'), + onTap: () { + Navigator.of(ctx).pop(); + _startVoiceCall(); + }, + ), + ), + ); + }, + ), + ], + ), + body: Column( + children: [ + Expanded( + child: GestureDetector( + onTap: () => FocusScope.of(context).unfocus(), + child: ListView.builder( + controller: _scroll, + reverse: true, + padding: const EdgeInsets.symmetric(vertical: AppGap.x2), + itemCount: _messages.length, + itemBuilder: (context, i) { + final msg = _messages[i]; + return Column( + children: [ + _buildMessageRow(msg, self), + if (_showDividerAt(i)) _timeDivider(msg), + ], + ); + }, + ), + ), + ), + SafeArea( + top: false, + child: ChatInputBar( + onSendText: _sendText, + onSendVoice: _sendVoice, + onPickImage: _pickAndSendImage, + onPickFile: _pickAndSendFile, + onVoiceCall: _isGroup ? null : _startVoiceCall, + ), + ), + ], + ), + ); + } + + Widget _buildMessageRow(Message msg, UserInfo? self) { + final isMine = msg.sendID == IMService.instance.currentUserID; + // 通知类消息(如入群提示):居中灰字 + if ((msg.contentType ?? 0) >= MessageType.notificationBegin) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: AppGap.x2), + child: Center( + child: Text( + FormatUtils.messagePreview(msg, isGroup: _isGroup), + style: const TextStyle(fontSize: AppFont.small, color: AppColors.textSecondary), + ), + ), + ); + } + return MessageBubble( + message: msg, + isMine: isMine, + selfName: self?.nickname ?? '', + selfFaceURL: self?.faceURL, + peerName: _title, + peerFaceURL: _isGroup ? null : _conv.faceURL, + showSenderName: _isGroup, + playingVoiceId: _playingVoiceId, + onTapVoice: _playVoice, + onTapFile: _openFile, + onResend: _resend, + ); + } + + /// 与上一条(更旧的)消息间隔超过 5 分钟时显示时间分隔条 + bool _showDividerAt(int i) { + final current = _messages[i].sendTime ?? 0; + if (current <= 0) return false; + if (i == _messages.length - 1) return true; // 最早一条也显示 + final older = _messages[i + 1].sendTime ?? 0; + return current - older > _dividerGapMillis; + } + + Widget _timeDivider(Message msg) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: AppGap.x2), + child: Center( + child: Text( + FormatUtils.chatDividerTime(msg.sendTime ?? 0), + style: const TextStyle(fontSize: AppFont.small, color: AppColors.textSecondary), + ), + ), + ); + } +} diff --git a/mobile/lib/screens/contacts_screen.dart b/mobile/lib/screens/contacts_screen.dart new file mode 100644 index 0000000..faaa7ba --- /dev/null +++ b/mobile/lib/screens/contacts_screen.dart @@ -0,0 +1,274 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter_openim_sdk/flutter_openim_sdk.dart'; + +import '../services/im_service.dart'; +import '../theme.dart'; +import '../widgets/avatar.dart'; +import '../widgets/search_box.dart'; +import 'add_friend_screen.dart'; +import 'chat_screen.dart'; +import 'group_list_screen.dart'; +import 'new_friends_screen.dart'; + +/// 同事扩展信息(friendInfo 的 ex 字段,JSON 格式) +class _ContactEx { + final String department; + final String title; + + _ContactEx(this.department, this.title); + + /// 解析 ex 字段;解析不到归「同事」组、无职务 + static _ContactEx parse(String? ex) { + if (ex == null || ex.isEmpty) return _ContactEx('同事', ''); + try { + final map = jsonDecode(ex); + if (map is Map) { + return _ContactEx( + map['department']?.toString() ?? '同事', + map['title']?.toString() ?? '', + ); + } + } catch (_) { + // 不是 JSON 按默认组处理 + } + return _ContactEx('同事', ''); + } +} + +/// 通讯录页(效果图 m3): +/// 标题 + 添加人图标、搜索框、「新的同事」「我的群聊」两行入口、按部门分组的联系人列表。 +/// 数据源:OpenIM 好友列表,部门/职务取自好友 ex 字段(JSON)。 +class ContactsScreen extends StatefulWidget { + const ContactsScreen({super.key}); + + @override + State createState() => _ContactsScreenState(); +} + +class _ContactsScreenState extends State { + List _friends = []; + int _pendingRequests = 0; + bool _loading = true; + bool _failed = false; + String _keyword = ''; + + @override + void initState() { + super.initState(); + _load(); + // 好友变更时刷新(IMService 会 notifyListeners) + IMService.instance.addListener(_onImChanged); + } + + @override + void dispose() { + IMService.instance.removeListener(_onImChanged); + super.dispose(); + } + + void _onImChanged() => _load(); + + Future _load() async { + if (!IMService.instance.loggedIn) return; + try { + final friends = await OpenIM.iMManager.friendshipManager.getFriendList(); + final applications = await OpenIM.iMManager.friendshipManager.getFriendApplicationListAsRecipient(); + if (!mounted) return; + setState(() { + _friends = friends; + _pendingRequests = applications.where((a) => a.handleResult == 0).length; + _loading = false; + _failed = false; + }); + } catch (_) { + if (!mounted) return; + setState(() { + _loading = false; + _failed = true; + }); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('通讯录'), + actions: [ + IconButton( + icon: const Icon(Icons.person_add_alt, size: 24), + onPressed: () { + Navigator.of(context) + .push(MaterialPageRoute(builder: (_) => const AddFriendScreen())) + .then((_) => _load()); + }, + ), + ], + ), + body: Column( + children: [ + SearchBox(onChanged: (v) => setState(() => _keyword = v)), + Expanded(child: _buildBody()), + ], + ), + ); + } + + Widget _buildBody() { + if (_loading) { + return const Center(child: CircularProgressIndicator(color: AppColors.primary)); + } + if (_failed) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text('通讯录没加载出来', style: TextStyle(fontSize: AppFont.body, color: AppColors.textPrimary)), + const SizedBox(height: AppGap.x2), + const Text('检查一下网络,再点重试', style: TextStyle(fontSize: AppFont.sub, color: AppColors.textSecondary)), + const SizedBox(height: AppGap.x4), + OutlinedButton(onPressed: _load, child: const Text('重试')), + ], + ), + ); + } + + // 搜索时只过滤联系人,隐藏两行功能入口与分组 + if (_keyword.isNotEmpty) { + final matched = _friends.where((f) => _displayName(f).toLowerCase().contains(_keyword.toLowerCase())).toList(); + if (matched.isEmpty) { + return const Center( + child: Text('没有找到相关同事', style: TextStyle(fontSize: AppFont.sub, color: AppColors.textSecondary)), + ); + } + return ListView.separated( + itemCount: matched.length, + separatorBuilder: (_, __) => const Divider(indent: 76), + itemBuilder: (_, i) => _contactTile(matched[i]), + ); + } + + // 按部门分组 + final groups = >{}; + for (final f in _friends) { + final ex = _ContactEx.parse(f.ex); + groups.putIfAbsent(ex.department, () => []).add(f); + } + final departments = groups.keys.toList()..sort((a, b) => a == '同事' ? 1 : b == '同事' ? -1 : a.compareTo(b)); + + return ListView( + children: [ + _entryTile(), + const SizedBox(height: AppGap.x2), + for (final dept in departments) ...[ + _groupHeader(dept, groups[dept]!.length), + for (var i = 0; i < groups[dept]!.length; i++) ...[ + _contactTile(groups[dept]![i]), + if (i < groups[dept]!.length - 1) const Divider(indent: 76), + ], + ], + ], + ); + } + + /// 「新的同事」「我的群聊」两行功能入口 + Widget _entryTile() { + return Column( + children: [ + ListTile( + leading: _entryIcon(Icons.person_add_alt), + title: const Text('新的同事', style: TextStyle(fontSize: AppFont.body)), + trailing: _pendingRequests > 0 + ? Container( + constraints: const BoxConstraints(minWidth: 20), + height: 20, + padding: const EdgeInsets.symmetric(horizontal: 5), + alignment: Alignment.center, + decoration: BoxDecoration(color: AppColors.danger, borderRadius: BorderRadius.circular(10)), + child: Text( + _pendingRequests > 99 ? '99+' : '$_pendingRequests', + style: const TextStyle(fontSize: AppFont.small, color: Colors.white), + ), + ) + : const Icon(Icons.chevron_right, color: AppColors.textSecondary), + onTap: () { + Navigator.of(context) + .push(MaterialPageRoute(builder: (_) => const NewFriendsScreen())) + .then((_) => _load()); + }, + ), + const Divider(indent: 76), + ListTile( + leading: _entryIcon(Icons.people_outline), + title: const Text('我的群聊', style: TextStyle(fontSize: AppFont.body)), + trailing: const Icon(Icons.chevron_right, color: AppColors.textSecondary), + onTap: () { + Navigator.of(context).push(MaterialPageRoute(builder: (_) => const GroupListScreen())); + }, + ), + ], + ); + } + + Widget _entryIcon(IconData icon) { + return Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: AppColors.bubbleMine, + borderRadius: BorderRadius.circular(8), + ), + child: Icon(icon, color: AppColors.primary), + ); + } + + Widget _groupHeader(String department, int count) { + return Container( + width: double.infinity, + color: AppColors.pinnedBg, + padding: const EdgeInsets.symmetric(horizontal: AppGap.x4, vertical: AppGap.x2), + child: Text( + '$department($count 人)', + style: const TextStyle(fontSize: AppFont.sub, color: AppColors.textSecondary), + ), + ); + } + + String _displayName(FriendInfo f) { + if (f.remark?.isNotEmpty == true) return f.remark!; + return f.nickname ?? f.friendUserID ?? ''; + } + + Widget _contactTile(FriendInfo f) { + final name = _displayName(f); + final ex = _ContactEx.parse(f.ex); + return ListTile( + leading: NameAvatar(name: name, faceURL: f.faceURL), + title: Text(name, style: const TextStyle(fontSize: AppFont.body, color: AppColors.textPrimary)), + trailing: ex.title.isNotEmpty + ? Text(ex.title, style: const TextStyle(fontSize: AppFont.sub, color: AppColors.textSecondary)) + : null, + onTap: () => _openChat(f, name), + ); + } + + /// 点联系人直接进单聊 + Future _openChat(FriendInfo f, String name) async { + try { + final conversation = await OpenIM.iMManager.conversationManager.getOneConversation( + sourceID: f.friendUserID ?? '', + sessionType: ConversationType.single, + ); + conversation.showName = name; + if (!mounted) return; + Navigator.of(context).push(MaterialPageRoute(builder: (_) => ChatScreen(conversation: conversation))); + } catch (_) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('打开会话失败,请检查网络'), duration: Duration(seconds: 2)), + ); + } + } +} diff --git a/mobile/lib/screens/conversation_list_screen.dart b/mobile/lib/screens/conversation_list_screen.dart new file mode 100644 index 0000000..e4b29ed --- /dev/null +++ b/mobile/lib/screens/conversation_list_screen.dart @@ -0,0 +1,191 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_openim_sdk/flutter_openim_sdk.dart'; + +import '../services/im_service.dart'; +import '../theme.dart'; +import '../utils/format.dart'; +import '../widgets/avatar.dart'; +import '../widgets/search_box.dart'; +import '../widgets/state_views.dart'; +import 'add_friend_screen.dart'; +import 'chat_screen.dart'; + +/// 消息列表页(效果图 m1): +/// 标题「畅联」+ 圆形加号、搜索框、会话行(头像/名称/预览/时间/未读角标/免打扰铃铛)、 +/// 置顶置灰,空/加载/失败/断网四态按 states.png。 +class ConversationListScreen extends StatefulWidget { + /// 空态「去找同事」按钮:切到通讯录 Tab + final VoidCallback? onFindContacts; + + const ConversationListScreen({super.key, this.onFindContacts}); + + @override + State createState() => _ConversationListScreenState(); +} + +class _ConversationListScreenState extends State { + String _keyword = ''; + + bool get _offline => IMService.instance.connectStatus != ConnectStatus.success; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('畅联'), + actions: [ + IconButton( + icon: const Icon(Icons.add_circle_outline, size: 26), + onPressed: () { + // 加号:从简只保留「添加同事」入口 + showModalBottomSheet( + context: context, + builder: (ctx) => SafeArea( + child: ListTile( + leading: const Icon(Icons.person_add_alt), + title: const Text('添加同事'), + onTap: () { + Navigator.of(ctx).pop(); + Navigator.of(context).push(MaterialPageRoute(builder: (_) => const AddFriendScreen())); + }, + ), + ), + ); + }, + ), + ], + ), + body: AnimatedBuilder( + animation: IMService.instance, + builder: (context, _) { + return Column( + children: [ + if (_offline) const OfflineBanner(), + SearchBox(onChanged: (v) => setState(() => _keyword = v)), + Expanded(child: _buildBody()), + ], + ); + }, + ), + ); + } + + Widget _buildBody() { + final im = IMService.instance; + // 四态:加载中 / 加载失败 / 空 / 列表 + if (!im.conversationsLoaded && im.syncing && im.conversations.isEmpty) { + return const LoadingConversations(); + } + if (im.syncFailed && im.conversations.isEmpty) { + return ErrorConversations(onRetry: () => im.refreshConversations()); + } + final list = _keyword.isEmpty + ? im.conversations + : im.conversations + .where((c) => (c.showName ?? '').toLowerCase().contains(_keyword.toLowerCase())) + .toList(); + if (list.isEmpty) { + if (_keyword.isNotEmpty) { + return const Center( + child: Text('没有找到相关会话', style: TextStyle(fontSize: AppFont.sub, color: AppColors.textSecondary)), + ); + } + return EmptyConversations(onFindContacts: widget.onFindContacts); + } + return ListView.separated( + itemCount: list.length + (_offline ? 1 : 0), + separatorBuilder: (_, i) => const Divider(indent: 80), + itemBuilder: (context, i) { + if (i >= list.length) return const OfflineFooter(); + return _conversationTile(list[i]); + }, + ); + } + + Widget _conversationTile(ConversationInfo c) { + final isGroup = c.conversationType == ConversationType.superGroup || c.conversationType == ConversationType.group; + final muted = (c.recvMsgOpt ?? 0) != 0; + return Material( + color: c.isPinned == true ? AppColors.pinnedBg : AppColors.pageBg, + child: InkWell( + onTap: () { + Navigator.of(context) + .push(MaterialPageRoute(builder: (_) => ChatScreen(conversation: c))) + .then((_) => IMService.instance.refreshConversations()); + }, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: AppGap.x3, vertical: AppGap.x3), + child: Row( + children: [ + _avatarWithBadge(c, isGroup, muted), + const SizedBox(width: AppGap.x3), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + c.showName ?? '', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: AppFont.body, color: AppColors.textPrimary), + ), + const SizedBox(height: AppGap.x1), + Row( + children: [ + Expanded( + child: Text( + FormatUtils.messagePreview(c.latestMsg, isGroup: isGroup), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: AppFont.sub, color: AppColors.textSecondary), + ), + ), + if (muted) + const Icon(Icons.notifications_off_outlined, size: 16, color: AppColors.textSecondary), + ], + ), + ], + ), + ), + const SizedBox(width: AppGap.x2), + Text( + FormatUtils.conversationTime(c.latestMsgSendTime), + style: const TextStyle(fontSize: AppFont.small, color: AppColors.textSecondary), + ), + ], + ), + ), + ), + ); + } + + Widget _avatarWithBadge(ConversationInfo c, bool isGroup, bool muted) { + final unread = c.unreadCount; + return Stack( + clipBehavior: Clip.none, + children: [ + NameAvatar(name: c.showName ?? '', faceURL: c.faceURL, isGroup: isGroup, size: 56), + if (unread > 0) + Positioned( + right: -6, + top: -6, + child: Container( + constraints: const BoxConstraints(minWidth: 18), + height: 18, + padding: const EdgeInsets.symmetric(horizontal: 4), + alignment: Alignment.center, + decoration: BoxDecoration( + // 免打扰会话的角标弱化(参考微信习惯) + color: muted ? AppColors.textSecondary : AppColors.danger, + borderRadius: BorderRadius.circular(9), + ), + child: Text( + unread > 99 ? '99+' : '$unread', + style: const TextStyle(fontSize: 11, color: Colors.white), + ), + ), + ), + ], + ); + } +} diff --git a/mobile/lib/screens/group_list_screen.dart b/mobile/lib/screens/group_list_screen.dart new file mode 100644 index 0000000..d8a0bfb --- /dev/null +++ b/mobile/lib/screens/group_list_screen.dart @@ -0,0 +1,106 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_openim_sdk/flutter_openim_sdk.dart'; + +import '../theme.dart'; +import '../widgets/avatar.dart'; +import 'chat_screen.dart'; + +/// 「我的群聊」:已加入的群列表 +class GroupListScreen extends StatefulWidget { + const GroupListScreen({super.key}); + + @override + State createState() => _GroupListScreenState(); +} + +class _GroupListScreenState extends State { + List _groups = []; + bool _loading = true; + bool _failed = false; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + try { + final list = await OpenIM.iMManager.groupManager.getJoinedGroupList(); + if (!mounted) return; + setState(() { + _groups = list; + _loading = false; + _failed = false; + }); + } catch (_) { + if (!mounted) return; + setState(() { + _loading = false; + _failed = true; + }); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('我的群聊')), + body: _buildBody(), + ); + } + + Widget _buildBody() { + if (_loading) { + return const Center(child: CircularProgressIndicator(color: AppColors.primary)); + } + if (_failed) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text('群聊列表没加载出来', style: TextStyle(fontSize: AppFont.body, color: AppColors.textPrimary)), + const SizedBox(height: AppGap.x4), + OutlinedButton(onPressed: _load, child: const Text('重试')), + ], + ), + ); + } + if (_groups.isEmpty) { + return const Center( + child: Text('你还没有加入任何群聊', style: TextStyle(fontSize: AppFont.sub, color: AppColors.textSecondary)), + ); + } + return ListView.separated( + itemCount: _groups.length, + separatorBuilder: (_, __) => const Divider(indent: 76), + itemBuilder: (_, i) => _tile(_groups[i]), + ); + } + + Widget _tile(GroupInfo g) { + final name = g.groupName ?? ''; + return ListTile( + leading: NameAvatar(name: name, isGroup: true), + title: Text(name, style: const TextStyle(fontSize: AppFont.body)), + subtitle: Text('${g.memberCount ?? 0} 人', style: const TextStyle(fontSize: AppFont.sub, color: AppColors.textSecondary)), + onTap: () => _openChat(g), + ); + } + + Future _openChat(GroupInfo g) async { + try { + final conversation = await OpenIM.iMManager.conversationManager.getOneConversation( + sourceID: g.groupID, + sessionType: ConversationType.superGroup, + ); + if (!mounted) return; + Navigator.of(context).push(MaterialPageRoute(builder: (_) => ChatScreen(conversation: conversation))); + } catch (_) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('打开会话失败,请检查网络'), duration: Duration(seconds: 2)), + ); + } + } +} diff --git a/mobile/lib/screens/home_screen.dart b/mobile/lib/screens/home_screen.dart new file mode 100644 index 0000000..58525ec --- /dev/null +++ b/mobile/lib/screens/home_screen.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart'; + +import 'contacts_screen.dart'; +import 'conversation_list_screen.dart'; +import 'mine_screen.dart'; + +/// 主页:底部三 Tab —— 消息 / 通讯录 / 我的 +class HomeScreen extends StatefulWidget { + const HomeScreen({super.key}); + + @override + State createState() => _HomeScreenState(); +} + +class _HomeScreenState extends State { + int _index = 0; + + void switchTab(int index) => setState(() => _index = index); + + @override + Widget build(BuildContext context) { + return Scaffold( + body: IndexedStack( + index: _index, + children: [ + ConversationListScreen(onFindContacts: () => switchTab(1)), + const ContactsScreen(), + const MineScreen(), + ], + ), + bottomNavigationBar: BottomNavigationBar( + currentIndex: _index, + onTap: (i) => setState(() => _index = i), + items: const [ + BottomNavigationBarItem(icon: Icon(Icons.chat_bubble), label: '消息'), + BottomNavigationBarItem(icon: Icon(Icons.people), label: '通讯录'), + BottomNavigationBarItem(icon: Icon(Icons.person), label: '我的'), + ], + ), + ); + } +} diff --git a/mobile/lib/screens/login_screen.dart b/mobile/lib/screens/login_screen.dart new file mode 100644 index 0000000..f2ccaac --- /dev/null +++ b/mobile/lib/screens/login_screen.dart @@ -0,0 +1,171 @@ +import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../services/auth_api.dart'; +import '../services/call_service.dart'; +import '../services/im_service.dart'; +import '../theme.dart'; +import 'home_screen.dart'; + +/// 登录页:工号 + 密码,登录中按钮转菊花,失败在按钮下方红字提示。 +class LoginScreen extends StatefulWidget { + const LoginScreen({super.key}); + + /// 本地保存登录凭证的键(自动登录用) + static const String keyUserID = 'login_userID'; + static const String keyToken = 'login_token'; + static const String keyNickname = 'login_nickname'; + + /// 保存登录凭证 + static Future saveCredential(String userID, String token, String nickname) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(keyUserID, userID); + await prefs.setString(keyToken, token); + await prefs.setString(keyNickname, nickname); + } + + /// 清除登录凭证(退出登录 / token 失效时) + static Future clearCredential() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(keyUserID); + await prefs.remove(keyToken); + await prefs.remove(keyNickname); + } + + /// 读取已保存的凭证 + static Future<(String, String)?> readCredential() async { + final prefs = await SharedPreferences.getInstance(); + final userID = prefs.getString(keyUserID); + final token = prefs.getString(keyToken); + if (userID == null || userID.isEmpty || token == null || token.isEmpty) return null; + return (userID, token); + } + + @override + State createState() => _LoginScreenState(); +} + +class _LoginScreenState extends State { + final TextEditingController _idCtrl = TextEditingController(); + final TextEditingController _pwdCtrl = TextEditingController(); + final AuthApi _authApi = AuthApi(); + + bool _logging = false; + String? _error; + + @override + void dispose() { + _idCtrl.dispose(); + _pwdCtrl.dispose(); + super.dispose(); + } + + Future _login() async { + final staffNo = _idCtrl.text.trim(); + final password = _pwdCtrl.text; + if (staffNo.isEmpty || password.isEmpty) { + setState(() => _error = '请输入工号和密码'); + return; + } + setState(() { + _logging = true; + _error = null; + }); + try { + // 1. 公司账号登录,拿 OpenIM 的 userID/imToken + final result = await _authApi.login( + staffNo: staffNo, + password: password, + platformID: IMService.instance.platformID, + ); + // 2. 登录 OpenIM SDK + await IMService.instance.login(userID: result.userID, token: result.token); + // 3. 启动通话信令监听 + CallService.instance.start(); + // 4. 存凭证用于下次自动登录 + await LoginScreen.saveCredential(result.userID, result.token, result.nickname); + if (!mounted) return; + Navigator.of(context).pushReplacement( + MaterialPageRoute(builder: (_) => const HomeScreen()), + ); + } on AuthException catch (e) { + setState(() => _error = e.message); + } catch (_) { + setState(() => _error = '登录出错了,请稍后再试'); + } finally { + if (mounted) setState(() => _logging = false); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppColors.pageBg, + body: SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: AppGap.x8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 96), + const Text( + '畅联', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 32, fontWeight: FontWeight.w600, color: AppColors.primary), + ), + const SizedBox(height: AppGap.x12), + _input(_idCtrl, '请输入工号', Icons.person_outline, false), + const SizedBox(height: AppGap.x4), + _input(_pwdCtrl, '请输入密码', Icons.lock_outline, true), + const SizedBox(height: AppGap.x6), + SizedBox( + height: 48, + child: FilledButton( + onPressed: _logging ? null : _login, + style: FilledButton.styleFrom( + backgroundColor: AppColors.primary, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + child: _logging + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white), + ) + : const Text('登录', style: TextStyle(fontSize: AppFont.body)), + ), + ), + if (_error != null) ...[ + const SizedBox(height: AppGap.x3), + Text( + _error!, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: AppFont.sub, color: AppColors.danger), + ), + ], + ], + ), + ), + ), + ); + } + + Widget _input(TextEditingController ctrl, String hint, IconData icon, bool obscure) { + return TextField( + controller: ctrl, + obscureText: obscure, + decoration: InputDecoration( + hintText: hint, + hintStyle: const TextStyle(color: AppColors.textSecondary, fontSize: AppFont.body), + prefixIcon: Icon(icon, color: AppColors.textSecondary), + filled: true, + fillColor: AppColors.searchBg, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide.none, + ), + contentPadding: const EdgeInsets.symmetric(vertical: AppGap.x4), + ), + ); + } +} diff --git a/mobile/lib/screens/mine_screen.dart b/mobile/lib/screens/mine_screen.dart new file mode 100644 index 0000000..0da9f85 --- /dev/null +++ b/mobile/lib/screens/mine_screen.dart @@ -0,0 +1,182 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_openim_sdk/flutter_openim_sdk.dart'; + +import '../services/im_service.dart'; +import '../theme.dart'; +import '../widgets/avatar.dart'; +import 'login_screen.dart'; + +/// 「我的」页:大头像 + 姓名 + 工号,设置 / 关于 / 退出登录 +class MineScreen extends StatefulWidget { + const MineScreen({super.key}); + + @override + State createState() => _MineScreenState(); +} + +class _MineScreenState extends State { + @override + void initState() { + super.initState(); + IMService.instance.refreshSelfInfo(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('我的')), + body: AnimatedBuilder( + animation: IMService.instance, + builder: (context, _) { + final user = IMService.instance.selfInfo; + final nickname = user?.nickname?.isNotEmpty == true ? user!.nickname! : ''; + final userID = IMService.instance.currentUserID ?? ''; + return ListView( + children: [ + // 头部:大头像 + 姓名 + 工号 + Container( + color: AppColors.pageBg, + padding: const EdgeInsets.all(AppGap.x4), + child: Row( + children: [ + NameAvatar(name: nickname.isNotEmpty ? nickname : userID, faceURL: user?.faceURL, size: 64), + const SizedBox(width: AppGap.x4), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + nickname.isNotEmpty ? nickname : userID, + style: const TextStyle(fontSize: AppFont.title, fontWeight: FontWeight.w600, color: AppColors.textPrimary), + ), + const SizedBox(height: AppGap.x1), + Text( + '工号:$userID', + style: const TextStyle(fontSize: AppFont.sub, color: AppColors.textSecondary), + ), + ], + ), + ], + ), + ), + const SizedBox(height: AppGap.x3), + _item(Icons.settings_outlined, '设置', () => _showSettings(nickname)), + const Divider(indent: AppGap.x4), + _item(Icons.info_outline, '关于', _showAbout), + const Divider(indent: AppGap.x4), + ListTile( + leading: const Icon(Icons.logout, color: AppColors.danger), + title: const Text('退出登录', style: TextStyle(fontSize: AppFont.body, color: AppColors.danger)), + onTap: _confirmLogout, + ), + ], + ); + }, + ), + ); + } + + Widget _item(IconData icon, String label, VoidCallback onTap) { + return ListTile( + leading: Icon(icon, color: AppColors.textPrimary), + title: Text(label, style: const TextStyle(fontSize: AppFont.body)), + trailing: const Icon(Icons.chevron_right, color: AppColors.textSecondary), + onTap: onTap, + ); + } + + /// 设置:目前只有修改昵称 + void _showSettings(String currentNickname) { + final ctrl = TextEditingController(text: currentNickname); + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (ctx) => Padding( + padding: EdgeInsets.only( + left: AppGap.x4, + right: AppGap.x4, + top: AppGap.x4, + bottom: MediaQuery.of(ctx).viewInsets.bottom + AppGap.x4, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Text('修改昵称', style: TextStyle(fontSize: AppFont.title, fontWeight: FontWeight.w600)), + const SizedBox(height: AppGap.x4), + TextField( + controller: ctrl, + decoration: InputDecoration( + hintText: '请输入新昵称', + filled: true, + fillColor: AppColors.searchBg, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide.none, + ), + ), + ), + const SizedBox(height: AppGap.x4), + FilledButton( + onPressed: () async { + final name = ctrl.text.trim(); + if (name.isEmpty) return; + try { + await OpenIM.iMManager.userManager.setSelfInfo(nickname: name); + await IMService.instance.refreshSelfInfo(); + if (ctx.mounted) Navigator.of(ctx).pop(); + } catch (_) { + if (ctx.mounted) { + ScaffoldMessenger.of(ctx).showSnackBar( + const SnackBar(content: Text('修改失败,请检查网络后重试'), duration: Duration(seconds: 2)), + ); + } + } + }, + style: FilledButton.styleFrom(backgroundColor: AppColors.primary), + child: const Text('保存', style: TextStyle(fontSize: AppFont.body)), + ), + ], + ), + ), + ); + } + + void _showAbout() { + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('关于畅联'), + content: const Text('畅联 1.0.0\n公司内部通讯工具,供同事之间消息、文件与语音通话使用。'), + actions: [ + TextButton(onPressed: () => Navigator.of(ctx).pop(), child: const Text('知道了')), + ], + ), + ); + } + + void _confirmLogout() { + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('退出登录'), + content: const Text('退出后要重新输入工号和密码,确定退出吗?'), + actions: [ + TextButton(onPressed: () => Navigator.of(ctx).pop(), child: const Text('取消')), + TextButton( + onPressed: () async { + Navigator.of(ctx).pop(); + await LoginScreen.clearCredential(); + await IMService.instance.logout(); + if (!mounted) return; + Navigator.of(context).pushAndRemoveUntil( + MaterialPageRoute(builder: (_) => const LoginScreen()), + (route) => false, + ); + }, + child: const Text('退出', style: TextStyle(color: AppColors.danger)), + ), + ], + ), + ); + } +} diff --git a/mobile/lib/screens/new_friends_screen.dart b/mobile/lib/screens/new_friends_screen.dart new file mode 100644 index 0000000..1716059 --- /dev/null +++ b/mobile/lib/screens/new_friends_screen.dart @@ -0,0 +1,140 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_openim_sdk/flutter_openim_sdk.dart'; + +import '../theme.dart'; +import '../widgets/avatar.dart'; + +/// 「新的同事」:收到的好友申请列表,可接受 / 拒绝 +class NewFriendsScreen extends StatefulWidget { + const NewFriendsScreen({super.key}); + + @override + State createState() => _NewFriendsScreenState(); +} + +class _NewFriendsScreenState extends State { + List _list = []; + bool _loading = true; + bool _failed = false; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + try { + final list = await OpenIM.iMManager.friendshipManager.getFriendApplicationListAsRecipient(); + if (!mounted) return; + setState(() { + _list = list; + _loading = false; + _failed = false; + }); + } catch (_) { + if (!mounted) return; + setState(() { + _loading = false; + _failed = true; + }); + } + } + + Future _handle(FriendApplicationInfo a, bool accept) async { + try { + if (accept) { + await OpenIM.iMManager.friendshipManager.acceptFriendApplication(userID: a.fromUserID ?? '', handleMsg: ''); + } else { + await OpenIM.iMManager.friendshipManager.refuseFriendApplication(userID: a.fromUserID ?? '', handleMsg: ''); + } + if (!mounted) return; + setState(() => a.handleResult = accept ? 1 : -1); + _toast(accept ? '已添加为同事' : '已拒绝'); + } catch (_) { + _toast('操作失败,请检查网络后重试'); + } + } + + void _toast(String text) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(text), duration: const Duration(seconds: 2))); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('新的同事')), + body: _buildBody(), + ); + } + + Widget _buildBody() { + if (_loading) { + return const Center(child: CircularProgressIndicator(color: AppColors.primary)); + } + if (_failed) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text('申请列表没加载出来', style: TextStyle(fontSize: AppFont.body, color: AppColors.textPrimary)), + const SizedBox(height: AppGap.x4), + OutlinedButton(onPressed: _load, child: const Text('重试')), + ], + ), + ); + } + if (_list.isEmpty) { + return const Center( + child: Text('暂时没有新的申请', style: TextStyle(fontSize: AppFont.sub, color: AppColors.textSecondary)), + ); + } + return ListView.separated( + itemCount: _list.length, + separatorBuilder: (_, __) => const Divider(indent: 76), + itemBuilder: (_, i) => _tile(_list[i]), + ); + } + + Widget _tile(FriendApplicationInfo a) { + final name = a.fromNickname?.isNotEmpty == true ? a.fromNickname! : (a.fromUserID ?? ''); + Widget trailing; + if (a.handleResult == 1) { + trailing = const Text('已添加', style: TextStyle(fontSize: AppFont.sub, color: AppColors.textSecondary)); + } else if (a.handleResult == -1) { + trailing = const Text('已拒绝', style: TextStyle(fontSize: AppFont.sub, color: AppColors.textSecondary)); + } else { + trailing = Row( + mainAxisSize: MainAxisSize.min, + children: [ + FilledButton( + onPressed: () => _handle(a, true), + style: FilledButton.styleFrom( + backgroundColor: AppColors.primary, + minimumSize: const Size(0, 32), + padding: const EdgeInsets.symmetric(horizontal: AppGap.x3), + ), + child: const Text('接受', style: TextStyle(fontSize: AppFont.sub)), + ), + const SizedBox(width: AppGap.x2), + OutlinedButton( + onPressed: () => _handle(a, false), + style: OutlinedButton.styleFrom( + minimumSize: const Size(0, 32), + padding: const EdgeInsets.symmetric(horizontal: AppGap.x3), + ), + child: const Text('拒绝', style: TextStyle(fontSize: AppFont.sub)), + ), + ], + ); + } + return ListTile( + leading: NameAvatar(name: name), + title: Text(name, style: const TextStyle(fontSize: AppFont.body)), + subtitle: a.reqMsg?.isNotEmpty == true + ? Text(a.reqMsg!, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: AppFont.sub, color: AppColors.textSecondary)) + : null, + trailing: trailing, + ); + } +} diff --git a/mobile/lib/services/auth_api.dart b/mobile/lib/services/auth_api.dart new file mode 100644 index 0000000..34d4b3f --- /dev/null +++ b/mobile/lib/services/auth_api.dart @@ -0,0 +1,120 @@ +import 'package:dio/dio.dart'; + +import '../config.dart'; + +/// 公司账号登录接口返回的数据 +class AuthResult { + final String userID; + final String token; + final String nickname; + + AuthResult({required this.userID, required this.token, required this.nickname}); + + factory AuthResult.fromJson(Map json) => AuthResult( + userID: json['userID']?.toString() ?? '', + token: json['imToken']?.toString() ?? '', + nickname: json['nickname']?.toString() ?? '', + ); +} + +/// 登录失败,message 直接给用户看(简体中文) +class AuthException implements Exception { + final String message; + AuthException(this.message); + + @override + String toString() => message; +} + +/// 公司账号登录 HTTP 客户端。 +/// +/// 接口契约(account-service,见仓库 account-service/README.md): +/// - POST {authApiBase}/api/login +/// body: {"staffNo": "工号", "password": "...", "platformID": 1 iOS / 2 Android} +/// 响应统一包络 {"code": 0, "msg": "", "data": {...}},失败也是 HTTP 200 + code != 0 +/// 成功 data: {"userID", "nickname", "imToken", "expireTimeSeconds"}(userID/imToken 直接用于 OpenIM SDK 登录) +/// - POST {authApiBase}/api/rtc_token(语音通话换 LiveKit 进房 token) +/// 请求头: Authorization: Bearer {imToken} +/// body: {"room": "房间号", "identity": "当前用户 userID"} +/// 成功 data: {"token": "LiveKit 访问 token"} +class AuthApi { + final Dio _dio = Dio(BaseOptions( + baseUrl: authApiBase, + connectTimeout: const Duration(seconds: 10), + receiveTimeout: const Duration(seconds: 10), + )); + + /// 拆统一响应包络:code == 0 返回 data,否则用 msg 抛 [AuthException] + Map _unwrap(dynamic body) { + if (body is Map) { + if (body['code'] == 0 && body['data'] is Map) { + return Map.from(body['data'] as Map); + } + final msg = body['msg']?.toString(); + throw AuthException((msg != null && msg.isNotEmpty) ? msg : '服务器返回的数据不对,请联系管理员'); + } + throw AuthException('服务器返回的数据不对,请联系管理员'); + } + + /// 工号 + 密码登录,成功后返回 OpenIM 登录所需的 userID/imToken + Future login({required String staffNo, required String password, required int platformID}) async { + try { + final resp = await _dio.post('/api/login', data: { + 'staffNo': staffNo, + 'password': password, + 'platformID': platformID, + }); + final result = AuthResult.fromJson(_unwrap(resp.data)); + if (result.userID.isNotEmpty && result.token.isNotEmpty) return result; + throw AuthException('服务器返回的数据不对,请联系管理员'); + } on DioException catch (e) { + // 401 等 HTTP 层失败也带 {code, msg} 包络 + final data = e.response?.data; + if (data is Map && data['msg'] != null && data['msg'].toString().isNotEmpty) { + throw AuthException(data['msg'].toString()); + } + throw AuthException('连不上服务器,请检查网络'); + } catch (e) { + if (e is AuthException) rethrow; + throw AuthException('登录出错了,请稍后再试'); + } + } + + /// 获取 LiveKit 房间 token(语音通话用)。authToken 即登录返回的 imToken。 + Future getRtcToken({required String room, required String identity, required String authToken}) async { + try { + final resp = await _dio.post( + '/api/rtc_token', + data: {'room': room, 'identity': identity}, + options: Options(headers: {'Authorization': 'Bearer $authToken'}), + ); + final data = _unwrap(resp.data); + final token = data['token']?.toString(); + if (token != null && token.isNotEmpty) { + final live = data['liveURL']?.toString(); + return LiveKitCredential( + token: token, + liveURL: live != null && live.isNotEmpty ? live : livekitUrl, + ); + } + throw AuthException('服务器返回的数据不对,请联系管理员'); + } on DioException catch (e) { + final data = e.response?.data; + if (data is Map && data['msg'] != null && data['msg'].toString().isNotEmpty) { + throw AuthException(data['msg'].toString()); + } + throw AuthException('连不上服务器,请检查网络'); + } catch (e) { + if (e is AuthException) rethrow; + throw AuthException('发起通话失败,请稍后再试'); + } + } +} + +/// LiveKit 进房凭证 +class LiveKitCredential { + final String token; + final String liveURL; + + LiveKitCredential({required this.token, required this.liveURL}); +} diff --git a/mobile/lib/services/call_service.dart b/mobile/lib/services/call_service.dart new file mode 100644 index 0000000..4c80017 --- /dev/null +++ b/mobile/lib/services/call_service.dart @@ -0,0 +1,394 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_openim_sdk/flutter_openim_sdk.dart'; +import 'package:livekit_client/livekit_client.dart'; +import 'package:uuid/uuid.dart'; + +import '../models/signaling.dart'; +import 'auth_api.dart'; +import 'im_service.dart'; + +/// 通话阶段 +enum CallPhase { + idle, // 空闲 + outgoing, // 呼出中(等待对方接听) + incoming, // 来电中(等待本机接听) + incall, // 通话中 +} + +/// 一对一语音通话:OpenIM 自定义消息做信令 + LiveKit 传声音。 +/// 信令协议与官方样板工程一致(customType 200-204 的仅在线自定义消息)。 +class CallService extends ChangeNotifier { + CallService._(); + + static final CallService instance = CallService._(); + + final AuthApi _authApi = AuthApi(); + + CallPhase phase = CallPhase.idle; + + /// 对方 userID 与显示名 + String? peerUserID; + String peerName = ''; + + /// 通话时长(秒) + int callSeconds = 0; + + /// 麦克风是否开启 + bool micOn = true; + + /// 是否免提 + bool speakerOn = false; + + /// 有来电时的回调(由 main.dart 设置,用于弹出来电界面) + void Function()? onIncomingCall; + + /// 通话结束时要提示给用户的一句话(可为空) + String? endHint; + + InvitationInfo? _invitation; + Room? _room; + EventsListener? _roomListener; + Timer? _timeoutTimer; + Timer? _durationTimer; + StreamSubscription? _signalingSub; + bool _started = false; + + /// 来电铃声等待 / 呼叫超时时间(秒),与信令里的 timeout 一致 + static const int _inviteTimeoutSec = 30; + + /// 启动信令监听(登录成功后调用一次) + void start() { + if (_started) return; + _started = true; + _signalingSub = IMService.instance.onSignaling.listen(_onSignaling); + } + + bool get isBusy => phase != CallPhase.idle; + + /// 发起呼叫(单聊)。返回错误提示,null 表示成功进入呼叫流程。 + Future startCall({required String peerUserID, required String peerName}) async { + if (isBusy) return '正在通话中,请稍后再试'; + final im = IMService.instance; + final myUserID = im.currentUserID; + final authToken = im.currentToken; + if (myUserID == null || authToken == null) return '登录状态已失效,请重新登录'; + + _invitation = InvitationInfo( + inviterUserID: myUserID, + inviteeUserIDList: [peerUserID], + roomID: const Uuid().v4(), + timeout: _inviteTimeoutSec, + initiateTime: DateTime.now().millisecondsSinceEpoch, + mediaType: 'audio', + sessionType: ConversationType.single, + platformID: im.platformID, + ); + this.peerUserID = peerUserID; + this.peerName = peerName; + + try { + // 1. 发呼叫信令(仅在线,不落地) + await _sendSignaling(SignalingType.callingInvite, peerUserID); + // 2. 取 LiveKit token 并进房(先进房等对方,对方接听信令到达后开始计时) + await _joinRoom(myUserID, authToken); + _setPhase(CallPhase.outgoing); + _startTimeoutTimer(() { + endHint = '对方暂时无人接听'; + _sendSignalingQuietly(SignalingType.callingCancel, peerUserID); + _teardown(); + }); + return null; + } on AuthException catch (e) { + _teardown(); + return e.message; + } catch (_) { + _teardown(); + return '发起通话失败,请检查网络后重试'; + } + } + + /// 接听来电 + Future accept() async { + if (phase != CallPhase.incoming) return; + final im = IMService.instance; + final myUserID = im.currentUserID; + final authToken = im.currentToken; + final inviter = _invitation?.inviterUserID; + if (myUserID == null || authToken == null || inviter == null) { + _teardown(); + return; + } + _timeoutTimer?.cancel(); + try { + await _sendSignaling(SignalingType.callingAccept, inviter); + await _joinRoom(myUserID, authToken); + _beginIncall(); + } catch (_) { + endHint = '接听失败,请检查网络'; + _sendSignalingQuietly(SignalingType.callingReject, inviter); + _teardown(); + } + } + + /// 拒接来电 + Future reject() async { + if (phase != CallPhase.incoming) return; + final inviter = _invitation?.inviterUserID; + if (inviter != null) await _sendSignalingQuietly(SignalingType.callingReject, inviter); + _teardown(); + } + + /// 取消呼叫(呼出方主动取消) + Future cancel() async { + if (phase != CallPhase.outgoing) return; + final peer = peerUserID; + if (peer != null) await _sendSignalingQuietly(SignalingType.callingCancel, peer); + _teardown(); + } + + /// 挂断(通话中) + Future hangup() async { + if (phase != CallPhase.incall) return; + final peer = peerUserID; + if (peer != null) await _sendSignalingQuietly(SignalingType.callingHungup, peer); + _teardown(); + } + + /// 通话界面上的统一返回键:按当前阶段取消/挂断 + Future leave() async { + switch (phase) { + case CallPhase.outgoing: + await cancel(); + break; + case CallPhase.incoming: + await reject(); + break; + case CallPhase.incall: + await hangup(); + break; + case CallPhase.idle: + break; + } + } + + Future toggleMic() async { + micOn = !micOn; + notifyListeners(); + try { + await _room?.localParticipant?.setMicrophoneEnabled(micOn); + } catch (_) { + // 切换失败时回退状态 + micOn = !micOn; + notifyListeners(); + } + } + + Future toggleSpeaker() async { + speakerOn = !speakerOn; + notifyListeners(); + try { + await Hardware.instance.setSpeakerphoneOn(speakerOn); + } catch (_) { + speakerOn = !speakerOn; + notifyListeners(); + } + } + + // ---------------- 信令处理 ---------------- + + void _onSignaling(Message msg) { + final payload = IMService.parseSignaling(msg); + if (payload == null) return; + final myUserID = IMService.instance.currentUserID; + + switch (payload.type) { + case SignalingType.callingInvite: + // 只处理发给自己的单聊音频呼叫 + final invitation = payload.invitation; + final invitees = invitation.inviteeUserIDList ?? []; + if (!invitees.contains(myUserID)) return; + if (invitation.mediaType != 'audio') return; + if (isBusy) { + // 占线:直接回拒接(UI 从简,无排队等待) + _sendSignalingQuietly(SignalingType.callingReject, invitation.inviterUserID, invitation: invitation); + return; + } + _invitation = invitation; + peerUserID = invitation.inviterUserID; + peerName = invitation.inviterUserID ?? ''; + _loadPeerName(); + _setPhase(CallPhase.incoming); + _startTimeoutTimer(() { + // 来电超时未接 + _teardown(); + }); + onIncomingCall?.call(); + break; + case SignalingType.callingAccept: + if (phase == CallPhase.outgoing && _sameRoom(payload)) { + _timeoutTimer?.cancel(); + _beginIncall(); + } + break; + case SignalingType.callingReject: + if ((phase == CallPhase.outgoing || phase == CallPhase.incall) && _sameRoom(payload)) { + endHint = '对方拒绝了通话'; + _teardown(); + } + break; + case SignalingType.callingCancel: + if (phase == CallPhase.incoming && _sameRoom(payload)) { + endHint = '对方已取消'; + _teardown(); + } + break; + case SignalingType.callingHungup: + if (phase == CallPhase.incall && _sameRoom(payload)) { + endHint = '通话已结束'; + _teardown(); + } + break; + } + } + + bool _sameRoom(SignalingPayload payload) { + return payload.roomID != null && payload.roomID == _invitation?.roomID; + } + + Future _loadPeerName() async { + final id = peerUserID; + if (id == null) return; + try { + final list = await OpenIM.iMManager.userManager.getUsersInfo(userIDList: [id]); + if (list.isNotEmpty) { + final name = list.first.nickname ?? ''; + if (name.isNotEmpty && peerUserID == id) { + peerName = name; + notifyListeners(); + } + } + } catch (_) { + // 拉不到名字就显示工号 + } + } + + // ---------------- LiveKit ---------------- + + Future _joinRoom(String myUserID, String authToken) async { + final roomID = _invitation?.roomID; + if (roomID == null) throw AuthException('通话数据不完整'); + final credential = await _authApi.getRtcToken(room: roomID, identity: myUserID, authToken: authToken); + + _room = Room(); + _roomListener = _room!.createListener(); + _roomListener! + ..on((event) { + // 掉线或房间被销毁:直接结束 + if (phase == CallPhase.incall) endHint = '通话已结束'; + _teardown(); + }) + ..on((event) { + // 对方离开房间 + if (phase == CallPhase.incall) { + endHint = '通话已结束'; + _teardown(); + } + }); + + await _room!.connect(credential.liveURL, credential.token); + await _room!.localParticipant?.setMicrophoneEnabled(micOn); + } + + // ---------------- 内部工具 ---------------- + + void _beginIncall() { + callSeconds = 0; + _durationTimer?.cancel(); + _durationTimer = Timer.periodic(const Duration(seconds: 1), (_) { + callSeconds += 1; + notifyListeners(); + }); + _setPhase(CallPhase.incall); + } + + void _setPhase(CallPhase p) { + phase = p; + notifyListeners(); + } + + void _startTimeoutTimer(void Function() onTimeout) { + _timeoutTimer?.cancel(); + _timeoutTimer = Timer(const Duration(seconds: _inviteTimeoutSec), onTimeout); + } + + Future _sendSignaling(int type, String recvUserID, {InvitationInfo? invitation}) async { + final inv = invitation ?? _invitation; + if (inv == null) return; + final data = jsonEncode({'customType': type, 'data': inv.toJson()}); + final message = await OpenIM.iMManager.messageManager.createCustomMessage( + data: data, + extension: '', + description: '', + ); + await OpenIM.iMManager.messageManager.sendMessage( + message: message, + offlinePushInfo: OfflinePushInfo(), + userID: recvUserID, + isOnlineOnly: true, + ); + } + + /// 发信令失败时静默处理(挂断/取消类消息失败不影响本地收尾) + Future _sendSignalingQuietly(int type, String? recvUserID, {InvitationInfo? invitation}) async { + if (recvUserID == null) return; + try { + await _sendSignaling(type, recvUserID, invitation: invitation); + } catch (_) {} + } + + /// 结束通话:释放房间与计时器,回到空闲态 + void _teardown() { + _timeoutTimer?.cancel(); + _timeoutTimer = null; + _durationTimer?.cancel(); + _durationTimer = null; + final room = _room; + _room = null; + _roomListener?.dispose(); + _roomListener = null; + if (room != null) { + () async { + try { + await room.disconnect(); + await room.dispose(); + } catch (_) {} + }(); + } + // 复位免提 + if (speakerOn) { + Hardware.instance.setSpeakerphoneOn(false).catchError((_) {}); + speakerOn = false; + } + micOn = true; + callSeconds = 0; + _invitation = null; + _setPhase(CallPhase.idle); + } + + /// 消费结束提示(界面弹出提示后清空) + String? takeEndHint() { + final hint = endHint; + endHint = null; + return hint; + } + + @override + void dispose() { + _signalingSub?.cancel(); + _teardown(); + super.dispose(); + } +} diff --git a/mobile/lib/services/im_service.dart b/mobile/lib/services/im_service.dart new file mode 100644 index 0000000..d8cee1a --- /dev/null +++ b/mobile/lib/services/im_service.dart @@ -0,0 +1,289 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_openim_sdk/flutter_openim_sdk.dart'; +import 'package:path_provider/path_provider.dart'; + +import '../config.dart'; +import '../models/signaling.dart'; + +/// SDK 与服务器的连接状态 +enum ConnectStatus { idle, connecting, success, failed } + +/// 通话信令的自定义消息类型(与官方样板工程保持一致,保证互通) +class SignalingType { + SignalingType._(); + + static const int callingInvite = 200; // 发起呼叫 + static const int callingAccept = 201; // 接听 + static const int callingReject = 202; // 拒接 + static const int callingCancel = 203; // 取消呼叫 + static const int callingHungup = 204; // 挂断 +} + +/// OpenIM SDK 的统一封装:初始化、登录、监听、会话列表。 +/// 用 ChangeNotifier 做状态分发,不引入额外状态管理框架。 +class IMService extends ChangeNotifier { + IMService._(); + + static final IMService instance = IMService._(); + + /// SDK 是否已初始化(App 启动时做一次) + bool sdkReady = false; + + /// 与服务器的连接状态(断网横幅用) + ConnectStatus connectStatus = ConnectStatus.idle; + + /// 是否已登录 + bool loggedIn = false; + + /// 会话首次同步中(消息列表的「正在加载」态) + bool syncing = true; + + /// 会话同步失败(消息列表的「加载失败」态) + bool syncFailed = false; + + /// 会话同步是否完成过至少一次(区分「正在加载」和「还没有消息」) + bool conversationsLoaded = false; + + /// 会话列表(置顶在前,其余按最新消息时间倒序) + List conversations = []; + + /// 当前登录用户信息 + UserInfo? selfInfo; + + /// 当前登录凭证(发起通话取 LiveKit token 时要用) + String? currentUserID; + String? currentToken; + + /// 被踢下线 / token 失效时的回调(由 main.dart 设置:清缓存、回登录页) + void Function()? onForceLogout; + + /// 收到新消息(聊天页订阅) + final StreamController _newMsgController = StreamController.broadcast(); + Stream get onNewMessage => _newMsgController.stream; + + /// 消息被撤回(撤回方的 clientMsgID,聊天页订阅) + final StreamController _revokeController = StreamController.broadcast(); + Stream get onMessageRevoked => _revokeController.stream; + + /// 收到通话信令(仅在线自定义消息,通话模块订阅) + final StreamController _signalingController = StreamController.broadcast(); + Stream get onSignaling => _signalingController.stream; + + int get platformID => Platform.isIOS ? IMPlatform.ios : IMPlatform.android; + + /// App 启动时初始化 SDK(只做一次) + Future init() async { + if (sdkReady) return; + final dir = await getApplicationDocumentsDirectory(); + final dataDir = '${dir.path}/openim'; + await Directory(dataDir).create(recursive: true); + final ok = await OpenIM.iMManager.initSDK( + platformID: platformID, + apiAddr: apiAddr, + wsAddr: wsAddr, + dataDir: dataDir, + logFilePath: dataDir, + logLevel: 6, + listener: OnConnectListener( + onConnecting: () { + connectStatus = ConnectStatus.connecting; + notifyListeners(); + }, + onConnectSuccess: () { + connectStatus = ConnectStatus.success; + notifyListeners(); + }, + onConnectFailed: (code, error) { + connectStatus = ConnectStatus.failed; + notifyListeners(); + }, + onKickedOffline: _forceLogout, + onUserTokenExpired: _forceLogout, + onUserTokenInvalid: _forceLogout, + ), + ); + sdkReady = ok == true; + _setBusinessListeners(); + notifyListeners(); + } + + void _setBusinessListeners() { + OpenIM.iMManager.conversationManager.setConversationListener( + OnConversationListener( + onSyncServerStart: (reInstall) { + syncing = true; + syncFailed = false; + notifyListeners(); + }, + onSyncServerFinish: (reInstall) { + syncing = false; + conversationsLoaded = true; + refreshConversations(); + }, + onSyncServerFailed: (reInstall) { + syncing = false; + syncFailed = true; + notifyListeners(); + }, + onConversationChanged: (list) => refreshConversations(), + onNewConversation: (list) => refreshConversations(), + onTotalUnreadMessageCountChanged: (count) => notifyListeners(), + ), + ); + OpenIM.iMManager.messageManager.setAdvancedMsgListener( + OnAdvancedMsgListener( + onRecvNewMessage: (msg) { + _newMsgController.add(msg); + }, + onRecvOfflineNewMessage: (msg) { + _newMsgController.add(msg); + }, + onNewRecvMessageRevoked: (info) { + if (info.clientMsgID != null) _revokeController.add(info.clientMsgID!); + }, + onRecvOnlineOnlyMessage: (msg) { + // 通话信令走「仅在线」的自定义消息,转发给通话模块 + if (msg.contentType == MessageType.custom) { + _signalingController.add(msg); + } + }, + ), + ); + // 好友与群变更只负责刷新界面(通讯录页直接监听 IMService) + OpenIM.iMManager.friendshipManager.setFriendshipListener( + OnFriendshipListener( + onFriendAdded: (info) => notifyListeners(), + onFriendDeleted: (info) => notifyListeners(), + onFriendInfoChanged: (info) => notifyListeners(), + onFriendApplicationAdded: (info) => notifyListeners(), + onFriendApplicationAccepted: (info) => notifyListeners(), + onFriendApplicationRejected: (info) => notifyListeners(), + ), + ); + OpenIM.iMManager.groupManager.setGroupListener( + OnGroupListener( + onJoinedGroupAdded: (info) => notifyListeners(), + onJoinedGroupDeleted: (info) => notifyListeners(), + onGroupInfoChanged: (info) => notifyListeners(), + ), + ); + } + + /// 登录(init 之后调用)。userID/token 来自公司账号登录接口。 + Future login({required String userID, required String token}) async { + await init(); + final user = await OpenIM.iMManager.login( + userID: userID, + token: token, + defaultValue: () async => UserInfo(userID: userID), + ); + currentUserID = userID; + currentToken = token; + selfInfo = user; + loggedIn = true; + notifyListeners(); + } + + /// 刷新自己的资料(我的页面展示用) + Future refreshSelfInfo() async { + if (!loggedIn) return; + try { + selfInfo = await OpenIM.iMManager.userManager.getSelfUserInfo(); + notifyListeners(); + } catch (_) { + // 拉取失败沿用内存里的旧数据 + } + } + + /// 退出登录 + Future logout() async { + try { + await OpenIM.iMManager.logout(); + } catch (_) { + // 本地照常清理 + } + _resetLoginState(); + } + + void _forceLogout() { + _resetLoginState(); + onForceLogout?.call(); + } + + void _resetLoginState() { + loggedIn = false; + currentUserID = null; + currentToken = null; + selfInfo = null; + conversations = []; + conversationsLoaded = false; + syncing = true; + syncFailed = false; + notifyListeners(); + } + + /// 重新拉取全部会话并排序(置顶在前,其余按最新消息时间倒序) + Future refreshConversations() async { + if (!loggedIn) return; + try { + final list = await OpenIM.iMManager.conversationManager.getAllConversationList(); + list.sort((a, b) { + final ap = a.isPinned == true ? 0 : 1; + final bp = b.isPinned == true ? 0 : 1; + if (ap != bp) return ap - bp; + return (b.latestMsgSendTime ?? 0).compareTo(a.latestMsgSendTime ?? 0); + }); + conversations = list; + conversationsLoaded = true; + notifyListeners(); + } catch (_) { + // 拉取失败保留现有列表 + } + } + + /// 进入聊天页后清除该会话未读 + Future markConversationRead(String conversationID) async { + try { + await OpenIM.iMManager.conversationManager.markConversationMessageAsRead(conversationID: conversationID); + } catch (_) { + // 标记失败不影响聊天 + } + } + + /// 解析「仅在线」自定义消息里的通话信令,不是通话信令返回 null + static SignalingPayload? parseSignaling(Message msg) { + try { + final data = msg.customElem?.data; + if (data == null || data.isEmpty) return null; + final map = jsonDecode(data) as Map; + final customType = map['customType']; + if (customType is! int || customType < 200 || customType > 204) return null; + final payload = SignalingPayload( + type: customType, + invitation: InvitationInfo.fromJson(Map.from(map['data'] ?? {})), + ); + return payload; + } catch (_) { + return null; + } + } +} + +/// 一条通话信令 +class SignalingPayload { + /// 见 [SignalingType] + final int type; + final InvitationInfo invitation; + + SignalingPayload({required this.type, required this.invitation}); + + /// 房间号 + String? get roomID => invitation.roomID; + + /// 呼叫发起人 + String? get inviterUserID => invitation.inviterUserID; +} diff --git a/mobile/lib/theme.dart b/mobile/lib/theme.dart new file mode 100644 index 0000000..fbb5bed --- /dev/null +++ b/mobile/lib/theme.dart @@ -0,0 +1,114 @@ +import 'package:flutter/material.dart'; + +/// 全 App 统一的视觉常量:颜色、字号阶梯、间距。 +/// 字号只保留 4 级,间距全部取 4 的倍数,保证两端(手机/电脑)观感一致。 +class AppColors { + AppColors._(); + + /// 主色蓝(按钮、选中态、头像蓝) + static const Color primary = Color(0xFF3B87F5); + + /// 头像灰蓝 + static const Color avatarGray = Color(0xFF8593A8); + + /// 聊天页背景 + static const Color chatBg = Color(0xFFF5F6F8); + + /// 页面背景 + static const Color pageBg = Color(0xFFFFFFFF); + + /// 己方气泡浅蓝 + static const Color bubbleMine = Color(0xFFD6E7FC); + + /// 对方气泡白 + static const Color bubbleOther = Color(0xFFFFFFFF); + + /// 搜索框灰底 + static const Color searchBg = Color(0xFFF2F3F5); + + /// 置顶会话底色(略灰) + static const Color pinnedBg = Color(0xFFF7F8FA); + + /// 主要文字 + static const Color textPrimary = Color(0xFF1D2129); + + /// 次要文字(预览、时间、提示) + static const Color textSecondary = Color(0xFF86909C); + + /// 危险/未读红 + static const Color danger = Color(0xFFF53F3F); + + /// 分隔线 + static const Color divider = Color(0xFFEEEEEE); + + /// 断网横幅深色底 + static const Color bannerBg = Color(0xFF3A3F47); +} + +/// 字号阶梯(全 App 只用这 4 级) +class AppFont { + AppFont._(); + + /// 辅助文字:时间、角标、提示小字 + static const double small = 12; + + /// 次要文字:预览、职务、副标题 + static const double sub = 14; + + /// 正文:名字、消息内容、按钮 + static const double body = 16; + + /// 标题:导航栏、页面大标题 + static const double title = 18; +} + +/// 间距常量(4 的倍数) +class AppGap { + AppGap._(); + + static const double x1 = 4; + static const double x2 = 8; + static const double x3 = 12; + static const double x4 = 16; + static const double x6 = 24; + static const double x8 = 32; + static const double x12 = 48; +} + +/// 统一的主题 +ThemeData buildAppTheme() { + return ThemeData( + useMaterial3: true, + primaryColor: AppColors.primary, + scaffoldBackgroundColor: AppColors.pageBg, + colorScheme: ColorScheme.fromSeed( + seedColor: AppColors.primary, + primary: AppColors.primary, + error: AppColors.danger, + ), + appBarTheme: const AppBarTheme( + backgroundColor: AppColors.pageBg, + foregroundColor: AppColors.textPrimary, + elevation: 0, + scrolledUnderElevation: 0, + centerTitle: true, + titleTextStyle: TextStyle( + color: AppColors.textPrimary, + fontSize: AppFont.title, + fontWeight: FontWeight.w600, + ), + ), + bottomNavigationBarTheme: const BottomNavigationBarThemeData( + selectedItemColor: AppColors.primary, + unselectedItemColor: AppColors.textSecondary, + selectedLabelStyle: TextStyle(fontSize: AppFont.small), + unselectedLabelStyle: TextStyle(fontSize: AppFont.small), + type: BottomNavigationBarType.fixed, + ), + dividerTheme: const DividerThemeData( + color: AppColors.divider, + thickness: 0.5, + space: 0, + ), + ); +} diff --git a/mobile/lib/utils/format.dart b/mobile/lib/utils/format.dart new file mode 100644 index 0000000..fbf6017 --- /dev/null +++ b/mobile/lib/utils/format.dart @@ -0,0 +1,121 @@ +import 'package:flutter_openim_sdk/flutter_openim_sdk.dart'; + +/// 时间与消息预览的格式化工具,全部用户可见文案集中在这里,避免出现英文残留。 +class FormatUtils { + FormatUtils._(); + + static const List _weekdays = ['星期一', '星期二', '星期三', '星期四', '星期五', '星期六', '星期日']; + + static String _two(int v) => v.toString().padLeft(2, '0'); + + /// 消息列表右侧时间:当天 HH:mm、昨天、一周内星期X、更早显示日期 + static String conversationTime(int? millis) { + if (millis == null || millis <= 0) return ''; + final t = DateTime.fromMillisecondsSinceEpoch(millis); + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + final day = DateTime(t.year, t.month, t.day); + final diff = today.difference(day).inDays; + if (diff <= 0) { + return '${t.hour}:${_two(t.minute)}'; + } else if (diff == 1) { + return '昨天'; + } else if (diff < 7) { + return _weekdays[t.weekday - 1]; + } else if (t.year == now.year) { + return '${t.month}月${t.day}日'; + } + return '${t.year}年${t.month}月${t.day}日'; + } + + /// 聊天页时间分隔条:当天「上午 9:30」,非当天带日期 + static String chatDividerTime(int millis) { + final t = DateTime.fromMillisecondsSinceEpoch(millis); + final now = DateTime.now(); + final isToday = t.year == now.year && t.month == now.month && t.day == now.day; + final period = t.hour < 6 + ? '凌晨' + : t.hour < 12 + ? '上午' + : t.hour < 13 + ? '中午' + : t.hour < 18 + ? '下午' + : '晚上'; + final hour12 = t.hour % 12 == 0 ? 12 : t.hour % 12; + final clock = '$period $hour12:${_two(t.minute)}'; + if (isToday) return clock; + if (t.year == now.year) return '${t.month}月${t.day}日 $clock'; + return '${t.year}年${t.month}月${t.day}日 $clock'; + } + + /// 通话时长:mm:ss + static String callDuration(int seconds) { + final m = seconds ~/ 60; + final s = seconds % 60; + return '${_two(m)}:${_two(s)}'; + } + + /// 文件大小:不足 1 KB 显示 B,不足 1 MB 显示 KB,否则 MB + static String fileSize(int? bytes) { + if (bytes == null || bytes < 0) return ''; + if (bytes < 1024) return '$bytes B'; + if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(0)} KB'; + return '${(bytes / 1024 / 1024).toStringAsFixed(1)} MB'; + } + + /// 会话预览文本:群消息带「发送人: 」前缀,语音/文件等按效果图规则 + static String messagePreview(Message? msg, {required bool isGroup}) { + if (msg == null) return ''; + String body; + switch (msg.contentType) { + case MessageType.text: + case MessageType.atText: + case MessageType.quote: + body = msg.textElem?.content ?? ''; + break; + case MessageType.voice: + body = '[语音]'; + break; + case MessageType.picture: + body = '[图片]'; + break; + case MessageType.video: + body = '[视频]'; + break; + case MessageType.file: + final name = msg.fileElem?.fileName ?? ''; + body = name.isEmpty ? '[文件]' : '[文件] $name'; + break; + case MessageType.custom: + body = _customPreview(msg); + break; + default: + if ((msg.contentType ?? 0) >= MessageType.notificationBegin) { + body = '[群通知]'; + } else { + body = '[其他消息]'; + } + } + // 群聊里别人发的消息,预览加「发送人: 」前缀 + if (isGroup && msg.sendID != OpenIM.iMManager.userID && body.isNotEmpty) { + final sender = msg.senderNickname ?? ''; + if (sender.isNotEmpty) return '$sender: $body'; + } + return body; + } + + static String _customPreview(Message msg) { + try { + final data = msg.customElem?.data; + if (data == null || data.isEmpty) return '[其他消息]'; + // 通话相关的自定义消息(信令 200-204、通话记录 901)在会话里统一显示 [语音通话] + if (data.contains('calling') || data.contains('"customType":901')) { + return '[语音通话]'; + } + } catch (_) { + // 解析失败按未知消息处理 + } + return '[其他消息]'; + } +} diff --git a/mobile/lib/widgets/avatar.dart b/mobile/lib/widgets/avatar.dart new file mode 100644 index 0000000..3d715c8 --- /dev/null +++ b/mobile/lib/widgets/avatar.dart @@ -0,0 +1,102 @@ +import 'package:flutter/material.dart'; + +import '../theme.dart'; + +/// 头像(按效果图): +/// - 单人是圆角方块 + 姓氏首字,蓝 / 灰蓝两色按名字 hash 取色; +/// - 群聊是 2x2 小方块拼格; +/// - 有 faceURL 时优先显示网络头像,加载失败回退到首字。 +class NameAvatar extends StatelessWidget { + final String name; + final String? faceURL; + final double size; + + /// 是否群聊样式(2x2 拼格) + final bool isGroup; + + const NameAvatar({ + super.key, + required this.name, + this.faceURL, + this.size = 48, + this.isGroup = false, + }); + + Color _colorFor(String text) { + var hash = 0; + for (final code in text.codeUnits) { + hash = (hash * 31 + code) & 0x7fffffff; + } + return hash % 2 == 0 ? AppColors.primary : AppColors.avatarGray; + } + + @override + Widget build(BuildContext context) { + final radius = BorderRadius.circular(size * 0.18); + if (isGroup) return _groupAvatar(radius); + + final hasFace = faceURL != null && faceURL!.isNotEmpty; + final letter = name.isNotEmpty ? name.substring(0, 1) : ''; + + return ClipRRect( + borderRadius: radius, + child: Container( + width: size, + height: size, + color: _colorFor(name), + alignment: Alignment.center, + child: hasFace + ? Image.network( + faceURL!, + width: size, + height: size, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => _letterText(letter), + loadingBuilder: (_, child, progress) => progress == null ? child : _letterText(letter), + ) + : _letterText(letter), + ), + ); + } + + Widget _letterText(String letter) { + return Text( + letter, + style: TextStyle( + color: Colors.white, + fontSize: size * 0.4, + fontWeight: FontWeight.w500, + ), + ); + } + + /// 群头像:浅灰底上 2x2 小方块,蓝 / 灰蓝交替 + Widget _groupAvatar(BorderRadius radius) { + final cell = (size - 10) / 2; + const colors = [AppColors.primary, AppColors.avatarGray, AppColors.avatarGray, AppColors.primary]; + return ClipRRect( + borderRadius: radius, + child: Container( + width: size, + height: size, + color: AppColors.searchBg, + padding: const EdgeInsets.all(2), + child: Wrap( + spacing: 2, + runSpacing: 2, + children: List.generate( + 4, + (i) => Container( + width: cell, + height: cell, + decoration: BoxDecoration( + color: colors[i], + borderRadius: BorderRadius.circular(2), + ), + ), + ), + ), + ), + ); + } +} diff --git a/mobile/lib/widgets/chat_input_bar.dart b/mobile/lib/widgets/chat_input_bar.dart new file mode 100644 index 0000000..5a82a45 --- /dev/null +++ b/mobile/lib/widgets/chat_input_bar.dart @@ -0,0 +1,304 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:record/record.dart'; + +import '../theme.dart'; + +/// 聊天底部输入栏: +/// 左侧麦克风/键盘切换、中间输入框(语音模式变成「按住 说话」)、右侧表情和加号。 +/// 有文字时加号变成「发送」按钮(微信式交互)。 +class ChatInputBar extends StatefulWidget { + /// 发送文字 + final void Function(String text) onSendText; + + /// 发送语音(本地文件路径 + 秒数) + final void Function(String path, int duration) onSendVoice; + + /// 从相册选图片发送 + final VoidCallback onPickImage; + + /// 选文件发送 + final VoidCallback onPickFile; + + /// 发起语音通话(群聊为 null,不显示该入口) + final VoidCallback? onVoiceCall; + + const ChatInputBar({ + super.key, + required this.onSendText, + required this.onSendVoice, + required this.onPickImage, + required this.onPickFile, + this.onVoiceCall, + }); + + @override + State createState() => _ChatInputBarState(); +} + +class _ChatInputBarState extends State { + final TextEditingController _ctrl = TextEditingController(); + final FocusNode _focus = FocusNode(); + final AudioRecorder _recorder = AudioRecorder(); + + bool _voiceMode = false; + bool _panelOpen = false; + bool _recording = false; + bool _hasText = false; + String? _recordPath; + int _recordStart = 0; + Timer? _recordTimer; + + /// 最长录音 60 秒 + static const int _maxRecordSec = 60; + + /// 常用表情(表情按钮点开的小面板,不加第三方表情包) + static const List _emojis = [ + '😀', '😄', '😂', '🤣', '😊', '😍', '🤔', '😅', + '👍', '👌', '🙏', '👏', '💪', '🎉', '❤️', '😢', + ]; + + @override + void dispose() { + _recordTimer?.cancel(); + _recorder.dispose(); + _ctrl.dispose(); + _focus.dispose(); + super.dispose(); + } + + void _sendText() { + final text = _ctrl.text.trim(); + if (text.isEmpty) return; + widget.onSendText(text); + _ctrl.clear(); + setState(() => _hasText = false); + } + + // ---------------- 录音 ---------------- + + Future _startRecord() async { + try { + if (!await _recorder.hasPermission()) { + _toast('需要麦克风权限才能发语音'); + return; + } + final dir = await getApplicationDocumentsDirectory(); + final path = '${dir.path}/voice/${DateTime.now().millisecondsSinceEpoch}.m4a'; + await File(path).create(recursive: true); + await _recorder.start(const RecordConfig(), path: path); + _recordPath = path; + _recordStart = DateTime.now().millisecondsSinceEpoch; + setState(() => _recording = true); + _recordTimer?.cancel(); + _recordTimer = Timer(const Duration(seconds: _maxRecordSec), () => _stopRecord(send: true)); + } catch (_) { + _toast('录音启动失败,请重试'); + } + } + + Future _stopRecord({required bool send}) async { + _recordTimer?.cancel(); + _recordTimer = null; + if (!await _recorder.isRecording()) { + if (mounted) setState(() => _recording = false); + return; + } + await _recorder.stop(); + if (mounted) setState(() => _recording = false); + final path = _recordPath; + if (!send || path == null) return; + final duration = (DateTime.now().millisecondsSinceEpoch - _recordStart) ~/ 1000; + if (duration < 1) { + _toast('说话时间太短'); + return; + } + widget.onSendVoice(path, duration); + } + + void _toast(String text) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(text), duration: const Duration(seconds: 2))); + } + + // ---------------- 表情面板 ---------------- + + void _showEmojiPanel() { + _focus.unfocus(); + showModalBottomSheet( + context: context, + builder: (ctx) => SafeArea( + child: GridView.count( + crossAxisCount: 8, + shrinkWrap: true, + padding: const EdgeInsets.all(AppGap.x3), + children: _emojis + .map( + (e) => InkWell( + onTap: () { + _ctrl.text += e; + _ctrl.selection = TextSelection.collapsed(offset: _ctrl.text.length); + setState(() => _hasText = true); + Navigator.of(ctx).pop(); + }, + child: Center(child: Text(e, style: const TextStyle(fontSize: 24))), + ), + ) + .toList(), + ), + ), + ); + } + + // ---------------- 界面 ---------------- + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + color: AppColors.pageBg, + padding: const EdgeInsets.symmetric(horizontal: AppGap.x2, vertical: AppGap.x2), + child: Row( + children: [ + // 麦克风 / 键盘切换 + _circleButton( + _voiceMode ? Icons.keyboard_outlined : Icons.mic_none, + () => setState(() => _voiceMode = !_voiceMode), + ), + const SizedBox(width: AppGap.x2), + Expanded(child: _voiceMode ? _holdToTalk() : _textField()), + const SizedBox(width: AppGap.x2), + _circleButton(Icons.sentiment_satisfied_alt, _showEmojiPanel), + const SizedBox(width: AppGap.x2), + if (_hasText) + FilledButton( + onPressed: _sendText, + style: FilledButton.styleFrom( + backgroundColor: AppColors.primary, + padding: const EdgeInsets.symmetric(horizontal: AppGap.x4), + minimumSize: const Size(0, 40), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + child: const Text('发送', style: TextStyle(fontSize: AppFont.sub)), + ) + else + _circleButton(Icons.add_circle_outline, () { + _focus.unfocus(); + setState(() => _panelOpen = !_panelOpen); + }), + ], + ), + ), + if (_panelOpen) _plusPanel(), + ], + ); + } + + Widget _circleButton(IconData icon, VoidCallback onTap) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(20), + child: Padding( + padding: const EdgeInsets.all(AppGap.x1), + child: Icon(icon, size: 28, color: AppColors.textPrimary), + ), + ); + } + + Widget _textField() { + return Container( + constraints: const BoxConstraints(minHeight: 40), + decoration: BoxDecoration( + color: AppColors.searchBg, + borderRadius: BorderRadius.circular(8), + ), + child: TextField( + controller: _ctrl, + focusNode: _focus, + textInputAction: TextInputAction.send, + onSubmitted: (_) => _sendText(), + onChanged: (v) => setState(() => _hasText = v.trim().isNotEmpty), + onTap: () => setState(() => _panelOpen = false), + maxLines: 4, + minLines: 1, + decoration: const InputDecoration( + border: InputBorder.none, + isDense: true, + contentPadding: EdgeInsets.symmetric(horizontal: AppGap.x3, vertical: 10), + ), + ), + ); + } + + /// 语音模式下的「按住 说话」按钮 + Widget _holdToTalk() { + return GestureDetector( + onLongPressStart: (_) => _startRecord(), + onLongPressEnd: (_) => _stopRecord(send: true), + onLongPressCancel: () => _stopRecord(send: false), + child: Container( + height: 40, + alignment: Alignment.center, + decoration: BoxDecoration( + color: _recording ? AppColors.primary : AppColors.searchBg, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + _recording ? '松开 发送' : '按住 说话', + style: TextStyle( + fontSize: AppFont.body, + color: _recording ? Colors.white : AppColors.textPrimary, + ), + ), + ), + ); + } + + /// 加号面板:图片 / 文件 / 语音通话(单聊) + Widget _plusPanel() { + return Container( + color: AppColors.chatBg, + padding: const EdgeInsets.all(AppGap.x6), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _panelItem(Icons.image_outlined, '图片', widget.onPickImage), + _panelItem(Icons.insert_drive_file_outlined, '文件', widget.onPickFile), + if (widget.onVoiceCall != null) _panelItem(Icons.phone_outlined, '语音通话', widget.onVoiceCall!), + ], + ), + ); + } + + Widget _panelItem(IconData icon, String label, VoidCallback onTap) { + return Padding( + padding: const EdgeInsets.only(right: AppGap.x6), + child: InkWell( + onTap: () { + setState(() => _panelOpen = false); + onTap(); + }, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: AppColors.pageBg, + borderRadius: BorderRadius.circular(12), + ), + child: Icon(icon, size: 28, color: AppColors.textPrimary), + ), + const SizedBox(height: AppGap.x1), + Text(label, style: const TextStyle(fontSize: AppFont.small, color: AppColors.textSecondary)), + ], + ), + ), + ); + } +} diff --git a/mobile/lib/widgets/message_bubble.dart b/mobile/lib/widgets/message_bubble.dart new file mode 100644 index 0000000..d8b4935 --- /dev/null +++ b/mobile/lib/widgets/message_bubble.dart @@ -0,0 +1,288 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_openim_sdk/flutter_openim_sdk.dart'; + +import '../theme.dart'; +import '../utils/format.dart'; +import 'avatar.dart'; + +/// 聊天页的消息气泡:文字 / 语音条 / 文件卡片 / 图片四种, +/// 外加发送中(小菊花)与发送失败(红色感叹号)两种状态。 +class MessageBubble extends StatelessWidget { + final Message message; + + /// 是否己方发送 + final bool isMine; + + /// 己方头像信息 + final String selfName; + final String? selfFaceURL; + + /// 对方头像信息(群聊里每条消息带各自发送者信息) + final String peerName; + final String? peerFaceURL; + + /// 群聊中是否显示发送者昵称(对方消息) + final bool showSenderName; + + /// 点击语音气泡(播放) + final void Function(Message message)? onTapVoice; + + /// 点击文件气泡(打开 / 下载) + final void Function(Message message)? onTapFile; + + /// 点击红色感叹号重发 + final void Function(Message message)? onResend; + + /// 正在播放的语音消息 ID(用于显示播放中状态,可为空) + final String? playingVoiceId; + + const MessageBubble({ + super.key, + required this.message, + required this.isMine, + this.selfName = '', + this.selfFaceURL, + this.peerName = '', + this.peerFaceURL, + this.showSenderName = false, + this.onTapVoice, + this.onTapFile, + this.onResend, + this.playingVoiceId, + }); + + bool get _failed => message.status == MessageStatus.failed; + bool get _sending => message.status == MessageStatus.sending; + + @override + Widget build(BuildContext context) { + final avatar = NameAvatar( + name: isMine ? selfName : (message.senderNickname?.isNotEmpty == true ? message.senderNickname! : peerName), + faceURL: isMine ? selfFaceURL : (message.senderFaceUrl?.isNotEmpty == true ? message.senderFaceUrl : peerFaceURL), + size: 40, + ); + + final bubble = ConstrainedBox( + constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.65), + child: _buildContent(context), + ); + + final statusIcon = _buildStatus(); + + final row = Row( + mainAxisAlignment: isMine ? MainAxisAlignment.end : MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: isMine + ? [statusIcon, Flexible(child: bubble), const SizedBox(width: AppGap.x2), avatar] + : [avatar, const SizedBox(width: AppGap.x2), Flexible(child: bubble), statusIcon], + ); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: AppGap.x3, vertical: AppGap.x2), + child: Column( + crossAxisAlignment: isMine ? CrossAxisAlignment.end : CrossAxisAlignment.start, + children: [ + if (showSenderName && !isMine && (message.senderNickname?.isNotEmpty == true)) + Padding( + padding: const EdgeInsets.only(left: 48, bottom: AppGap.x1), + child: Text(message.senderNickname!, style: const TextStyle(fontSize: AppFont.small, color: AppColors.textSecondary)), + ), + row, + // 发送失败提示(效果图:气泡下方灰字) + if (_failed) + const Padding( + padding: EdgeInsets.only(top: AppGap.x1), + child: Text( + '消息没发出去,点红色感叹号重发', + style: TextStyle(fontSize: AppFont.small, color: AppColors.textSecondary), + ), + ), + ], + ), + ); + } + + /// 发送中小菊花 / 失败红色感叹号 + Widget _buildStatus() { + if (_sending) { + return const Padding( + padding: EdgeInsets.symmetric(horizontal: AppGap.x2, vertical: AppGap.x3), + child: SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2, color: AppColors.textSecondary)), + ); + } + if (_failed) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: AppGap.x2, vertical: AppGap.x3), + child: GestureDetector( + onTap: () => onResend?.call(message), + child: const Icon(Icons.error, size: 20, color: AppColors.danger), + ), + ); + } + return const SizedBox.shrink(); + } + + Widget _buildContent(BuildContext context) { + switch (message.contentType) { + case MessageType.voice: + return _voiceBubble(); + case MessageType.file: + return _fileCard(); + case MessageType.picture: + return _imageBubble(); + case MessageType.custom: + return _textBubble(FormatUtils.messagePreview(message, isGroup: false)); + default: + if ((message.contentType ?? 0) >= MessageType.notificationBegin) { + // 通知类消息:居中灰字(如入群通知) + return const SizedBox.shrink(); + } + return _textBubble(message.textElem?.content ?? ''); + } + } + + /// 文字气泡:己方浅蓝、对方白色 + Widget _textBubble(String text) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: AppGap.x3, vertical: AppGap.x2), + decoration: BoxDecoration( + color: isMine ? AppColors.bubbleMine : AppColors.bubbleOther, + borderRadius: BorderRadius.circular(8), + ), + child: Text(text, style: const TextStyle(fontSize: AppFont.body, color: AppColors.textPrimary)), + ); + } + + /// 语音气泡:喇叭图标 + 秒数;未读语音右侧小红点 + Widget _voiceBubble() { + final duration = message.soundElem?.duration ?? 0; + final playing = playingVoiceId != null && playingVoiceId == message.clientMsgID; + // 宽度随时长增加,封顶 + final width = 72.0 + (duration > 30 ? 60 : duration * 2); + final unread = !isMine && message.isRead == false; + + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + GestureDetector( + onTap: () => onTapVoice?.call(message), + child: Container( + width: width, + padding: const EdgeInsets.symmetric(horizontal: AppGap.x3, vertical: AppGap.x2), + decoration: BoxDecoration( + color: isMine ? AppColors.bubbleMine : AppColors.bubbleOther, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: isMine ? MainAxisAlignment.end : MainAxisAlignment.start, + children: isMine + ? [ + Text('$duration″', style: const TextStyle(fontSize: AppFont.body, color: AppColors.textPrimary)), + const SizedBox(width: AppGap.x2), + Icon(playing ? Icons.volume_up : Icons.volume_up_outlined, size: 20, color: AppColors.textPrimary), + ] + : [ + Icon(playing ? Icons.volume_up : Icons.volume_up_outlined, size: 20, color: AppColors.textPrimary), + const SizedBox(width: AppGap.x2), + Text('$duration″', style: const TextStyle(fontSize: AppFont.body, color: AppColors.textPrimary)), + ], + ), + ), + ), + if (unread) + Container( + width: 8, + height: 8, + margin: const EdgeInsets.only(left: AppGap.x1), + decoration: const BoxDecoration(color: AppColors.danger, shape: BoxShape.circle), + ), + ], + ); + } + + /// 文件气泡:卡片样式(文件图标、文件名、大小) + Widget _fileCard() { + final name = message.fileElem?.fileName ?? '未知文件'; + final size = FormatUtils.fileSize(message.fileElem?.fileSize); + return GestureDetector( + onTap: () => onTapFile?.call(message), + child: Container( + padding: const EdgeInsets.all(AppGap.x3), + decoration: BoxDecoration( + color: isMine ? AppColors.bubbleMine : AppColors.bubbleOther, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: AppColors.pageBg, + borderRadius: BorderRadius.circular(8), + ), + child: const Icon(Icons.insert_drive_file_outlined, size: 28, color: AppColors.primary), + ), + const SizedBox(width: AppGap.x2), + Flexible( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: AppFont.body, color: AppColors.textPrimary), + ), + const SizedBox(height: AppGap.x1), + Text(size, style: const TextStyle(fontSize: AppFont.small, color: AppColors.textSecondary)), + ], + ), + ), + ], + ), + ), + ); + } + + /// 图片气泡:本地优先,其次网络图 + Widget _imageBubble() { + final localPath = message.pictureElem?.sourcePath; + final url = message.pictureElem?.snapshotPicture?.url ?? message.pictureElem?.sourcePicture?.url; + Widget child; + if (localPath != null && localPath.isNotEmpty && File(localPath).existsSync()) { + child = Image.file(File(localPath), fit: BoxFit.cover); + } else if (url != null && url.isNotEmpty) { + child = Image.network( + url, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => _imagePlaceholder(), + loadingBuilder: (_, c, p) => p == null ? c : _imagePlaceholder(), + ); + } else { + child = _imagePlaceholder(); + } + return ClipRRect( + borderRadius: BorderRadius.circular(8), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 160, maxHeight: 200), + child: child, + ), + ); + } + + Widget _imagePlaceholder() { + return Container( + width: 120, + height: 120, + color: AppColors.searchBg, + alignment: Alignment.center, + child: const Icon(Icons.image_outlined, size: 40, color: AppColors.textSecondary), + ); + } +} diff --git a/mobile/lib/widgets/search_box.dart b/mobile/lib/widgets/search_box.dart new file mode 100644 index 0000000..7517242 --- /dev/null +++ b/mobile/lib/widgets/search_box.dart @@ -0,0 +1,67 @@ +import 'package:flutter/material.dart'; + +import '../theme.dart'; + +/// 消息列表 / 通讯录共用的搜索框(效果图 m1/m3): +/// 未输入时「放大镜 + 搜索」居中显示,输入后正常左对齐。 +class SearchBox extends StatefulWidget { + final ValueChanged onChanged; + + const SearchBox({super.key, required this.onChanged}); + + @override + State createState() => _SearchBoxState(); +} + +class _SearchBoxState extends State { + final TextEditingController _ctrl = TextEditingController(); + + @override + void dispose() { + _ctrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: AppGap.x3, vertical: AppGap.x2), + child: Stack( + alignment: Alignment.centerLeft, + children: [ + TextField( + controller: _ctrl, + onChanged: (v) { + setState(() {}); + widget.onChanged(v.trim()); + }, + decoration: InputDecoration( + filled: true, + fillColor: AppColors.searchBg, + isDense: true, + contentPadding: const EdgeInsets.symmetric(horizontal: AppGap.x3, vertical: AppGap.x2), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide.none, + ), + ), + ), + // 空内容时的居中占位(不拦截点击,点按穿透到输入框) + if (_ctrl.text.isEmpty) + const IgnorePointer( + child: Center( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.search, size: 20, color: AppColors.textSecondary), + SizedBox(width: AppGap.x1), + Text('搜索', style: TextStyle(fontSize: AppFont.sub, color: AppColors.textSecondary)), + ], + ), + ), + ), + ], + ), + ); + } +} diff --git a/mobile/lib/widgets/state_views.dart b/mobile/lib/widgets/state_views.dart new file mode 100644 index 0000000..4b6021b --- /dev/null +++ b/mobile/lib/widgets/state_views.dart @@ -0,0 +1,134 @@ +import 'package:flutter/material.dart'; + +import '../theme.dart'; + +/// 空 / 加载中 / 加载失败 / 断网 四态组件,文案严格按效果图 states.png。 + +/// 空态:还没有消息 +class EmptyConversations extends StatelessWidget { + /// 「去找同事」按钮点击(跳到通讯录 Tab) + final VoidCallback? onFindContacts; + + const EmptyConversations({super.key, this.onFindContacts}); + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.chat_bubble_outline, size: 64, color: AppColors.divider), + const SizedBox(height: AppGap.x6), + const Text('还没有消息', style: TextStyle(fontSize: AppFont.body, color: AppColors.textPrimary)), + const SizedBox(height: AppGap.x2), + const Text('去通讯录找同事聊聊吧', style: TextStyle(fontSize: AppFont.sub, color: AppColors.textSecondary)), + const SizedBox(height: AppGap.x6), + FilledButton( + onPressed: onFindContacts, + style: FilledButton.styleFrom( + backgroundColor: AppColors.primary, + padding: const EdgeInsets.symmetric(horizontal: AppGap.x6, vertical: AppGap.x3), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + child: const Text('去找同事', style: TextStyle(fontSize: AppFont.body)), + ), + ], + ), + ); + } +} + +/// 加载中 +class LoadingConversations extends StatelessWidget { + const LoadingConversations({super.key}); + + @override + Widget build(BuildContext context) { + return const Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 32, + height: 32, + child: CircularProgressIndicator(strokeWidth: 3, color: AppColors.primary), + ), + SizedBox(height: AppGap.x6), + Text('正在加载', style: TextStyle(fontSize: AppFont.body, color: AppColors.textPrimary)), + SizedBox(height: AppGap.x2), + Text('消息马上就好,请稍等', style: TextStyle(fontSize: AppFont.sub, color: AppColors.textSecondary)), + ], + ), + ); + } +} + +/// 加载失败 +class ErrorConversations extends StatelessWidget { + final VoidCallback? onRetry; + + const ErrorConversations({super.key, this.onRetry}); + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, size: 64, color: AppColors.divider), + const SizedBox(height: AppGap.x6), + const Text('消息没加载出来', style: TextStyle(fontSize: AppFont.body, color: AppColors.textPrimary)), + const SizedBox(height: AppGap.x2), + const Text('检查一下网络,再点重试', style: TextStyle(fontSize: AppFont.sub, color: AppColors.textSecondary)), + const SizedBox(height: AppGap.x6), + OutlinedButton( + onPressed: onRetry, + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.primary, + side: const BorderSide(color: AppColors.primary), + padding: const EdgeInsets.symmetric(horizontal: AppGap.x6, vertical: AppGap.x3), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + child: const Text('重试', style: TextStyle(fontSize: AppFont.body)), + ), + ], + ), + ); + } +} + +/// 断网时顶部的深色横幅 +class OfflineBanner extends StatelessWidget { + const OfflineBanner({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + color: AppColors.bannerBg, + padding: const EdgeInsets.symmetric(horizontal: AppGap.x4, vertical: AppGap.x2), + child: const Row( + children: [ + Icon(Icons.error_outline, size: 16, color: Colors.white), + SizedBox(width: AppGap.x2), + Text('当前网络不可用,请检查网络连接', style: TextStyle(fontSize: AppFont.sub, color: Colors.white)), + ], + ), + ); + } +} + +/// 断网时列表底部的灰字提示 +class OfflineFooter extends StatelessWidget { + const OfflineFooter({super.key}); + + @override + Widget build(BuildContext context) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: AppGap.x4), + child: Center( + child: Text('老消息还能看,新消息联网后自动收到', style: TextStyle(fontSize: AppFont.small, color: AppColors.textSecondary)), + ), + ); + } +} diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock new file mode 100644 index 0000000..b5892cc --- /dev/null +++ b/mobile/pubspec.lock @@ -0,0 +1,999 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + audio_session: + dependency: transitive + description: + name: audio_session + sha256: f9e7711a0e24ca8b40f5d7ac374c3c6e55016f3157912badd18199cdbbca5c4d + url: "https://pub.dev" + source: hosted + version: "0.2.4" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + connectivity_plus: + dependency: transitive + description: + name: connectivity_plus + sha256: b5e72753cf63becce2c61fd04dfe0f1c430cc5278b53a1342dc5ad839eab29ec + url: "https://pub.dev" + source: hosted + version: "6.1.5" + connectivity_plus_platform_interface: + dependency: transitive + description: + name: connectivity_plus_platform_interface + sha256: "3c09627c536d22fd24691a905cdd8b14520de69da52c7a97499c8be5284a32ed" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "92c9c43c383bfa1c32079d3bc492d55d6d4318044b7b47edaff8971cbb555c51" + url: "https://pub.dev" + source: hosted + version: "0.3.5+4" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 + url: "https://pub.dev" + source: hosted + version: "1.0.8" + dart_webrtc: + dependency: transitive + description: + name: dart_webrtc + sha256: f6d615bddea5e458ce180a914f3055c234ffb52fb7397a51b3491e76d6d7edb2 + url: "https://pub.dev" + source: hosted + version: "1.8.1" + dbus: + dependency: transitive + description: + name: dbus + sha256: "0ce9b0a839e6dee59a37a623d2fc26a35bbbe6404213e419b0d6411023d62645" + url: "https://pub.dev" + source: hosted + version: "0.7.14" + device_info_plus: + dependency: transitive + description: + name: device_info_plus + sha256: "98f28b42168cc509abc92f88518882fd58061ea372d7999aecc424345c7bff6a" + url: "https://pub.dev" + source: hosted + version: "11.5.0" + device_info_plus_platform_interface: + dependency: transitive + description: + name: device_info_plus_platform_interface + sha256: e1ea89119e34903dca74b883d0dd78eb762814f97fb6c76f35e9ff74d261a18f + url: "https://pub.dev" + source: hosted + version: "7.0.3" + dio: + dependency: "direct main" + description: + name: dio + sha256: d90ee57923d1828ac14e492ca49440f65477f4bb1263575900be731a3dac66a9 + url: "https://pub.dev" + source: hosted + version: "5.9.0" + dio_web_adapter: + dependency: transitive + description: + name: dio_web_adapter + sha256: "0786d0b7295a373de356fc0af4f6f1d0ab2844ed31b19dfc5e7556b70e24212c" + url: "https://pub.dev" + source: hosted + version: "2.2.1" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + file_picker: + dependency: "direct main" + description: + name: file_picker + sha256: f2d9f173c2c14635cc0e9b14c143c49ef30b4934e8d1d274d6206fcb0086a06f + url: "https://pub.dev" + source: hosted + version: "10.3.3" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0" + url: "https://pub.dev" + source: hosted + version: "0.9.4" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a" + url: "https://pub.dev" + source: hosted + version: "0.9.5" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85" + url: "https://pub.dev" + source: hosted + version: "2.7.0" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd" + url: "https://pub.dev" + source: hosted + version: "0.9.3+5" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" + url: "https://pub.dev" + source: hosted + version: "6.0.0" + flutter_localizations: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_openim_sdk: + dependency: "direct main" + description: + name: flutter_openim_sdk + sha256: b57d1fd63f52fa5cbd7c257fccc9d04902c266e96172f5112c00b428af2f2ff5 + url: "https://pub.dev" + source: hosted + version: "3.8.3+hotfix.12" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: "3854fe5e3bff0b113c658f260b90c95dea17c92db0f2addeac2e343dd9969785" + url: "https://pub.dev" + source: hosted + version: "2.0.35" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + flutter_webrtc: + dependency: transitive + description: + name: flutter_webrtc + sha256: e997161d7da3adedd3d430691b20931b0b4d96fa48bb60938d9ba0bf6fca98be + url: "https://pub.dev" + source: hosted + version: "1.6.0" + hooks: + dependency: transitive + description: + name: hooks + sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + image_picker: + dependency: "direct main" + description: + name: image_picker + sha256: "021834d9c0c3de46bf0fe40341fa07168407f694d9b2bb18d532dc1261867f7a" + url: "https://pub.dev" + source: hosted + version: "1.1.2" + image_picker_android: + dependency: transitive + description: + name: image_picker_android + sha256: "6f3a1995eafb000333174fae92202622033b0ee7fd917a6cd3730295264df84a" + url: "https://pub.dev" + source: hosted + version: "0.8.13+19" + image_picker_for_web: + dependency: transitive + description: + name: image_picker_for_web + sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214" + url: "https://pub.dev" + source: hosted + version: "3.1.1" + image_picker_ios: + dependency: transitive + description: + name: image_picker_ios + sha256: b9c4a438a9ff4f60808c9cf0039b93a42bb6c2211ef6ebb647394b2b3fa84588 + url: "https://pub.dev" + source: hosted + version: "0.8.13+6" + image_picker_linux: + dependency: transitive + description: + name: image_picker_linux + sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + image_picker_macos: + dependency: transitive + description: + name: image_picker_macos + sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91" + url: "https://pub.dev" + source: hosted + version: "0.2.2+1" + image_picker_platform_interface: + dependency: transitive + description: + name: image_picker_platform_interface + sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c" + url: "https://pub.dev" + source: hosted + version: "2.11.1" + image_picker_windows: + dependency: transitive + description: + name: image_picker_windows + sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae + url: "https://pub.dev" + source: hosted + version: "0.2.2" + intl: + dependency: transitive + description: + name: intl + sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + url: "https://pub.dev" + source: hosted + version: "0.20.2" + jni: + dependency: transitive + description: + name: jni + sha256: f038e58b4dc2c9037f50e233175086337e0b305e356d28211bf55f21c504cbd3 + url: "https://pub.dev" + source: hosted + version: "1.0.3" + jni_flutter: + dependency: transitive + description: + name: jni_flutter + sha256: "7b717011ea40d04fd47c2731d3d1d36eb99eba3435c2753d62489e8c3c9991d5" + url: "https://pub.dev" + source: hosted + version: "1.0.2" + jni_util: + dependency: transitive + description: + name: jni_util + sha256: "1ba86da04a5f2bf18fde2edb235587e70c5b0fc5bd4ba955f46b00942c3fc35f" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + js: + dependency: transitive + description: + name: js + sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" + url: "https://pub.dev" + source: hosted + version: "0.7.2" + just_audio: + dependency: "direct main" + description: + name: just_audio + sha256: "9694e4734f515f2a052493d1d7e0d6de219ee0427c7c29492e246ff32a219908" + url: "https://pub.dev" + source: hosted + version: "0.10.5" + just_audio_platform_interface: + dependency: transitive + description: + name: just_audio_platform_interface + sha256: "2532c8d6702528824445921c5ff10548b518b13f808c2e34c2fd54793b999a6a" + url: "https://pub.dev" + source: hosted + version: "4.6.0" + just_audio_web: + dependency: transitive + description: + name: just_audio_web + sha256: "6ba8a2a7e87d57d32f0f7b42856ade3d6a9fbe0f1a11fabae0a4f00bb73f0663" + url: "https://pub.dev" + source: hosted + version: "0.4.16" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" + url: "https://pub.dev" + source: hosted + version: "6.1.0" + livekit_client: + dependency: "direct main" + description: + name: livekit_client + sha256: "011affc0fca22b2f9b0e8827219dad9948f84f2bf057980693de13039de904c7" + url: "https://pub.dev" + source: hosted + version: "2.5.0+hotfix.3" + logger: + dependency: transitive + description: + name: logger + sha256: "25aee487596a6257655a1e091ec2ae66bc30e7af663592cc3a27e6591e05035c" + url: "https://pub.dev" + source: hosted + version: "2.7.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + url: "https://pub.dev" + source: hosted + version: "0.12.19" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.dev" + source: hosted + version: "0.13.0" + meta: + dependency: transitive + description: + name: meta + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + url: "https://pub.dev" + source: hosted + version: "1.18.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + mime_type: + dependency: transitive + description: + name: mime_type + sha256: d652b613e84dac1af28030a9fba82c0999be05b98163f9e18a0849c6e63838bb + url: "https://pub.dev" + source: hosted + version: "1.0.1" + nm: + dependency: transitive + description: + name: nm + sha256: "2c9aae4127bdc8993206464fcc063611e0e36e72018696cd9631023a31b24254" + url: "https://pub.dev" + source: hosted + version: "0.5.0" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: b7fb95a6d9a4f009edd63dc5ac69f07420b23a16161c6dd8660290b59c602e8e + url: "https://pub.dev" + source: hosted + version: "9.5.0" + open_filex: + dependency: "direct main" + description: + name: open_filex + sha256: "9976da61b6a72302cf3b1efbce259200cd40232643a467aac7370addf94d6900" + url: "https://pub.dev" + source: hosted + version: "4.7.0" + package_config: + dependency: transitive + description: + name: package_config + sha256: ffcf4cf3d6c0b74ac43708d9f56625506e8a68aa935abe9d267a7330f320eb5d + url: "https://pub.dev" + source: hosted + version: "3.0.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_provider: + dependency: "direct main" + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" + url: "https://pub.dev" + source: hosted + version: "2.3.1" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + url: "https://pub.dev" + source: hosted + version: "2.6.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16" + url: "https://pub.dev" + source: hosted + version: "2.2.2" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda" + url: "https://pub.dev" + source: hosted + version: "2.1.3" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + permission_handler: + dependency: "direct main" + description: + name: permission_handler + sha256: "59adad729136f01ea9e35a48f5d1395e25cba6cea552249ddbe9cf950f5d7849" + url: "https://pub.dev" + source: hosted + version: "11.4.0" + permission_handler_android: + dependency: transitive + description: + name: permission_handler_android + sha256: d3971dcdd76182a0c198c096b5db2f0884b0d4196723d21a866fc4cdea057ebc + url: "https://pub.dev" + source: hosted + version: "12.1.0" + permission_handler_apple: + dependency: transitive + description: + name: permission_handler_apple + sha256: "11b7e94a9d2fbee23c27f0cae0105c6266c03fd83b9a2eda6cf09141fc82624b" + url: "https://pub.dev" + source: hosted + version: "9.5.0" + permission_handler_html: + dependency: transitive + description: + name: permission_handler_html + sha256: "6ea98b3f17f60d3b527f2647ed2ab4dc0f6bfe25b22cb1c363f5d8f62252f6ac" + url: "https://pub.dev" + source: hosted + version: "0.1.4+1" + permission_handler_platform_interface: + dependency: transitive + description: + name: permission_handler_platform_interface + sha256: a5c8a97ecf5616112a5b16d4b8e9ec0e5ae90ef63ac69c0d7b8ae240be760b23 + url: "https://pub.dev" + source: hosted + version: "4.4.0" + permission_handler_windows: + dependency: transitive + description: + name: permission_handler_windows + sha256: caeae01858a0a7d2df67a445ac98e1ad95e55a0e77c73044f4e9b1c8c2289cbd + url: "https://pub.dev" + source: hosted + version: "0.2.2" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" + url: "https://pub.dev" + source: hosted + version: "7.0.2" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + protobuf: + dependency: transitive + description: + name: protobuf + sha256: de9c9eb2c33f8e933a42932fe1dc504800ca45ebc3d673e6ed7f39754ee4053e + url: "https://pub.dev" + source: hosted + version: "4.2.0" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + record: + dependency: "direct main" + description: + name: record + sha256: "6bad72fb3ea6708d724cf8b6c97c4e236cf9f43a52259b654efeb6fd9b737f1f" + url: "https://pub.dev" + source: hosted + version: "6.1.2" + record_android: + dependency: transitive + description: + name: record_android + sha256: eb1732e42d0d2a1895b8db86e4fc917287e6d8491b6ed59918aea8bed6c69de4 + url: "https://pub.dev" + source: hosted + version: "1.5.2" + record_ios: + dependency: transitive + description: + name: record_ios + sha256: c051fb48edd7a0e265daafb9108730dc827c27b551728a3fdfb3ef69efd89c73 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + record_linux: + dependency: transitive + description: + name: record_linux + sha256: "31181787bf7eccb0e298835836b69b3cd0a903863b75d70e937de3dec71cd8f3" + url: "https://pub.dev" + source: hosted + version: "1.3.1" + record_macos: + dependency: transitive + description: + name: record_macos + sha256: cfe1b61435e27db418bf513dc36820d10c9f7eb1843786c2c9a52e07e2f4f627 + url: "https://pub.dev" + source: hosted + version: "1.2.2" + record_platform_interface: + dependency: transitive + description: + name: record_platform_interface + sha256: "8e56cbe06c6984137fb86132ff03459f29938d927496d9b2d0962e2d6345d488" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + record_use: + dependency: transitive + description: + name: record_use + sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" + url: "https://pub.dev" + source: hosted + version: "0.6.0" + record_web: + dependency: transitive + description: + name: record_web + sha256: "7e9846981c1f2d111d86f0ae3309071f5bba8b624d1c977316706f08fc31d16d" + url: "https://pub.dev" + source: hosted + version: "1.3.0" + record_windows: + dependency: transitive + description: + name: record_windows + sha256: "223258060a1d25c62bae18282c16783f28581ec19401d17e56b5205b9f039d78" + url: "https://pub.dev" + source: hosted + version: "1.0.7" + rxdart: + dependency: transitive + description: + name: rxdart + sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" + url: "https://pub.dev" + source: hosted + version: "0.28.0" + sdp_transform: + dependency: transitive + description: + name: sdp_transform + sha256: "73e412a5279a5c2de74001535208e20fff88f225c9a4571af0f7146202755e45" + url: "https://pub.dev" + source: hosted + version: "0.3.2" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5" + url: "https://pub.dev" + source: hosted + version: "2.5.3" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "0634e64bd719f89c012f392938e173521f535d3ecaf66558fa94a056d22b5cc7" + url: "https://pub.dev" + source: hosted + version: "2.4.27" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: "61894a1956de6b4fc1aefd0892e109514a1a706cbece3ac59decd90ff5a7a423" + url: "https://pub.dev" + source: hosted + version: "3.4.1+1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + url: "https://pub.dev" + source: hosted + version: "0.7.11" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + uuid: + dependency: "direct main" + description: + name: uuid + sha256: a11b666489b1954e01d992f3d601b1804a33937b5a8fe677bd26b8a9f96f96e8 + url: "https://pub.dev" + source: hosted + version: "4.5.2" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" + url: "https://pub.dev" + source: hosted + version: "15.2.0" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + webrtc_interface: + dependency: transitive + description: + name: webrtc_interface + sha256: c6f100eac5057d9a817a60473126f9828c796d42884d498af4f339c97b21014f + url: "https://pub.dev" + source: hosted + version: "1.5.1" + win32: + dependency: transitive + description: + name: win32 + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + url: "https://pub.dev" + source: hosted + version: "5.15.0" + win32_registry: + dependency: transitive + description: + name: win32_registry + sha256: "6f1b564492d0147b330dd794fee8f512cec4977957f310f9951b5f9d83618dae" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" + url: "https://pub.dev" + source: hosted + version: "6.6.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" +sdks: + dart: ">=3.12.0 <4.0.0" + flutter: ">=3.44.0" diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml new file mode 100644 index 0000000..aa76749 --- /dev/null +++ b/mobile/pubspec.yaml @@ -0,0 +1,47 @@ +name: changlian +description: 畅联 —— 公司内部通讯 App(手机端) +publish_to: "none" +version: 1.0.0+1 + +environment: + sdk: ">=3.6.0 <4.0.0" + +dependencies: + flutter: + sdk: flutter + flutter_localizations: + sdk: flutter + + cupertino_icons: 1.0.8 + # OpenIM 客户端 SDK(3.8.x 系列,与服务端 v3.8.3 配套,版本取自官方样板工程 pubspec.lock) + flutter_openim_sdk: 3.8.3+hotfix.12 + # 公司账号登录等 HTTP 请求 + dio: 5.9.0 + # 登录凭证 / 本地小数据存储 + shared_preferences: 2.5.3 + # SDK 数据目录 / 录音与文件下载目录 + path_provider: 2.1.5 + # 语音消息录音(样板工程同款插件) + record: 6.1.2 + # 语音消息播放(样板工程同款插件) + just_audio: 0.10.5 + # 发文件消息时选文件 + file_picker: 10.3.3 + # 发图片消息时选相册图片 + image_picker: 1.1.2 + # 一对一语音通话(LiveKit SFU,版本取自官方样板工程 openim_live/pubspec.yaml) + livekit_client: 2.5.0+hotfix.3 + # 麦克风权限申请 + permission_handler: 11.4.0 + # 打开收到的文件 + open_filex: 4.7.0 + # 通话房间号 + uuid: 4.5.2 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: 6.0.0 + +flutter: + uses-material-design: true