feat: анимации с реанимации

This commit is contained in:
Jganenokk
2026-08-06 19:21:45 +07:00
parent 81159bac60
commit 79c743e86a
50 changed files with 3247 additions and 925 deletions
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import '../../../core/protocol/packet.dart';
import '../../../main.dart';
import '../../widgets/animated_slash_icon.dart';
import '../../widgets/custom_notification.dart';
import '../../widgets/login_success_screen.dart';
import '../../widgets/small_spinner.dart';
@@ -171,10 +172,10 @@ class _Password2FAScreenState extends State<Password2FAScreen>
borderSide: BorderSide.none,
),
suffixIcon: IconButton(
icon: Icon(
_isPasswordVisible
? Icons.visibility_off
: Icons.visibility,
icon: AnimatedSlashIcon(
icon: Icons.visibility,
slashedIcon: Icons.visibility_off,
slashed: _isPasswordVisible,
color: cs.onSurfaceVariant,
),
onPressed: () {
@@ -5,6 +5,7 @@ import 'package:material_symbols_icons/symbols.dart';
import '../../../core/calls/call_admin.dart';
import '../../../core/calls/call_session.dart';
import '../../widgets/animated_slash_icon.dart';
import '../../widgets/custom_notification.dart';
import '../../widgets/komet_avatar.dart';
import '../../widgets/prompt_dialog.dart';
@@ -497,8 +498,10 @@ class _ParticipantsSheetState extends State<_ParticipantsSheet> {
Icon(Symbols.screen_share, size: 18, color: cs.primary),
if (p.videoEnabled)
Icon(Symbols.videocam, size: 18, color: cs.onSurfaceVariant),
Icon(
p.audioEnabled ? Symbols.mic : Symbols.mic_off,
AnimatedSlashIcon(
icon: Symbols.mic,
slashedIcon: Symbols.mic_off,
slashed: !p.audioEnabled,
size: 18,
color: p.audioEnabled ? cs.onSurfaceVariant : cs.error,
),
+27 -3
View File
@@ -25,6 +25,7 @@ import '../../../core/utils/logger.dart';
import '../../../l10n/app_localizations.dart';
import '../../widgets/custom_notification.dart';
import '../../widgets/glossy_pill.dart';
import '../../widgets/animated_slash_icon.dart';
import '../../widgets/sheet_helpers.dart';
import '../../widgets/small_spinner.dart';
import 'call_participants_sheet.dart';
@@ -1260,7 +1261,9 @@ class _CallScreenState extends State<CallScreen> with TickerProviderStateMixin {
onTap: _toggleSpeaker,
),
_CallButton(
icon: video ? Symbols.videocam : Symbols.videocam_off,
icon: Symbols.videocam,
slashedIcon: Symbols.videocam_off,
slashed: !video,
label: l10n.callVideoLabel,
background: video ? cs.primary : cs.surfaceContainerHighest,
foreground: video ? cs.onPrimary : cs.onSurface,
@@ -1276,7 +1279,9 @@ class _CallScreenState extends State<CallScreen> with TickerProviderStateMixin {
onTap: _toggleScreen,
),
_CallButton(
icon: _isMuted ? Symbols.mic_off : Symbols.mic,
icon: Symbols.mic,
slashedIcon: Symbols.mic_off,
slashed: _isMuted,
label: _isMuted ? l10n.callUnmute : l10n.callMute,
background: _isMuted ? cs.primary : cs.surfaceContainerHighest,
foreground: _isMuted ? cs.onPrimary : cs.onSurface,
@@ -1340,6 +1345,8 @@ class _CallingDots extends StatelessWidget {
class _CallButton extends StatelessWidget {
final IconData icon;
final IconData? slashedIcon;
final bool slashed;
final String label;
final Color background;
final Color foreground;
@@ -1352,9 +1359,26 @@ class _CallButton extends StatelessWidget {
required this.background,
required this.foreground,
required this.onTap,
this.slashedIcon,
this.slashed = false,
this.busy = false,
});
Widget _buildIcon() {
final crossed = slashedIcon;
if (crossed == null) {
return Icon(icon, color: foreground, size: 26, fill: 1);
}
return AnimatedSlashIcon(
icon: icon,
slashedIcon: crossed,
slashed: slashed,
color: foreground,
size: 26,
fill: 1,
);
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
@@ -1372,7 +1396,7 @@ class _CallButton extends StatelessWidget {
child: Center(
child: busy
? SmallSpinner(size: 22, color: foreground)
: Icon(icon, color: foreground, size: 26, fill: 1),
: _buildIcon(),
),
),
),
@@ -0,0 +1,32 @@
import '../../../../backend/modules/messages.dart';
import '../../../../core/storage/chat_activity_store.dart';
String chatActivityLabel(
ChatActivitySnapshot snapshot, {
bool withNames = false,
}) {
if (!withNames) return snapshot.activity.label;
final names = <String>[];
for (final id in snapshot.userIds) {
final name = ContactCache.get(id);
if (name == null || name.trim().isEmpty) continue;
names.add(_shortName(name));
}
if (names.isEmpty) return snapshot.activity.label;
final many = names.length > 1;
final verb = switch (snapshot.activity) {
ChatActivity.typing => many ? 'печатают' : 'печатает',
ChatActivity.sticker => many ? 'выбирают стикеры' : 'выбирает стикер',
};
if (!many) return '${names.first} $verb...';
if (names.length == 2) return '${names[0]} и ${names[1]} $verb...';
return '${names[0]} и ещё ${names.length - 1} $verb...';
}
String _shortName(String name) {
final trimmed = name.trim();
final space = trimmed.indexOf(' ');
return space > 0 ? trimmed.substring(0, space) : trimmed;
}
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:komet/core/storage/chat_activity_store.dart';
import 'package:komet/frontend/screens/chats/chat/typing_label.dart';
import 'package:komet/frontend/widgets/animated_text_swap.dart';
class AnimatedChatTile extends StatefulWidget {
@@ -126,30 +127,37 @@ class ActivitySubtitle extends StatefulWidget {
super.key,
required this.chatId,
required this.child,
this.group = false,
});
final int chatId;
final Widget child;
final bool group;
@override
State<ActivitySubtitle> createState() => _ActivitySubtitleState();
}
class _ActivitySubtitleState extends State<ActivitySubtitle> {
ChatActivity _lastActivity = ChatActivity.typing;
String _lastLabel = ChatActivity.typing.label.toLowerCase();
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return ValueListenableBuilder<ChatActivity?>(
return ValueListenableBuilder<ChatActivitySnapshot?>(
valueListenable: ChatActivityStore.instance.listenable(widget.chatId),
child: widget.child,
builder: (context, activity, base) {
if (activity != null) _lastActivity = activity;
if (activity != null) {
final named = chatActivityLabel(activity, withNames: widget.group);
_lastLabel = widget.group && named != activity.label
? named
: named.toLowerCase();
}
return AnimatedTextSwap(
showAlternate: activity != null,
alternate: Text(
_lastActivity.label.toLowerCase(),
_lastLabel,
style: TextStyle(
color: cs.primary,
fontSize: 14,
@@ -14,6 +14,7 @@ import 'package:komet/core/config/app_frost.dart';
import 'package:komet/frontend/screens/chats/chat/upload_status.dart';
import 'package:komet/frontend/screens/chats/chat/video_note_controller.dart';
import 'package:komet/frontend/screens/chats/chat/voice_record_controller.dart';
import 'package:komet/frontend/widgets/composer_morph_icon.dart';
import 'package:komet/frontend/widgets/glossy_pill.dart';
import 'package:komet/frontend/widgets/liquid_glass.dart';
import 'package:komet/frontend/widgets/rich_message_controller.dart';
@@ -387,113 +388,103 @@ class ComposerInputBar extends StatelessWidget {
},
child: ValueListenableBuilder<bool>(
valueListenable: hasText,
builder: (context, hasText, _) =>
ValueListenableBuilder<bool>(
valueListenable: voiceRec.locked,
builder: (context, locked, _) =>
ValueListenableBuilder<bool>(
valueListenable: voiceRec.isRecording,
builder: (context, recording, _) =>
ValueListenableBuilder<bool>(
valueListenable: note.videoNoteMode,
builder: (context, videoMode, _) {
final sendMode =
hasText ||
hasForward ||
locked ||
forceSend;
final pill = _actionSurface(
color: _flat
? Colors.transparent
: sendMode
? cs.primary
: recording
? cs.error
: _frost
? AppFrost.inputTint(cs)
: cs.surfaceContainerHighest,
onTap:
(hasText ||
hasForward ||
forceSend)
? onSendText
: locked
? () => voiceRec.stop(
cancel: false,
)
: null,
onLongPress:
(hasText &&
!forceSend &&
!hasForward)
? onScheduleMessage
: null,
child: SizedBox(
width: 54,
height: 54,
child: Center(
child: Icon(
sendMode
? Symbols.send
: videoMode
? Symbols.videocam
: Symbols.mic,
color: _flat
? (sendMode
? cs.primary
: recording
? cs.error
: cs.onSurfaceVariant)
: sendMode
? cs.onPrimary
: recording
? cs.onError
: cs.onSurface,
size: 24,
weight: 400,
),
),
builder: (context, hasText, _) => ValueListenableBuilder<bool>(
valueListenable: voiceRec.locked,
builder: (context, locked, _) =>
ValueListenableBuilder<bool>(
valueListenable: voiceRec.isRecording,
builder: (context, recording, _) =>
ValueListenableBuilder<bool>(
valueListenable: note.videoNoteMode,
builder: (context, videoMode, _) {
final sendMode =
hasText ||
hasForward ||
locked ||
forceSend;
final pill = _actionSurface(
color: _flat
? Colors.transparent
: recording
? cs.error
: _frost
? AppFrost.inputTint(cs)
: cs.surfaceContainerHighest,
onTap:
(hasText ||
hasForward ||
forceSend)
? onSendText
: locked
? () => voiceRec.stop(
cancel: false,
)
: null,
onLongPress:
(hasText &&
!forceSend &&
!hasForward)
? onScheduleMessage
: null,
child: SizedBox(
width: 54,
height: 54,
child: Center(
child: ComposerMorphIcon(
action: sendMode
? ComposerAction.send
: videoMode
? ComposerAction.videocam
: ComposerAction.mic,
color: recording
? (_flat
? cs.error
: cs.onError)
: sendMode
? cs.primary
: _flat
? cs.onSurfaceVariant
: cs.onSurface,
),
);
final visual =
_recordingButtonVisual(
pill: pill,
cs: cs,
active:
recording && !locked,
);
final voiceEnabled =
!sendMode && !forceSend;
return GestureDetector(
onTap: voiceEnabled
? note.toggleMode
: null,
onLongPressStart: voiceEnabled
? (_) => videoMode
? note.start()
: voiceRec.start()
: null,
onLongPressMoveUpdate:
voiceEnabled
? (d) => videoMode
? note.handleDrag(
d.offsetFromOrigin,
)
: voiceRec.handleDrag(
d.offsetFromOrigin,
)
: null,
onLongPressEnd: voiceEnabled
? (_) => videoMode
? note.handleEnd()
: voiceRec.handleEnd()
: null,
child: visual,
);
},
),
),
),
),
),
);
final visual = _recordingButtonVisual(
pill: pill,
cs: cs,
active: recording && !locked,
);
final voiceEnabled =
!sendMode && !forceSend;
return GestureDetector(
onTap: voiceEnabled
? note.toggleMode
: null,
onLongPressStart: voiceEnabled
? (_) => videoMode
? note.start()
: voiceRec.start()
: null,
onLongPressMoveUpdate: voiceEnabled
? (d) => videoMode
? note.handleDrag(
d.offsetFromOrigin,
)
: voiceRec.handleDrag(
d.offsetFromOrigin,
)
: null,
onLongPressEnd: voiceEnabled
? (_) => videoMode
? note.handleEnd()
: voiceRec.handleEnd()
: null,
child: visual,
);
},
),
),
),
),
),
],
@@ -555,15 +546,32 @@ class ComposerInputBar extends StatelessWidget {
required Widget child,
VoidCallback? onTap,
VoidCallback? onLongPress,
}) {
return TweenAnimationBuilder<Color?>(
tween: ColorTween(end: color),
duration: const Duration(milliseconds: 220),
curve: Curves.easeOut,
builder: (context, tinted, _) => _actionSurfaceOf(
color: tinted ?? color,
onTap: onTap,
onLongPress: onLongPress,
child: child,
),
);
}
Widget _actionSurfaceOf({
required Color color,
required Widget child,
VoidCallback? onTap,
VoidCallback? onLongPress,
}) {
if (_flat) {
return Material(
color: color,
shape: const CircleBorder(),
clipBehavior: Clip.antiAlias,
child: onTap == null && onLongPress == null
? child
: InkWell(onTap: onTap, onLongPress: onLongPress, child: child),
child: InkWell(onTap: onTap, onLongPress: onLongPress, child: child),
);
}
return GlossyPill(
@@ -574,6 +582,7 @@ class ComposerInputBar extends StatelessWidget {
borderRadius: BorderRadius.circular(27),
onTap: onTap,
onLongPress: onLongPress,
keepInkLayer: true,
depth: 8,
child: child,
);
@@ -749,11 +758,10 @@ class ComposerInputBar extends StatelessWidget {
duration: const Duration(milliseconds: 220),
curve: Curves.easeOut,
builder: (context, a, _) {
if (a <= 0.001) return pill;
return ValueListenableBuilder<double>(
valueListenable: voiceRec.amplitude,
builder: (context, amp, _) => TweenAnimationBuilder<double>(
tween: Tween(begin: 0.0, end: amp),
tween: Tween(begin: 0.0, end: a <= 0.001 ? 0.0 : amp),
duration: const Duration(milliseconds: 110),
builder: (context, v, _) {
final glow = a * (88.0 + v * 76.0);
@@ -775,7 +783,7 @@ class ComposerInputBar extends StatelessWidget {
),
),
),
_voiceLockChip(cs),
_voiceLockChip(cs, a),
Transform.scale(
scale: 1.0 + a * 0.14 + a * v * 0.24,
child: pill,
@@ -789,13 +797,13 @@ class ComposerInputBar extends StatelessWidget {
);
}
Widget _voiceLockChip(ColorScheme cs) {
Widget _voiceLockChip(ColorScheme cs, double reveal) {
return Positioned(
bottom: 62,
child: ValueListenableBuilder<double>(
valueListenable: voiceRec.lockDrag,
builder: (context, lock, _) => Opacity(
opacity: (0.5 + lock * 0.5).clamp(0.0, 1.0),
opacity: (reveal * (0.5 + lock * 0.5)).clamp(0.0, 1.0),
child: Transform.translate(
offset: Offset(0, lock * 12),
child: Container(
@@ -3,6 +3,7 @@ import 'package:material_symbols_icons/symbols.dart';
import '../../../core/storage/chat_encryption_store.dart';
import '../../widgets/custom_notification.dart';
import '../../widgets/animated_slash_icon.dart';
import '../../widgets/glossy_pill.dart';
import '../../widgets/primary_loading_button.dart';
import '../../widgets/settings_card.dart';
@@ -156,10 +157,10 @@ class _ChatEncryptionScreenState extends State<ChatEncryptionScreen> {
borderSide: BorderSide.none,
),
suffixIcon: IconButton(
icon: Icon(
_keyVisible
? Symbols.visibility_off
: Symbols.visibility,
icon: AnimatedSlashIcon(
icon: Symbols.visibility,
slashedIcon: Symbols.visibility_off,
slashed: _keyVisible,
color: cs.onSurfaceVariant,
),
onPressed: () =>
@@ -17,6 +17,7 @@ import '../../../core/calls/call_controller.dart';
import '../../../core/config/app_show_extra_info.dart';
import '../../../core/config/app_stories.dart';
import '../../../core/storage/app_database.dart';
import '../../../core/storage/chat_members_store.dart';
import '../../../core/utils/format.dart';
import '../../../core/utils/logger.dart';
import '../../../core/utils/haptics.dart';
@@ -24,6 +25,7 @@ import '../../../l10n/app_localizations.dart';
import '../../../models/chat_info.dart';
import '../../../models/contact_info.dart';
import '../../../models/story.dart';
import '../../widgets/animated_slash_icon.dart';
import '../../widgets/animated_text_swap.dart';
import '../../widgets/avatar_history_screen.dart';
import '../../widgets/chat_info/shared_content_tabs.dart';
@@ -170,9 +172,18 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
void initState() {
super.initState();
storiesModule.storiesChanged.addListener(_onStoriesChanged);
ChatMembersStore.instance
.listenable(widget.chatId)
.addListener(_onMemberCountChanged);
_load();
}
int? get _memberCount => ChatMembersStore.instance.count(widget.chatId);
void _onMemberCountChanged() {
if (mounted) setState(() {});
}
void _onStoriesChanged() {
if (!mounted) return;
setState(_refreshUnreadStories);
@@ -181,6 +192,9 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
@override
void dispose() {
storiesModule.storiesChanged.removeListener(_onStoriesChanged);
ChatMembersStore.instance
.listenable(widget.chatId)
.removeListener(_onMemberCountChanged);
_tabScrollController.dispose();
_bodyScrollController?.dispose();
_avatarPageController.dispose();
@@ -446,7 +460,7 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
unawaited(storiesModule.loadOwnersPreviews(fresh));
}
final total = _chatInfo?.participantsCount;
final total = _memberCount;
if (page.members.isEmpty ||
added == 0 ||
page.marker == _memberMarker ||
@@ -496,6 +510,9 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
}
Future<void> _refreshMembers() async {
final info = await ChatInfoFetch.get(widget.chatId, forceRefresh: true);
if (!mounted) return;
if (info != null) _chatInfo = info;
_contactMembers.clear();
_otherMembers.clear();
_seenMemberIds
@@ -1277,8 +1294,10 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
color: _showRealName
? Color.lerp(cs.primary, Colors.white, t)
: textColor.withValues(alpha: 0.7),
icon: Icon(
_showRealName ? Symbols.visibility : Symbols.visibility_off,
icon: AnimatedSlashIcon(
icon: Symbols.visibility,
slashedIcon: Symbols.visibility_off,
slashed: !_showRealName,
),
tooltip: real,
onPressed: () =>
@@ -1303,10 +1322,10 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
}
return '';
case 'CHAT':
final total = _chatInfo?.participantsCount ?? _members.length;
final total = _memberCount ?? _members.length;
return '$total ${pluralRu(total, 'участник', 'участника', 'участников')}';
case 'CHANNEL':
final count = _chatInfo?.participantsCount ?? 0;
final count = _memberCount ?? 0;
return '$count ${pluralRu(count, 'подписчик', 'подписчика', 'подписчиков')}';
default:
return '';
@@ -1330,7 +1349,9 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
Widget _buildActions(ColorScheme cs) {
final muteBtn = (
icon: _isMuted ? Icons.notifications_off : Icons.notifications,
icon: Icons.notifications,
slashedIcon: Icons.notifications_off,
slashed: _isMuted,
label: _isMuted
? l10n.chatInfoActionMuted
: l10n.contactProfileActionSound,
@@ -1338,16 +1359,29 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
);
final chatBtn = (
icon: Icons.chat_bubble,
slashedIcon: null,
slashed: false,
label: l10n.contactProfileActionChat,
onTap: _openChat,
);
final leaveBtn = (
icon: Icons.exit_to_app,
slashedIcon: null,
slashed: false,
label: l10n.chatInfoActionLeave,
onTap: _leaveChat,
);
final List<({IconData icon, String label, VoidCallback? onTap})> btns;
final List<
({
IconData icon,
IconData? slashedIcon,
bool slashed,
String label,
VoidCallback? onTap,
})
>
btns;
if (widget.chatType == 'DIALOG') {
btns = [
chatBtn,
@@ -1355,6 +1389,8 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
if (!_isBot)
(
icon: Icons.call,
slashedIcon: null,
slashed: false,
label: l10n.contactProfileActionCall,
onTap: _confirmAndStartCall,
),
@@ -1371,7 +1407,14 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
Row(
children: [
for (int i = 0; i < btns.length; i++) ...[
_actionBtn(cs, btns[i].icon, btns[i].label, btns[i].onTap),
_actionBtn(
cs,
btns[i].icon,
btns[i].label,
onTap: btns[i].onTap,
slashedIcon: btns[i].slashedIcon,
slashed: btns[i].slashed,
),
if (i < btns.length - 1) const SizedBox(width: 8),
],
],
@@ -1645,9 +1688,11 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
Widget _actionBtn(
ColorScheme cs,
IconData icon,
String label, [
String label, {
VoidCallback? onTap,
]) {
IconData? slashedIcon,
bool slashed = false,
}) {
return Expanded(
child: GlossyPill(
onTap: onTap,
@@ -1658,7 +1703,16 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, color: cs.primary, size: 22),
if (slashedIcon != null)
AnimatedSlashIcon(
icon: icon,
slashedIcon: slashedIcon,
slashed: slashed,
color: cs.primary,
size: 22,
)
else
Icon(icon, color: cs.primary, size: 22),
const SizedBox(height: 4),
Text(
label,
@@ -3108,6 +3108,7 @@ class _ChatListScreenState extends State<ChatListScreen>
Expanded(
child: ActivitySubtitle(
chatId: int.tryParse(id) ?? 0,
group: chatType != 'DIALOG',
child: messageLine,
),
),
+114 -178
View File
@@ -12,9 +12,7 @@ import 'package:flutter/services.dart';
import 'package:komet/backend/modules/chat_preview.dart';
import 'package:komet/backend/modules/chats.dart';
import 'package:komet/backend/modules/comments.dart';
import 'package:komet/backend/modules/file_uploader.dart';
import 'package:komet/backend/modules/upload_notification_service.dart';
import 'package:komet/backend/modules/media_send.dart';
import 'package:komet/backend/modules/upload_service.dart';
import 'package:komet/backend/modules/webapp.dart';
import 'package:komet/frontend/screens/webapp/open_mini_app.dart';
import 'package:komet/frontend/widgets/sending_clock_icon.dart';
@@ -45,6 +43,7 @@ import '../../../core/protocol/packet.dart';
import '../../../core/push/push_service.dart';
import '../../../core/storage/app_database.dart';
import '../../../core/storage/chat_activity_store.dart';
import '../../../core/storage/chat_members_store.dart';
import '../../../core/crypto/chat_crypto_service.dart';
import '../../../core/crypto/encrypted_photo.dart';
import '../../../core/crypto/message_decryption_cache.dart';
@@ -69,6 +68,7 @@ import 'chat/command_panel_controller.dart';
import 'chat/sticker_panel_controller.dart';
import 'chat/chat_search_controller.dart';
import 'chat/message_search_result.dart';
import 'chat/typing_label.dart';
import 'chat/upload_status.dart';
import 'chat/view/search_view.dart';
import 'chat/view/composer_input.dart';
@@ -317,7 +317,8 @@ class _ChatScreenState extends State<ChatScreen>
final ValueNotifier<UploadStatus> _uploadStatus = ValueNotifier(
const UploadStatus(),
);
StreamSubscription<UploadEvent>? _uploadSub;
String? _uploadStatusJobId;
ValueListenable<UploadBytes>? _uploadStatusBytes;
StreamSubscription<Packet>? _pushSub;
StreamSubscription<MessageEvent>? _messageEventSub;
StreamSubscription<Map<String, CommentsInfo>>? _commentsInfoSub;
@@ -350,10 +351,10 @@ class _ChatScreenState extends State<ChatScreen>
formatElapsed: formatVoiceElapsed,
);
StreamSubscription<MediaSendEvent>? _mediaSendSub;
StreamSubscription<UploadJobEvent>? _uploadEventSub;
ValueListenable<List<double>>? _photoProgressFor(CachedMessage m) =>
_photoUploadProgress[m.id] ?? MediaSendService.instance.progressFor(m.id);
_photoUploadProgress[m.id] ?? UploadService.instance.progressFor(m.id);
ValueNotifier<Map<String, dynamic>?> _reactionNotifierFor(CachedMessage m) {
final existing = _reactionNotifiers[m.id];
@@ -498,7 +499,6 @@ class _ChatScreenState extends State<ChatScreen>
int _otherStatus = 0;
int? _otherSeenTime;
int? _participantsCount;
final ValueNotifier<CachedMessage?> _replyTo = ValueNotifier(null);
final ValueNotifier<List<CachedMessage>> _pendingForwards = ValueNotifier(
@@ -661,7 +661,8 @@ class _ChatScreenState extends State<ChatScreen>
.catchError((_) {}),
);
WidgetsBinding.instance.addObserver(this);
_mediaSendSub = MediaSendService.instance.events.listen(_onMediaSendEvent);
_uploadEventSub = UploadService.instance.events.listen(_onUploadEvent);
_syncUploadStatus();
chats.chatsChanged.addListener(_onChatsBump);
_messageController.addListener(_onTextChanged);
_scrollController.addListener(_onScrollForDate);
@@ -751,6 +752,9 @@ class _ChatScreenState extends State<ChatScreen>
ChatActivityStore.instance
.listenable(widget.chatId)
.addListener(_recomputeHeaderStatus);
ChatMembersStore.instance
.listenable(widget.chatId)
.addListener(_recomputeHeaderStatus);
_connSub = api.stateStream.listen((_) {
if (mounted) _recomputeHeaderStatus();
});
@@ -799,11 +803,6 @@ class _ChatScreenState extends State<ChatScreen>
final link = info?['link'];
if (link is String && link.isNotEmpty) _channelLink = link;
}
final count = info?['participantsCount'] as int?;
if (count != null && count != _participantsCount) {
_participantsCount = count;
_recomputeHeaderStatus();
}
}
Future<void> _loadPeerKind() async {
@@ -2009,7 +2008,7 @@ class _ChatScreenState extends State<ChatScreen>
unawaited(chats.subscribeChat(api, widget.chatId, subscribe: false));
}
WidgetsBinding.instance.removeObserver(this);
_mediaSendSub?.cancel();
_uploadEventSub?.cancel();
chats.chatsChanged.removeListener(_onChatsBump);
_otherUnread.dispose();
_animojiHold.dispose();
@@ -2039,7 +2038,7 @@ class _ChatScreenState extends State<ChatScreen>
_scheduledCount.dispose();
_showAttachmentPanel.removeListener(_onAttachPanelToggle);
_showAttachmentPanel.dispose();
_uploadSub?.cancel();
_detachUploadStatus();
_pushSub?.cancel();
_messageEventSub?.cancel();
_commentsInfoSub?.cancel();
@@ -2060,6 +2059,9 @@ class _ChatScreenState extends State<ChatScreen>
ChatActivityStore.instance
.listenable(widget.chatId)
.removeListener(_recomputeHeaderStatus);
ChatMembersStore.instance
.listenable(widget.chatId)
.removeListener(_recomputeHeaderStatus);
PresenceFetch.revision.removeListener(_onPresenceChanged);
ContactsModule.revision.removeListener(_onContactsChanged);
if (_wallpaperListening) {
@@ -2958,7 +2960,7 @@ class _ChatScreenState extends State<ChatScreen>
if (_commentsMode) return;
switch (event) {
case MessageAddedEvent(:final message):
if (message.senderId == _myId) return;
if (message.senderId == _myId && !message.isControl) return;
if (_messages.any((m) => m.id == message.id)) return;
final nearBottom = _isNearBottom();
final anchorId = nearBottom ? null : _viewportAnchorId();
@@ -3292,9 +3294,11 @@ class _ChatScreenState extends State<ChatScreen>
_previewChat = false;
_subscribing = false;
chat = result.chat;
_participantsCount =
result.subscribersCount ?? ((_participantsCount ?? 0) + 1);
});
ChatMembersStore.instance.setCount(
widget.chatId,
result.subscribersCount,
);
_recomputeHeaderStatus();
showCustomNotification(context, 'Вы подписались на канал');
} catch (e) {
@@ -3596,17 +3600,27 @@ class _ChatScreenState extends State<ChatScreen>
_headerStatusNotifier.value = _headerStatus();
}
int get _memberCount =>
ChatMembersStore.instance.count(widget.chatId) ??
chat?.participants.length ??
0;
bool get _isGroupChat =>
widget.chatType == 'CHAT' || widget.chatType == 'CHANNEL';
String _headerStatus() {
final conn = connectionStatusLabel(api.state);
if (conn != null) return conn;
final activity = ChatActivityStore.instance.activity(widget.chatId);
if (activity != null) return activity.label;
final activity = ChatActivityStore.instance.snapshot(widget.chatId);
if (activity != null) {
return chatActivityLabel(activity, withNames: _isGroupChat);
}
if (widget.chatType == 'CHAT') {
final count = _participantsCount ?? chat?.participants.length ?? 0;
final count = _memberCount;
return '$count участников';
}
if (widget.chatType == 'CHANNEL') {
final count = _participantsCount ?? chat?.participants.length ?? 0;
final count = _memberCount;
return '$count подписчиков';
}
if (_otherStatus == 1) return 'В сети';
@@ -3627,6 +3641,14 @@ class _ChatScreenState extends State<ChatScreen>
userId,
chatActivityFromType(payload['type']),
);
unawaited(_ensureTypingName(userId));
}
Future<void> _ensureTypingName(int userId) async {
if (!_isGroupChat) return;
if (ContactCache.get(userId) != null) return;
final resolved = await messagesModule.ensureContactNames({userId});
if (resolved && mounted) _recomputeHeaderStatus();
}
void _clearTyping(int userId) {
@@ -5804,8 +5826,13 @@ class _ChatScreenState extends State<ChatScreen>
right: -3,
child: ValueListenableBuilder<int>(
valueListenable: _newMessageCount,
builder: (context, count, _) =>
count <= 0 ? const SizedBox.shrink() : _unreadBadge(cs, count),
builder: (context, count, _) => count <= 0
? const SizedBox.shrink()
: AnimatedValueSwap<int>(
value: count > 99 ? 100 : count,
alignment: Alignment.centerRight,
builder: (context, value) => _unreadBadge(cs, value),
),
),
),
],
@@ -6021,7 +6048,7 @@ class _ChatScreenState extends State<ChatScreen>
}
}
String _addOptimisticFileMessage(FileAttachment attachment) {
CachedMessage _addOptimisticFileMessage(FileAttachment attachment) {
final now = DateTime.now().millisecondsSinceEpoch;
final tempId = _nextTempId();
final msg = CachedMessage(
@@ -6038,7 +6065,7 @@ class _ChatScreenState extends State<ChatScreen>
_bumpMessages();
Haptics.send();
_scrollToBottom();
return tempId;
return msg;
}
void _updateFileMessageStatus(
@@ -6073,7 +6100,7 @@ class _ChatScreenState extends State<ChatScreen>
name: entry.filename,
size: entry.size,
),
);
).id;
_showAttachmentPanel.value = false;
try {
final realId = await messagesModule.sendFileMessage(
@@ -6092,7 +6119,9 @@ class _ChatScreenState extends State<ChatScreen>
}
Future<bool> _sendFileById(int fileId) async {
final tempId = _addOptimisticFileMessage(FileAttachment(fileId: fileId));
final tempId = _addOptimisticFileMessage(
FileAttachment(fileId: fileId),
).id;
try {
final realId = await messagesModule.sendFileMessage(widget.chatId, fileId);
final ok = realId != null;
@@ -6205,7 +6234,7 @@ class _ChatScreenState extends State<ChatScreen>
_scrollToBottom();
unawaited(
MediaSendService.instance.sendPhotos(
UploadService.instance.sendPhotos(
accountId: _myId,
chatId: widget.chatId,
tempId: tempId,
@@ -6274,7 +6303,7 @@ class _ChatScreenState extends State<ChatScreen>
}
unawaited(
MediaSendService.instance.sendVideo(
UploadService.instance.sendVideo(
accountId: _myId,
chatId: widget.chatId,
tempId: tempId,
@@ -6286,9 +6315,10 @@ class _ChatScreenState extends State<ChatScreen>
);
}
void _onMediaSendEvent(MediaSendEvent event) {
void _onUploadEvent(UploadJobEvent event) {
if (!mounted || event.chatId != widget.chatId) return;
if (event is MediaSendDone) {
_syncUploadStatus();
if (event is UploadJobDone) {
if (event.scheduled) {
Haptics.send();
_markHasScheduled();
@@ -6309,18 +6339,52 @@ class _ChatScreenState extends State<ChatScreen>
_messages[idx] = real;
_bumpMessages();
}
} else if (event is MediaSendFailed) {
} else if (event is UploadJobFailed) {
if (event.scheduled) {
Haptics.error();
showCustomNotification(context, 'Не удалось запланировать');
return;
}
_failPhotoMessage(event.tempId);
if (event.kind == UploadKind.file) {
showCustomNotification(context, 'Ошибка: ${event.reason}');
}
}
}
void _syncUploadStatus() {
final job = UploadService.instance.activeFileJob(widget.chatId);
if (job?.id == _uploadStatusJobId) return;
_detachUploadStatus();
if (job == null) {
_uploadStatus.value = const UploadStatus();
return;
}
_uploadStatusJobId = job.id;
_uploadStatusBytes = job.bytes;
job.bytes.addListener(_onUploadBytes);
_onUploadBytes();
}
void _onUploadBytes() {
final bytes = _uploadStatusBytes?.value;
if (bytes == null) return;
_uploadStatus.value = UploadStatus(
active: true,
sent: bytes.sent,
total: bytes.total,
);
}
void _detachUploadStatus() {
_uploadStatusBytes?.removeListener(_onUploadBytes);
_uploadStatusBytes = null;
_uploadStatusJobId = null;
}
void _mergePendingMedia() {
final service = MediaSendService.instance;
_syncUploadStatus();
final service = UploadService.instance;
var changed = false;
for (var i = _messages.length - 1; i >= 0; i--) {
@@ -6380,7 +6444,7 @@ class _ChatScreenState extends State<ChatScreen>
showCustomNotification(context, 'Загрузка…');
unawaited(
MediaSendService.instance.sendPhotos(
UploadService.instance.sendPhotos(
accountId: _myId,
chatId: widget.chatId,
tempId: _nextTempId(),
@@ -6634,156 +6698,28 @@ class _ChatScreenState extends State<ChatScreen>
required int size,
int? scheduledTime,
}) async {
final file = (name: filename, size: size);
final done = Completer<void>();
if (_myId == 0) return;
_showAttachmentPanel.value = false;
_uploadStatus.value = UploadStatus(active: true, total: file.size);
final scheduled = scheduledTime != null;
final tempId = scheduled
final placeholder = scheduledTime != null
? null
: _addOptimisticFileMessage(
FileAttachment(name: file.name, size: file.size),
FileAttachment(name: filename, size: size),
);
ValueNotifier<List<double>>? fileProgress;
if (tempId != null) {
fileProgress = ValueNotifier<List<double>>(const [0]);
_photoUploadProgress[tempId] = fileProgress;
}
UploadNotificationService.start(file.name);
var notifLastSent = 0;
var notifLastMs = DateTime.now().millisecondsSinceEpoch;
var notifSpeedBps = 0;
var notifLastPercent = -1;
void stopNotif() => UploadNotificationService.stop();
_uploadSub?.cancel();
_uploadSub = fileUploader
.upload(
chatId: widget.chatId,
file: source,
filename: file.name,
totalSize: file.size,
scheduledTime: scheduledTime,
)
.listen(
(event) {
if (!mounted) return;
switch (event) {
case UploadProgress(:final sent, :final total):
_uploadStatus.value = UploadStatus(
active: true,
sent: sent,
total: total,
);
if (total > 0) {
fileProgress?.value = [(sent / total).clamp(0.0, 1.0)];
}
final nowMs = DateTime.now().millisecondsSinceEpoch;
final elapsed = nowMs - notifLastMs;
if (elapsed >= 500) {
notifSpeedBps = ((sent - notifLastSent) * 1000 / elapsed)
.round();
notifLastSent = sent;
notifLastMs = nowMs;
}
final percent = total > 0 ? (sent * 100 ~/ total) : 0;
if (percent != notifLastPercent) {
notifLastPercent = percent;
UploadNotificationService.update(
filename: file.name,
progressPercent: percent,
speedBps: notifSpeedBps,
);
}
case UploadDone(
:final fileId,
:final token,
:final url,
:final messageId,
):
stopNotif();
FileHistoryCache.add(
FileHistoryEntry(
fileId: fileId,
url: url,
token: token,
filename: file.name,
size: file.size,
sentAt: DateTime.now(),
),
);
if (scheduled) {
Haptics.send();
showCustomNotification(
context,
'Запланировано на '
'${formatDateTimeWords(DateTime.fromMillisecondsSinceEpoch(scheduledTime))}',
);
} else {
_disposePhotoProgress(tempId!);
_updateFileMessageStatus(
tempId,
'sent',
realId: messageId,
attachment: FileAttachment(
fileId: fileId,
fileToken: token,
name: file.name,
size: file.size,
),
);
}
case UploadError(:final message):
stopNotif();
showCustomNotification(context, 'Ошибка: $message');
if (tempId != null) {
_disposePhotoProgress(tempId);
_updateFileMessageStatus(tempId, 'error');
}
}
},
onDone: () {
if (!mounted) return;
stopNotif();
if (tempId != null) {
final inFlight = _messages.firstWhere(
(m) => m.id == tempId,
orElse: () => CachedMessage(
id: '',
accountId: 0,
chatId: 0,
senderId: 0,
time: 0,
),
);
if (inFlight.id == tempId && inFlight.status == 'sending') {
_disposePhotoProgress(tempId);
_updateFileMessageStatus(tempId, 'error');
}
}
_uploadStatus.value = const UploadStatus();
_uploadSub = null;
if (!done.isCompleted) done.complete();
},
onError: (Object e) {
if (!mounted) return;
stopNotif();
showCustomNotification(context, 'Ошибка: $e');
if (tempId != null) {
_disposePhotoProgress(tempId);
_updateFileMessageStatus(tempId, 'error');
}
_uploadStatus.value = const UploadStatus();
_uploadSub = null;
if (!done.isCompleted) done.complete();
},
);
return done.future;
final sending = UploadService.instance.sendFile(
accountId: _myId,
chatId: widget.chatId,
tempId: placeholder?.id ?? _nextTempId(),
source: source,
filename: filename,
size: size,
placeholder: placeholder,
scheduledTime: scheduledTime,
);
_syncUploadStatus();
await sending;
}
}
@@ -9,7 +9,7 @@ import 'package:material_symbols_icons/symbols.dart';
import '../../../backend/modules/chats.dart';
import '../../../backend/modules/cloud_storage.dart';
import '../../../backend/modules/upload_manager.dart';
import '../../../backend/modules/upload_service.dart';
import '../../../core/storage/app_database.dart';
import '../../../core/utils/format.dart';
import '../../../l10n/app_localizations.dart';
@@ -52,6 +52,8 @@ class _CloudStorageScreenState extends State<CloudStorageScreen>
bool _isUploading = false;
final ValueNotifier<double> _uploadProgress = ValueNotifier(0);
bool _animateNewCard = false;
StreamSubscription<UploadJobEvent>? _uploadEventSub;
UploadJob? _uploadJob;
@override
void initState() {
@@ -60,47 +62,73 @@ class _CloudStorageScreenState extends State<CloudStorageScreen>
_pageController = PageController(viewportFraction: _cardViewportFraction);
_pageController.addListener(_onPageScroll);
_checkEnv();
_bindUploadManager();
_uploadEventSub = UploadService.instance.events.listen(_onUploadEvent);
}
void _onPageScroll() {
_currentFilePage.value = _pageController.page?.round() ?? 0;
}
void _bindUploadManager() {
final mgr = UploadManager.instance;
if (mgr.isActive) {
setState(() => _isUploading = true);
_mode.open();
void _syncUploadJob() {
final chatId = _envGroupId;
final job = chatId == null
? null
: UploadService.instance.activeFileJob(chatId);
if (identical(job, _uploadJob)) return;
_uploadJob?.progress.removeListener(_onUploadProgress);
_uploadJob = job;
if (job == null) {
_uploadProgress.value = 0;
if (_isUploading) setState(() => _isUploading = false);
return;
}
mgr.onProgress = (progress, _) {
if (!mounted) return;
if (!_isUploading) setState(() => _isUploading = true);
_uploadProgress.value = progress;
};
mgr.onDone = (file) {
if (!mounted) return;
_uploadProgress.value = 0;
setState(() => _isUploading = false);
_prependFile(file);
};
mgr.onError = (msg) {
if (!mounted) return;
_uploadProgress.value = 0;
setState(() => _isUploading = false);
job.progress.addListener(_onUploadProgress);
_onUploadProgress();
if (_isUploading) return;
setState(() => _isUploading = true);
_mode.open();
}
void _onUploadProgress() {
final values = _uploadJob?.progress.value;
if (values == null || values.isEmpty) return;
_uploadProgress.value = values.first;
}
Future<void> _onUploadEvent(UploadJobEvent event) async {
final chatId = _envGroupId;
final accountId = _accountId;
if (!mounted || chatId == null || accountId == null) return;
if (event.chatId != chatId || event.kind != UploadKind.file) return;
_syncUploadJob();
if (event is UploadJobFailed) {
showCustomNotification(
context,
AppLocalizations.of(context)!.devicesGenericError(msg),
AppLocalizations.of(context)!.devicesGenericError(event.reason),
);
};
return;
}
if (event is! UploadJobDone) return;
final newest = await CloudStorageModule.fetchLatestFile(
messagesModule,
accountId,
chatId,
expectedFileId: event.fileId,
);
if (!mounted || newest == null) return;
_prependFile(newest);
}
@override
void dispose() {
final mgr = UploadManager.instance;
mgr.onProgress = null;
mgr.onDone = null;
mgr.onError = null;
_uploadEventSub?.cancel();
_uploadJob?.progress.removeListener(_onUploadProgress);
_mode.dispose();
_pageController.dispose();
_currentFilePage.dispose();
@@ -205,6 +233,7 @@ class _CloudStorageScreenState extends State<CloudStorageScreen>
);
if (!mounted) return;
setState(() => _files = files.reversed.toList());
_syncUploadJob();
}
void _prependFile(CloudFile file) {
@@ -268,13 +297,17 @@ class _CloudStorageScreenState extends State<CloudStorageScreen>
_uploadProgress.value = 0;
setState(() => _isUploading = true);
await UploadManager.instance.start(
chatId: chatId,
final service = UploadService.instance;
final sending = service.sendFile(
accountId: accountId,
file: File(picked.path!),
chatId: chatId,
tempId: service.newTempId(),
source: File(picked.path!),
filename: picked.name,
totalSize: picked.size,
size: picked.size,
);
_syncUploadJob();
await sending;
}
void _showSendByIdSheet() {
@@ -4,6 +4,7 @@ import '../../../main.dart' show accountModule;
import '../../../backend/modules/account.dart' show TwoFactorDetails;
import '../../../core/storage/app_database.dart';
import '../../../l10n/app_localizations.dart';
import '../../widgets/animated_slash_icon.dart';
import '../../widgets/custom_notification.dart';
import '../../widgets/glossy_pill.dart';
import '../../widgets/primary_loading_button.dart';
@@ -1440,8 +1441,10 @@ class _PasswordFieldState extends State<_PasswordField> {
borderSide: BorderSide.none,
),
suffixIcon: IconButton(
icon: Icon(
_visible ? Symbols.visibility_off : Symbols.visibility,
icon: AnimatedSlashIcon(
icon: Symbols.visibility,
slashedIcon: Symbols.visibility_off,
slashed: _visible,
color: cs.onSurfaceVariant,
),
onPressed: () => setState(() => _visible = !_visible),
@@ -16,6 +16,7 @@ import '../../../core/utils/format.dart';
import '../../../core/utils/update_checker.dart';
import '../../../l10n/app_localizations.dart';
import '../../../main.dart';
import '../../widgets/animated_slash_icon.dart';
import '../../widgets/avatar_history_screen.dart';
import '../../widgets/connection_status.dart';
import '../../widgets/info_action_sheet.dart';
@@ -867,10 +868,10 @@ class _SettingsTabState extends State<SettingsTab> with SpectrumSurface {
),
),
const SizedBox(width: 4),
Icon(
_isPhoneVisible
? Symbols.visibility
: Symbols.visibility_off,
AnimatedSlashIcon(
icon: Symbols.visibility,
slashedIcon: Symbols.visibility_off,
slashed: !_isPhoneVisible,
size: 14,
color: Color.lerp(
cs.mutedText,
@@ -6,6 +6,7 @@ import 'package:flutter/services.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:mobile_scanner/mobile_scanner.dart';
import '../../widgets/animated_slash_icon.dart';
import '../../widgets/connection_status.dart';
class WebQrScanScreen extends StatefulWidget {
@@ -85,8 +86,10 @@ class _WebQrScanScreenState extends State<WebQrScanScreen> {
valueListenable: _controller,
builder: (context, state, _) {
final on = state.torchState == TorchState.on;
return Icon(
on ? Symbols.flash_on : Symbols.flash_off,
return AnimatedSlashIcon(
icon: Symbols.flash_on,
slashedIcon: Symbols.flash_off,
slashed: !on,
color: Colors.white,
);
},