ПОШЛО НАХУЙ КТО ЭТИ ЗВОВНКИ ДЕЛАЛ
This commit is contained in:
@@ -0,0 +1,510 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
import '../../../core/calls/call_admin.dart';
|
||||
import '../../../core/calls/call_session.dart';
|
||||
import '../../widgets/custom_notification.dart';
|
||||
import '../../widgets/komet_avatar.dart';
|
||||
import '../../widgets/prompt_dialog.dart';
|
||||
import '../../widgets/sheet_helpers.dart';
|
||||
|
||||
class CallParticipantView {
|
||||
final String name;
|
||||
final String? avatarUrl;
|
||||
|
||||
const CallParticipantView({required this.name, this.avatarUrl});
|
||||
}
|
||||
|
||||
typedef CallParticipantResolver =
|
||||
CallParticipantView Function(CallParticipant participant);
|
||||
|
||||
Future<void> showCallParticipantsSheet(
|
||||
BuildContext context, {
|
||||
required CallSession session,
|
||||
required ColorScheme scheme,
|
||||
required CallParticipantResolver resolve,
|
||||
}) {
|
||||
return showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
showDragHandle: true,
|
||||
backgroundColor: scheme.surfaceContainerHigh,
|
||||
shape: kSheetShape,
|
||||
builder: (_) => Theme(
|
||||
data: Theme.of(context).copyWith(colorScheme: scheme),
|
||||
child: _ParticipantsSheet(session: session, resolve: resolve),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _ParticipantsSheet extends StatefulWidget {
|
||||
final CallSession session;
|
||||
final CallParticipantResolver resolve;
|
||||
|
||||
const _ParticipantsSheet({required this.session, required this.resolve});
|
||||
|
||||
@override
|
||||
State<_ParticipantsSheet> createState() => _ParticipantsSheetState();
|
||||
}
|
||||
|
||||
class _ParticipantsSheetState extends State<_ParticipantsSheet> {
|
||||
final Map<CallOption, bool> _options = {};
|
||||
final Map<CallFeature, Set<CallRoleName>> _features = {};
|
||||
bool _recording = false;
|
||||
StreamSubscription<void>? _infoSub;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_infoSub = widget.session.infoUpdates.listen((_) {
|
||||
if (mounted) setState(() {});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_infoSub?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
CallParticipant? get _self {
|
||||
for (final p in widget.session.participants) {
|
||||
if (p.isSelf) return p;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<bool> _run(Future<void> Function(CallAdmin admin) action) async {
|
||||
final admin = widget.session.admin;
|
||||
if (admin == null) {
|
||||
showCustomNotification(context, 'Нет связи с сервером звонка');
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
await action(admin);
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (mounted) showCustomNotification(context, 'Не удалось: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
CallParticipantRef _ref(CallParticipant p) => CallParticipantRef(p.id);
|
||||
|
||||
void _participantActions(CallParticipant p) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final view = widget.resolve(p);
|
||||
final isAdmin = p.isAdmin;
|
||||
final isSpeaker = p.isSpeaker;
|
||||
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
showDragHandle: true,
|
||||
backgroundColor: cs.surfaceContainerHigh,
|
||||
shape: kSheetShape,
|
||||
builder: (sheetContext) => SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 0, 20, 4),
|
||||
child: Text(
|
||||
view.name,
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontFamily: 'Outfit',
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 0, 20, 8),
|
||||
child: Text(
|
||||
p.roles.isEmpty ? 'Участник' : p.roles.join(' · '),
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
|
||||
),
|
||||
),
|
||||
_action(cs, Symbols.mic_off, 'Выключить микрофон', () {
|
||||
Navigator.pop(sheetContext);
|
||||
_run((a) => a.muteMicrophone(_ref(p)));
|
||||
}),
|
||||
_action(cs, Symbols.videocam_off, 'Запросить камеру', () {
|
||||
Navigator.pop(sheetContext);
|
||||
_run(
|
||||
(a) =>
|
||||
a.requestMedia({CallMedia.video}, participant: _ref(p)),
|
||||
);
|
||||
}),
|
||||
_action(
|
||||
cs,
|
||||
isAdmin ? Symbols.remove_moderator : Symbols.shield_person,
|
||||
isAdmin ? 'Снять администратора' : 'Назначить администратором',
|
||||
() {
|
||||
Navigator.pop(sheetContext);
|
||||
_run(
|
||||
(a) => a.setRoles(_ref(p), [
|
||||
CallRoleName.admin,
|
||||
], revoke: isAdmin),
|
||||
);
|
||||
},
|
||||
),
|
||||
_action(
|
||||
cs,
|
||||
isSpeaker ? Symbols.voice_over_off : Symbols.record_voice_over,
|
||||
isSpeaker ? 'Убрать из спикеров' : 'Сделать спикером',
|
||||
() {
|
||||
Navigator.pop(sheetContext);
|
||||
_run(
|
||||
(a) => a.setRoles(_ref(p), [
|
||||
CallRoleName.speaker,
|
||||
], revoke: isSpeaker),
|
||||
);
|
||||
},
|
||||
),
|
||||
_action(cs, Symbols.arrow_upward, 'Повысить (promote)', () {
|
||||
Navigator.pop(sheetContext);
|
||||
_run((a) => a.setPromoted(_ref(p), true));
|
||||
}),
|
||||
_action(cs, Symbols.arrow_downward, 'Понизить (demote)', () {
|
||||
Navigator.pop(sheetContext);
|
||||
_run((a) => a.setPromoted(_ref(p), false));
|
||||
}),
|
||||
_action(cs, Symbols.push_pin, 'Закрепить', () {
|
||||
Navigator.pop(sheetContext);
|
||||
_run((a) => a.setPinned(_ref(p), true));
|
||||
}),
|
||||
_action(cs, Symbols.keep_off, 'Открепить', () {
|
||||
Navigator.pop(sheetContext);
|
||||
_run((a) => a.setPinned(_ref(p), false));
|
||||
}),
|
||||
_action(cs, Symbols.person_remove, 'Удалить из звонка', () {
|
||||
Navigator.pop(sheetContext);
|
||||
_run((a) => a.removeParticipant(_ref(p)));
|
||||
}, destructive: true),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showOptions() {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
showDragHandle: true,
|
||||
backgroundColor: cs.surfaceContainerHigh,
|
||||
shape: kSheetShape,
|
||||
builder: (_) => Theme(
|
||||
data: Theme.of(context).copyWith(colorScheme: cs),
|
||||
child: StatefulBuilder(
|
||||
builder: (_, setSheet) => SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_sheetTitle(cs, 'Настройки звонка'),
|
||||
for (final option in CallOption.values)
|
||||
SwitchListTile(
|
||||
value: _options[option] ?? false,
|
||||
title: Text(
|
||||
_optionLabel(option),
|
||||
style: TextStyle(color: cs.onSurface, fontSize: 15),
|
||||
),
|
||||
subtitle: Text(
|
||||
option.wire,
|
||||
style: TextStyle(
|
||||
color: cs.onSurfaceVariant,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
onChanged: (value) async {
|
||||
setSheet(() => _options[option] = value);
|
||||
final ok = await _run(
|
||||
(a) => a.setOptions({option: value}),
|
||||
);
|
||||
if (!ok) setSheet(() => _options[option] = !value);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showFeatures() {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
showDragHandle: true,
|
||||
backgroundColor: cs.surfaceContainerHigh,
|
||||
shape: kSheetShape,
|
||||
builder: (_) => Theme(
|
||||
data: Theme.of(context).copyWith(colorScheme: cs),
|
||||
child: StatefulBuilder(
|
||||
builder: (_, setSheet) => SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_sheetTitle(cs, 'Кому доступны функции'),
|
||||
for (final feature in CallFeature.values)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 4, 20, 4),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
_featureLabel(feature),
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
children: [
|
||||
for (final role in CallRoleName.values)
|
||||
FilterChip(
|
||||
label: Text(role.wire),
|
||||
selected:
|
||||
_features[feature]?.contains(role) ??
|
||||
false,
|
||||
onSelected: (selected) {
|
||||
final set = _features.putIfAbsent(
|
||||
feature,
|
||||
() => <CallRoleName>{},
|
||||
);
|
||||
setSheet(() {
|
||||
selected
|
||||
? set.add(role)
|
||||
: set.remove(role);
|
||||
});
|
||||
_run(
|
||||
(a) => a.enableFeatureForRoles(
|
||||
feature,
|
||||
set.toList(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _addByLink() async {
|
||||
final link = await showTextInputDialog(
|
||||
context,
|
||||
title: 'Добавить участника',
|
||||
description: 'Ссылка-приглашение участника',
|
||||
confirmLabel: 'Добавить',
|
||||
);
|
||||
if (link == null || link.trim().isEmpty || !mounted) return;
|
||||
await _run((a) => a.addParticipantByLink(link.trim()));
|
||||
}
|
||||
|
||||
String _optionLabel(CallOption option) => switch (option) {
|
||||
CallOption.requireAuthToJoin => 'Только авторизованные',
|
||||
CallOption.waitingHall => 'Зал ожидания',
|
||||
CallOption.recurring => 'Повторяющийся звонок',
|
||||
CallOption.feedback => 'Сбор отзывов',
|
||||
CallOption.audienceMode => 'Режим зрителей',
|
||||
CallOption.asr => 'Расшифровка речи',
|
||||
CallOption.waitForAdmin => 'Ждать администратора',
|
||||
CallOption.adminIsHere => 'Администратор на месте',
|
||||
};
|
||||
|
||||
String _featureLabel(CallFeature feature) => switch (feature) {
|
||||
CallFeature.addParticipant => 'Добавлять участников',
|
||||
CallFeature.admin => 'Права администратора',
|
||||
CallFeature.asr => 'Расшифровка речи',
|
||||
CallFeature.movieShare => 'Совместный просмотр',
|
||||
CallFeature.record => 'Запись звонка',
|
||||
CallFeature.speaker => 'Быть спикером',
|
||||
};
|
||||
|
||||
Widget _sheetTitle(ColorScheme cs, String text) => Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 0, 20, 12),
|
||||
child: Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontFamily: 'Outfit',
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Widget _action(
|
||||
ColorScheme cs,
|
||||
IconData icon,
|
||||
String label,
|
||||
VoidCallback onTap, {
|
||||
bool destructive = false,
|
||||
}) {
|
||||
final color = destructive ? cs.error : cs.onSurface;
|
||||
return ListTile(
|
||||
leading: Icon(icon, color: color),
|
||||
title: Text(label, style: TextStyle(color: color, fontSize: 16)),
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final participants = widget.session.participants;
|
||||
final self = _self;
|
||||
final handRaised = self?.handRaised ?? false;
|
||||
|
||||
return SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_sheetTitle(cs, 'Участники · ${participants.length}'),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
_chip(cs, Symbols.mic_off, 'Заглушить всех', () {
|
||||
_run((a) => a.muteEveryone());
|
||||
}),
|
||||
_chip(cs, Symbols.do_not_touch, 'Опустить руки', () {
|
||||
_run((a) => a.lowerAllHands());
|
||||
}),
|
||||
_chip(
|
||||
cs,
|
||||
handRaised ? Symbols.back_hand : Symbols.front_hand,
|
||||
handRaised ? 'Опустить руку' : 'Поднять руку',
|
||||
() => _run((a) => a.setHandRaised(!handRaised)),
|
||||
active: handRaised,
|
||||
),
|
||||
_chip(
|
||||
cs,
|
||||
_recording
|
||||
? Symbols.stop_circle
|
||||
: Symbols.radio_button_checked,
|
||||
_recording ? 'Остановить запись' : 'Начать запись',
|
||||
() async {
|
||||
final next = !_recording;
|
||||
setState(() => _recording = next);
|
||||
final ok = await _run(
|
||||
(a) => next
|
||||
? a.startRecord(name: 'Запись звонка')
|
||||
: a.stopRecord(),
|
||||
);
|
||||
if (!ok && mounted) setState(() => _recording = !next);
|
||||
},
|
||||
active: _recording,
|
||||
),
|
||||
_chip(cs, Symbols.tune, 'Настройки', _showOptions),
|
||||
_chip(cs, Symbols.shield_person, 'Права ролей', _showFeatures),
|
||||
_chip(cs, Symbols.person_add, 'Добавить по ссылке', _addByLink),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Flexible(
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: participants.length,
|
||||
itemBuilder: (_, i) => _tile(cs, participants[i]),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _chip(
|
||||
ColorScheme cs,
|
||||
IconData icon,
|
||||
String label,
|
||||
VoidCallback onTap, {
|
||||
bool active = false,
|
||||
}) {
|
||||
return ActionChip(
|
||||
avatar: Icon(
|
||||
icon,
|
||||
size: 18,
|
||||
color: active ? cs.onPrimary : cs.onSurfaceVariant,
|
||||
),
|
||||
label: Text(label),
|
||||
labelStyle: TextStyle(color: active ? cs.onPrimary : cs.onSurface),
|
||||
backgroundColor: active ? cs.primary : cs.surfaceContainerHighest,
|
||||
side: BorderSide.none,
|
||||
onPressed: onTap,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _tile(ColorScheme cs, CallParticipant p) {
|
||||
final view = widget.resolve(p);
|
||||
final subtitle = <String>[
|
||||
if (p.isCreator) 'Создатель' else if (p.isAdmin) 'Администратор',
|
||||
if (p.isSpeaker) 'Спикер',
|
||||
if (p.handRaised) 'Поднял руку',
|
||||
];
|
||||
|
||||
return ListTile(
|
||||
leading: KometAvatar(name: view.name, imageUrl: view.avatarUrl, size: 40),
|
||||
title: Text(
|
||||
view.name,
|
||||
style: TextStyle(color: cs.onSurface, fontSize: 16),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: subtitle.isEmpty
|
||||
? null
|
||||
: Text(
|
||||
subtitle.join(' · '),
|
||||
style: TextStyle(color: cs.primary, fontSize: 13),
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (p.screenSharing)
|
||||
Icon(Symbols.screen_share, size: 18, color: cs.primary),
|
||||
if (p.videoEnabled)
|
||||
Icon(Symbols.videocam, size: 18, color: cs.onSurfaceVariant),
|
||||
Icon(
|
||||
p.audioEnabled ? Symbols.mic : Symbols.mic_off,
|
||||
size: 18,
|
||||
color: p.audioEnabled ? cs.onSurfaceVariant : cs.error,
|
||||
),
|
||||
],
|
||||
),
|
||||
onTap: p.isSelf ? null : () => _participantActions(p),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import '../../widgets/custom_notification.dart';
|
||||
import '../../widgets/glossy_pill.dart';
|
||||
import '../../widgets/sheet_helpers.dart';
|
||||
import '../../widgets/small_spinner.dart';
|
||||
import 'call_participants_sheet.dart';
|
||||
import 'komet_hub.dart';
|
||||
|
||||
const Color _kEndRed = Color(0xFFE5484D);
|
||||
@@ -333,6 +334,8 @@ class _CallScreenState extends State<CallScreen> with TickerProviderStateMixin {
|
||||
await WidgetsBinding.instance.endOfFrame;
|
||||
try {
|
||||
await session.setVideoEnabled(!session.localVideo);
|
||||
} catch (e) {
|
||||
if (mounted) showCustomNotification(context, 'Камера недоступна: $e');
|
||||
} finally {
|
||||
_syncLocalPreview();
|
||||
if (mounted) setState(() => _videoBusy = false);
|
||||
@@ -346,6 +349,10 @@ class _CallScreenState extends State<CallScreen> with TickerProviderStateMixin {
|
||||
await WidgetsBinding.instance.endOfFrame;
|
||||
try {
|
||||
await session.setScreenSharing(!session.localScreen);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
showCustomNotification(context, 'Трансляция не запустилась: $e');
|
||||
}
|
||||
} finally {
|
||||
_syncLocalPreview();
|
||||
if (mounted) setState(() => _videoBusy = false);
|
||||
@@ -367,13 +374,40 @@ class _CallScreenState extends State<CallScreen> with TickerProviderStateMixin {
|
||||
_remoteStreamSub?.cancel();
|
||||
_dotsController.dispose();
|
||||
_videoController.dispose();
|
||||
_remoteRenderer.srcObject = null;
|
||||
if (_rendererReady) _remoteRenderer.srcObject = null;
|
||||
_remoteRenderer.dispose();
|
||||
_localRenderer.srcObject = null;
|
||||
if (_localRendererReady) _localRenderer.srcObject = null;
|
||||
_localRenderer.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _showParticipants() {
|
||||
final session = _session;
|
||||
if (session == null) return;
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
showCallParticipantsSheet(
|
||||
context,
|
||||
session: session,
|
||||
scheme: _darkScheme(context),
|
||||
resolve: (p) {
|
||||
if (p.isSelf) {
|
||||
return CallParticipantView(
|
||||
name: l10n.callParticipantYou,
|
||||
avatarUrl: _avatarUrl,
|
||||
);
|
||||
}
|
||||
final ext = p.externalId;
|
||||
final info = ext != null ? _peerInfo[ext] : null;
|
||||
return CallParticipantView(
|
||||
name: info?.name?.isNotEmpty == true
|
||||
? info!.name!
|
||||
: l10n.callParticipantFallback,
|
||||
avatarUrl: info?.avatar,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _showInfoSheet() {
|
||||
final cs = _darkScheme(context);
|
||||
showModalBottomSheet<void>(
|
||||
@@ -520,9 +554,29 @@ class _CallScreenState extends State<CallScreen> with TickerProviderStateMixin {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
subtitle,
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14),
|
||||
InkWell(
|
||||
onTap: count > 0 ? _showParticipants : null,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
subtitle,
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14),
|
||||
),
|
||||
if (count > 0) ...[
|
||||
const SizedBox(width: 4),
|
||||
Icon(
|
||||
Symbols.chevron_right,
|
||||
size: 16,
|
||||
color: cs.onSurfaceVariant,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -927,7 +981,8 @@ class _CallScreenState extends State<CallScreen> with TickerProviderStateMixin {
|
||||
final session = _session;
|
||||
if (session == null) return null;
|
||||
final pills = <Widget>[
|
||||
if (session.peerMuted) _statePill(cs, Symbols.mic_off, l10n.callPeerMicOff),
|
||||
if (session.peerMuted)
|
||||
_statePill(cs, Symbols.mic_off, l10n.callPeerMicOff),
|
||||
if (session.peerVideo)
|
||||
_statePill(cs, Symbols.videocam, l10n.callPeerCameraOn),
|
||||
];
|
||||
@@ -1346,7 +1401,10 @@ class _CallInfoSheet extends StatelessWidget {
|
||||
add(l10n.callInfoCountry, incoming?.country);
|
||||
final isContact = incoming?.isContact;
|
||||
if (isContact != null) {
|
||||
add(l10n.callInfoInContacts, isContact ? l10n.callValueYes : l10n.callValueNo);
|
||||
add(
|
||||
l10n.callInfoInContacts,
|
||||
isContact ? l10n.callValueYes : l10n.callValueNo,
|
||||
);
|
||||
}
|
||||
add(l10n.callInfoPeerIp, info?.peerIp);
|
||||
add(l10n.callInfoPeerNetwork, info?.peerNetwork);
|
||||
@@ -1378,9 +1436,7 @@ class _CallInfoSheet extends StatelessWidget {
|
||||
final vtracks = renderer.srcObject?.getVideoTracks().length ?? 0;
|
||||
add(
|
||||
l10n.callInfoVideoTrack,
|
||||
vtracks > 0
|
||||
? l10n.callInfoVideoTrackPresent(vtracks)
|
||||
: l10n.callValueNo,
|
||||
vtracks > 0 ? l10n.callInfoVideoTrackPresent(vtracks) : l10n.callValueNo,
|
||||
);
|
||||
final w = renderer.value.width.toInt();
|
||||
final h = renderer.value.height.toInt();
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import '../../../main.dart' show api, accountModule;
|
||||
import '../../../backend/modules/account.dart';
|
||||
import '../../../core/storage/app_database.dart';
|
||||
import '../../../core/utils/format.dart';
|
||||
import '../../../core/calls/call_controller.dart';
|
||||
import '../../../core/calls/call_session.dart';
|
||||
import '../../../backend/modules/calls.dart';
|
||||
import '../../widgets/komet_avatar.dart';
|
||||
import '../../widgets/connection_status.dart';
|
||||
@@ -14,6 +16,8 @@ import '../../widgets/reload_on_reconnect.dart';
|
||||
import '../../widgets/custom_notification.dart';
|
||||
import '../../widgets/chat_menu_overlay.dart';
|
||||
import '../../widgets/small_spinner.dart';
|
||||
import '../../widgets/prompt_dialog.dart';
|
||||
import '../../widgets/call_link_handler.dart';
|
||||
import 'call_screen.dart';
|
||||
|
||||
class CallsTab extends StatefulWidget {
|
||||
@@ -330,6 +334,102 @@ class _CallsTabState extends State<CallsTab> with ReloadOnReconnect {
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildLinkAction(
|
||||
ColorScheme cs, {
|
||||
required IconData icon,
|
||||
required String label,
|
||||
required VoidCallback onTap,
|
||||
bool alignEnd = false,
|
||||
}) {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
|
||||
child: Row(
|
||||
mainAxisAlignment: alignEnd
|
||||
? MainAxisAlignment.end
|
||||
: MainAxisAlignment.start,
|
||||
children: [
|
||||
Icon(icon, color: cs.primary, size: 24),
|
||||
const SizedBox(width: 12),
|
||||
Flexible(
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: cs.primary,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _createGroupCall() async {
|
||||
final controller = CallController.instance;
|
||||
if (controller.isBusy) {
|
||||
showCustomNotification(context, 'Звонок уже идёт');
|
||||
return;
|
||||
}
|
||||
|
||||
final navigator = Navigator.of(context);
|
||||
({CallSession session, String? joinLink}) created;
|
||||
try {
|
||||
created = await controller.createGroupCall();
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
showCustomNotification(context, 'Не удалось создать звонок: $e');
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!mounted) return;
|
||||
|
||||
final link = created.joinLink;
|
||||
if (link != null) {
|
||||
await Clipboard.setData(ClipboardData(text: link));
|
||||
if (!mounted) return;
|
||||
showCustomNotification(context, 'Ссылка на звонок скопирована');
|
||||
}
|
||||
|
||||
await navigator.push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => CallScreen(
|
||||
name: 'Групповой звонок',
|
||||
session: created.session,
|
||||
isGroup: true,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _joinGroupCall() async {
|
||||
if (CallController.instance.isBusy) {
|
||||
showCustomNotification(context, 'Звонок уже идёт');
|
||||
return;
|
||||
}
|
||||
|
||||
final url = await showTextInputDialog(
|
||||
context,
|
||||
title: 'Присоединиться к звонку',
|
||||
description: 'Вставьте ссылку-приглашение',
|
||||
hint: 'https://max.ru/joincall/...',
|
||||
confirmLabel: 'Присоединиться',
|
||||
keyboardType: TextInputType.url,
|
||||
);
|
||||
if (url == null || url.trim().isEmpty || !mounted) return;
|
||||
|
||||
final handled = await tryHandleCallLink(context, url.trim());
|
||||
if (!handled && mounted) {
|
||||
showCustomNotification(context, 'Это не ссылка на звонок');
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildTabItem(String label, int index, ColorScheme cs) {
|
||||
final isSelected = _selectedTabIndex == index;
|
||||
return GestureDetector(
|
||||
@@ -393,27 +493,28 @@ class _CallsTabState extends State<CallsTab> with ReloadOnReconnect {
|
||||
],
|
||||
),
|
||||
),
|
||||
InkWell(
|
||||
onTap: () {},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 20,
|
||||
vertical: 12,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Symbols.link, color: cs.primary, size: 24),
|
||||
const SizedBox(width: 16),
|
||||
Text(
|
||||
'Создать групповой звонок',
|
||||
style: TextStyle(
|
||||
color: cs.primary,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildLinkAction(
|
||||
cs,
|
||||
icon: Symbols.link,
|
||||
label: 'Создать звонок',
|
||||
onTap: _createGroupCall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _buildLinkAction(
|
||||
cs,
|
||||
icon: Symbols.group_add,
|
||||
label: 'Присоединиться',
|
||||
onTap: _joinGroupCall,
|
||||
alignEnd: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
|
||||
@@ -550,7 +550,9 @@ class _CheckersViewState extends State<_CheckersView> {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final w = _result;
|
||||
if (w != null) return w == _me ? l10n.hubCheckersWon : l10n.hubCheckersLost;
|
||||
return _turn == _me ? l10n.hubCheckersYourMove : l10n.hubCheckersOpponentMove;
|
||||
return _turn == _me
|
||||
? l10n.hubCheckersYourMove
|
||||
: l10n.hubCheckersOpponentMove;
|
||||
}
|
||||
|
||||
Widget _boardWidget(ColorScheme cs) {
|
||||
|
||||
Reference in New Issue
Block a user