feat/fix: бейджик для свернутого звонка. Фикс смены иконки на айос
This commit is contained in:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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 => 'Клиент';
|
||||
|
||||
|
||||
@@ -347,6 +347,7 @@
|
||||
"callParticipantYou": "Вы",
|
||||
"callParticipantFallback": "Участник",
|
||||
"callTooltipMinimize": "Свернуть",
|
||||
"callTooltipExpand": "Развернуть",
|
||||
"callTooltipKometHub": "Komet",
|
||||
"callInfoTitle": "О звонке",
|
||||
"callPeerMicOff": "Микрофон выключен",
|
||||
@@ -363,6 +364,7 @@
|
||||
"callUnmute": "Вкл. звук",
|
||||
"callMute": "Выкл. звук",
|
||||
"callEndButton": "Завершить",
|
||||
"callCameraUnavailable": "Камера недоступна: {error}",
|
||||
"callInfoClient": "Клиент",
|
||||
"callInfoPlatform": "Платформа",
|
||||
"callInfoCountry": "Страна",
|
||||
|
||||
@@ -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(),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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)],
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user