feat: анимации с реанимации
This commit is contained in:
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user