新增 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,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))),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user