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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+27 -18
View File
@@ -65,26 +65,35 @@ final class KometStreamHandler: NSObject, FlutterStreamHandler {
private func registerAppIcon(_ messenger: FlutterBinaryMessenger) {
method("ru.komet.app/app_icon", messenger) { call, result in
guard call.method == "setAppIcon" else {
result(FlutterMethodNotImplemented)
return
}
let name = (call.arguments as? [String: Any])?["name"] as? String
let iconName: String? = (name == "DefaultIcon") ? nil : name
guard UIApplication.shared.supportsAlternateIcons else {
result(FlutterError(code: "UNSUPPORTED",
message: "Alternate icons are not supported",
details: nil))
return
}
UIApplication.shared.setAlternateIconName(iconName) { error in
if let error = error {
result(FlutterError(code: "APPLY_FAILED",
message: error.localizedDescription,
switch call.method {
case "getAppIcon":
result(UIApplication.shared.alternateIconName)
case "setAppIcon":
let requested = (call.arguments as? [String: Any])?["name"] as? String
let iconName: String? = (requested?.isEmpty ?? true) ? nil : requested
guard UIApplication.shared.supportsAlternateIcons else {
result(FlutterError(code: "UNSUPPORTED",
message: "Alternate icons are not supported",
details: nil))
} else {
result(nil)
return
}
guard UIApplication.shared.alternateIconName != iconName else {
result(nil)
return
}
UIApplication.shared.setAlternateIconName(iconName) { error in
DispatchQueue.main.async {
if let error = error {
result(FlutterError(code: "APPLY_FAILED",
message: error.localizedDescription,
details: nil))
} else {
result(nil)
}
}
}
default:
result(FlutterMethodNotImplemented)
}
}
}
+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(),
],
);
+96
View File
@@ -0,0 +1,96 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:komet/core/config/app_icon.dart';
Set<String> _iosAlternateIcons() {
final plist = File('ios/Runner/Info.plist').readAsStringSync();
final anchor = plist.indexOf('<key>CFBundleAlternateIcons</key>');
expect(
anchor,
isNonNegative,
reason: 'в Info.plist нет альтернативных иконок',
);
final tokens = RegExp(
r'<dict>|</dict>|<key>([^<]*)</key>',
).allMatches(plist.substring(anchor));
final names = <String>{};
var depth = 0;
var entered = false;
for (final token in tokens) {
final text = token.group(0)!;
if (text == '<dict>') {
depth++;
entered = true;
} else if (text == '</dict>') {
depth--;
if (entered && depth == 0) break;
} else if (entered && depth == 1) {
names.add(token.group(1)!);
}
}
return names;
}
Set<String> _androidComponents() {
final manifest = File(
'android/app/src/main/AndroidManifest.xml',
).readAsStringSync();
return RegExp(
r'android:name="ru\.komet\.app\.(\w+)"',
).allMatches(manifest).map((m) => m.group(1)!).toSet();
}
Set<String> _androidIconKeys() {
final source = File(
'android/app/src/main/kotlin/ru/komet/app/MainActivity.kt',
).readAsStringSync();
final start = source.indexOf('iconComponents = mapOf(');
expect(start, isNonNegative, reason: 'в MainActivity нет карты иконок');
final body = source.substring(start, source.indexOf(')', start));
return RegExp(r'"(\w+)" to').allMatches(body).map((m) => m.group(1)!).toSet();
}
void main() {
test('дефолтная иконка на iOS — это primary, а не альтернативная', () {
expect(
AppIcon.defaultIcon.iosAlternateName,
isNull,
reason: 'setAlternateIconName ждёт nil, любое имя тут — APPLY_FAILED',
);
});
test('каждая альтернативная иконка объявлена в Info.plist', () {
final declared = _iosAlternateIcons();
for (final icon in AppIcon.values) {
final name = icon.iosAlternateName;
if (name == null) continue;
expect(
declared,
contains(name),
reason: 'иконка ${icon.id} шлёт в iOS имя $name',
);
}
});
test('каждый android alias объявлен в манифесте и в MainActivity', () {
final components = _androidComponents();
final keys = _androidIconKeys();
for (final icon in AppIcon.values) {
expect(
components,
contains(icon.androidAlias),
reason: 'иконка ${icon.id} шлёт в Android имя ${icon.androidAlias}',
);
expect(keys, contains(icon.androidAlias));
}
});
test('имена иконок не пересекаются между платформами по смыслу', () {
final ids = AppIcon.values.map((i) => i.id).toSet();
expect(ids.length, AppIcon.values.length);
final aliases = AppIcon.values.map((i) => i.androidAlias).toSet();
expect(aliases.length, AppIcon.values.length);
});
}
+128
View File
@@ -0,0 +1,128 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:komet/core/calls/active_call.dart';
import 'package:komet/core/calls/call_session.dart';
import 'package:komet/core/calls/ws2_signaling.dart';
import 'package:komet/frontend/widgets/floating_call_badge.dart';
import 'package:komet/l10n/app_localizations.dart';
import 'package:material_symbols_icons/symbols.dart';
const _name = 'Тестовый собеседник';
CallSession _session() => CallSession(
ws2Config: Ws2Config(uri: Uri.parse('wss://calls.invalid/ws2'), userId: 42),
role: CallRole.caller,
);
Widget _host() => MaterialApp(
locale: const Locale('ru'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
builder: (context, child) => Stack(
children: [
child!,
const Positioned.fill(child: FloatingCallBadgeLayer()),
],
),
home: const Scaffold(body: SizedBox.expand()),
);
final Finder _hangup = find.byIcon(Symbols.call_end);
double _controlsOpacity(WidgetTester tester) => tester
.widget<Opacity>(
find.ancestor(of: _hangup, matching: find.byType(Opacity)).first,
)
.opacity;
Finder get _badge => find.descendant(
of: find.byType(FloatingCallBadgeLayer),
matching: find.byType(ScaleTransition),
);
Offset _badgeCorner(WidgetTester tester) => tester.getTopLeft(_badge);
void main() {
tearDown(() {
ActiveCall.instance.detach();
while (ActiveCall.instance.screenVisible.value) {
ActiveCall.instance.leaveScreen();
}
});
testWidgets('бейджик виден только пока экран звонка закрыт', (tester) async {
await tester.pumpWidget(_host());
expect(find.text(_name), findsNothing);
ActiveCall.instance.attach(session: _session(), name: _name);
await tester.pump();
expect(find.text(_name), findsOneWidget);
ActiveCall.instance.enterScreen();
await tester.pump();
expect(find.text(_name), findsNothing);
ActiveCall.instance.leaveScreen();
await tester.pump();
expect(find.text(_name), findsOneWidget);
ActiveCall.instance.detach();
await tester.pump();
expect(find.text(_name), findsNothing);
});
testWidgets('тап раскрывает кнопки и повторный тап их прячет', (
tester,
) async {
ActiveCall.instance.attach(session: _session(), name: _name);
await tester.pumpWidget(_host());
expect(
_hangup,
findsNothing,
reason: 'свёрнутый бейджик не держит кнопки в дереве',
);
await tester.tap(find.text(_name));
await tester.pumpAndSettle();
expect(_hangup, findsOneWidget);
expect(_controlsOpacity(tester), 1);
await tester.tap(find.text(_name));
await tester.pumpAndSettle();
expect(_hangup, findsNothing);
});
testWidgets('кнопки прячутся сами, если бейджик не трогают', (tester) async {
ActiveCall.instance.attach(session: _session(), name: _name);
await tester.pumpWidget(_host());
await tester.tap(find.text(_name));
await tester.pumpAndSettle();
expect(_controlsOpacity(tester), 1);
await tester.pump(const Duration(seconds: 5));
await tester.pumpAndSettle();
expect(_hangup, findsNothing);
});
testWidgets('перетаскивание липнет к краю экрана', (tester) async {
ActiveCall.instance.attach(session: _session(), name: _name);
await tester.pumpWidget(_host());
final screen = tester.getSize(find.byType(FloatingCallBadgeLayer)).width;
final start = _badgeCorner(tester);
final width = tester.getSize(_badge).width;
expect(start.dx + width, moreOrLessEquals(screen - 12));
await tester.timedDrag(
find.text(_name),
const Offset(-260, -120),
const Duration(milliseconds: 300),
);
await tester.pumpAndSettle();
expect(_badgeCorner(tester).dx, moreOrLessEquals(12));
expect(_badgeCorner(tester).dy, lessThan(start.dy));
});
}
+94
View File
@@ -0,0 +1,94 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:komet/frontend/widgets/draggable_floating_layer.dart';
const _screen = Size(400, 800);
const _safe = EdgeInsets.only(top: 48, bottom: 24);
FloatingDockGeometry _dockOf(Size size) =>
FloatingDockGeometry(bounds: _screen, size: size, safeArea: _safe);
final _badge = _dockOf(const Size(120, 118));
void main() {
test('бейджик стартует у правого края над нижней панелью', () {
final resting = _badge.resting;
expect(resting.dx, _screen.width - 120 - 12);
expect(resting.dy, _screen.height - 118 - 24 - 96);
});
test('позиция зажата в безопасной зоне', () {
expect(_badge.clamp(const Offset(-500, -500)), Offset(12, 48 + 12));
expect(
_badge.clamp(const Offset(9999, 9999)),
Offset(_screen.width - 120 - 12, _screen.height - 118 - 24 - 12),
);
});
test('без броска липнет к ближнему краю', () {
final left = _badge.snap(const Offset(40, 300), Offset.zero);
expect(left.dx, 12);
expect(
left.dy,
300,
reason: 'высота не меняется без вертикальной скорости',
);
final right = _badge.snap(const Offset(250, 300), Offset.zero);
expect(right.dx, _screen.width - 120 - 12);
});
test('бросок перебивает ближний край', () {
final flungRight = _badge.snap(const Offset(40, 300), const Offset(900, 0));
expect(flungRight.dx, _screen.width - 120 - 12);
final flungLeft = _badge.snap(
const Offset(250, 300),
const Offset(-900, 0),
);
expect(flungLeft.dx, 12);
});
test('слабый рывок не считается броском', () {
final weak = _badge.snap(const Offset(40, 300), const Offset(200, 0));
expect(weak.dx, 12);
});
test('вертикальный бросок продолжает движение, но не за экран', () {
final down = _badge.snap(const Offset(250, 300), const Offset(0, 1000));
expect(down.dy, 300 + 1000 * FloatingDockGeometry.flingSeconds);
final overshoot = _badge.snap(
const Offset(250, 600),
const Offset(0, 5000),
);
expect(overshoot.dy, _screen.height - 118 - 24 - 12);
final up = _badge.snap(const Offset(250, 100), const Offset(0, -5000));
expect(up.dy, 48 + 12);
});
test('раскрытие бейджика удерживает правый край на месте', () {
final collapsed = _badge;
final expanded = _dockOf(const Size(152, 166));
final docked = collapsed.resting;
final grown = expanded.clamp(docked);
expect(
grown.dx + 152,
docked.dx + 120,
reason: 'кнопки должны вырастать влево, а не выезжать за экран',
);
});
test('узкий экран не переворачивает границы', () {
final tiny = FloatingDockGeometry(
bounds: const Size(100, 200),
size: const Size(120, 118),
safeArea: EdgeInsets.zero,
);
expect(tiny.maxX, tiny.minX);
expect(tiny.clamp(const Offset(50, 50)).dx, 12);
});
}
+14
View File
@@ -657,7 +657,9 @@ def pair_glyphs(from_cp, to_cp, count, fill=0.0):
MIC = 0xE31D
MIC_OFF = 0xE02B
CAM = 0xE04B
CAM_OFF = 0xE04C
SEND = 0xE163
VOLUME_UP = 0xE050
VOLUME_OFF = 0xE04F
@@ -993,6 +995,18 @@ SLASH_SPECS = [
fill=1.0,
scale=[(0, 100), (11, 92), (DUR, 100)],
),
dict(
name='ic_mic_on_to_off',
plain_cp=MIC, slashed_cp=MIC_OFF,
fill=1.0,
scale=[(0, 100), (11, 92), (DUR, 100)],
),
dict(
name='ic_videocam_on_to_off',
plain_cp=CAM, slashed_cp=CAM_OFF,
fill=1.0,
scale=[(0, 100), (11, 92), (DUR, 100)],
),
]