Release IKAR 1.0.7: port navigation and sharing improvements, harden calls
Tests / test (push) Canceled after 0s
Build Android (FCM) / build-android-fcm (push) Canceled after 0s
Build Android / build-android (push) Canceled after 0s
Build iOS / build-ios (push) Canceled after 0s
Build Linux / build-linux (push) Canceled after 0s
Build macOS / build-macos (push) Canceled after 0s
Build Windows / build-windows (push) Canceled after 0s
Native crypto / native-crypto (push) Canceled after 0s
Release (main) / android (oneme) (push) Canceled after 0s
Release (main) / android (qlyra) (push) Canceled after 0s
Release (main) / windows (push) Canceled after 0s
Release (main) / linux (push) Canceled after 0s
Release (main) / macos (push) Canceled after 0s
Release (main) / ios (push) Canceled after 0s
Release (main) / release (push) Canceled after 0s
Tests / test (push) Canceled after 0s
Build Android (FCM) / build-android-fcm (push) Canceled after 0s
Build Android / build-android (push) Canceled after 0s
Build iOS / build-ios (push) Canceled after 0s
Build Linux / build-linux (push) Canceled after 0s
Build macOS / build-macos (push) Canceled after 0s
Build Windows / build-windows (push) Canceled after 0s
Native crypto / native-crypto (push) Canceled after 0s
Release (main) / android (oneme) (push) Canceled after 0s
Release (main) / android (qlyra) (push) Canceled after 0s
Release (main) / windows (push) Canceled after 0s
Release (main) / linux (push) Canceled after 0s
Release (main) / macos (push) Canceled after 0s
Release (main) / ios (push) Canceled after 0s
Release (main) / release (push) Canceled after 0s
This commit is contained in:
@@ -201,7 +201,8 @@ class TranscriptionCache {
|
||||
} else {
|
||||
_expanded.remove(messageId);
|
||||
}
|
||||
for (final listener in _listeners[messageId]?.toList() ?? <VoidCallback>[]) {
|
||||
for (final listener
|
||||
in _listeners[messageId]?.toList() ?? <VoidCallback>[]) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
@@ -789,6 +790,23 @@ class MessagesModule {
|
||||
}
|
||||
|
||||
// #***! история с сервера
|
||||
Future<CachedMessage?> fetchMessage(
|
||||
int accountId,
|
||||
int chatId,
|
||||
String id,
|
||||
) async {
|
||||
final messageId = int.tryParse(id);
|
||||
if (messageId == null) return null;
|
||||
final response = await _api.sendRequest(Opcode.msgGet, {
|
||||
'chatId': chatId,
|
||||
'messageId': messageId,
|
||||
});
|
||||
if (!response.isOk || response.payload is! Map) return null;
|
||||
final raw = (response.payload as Map)['message'];
|
||||
if (raw is! Map) return null;
|
||||
return _parseMessage(raw, accountId, chatId);
|
||||
}
|
||||
|
||||
Future<List<CachedMessage>> fetchHistory(
|
||||
int accountId,
|
||||
int chatId, {
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// Pending UI removals are scoped to an account as well as a chat.
|
||||
class PendingMessageDeletions extends ChangeNotifier {
|
||||
PendingMessageDeletions();
|
||||
static final instance = PendingMessageDeletions();
|
||||
final Map<(int, int), Set<String>> _pending = {};
|
||||
|
||||
bool contains(int accountId, int chatId, String id) =>
|
||||
_pending[(accountId, chatId)]?.contains(id) ?? false;
|
||||
|
||||
void hold(int accountId, int chatId, Iterable<String> ids) {
|
||||
_pending.putIfAbsent((accountId, chatId), () => {}).addAll(ids);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void release(int accountId, int chatId, Iterable<String> ids) {
|
||||
final pending = _pending[(accountId, chatId)];
|
||||
if (pending == null) return;
|
||||
pending.removeAll(ids);
|
||||
if (pending.isEmpty) _pending.remove((accountId, chatId));
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart'
|
||||
show TargetPlatform, defaultTargetPlatform;
|
||||
show TargetPlatform, defaultTargetPlatform, visibleForTesting;
|
||||
import 'package:flutter_webrtc/flutter_webrtc.dart';
|
||||
|
||||
import '../config/app_camera.dart';
|
||||
@@ -127,6 +127,8 @@ class CallSession {
|
||||
static const Duration _maxReconnectDelay = Duration(seconds: 20);
|
||||
|
||||
bool get isReconnecting => _reconnecting;
|
||||
String? _failureReason;
|
||||
String? get failureReason => _failureReason;
|
||||
|
||||
Timer? _levelTimer;
|
||||
final Map<int, int> _speakHold = {};
|
||||
@@ -238,7 +240,11 @@ class CallSession {
|
||||
: DateTime.now().difference(_activeSince!).inSeconds;
|
||||
|
||||
void _setState(CallSessionState s) {
|
||||
if (_current == s || _current == CallSessionState.ended) return;
|
||||
if (_state.isClosed ||
|
||||
_current == s ||
|
||||
_current == CallSessionState.ended) {
|
||||
return;
|
||||
}
|
||||
if (s == CallSessionState.active) _activeSince ??= DateTime.now();
|
||||
_current = s;
|
||||
_state.add(s);
|
||||
@@ -360,6 +366,7 @@ class CallSession {
|
||||
_reconnecting = false;
|
||||
if (!_ended) {
|
||||
logger.w('[call] reconnect gave up');
|
||||
_failureReason = 'Не удалось восстановить соединение для звонка';
|
||||
_end();
|
||||
}
|
||||
}
|
||||
@@ -368,6 +375,8 @@ class CallSession {
|
||||
if (_ended || _iceRestarting || _topology == 'SERVER') return;
|
||||
if (_iceRestarts >= _maxIceRestarts) {
|
||||
logger.w('[call] ice restart budget exhausted, ending call');
|
||||
_failureReason =
|
||||
'Не удалось установить соединение для звонка. Проверьте сеть';
|
||||
_end();
|
||||
return;
|
||||
}
|
||||
@@ -398,10 +407,15 @@ class CallSession {
|
||||
_probeChannel = null;
|
||||
await _closeSfuChannels();
|
||||
|
||||
try {
|
||||
await _pc?.close();
|
||||
} catch (_) {}
|
||||
final previousPc = _pc;
|
||||
_pc = null;
|
||||
previousPc?.onIceCandidate = null;
|
||||
previousPc?.onTrack = null;
|
||||
previousPc?.onConnectionState = null;
|
||||
previousPc?.onIceConnectionState = null;
|
||||
try {
|
||||
await previousPc?.close();
|
||||
} catch (_) {}
|
||||
|
||||
_audioSender = null;
|
||||
_videoSender = null;
|
||||
@@ -409,6 +423,7 @@ class CallSession {
|
||||
_remoteDescSet = false;
|
||||
_pendingCandidates.clear();
|
||||
_accepted = false;
|
||||
_gotConnection = false;
|
||||
_mediaConnected = false;
|
||||
_sfuSessionId = null;
|
||||
await _clearParticipantStreams();
|
||||
@@ -785,6 +800,9 @@ class CallSession {
|
||||
}
|
||||
|
||||
Future<void> _onConnection(Map<String, dynamic> msg) async {
|
||||
if (_ended) return;
|
||||
// A wake command or reconnect can repeat the handshake notification.
|
||||
if (_gotConnection) return;
|
||||
_gotConnection = true;
|
||||
logger.i('[call] connection notification received');
|
||||
final convParams = msg['conversationParams'];
|
||||
@@ -808,8 +826,13 @@ class CallSession {
|
||||
}
|
||||
|
||||
final pc = await _createPc(ice);
|
||||
if (_ended) {
|
||||
await pc.close();
|
||||
return;
|
||||
}
|
||||
_pc = pc;
|
||||
await _addLocalMedia(pc);
|
||||
if (_ended || !identical(_pc, pc)) return;
|
||||
|
||||
await pc.addTransceiver(
|
||||
kind: RTCRtpMediaType.RTCRtpMediaTypeVideo,
|
||||
@@ -846,12 +869,13 @@ class CallSession {
|
||||
final done = _gatherDone;
|
||||
if (done != null && !done.isCompleted) done.complete();
|
||||
};
|
||||
pc.onTrack = (event) => unawaited(_onRemoteTrack(event));
|
||||
pc.onTrack = (event) => _runCallTask(() => _onRemoteTrack(event));
|
||||
pc.onDataChannel = (channel) {
|
||||
if (!_qlyraProbeEnabled) return;
|
||||
_bindProbeChannel(channel, ask: false);
|
||||
};
|
||||
pc.onIceConnectionState = (s) {
|
||||
if (_ended || !identical(_pc, pc)) return;
|
||||
logger.i('[call] ice $s');
|
||||
if (s != RTCIceConnectionState.RTCIceConnectionStateFailed) return;
|
||||
if (_topology != 'SERVER' || _ended) return;
|
||||
@@ -865,6 +889,7 @@ class CallSession {
|
||||
);
|
||||
};
|
||||
pc.onConnectionState = (s) {
|
||||
if (_ended || !identical(_pc, pc)) return;
|
||||
logger.i('[call] pc $s');
|
||||
final connected =
|
||||
s == RTCPeerConnectionState.RTCPeerConnectionStateConnected;
|
||||
@@ -876,9 +901,9 @@ class CallSession {
|
||||
if (role == CallRole.joiner || _topology == 'SERVER') {
|
||||
_setState(CallSessionState.active);
|
||||
}
|
||||
unawaited(applyAudioRoute());
|
||||
unawaited(_resolvePath());
|
||||
unawaited(_collectReceivers());
|
||||
_runCallTask(applyAudioRoute);
|
||||
_runCallTask(_resolvePath);
|
||||
_runCallTask(_collectReceivers);
|
||||
}
|
||||
}
|
||||
if (_topology == 'SERVER') return;
|
||||
@@ -905,15 +930,22 @@ class CallSession {
|
||||
await _resetMicRoute();
|
||||
}
|
||||
await _selectMicInsideEngine();
|
||||
_localStream = await navigator.mediaDevices.getUserMedia({
|
||||
if (_ended || !identical(_pc, pc)) return;
|
||||
final stream = await navigator.mediaDevices.getUserMedia({
|
||||
'audio': AudioDevices.micConstraints(
|
||||
_micDeviceId,
|
||||
monitorCapture: _monitorCapture,
|
||||
),
|
||||
'video': _wantVideo,
|
||||
});
|
||||
for (final track in _localStream!.getTracks()) {
|
||||
final sender = await pc.addTrack(track, _localStream!);
|
||||
if (_ended || !identical(_pc, pc)) {
|
||||
await _disposeStream(stream);
|
||||
return;
|
||||
}
|
||||
_localStream = stream;
|
||||
for (final track in stream.getTracks()) {
|
||||
if (_ended || !identical(_pc, pc)) return;
|
||||
final sender = await pc.addTrack(track, stream);
|
||||
if (track.kind == 'audio') _audioSender = sender;
|
||||
}
|
||||
_applyAudioTracks();
|
||||
@@ -1136,7 +1168,7 @@ class CallSession {
|
||||
final id = _participantIdFrom(key.split(':').first);
|
||||
if (id != null) _slotParticipant[slot] = id;
|
||||
});
|
||||
unawaited(_rebindSlotTracks());
|
||||
_runCallTask(_rebindSlotTracks);
|
||||
}
|
||||
|
||||
Future<void> _rebindSlotTracks() async {
|
||||
@@ -1829,6 +1861,7 @@ class CallSession {
|
||||
}
|
||||
|
||||
Future<void> _onRemoteTrack(RTCTrackEvent event) async {
|
||||
if (_ended) return;
|
||||
logger.t(
|
||||
'[call] remote track: ${event.track.kind} id=${event.track.id} '
|
||||
'streams=${event.streams.length}',
|
||||
@@ -1836,7 +1869,9 @@ class CallSession {
|
||||
await _bindParticipantTrack(event.track);
|
||||
if (event.streams.isNotEmpty) {
|
||||
_remoteStreamRef = event.streams.first;
|
||||
_remoteStream.add(event.streams.first);
|
||||
if (!_ended && !_remoteStream.isClosed) {
|
||||
_remoteStream.add(event.streams.first);
|
||||
}
|
||||
} else {
|
||||
await _collectReceivers();
|
||||
}
|
||||
@@ -1887,7 +1922,7 @@ class CallSession {
|
||||
await stream.addTrack(track);
|
||||
} catch (_) {}
|
||||
}
|
||||
_remoteStream.add(stream);
|
||||
if (!_ended && !_remoteStream.isClosed) _remoteStream.add(stream);
|
||||
}
|
||||
|
||||
Future<void> _logSenders() async {
|
||||
@@ -1971,7 +2006,7 @@ class CallSession {
|
||||
|
||||
Future<void> _onTransmittedData(Map<String, dynamic> msg) async {
|
||||
final pc = _pc;
|
||||
if (pc == null) return;
|
||||
if (pc == null || _ended) return;
|
||||
|
||||
final data = msg['data'];
|
||||
if (data is! Map) return;
|
||||
@@ -2075,7 +2110,35 @@ class CallSession {
|
||||
}
|
||||
}
|
||||
|
||||
void _runCallTask(Future<void> Function() operation) {
|
||||
if (_ended) return;
|
||||
unawaited(() async {
|
||||
try {
|
||||
await operation();
|
||||
} catch (error, stack) {
|
||||
if (!_ended) {
|
||||
logger.w(
|
||||
'[call] asynchronous operation failed',
|
||||
error: error,
|
||||
stackTrace: stack,
|
||||
);
|
||||
}
|
||||
}
|
||||
}());
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
void bindCandidateSignalingForTesting(Ws2Signaling signaling, int peerId) {
|
||||
_signaling = signaling;
|
||||
_peerId = peerId;
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
void receiveLocalCandidateForTesting(RTCIceCandidate candidate) =>
|
||||
_onLocalCandidate(candidate);
|
||||
|
||||
void _onLocalCandidate(RTCIceCandidate candidate) {
|
||||
if (_ended) return;
|
||||
final line = candidate.candidate;
|
||||
if (line != null && line.contains(' typ relay')) {
|
||||
final done = _gatherDone;
|
||||
@@ -2083,15 +2146,24 @@ class CallSession {
|
||||
}
|
||||
if (_topology == 'SERVER') return;
|
||||
final peerId = _peerId;
|
||||
if (peerId == null || candidate.candidate == null) return;
|
||||
_signaling?.transmitCandidate(
|
||||
participantId: peerId,
|
||||
participantType: _peerType,
|
||||
deviceIdx: _peerDeviceIdx,
|
||||
candidate: candidate.candidate!,
|
||||
sdpMid: candidate.sdpMid ?? '0',
|
||||
sdpMLineIndex: candidate.sdpMLineIndex ?? 0,
|
||||
);
|
||||
final signaling = _signaling;
|
||||
if (peerId == null || line == null || signaling == null) return;
|
||||
_runCallTask(() async {
|
||||
try {
|
||||
await signaling.transmitCandidate(
|
||||
participantId: peerId,
|
||||
participantType: _peerType,
|
||||
deviceIdx: _peerDeviceIdx,
|
||||
candidate: line,
|
||||
sdpMid: candidate.sdpMid ?? '0',
|
||||
sdpMLineIndex: candidate.sdpMLineIndex ?? 0,
|
||||
);
|
||||
} catch (error) {
|
||||
if (_ended || !identical(_signaling, signaling)) return;
|
||||
logger.w('[call] ICE signaling interrupted: $error');
|
||||
_onSignalingLost();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> accept({bool activate = true}) async {
|
||||
@@ -2316,35 +2388,63 @@ class CallSession {
|
||||
if (_ended) return;
|
||||
_ended = true;
|
||||
_setState(CallSessionState.ended);
|
||||
_dispose();
|
||||
unawaited(
|
||||
_dispose().catchError((Object error, StackTrace stack) {
|
||||
logger.w('[call] cleanup failed', error: error, stackTrace: stack);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _dispose() async {
|
||||
_levelTimer?.cancel();
|
||||
_videoStatsTimer?.cancel();
|
||||
final pc = _pc;
|
||||
_pc = null;
|
||||
if (pc != null) {
|
||||
pc.onIceCandidate = null;
|
||||
pc.onTrack = null;
|
||||
pc.onConnectionState = null;
|
||||
pc.onIceConnectionState = null;
|
||||
pc.onIceGatheringState = null;
|
||||
pc.onDataChannel = null;
|
||||
}
|
||||
try {
|
||||
await _probeChannel?.close();
|
||||
} catch (_) {}
|
||||
_probeChannel = null;
|
||||
await _closeSfuChannels();
|
||||
for (final track in _localStream?.getTracks() ?? <MediaStreamTrack>[]) {
|
||||
await track.stop();
|
||||
try {
|
||||
await _closeSfuChannels();
|
||||
} catch (e) {
|
||||
logger.w('[call] channel cleanup: $e');
|
||||
}
|
||||
await _localStream?.dispose();
|
||||
await _disposeStream(_localStream);
|
||||
_localStream = null;
|
||||
await _disposeMicStream();
|
||||
await PulseAudio.closeBridge();
|
||||
try {
|
||||
await PulseAudio.closeBridge();
|
||||
} catch (e) {
|
||||
logger.w('[call] audio cleanup: $e');
|
||||
}
|
||||
await _disposeStream(_cameraStream);
|
||||
await _disposeStream(_screenStream);
|
||||
_cameraStream = null;
|
||||
_screenStream = null;
|
||||
await _pc?.close();
|
||||
try {
|
||||
await pc?.close();
|
||||
} catch (e) {
|
||||
logger.w('[call] peer cleanup: $e');
|
||||
}
|
||||
if (_ownRemoteStream) {
|
||||
try {
|
||||
await _remoteStreamRef?.dispose();
|
||||
} catch (_) {}
|
||||
}
|
||||
await _clearParticipantStreams();
|
||||
await _signaling?.close();
|
||||
try {
|
||||
await _signaling?.close();
|
||||
} catch (e) {
|
||||
logger.w('[call] signaling cleanup: $e');
|
||||
}
|
||||
if (!_participantStreamUpdates.isClosed) {
|
||||
await _participantStreamUpdates.close();
|
||||
}
|
||||
|
||||
@@ -167,6 +167,7 @@ class Ws2Signaling {
|
||||
}
|
||||
|
||||
void _onDone(Object? error) {
|
||||
_call = null;
|
||||
for (final waiter in _requests.values) {
|
||||
if (!waiter.isCompleted)
|
||||
waiter.completeError(error ?? StateError('Звонок завершён'));
|
||||
@@ -184,22 +185,24 @@ class Ws2Signaling {
|
||||
Duration timeout = const Duration(seconds: 15),
|
||||
}) async {
|
||||
final call = _call;
|
||||
if (call == null) {
|
||||
if (call == null ||
|
||||
_closed.isCompleted ||
|
||||
call.readyState != WebSocket.open) {
|
||||
return Future.error(StateError('ws2 не подключён'));
|
||||
}
|
||||
try {
|
||||
final sequence = ++_sequence;
|
||||
final waiter = Completer<Map<String, dynamic>>();
|
||||
_requests[sequence] = waiter;
|
||||
call.add(
|
||||
jsonEncode({
|
||||
'type': 'request',
|
||||
'command': command,
|
||||
'sequence': sequence,
|
||||
...extra,
|
||||
}),
|
||||
);
|
||||
try {
|
||||
call.add(
|
||||
jsonEncode({
|
||||
'type': 'request',
|
||||
'command': command,
|
||||
'sequence': sequence,
|
||||
...extra,
|
||||
}),
|
||||
);
|
||||
return await waiter.future.timeout(timeout);
|
||||
} finally {
|
||||
_requests.remove(sequence);
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'persisted_setting.dart';
|
||||
|
||||
enum BottomNavigationStyle { floating, compact }
|
||||
|
||||
class AppBottomNavigationStyle {
|
||||
static const prefKey = 'app_bottom_navigation_style';
|
||||
|
||||
static final _setting = PersistedEnum<BottomNavigationStyle>(
|
||||
prefKey: prefKey,
|
||||
defaultValue: BottomNavigationStyle.floating,
|
||||
encode: (value) => value.name,
|
||||
decode: (value) => enumFromName(
|
||||
BottomNavigationStyle.values,
|
||||
value,
|
||||
BottomNavigationStyle.floating,
|
||||
),
|
||||
);
|
||||
|
||||
static ValueNotifier<BottomNavigationStyle> get current => _setting.current;
|
||||
|
||||
static Future<BottomNavigationStyle> load() => _setting.load();
|
||||
|
||||
static Future<void> save(BottomNavigationStyle value) => _setting.save(value);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import '../../models/shared_payload.dart';
|
||||
|
||||
/// Only one native share is presented at a time. A record is acknowledged
|
||||
/// after its screen closes, and released after its uploads stop using files.
|
||||
class ShareInbox {
|
||||
ShareInbox({
|
||||
required this.consume,
|
||||
required this.acknowledge,
|
||||
required this.release,
|
||||
required this.onShare,
|
||||
});
|
||||
final Future<Object?> Function() consume;
|
||||
final Future<void> Function(String) acknowledge;
|
||||
final Future<void> Function(String) release;
|
||||
final void Function(SharedPayload) onShare;
|
||||
String? _activeId;
|
||||
bool _completed = false;
|
||||
bool _acknowledged = false;
|
||||
Future<void> Function()? _waitForRelease;
|
||||
bool _requested = false;
|
||||
Future<void>? _operation;
|
||||
|
||||
Future<void> check() {
|
||||
_requested = true;
|
||||
return _operation ??= _drain().whenComplete(() => _operation = null);
|
||||
}
|
||||
|
||||
Future<void> complete({Future<void> Function()? waitForRelease}) {
|
||||
if (_activeId == null) return Future.value();
|
||||
_completed = true;
|
||||
_waitForRelease ??= waitForRelease;
|
||||
return check();
|
||||
}
|
||||
|
||||
Future<void> _drain() async {
|
||||
while (_requested) {
|
||||
_requested = false;
|
||||
final activeId = _activeId;
|
||||
if (activeId != null) {
|
||||
if (!_completed) return;
|
||||
if (!_acknowledged) {
|
||||
await acknowledge(activeId);
|
||||
_acknowledged = true;
|
||||
}
|
||||
await _waitForRelease?.call();
|
||||
await release(activeId);
|
||||
_activeId = null;
|
||||
_completed = _acknowledged = false;
|
||||
_waitForRelease = null;
|
||||
}
|
||||
final raw = await consume();
|
||||
if (raw is! Map) continue;
|
||||
final id = raw['id'];
|
||||
if (id is! String || id.isEmpty) continue;
|
||||
final payload = SharedPayload.fromMap(raw);
|
||||
if (payload == null) {
|
||||
await acknowledge(id);
|
||||
await release(id);
|
||||
_requested = true;
|
||||
continue;
|
||||
}
|
||||
_activeId = id;
|
||||
onShare(payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import '../../main.dart';
|
||||
import '../../models/shared_payload.dart';
|
||||
import '../utils/logger.dart';
|
||||
import 'shared_file_uploads.dart';
|
||||
import 'share_inbox.dart';
|
||||
|
||||
class ShareIntentBridge {
|
||||
ShareIntentBridge._();
|
||||
@@ -26,6 +27,13 @@ class ShareIntentBridge {
|
||||
SharedPayload? _pending;
|
||||
int _retriesLeft = 0;
|
||||
Timer? _retry;
|
||||
late final _inbox = ShareInbox(
|
||||
consume: () => _method.invokeMethod<dynamic>('consumeInitialShare'),
|
||||
acknowledge: (id) =>
|
||||
_method.invokeMethod<void>('acknowledgeShare', {'id': id}),
|
||||
release: (id) => _method.invokeMethod<void>('releaseShare', {'id': id}),
|
||||
onShare: _receivePayload,
|
||||
);
|
||||
|
||||
bool get _native {
|
||||
try {
|
||||
@@ -55,7 +63,7 @@ class ShareIntentBridge {
|
||||
Future<void> checkInitialShare() async {
|
||||
if (!_native) return;
|
||||
try {
|
||||
_onEvent(await _method.invokeMethod<dynamic>('consumeInitialShare'));
|
||||
await _inbox.check();
|
||||
} catch (e) {
|
||||
logger.w('ShareIntentBridge.checkInitialShare: $e');
|
||||
}
|
||||
@@ -73,8 +81,10 @@ class ShareIntentBridge {
|
||||
}
|
||||
|
||||
void _onEvent(Object? event) {
|
||||
final payload = SharedPayload.fromMap(event);
|
||||
if (payload == null) return;
|
||||
unawaited(checkInitialShare());
|
||||
}
|
||||
|
||||
void _receivePayload(SharedPayload payload) {
|
||||
logger.i(
|
||||
'Поделиться: получено ${payload.files.length} файлов'
|
||||
'${payload.text != null ? ' и текст' : ''}',
|
||||
@@ -91,7 +101,7 @@ class ShareIntentBridge {
|
||||
final context = QlyraApp.navigatorKey.currentContext;
|
||||
if (!_ready || context == null || api.state != SessionState.online) {
|
||||
if (_retriesLeft <= 0) {
|
||||
_pending = null;
|
||||
// Keep the native record and payload until a session becomes ready.
|
||||
return;
|
||||
}
|
||||
_retriesLeft--;
|
||||
@@ -108,9 +118,17 @@ class ShareIntentBridge {
|
||||
pushSwipeable<void>(
|
||||
context,
|
||||
(_) => ChatListScreen(sharePayload: payload),
|
||||
).whenComplete(() {
|
||||
).whenComplete(() async {
|
||||
_presenting = false;
|
||||
unawaited(clearCache(payload));
|
||||
try {
|
||||
await _inbox.complete(
|
||||
waitForRelease: () => SharedFileUploads.instance.waitUntilIdle(
|
||||
payload.files.map((file) => file.path).toList(),
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
logger.w('ShareIntentBridge.complete: $e');
|
||||
}
|
||||
_flushPending();
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -322,7 +322,11 @@ class _CallScreenState extends State<CallScreen> with TickerProviderStateMixin {
|
||||
void _onState(CallSessionState state) {
|
||||
if (!mounted) return;
|
||||
setState(() => _state = state);
|
||||
if (state == CallSessionState.ended) _close();
|
||||
if (state == CallSessionState.ended) {
|
||||
final failure = _session?.failureReason;
|
||||
if (failure != null) showCustomNotification(context, failure);
|
||||
_close();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _accept() async {
|
||||
|
||||
@@ -202,12 +202,13 @@ class ChatController extends ChangeNotifier {
|
||||
Future<bool> loadMessageWindow({
|
||||
required String targetId,
|
||||
required int targetTime,
|
||||
bool Function()? stillWanted,
|
||||
}) async {
|
||||
if (myId == 0 || targetTime <= 0) return false;
|
||||
final onlyVisible = !QlyraSettings.viewDeleted.value;
|
||||
|
||||
var window = await loadWindowFromDb(targetTime, onlyVisible);
|
||||
if (!isMounted()) return false;
|
||||
if (!isMounted() || stillWanted?.call() == false) return false;
|
||||
|
||||
if (!window.any((m) => m.id == targetId)) {
|
||||
final fetched = await messagesModule.fetchHistory(
|
||||
@@ -217,12 +218,12 @@ class ChatController extends ChangeNotifier {
|
||||
forward: jumpWindowAfter,
|
||||
backward: jumpWindowBefore + 1,
|
||||
);
|
||||
if (!isMounted()) return false;
|
||||
if (!isMounted() || stillWanted?.call() == false) return false;
|
||||
if (fetched.isNotEmpty && QlyraSettings.viewDeleted.value) {
|
||||
await chats.reconcileDeletedFromFetch(myId, chatId, fetched);
|
||||
}
|
||||
window = await loadWindowFromDb(targetTime, onlyVisible);
|
||||
if (!isMounted()) return false;
|
||||
if (!isMounted() || stillWanted?.call() == false) return false;
|
||||
}
|
||||
|
||||
if (window.isEmpty) return false;
|
||||
|
||||
@@ -31,6 +31,8 @@ import '../../widgets/sheet_helpers.dart';
|
||||
import '../../widgets/swipe_route.dart';
|
||||
import '../../widgets/toast_placement.dart';
|
||||
import '../../widgets/sliding_pill_nav.dart';
|
||||
import '../../widgets/compact_bottom_navigation_bar.dart';
|
||||
import '../../../core/config/app_bottom_navigation_style.dart';
|
||||
import '../../widgets/springy_tap.dart';
|
||||
import '../../widgets/visible_page_tickers.dart';
|
||||
import '../../widgets/informer_banner_tile.dart';
|
||||
@@ -2472,92 +2474,123 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
return best;
|
||||
}
|
||||
|
||||
return AnimatedPositioned(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeOutCubic,
|
||||
left: 8,
|
||||
right: 8,
|
||||
bottom: _isSelectionMode ? -100 : bottomInset + 10.0,
|
||||
child: RepaintBoundary(
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onHorizontalDragStart: (_) {
|
||||
if (_isSelectionMode) return;
|
||||
_navPageAnimController.stop();
|
||||
_navPageAnimController.value = 1.0;
|
||||
_navDragDx.value = 0;
|
||||
setState(() {
|
||||
_navDragging = true;
|
||||
_navDragBaseLeft = bubbleLeftForIndex(_currentNavIndex);
|
||||
});
|
||||
},
|
||||
onHorizontalDragUpdate: (details) {
|
||||
if (!_navDragging) return;
|
||||
_navDragDx.value += details.delta.dx;
|
||||
},
|
||||
onHorizontalDragEnd: (_) {
|
||||
if (!_navDragging) return;
|
||||
final left = (_navDragBaseLeft + _navDragDx.value).clamp(
|
||||
minBubbleLeft,
|
||||
maxBubbleLeft,
|
||||
);
|
||||
final next = indexForBubbleLeft(left);
|
||||
_navDragDx.value = 0;
|
||||
setState(() {
|
||||
_currentNavIndex = next;
|
||||
_navDragging = false;
|
||||
});
|
||||
activeNavTab.value = next;
|
||||
if (next == 0) _scheduleInformerPresentation();
|
||||
},
|
||||
onHorizontalDragCancel: () {
|
||||
if (!_navDragging) return;
|
||||
_navDragDx.value = 0;
|
||||
setState(() {
|
||||
_navDragging = false;
|
||||
});
|
||||
},
|
||||
child: ValueListenableBuilder<double>(
|
||||
valueListenable: _navDragDx,
|
||||
builder: (context, navDragDx, _) {
|
||||
final position = _navDragging
|
||||
? ((_navDragBaseLeft + navDragDx).clamp(
|
||||
minBubbleLeft,
|
||||
maxBubbleLeft,
|
||||
) -
|
||||
4) /
|
||||
inactiveWidth
|
||||
: _currentNavIndex.toDouble();
|
||||
return SlidingPillNav(
|
||||
items: [
|
||||
PillNavItem(
|
||||
icon: _chatsNavItems(
|
||||
AppLocalizations.of(context)!,
|
||||
).first.icon,
|
||||
label: _chatsNavItems(
|
||||
AppLocalizations.of(context)!,
|
||||
).first.label,
|
||||
unreadCount: countUnreadChats(_folderSourceChats),
|
||||
),
|
||||
..._chatsNavItems(AppLocalizations.of(context)!).skip(1),
|
||||
],
|
||||
position: position,
|
||||
animationDuration: _navDragging
|
||||
? Duration.zero
|
||||
: const Duration(milliseconds: 350),
|
||||
geometry: geometry,
|
||||
iconSize: 20,
|
||||
labelGap: 4,
|
||||
backdropKey: _frostBackdrop,
|
||||
onTap: _onNavTabSelected,
|
||||
onItemLongPress: (index, pos) {
|
||||
if (index == 3) _openAccountSwitcher(pos);
|
||||
return ValueListenableBuilder<BottomNavigationStyle>(
|
||||
valueListenable: AppBottomNavigationStyle.current,
|
||||
builder: (context, style, _) {
|
||||
if (style == BottomNavigationStyle.compact) {
|
||||
return Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: _isSelectionMode ? -150 : 0,
|
||||
child: CompactBottomNavigationBar(
|
||||
items: [
|
||||
PillNavItem(
|
||||
icon: _chatsNavItems(
|
||||
AppLocalizations.of(context)!,
|
||||
).first.icon,
|
||||
label: _chatsNavItems(
|
||||
AppLocalizations.of(context)!,
|
||||
).first.label,
|
||||
unreadCount: countUnreadChats(_folderSourceChats),
|
||||
),
|
||||
..._chatsNavItems(AppLocalizations.of(context)!).skip(1),
|
||||
],
|
||||
currentIndex: _currentNavIndex,
|
||||
onTap: _onNavTabSelected,
|
||||
onItemLongPress: (index, position) {
|
||||
if (index == 3) _openAccountSwitcher(position);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
return AnimatedPositioned(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeOutCubic,
|
||||
left: 8,
|
||||
right: 8,
|
||||
bottom: _isSelectionMode ? -100 : bottomInset + 10.0,
|
||||
child: RepaintBoundary(
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onHorizontalDragStart: (_) {
|
||||
if (_isSelectionMode) return;
|
||||
_navPageAnimController.stop();
|
||||
_navPageAnimController.value = 1.0;
|
||||
_navDragDx.value = 0;
|
||||
setState(() {
|
||||
_navDragging = true;
|
||||
_navDragBaseLeft = bubbleLeftForIndex(_currentNavIndex);
|
||||
});
|
||||
},
|
||||
onHorizontalDragUpdate: (details) {
|
||||
if (!_navDragging) return;
|
||||
_navDragDx.value += details.delta.dx;
|
||||
},
|
||||
onHorizontalDragEnd: (_) {
|
||||
if (!_navDragging) return;
|
||||
final left = (_navDragBaseLeft + _navDragDx.value).clamp(
|
||||
minBubbleLeft,
|
||||
maxBubbleLeft,
|
||||
);
|
||||
final next = indexForBubbleLeft(left);
|
||||
_navDragDx.value = 0;
|
||||
setState(() {
|
||||
_currentNavIndex = next;
|
||||
_navDragging = false;
|
||||
});
|
||||
activeNavTab.value = next;
|
||||
if (next == 0) _scheduleInformerPresentation();
|
||||
},
|
||||
onHorizontalDragCancel: () {
|
||||
if (!_navDragging) return;
|
||||
_navDragDx.value = 0;
|
||||
setState(() {
|
||||
_navDragging = false;
|
||||
});
|
||||
},
|
||||
child: ValueListenableBuilder<double>(
|
||||
valueListenable: _navDragDx,
|
||||
builder: (context, navDragDx, _) {
|
||||
final position = _navDragging
|
||||
? ((_navDragBaseLeft + navDragDx).clamp(
|
||||
minBubbleLeft,
|
||||
maxBubbleLeft,
|
||||
) -
|
||||
4) /
|
||||
inactiveWidth
|
||||
: _currentNavIndex.toDouble();
|
||||
return SlidingPillNav(
|
||||
items: [
|
||||
PillNavItem(
|
||||
icon: _chatsNavItems(
|
||||
AppLocalizations.of(context)!,
|
||||
).first.icon,
|
||||
label: _chatsNavItems(
|
||||
AppLocalizations.of(context)!,
|
||||
).first.label,
|
||||
unreadCount: countUnreadChats(_folderSourceChats),
|
||||
),
|
||||
..._chatsNavItems(AppLocalizations.of(context)!).skip(1),
|
||||
],
|
||||
position: position,
|
||||
animationDuration: _navDragging
|
||||
? Duration.zero
|
||||
: const Duration(milliseconds: 350),
|
||||
geometry: geometry,
|
||||
iconSize: 20,
|
||||
labelGap: 4,
|
||||
backdropKey: _frostBackdrop,
|
||||
onTap: _onNavTabSelected,
|
||||
onItemLongPress: (index, pos) {
|
||||
if (index == 3) _openAccountSwitcher(pos);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import '../../../backend/modules/pending_message_deletions.dart';
|
||||
import 'package:ikar/frontend/widgets/toast_placement.dart';
|
||||
import 'package:ikar/frontend/widgets/hint_bubble.dart';
|
||||
import 'chat/view/message_row_widgets.dart' show SelectableMessageRow;
|
||||
@@ -916,6 +917,7 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
unawaited(_loadPeerKind());
|
||||
unawaited(_loadWallpaper());
|
||||
unawaited(_loadReactionSettings());
|
||||
PendingMessageDeletions.instance.addListener(_pendingRemovalsChanged);
|
||||
_forwardedSub = ForwardSender.delivered.listen(_onForwardedHere);
|
||||
_departedSub = chats.departedChats.listen((id) {
|
||||
if (!mounted || id != widget.chatId) return;
|
||||
@@ -2016,11 +2018,17 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<void> _loadMessageWindow(String messageId, int targetTime) async {
|
||||
Future<void> _loadMessageWindow(
|
||||
String messageId,
|
||||
int targetTime, {
|
||||
bool Function()? stillWanted,
|
||||
}) async {
|
||||
if (targetTime <= 0) {
|
||||
await _walkHistoryBack(
|
||||
reached: () => _messages.any((m) => m.id == messageId),
|
||||
maxPages: 10,
|
||||
reached: () =>
|
||||
stillWanted?.call() == false ||
|
||||
_messages.any((m) => m.id == messageId),
|
||||
maxPages: 100,
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -2030,6 +2038,7 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
await _chatController.loadMessageWindow(
|
||||
targetId: messageId,
|
||||
targetTime: targetTime,
|
||||
stillWanted: stillWanted,
|
||||
);
|
||||
} finally {
|
||||
_historyAutoloadSuppressCount--;
|
||||
@@ -2424,6 +2433,7 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
_detachUploadStatus();
|
||||
_pushSub?.cancel();
|
||||
_reactionUpdatesSub?.cancel();
|
||||
PendingMessageDeletions.instance.removeListener(_pendingRemovalsChanged);
|
||||
_forwardedSub?.cancel();
|
||||
_departedSub?.cancel();
|
||||
_messageEventSub?.cancel();
|
||||
@@ -2762,39 +2772,70 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
if (forEveryone == null || !mounted) return;
|
||||
|
||||
_clearSelection();
|
||||
for (final message in msgs.where((m) => m.id.startsWith('temp_'))) {
|
||||
_startDeleteAnimation(message.id);
|
||||
}
|
||||
_removeMessagesWithUndo(serverMsgs, forEveryone: forEveryone);
|
||||
}
|
||||
|
||||
void _pendingRemovalsChanged() {
|
||||
if (mounted) _bumpMessages();
|
||||
}
|
||||
|
||||
void _removeMessagesWithUndo(
|
||||
List<CachedMessage> messages, {
|
||||
required bool forEveryone,
|
||||
}) {
|
||||
final accountId = _myId;
|
||||
final chatId = widget.chatId;
|
||||
final ids = messages.map((message) => message.id).toList();
|
||||
final pending = PendingMessageDeletions.instance;
|
||||
if (ids.any((id) => pending.contains(accountId, chatId, id))) return;
|
||||
pending.hold(accountId, chatId, ids);
|
||||
final overlay = Overlay.of(context, rootOverlay: true);
|
||||
void restore() => pending.release(accountId, chatId, ids);
|
||||
showUndoNotification(
|
||||
context,
|
||||
'Удаление сообщений через 5 секунд',
|
||||
onUndo: () {},
|
||||
messages.length == 1 ? 'Сообщение удалено' : 'Сообщения удалены',
|
||||
onUndo: restore,
|
||||
onCommit: () async {
|
||||
if ((await AppDatabase.loadActiveProfile())?.id != accountId) return;
|
||||
final ok = await messagesModule.deleteMessages(
|
||||
widget.chatId,
|
||||
serverMsgs.map((m) => m.id).toList(),
|
||||
forEveryone: forEveryone,
|
||||
);
|
||||
if (ok) {
|
||||
for (final message in serverMsgs) {
|
||||
await AppDatabase.deleteMessage(
|
||||
accountId,
|
||||
widget.chatId,
|
||||
message.id,
|
||||
try {
|
||||
if ((await AppDatabase.loadActiveProfile())?.id != accountId) return;
|
||||
final ok = await messagesModule.deleteMessages(
|
||||
chatId,
|
||||
ids,
|
||||
forEveryone: forEveryone,
|
||||
);
|
||||
if (!ok) {
|
||||
if (overlay.mounted) {
|
||||
showCustomNotificationOnOverlay(
|
||||
overlay,
|
||||
'Не удалось удалить сообщения',
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
for (final id in ids) {
|
||||
await AppDatabase.deleteMessage(accountId, chatId, id);
|
||||
}
|
||||
MessageSessionCache.remove(accountId, chatId);
|
||||
await chats.reconcileLastMessage(accountId, chatId);
|
||||
if (mounted && _myId == accountId) {
|
||||
_messages.removeWhere((message) => ids.contains(message.id));
|
||||
for (final id in ids) {
|
||||
_reactionNotifiers.remove(id)?.dispose();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (overlay.mounted) {
|
||||
showCustomNotificationOnOverlay(
|
||||
overlay,
|
||||
'Не удалось удалить сообщения',
|
||||
);
|
||||
}
|
||||
MessageSessionCache.remove(accountId, widget.chatId);
|
||||
await chats.reconcileLastMessage(accountId, widget.chatId);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
if (!mounted) return;
|
||||
if (!ok) {
|
||||
Haptics.error();
|
||||
showCustomNotification(context, 'Не удалось удалить сообщения');
|
||||
return;
|
||||
}
|
||||
for (final m in msgs) {
|
||||
_startDeleteAnimation(m.id);
|
||||
}
|
||||
_clearSelection();
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -3240,6 +3281,11 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
}
|
||||
|
||||
Future<void> _confirmDeleteMessage(String messageId, bool isMe) async {
|
||||
final messageIndex = _messages.indexWhere(
|
||||
(message) => message.id == messageId,
|
||||
);
|
||||
if (messageIndex == -1) return;
|
||||
final message = _messages[messageIndex];
|
||||
final isLocalOnly = messageId.startsWith('temp_');
|
||||
final canForEveryone = (isMe || _canDeleteOthers) && !isLocalOnly;
|
||||
|
||||
@@ -3251,30 +3297,7 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
final forEveryone = await _showDeleteMessageDialog(canForEveryone);
|
||||
if (forEveryone == null || !mounted) return;
|
||||
|
||||
final accountId = _myId;
|
||||
showUndoNotification(
|
||||
context,
|
||||
'Удаление сообщения через 5 секунд',
|
||||
onUndo: () {},
|
||||
onCommit: () async {
|
||||
if ((await AppDatabase.loadActiveProfile())?.id != accountId) return;
|
||||
final ok = await messagesModule.deleteMessages(widget.chatId, [
|
||||
messageId,
|
||||
], forEveryone: forEveryone);
|
||||
if (ok) {
|
||||
await AppDatabase.deleteMessage(accountId, widget.chatId, messageId);
|
||||
MessageSessionCache.remove(accountId, widget.chatId);
|
||||
await chats.reconcileLastMessage(accountId, widget.chatId);
|
||||
}
|
||||
if (!mounted) return;
|
||||
if (!ok) {
|
||||
Haptics.error();
|
||||
showCustomNotification(context, 'Не удалось удалить сообщение');
|
||||
return;
|
||||
}
|
||||
_startDeleteAnimation(messageId);
|
||||
},
|
||||
);
|
||||
_removeMessagesWithUndo([message], forEveryone: forEveryone);
|
||||
}
|
||||
|
||||
void _startDeleteAnimation(String messageId) {
|
||||
@@ -4915,18 +4938,45 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
);
|
||||
}
|
||||
|
||||
void _jumpToMessage(String messageId, {String? fromId}) {
|
||||
final index = _messages.indexWhere((m) => m.id == messageId);
|
||||
if (index == -1) {
|
||||
showCustomNotification(context, 'Сообщение не загружено');
|
||||
return;
|
||||
}
|
||||
int _messageNavigationToken = 0;
|
||||
|
||||
Future<void> _jumpToMessage(String messageId, {String? fromId}) async {
|
||||
final token = ++_messageNavigationToken;
|
||||
final gesture = _userGestureEpoch;
|
||||
bool current() =>
|
||||
mounted &&
|
||||
token == _messageNavigationToken &&
|
||||
gesture == _userGestureEpoch;
|
||||
if (fromId != null) _pushReturnAnchor(fromId);
|
||||
if (!_messages.any((message) => message.id == messageId)) {
|
||||
try {
|
||||
final target = await messagesModule.fetchMessage(
|
||||
_myId,
|
||||
widget.chatId,
|
||||
messageId,
|
||||
);
|
||||
if (!mounted || !current()) return;
|
||||
if (target == null) {
|
||||
showCustomNotification(context, 'Сообщение не найдено');
|
||||
return;
|
||||
}
|
||||
await _loadMessageWindow(messageId, target.time, stillWanted: current);
|
||||
} catch (e) {
|
||||
if (mounted && current()) {
|
||||
showCustomNotification(context, 'Не удалось загрузить сообщение');
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!mounted || !current()) return;
|
||||
if (!_messages.any((message) => message.id == messageId)) {
|
||||
showCustomNotification(context, 'Сообщение не найдено');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
final key = _keyForMessage(messageId);
|
||||
final ctx = key.currentContext;
|
||||
if (ctx != null) {
|
||||
if (ctx != null && ctx.mounted) {
|
||||
Scrollable.ensureVisible(
|
||||
ctx,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
@@ -4934,7 +4984,12 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
alignment: 0.4,
|
||||
);
|
||||
} else {
|
||||
unawaited(_scrollToMessagePrecise(messageId, alignment: 0.4));
|
||||
await _scrollToMessagePrecise(
|
||||
messageId,
|
||||
alignment: 0.4,
|
||||
stillWanted: current,
|
||||
);
|
||||
if (!mounted || !current()) return;
|
||||
}
|
||||
|
||||
_highlightTimer?.cancel();
|
||||
@@ -4986,14 +5041,20 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
}
|
||||
|
||||
Future<void> _runGoToMessage(String id, int targetTime) async {
|
||||
final token = ++_messageNavigationToken;
|
||||
final epoch = _userGestureEpoch;
|
||||
bool current() =>
|
||||
mounted &&
|
||||
token == _messageNavigationToken &&
|
||||
epoch == _userGestureEpoch;
|
||||
await WidgetsBinding.instance.endOfFrame;
|
||||
if (!mounted) return;
|
||||
if (!current()) return;
|
||||
|
||||
if (!_messages.any((m) => m.id == id)) {
|
||||
await _loadMessageWindow(id, targetTime);
|
||||
if (!mounted) return;
|
||||
await _loadMessageWindow(id, targetTime, stillWanted: current);
|
||||
if (!current()) return;
|
||||
await WidgetsBinding.instance.endOfFrame;
|
||||
if (!mounted) return;
|
||||
if (!current()) return;
|
||||
}
|
||||
|
||||
if (!_messages.any((m) => m.id == id)) {
|
||||
@@ -5009,8 +5070,8 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
if (_highlightMessageId.value == id) _highlightMessageId.value = null;
|
||||
});
|
||||
|
||||
await _scrollToMessagePrecise(id);
|
||||
_finishTargetNavigation();
|
||||
await _scrollToMessagePrecise(id, stillWanted: current);
|
||||
if (token == _messageNavigationToken && mounted) _finishTargetNavigation();
|
||||
}
|
||||
|
||||
({int min, int max})? _laidOutMessageRange(List<Object> items) {
|
||||
@@ -5034,6 +5095,7 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
Future<void> _scrollToMessagePrecise(
|
||||
String id, {
|
||||
double alignment = 0.32,
|
||||
bool Function()? stillWanted,
|
||||
}) async {
|
||||
if (!mounted || !_scrollController.hasClients) return;
|
||||
if (_messages.indexWhere((m) => m.id == id) == -1) return;
|
||||
@@ -5050,7 +5112,7 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
var stable = 0;
|
||||
for (var iter = 0; iter < 120; iter++) {
|
||||
if (!mounted || !_scrollController.hasClients) return;
|
||||
if (_userGestureEpoch != epoch) return;
|
||||
if (_userGestureEpoch != epoch || stillWanted?.call() == false) return;
|
||||
final listObj = _listKey.currentContext?.findRenderObject();
|
||||
final boxObj = _keyForMessage(id).currentContext?.findRenderObject();
|
||||
final p = _scrollController.position;
|
||||
@@ -5382,6 +5444,13 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
|
||||
for (int i = 0; i < visible; i++) {
|
||||
final msg = _messages[i];
|
||||
if (PendingMessageDeletions.instance.contains(
|
||||
_myId,
|
||||
widget.chatId,
|
||||
msg.id,
|
||||
)) {
|
||||
continue;
|
||||
}
|
||||
final msgDate = DateTime.fromMillisecondsSinceEpoch(msg.time);
|
||||
final dayMillis = DateTime(
|
||||
msgDate.year,
|
||||
|
||||
@@ -21,6 +21,7 @@ import '../../../l10n/app_localizations.dart';
|
||||
import '../../../main.dart';
|
||||
import '../../widgets/liquid_glass.dart';
|
||||
import '../../widgets/settings_card.dart';
|
||||
import '../../widgets/bottom_navigation_style_card.dart';
|
||||
import '../../../core/config/app_shape.dart';
|
||||
import '../../../core/utils/seed_scheme.dart';
|
||||
|
||||
@@ -124,6 +125,8 @@ class _AppearanceScreenState extends State<AppearanceScreen> {
|
||||
const SizedBox(height: 12),
|
||||
const _VisualStyleCard(),
|
||||
const SizedBox(height: 12),
|
||||
const BottomNavigationStyleCard(),
|
||||
const SizedBox(height: 12),
|
||||
const _ChatChromeCard(),
|
||||
const SizedBox(height: 12),
|
||||
const _ComposerBarCard(),
|
||||
|
||||
@@ -27,21 +27,52 @@ import '../../share_unopenable_file.dart';
|
||||
import '../../upload_progress_ring.dart';
|
||||
import 'bubble_context.dart';
|
||||
|
||||
class FileBubble extends StatelessWidget {
|
||||
static const double _previewWidth = 240;
|
||||
static const double _previewHeight = 160;
|
||||
typedef FileBubbleOpener =
|
||||
Future<FileDownloadResult> Function(
|
||||
String cacheName,
|
||||
Future<String?> Function() resolveUrl, {
|
||||
void Function(double)? onProgress,
|
||||
void Function()? onReady,
|
||||
DownloadMetadata? download,
|
||||
bool downloadOnlyIfMissing,
|
||||
});
|
||||
|
||||
class FileBubble extends StatefulWidget {
|
||||
final BubbleContext ctx;
|
||||
final FileAttachment file;
|
||||
final bool fill;
|
||||
final FileBubbleOpener openFile;
|
||||
|
||||
const FileBubble({
|
||||
super.key,
|
||||
required this.ctx,
|
||||
required this.file,
|
||||
this.fill = false,
|
||||
this.openFile = openCachedFile,
|
||||
});
|
||||
|
||||
@override
|
||||
State<FileBubble> createState() => _FileBubbleState();
|
||||
}
|
||||
|
||||
class _FileBubbleState extends State<FileBubble> {
|
||||
static const double _previewWidth = 240;
|
||||
static const double _previewHeight = 160;
|
||||
BubbleContext get ctx => widget.ctx;
|
||||
FileAttachment get file => widget.file;
|
||||
bool get fill => widget.fill;
|
||||
bool _opening = false;
|
||||
|
||||
Future<void> _openOnce(Future<void> Function() open) async {
|
||||
if (_opening || ctx.uploadProgress != null) return;
|
||||
setState(() => _opening = true);
|
||||
try {
|
||||
await open();
|
||||
} finally {
|
||||
if (mounted) setState(() => _opening = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
@@ -124,7 +155,11 @@ class FileBubble extends StatelessWidget {
|
||||
builder: (context, progress, _) => Text(
|
||||
progress != null
|
||||
? '${(progress * 100).round()}% · $sizeStr'
|
||||
: sizeStr,
|
||||
: '${_opening
|
||||
? 'Открытие…'
|
||||
: MediaCache.presence(cacheName).value
|
||||
? 'Открыть'
|
||||
: 'Скачать'} · $sizeStr',
|
||||
style: TextStyle(
|
||||
color: ctx.dim,
|
||||
fontSize: 12,
|
||||
@@ -144,7 +179,7 @@ class FileBubble extends StatelessWidget {
|
||||
: ctx.cs.primary;
|
||||
Widget circle(Widget child, VoidCallback? onTap) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
onTap: _opening ? null : onTap,
|
||||
child: Container(
|
||||
width: 34,
|
||||
height: 34,
|
||||
@@ -159,13 +194,15 @@ class FileBubble extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
if (progress != null) {
|
||||
if (progress != null || _opening) {
|
||||
return circle(
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
value: progress > 0 ? progress : null,
|
||||
value: progress != null && progress > 0
|
||||
? progress
|
||||
: null,
|
||||
color: iconColor,
|
||||
),
|
||||
),
|
||||
@@ -389,7 +426,7 @@ class FileBubble extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _openInViewer(
|
||||
Future<void> _openInViewerInner(
|
||||
BuildContext context,
|
||||
String name,
|
||||
String cacheName,
|
||||
@@ -498,6 +535,22 @@ class FileBubble extends StatelessWidget {
|
||||
BuildContext context,
|
||||
FileAttachment file,
|
||||
String name,
|
||||
) => _openOnce(() => _downloadFileInner(context, file, name));
|
||||
Future<void> _playAudioFile(
|
||||
BuildContext context,
|
||||
FileAttachment file,
|
||||
String name,
|
||||
) => _openOnce(() => _playAudioFileInner(context, file, name));
|
||||
Future<void> _openInViewer(
|
||||
BuildContext context,
|
||||
String name,
|
||||
String cacheName,
|
||||
) => _openOnce(() => _openInViewerInner(context, name, cacheName));
|
||||
|
||||
Future<void> _downloadFileInner(
|
||||
BuildContext context,
|
||||
FileAttachment file,
|
||||
String name,
|
||||
) async {
|
||||
final fileId = file.fileId;
|
||||
if (fileId == null) {
|
||||
@@ -514,7 +567,7 @@ class FileBubble extends StatelessWidget {
|
||||
final kind = downloadKindForName(name);
|
||||
|
||||
MediaDownloadProgress.set(cacheName, 0);
|
||||
final result = await openCachedFile(
|
||||
final result = await widget.openFile(
|
||||
cacheName,
|
||||
_fileUrl,
|
||||
downloadOnlyIfMissing: true,
|
||||
@@ -553,7 +606,7 @@ class FileBubble extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _playAudioFile(
|
||||
Future<void> _playAudioFileInner(
|
||||
BuildContext context,
|
||||
FileAttachment file,
|
||||
String name,
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/config/app_bottom_navigation_style.dart';
|
||||
import '../../core/utils/haptics.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import 'settings_card.dart';
|
||||
|
||||
class BottomNavigationStyleCard extends StatelessWidget {
|
||||
const BottomNavigationStyleCard({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
return SettingsPanel(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
l10n.appearanceBottomNavigationTitle,
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
l10n.appearanceBottomNavigationSubtitle,
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ValueListenableBuilder<BottomNavigationStyle>(
|
||||
valueListenable: AppBottomNavigationStyle.current,
|
||||
builder: (context, current, _) =>
|
||||
SegmentedButton<BottomNavigationStyle>(
|
||||
showSelectedIcon: false,
|
||||
segments: [
|
||||
ButtonSegment(
|
||||
value: BottomNavigationStyle.floating,
|
||||
label: Text(l10n.appearanceBottomNavigationFloating),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: BottomNavigationStyle.compact,
|
||||
label: Text(l10n.appearanceBottomNavigationCompact),
|
||||
),
|
||||
],
|
||||
selected: {current},
|
||||
onSelectionChanged: (selection) {
|
||||
Haptics.selection();
|
||||
AppBottomNavigationStyle.save(selection.first);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'sliding_pill_nav.dart';
|
||||
|
||||
class CompactBottomNavigationBar extends StatelessWidget {
|
||||
const CompactBottomNavigationBar({
|
||||
super.key,
|
||||
required this.items,
|
||||
required this.currentIndex,
|
||||
required this.onTap,
|
||||
this.onItemLongPress,
|
||||
});
|
||||
|
||||
final List<PillNavItem> items;
|
||||
final int currentIndex;
|
||||
final ValueChanged<int> onTap;
|
||||
final void Function(int index, Offset position)? onItemLongPress;
|
||||
|
||||
static double contentHeight(BuildContext context) =>
|
||||
math.max(50, 34 + MediaQuery.textScalerOf(context).scale(10) * 1.2);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (MediaQuery.viewInsetsOf(context).bottom > 0) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surface,
|
||||
border: Border(top: BorderSide(color: cs.outlineVariant, width: 0.5)),
|
||||
),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: SizedBox(
|
||||
height: contentHeight(context),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
for (var index = 0; index < items.length; index++)
|
||||
Expanded(
|
||||
child: _CompactTab(
|
||||
item: items[index],
|
||||
selected: index == currentIndex,
|
||||
onTap: () => onTap(index),
|
||||
onLongPress:
|
||||
items[index].longPressable && onItemLongPress != null
|
||||
? (position) => onItemLongPress!(index, position)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CompactTab extends StatefulWidget {
|
||||
const _CompactTab({
|
||||
required this.item,
|
||||
required this.selected,
|
||||
required this.onTap,
|
||||
this.onLongPress,
|
||||
});
|
||||
|
||||
final PillNavItem item;
|
||||
final bool selected;
|
||||
final VoidCallback onTap;
|
||||
final ValueChanged<Offset>? onLongPress;
|
||||
|
||||
@override
|
||||
State<_CompactTab> createState() => _CompactTabState();
|
||||
}
|
||||
|
||||
class _CompactTabState extends State<_CompactTab> {
|
||||
bool _pressed = false;
|
||||
|
||||
void _setPressed(bool value) {
|
||||
if (_pressed != value) setState(() => _pressed = value);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final color = widget.selected ? cs.primary : cs.onSurfaceVariant;
|
||||
return Semantics(
|
||||
button: true,
|
||||
selected: widget.selected,
|
||||
label: widget.item.label,
|
||||
onTap: widget.onTap,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
excludeFromSemantics: true,
|
||||
onTapDown: (_) => _setPressed(true),
|
||||
onTapUp: (_) => _setPressed(false),
|
||||
onTapCancel: () => _setPressed(false),
|
||||
onTap: widget.onTap,
|
||||
onLongPressStart: widget.onLongPress == null
|
||||
? null
|
||||
: (details) {
|
||||
_setPressed(false);
|
||||
widget.onLongPress!(details.globalPosition);
|
||||
},
|
||||
child: ExcludeSemantics(
|
||||
child: AnimatedOpacity(
|
||||
opacity: _pressed ? 0.55 : 1,
|
||||
duration: const Duration(milliseconds: 100),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Badge(
|
||||
isLabelVisible: widget.item.unreadCount > 0,
|
||||
label: Text(
|
||||
widget.item.unreadCount > 99
|
||||
? '99+'
|
||||
: '${widget.item.unreadCount}',
|
||||
),
|
||||
child: Icon(
|
||||
widget.item.icon,
|
||||
color: color,
|
||||
size: 24,
|
||||
fill: widget.selected ? 1 : 0,
|
||||
weight: widget.selected ? 500 : 400,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
widget.item.label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontSize: 10,
|
||||
height: 1.2,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+5
-1
@@ -3309,5 +3309,9 @@
|
||||
"avatarEditorSetPhoto": "Set photo",
|
||||
"avatarEditorDraw": "Draw",
|
||||
"avatarPickerFilesTitle": "Pick a photo from files",
|
||||
"avatarPickerFilesSubtitle": "If the photo you need isn't in the gallery"
|
||||
"avatarPickerFilesSubtitle": "If the photo you need isn't in the gallery",
|
||||
"appearanceBottomNavigationTitle": "Bottom navigation",
|
||||
"appearanceBottomNavigationSubtitle": "Choose your navigation layout",
|
||||
"appearanceBottomNavigationFloating": "Floating",
|
||||
"appearanceBottomNavigationCompact": "Compact"
|
||||
}
|
||||
|
||||
@@ -11641,6 +11641,30 @@ abstract class AppLocalizations {
|
||||
/// In en, this message translates to:
|
||||
/// **'If the photo you need isn\'t in the gallery'**
|
||||
String get avatarPickerFilesSubtitle;
|
||||
|
||||
/// No description provided for @appearanceBottomNavigationTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Bottom navigation'**
|
||||
String get appearanceBottomNavigationTitle;
|
||||
|
||||
/// No description provided for @appearanceBottomNavigationSubtitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Choose your navigation layout'**
|
||||
String get appearanceBottomNavigationSubtitle;
|
||||
|
||||
/// No description provided for @appearanceBottomNavigationFloating.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Floating'**
|
||||
String get appearanceBottomNavigationFloating;
|
||||
|
||||
/// No description provided for @appearanceBottomNavigationCompact.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Compact'**
|
||||
String get appearanceBottomNavigationCompact;
|
||||
}
|
||||
|
||||
class _AppLocalizationsDelegate
|
||||
|
||||
@@ -6554,4 +6554,17 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get avatarPickerFilesSubtitle =>
|
||||
'If the photo you need isn\'t in the gallery';
|
||||
|
||||
@override
|
||||
String get appearanceBottomNavigationTitle => 'Bottom navigation';
|
||||
|
||||
@override
|
||||
String get appearanceBottomNavigationSubtitle =>
|
||||
'Choose your navigation layout';
|
||||
|
||||
@override
|
||||
String get appearanceBottomNavigationFloating => 'Floating';
|
||||
|
||||
@override
|
||||
String get appearanceBottomNavigationCompact => 'Compact';
|
||||
}
|
||||
|
||||
@@ -6603,4 +6603,17 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get avatarPickerFilesSubtitle => 'Если нужного фото нет в галерее';
|
||||
|
||||
@override
|
||||
String get appearanceBottomNavigationTitle => 'Нижняя панель';
|
||||
|
||||
@override
|
||||
String get appearanceBottomNavigationSubtitle =>
|
||||
'Выберите удобный вид навигации';
|
||||
|
||||
@override
|
||||
String get appearanceBottomNavigationFloating => 'Плавающая';
|
||||
|
||||
@override
|
||||
String get appearanceBottomNavigationCompact => 'Компактная';
|
||||
}
|
||||
|
||||
+5
-1
@@ -2110,5 +2110,9 @@
|
||||
"avatarEditorSetPhoto": "Установить фото",
|
||||
"avatarEditorDraw": "Рисовать",
|
||||
"avatarPickerFilesTitle": "Выбрать фото из файлов",
|
||||
"avatarPickerFilesSubtitle": "Если нужного фото нет в галерее"
|
||||
"avatarPickerFilesSubtitle": "Если нужного фото нет в галерее",
|
||||
"appearanceBottomNavigationTitle": "Нижняя панель",
|
||||
"appearanceBottomNavigationSubtitle": "Выберите удобный вид навигации",
|
||||
"appearanceBottomNavigationFloating": "Плавающая",
|
||||
"appearanceBottomNavigationCompact": "Компактная"
|
||||
}
|
||||
|
||||
@@ -63,6 +63,7 @@ import 'core/config/app_chat_chrome.dart';
|
||||
import 'core/config/app_composer_background.dart';
|
||||
import 'core/config/app_composer_style.dart';
|
||||
import 'core/config/app_nav_pill_style.dart';
|
||||
import 'core/config/app_bottom_navigation_style.dart';
|
||||
import 'core/config/app_wallpaper_tint.dart';
|
||||
import 'core/storage/chat_wallpaper_store.dart';
|
||||
import 'core/utils/wallpaper_seed.dart';
|
||||
@@ -255,6 +256,7 @@ void main(List<String> args) async {
|
||||
final composerStyleFuture = AppComposerStyle.load();
|
||||
final composerBackgroundFuture = AppComposerBackground.load();
|
||||
final navPillStyleFuture = AppNavPillStyle.load();
|
||||
await AppBottomNavigationStyle.load();
|
||||
final wallpaperTintFuture = AppWallpaperTint.load();
|
||||
final themeScheduleFuture = AppThemeSchedule.load();
|
||||
final messageActionsFuture = AppMessageActionsStyle.load();
|
||||
|
||||
Reference in New Issue
Block a user