新增 mobile/:畅联手机端 Flutter 工程(B-58)
- 登录:工号+密码走 account-service /api/login,自动登录、被踢下线回登录页 - 消息:会话列表(未读角标/免打扰/时间)、单聊群聊、文字/语音/图片/文件消息、失败重发、历史分页 - 通讯录:新的同事/我的群聊入口、按部门分组(好友 ex 字段)、搜索 - 通话:一对一语音通话(信令走 OpenIM 自定义消息 + LiveKit,token 走 /api/rtc_token) - 界面按确认效果图 m1/m2/m3 实现,主色 #3B87F5,四态视图齐全,无英文残留
This commit is contained in:
@@ -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 端不开放自助注册。联调时请管理员先导入测试名单。
|
||||
@@ -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';
|
||||
@@ -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<ChanglianApp> createState() => _ChanglianAppState();
|
||||
}
|
||||
|
||||
class _ChanglianAppState extends State<ChanglianApp> {
|
||||
@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<SplashGate> createState() => _SplashGateState();
|
||||
}
|
||||
|
||||
class _SplashGateState extends State<SplashGate> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_boot();
|
||||
}
|
||||
|
||||
Future<void> _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)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/// 通话信令的数据模型。
|
||||
/// 与官方样板工程 openim_common 的 InvitationInfo 字段保持一致,
|
||||
/// 保证和 PC 端 / 其他端互通。
|
||||
class InvitationInfo {
|
||||
/// 邀请者 userID
|
||||
String? inviterUserID;
|
||||
|
||||
/// 被邀请者 userID 列表,单聊只有一个元素
|
||||
List<String>? 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<String, dynamic> json) {
|
||||
inviterUserID = json['inviterUserID'];
|
||||
inviteeUserIDList = json['inviteeUserIDList']?.cast<String>();
|
||||
groupID = json['groupID'];
|
||||
roomID = json['roomID'];
|
||||
timeout = json['timeout'];
|
||||
initiateTime = json['initiateTime'];
|
||||
mediaType = json['mediaType'];
|
||||
sessionType = json['sessionType'];
|
||||
platformID = json['platformID'];
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final data = <String, dynamic>{};
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 全局导航键:被踢下线回登录页、来电弹通话界面、通话结束提示都要用它
|
||||
final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
|
||||
@@ -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<AddFriendScreen> createState() => _AddFriendScreenState();
|
||||
}
|
||||
|
||||
class _AddFriendScreenState extends State<AddFriendScreen> {
|
||||
final TextEditingController _idCtrl = TextEditingController();
|
||||
bool _sending = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_idCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _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)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<CallScreen> createState() => _CallScreenState();
|
||||
}
|
||||
|
||||
class _CallScreenState extends State<CallScreen> {
|
||||
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))),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<ChatScreen> createState() => _ChatScreenState();
|
||||
}
|
||||
|
||||
class _ChatScreenState extends State<ChatScreen> {
|
||||
/// 消息列表,新消息在前(配合 reverse 列表)
|
||||
final List<Message> _messages = [];
|
||||
final ScrollController _scroll = ScrollController();
|
||||
final AudioPlayer _player = AudioPlayer();
|
||||
final Dio _dio = Dio();
|
||||
|
||||
StreamSubscription<Message>? _msgSub;
|
||||
StreamSubscription<String>? _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<void> _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<void> _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<void> _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<void> _sendText(String text) async {
|
||||
try {
|
||||
final message = await OpenIM.iMManager.messageManager.createTextMessage(text: text);
|
||||
_sendMessage(message);
|
||||
} catch (_) {
|
||||
_toast('消息没发出去,请重试');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _sendVoice(String path, int duration) async {
|
||||
try {
|
||||
final message = await OpenIM.iMManager.messageManager.createSoundMessageFromFullPath(
|
||||
soundPath: path,
|
||||
duration: duration,
|
||||
);
|
||||
_sendMessage(message);
|
||||
} catch (_) {
|
||||
_toast('语音没发出去,请重试');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _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<void> _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<void> _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<void> _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<void> _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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<String, dynamic>) {
|
||||
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<ContactsScreen> createState() => _ContactsScreenState();
|
||||
}
|
||||
|
||||
class _ContactsScreenState extends State<ContactsScreen> {
|
||||
List<FriendInfo> _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<void> _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 = <String, List<FriendInfo>>{};
|
||||
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<void> _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)),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<ConversationListScreen> createState() => _ConversationListScreenState();
|
||||
}
|
||||
|
||||
class _ConversationListScreenState extends State<ConversationListScreen> {
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<GroupListScreen> createState() => _GroupListScreenState();
|
||||
}
|
||||
|
||||
class _GroupListScreenState extends State<GroupListScreen> {
|
||||
List<GroupInfo> _groups = [];
|
||||
bool _loading = true;
|
||||
bool _failed = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _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<void> _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)),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<HomeScreen> createState() => _HomeScreenState();
|
||||
}
|
||||
|
||||
class _HomeScreenState extends State<HomeScreen> {
|
||||
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: '我的'),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<void> 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<void> 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<LoginScreen> createState() => _LoginScreenState();
|
||||
}
|
||||
|
||||
class _LoginScreenState extends State<LoginScreen> {
|
||||
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<void> _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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<MineScreen> createState() => _MineScreenState();
|
||||
}
|
||||
|
||||
class _MineScreenState extends State<MineScreen> {
|
||||
@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)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<NewFriendsScreen> createState() => _NewFriendsScreenState();
|
||||
}
|
||||
|
||||
class _NewFriendsScreenState extends State<NewFriendsScreen> {
|
||||
List<FriendApplicationInfo> _list = [];
|
||||
bool _loading = true;
|
||||
bool _failed = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _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<void> _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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<String, dynamic> 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<String, dynamic> _unwrap(dynamic body) {
|
||||
if (body is Map<String, dynamic>) {
|
||||
if (body['code'] == 0 && body['data'] is Map<String, dynamic>) {
|
||||
return Map<String, dynamic>.from(body['data'] as Map);
|
||||
}
|
||||
final msg = body['msg']?.toString();
|
||||
throw AuthException((msg != null && msg.isNotEmpty) ? msg : '服务器返回的数据不对,请联系管理员');
|
||||
}
|
||||
throw AuthException('服务器返回的数据不对,请联系管理员');
|
||||
}
|
||||
|
||||
/// 工号 + 密码登录,成功后返回 OpenIM 登录所需的 userID/imToken
|
||||
Future<AuthResult> 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<LiveKitCredential> 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});
|
||||
}
|
||||
@@ -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<RoomEvent>? _roomListener;
|
||||
Timer? _timeoutTimer;
|
||||
Timer? _durationTimer;
|
||||
StreamSubscription<Message>? _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<String?> 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<void> 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<void> reject() async {
|
||||
if (phase != CallPhase.incoming) return;
|
||||
final inviter = _invitation?.inviterUserID;
|
||||
if (inviter != null) await _sendSignalingQuietly(SignalingType.callingReject, inviter);
|
||||
_teardown();
|
||||
}
|
||||
|
||||
/// 取消呼叫(呼出方主动取消)
|
||||
Future<void> cancel() async {
|
||||
if (phase != CallPhase.outgoing) return;
|
||||
final peer = peerUserID;
|
||||
if (peer != null) await _sendSignalingQuietly(SignalingType.callingCancel, peer);
|
||||
_teardown();
|
||||
}
|
||||
|
||||
/// 挂断(通话中)
|
||||
Future<void> hangup() async {
|
||||
if (phase != CallPhase.incall) return;
|
||||
final peer = peerUserID;
|
||||
if (peer != null) await _sendSignalingQuietly(SignalingType.callingHungup, peer);
|
||||
_teardown();
|
||||
}
|
||||
|
||||
/// 通话界面上的统一返回键:按当前阶段取消/挂断
|
||||
Future<void> 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<void> toggleMic() async {
|
||||
micOn = !micOn;
|
||||
notifyListeners();
|
||||
try {
|
||||
await _room?.localParticipant?.setMicrophoneEnabled(micOn);
|
||||
} catch (_) {
|
||||
// 切换失败时回退状态
|
||||
micOn = !micOn;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> 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<void> _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<void> _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<RoomDisconnectedEvent>((event) {
|
||||
// 掉线或房间被销毁:直接结束
|
||||
if (phase == CallPhase.incall) endHint = '通话已结束';
|
||||
_teardown();
|
||||
})
|
||||
..on<ParticipantDisconnectedEvent>((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<void> _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<void> _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();
|
||||
}
|
||||
}
|
||||
@@ -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<ConversationInfo> conversations = [];
|
||||
|
||||
/// 当前登录用户信息
|
||||
UserInfo? selfInfo;
|
||||
|
||||
/// 当前登录凭证(发起通话取 LiveKit token 时要用)
|
||||
String? currentUserID;
|
||||
String? currentToken;
|
||||
|
||||
/// 被踢下线 / token 失效时的回调(由 main.dart 设置:清缓存、回登录页)
|
||||
void Function()? onForceLogout;
|
||||
|
||||
/// 收到新消息(聊天页订阅)
|
||||
final StreamController<Message> _newMsgController = StreamController<Message>.broadcast();
|
||||
Stream<Message> get onNewMessage => _newMsgController.stream;
|
||||
|
||||
/// 消息被撤回(撤回方的 clientMsgID,聊天页订阅)
|
||||
final StreamController<String> _revokeController = StreamController<String>.broadcast();
|
||||
Stream<String> get onMessageRevoked => _revokeController.stream;
|
||||
|
||||
/// 收到通话信令(仅在线自定义消息,通话模块订阅)
|
||||
final StreamController<Message> _signalingController = StreamController<Message>.broadcast();
|
||||
Stream<Message> get onSignaling => _signalingController.stream;
|
||||
|
||||
int get platformID => Platform.isIOS ? IMPlatform.ios : IMPlatform.android;
|
||||
|
||||
/// App 启动时初始化 SDK(只做一次)
|
||||
Future<void> 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<void> 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<void> refreshSelfInfo() async {
|
||||
if (!loggedIn) return;
|
||||
try {
|
||||
selfInfo = await OpenIM.iMManager.userManager.getSelfUserInfo();
|
||||
notifyListeners();
|
||||
} catch (_) {
|
||||
// 拉取失败沿用内存里的旧数据
|
||||
}
|
||||
}
|
||||
|
||||
/// 退出登录
|
||||
Future<void> 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<void> 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<void> 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<String, dynamic>;
|
||||
final customType = map['customType'];
|
||||
if (customType is! int || customType < 200 || customType > 204) return null;
|
||||
final payload = SignalingPayload(
|
||||
type: customType,
|
||||
invitation: InvitationInfo.fromJson(Map<String, dynamic>.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;
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import 'package:flutter_openim_sdk/flutter_openim_sdk.dart';
|
||||
|
||||
/// 时间与消息预览的格式化工具,全部用户可见文案集中在这里,避免出现英文残留。
|
||||
class FormatUtils {
|
||||
FormatUtils._();
|
||||
|
||||
static const List<String> _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 '[其他消息]';
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<ChatInputBar> createState() => _ChatInputBarState();
|
||||
}
|
||||
|
||||
class _ChatInputBarState extends State<ChatInputBar> {
|
||||
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<String> _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<void> _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<void> _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)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../theme.dart';
|
||||
|
||||
/// 消息列表 / 通讯录共用的搜索框(效果图 m1/m3):
|
||||
/// 未输入时「放大镜 + 搜索」居中显示,输入后正常左对齐。
|
||||
class SearchBox extends StatefulWidget {
|
||||
final ValueChanged<String> onChanged;
|
||||
|
||||
const SearchBox({super.key, required this.onChanged});
|
||||
|
||||
@override
|
||||
State<SearchBox> createState() => _SearchBoxState();
|
||||
}
|
||||
|
||||
class _SearchBoxState extends State<SearchBox> {
|
||||
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)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user