- 登录:工号+密码走 account-service /api/login,自动登录、被踢下线回登录页 - 消息:会话列表(未读角标/免打扰/时间)、单聊群聊、文字/语音/图片/文件消息、失败重发、历史分页 - 通讯录:新的同事/我的群聊入口、按部门分组(好友 ex 字段)、搜索 - 通话:一对一语音通话(信令走 OpenIM 自定义消息 + LiveKit,token 走 /api/rtc_token) - 界面按确认效果图 m1/m2/m3 实现,主色 #3B87F5,四态视图齐全,无英文残留
186 lines
5.6 KiB
Dart
186 lines
5.6 KiB
Dart
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))),
|
|
],
|
|
);
|
|
}
|
|
}
|