feat/fix: бейджик для свернутого звонка. Фикс смены иконки на айос

This commit is contained in:
Jganenokk
2026-08-21 18:44:40 +07:00
parent 8ce5df9b7e
commit eb94461c04
21 changed files with 1637 additions and 112 deletions
+93
View File
@@ -0,0 +1,93 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/scheduler.dart';
import 'call_session.dart';
class ActiveCallPresentation {
const ActiveCallPresentation({
required this.session,
required this.name,
required this.avatarUrl,
required this.isGroup,
});
final CallSession session;
final String name;
final String? avatarUrl;
final bool isGroup;
bool sameAs(ActiveCallPresentation other) =>
identical(session, other.session) &&
name == other.name &&
avatarUrl == other.avatarUrl &&
isGroup == other.isGroup;
}
class ActiveCall {
ActiveCall._();
static final ActiveCall instance = ActiveCall._();
final ValueNotifier<ActiveCallPresentation?> current = ValueNotifier(null);
final ValueNotifier<bool> screenVisible = ValueNotifier(false);
int _openScreens = 0;
StreamSubscription<CallSessionState>? _stateSub;
void attach({
required CallSession session,
required String name,
String? avatarUrl,
bool isGroup = false,
}) {
if (session.currentState == CallSessionState.ended) return;
final next = ActiveCallPresentation(
session: session,
name: name,
avatarUrl: avatarUrl,
isGroup: isGroup,
);
final previous = current.value;
if (previous != null && previous.sameAs(next)) return;
if (previous == null || !identical(previous.session, session)) {
_stateSub?.cancel();
_stateSub = session.stateStream.listen((state) {
if (state == CallSessionState.ended) detach(session);
});
}
_publish(current, next);
}
void detach([CallSession? session]) {
final active = current.value;
if (active == null) return;
if (session != null && !identical(active.session, session)) return;
_stateSub?.cancel();
_stateSub = null;
_publish(current, null);
}
void enterScreen() {
_openScreens++;
_publish(screenVisible, true);
}
void leaveScreen() {
if (_openScreens > 0) _openScreens--;
_publish(screenVisible, _openScreens > 0);
}
void _publish<T>(ValueNotifier<T> notifier, T value) {
if (SchedulerBinding.instance.schedulerPhase ==
SchedulerPhase.persistentCallbacks) {
SchedulerBinding.instance.addPostFrameCallback((_) {
notifier.value = value;
});
return;
}
notifier.value = value;
}
}
+42 -7
View File
@@ -5,15 +5,34 @@ import 'package:flutter/services.dart';
import 'package:shared_preferences/shared_preferences.dart';
enum AppIcon {
defaultIcon('default', 'Default', 'assets/komet_icon.png', 'MainActivity'),
minimal('minimal', 'Minimal', 'assets/meteor_icon.png', 'MinimalIcon');
defaultIcon(
'default',
'Default',
'assets/komet_icon.png',
'MainActivity',
null,
),
minimal(
'minimal',
'Minimal',
'assets/meteor_icon.png',
'MinimalIcon',
'MinimalIcon',
);
final String id;
final String title;
final String previewAsset;
final String platformName;
final String androidAlias;
final String? iosAlternateName;
const AppIcon(this.id, this.title, this.previewAsset, this.platformName);
const AppIcon(
this.id,
this.title,
this.previewAsset,
this.androidAlias,
this.iosAlternateName,
);
}
class AppIconConfig {
@@ -29,21 +48,37 @@ class AppIconConfig {
static Future<void> load() async {
if (!isSupported) return;
final prefs = await SharedPreferences.getInstance();
final id = prefs.getString(prefKey);
current.value = _parse(id);
var icon = _parse(prefs.getString(prefKey));
final applied = await _appliedIcon();
if (applied != null && applied != icon) {
icon = applied;
await prefs.setString(prefKey, icon.id);
}
current.value = icon;
}
static Future<void> apply(AppIcon icon) async {
if (!isSupported) return;
if (current.value == icon) return;
await _channel.invokeMethod<void>('setAppIcon', {
'name': icon.platformName,
'name': Platform.isIOS ? icon.iosAlternateName : icon.androidAlias,
});
final prefs = await SharedPreferences.getInstance();
await prefs.setString(prefKey, icon.id);
current.value = icon;
}
static Future<AppIcon?> _appliedIcon() async {
if (!Platform.isIOS) return null;
try {
final name = await _channel.invokeMethod<String>('getAppIcon');
for (final icon in AppIcon.values) {
if (icon.iosAlternateName == name) return icon;
}
} catch (_) {}
return null;
}
static AppIcon _parse(String? val) {
for (final icon in AppIcon.values) {
if (icon.id == val) return icon;
+39 -17
View File
@@ -9,18 +9,19 @@ import 'package:flutter_webrtc/flutter_webrtc.dart'
MediaStream,
RTCVideoRenderer,
RTCVideoValue,
RTCVideoView,
RTCVideoViewObjectFit;
import 'package:material_symbols_icons/symbols.dart';
import '../../../backend/modules/messages.dart' show ContactCache;
import '../../../core/cache/info_cache.dart';
import '../../../core/calls/active_call.dart';
import '../../../core/calls/call_controller.dart';
import '../../../core/calls/call_info.dart';
import '../../../core/calls/call_session.dart';
import '../../../core/config/app_colors.dart';
import '../../../core/utils/format.dart';
import '../../../l10n/app_localizations.dart';
import '../../widgets/call_video_view.dart';
import '../../widgets/custom_notification.dart';
import '../../widgets/glossy_pill.dart';
import '../../widgets/animated_slash_icon.dart';
@@ -133,6 +134,7 @@ class _CallScreenState extends State<CallScreen> with TickerProviderStateMixin {
@override
void initState() {
super.initState();
ActiveCall.instance.enterScreen();
_dotsController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1400),
@@ -179,6 +181,7 @@ class _CallScreenState extends State<CallScreen> with TickerProviderStateMixin {
if (name != null && name.isNotEmpty) _name = name;
if (avatar != null && avatar.isNotEmpty) _avatarUrl = avatar;
});
_publishActiveCall();
}
Future<void> _initRenderer() async {
@@ -245,6 +248,7 @@ class _CallScreenState extends State<CallScreen> with TickerProviderStateMixin {
_syncLocalPreview();
_isSpeaker = session.isSpeaker;
setState(() {});
_publishActiveCall();
});
_remoteStreamSub = session.remoteStreamStream.listen(_attachStream);
_tileStreamSub = session.participantStreamUpdates.listen(_onTileStream);
@@ -258,6 +262,18 @@ class _CallScreenState extends State<CallScreen> with TickerProviderStateMixin {
_resolveParticipants();
_syncVideo();
_isSpeaker = session.isSpeaker;
_publishActiveCall();
}
void _publishActiveCall() {
final session = _session;
if (session == null) return;
ActiveCall.instance.attach(
session: session,
name: _name,
avatarUrl: _avatarUrl,
isGroup: _isGroup,
);
}
void _showKometBadge() {
@@ -368,12 +384,15 @@ class _CallScreenState extends State<CallScreen> with TickerProviderStateMixin {
Future<void> _toggleVideo() async {
final session = _session;
if (session == null || _videoBusy) return;
final l10n = AppLocalizations.of(context)!;
setState(() => _videoBusy = true);
await WidgetsBinding.instance.endOfFrame;
try {
await session.setVideoEnabled(!session.localVideo);
} catch (e) {
if (mounted) showCustomNotification(context, 'Камера недоступна: $e');
if (mounted) {
showCustomNotification(context, l10n.callCameraUnavailable(e));
}
} finally {
_syncLocalPreview();
if (mounted) setState(() => _videoBusy = false);
@@ -404,6 +423,7 @@ class _CallScreenState extends State<CallScreen> with TickerProviderStateMixin {
@override
void dispose() {
ActiveCall.instance.leaveScreen();
_stateSub?.cancel();
_canceledSub?.cancel();
_infoSub?.cancel();
@@ -528,25 +548,26 @@ class _CallScreenState extends State<CallScreen> with TickerProviderStateMixin {
border: Border.all(color: cs.outlineVariant, width: 1),
),
child: _localRendererReady && _localRenderer.srcObject != null
? RTCVideoView(
_localRenderer,
? CallVideoView(
renderer: _localRenderer,
mirror: _session?.localScreen != true,
objectFit: RTCVideoViewObjectFit.RTCVideoViewObjectFitCover,
placeholder: _localPreviewIcon(cs),
)
: Center(
child: Icon(
_session?.localScreen == true
? Symbols.screen_share
: Symbols.videocam,
color: cs.onSurfaceVariant,
size: 28,
),
),
: _localPreviewIcon(cs),
),
),
);
}
Widget _localPreviewIcon(ColorScheme cs) => Center(
child: Icon(
_session?.localScreen == true ? Symbols.screen_share : Symbols.videocam,
color: cs.onSurfaceVariant,
size: 28,
),
);
Widget _buildGroupBody(ColorScheme cs) {
final l10n = AppLocalizations.of(context)!;
final participants = _session?.participants ?? const <CallParticipant>[];
@@ -764,9 +785,10 @@ class _CallScreenState extends State<CallScreen> with TickerProviderStateMixin {
child: Stack(
fit: StackFit.expand,
children: [
RTCVideoView(
renderer,
CallVideoView(
renderer: renderer,
objectFit: RTCVideoViewObjectFit.RTCVideoViewObjectFitCover,
placeholder: ColoredBox(color: cs.surfaceContainerHighest),
),
Positioned(
left: 8,
@@ -872,8 +894,8 @@ class _CallScreenState extends State<CallScreen> with TickerProviderStateMixin {
child: AspectRatio(
aspectRatio: ar,
child: RepaintBoundary(
child: RTCVideoView(
_remoteRenderer,
child: CallVideoView(
renderer: _remoteRenderer,
objectFit: RTCVideoViewObjectFit
.RTCVideoViewObjectFitCover,
),
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../widgets/connection_status.dart';
@@ -38,7 +39,8 @@ class _AppIconScreenState extends State<AppIconScreen> {
showCustomNotification(context, 'Иконка изменена на «${icon.title}»');
} catch (e) {
if (!mounted) return;
showCustomNotification(context, 'Не удалось сменить иконку: $e');
final reason = e is PlatformException ? (e.message ?? e.code) : '$e';
showCustomNotification(context, 'Не удалось сменить иконку: $reason');
}
}
+99
View File
@@ -0,0 +1,99 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart'
show RTCVideoRenderer, RTCVideoView, RTCVideoViewObjectFit;
class CallVideoView extends StatefulWidget {
const CallVideoView({
super.key,
required this.renderer,
this.objectFit = RTCVideoViewObjectFit.RTCVideoViewObjectFitContain,
this.mirror = false,
this.placeholder,
});
final RTCVideoRenderer renderer;
final RTCVideoViewObjectFit objectFit;
final bool mirror;
final Widget? placeholder;
@override
State<CallVideoView> createState() => _CallVideoViewState();
}
class _CallVideoViewState extends State<CallVideoView> {
static const Duration _switchCooldown = Duration(milliseconds: 200);
bool _armed = false;
String? _sourceId;
Timer? _cooldown;
@override
void initState() {
super.initState();
_bind(widget.renderer);
}
@override
void didUpdateWidget(CallVideoView old) {
super.didUpdateWidget(old);
if (identical(old.renderer, widget.renderer)) return;
old.renderer.removeListener(_onRenderer);
_cooldown?.cancel();
_cooldown = null;
_bind(widget.renderer);
}
@override
void dispose() {
_cooldown?.cancel();
widget.renderer.removeListener(_onRenderer);
super.dispose();
}
void _bind(RTCVideoRenderer renderer) {
_sourceId = renderer.srcObject?.id;
_armed = _hasFrames;
renderer.addListener(_onRenderer);
}
bool get _hasFrames {
final renderer = widget.renderer;
return renderer.textureId != null &&
renderer.srcObject != null &&
renderer.value.width > 0;
}
void _onRenderer() {
final id = widget.renderer.srcObject?.id;
if (id != _sourceId) {
_sourceId = id;
_cooldown?.cancel();
_cooldown = Timer(_switchCooldown, () {
_cooldown = null;
_sync();
});
if (_armed && mounted) setState(() => _armed = false);
return;
}
if (_cooldown != null) return;
_sync();
}
void _sync() {
if (!mounted) return;
final next = _hasFrames;
if (next != _armed) setState(() => _armed = next);
}
@override
Widget build(BuildContext context) {
if (!_armed) return widget.placeholder ?? const SizedBox.expand();
return RTCVideoView(
widget.renderer,
objectFit: widget.objectFit,
mirror: widget.mirror,
);
}
}
@@ -0,0 +1,245 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter/physics.dart';
import '../../core/utils/haptics.dart';
@immutable
class FloatingDockGeometry {
const FloatingDockGeometry({
required this.bounds,
required this.size,
required this.safeArea,
this.edge = 12,
this.restingBottomGap = 96,
});
final Size bounds;
final Size size;
final EdgeInsets safeArea;
final double edge;
final double restingBottomGap;
static const double flingSeconds = 0.09;
static const double flingThreshold = 320;
double get minX => edge;
double get maxX => math.max(minX, bounds.width - size.width - edge);
double get minY => safeArea.top + edge;
double get maxY =>
math.max(minY, bounds.height - size.height - safeArea.bottom - edge);
Offset get resting => clamp(
Offset(
maxX,
bounds.height - size.height - safeArea.bottom - restingBottomGap,
),
);
Offset clamp(Offset value) =>
Offset(value.dx.clamp(minX, maxX), value.dy.clamp(minY, maxY));
Offset snap(Offset value, Offset velocity) {
final center = value.dx + size.width / 2;
final toRight = velocity.dx.abs() > flingThreshold
? velocity.dx > 0
: center >= bounds.width / 2;
return clamp(
Offset(toRight ? maxX : minX, value.dy + velocity.dy * flingSeconds),
);
}
}
class DraggableFloatingLayer extends StatefulWidget {
const DraggableFloatingLayer({
super.key,
required this.storageKey,
required this.size,
required this.child,
this.onTap,
this.onDragStart,
this.onDragEnd,
this.edge = 12,
this.restingBottomGap = 96,
});
final String storageKey;
final Size size;
final Widget child;
final VoidCallback? onTap;
final VoidCallback? onDragStart;
final VoidCallback? onDragEnd;
final double edge;
final double restingBottomGap;
@override
State<DraggableFloatingLayer> createState() => _DraggableFloatingLayerState();
}
class _DraggableFloatingLayerState extends State<DraggableFloatingLayer>
with TickerProviderStateMixin {
static final Map<String, Offset> _remembered = {};
static final SpringDescription _spring = SpringDescription.withDampingRatio(
mass: 1,
stiffness: 520,
ratio: 1.0,
);
final ValueNotifier<Offset?> _offset = ValueNotifier(null);
late final AnimationController _settle =
AnimationController.unbounded(vsync: this)
..addListener(_onSettle)
..addStatusListener(_onSettleStatus);
late final AnimationController _lift = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 160),
reverseDuration: const Duration(milliseconds: 220),
);
FloatingDockGeometry _dock = const FloatingDockGeometry(
bounds: Size.zero,
size: Size.zero,
safeArea: EdgeInsets.zero,
);
Offset _from = Offset.zero;
Offset _to = Offset.zero;
Timer? _resizeSettle;
bool _dragging = false;
@override
void initState() {
super.initState();
_offset.value = _remembered[widget.storageKey];
}
@override
void didUpdateWidget(DraggableFloatingLayer old) {
super.didUpdateWidget(old);
if (widget.size == old.size || _dragging) return;
_resizeSettle?.cancel();
_resizeSettle = Timer(const Duration(milliseconds: 90), _redock);
}
@override
void dispose() {
_resizeSettle?.cancel();
_settle.dispose();
_lift.dispose();
_offset.dispose();
super.dispose();
}
Offset get _current => _dock.clamp(_offset.value ?? _dock.resting);
void _redock() {
if (!mounted || _dragging || _dock.bounds.isEmpty) return;
_animateTo(_dock.snap(_current, Offset.zero), Offset.zero);
}
void _animateTo(Offset target, Offset velocity) {
final start = _current;
final delta = target - start;
final distance = delta.distance;
if (distance < 0.5) {
_apply(target);
return;
}
final along =
(velocity.dx * delta.dx + velocity.dy * delta.dy) /
(distance * distance);
_from = start;
_to = target;
_settle.stop();
_settle.value = 0;
_settle.animateWith(
SpringSimulation(_spring, 0, 1, along.clamp(-12.0, 12.0)),
);
}
void _onSettle() => _apply(Offset.lerp(_from, _to, _settle.value)!);
void _onSettleStatus(AnimationStatus status) {
if (status.isAnimating) return;
_apply(_to);
}
void _apply(Offset value) {
final position = _dock.clamp(value);
_offset.value = position;
_remembered[widget.storageKey] = position;
}
void _onPanStart(DragStartDetails details) {
_settle.stop();
_resizeSettle?.cancel();
_dragging = true;
_lift.forward();
Haptics.selection();
widget.onDragStart?.call();
}
void _onPanUpdate(DragUpdateDetails details) =>
_apply(_current + details.delta);
void _onPanEnd(DragEndDetails details) {
final velocity = details.velocity.pixelsPerSecond;
_dragging = false;
_lift.reverse();
widget.onDragEnd?.call();
_animateTo(_dock.snap(_current, velocity), velocity);
}
void _onPanCancel() {
if (!_dragging) return;
_dragging = false;
_lift.reverse();
widget.onDragEnd?.call();
_redock();
}
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
_dock = FloatingDockGeometry(
bounds: constraints.biggest,
size: widget.size,
safeArea: MediaQuery.paddingOf(context),
edge: widget.edge,
restingBottomGap: widget.restingBottomGap,
);
return ValueListenableBuilder<Offset?>(
valueListenable: _offset,
child: RepaintBoundary(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: widget.onTap,
onPanStart: _onPanStart,
onPanUpdate: _onPanUpdate,
onPanEnd: _onPanEnd,
onPanCancel: _onPanCancel,
child: ScaleTransition(
scale: Tween<double>(begin: 1, end: 1.06).animate(
CurvedAnimation(parent: _lift, curve: Curves.easeOutCubic),
),
child: widget.child,
),
),
),
builder: (context, offset, child) {
final position = _dock.clamp(offset ?? _dock.resting);
return Stack(
children: [Transform.translate(offset: position, child: child)],
);
},
);
},
);
}
}
@@ -0,0 +1,711 @@
import 'dart:async';
import 'dart:ui' show lerpDouble;
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart'
show MediaStream, RTCVideoRenderer, RTCVideoViewObjectFit;
import 'package:material_symbols_icons/symbols.dart';
import '../../core/calls/active_call.dart';
import '../../core/calls/call_session.dart';
import '../../core/config/app_colors.dart';
import '../../core/config/app_fonts.dart';
import '../../core/utils/format.dart';
import '../../core/utils/haptics.dart';
import '../../l10n/app_localizations.dart';
import '../../main.dart' show KometApp;
import '../screens/calls/call_screen.dart';
import 'call_video_view.dart';
import 'custom_notification.dart';
import 'draggable_floating_layer.dart';
import 'glossy_pill.dart';
import 'lottie_slash_icon.dart';
import 'small_spinner.dart';
class FloatingCallBadgeLayer extends StatelessWidget {
const FloatingCallBadgeLayer({super.key});
@override
Widget build(BuildContext context) {
final call = ActiveCall.instance;
return ValueListenableBuilder<ActiveCallPresentation?>(
valueListenable: call.current,
builder: (context, active, _) {
if (active == null) return const SizedBox.shrink();
return ValueListenableBuilder<bool>(
valueListenable: call.screenVisible,
builder: (context, onScreen, _) => onScreen
? const SizedBox.shrink()
: _CallBadge(key: ObjectKey(active.session), call: active),
);
},
);
}
}
typedef _BadgeSnapshot = ({
bool muted,
bool video,
bool speaking,
bool hasVideo,
bool reconnecting,
CallSessionState state,
});
class _CallBadge extends StatefulWidget {
const _CallBadge({super.key, required this.call});
final ActiveCallPresentation call;
@override
State<_CallBadge> createState() => _CallBadgeState();
}
class _CallBadgeState extends State<_CallBadge>
with SingleTickerProviderStateMixin {
static const double _collapsedWidth = 120;
static const double _expandedWidth = 152;
static const double _collapsedHeight = 112;
static const double _expandedHeight = 160;
static const double _avatarSize = 56;
static const double _buttonSize = 38;
static const double _controlsWidth = _expandedWidth - 20;
static const Duration _autoCollapse = Duration(seconds: 4);
late final AnimationController _reveal = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 260),
reverseDuration: const Duration(milliseconds: 200),
);
StreamSubscription<CallSessionState>? _stateSub;
StreamSubscription<void>? _infoSub;
StreamSubscription<MediaStream>? _remoteStreamSub;
StreamSubscription<int>? _participantStreamSub;
Timer? _collapseTimer;
bool _chromeMounted = false;
_BadgeSnapshot? _rendered;
RTCVideoRenderer? _renderer;
MediaStream? _videoStream;
bool _rendererPending = false;
bool _videoBusy = false;
CallSession get _session => widget.call.session;
@override
void initState() {
super.initState();
_stateSub = _session.stateStream.listen((_) => _sync());
_infoSub = _session.infoUpdates.listen((_) => _sync());
_remoteStreamSub = _session.remoteStreamStream.listen((_) => _sync());
_participantStreamSub = _session.participantStreamUpdates.listen(
(_) => _sync(),
);
_reveal.addStatusListener(_onRevealStatus);
_attachVideo();
_rendered = _snapshot();
}
@override
void dispose() {
_stateSub?.cancel();
_infoSub?.cancel();
_remoteStreamSub?.cancel();
_participantStreamSub?.cancel();
_collapseTimer?.cancel();
_renderer?.srcObject = null;
_renderer?.dispose();
_reveal.dispose();
super.dispose();
}
void _sync() {
if (!mounted) return;
_attachVideo();
final next = _snapshot();
if (next == _rendered) return;
_rendered = next;
setState(() {});
}
void _redraw() {
if (!mounted) return;
_rendered = _snapshot();
setState(() {});
}
_BadgeSnapshot _snapshot() => (
muted: _session.isMuted,
video: _session.localVideo,
speaking: _peerSpeaking,
hasVideo: _videoStream != null,
reconnecting: _session.isReconnecting,
state: _session.currentState,
);
MediaStream? _pickVideoStream() {
final session = _session;
for (final participant in session.participants) {
if (participant.isSelf) continue;
if (!participant.videoEnabled && !participant.screenSharing) continue;
final stream = session.streamOf(participant.id);
if (stream != null && stream.getVideoTracks().isNotEmpty) return stream;
}
final remote = session.remoteStream;
if (session.peerVideo &&
remote != null &&
remote.getVideoTracks().isNotEmpty) {
return remote;
}
return null;
}
void _attachVideo() {
final next = _pickVideoStream();
if (identical(next, _videoStream)) return;
_videoStream = next;
final renderer = _renderer;
if (renderer != null) {
renderer.srcObject = next;
return;
}
if (next != null && !_rendererPending) {
_rendererPending = true;
unawaited(_createRenderer());
}
}
Future<void> _createRenderer() async {
final renderer = RTCVideoRenderer();
try {
await renderer.initialize();
} catch (_) {
_rendererPending = false;
return;
}
_rendererPending = false;
if (!mounted || _videoStream == null) {
await renderer.dispose();
return;
}
renderer.srcObject = _videoStream;
_renderer = renderer;
_redraw();
}
void _restartCollapseTimer() {
_collapseTimer?.cancel();
_collapseTimer = Timer(_autoCollapse, _collapse);
}
void _mountChrome() {
if (_chromeMounted) return;
setState(() => _chromeMounted = true);
}
void _onRevealStatus(AnimationStatus status) {
if (status != AnimationStatus.dismissed || !_chromeMounted) return;
setState(() => _chromeMounted = false);
}
void _expandControls() {
_mountChrome();
_reveal.forward();
_restartCollapseTimer();
}
void _collapse() {
_collapseTimer?.cancel();
if (mounted) _reveal.reverse();
}
void _toggleControls() {
Haptics.tap();
if (_reveal.value > 0.5) {
_collapse();
} else {
_expandControls();
}
}
void _onDragStart() {
_collapseTimer?.cancel();
_mountChrome();
_reveal.forward();
}
void _onDragEnd() => _restartCollapseTimer();
Future<void> _toggleMute() async {
Haptics.tap();
_restartCollapseTimer();
await _session.setMuted(!_session.isMuted);
_redraw();
}
Future<void> _toggleVideo() async {
if (_videoBusy) return;
Haptics.tap();
_restartCollapseTimer();
final l10n = AppLocalizations.of(context)!;
setState(() => _videoBusy = true);
await WidgetsBinding.instance.endOfFrame;
try {
await _session.setVideoEnabled(!_session.localVideo);
} catch (e) {
_notify(l10n.callCameraUnavailable(e));
} finally {
_videoBusy = false;
_redraw();
}
}
void _notify(String message) {
final overlay = KometApp.navigatorKey.currentState?.overlay;
if (overlay == null) return;
showCustomNotificationOnOverlay(overlay, message);
}
Future<void> _hangup() async {
Haptics.medium();
_collapseTimer?.cancel();
await _session.hangup();
}
void _openCall() {
Haptics.tap();
_collapse();
final navigator = KometApp.navigatorKey.currentState;
if (navigator == null) return;
navigator.push(
MaterialPageRoute(
builder: (_) => CallScreen(
name: widget.call.name,
avatarUrl: widget.call.avatarUrl,
session: _session,
isGroup: widget.call.isGroup,
),
),
);
}
bool get _peerSpeaking =>
_session.participants.any((p) => !p.isSelf && _session.isSpeaking(p.id));
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final cs = theme.colorScheme;
final l10n = AppLocalizations.of(context)!;
final text = (theme.textTheme.bodyMedium ?? const TextStyle()).copyWith(
color: cs.onSurface,
);
final title = _title(cs, l10n);
final face = _face(cs);
final expand = _chromeMounted ? _expandButton(cs, l10n) : null;
final controls = _chromeMounted ? _controls(cs, l10n) : null;
return AnimatedBuilder(
animation: _reveal,
builder: (context, _) {
final t = Curves.easeOutCubic.transform(_reveal.value);
final size = Size(
lerpDouble(_collapsedWidth, _expandedWidth, t)!,
lerpDouble(_collapsedHeight, _expandedHeight, t)!,
);
return DraggableFloatingLayer(
storageKey: 'call_badge',
size: size,
onTap: _toggleControls,
onDragStart: _onDragStart,
onDragEnd: _onDragEnd,
child: SizedBox.fromSize(
size: size,
child: _card(
cs,
t,
text: text,
title: title,
face: face,
expand: expand,
controls: controls,
),
),
);
},
);
}
Widget _card(
ColorScheme cs,
double t, {
required TextStyle text,
required Widget title,
required Widget face,
required Widget? expand,
required Widget? controls,
}) {
final radius = BorderRadius.circular(26);
return DefaultTextStyle(
style: text,
child: GlossyPill(
color: cs.surfaceContainerHigh,
borderRadius: radius,
depth: 10,
child: ClipRRect(
borderRadius: radius,
child: Stack(
children: [
Positioned(
left: 12,
right: lerpDouble(12, 44, t)!,
top: 10,
child: title,
),
if (expand != null)
Positioned(top: 7, right: 7, child: _fade(t, expand)),
Positioned(
left: 0,
right: 0,
top: 44,
bottom: lerpDouble(12, 58, t)!,
child: Center(child: face),
),
if (controls != null)
Positioned(
left: 0,
right: 0,
bottom: 10,
child: _fade(
t,
SizedBox(
height: _buttonSize,
child: OverflowBox(
minWidth: _controlsWidth,
maxWidth: _controlsWidth,
minHeight: _buttonSize,
maxHeight: _buttonSize,
alignment: Alignment.center,
child: controls,
),
),
),
),
],
),
),
),
);
}
Widget _fade(double t, Widget child) => IgnorePointer(
ignoring: t < 0.5,
child: Opacity(
opacity: t.clamp(0.0, 1.0),
child: Transform.scale(scale: lerpDouble(0.82, 1, t)!, child: child),
),
);
Widget _title(ColorScheme cs, AppLocalizations l10n) {
final name = widget.call.name.isEmpty
? l10n.callUnknownName
: widget.call.name;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: cs.onSurface,
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
_CallStatusLine(session: _session, color: cs.onSurfaceVariant),
],
);
}
Widget _expandButton(ColorScheme cs, AppLocalizations l10n) {
return Semantics(
label: l10n.callTooltipExpand,
button: true,
child: SizedBox.square(
dimension: 30,
child: GlossyPill(
color: cs.surfaceContainerHighest,
borderRadius: BorderRadius.circular(15),
depth: 6,
onTap: _openCall,
child: Center(
child: Icon(
Symbols.open_in_full,
size: 16,
weight: 600,
color: cs.onSurface,
),
),
),
),
);
}
Widget _face(ColorScheme cs) {
final muted = _session.isMuted;
final renderer = _renderer;
final showVideo = _videoStream != null && renderer != null;
return SizedBox.square(
dimension: _avatarSize,
child: Stack(
clipBehavior: Clip.none,
children: [
AnimatedContainer(
duration: const Duration(milliseconds: 220),
width: _avatarSize,
height: _avatarSize,
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: cs.surfaceContainerHighest,
border: Border.all(
color: _peerSpeaking
? kSuccessGreen
: Colors.white.withValues(alpha: 0.10),
width: _peerSpeaking ? 2.5 : 1.5,
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.32),
blurRadius: 16,
offset: const Offset(0, 6),
),
],
),
child: showVideo
? CallVideoView(
renderer: renderer,
objectFit: RTCVideoViewObjectFit.RTCVideoViewObjectFitCover,
placeholder: _avatar(cs),
)
: _avatar(cs),
),
if (muted)
Positioned(
right: -2,
bottom: -2,
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: cs.surfaceContainerHighest,
shape: BoxShape.circle,
border: Border.all(color: cs.surfaceContainerHigh, width: 2),
),
child: Icon(
Symbols.mic_off,
size: 12,
fill: 1,
color: cs.onSurfaceVariant,
),
),
),
],
),
);
}
Widget _avatar(ColorScheme cs) {
final url = widget.call.avatarUrl;
if (url == null || url.isEmpty) return _avatarFallback(cs);
return CachedNetworkImage(
imageUrl: url,
fit: BoxFit.cover,
memCacheWidth: 192,
memCacheHeight: 192,
errorWidget: (_, _, _) => _avatarFallback(cs),
);
}
Widget _avatarFallback(ColorScheme cs) {
final name = widget.call.name;
final letter = (name.isEmpty ? '?' : name[0]).toUpperCase();
return ColoredBox(
color: cs.primaryContainer,
child: Center(
child: Text(
letter,
style: TextStyle(
color: cs.onPrimaryContainer,
fontSize: _avatarSize * 0.38,
fontWeight: FontWeight.w600,
fontFamily: displayFontOf(context),
),
),
),
);
}
Widget _controls(ColorScheme cs, AppLocalizations l10n) {
final muted = _session.isMuted;
final video = _session.localVideo;
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_BadgeButton(
size: _buttonSize,
background: muted ? cs.primary : cs.surfaceContainerHighest,
label: muted ? l10n.callUnmute : l10n.callMute,
onTap: _toggleMute,
child: LottieSlashIcon(
asset: 'assets/lottie/ic_mic_on_to_off.json',
slashed: muted,
color: muted ? cs.onPrimary : cs.onSurface,
size: 20,
),
),
_BadgeButton(
size: _buttonSize,
background: video ? cs.primary : cs.surfaceContainerHighest,
label: l10n.callVideoLabel,
onTap: _toggleVideo,
child: _videoBusy
? SmallSpinner(
size: 16,
color: video ? cs.onPrimary : cs.onSurface,
)
: LottieSlashIcon(
asset: 'assets/lottie/ic_videocam_on_to_off.json',
slashed: !video,
color: video ? cs.onPrimary : cs.onSurface,
size: 20,
),
),
_BadgeButton(
size: _buttonSize,
background: kDangerRed,
label: l10n.callEndButton,
onTap: _hangup,
child: const Icon(
Symbols.call_end,
size: 20,
fill: 1,
color: Colors.white,
),
),
],
);
}
}
class _BadgeButton extends StatelessWidget {
const _BadgeButton({
required this.size,
required this.background,
required this.label,
required this.onTap,
required this.child,
});
final double size;
final Color background;
final String label;
final VoidCallback onTap;
final Widget child;
@override
Widget build(BuildContext context) {
return Semantics(
label: label,
button: true,
child: SizedBox.square(
dimension: size,
child: GlossyPill(
color: background,
borderRadius: BorderRadius.circular(size / 2),
depth: 7,
onTap: onTap,
child: Center(child: child),
),
),
);
}
}
class _CallStatusLine extends StatefulWidget {
const _CallStatusLine({required this.session, required this.color});
final CallSession session;
final Color color;
@override
State<_CallStatusLine> createState() => _CallStatusLineState();
}
class _CallStatusLineState extends State<_CallStatusLine> {
Timer? _ticker;
@override
void initState() {
super.initState();
_syncTicker();
}
@override
void didUpdateWidget(_CallStatusLine old) {
super.didUpdateWidget(old);
_syncTicker();
}
@override
void dispose() {
_ticker?.cancel();
super.dispose();
}
void _syncTicker() {
final counting =
widget.session.currentState == CallSessionState.active &&
!widget.session.isReconnecting;
if (counting == (_ticker != null)) return;
if (!counting) {
_ticker?.cancel();
_ticker = null;
return;
}
_ticker = Timer.periodic(const Duration(seconds: 1), (_) {
if (mounted) setState(() {});
});
}
String _label(AppLocalizations l10n) {
final session = widget.session;
if (session.isReconnecting) return l10n.callStatusConnecting;
return switch (session.currentState) {
CallSessionState.connecting => l10n.callStatusConnecting,
CallSessionState.ringing => l10n.callStatusRinging,
CallSessionState.ended => l10n.callStatusEnded,
CallSessionState.active => formatSecondsMmSs(
session.elapsedSeconds,
padMinutes: true,
),
};
}
@override
Widget build(BuildContext context) {
return Text(
_label(AppLocalizations.of(context)!),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: widget.color,
fontSize: 11,
fontWeight: FontWeight.w500,
fontFeatures: const [FontFeature.tabularFigures()],
),
);
}
}
+8 -69
View File
@@ -5,6 +5,7 @@ import 'package:video_player/video_player.dart';
import '../../core/media/media_playback.dart';
import '../../core/utils/haptics.dart';
import 'draggable_floating_layer.dart';
class FloatingVideoNoteLayer extends StatelessWidget {
const FloatingVideoNoteLayer({super.key});
@@ -27,45 +28,16 @@ class FloatingVideoNoteLayer extends StatelessWidget {
}
}
class _DraggableNote extends StatefulWidget {
class _DraggableNote extends StatelessWidget {
const _DraggableNote({required this.track});
final VideoNoteTrack track;
@override
State<_DraggableNote> createState() => _DraggableNoteState();
}
class _DraggableNoteState extends State<_DraggableNote> {
static const double _size = 96;
static const double _edge = 12;
static Offset? _saved;
final ValueNotifier<Offset?> _offset = ValueNotifier(null);
@override
void dispose() {
_offset.dispose();
super.dispose();
}
Offset _clamp(Offset value, Size bounds, EdgeInsets safe) {
final minX = _edge;
final maxX = math.max(minX, bounds.width - _size - _edge);
final minY = safe.top + _edge;
final maxY = math.max(minY, bounds.height - _size - safe.bottom - _edge);
return Offset(value.dx.clamp(minX, maxX), value.dy.clamp(minY, maxY));
}
Offset _initial(Size bounds, EdgeInsets safe) => Offset(
bounds.width - _size - _edge,
bounds.height - _size - safe.bottom - 96,
);
void _toggle() {
Haptics.tap();
final controller = widget.track.controller;
final controller = track.controller;
if (controller.value.isPlaying) {
controller.pause();
} else {
@@ -73,46 +45,13 @@ class _DraggableNoteState extends State<_DraggableNote> {
}
}
void _drag(Offset delta, Size bounds, EdgeInsets safe) {
final current = _clamp(
_offset.value ?? _saved ?? _initial(bounds, safe),
bounds,
safe,
);
final next = _clamp(current + delta, bounds, safe);
_offset.value = next;
_saved = next;
}
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final bounds = constraints.biggest;
final safe = MediaQuery.paddingOf(context);
return ValueListenableBuilder<Offset?>(
valueListenable: _offset,
child: RepaintBoundary(
child: GestureDetector(
onTap: _toggle,
onPanUpdate: (details) => _drag(details.delta, bounds, safe),
child: _NoteCircle(track: widget.track, size: _size),
),
),
builder: (context, offset, child) {
final position = _clamp(
offset ?? _saved ?? _initial(bounds, safe),
bounds,
safe,
);
return Stack(
children: [
Positioned(left: position.dx, top: position.dy, child: child!),
],
);
},
);
},
return DraggableFloatingLayer(
storageKey: 'video_note',
size: const Size(_size, _size),
onTap: _toggle,
child: _NoteCircle(track: track, size: _size),
);
}
}
+2
View File
@@ -389,6 +389,7 @@
"callParticipantYou": "You",
"callParticipantFallback": "Participant",
"callTooltipMinimize": "Minimize",
"callTooltipExpand": "Expand",
"callTooltipKometHub": "Komet",
"callInfoTitle": "About call",
"callPeerMicOff": "Microphone off",
@@ -405,6 +406,7 @@
"callUnmute": "Unmute",
"callMute": "Mute",
"callEndButton": "End",
"callCameraUnavailable": "Camera unavailable: {error}",
"callInfoClient": "Client",
"callInfoPlatform": "Platform",
"callInfoCountry": "Country",
+12
View File
@@ -2072,6 +2072,12 @@ abstract class AppLocalizations {
/// **'Minimize'**
String get callTooltipMinimize;
/// No description provided for @callTooltipExpand.
///
/// In en, this message translates to:
/// **'Expand'**
String get callTooltipExpand;
/// No description provided for @callTooltipKometHub.
///
/// In en, this message translates to:
@@ -2168,6 +2174,12 @@ abstract class AppLocalizations {
/// **'End'**
String get callEndButton;
/// No description provided for @callCameraUnavailable.
///
/// In en, this message translates to:
/// **'Camera unavailable: {error}'**
String callCameraUnavailable(Object error);
/// No description provided for @callInfoClient.
///
/// In en, this message translates to:
+8
View File
@@ -1051,6 +1051,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get callTooltipMinimize => 'Minimize';
@override
String get callTooltipExpand => 'Expand';
@override
String get callTooltipKometHub => 'Komet';
@@ -1099,6 +1102,11 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get callEndButton => 'End';
@override
String callCameraUnavailable(Object error) {
return 'Camera unavailable: $error';
}
@override
String get callInfoClient => 'Client';
+8
View File
@@ -1053,6 +1053,9 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get callTooltipMinimize => 'Свернуть';
@override
String get callTooltipExpand => 'Развернуть';
@override
String get callTooltipKometHub => 'Komet';
@@ -1101,6 +1104,11 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get callEndButton => 'Завершить';
@override
String callCameraUnavailable(Object error) {
return 'Камера недоступна: $error';
}
@override
String get callInfoClient => 'Клиент';
+2
View File
@@ -347,6 +347,7 @@
"callParticipantYou": "Вы",
"callParticipantFallback": "Участник",
"callTooltipMinimize": "Свернуть",
"callTooltipExpand": "Развернуть",
"callTooltipKometHub": "Komet",
"callInfoTitle": "О звонке",
"callPeerMicOff": "Микрофон выключен",
@@ -363,6 +364,7 @@
"callUnmute": "Вкл. звук",
"callMute": "Выкл. звук",
"callEndButton": "Завершить",
"callCameraUnavailable": "Камера недоступна: {error}",
"callInfoClient": "Клиент",
"callInfoPlatform": "Платформа",
"callInfoCountry": "Страна",
+4
View File
@@ -91,6 +91,7 @@ import 'frontend/widgets/custom_notification.dart';
import 'frontend/widgets/liquid_glass.dart';
import 'frontend/widgets/small_spinner.dart';
import 'frontend/widgets/theme_reveal.dart';
import 'frontend/widgets/floating_call_badge.dart';
import 'frontend/widgets/floating_video_note.dart';
final api = Api();
@@ -1033,6 +1034,9 @@ class KometAppState extends State<KometApp>
const Positioned.fill(
child: FloatingVideoNoteLayer(),
),
const Positioned.fill(
child: FloatingCallBadgeLayer(),
),
if (fpsOn) const FpsOverlayLayer(),
],
);