Port Komet UI improvements and implement IKAR ghost mode
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

Adapt video editing, decrypted message actions, custom themes and gestures from PR 166. Track visible server presence per session and preserve delivery for invisible clients.
This commit is contained in:
sevenhill
2026-10-07 22:07:05 +03:00
parent 69ea5091eb
commit 1ef2b7420e
35 changed files with 2210 additions and 524 deletions
+44 -4
View File
@@ -3,6 +3,8 @@ import 'dart:io';
import 'package:http/http.dart' as http;
import '../core/config/config.dart';
import '../core/config/countries.dart';
import '../core/config/qlyra_settings.dart';
import '../core/utils/app_foreground.dart';
import '../core/protocol/opcode_map.dart';
import '../core/protocol/packet.dart';
import '../core/storage/token_storage.dart';
@@ -18,7 +20,10 @@ enum SessionState { disconnected, connecting, connected, online }
/// Client for ikar_server.
class Api {
Api({IkarTransport Function(Uri)? transportFactory})
: _transportFactory = transportFactory ?? IkarTransport.new;
: _transportFactory = transportFactory ?? IkarTransport.new {
QlyraSettings.ghostMode.addListener(_presenceChanged);
AppForeground.notifier.addListener(_presenceChanged);
}
final IkarTransport Function(Uri) _transportFactory;
final PacketDispatcher _dispatcher = PacketDispatcher();
@@ -35,6 +40,7 @@ class Api {
Timer? _reconnect;
Future<void> Function()? _onReconnect;
Future<bool>? _refreshing;
Future<void> _presenceSync = Future<void>.value();
bool _autoReconnect = false;
bool _disposed = false;
int _epoch = 0;
@@ -62,6 +68,15 @@ class Api {
Stream<String> get handshakeSuccessStream => _handshakes.stream;
Stream<String> get errorStream => _errors.stream;
Stream<Packet> get pushStream => _dispatcher.pushStream;
bool get _interactive =>
!QlyraSettings.ghostMode.value && AppForeground.value;
void _presenceChanged() {
final transport = _transport;
if (transport != null) transport.interactive = _interactive;
if (_state == SessionState.online) unawaited(_checkConnection());
}
void _setState(SessionState state) {
_state = state;
if (!_states.isClosed) _states.add(state);
@@ -81,6 +96,7 @@ class Api {
port: endpoint.port,
);
final transport = _transportFactory(baseUri);
transport.interactive = _interactive;
_transport = transport;
final ping = await transport.request(Opcode.ping, {});
if (ping['cmd'] != 1) throw StateError('Сервер ИКАР недоступен');
@@ -117,11 +133,25 @@ class Api {
}
}
Future<void> _checkConnection() async {
Future<void> _checkConnection({bool? interactive}) {
final generation = _generation;
final pending = _presenceSync.then((_) async {
if (generation != _generation) return;
await _performConnectionCheck(interactive: interactive);
});
_presenceSync = pending;
return pending;
}
Future<void> _performConnectionCheck({bool? interactive}) async {
final transport = _transport;
if (transport == null || _state != SessionState.online) return;
try {
await sendRequest(Opcode.ping, {}, silent: true);
final active =
(interactive ?? AppForeground.value) &&
!QlyraSettings.ghostMode.value;
transport.interactive = active;
await sendRequest(Opcode.ping, {'interactive': active}, silent: true);
if (transport.token != null && !transport.eventsConnected)
await transport.connectEvents();
} catch (_) {
@@ -157,6 +187,13 @@ class Api {
throw StateError('Сессия изменилась до отправки');
beforeSend?.call();
final body = Map<dynamic, dynamic>.from(payload);
transport.interactive = _interactive;
if (opcode == Opcode.login || opcode == Opcode.ping) {
body['interactive'] =
(body['interactive'] as bool? ?? AppForeground.value) &&
!QlyraSettings.ghostMode.value;
transport.interactive = body['interactive'] as bool;
}
if (opcode == Opcode.authRequest) {
final identity = await IkarDeviceKey.identity(body['phone'].toString());
_deviceId = identity['installationId'];
@@ -306,7 +343,8 @@ class Api {
unawaited(_checkConnection());
}
void sendPing({required bool interactive}) => unawaited(_checkConnection());
void sendPing({required bool interactive}) =>
unawaited(_checkConnection(interactive: interactive));
Future<bool> reconnectToEndpoint({
String? host,
int? port,
@@ -357,6 +395,8 @@ class Api {
Future<void> dispose() async {
_disposed = true;
QlyraSettings.ghostMode.removeListener(_presenceChanged);
AppForeground.notifier.removeListener(_presenceChanged);
await disconnect();
_dispatcher.dispose();
await _states.close();
+5 -1
View File
@@ -16,6 +16,7 @@ class IkarTransport {
final Uri baseUri;
final http.Client _client;
String? token;
bool interactive = true;
WebSocket? _socket;
Timer? _keepAlive;
final _events = StreamController<Map<String, dynamic>>.broadcast();
@@ -72,7 +73,10 @@ class IkarTransport {
.replace(
scheme: baseUri.scheme == 'https' ? 'wss' : 'ws',
port: baseUri.port,
queryParameters: {'access_token': token!},
queryParameters: {
'access_token': token!,
'interactive': '$interactive',
},
);
final socket = await IkarNetwork.connectWebSocket(
uri.toString(),
+152 -79
View File
@@ -13,6 +13,7 @@ import 'e2ee_service.dart';
// #***! расшифровалось, ключ не тот, либо копии текста на этом устройстве нет
enum MessageDecryptionState { decrypted, wrongKey, unavailable }
// #***! результат по одному сообщению
@immutable
class MessageDecryption {
final String? plaintext;
@@ -32,6 +33,7 @@ class MessageDecryption {
bool get isDecrypted => state == MessageDecryptionState.decrypted;
}
// #***! кэш расшифровок, при смене ключа сбрасывается целиком
class MessageDecryptionCache {
MessageDecryptionCache._() {
ChatEncryptionStore.instance.revision.addListener(clear);
@@ -40,12 +42,15 @@ class MessageDecryptionCache {
static final MessageDecryptionCache instance = MessageDecryptionCache._();
// #***! тысяча последних, дальше вытесняем
static const int _maxEntries = 1000;
int _generation = 0;
final LinkedHashMap<String, ValueNotifier<MessageDecryption?>> _entries =
LinkedHashMap();
final Set<String> _inFlight = {};
final Map<String, Future<void>> _inFlight = {};
// #***! пузырь подписывается сюда и перерисуется когда текст расшифруется
ValueListenable<MessageDecryption?> listenableFor(String messageId) =>
_entryFor(messageId);
@@ -60,36 +65,106 @@ class MessageDecryptionCache {
}
}
// #***! известный текст кладём сразу, например своё только что отправленное
void seed(String messageId, String plaintext) {
_entryFor(messageId).value = MessageDecryption.decrypted(plaintext);
}
// #***! поменяли временный id на настоящий, переносим расшифровку
void adopt(String fromMessageId, String toMessageId) {
final value = _entries[fromMessageId]?.value;
if (value != null) _entryFor(toMessageId).value = value;
}
// #***! _inFlight чтоб не расшифровывать одно сообщение дважды
void request({
required int accountId,
required int chatId,
required String messageId,
required String cipherText,
}) {
if (cipherText.isEmpty) return;
final e2ee =
E2eeService.instance.available &&
KometCrypto.classifyText(cipherText) != TextClass.none;
if (!e2ee && !ChatCryptoService.instance.isEnabled(accountId, chatId)) {
return;
final job = _begin(accountId, chatId, messageId, cipherText);
if (job != null) unawaited(job);
}
Future<MessageDecryption?> resolve({
required int accountId,
required int chatId,
required String messageId,
required String cipherText,
}) async {
try {
await _begin(accountId, chatId, messageId, cipherText);
} catch (_) {
return const MessageDecryption.unavailable();
}
if (_entryFor(messageId).value != null) return;
if (!_inFlight.add(messageId)) return;
_evictStale(messageId);
unawaited(
e2ee
? _resolveSealed(accountId, chatId, messageId, cipherText)
: _resolve(accountId, chatId, messageId, cipherText),
return _entries[messageId]?.value;
}
String? readableText(CachedMessage message) {
if (message.isControl) return null;
final decryption = _entries[message.id]?.value;
if (decryption == null) {
final text = message.selectableText;
if (message.e2ee != CachedMessage.e2eeNone ||
(text != null && ChatCryptoService.instance.looksEncrypted(text))) {
return null;
}
return text;
}
return decryption.isDecrypted ? decryption.plaintext : null;
}
Future<String?> resolveText(CachedMessage message) async {
final generation = _generation;
if (message.isControl) return null;
await resolve(
accountId: message.accountId,
chatId: message.chatId,
messageId: message.id,
cipherText: message.text ?? '',
);
if (_generation != generation) return null;
return readableText(message);
}
Future<void>? _begin(
int accountId,
int chatId,
String messageId,
String cipherText,
) {
if (cipherText.isEmpty) return null;
final e2ee =
E2eeService.instance.isOn(accountId, chatId) ||
(E2eeService.instance.available &&
KometCrypto.classifyText(cipherText) != TextClass.none);
if (!e2ee && !ChatCryptoService.instance.isEnabled(accountId, chatId)) {
return null;
}
if (_entryFor(messageId).value != null) return null;
final running = _inFlight[messageId];
if (running != null) return running;
_evictStale(messageId);
final generation = _generation;
late final Future<void> job;
job =
(e2ee
? _resolveSealed(accountId, chatId, messageId, cipherText)
: _resolve(accountId, chatId, messageId, cipherText))
.catchError((Object error) {
if (_generation == generation) {
_entryFor(messageId).value =
const MessageDecryption.unavailable();
}
})
.whenComplete(() {
if (identical(_inFlight[messageId], job)) {
_inFlight.remove(messageId);
}
});
_inFlight[messageId] = job;
return job;
}
// #***! сквозное: открытый текст лежит в базе под локальным ключом
@@ -99,51 +174,49 @@ class MessageDecryptionCache {
String messageId,
String cipherText,
) async {
try {
final row = await AppDatabase.loadMessage(accountId, chatId, messageId);
if (row == null) return;
final flag = row['e2ee'] as int? ?? 0;
final sealed = row['text_sealed'];
if (flag == CachedMessage.e2eeText && sealed is Uint8List) {
final text = await E2eeService.instance.openText(
accountId,
chatId,
sealed,
final generation = _generation;
final row = await AppDatabase.loadMessage(accountId, chatId, messageId);
if (_generation != generation) return;
if (row == null) return;
final flag = row['e2ee'] as int? ?? 0;
final sealed = row['text_sealed'];
if (flag == CachedMessage.e2eeText && sealed is Uint8List) {
final text = await E2eeService.instance.openText(
accountId,
chatId,
sealed,
);
if (_generation != generation) return;
_entryFor(messageId).value = text == null
? const MessageDecryption.unavailable()
: MessageDecryption.decrypted(text);
return;
}
if (flag == CachedMessage.e2eeFailed) {
_entryFor(messageId).value = const MessageDecryption.wrongKey();
return;
}
if (flag == CachedMessage.e2eeFile) {
_entryFor(messageId).value = const MessageDecryption.decrypted('');
return;
}
if (flag != CachedMessage.e2eeNone || !E2eeService.instance.available) {
return;
}
switch (KometCrypto.classifyText(cipherText)) {
case TextClass.session:
_entryFor(messageId).value = const MessageDecryption.unavailable();
case TextClass.offer:
case TextClass.answer:
_entryFor(messageId).value = MessageDecryption.decrypted(
cipherText.split('\n').first,
);
_entryFor(messageId).value = text == null
? const MessageDecryption.unavailable()
: MessageDecryption.decrypted(text);
return;
}
if (flag == CachedMessage.e2eeFailed) {
_entryFor(messageId).value = const MessageDecryption.wrongKey();
return;
}
if (flag == CachedMessage.e2eeFile) {
_entryFor(messageId).value = const MessageDecryption.decrypted('');
return;
}
if (flag != CachedMessage.e2eeNone || !E2eeService.instance.available) {
return;
}
switch (KometCrypto.classifyText(cipherText)) {
case TextClass.session:
_entryFor(messageId).value = const MessageDecryption.unavailable();
case TextClass.offer:
case TextClass.answer:
_entryFor(messageId).value = MessageDecryption.decrypted(
cipherText.split('\n').first,
);
case TextClass.legacy:
if (ChatCryptoService.instance.isEnabled(accountId, chatId)) {
_inFlight.remove(messageId);
await _resolve(accountId, chatId, messageId, cipherText);
}
case TextClass.none:
break;
}
} finally {
_inFlight.remove(messageId);
case TextClass.legacy:
if (ChatCryptoService.instance.isEnabled(accountId, chatId)) {
await _resolve(accountId, chatId, messageId, cipherText);
}
case TextClass.none:
break;
}
}
@@ -153,32 +226,32 @@ class MessageDecryptionCache {
String messageId,
String cipherText,
) async {
try {
final crypto = ChatCryptoService.instance;
final result = await crypto.decrypt(accountId, chatId, cipherText);
if (result.isOk) {
_entryFor(messageId).value = MessageDecryption.decrypted(result.text!);
return;
}
switch (result.failure) {
case CryptoFailure.wrongKey:
final generation = _generation;
final crypto = ChatCryptoService.instance;
final result = await crypto.decrypt(accountId, chatId, cipherText);
if (_generation != generation) return;
if (result.isOk) {
_entryFor(messageId).value = MessageDecryption.decrypted(result.text!);
return;
}
// #***! ключа нет показываем как неверный ключ только если текст правда похож на шифр
switch (result.failure) {
case CryptoFailure.wrongKey:
_entryFor(messageId).value = const MessageDecryption.wrongKey();
case CryptoFailure.noKey:
if (crypto.looksEncrypted(cipherText)) {
_entryFor(messageId).value = const MessageDecryption.wrongKey();
case CryptoFailure.noKey:
if (crypto.looksEncrypted(cipherText)) {
_entryFor(messageId).value = const MessageDecryption.wrongKey();
}
case CryptoFailure.notEncrypted:
case CryptoFailure.malformed:
case CryptoFailure.unavailable:
case null:
break;
}
} finally {
_inFlight.remove(messageId);
}
case CryptoFailure.notEncrypted:
case CryptoFailure.malformed:
case CryptoFailure.unavailable:
case null:
break;
}
}
void clear() {
_generation++;
_entries.clear();
_inFlight.clear();
}
+1
View File
@@ -87,6 +87,7 @@ class VoiceTranscription {
if (result.status != 1 && TranscriptionCache.get(id)?.status == 1) return;
TranscriptionCache.put(id, result, expanded: true);
} catch (_) {
if (TranscriptionCache.get(id)?.status == 1) return;
TranscriptionCache.put(
id,
TranscriptionResult(status: -1),
@@ -14,10 +14,11 @@ import 'package:ikar/core/config/app_fonts.dart';
import 'package:ikar/core/config/app_frost.dart';
import 'package:ikar/core/config/app_message_actions_style.dart';
import 'package:ikar/core/utils/haptics.dart';
import 'package:ikar/core/crypto/message_decryption_cache.dart';
import 'package:ikar/core/media/voice_transcription.dart';
import 'package:ikar/core/utils/text_format.dart';
import 'package:ikar/frontend/widgets/animated_text_swap.dart';
import 'package:ikar/frontend/widgets/directional_drag_recognizer.dart';
export 'package:ikar/frontend/widgets/swipe_to_reply.dart';
import 'package:ikar/frontend/widgets/liquid_glass.dart';
import 'package:ikar/frontend/widgets/message_actions_overlay.dart'
show
@@ -33,120 +34,6 @@ import 'package:ikar/main.dart' show animojiModule, messagesModule;
import 'package:ikar/models/animoji.dart' show Animoji;
import 'package:ikar/models/chat_reaction_settings.dart';
class SwipeToReply extends StatefulWidget {
final Widget child;
final bool isMe;
final VoidCallback onReply;
const SwipeToReply({
super.key,
required this.child,
required this.isMe,
required this.onReply,
});
@override
State<SwipeToReply> createState() => _SwipeToReplyState();
}
class _SwipeToReplyState extends State<SwipeToReply>
with SingleTickerProviderStateMixin {
static const double _maxDrag = 72.0;
static const double _triggerThreshold = 56.0;
late final AnimationController _springBack;
double _dragX = 0.0;
double _springFrom = 0.0;
bool _triggered = false;
@override
void initState() {
super.initState();
_springBack =
AnimationController(
vsync: this,
duration: const Duration(milliseconds: 200),
)..addListener(() {
final t = Curves.easeOut.transform(_springBack.value);
setState(() => _dragX = _springFrom * (1 - t));
});
}
@override
void dispose() {
_springBack.dispose();
super.dispose();
}
void _onDragUpdate(DragUpdateDetails d) {
if (_springBack.isAnimating) _springBack.stop();
var next = _dragX + d.delta.dx;
if (next > 0) next = 0;
if (next < -_maxDrag) next = -_maxDrag;
final wasTriggered = _triggered;
_triggered = next <= -_triggerThreshold;
if (_triggered && !wasTriggered) Haptics.medium();
setState(() => _dragX = next);
}
void _onDragEnd(DragEndDetails d) {
if (_triggered) widget.onReply();
_settle();
}
void _onDragCancel() => _settle();
void _settle() {
_triggered = false;
_springFrom = _dragX;
_springBack.forward(from: 0);
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final progress = (-_dragX / _triggerThreshold).clamp(0.0, 1.0);
return RawGestureDetector(
behavior: HitTestBehavior.opaque,
gestures: <Type, GestureRecognizerFactory>{
LeftwardDragRecognizer:
GestureRecognizerFactoryWithHandlers<LeftwardDragRecognizer>(
() => LeftwardDragRecognizer(debugOwner: this),
(instance) {
instance
..onUpdate = _onDragUpdate
..onEnd = _onDragEnd
..onCancel = _onDragCancel;
},
),
},
child: Stack(
alignment: Alignment.centerRight,
children: [
Positioned(
right: 16,
child: Opacity(
opacity: progress,
child: Transform.scale(
scale: 0.6 + 0.4 * progress,
child: Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: cs.surfaceContainerHighest,
shape: BoxShape.circle,
),
child: Icon(Symbols.reply, size: 20, color: cs.primary),
),
),
),
),
Transform.translate(offset: Offset(_dragX, 0), child: widget.child),
],
),
);
}
}
class PinnedMessageBanner extends StatelessWidget {
final String? text;
final bool isPreview;
@@ -447,7 +334,7 @@ class _SelectableMessageRowState extends State<SelectableMessageRow> {
isMe: widget.isMe,
bottomReservedSpace: widget.composerHeight?.value ?? 0,
messageText: widget.message.text,
copyText: widget.message.selectableText,
copyText: MessageDecryptionCache.instance.readableText(widget.message),
controller: controller,
style: AppMessageActionsStyle.effective,
interaction: MessageActionsInteraction.tap,
@@ -582,7 +469,7 @@ class _SelectableMessageRowState extends State<SelectableMessageRow> {
isMe: widget.isMe,
bottomReservedSpace: widget.composerHeight?.value ?? 0,
messageText: widget.message.text,
copyText: widget.message.selectableText,
copyText: MessageDecryptionCache.instance.readableText(widget.message),
controller: controller,
style: MessageActionsStyle.list,
interaction: MessageActionsInteraction.click,
+30 -125
View File
@@ -51,6 +51,8 @@ import 'package:ikar/frontend/screens/chats/poll_create_screen.dart';
import 'package:ikar/frontend/widgets/animated_text_swap.dart';
import 'package:ikar/frontend/widgets/custom_notification.dart';
import '../../widgets/undo_notification.dart';
import '../../widgets/scroll_keyboard_dismiss.dart';
import '../../widgets/swipe_to_reply.dart';
import '../../../core/security/app_lock.dart';
import 'package:ikar/frontend/widgets/chat_menu_overlay.dart';
import 'package:material_symbols_icons/symbols.dart';
@@ -145,7 +147,6 @@ import '../../widgets/sticker_pack_sheet.dart';
import '../../widgets/small_spinner.dart';
import '../../widgets/swipe_to_pop.dart';
import '../../widgets/swipe_route.dart';
import '../../widgets/directional_drag_recognizer.dart';
import '../../widgets/reload_on_reconnect.dart';
import '../../widgets/schedule_time_picker.dart';
import '../../widgets/chat_wallpaper_sheet.dart';
@@ -581,7 +582,7 @@ class _ChatScreenState extends State<ChatScreen>
set _historyKickedOff(bool v) => _chatController.historyKickedOff = v;
final GlobalKey _messageListKey = GlobalKey();
_ChatMessageList? _messageListWidget;
Widget? _messageListWidget;
final Set<String> _deletingIds = {};
static const double _avgMessageHeight = 72.0;
@@ -2681,7 +2682,10 @@ class _ChatScreenState extends State<ChatScreen>
}
void _startTextSelection(CachedMessage message, Offset globalPosition) {
if (message.isControl || message.selectableText == null) return;
if (message.isControl ||
MessageDecryptionCache.instance.readableText(message) == null) {
return;
}
_textSelectionDrag.value = null;
_textSelection.value = (id: message.id, pos: globalPosition);
}
@@ -2707,7 +2711,9 @@ class _ChatScreenState extends State<ChatScreen>
List<CachedMessage> _copyableSelection(Set<String> ids) => [
for (final m in _messages)
if (ids.contains(m.id) && (m.selectableText ?? '').isNotEmpty) m,
if (ids.contains(m.id) &&
(MessageDecryptionCache.instance.readableText(m) ?? '').isNotEmpty)
m,
];
CachedMessage? _singleEditable(Set<String> ids) {
@@ -2719,7 +2725,11 @@ class _ChatScreenState extends State<ChatScreen>
void _copySelected(List<CachedMessage> messages) {
if (messages.isEmpty) return;
final text = messages.map((m) => m.selectableText!).join('\n\n');
final text = messages
.map((m) => MessageDecryptionCache.instance.readableText(m) ?? '')
.where((text) => text.isNotEmpty)
.join('\n\n');
if (text.isEmpty) return;
Clipboard.setData(ClipboardData(text: text));
Haptics.tap();
showCustomNotification(
@@ -3130,6 +3140,14 @@ class _ChatScreenState extends State<ChatScreen>
}
Future<void> _startEditMessage(CachedMessage message) async {
final originalText = await MessageDecryptionCache.instance.resolveText(
message,
);
if (!mounted) return;
if (originalText == null) {
showCustomNotification(context, 'Не удалось расшифровать сообщение');
return;
}
final cs = Theme.of(context).colorScheme;
final content =
@@ -3143,7 +3161,7 @@ class _ChatScreenState extends State<ChatScreen>
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
builder: (sheetContext) => _EditMessageSheet(
text: message.text ?? '',
text: originalText,
formatRanges: message.formatRanges,
contextMenuBuilder: _formatContextMenu,
),
@@ -3158,8 +3176,7 @@ class _ChatScreenState extends State<ChatScreen>
final oldElements = serializeFormatElements(
message.formatRanges.where((r) => composerFormats.contains(r.format)),
);
if (newText == (message.text ?? '') &&
_sameElements(elements, oldElements)) {
if (newText == originalText && _sameElements(elements, oldElements)) {
return;
}
@@ -5990,8 +6007,10 @@ class _ChatScreenState extends State<ChatScreen>
);
}
Widget _buildMessagesList() =>
_messageListWidget ??= _ChatMessageList(this, key: _messageListKey);
Widget _buildMessagesList() => _messageListWidget ??= ScrollKeyboardDismiss(
onDismiss: _messageFocusNode.unfocus,
child: _ChatMessageList(this, key: _messageListKey),
);
EdgeInsets _messagesListPadding(BuildContext context) {
if (AppChatChrome.current.value == ChatChromeStyle.color) {
@@ -6199,8 +6218,7 @@ class _ChatScreenState extends State<ChatScreen>
final swipeable =
(message.isControl || isChannel || !_canReply)
? pressable
: _SwipeToReply(
isMe: isMe,
: SwipeToReply(
onReply: () => _startReply(message),
child: pressable,
);
@@ -6312,119 +6330,6 @@ class _ChatScreenState extends State<ChatScreen>
);
}
class _SwipeToReply extends StatefulWidget {
final Widget child;
final bool isMe;
final VoidCallback onReply;
const _SwipeToReply({
required this.child,
required this.isMe,
required this.onReply,
});
@override
State<_SwipeToReply> createState() => _SwipeToReplyState();
}
class _SwipeToReplyState extends State<_SwipeToReply>
with SingleTickerProviderStateMixin {
static const double _maxDrag = 72.0;
static const double _triggerThreshold = 56.0;
late final AnimationController _springBack;
double _dragX = 0.0;
double _springFrom = 0.0;
bool _triggered = false;
@override
void initState() {
super.initState();
_springBack =
AnimationController(
vsync: this,
duration: const Duration(milliseconds: 200),
)..addListener(() {
final t = Curves.easeOut.transform(_springBack.value);
setState(() => _dragX = _springFrom * (1 - t));
});
}
@override
void dispose() {
_springBack.dispose();
super.dispose();
}
void _onDragUpdate(DragUpdateDetails d) {
if (_springBack.isAnimating) _springBack.stop();
var next = _dragX + d.delta.dx;
if (next > 0) next = 0;
if (next < -_maxDrag) next = -_maxDrag;
final wasTriggered = _triggered;
_triggered = next <= -_triggerThreshold;
if (_triggered && !wasTriggered) Haptics.medium();
setState(() => _dragX = next);
}
void _onDragEnd(DragEndDetails d) {
if (_triggered) widget.onReply();
_settle();
}
void _onDragCancel() => _settle();
void _settle() {
_triggered = false;
_springFrom = _dragX;
_springBack.forward(from: 0);
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final progress = (-_dragX / _triggerThreshold).clamp(0.0, 1.0);
return RawGestureDetector(
behavior: HitTestBehavior.opaque,
gestures: <Type, GestureRecognizerFactory>{
LeftwardDragRecognizer:
GestureRecognizerFactoryWithHandlers<LeftwardDragRecognizer>(
() => LeftwardDragRecognizer(debugOwner: this),
(instance) {
instance
..onUpdate = _onDragUpdate
..onEnd = _onDragEnd
..onCancel = _onDragCancel;
},
),
},
child: Stack(
alignment: Alignment.centerRight,
children: [
Positioned(
right: 16,
child: Opacity(
opacity: progress,
child: Transform.scale(
scale: 0.6 + 0.4 * progress,
child: Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: cs.surfaceContainerHighest,
shape: BoxShape.circle,
),
child: Icon(Symbols.reply, size: 20, color: cs.primary),
),
),
),
),
Transform.translate(offset: Offset(_dragX, 0), child: widget.child),
],
),
);
}
}
class _PinnedMessageBanner extends StatelessWidget {
final String? text;
final bool isPreview;
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
@@ -6,12 +8,17 @@ import '../../widgets/connection_status.dart';
import '../../../core/config/app_amoled.dart';
import '../../../core/config/app_theme_mode.dart';
import '../../../core/config/app_theme_schedule.dart';
import '../../../core/config/app_wallpaper_tint.dart';
import '../../../core/storage/app_database.dart';
import '../../../core/storage/chat_wallpaper_store.dart';
import '../../../core/utils/haptics.dart';
import '../../../l10n/app_localizations.dart';
import '../../../main.dart';
import '../../widgets/glossy_pill.dart';
import '../../widgets/mesh_gradient_background.dart';
import '../../widgets/settings_radio_tile.dart';
import '../../widgets/settings_card.dart';
import 'custom_gradient_editor_screen.dart';
class ThemeSettingsScreen extends StatelessWidget {
const ThemeSettingsScreen({super.key});
@@ -45,9 +52,14 @@ class ThemeSettingsScreen extends StatelessWidget {
}
}
class _ThemeModeCard extends StatelessWidget {
class _ThemeModeCard extends StatefulWidget {
const _ThemeModeCard();
@override
State<_ThemeModeCard> createState() => _ThemeModeCardState();
}
class _ThemeModeCardState extends State<_ThemeModeCard> {
static const _items = [
(mode: AppThemeMode.system, icon: Symbols.brightness_auto),
(mode: AppThemeMode.light, icon: Symbols.light_mode),
@@ -55,6 +67,72 @@ class _ThemeModeCard extends StatelessWidget {
(mode: AppThemeMode.schedule, icon: Symbols.schedule),
];
int _accountId = 0;
ChatWallpaper? _wallpaper;
bool _ready = false;
@override
void initState() {
super.initState();
unawaited(_load());
}
Future<void> _load() async {
await ChatWallpaperStore.instance.load();
final profile = await AppDatabase.loadActiveProfile();
if (!mounted) return;
setState(() {
_accountId = profile?.id ?? 0;
final wallpaper = ChatWallpaperStore.instance.get(
_accountId,
kGlobalWallpaperChatId,
);
_wallpaper = wallpaper?.isGradient == true ? wallpaper : null;
_ready = true;
});
}
Future<void> _enableCustom() async {
Haptics.selection();
if (_wallpaper == null && _accountId != 0) {
final wallpaper = await ChatWallpaperStore.instance.setGradient(
_accountId,
kGlobalWallpaperChatId,
const [Color(0xFF000000)],
);
if (mounted) setState(() => _wallpaper = wallpaper);
}
await AppWallpaperTint.save(true);
}
Future<void> _openEditor() async {
if (_accountId == 0) return;
final result = await Navigator.of(context).push<CustomGradientResult>(
MaterialPageRoute(
builder: (_) => CustomGradientEditorScreen(
initialColors: _wallpaper?.gradientColors,
initialAnimated: _wallpaper?.gradientAnimated ?? false,
initialRotation: _wallpaper?.gradientRotation ?? 0,
),
),
);
if (result == null || !mounted) return;
final activeProfile = await AppDatabase.loadActiveProfile();
if (!mounted || activeProfile?.id != _accountId) return;
final wallpaper = await ChatWallpaperStore.instance.setGradient(
_accountId,
kGlobalWallpaperChatId,
result.colors,
animated: result.animated,
rotation: result.rotation,
);
if (!AppWallpaperTint.current.value) {
await AppWallpaperTint.save(true);
}
if (!mounted) return;
setState(() => _wallpaper = wallpaper);
}
String _labelFor(AppLocalizations l10n, AppThemeMode mode) {
switch (mode) {
case AppThemeMode.system:
@@ -72,6 +150,7 @@ class _ThemeModeCard extends StatelessWidget {
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final l10n = AppLocalizations.of(context)!;
final colors = _wallpaper?.gradientColors;
return SettingsPanel(
padding: const EdgeInsets.fromLTRB(20, 18, 20, 12),
child: Column(
@@ -91,24 +170,72 @@ class _ThemeModeCard extends StatelessWidget {
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
),
const SizedBox(height: 8),
ValueListenableBuilder<AppThemeMode>(
valueListenable: AppThemeModeConfig.current,
builder: (context, current, _) {
ListenableBuilder(
listenable: Listenable.merge([
AppThemeModeConfig.current,
AppWallpaperTint.current,
]),
builder: (context, _) {
final current = AppThemeModeConfig.current.value;
final customSelected = AppWallpaperTint.current.value;
return Column(
children: [
for (final item in _items)
_ModeTile(
icon: item.icon,
label: _labelFor(l10n, item.mode),
selected: current == item.mode,
selected: !customSelected && current == item.mode,
onTap: (position) {
if (current == item.mode) return;
if (!customSelected && current == item.mode) return;
Haptics.selection();
if (customSelected) {
unawaited(AppWallpaperTint.save(false));
}
QlyraApp.stateOf(
context,
)?.applyThemeModeWithReveal(item.mode, position);
},
),
_ModeTile(
icon: Symbols.palette,
label: l10n.themeSettingsCustomTitle,
selected: customSelected,
onTap: (_) {
if (!_ready || _accountId == 0) return;
if (customSelected) return;
unawaited(_enableCustom());
},
),
if (customSelected)
Padding(
padding: const EdgeInsets.only(left: 36),
child: SettingsNavTile(
isLast: true,
onTap: _ready ? _openEditor : null,
leading: ClipRRect(
borderRadius: BorderRadius.circular(10),
child: SizedBox(
width: 32,
height: 32,
child: colors != null && colors.isNotEmpty
? MeshGradientBackground(
colors: colors,
animate: false,
rotation: _wallpaper?.gradientRotation ?? 0,
)
: ColoredBox(
color: cs.surfaceContainerHighest,
child: Icon(
Symbols.palette,
color: cs.onSurface,
size: 18,
),
),
),
),
label: l10n.themeSettingsCustomizeAction,
),
),
],
);
},
@@ -68,6 +68,7 @@ class PhotoHeroController {
class PhotoHeroRoute<T> extends PageRouteBuilder<T> {
PhotoHeroRoute({required this.hero, required WidgetBuilder builder})
: super(
opaque: false,
transitionDuration: const Duration(milliseconds: 320),
reverseTransitionDuration: const Duration(milliseconds: 280),
pageBuilder: (context, animation, secondaryAnimation) =>
@@ -301,7 +302,7 @@ class _PhotoHeroTransitionState extends State<_PhotoHeroTransition> {
}
return Stack(
children: [
widget.child,
FadeTransition(opacity: widget.animation, child: widget.child),
Positioned.fill(
child: IgnorePointer(
child: AnimatedBuilder(
@@ -402,6 +402,7 @@ class _VideoQualityEditorState extends State<VideoQualityEditor> {
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final out = widget.geometry.naturalOutput;
final inset = _QualitySlider.insetFor(context);
return Scaffold(
backgroundColor: Colors.black,
appBar: AppBar(
@@ -450,22 +451,29 @@ class _VideoQualityEditorState extends State<VideoQualityEditor> {
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
padding: EdgeInsets.fromLTRB(inset, 8, inset, 0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
l10n.videoEditorQualityLow,
style: const TextStyle(
color: Colors.white70,
fontSize: 14,
Expanded(
child: Text(
l10n.videoEditorQualityLow,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white70,
fontSize: 14,
),
),
),
Text(
l10n.videoEditorQualityHigh,
style: const TextStyle(
color: Colors.white70,
fontSize: 14,
const SizedBox(width: 12),
Expanded(
child: Text(
l10n.videoEditorQualityHigh,
textAlign: TextAlign.end,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white70,
fontSize: 14,
),
),
),
],
@@ -474,6 +482,7 @@ class _VideoQualityEditorState extends State<VideoQualityEditor> {
_QualitySlider(
count: widget.options.length,
index: _index,
inset: inset,
onChanged: (value) => setState(() => _index = value),
),
Row(
@@ -519,23 +528,35 @@ class _VideoQualityEditorState extends State<VideoQualityEditor> {
class _QualitySlider extends StatelessWidget {
final int count;
final int index;
final double inset;
final ValueChanged<int> onChanged;
const _QualitySlider({
required this.count,
required this.index,
required this.inset,
required this.onChanged,
});
static const double _minInset = 16.0;
static const double _height = 48.0;
// #***! концы дорожки уводим из зоны системных жестов, иначе крайние
// значения на телефоне с жестовой навигацией нечем зацепить
static double insetFor(BuildContext context) {
final gesture = MediaQuery.of(context).systemGestureInsets;
return math.max(_minInset, math.max(gesture.left, gesture.right) + 8);
}
@override
Widget build(BuildContext context) {
final accent = MediaAccent.of(context);
return SizedBox(
height: 40,
height: _height,
child: LayoutBuilder(
builder: (context, constraints) {
const inset = 16.0;
final span = math.max(1.0, constraints.maxWidth - inset * 2);
final width = constraints.maxWidth;
final span = math.max(1.0, width - inset * 2);
void pick(double dx) {
if (count <= 1) return;
final t = ((dx - inset) / span).clamp(0.0, 1.0);
@@ -547,7 +568,10 @@ class _QualitySlider extends StatelessWidget {
behavior: HitTestBehavior.opaque,
onTapDown: (d) => pick(d.localPosition.dx),
onHorizontalDragUpdate: (d) => pick(d.localPosition.dx),
// #***! без явного размера CustomPaint схлопывается в ноль ширины,
// дорожка рисуется мимо и по ней нечем попасть
child: CustomPaint(
size: Size(width, _height),
painter: _QualitySliderPainter(
count: count,
index: index,
@@ -595,12 +595,20 @@ class _VideoPreviewScreenState extends State<VideoPreviewScreen> {
);
}
static const double _barInset = 8;
static const double _trimMinInset = 16;
Widget _bottomBar() {
final controller = _controller;
final gesture = MediaQuery.of(context).systemGestureInsets;
final trimInset = math.max(
_trimMinInset,
math.max(gesture.left, gesture.right) + 8 - _barInset,
);
return SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
padding: const EdgeInsets.fromLTRB(_barInset, 0, _barInset, 8),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
@@ -616,14 +624,17 @@ class _VideoPreviewScreenState extends State<VideoPreviewScreen> {
if (widget.editable &&
controller != null &&
controller.value.isInitialized)
TrimBar(
frames: _strip,
controller: controller,
duration: _edit.sourceDuration,
start: _edit.start,
end: _edit.end,
onScrub: _onScrub,
onTrim: _onTrim,
Padding(
padding: EdgeInsets.symmetric(horizontal: trimInset),
child: TrimBar(
frames: _strip,
controller: controller,
duration: _edit.sourceDuration,
start: _edit.start,
end: _edit.end,
onScrub: _onScrub,
onTrim: _onTrim,
),
),
const SizedBox(height: 10),
_captionField(),
@@ -3,44 +3,43 @@ import 'package:flutter/gestures.dart';
class DirectionalDragRecognizer extends HorizontalDragGestureRecognizer {
DirectionalDragRecognizer({
required this.direction,
this.minAcceptDistance = 20.0,
this.minAcceptVelocity,
this.minAcceptDistance = kTouchSlop,
super.debugOwner,
}) {
onlyAcceptDragOnThreshold = true;
dragStartBehavior = DragStartBehavior.down;
}
final double direction;
final double minAcceptDistance;
final double? minAcceptVelocity;
bool Function()? enabled;
final Map<int, Offset> _initialPositions = {};
final Map<int, VelocityTracker> _velocityTrackers = {};
final Map<int, double> _currentDeltaX = {};
final Map<int, Offset> _currentDeltas = {};
bool _directionLocked = false;
@override
bool isPointerAllowed(PointerEvent event) =>
(enabled?.call() ?? true) && super.isPointerAllowed(event);
@override
void addAllowedPointer(PointerDownEvent event) {
_initialPositions[event.pointer] = event.position;
final tracker = VelocityTracker.withKind(event.kind);
tracker.addPosition(event.timeStamp, event.localPosition);
_velocityTrackers[event.pointer] = tracker;
super.addAllowedPointer(event);
}
@override
void handleEvent(PointerEvent event) {
if (event is PointerMoveEvent) {
_velocityTrackers[event.pointer]?.addPosition(
event.timeStamp,
event.localPosition,
);
final initial = _initialPositions[event.pointer];
if (initial != null) {
final dx = (event.position.dx - initial.dx) * direction;
_currentDeltaX[event.pointer] = dx;
if (dx < -kTouchSlop) {
stopTrackingPointer(event.pointer);
_cleanup(event.pointer);
final delta = event.position - initial;
_currentDeltas[event.pointer] = delta;
if (!_directionLocked &&
(delta.dx * direction < -kTouchSlop ||
(delta.dy.abs() > kTouchSlop &&
delta.dy.abs() > delta.dx.abs()))) {
resolvePointer(event.pointer, GestureDisposition.rejected);
return;
}
}
@@ -59,30 +58,23 @@ class DirectionalDragRecognizer extends HorizontalDragGestureRecognizer {
)) {
return false;
}
double maxDx = 0;
for (final dx in _currentDeltaX.values) {
if (dx > maxDx) maxDx = dx;
}
if (maxDx < minAcceptDistance) return false;
final minVelocity = minAcceptVelocity;
if (minVelocity == null) return true;
for (final tracker in _velocityTrackers.values) {
final vx = tracker.getVelocity().pixelsPerSecond.dx * direction;
if (vx >= minVelocity) return true;
}
return false;
_directionLocked = _currentDeltas.values.any(
(delta) =>
delta.dx * direction >= minAcceptDistance &&
delta.dx.abs() > delta.dy.abs(),
);
return _directionLocked;
}
void _cleanup(int pointer) {
_initialPositions.remove(pointer);
_velocityTrackers.remove(pointer);
_currentDeltaX.remove(pointer);
_currentDeltas.remove(pointer);
}
@override
void didStopTrackingLastPointer(int pointer) {
_cleanup(pointer);
_directionLocked = false;
super.didStopTrackingLastPointer(pointer);
}
@@ -94,8 +86,7 @@ class DirectionalDragRecognizer extends HorizontalDragGestureRecognizer {
}
class RightwardDragRecognizer extends DirectionalDragRecognizer {
RightwardDragRecognizer({super.debugOwner})
: super(direction: 1, minAcceptVelocity: 700);
RightwardDragRecognizer({super.debugOwner}) : super(direction: 1);
}
class LeftwardDragRecognizer extends DirectionalDragRecognizer {
@@ -2,6 +2,7 @@ import 'package:flutter/gestures.dart';
import 'package:flutter/widgets.dart';
import '../../core/config/ios_release.dart';
import 'scroll_keyboard_dismiss.dart';
bool _focusIsText() {
final context = FocusManager.instance.primaryFocus?.context;
@@ -69,6 +70,9 @@ class _KeyboardDismissalState extends State<KeyboardDismissal> {
if (notification.dragDetails == null) return false;
if (notification.metrics.axis != Axis.vertical) return false;
final source = notification.context;
if (source?.findAncestorWidgetOfExactType<ScrollKeyboardDismiss>() != null) {
return false;
}
if (source != null &&
(source.findAncestorWidgetOfExactType<EditableText>() != null ||
source.findAncestorWidgetOfExactType<TextFieldTapRegion>() !=
+192 -61
View File
@@ -79,6 +79,19 @@ class PhotoViewerActions {
viewAllMedia == null;
}
class PhotoViewerRoute<T> extends PageRouteBuilder<T> {
PhotoViewerRoute({required WidgetBuilder builder})
: super(
opaque: false,
transitionDuration: const Duration(milliseconds: 220),
reverseTransitionDuration: const Duration(milliseconds: 200),
pageBuilder: (context, animation, secondaryAnimation) =>
builder(context),
transitionsBuilder: (context, animation, secondaryAnimation, child) =>
FadeTransition(opacity: animation, child: child),
);
}
class _ViewerMedia {
final String id;
final MessageAttachment attachment;
@@ -199,7 +212,8 @@ class PhotoViewerScreen extends StatefulWidget {
State<PhotoViewerScreen> createState() => _PhotoViewerScreenState();
}
class _PhotoViewerScreenState extends State<PhotoViewerScreen> {
class _PhotoViewerScreenState extends State<PhotoViewerScreen>
with SingleTickerProviderStateMixin {
static const int _prefetchThreshold = 3;
static const int _maxCachedVideoPlayers = 5;
static const double _compactHeight = 480;
@@ -217,9 +231,17 @@ class _PhotoViewerScreenState extends State<PhotoViewerScreen> {
final Map<String, Future<Map<String, String>>> _videoSourceLoads = {};
final TransformationController _heroTransform = TransformationController();
final Map<String, TransformationController> _pageTransforms = {};
int _pointers = 0;
bool _zoomed = false;
bool _swipeEnabled = true;
final Set<int> _pointers = {};
late final AnimationController _dismissReturn;
Offset _dismissOffset = Offset.zero;
Offset _returnFrom = Offset.zero;
Offset? _pointerOrigin;
VelocityTracker? _velocityTracker;
bool _dismissAllowed = false;
bool _draggingToDismiss = false;
bool _closing = false;
bool _feedLoaded = false;
bool _feedFailed = false;
bool _loadingMore = false;
@@ -231,6 +253,10 @@ class _PhotoViewerScreenState extends State<PhotoViewerScreen> {
@override
void initState() {
super.initState();
_dismissReturn = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 240),
)..addListener(_animateDismissReturn);
_heroTransform.addListener(_syncHero);
_heroTransform.addListener(_syncZoom);
_items = _localItems();
@@ -272,14 +298,8 @@ class _PhotoViewerScreenState extends State<PhotoViewerScreen> {
_syncSwipe();
}
void _updatePointers(int delta) {
final next = _pointers + delta;
_pointers = next < 0 ? 0 : next;
_syncSwipe();
}
void _syncSwipe() {
final enabled = _pointers < 2 && !_zoomed;
final enabled = _pointers.length < 2 && !_zoomed;
if (enabled == _swipeEnabled) return;
setState(() => _swipeEnabled = enabled);
}
@@ -287,6 +307,7 @@ class _PhotoViewerScreenState extends State<PhotoViewerScreen> {
@override
void dispose() {
_controller.dispose();
_dismissReturn.dispose();
_heroTransform.dispose();
for (final transform in _pageTransforms.values) {
transform.dispose();
@@ -297,6 +318,99 @@ class _PhotoViewerScreenState extends State<PhotoViewerScreen> {
super.dispose();
}
bool get _canDismiss {
final route = ModalRoute.of(context);
return !_closing &&
!_dismissReturn.isAnimating &&
route?.isCurrent == true &&
route?.popDisposition != RoutePopDisposition.doNotPop &&
Navigator.of(context).canPop() &&
(!_controller.hasClients ||
!_controller.position.isScrollingNotifier.value) &&
(_current.isVideo ||
_transformFor(_current.id).value.getMaxScaleOnAxis() <= 1.01);
}
void _onMediaPointerDown(PointerDownEvent event) {
_pointers.add(event.pointer);
_syncSwipe();
if (_pointers.length != 1) {
_dismissAllowed = false;
_restoreDismiss();
return;
}
_dismissAllowed = event.kind != PointerDeviceKind.mouse && _canDismiss;
_pointerOrigin = event.position;
_velocityTracker = VelocityTracker.withKind(event.kind)
..addPosition(event.timeStamp, event.position);
}
void _onMediaPointerMove(PointerMoveEvent event) {
if (!_dismissAllowed || _pointers.length != 1) return;
_velocityTracker?.addPosition(event.timeStamp, event.position);
final delta = event.position - _pointerOrigin!;
if (!_draggingToDismiss) {
if (delta.distance < kTouchSlop) return;
if (delta.dx.abs() >= delta.dy.abs()) {
_dismissAllowed = false;
return;
}
if (!_current.isVideo &&
_transformFor(_current.id).value.getMaxScaleOnAxis() > 1.01) {
_dismissAllowed = false;
return;
}
_draggingToDismiss = true;
}
setState(() => _dismissOffset = Offset(delta.dx * 0.35, delta.dy));
}
void _onMediaPointerUp(PointerUpEvent event) {
_pointers.remove(event.pointer);
_syncSwipe();
if (!_draggingToDismiss || !_dismissAllowed) return;
_dismissAllowed = false;
_draggingToDismiss = false;
_velocityTracker?.addPosition(event.timeStamp, event.position);
final velocity = _velocityTracker?.getVelocity().pixelsPerSecond.dy ?? 0;
final distance = _dismissOffset.dy;
final threshold = (MediaQuery.sizeOf(context).height * 0.18).clamp(96, 180);
final fling =
distance.abs() >= 32 &&
velocity.abs() >= 900 &&
velocity.sign == distance.sign;
final route = ModalRoute.of(context);
if ((distance.abs() >= threshold || fling) &&
route?.isCurrent == true &&
route?.popDisposition != RoutePopDisposition.doNotPop) {
_closing = true;
Navigator.of(context).pop();
} else {
_restoreDismiss();
}
}
void _onMediaPointerCancel(PointerCancelEvent event) {
_pointers.remove(event.pointer);
_syncSwipe();
_dismissAllowed = false;
_restoreDismiss();
}
void _restoreDismiss() {
_draggingToDismiss = false;
if (_closing || _dismissOffset == Offset.zero) return;
_returnFrom = _dismissOffset;
_dismissReturn.forward(from: 0);
}
void _animateDismissReturn() {
final value = Curves.easeOutCubic.transform(_dismissReturn.value);
setState(
() => _dismissOffset = Offset.lerp(_returnFrom, Offset.zero, value)!,
);
}
AvatarFeed? get _avatarFeed => widget.avatars;
bool get _isAvatars => _avatarFeed != null;
@@ -932,9 +1046,12 @@ class _PhotoViewerScreenState extends State<PhotoViewerScreen> {
final media = MediaQuery.of(context);
final padding = media.padding;
final compact = media.size.height < _compactHeight;
final dismissProgress =
(_dismissOffset.dy.abs() / (MediaQuery.sizeOf(context).height * 0.55))
.clamp(0.0, 1.0);
return Scaffold(
backgroundColor: Colors.black,
backgroundColor: Colors.black.withValues(alpha: 1 - dismissProgress),
body: CallbackShortcuts(
bindings: {
const SingleActivator(LogicalKeyboardKey.arrowLeft): () =>
@@ -946,51 +1063,70 @@ class _PhotoViewerScreenState extends State<PhotoViewerScreen> {
autofocus: true,
child: Stack(
children: [
Positioned.fill(child: _buildPager()),
Positioned.fill(
child: Listener(
behavior: HitTestBehavior.translucent,
onPointerDown: _onMediaPointerDown,
onPointerMove: _onMediaPointerMove,
onPointerUp: _onMediaPointerUp,
onPointerCancel: _onMediaPointerCancel,
child: Transform.translate(
key: const ValueKey('media-dismiss-transform'),
offset: _dismissOffset,
child: Transform.scale(
scale: 1 - 0.12 * dismissProgress,
child: _buildPager(),
),
),
),
),
Positioned.fill(
child: IgnorePointer(
ignoring: !_chromeVisible,
ignoring: !_chromeVisible || _dismissOffset != Offset.zero,
child: AnimatedOpacity(
opacity: _chromeVisible ? 1 : 0,
duration: const Duration(milliseconds: 220),
curve: Curves.easeOut,
child: Stack(
children: [
if (_canStep(_leftStep))
Align(
alignment: Alignment.centerLeft,
child: Padding(
padding: EdgeInsets.only(left: padding.left),
child: _arrow(
Symbols.chevron_left,
() => _step(_leftStep),
child: Opacity(
opacity: 1 - dismissProgress,
child: Stack(
children: [
if (_canStep(_leftStep))
Align(
alignment: Alignment.centerLeft,
child: Padding(
padding: EdgeInsets.only(left: padding.left),
child: _arrow(
Symbols.chevron_left,
() => _step(_leftStep),
),
),
),
),
if (_canStep(-_leftStep))
Align(
alignment: Alignment.centerRight,
child: Padding(
padding: EdgeInsets.only(right: padding.right),
child: _arrow(
Symbols.chevron_right,
() => _step(-_leftStep),
if (_canStep(-_leftStep))
Align(
alignment: Alignment.centerRight,
child: Padding(
padding: EdgeInsets.only(right: padding.right),
child: _arrow(
Symbols.chevron_right,
() => _step(-_leftStep),
),
),
),
Positioned(
top: 0,
left: 0,
right: 0,
child: _buildTopBar(padding, compact: compact),
),
Positioned(
top: 0,
left: 0,
right: 0,
child: _buildTopBar(padding, compact: compact),
),
Positioned(
left: 0,
right: 0,
bottom: 0,
child: _buildBottomBar(padding, compact: compact),
),
],
Positioned(
left: 0,
right: 0,
bottom: 0,
child: _buildBottomBar(padding, compact: compact),
),
],
),
),
),
),
@@ -1010,23 +1146,18 @@ class _PhotoViewerScreenState extends State<PhotoViewerScreen> {
gestureSettings: DeviceGestureSettings(touchSlop: swipeSlop),
);
return Listener(
onPointerDown: (_) => _updatePointers(1),
onPointerUp: (_) => _updatePointers(-1),
onPointerCancel: (_) => _updatePointers(-1),
child: MediaQuery(
data: swipeMedia,
child: PageView.builder(
key: ValueKey(_pager),
controller: _controller,
reverse: !_isAvatars,
physics: _swipeEnabled ? null : const NeverScrollableScrollPhysics(),
itemCount: _items.length,
onPageChanged: _onPageChanged,
itemBuilder: (_, i) => MediaQuery(
data: _zoomed ? media : swipeMedia,
child: _buildPage(i),
),
return MediaQuery(
data: swipeMedia,
child: PageView.builder(
key: ValueKey(_pager),
controller: _controller,
reverse: !_isAvatars,
physics: _swipeEnabled ? null : const NeverScrollableScrollPhysics(),
itemCount: _items.length,
onPageChanged: _onPageChanged,
itemBuilder: (_, i) => MediaQuery(
data: _zoomed ? media : swipeMedia,
child: _buildPage(i),
),
),
);
@@ -0,0 +1,72 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/widgets.dart';
class ScrollKeyboardDismiss extends StatefulWidget {
const ScrollKeyboardDismiss({
super.key,
required this.onDismiss,
required this.child,
});
final VoidCallback onDismiss;
final Widget child;
@override
State<ScrollKeyboardDismiss> createState() => _ScrollKeyboardDismissState();
}
class _ScrollKeyboardDismissState extends State<ScrollKeyboardDismiss> {
static const _minimumSpeed = 300.0;
static const _minimumDistance = 18.0;
VelocityTracker? _tracker;
Offset? _origin;
bool _dismissed = false;
bool _onScroll(ScrollNotification notification) {
if (notification.depth != 0 || notification.metrics.axis != Axis.vertical) {
return false;
}
if (notification is ScrollStartNotification) {
final details = notification.dragDetails;
_tracker = details == null
? null
: VelocityTracker.withKind(details.kind ?? PointerDeviceKind.touch);
_origin = details?.globalPosition;
_dismissed = false;
if (details?.sourceTimeStamp case final time?) {
_tracker?.addPosition(time, details!.globalPosition);
}
} else if (notification is ScrollEndNotification) {
_tracker = null;
_origin = null;
} else if (!_dismissed) {
final details = switch (notification) {
ScrollUpdateNotification() => notification.dragDetails,
OverscrollNotification() => notification.dragDetails,
_ => null,
};
final time = details?.sourceTimeStamp;
final tracker = _tracker;
final origin = _origin;
if (details != null &&
time != null &&
tracker != null &&
origin != null) {
tracker.addPosition(time, details.globalPosition);
if ((details.globalPosition.dy - origin.dy).abs() >= _minimumDistance &&
tracker.getVelocity().pixelsPerSecond.dy.abs() >= _minimumSpeed) {
_dismissed = true;
widget.onDismiss();
}
}
}
return false;
}
@override
Widget build(BuildContext context) =>
NotificationListener<ScrollNotification>(
onNotification: _onScroll,
child: widget.child,
);
}
+102 -31
View File
@@ -30,19 +30,36 @@ class SwipeRoute<T> extends PageRoute<T> {
@override
Duration get reverseTransitionDuration => const Duration(milliseconds: 400);
@override
DelegatedTransitionBuilder? get delegatedTransition =>
(context, animation, secondaryAnimation, allowSnapshotting, child) =>
CupertinoPageTransition(
primaryRouteAnimation: kAlwaysCompleteAnimation,
secondaryRouteAnimation: secondaryAnimation,
linearTransition: Navigator.of(context).userGestureInProgress,
child: child ?? const SizedBox.shrink(),
);
@override
bool canTransitionTo(TransitionRoute<dynamic> nextRoute) {
if (nextRoute is PageRoute && nextRoute.fullscreenDialog) return false;
return nextRoute is SwipeRoute ||
nextRoute is CupertinoRouteTransitionMixin;
nextRoute is CupertinoRouteTransitionMixin ||
(nextRoute is ModalRoute && nextRoute.delegatedTransition != null);
}
@override
bool get popGestureInProgress => _gestureController != null;
bool canTransitionFrom(TransitionRoute<dynamic> previousRoute) =>
previousRoute is PageRoute && !fullscreenDialog;
@override
bool get popGestureInProgress => navigator?.userGestureInProgress ?? false;
_SwipeBackController<T>? _gestureController;
@override
bool get popGestureEnabled {
if (fullscreenDialog || !isCurrent) return false;
if (isFirst) return false;
if (willHandlePopInternally) return false;
if (popDisposition == RoutePopDisposition.doNotPop) return false;
@@ -56,6 +73,9 @@ class SwipeRoute<T> extends PageRoute<T> {
final gesture = _SwipeBackController<T>(
navigator: navigator!,
controller: controller!,
isActive: () => isActive,
isCurrent: () => isCurrent,
canPop: () => popDisposition != RoutePopDisposition.doNotPop,
);
_gestureController = gesture;
gesture._onEnd = () {
@@ -63,9 +83,16 @@ class SwipeRoute<T> extends PageRoute<T> {
_gestureController = null;
}
};
navigator!.didStartUserGesture();
return gesture;
}
@override
void dispose() {
_gestureController?.dispose();
super.dispose();
}
@override
Widget buildPage(
BuildContext context,
@@ -134,6 +161,7 @@ class _SwipeBackGestureDetectorState<T>
if (!widget.enabledCallback()) return;
_width = context.size?.width ?? MediaQuery.of(context).size.width;
if (_width <= 0) _width = 1.0;
FocusManager.instance.primaryFocus?.unfocus();
_backController = widget.onStartPopGesture();
}
@@ -149,38 +177,53 @@ class _SwipeBackGestureDetectorState<T>
}
void _handleCancel() {
_backController?.dragEnd(0.0);
_backController?.dragEnd(0.0, cancelled: true);
_backController = null;
}
@override
Widget build(BuildContext context) {
return RawGestureDetector(
behavior: HitTestBehavior.translucent,
gestures: <Type, GestureRecognizerFactory>{
RightwardDragRecognizer:
GestureRecognizerFactoryWithHandlers<RightwardDragRecognizer>(
() => RightwardDragRecognizer(debugOwner: this),
(instance) {
instance
..onStart = _handleStart
..onUpdate = _handleUpdate
..onEnd = _handleEnd
..onCancel = _handleCancel;
},
),
},
child: widget.child,
return Listener(
onPointerCancel: (_) => _handleCancel(),
child: RawGestureDetector(
behavior: HitTestBehavior.translucent,
gestures: <Type, GestureRecognizerFactory>{
RightwardDragRecognizer:
GestureRecognizerFactoryWithHandlers<RightwardDragRecognizer>(
() => RightwardDragRecognizer(debugOwner: this),
(instance) {
instance
..enabled = widget.enabledCallback
..onStart = _handleStart
..onUpdate = _handleUpdate
..onEnd = _handleEnd
..onCancel = _handleCancel;
},
),
},
child: widget.child,
),
);
}
}
class _SwipeBackController<T> {
_SwipeBackController({required this.navigator, required this.controller});
_SwipeBackController({
required this.navigator,
required this.controller,
required this.isActive,
required this.isCurrent,
required this.canPop,
});
final NavigatorState navigator;
final AnimationController controller;
final ValueGetter<bool> isActive;
final ValueGetter<bool> isCurrent;
final ValueGetter<bool> canPop;
VoidCallback? _onEnd;
AnimationStatusListener? _statusListener;
bool _ended = false;
static const double _kMinFlingVelocity = 1.0;
@@ -188,11 +231,15 @@ class _SwipeBackController<T> {
controller.value -= delta;
}
void dragEnd(double velocity) {
const animationCurve = Curves.fastLinearToSlowEaseIn;
void dragEnd(double velocity, {bool cancelled = false}) {
const animationCurve = Curves.fastEaseInToSlowEaseOut;
final bool animateForward;
if (velocity.abs() >= _kMinFlingVelocity) {
if (!isCurrent()) {
animateForward = isActive();
} else if (cancelled || !canPop()) {
animateForward = true;
} else if (velocity.abs() >= _kMinFlingVelocity) {
animateForward = velocity <= 0;
} else {
animateForward = controller.value > 0.5;
@@ -209,9 +256,12 @@ class _SwipeBackController<T> {
curve: animationCurve,
);
} else {
navigator.pop();
if (isCurrent()) navigator.pop();
if (controller.isAnimating) {
final backMs = lerpDouble(0, 800, controller.value)!.floor();
final backMs = math.min(
lerpDouble(0, 800, controller.value)!.floor(),
300,
);
controller.animateBack(
0.0,
duration: Duration(milliseconds: backMs),
@@ -221,14 +271,35 @@ class _SwipeBackController<T> {
}
if (controller.isAnimating) {
late AnimationStatusListener statusCb;
statusCb = (status) {
_onEnd?.call();
controller.removeStatusListener(statusCb);
_statusListener = (status) {
if (status == AnimationStatus.completed ||
status == AnimationStatus.dismissed) {
_finish();
}
};
controller.addStatusListener(statusCb);
controller.addStatusListener(_statusListener!);
} else {
_onEnd?.call();
_finish();
}
}
void _finish() {
if (_ended) return;
_ended = true;
final listener = _statusListener;
if (listener != null) controller.removeStatusListener(listener);
_statusListener = null;
_onEnd?.call();
if (navigator.mounted) navigator.didStopUserGesture();
}
void dispose() {
if (_ended) return;
_ended = true;
final listener = _statusListener;
if (listener != null) controller.removeStatusListener(listener);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (navigator.mounted) navigator.didStopUserGesture();
});
}
}
+148
View File
@@ -0,0 +1,148 @@
import 'package:flutter/material.dart';
import 'package:flutter/physics.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../core/utils/haptics.dart';
import 'directional_drag_recognizer.dart';
class SwipeToReply extends StatefulWidget {
const SwipeToReply({super.key, required this.child, required this.onReply});
final Widget child;
final VoidCallback onReply;
@override
State<SwipeToReply> createState() => _SwipeToReplyState();
}
class _SwipeToReplyState extends State<SwipeToReply>
with SingleTickerProviderStateMixin {
static const double _triggerDistance = 56;
static const double _resistanceLength = 32;
static final SpringDescription _spring = SpringDescription.withDampingRatio(
mass: 1,
stiffness: 500,
ratio: 1,
);
late final AnimationController _offset;
double _distance = 0;
bool _triggered = false;
bool _hapticSent = false;
@override
void initState() {
super.initState();
_offset = AnimationController.unbounded(vsync: this);
}
@override
void dispose() {
_offset.dispose();
super.dispose();
}
void _onDragStart(DragStartDetails details) {
_offset.stop();
final visibleDistance = (-_offset.value).clamp(0.0, double.infinity);
final extra = (visibleDistance - _triggerDistance).clamp(
0.0,
_resistanceLength - 0.01,
);
_distance = extra == 0
? visibleDistance
: _triggerDistance + extra / (1 - extra / _resistanceLength);
_triggered = false;
_hapticSent = false;
}
void _onDragUpdate(DragUpdateDetails details) {
_distance = (_distance - details.delta.dx).clamp(0.0, double.infinity);
final extra = (_distance - _triggerDistance).clamp(0.0, double.infinity);
_offset.value = extra == 0
? -_distance
: -(_triggerDistance + extra / (1 + extra / _resistanceLength));
_triggered = _distance >= _triggerDistance;
if (_triggered && !_hapticSent) {
_hapticSent = true;
Haptics.medium();
}
}
void _onDragEnd(DragEndDetails details) {
final shouldReply = _triggered;
_settle(details.velocity.pixelsPerSecond.dx);
if (shouldReply) widget.onReply();
}
void _settle([double velocity = 0]) {
_triggered = false;
final extra = (_distance - _triggerDistance).clamp(0.0, double.infinity);
final resistance = 1 + extra / _resistanceLength;
_offset.animateWith(
SpringSimulation(
_spring,
_offset.value,
0,
velocity.clamp(-1500.0, 1500.0) / (resistance * resistance),
tolerance: const Tolerance(distance: 0.1, velocity: 0.1),
snapToEnd: true,
),
);
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Listener(
onPointerCancel: (_) => _settle(),
child: RawGestureDetector(
behavior: HitTestBehavior.opaque,
gestures: <Type, GestureRecognizerFactory>{
LeftwardDragRecognizer:
GestureRecognizerFactoryWithHandlers<LeftwardDragRecognizer>(
() => LeftwardDragRecognizer(debugOwner: this),
(instance) {
instance
..onStart = _onDragStart
..onUpdate = _onDragUpdate
..onEnd = _onDragEnd
..onCancel = _settle;
},
),
},
child: AnimatedBuilder(
animation: _offset,
child: RepaintBoundary(child: widget.child),
builder: (context, child) {
final offset = _offset.value.clamp(-double.infinity, 0.0);
final progress = (-offset / _triggerDistance).clamp(0.0, 1.0);
return Stack(
alignment: Alignment.centerRight,
children: [
Positioned(
right: 16,
child: Opacity(
opacity: progress,
child: Transform.scale(
scale: 0.6 + 0.4 * progress,
child: Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: cs.surfaceContainerHighest,
shape: BoxShape.circle,
),
child: Icon(Symbols.reply, size: 20, color: cs.primary),
),
),
),
),
Transform.translate(offset: Offset(offset, 0), child: child),
],
);
},
),
),
);
}
}
+1 -1
View File
@@ -3362,7 +3362,7 @@ class AppLocalizationsRu extends AppLocalizations {
String get msgActionsCopyLink => 'Скопировать ссылку';
@override
String get themeSettingsCustomTitle => 'Своя';
String get themeSettingsCustomTitle => 'Собственная тема';
@override
String get callKometDetectedNotification =>
+1 -1
View File
@@ -1203,7 +1203,7 @@
"e2eeExportedAndDisabled": "Перенос создан. На этом устройстве шифрование выключено: импортируйте файл на новом и включите там.",
"codeErrorInvalid": "Неверный код",
"msgActionsCopyLink": "Скопировать ссылку",
"themeSettingsCustomTitle": "Своя",
"themeSettingsCustomTitle": "Собственная тема",
"callKometDetectedNotification": "Этот человек использует Komet! :3",
"callTooltipKometHub": "Komet",
"webPushOpenSite": "Открыть push.komet.pw",