diff --git a/CHANGELOG.md b/CHANGELOG.md index a5d42e29..580246a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ - New: Adds an "Ask a Friend" button to new contact suggestions. - New: Adds security profiles. - Improved: Onboarding flow for new users. +- Improved: Flame restore experience. - Improved: The blue verification checkmark now displays the total number of verifications. - Fix: Issue with receiving messages when user closed app while decrypting - Fix: Background message fetching reliability. diff --git a/lib/src/services/flame.service.dart b/lib/src/services/flame.service.dart index 2630c419..a0396237 100644 --- a/lib/src/services/flame.service.dart +++ b/lib/src/services/flame.service.dart @@ -65,23 +65,18 @@ Future syncFlameCounters({String? forceForGroup}) async { ({int counter, bool isExpiring}) getFlameCounterFromGroup(Group? group) { const zero = (counter: 0, isExpiring: false); if (group == null) return zero; - if (group.lastMessageSend == null || - group.lastMessageReceived == null || - group.lastFlameCounterChange == null) { + if (group.lastMessageSend == null || group.lastMessageReceived == null || group.lastFlameCounterChange == null) { return zero; } final now = clock.now(); final startOfToday = DateTime(now.year, now.month, now.day); final twoDaysAgo = startOfToday.subtract(const Duration(days: 2)); final oneDayAgo = startOfToday.subtract(const Duration(days: 1)); - if (group.lastMessageSend!.isAfter(twoDaysAgo) && - group.lastMessageReceived!.isAfter(twoDaysAgo) || + if (group.lastMessageSend!.isAfter(twoDaysAgo) && group.lastMessageReceived!.isAfter(twoDaysAgo) || group.lastFlameCounterChange!.isAfter(oneDayAgo)) { // Flame is expiring when today no exchange has happened yet: // both lastMessageSend and lastMessageReceived are before startOfToday. - final isExpiring = - group.lastMessageSend!.isBefore(oneDayAgo) || - group.lastMessageReceived!.isBefore(oneDayAgo); + final isExpiring = group.lastMessageSend!.isBefore(oneDayAgo) || group.lastMessageReceived!.isBefore(oneDayAgo); return (counter: group.flameCounter, isExpiring: isExpiring); } else { return zero; @@ -122,8 +117,7 @@ Future incFlameCounter( final now = clock.now(); final startOfToday = DateTime(now.year, now.month, now.day); final twoDaysAgo = startOfToday.subtract(const Duration(days: 2)); - if (group.lastMessageSend!.isBefore(twoDaysAgo) || - group.lastMessageReceived!.isBefore(twoDaysAgo)) { + if (group.lastMessageSend!.isBefore(twoDaysAgo) || group.lastMessageReceived!.isBefore(twoDaysAgo)) { flameCounter = 0; } } @@ -135,25 +129,21 @@ Future incFlameCounter( final now = clock.now(); final startOfToday = DateTime(now.year, now.month, now.day); - if (group.lastFlameCounterChange == null || - group.lastFlameCounterChange!.isBefore(startOfToday)) { + if (group.lastFlameCounterChange == null || group.lastFlameCounterChange!.isBefore(startOfToday)) { // last flame update was yesterday. check if it can be updated. var updateFlame = false; if (received) { - if (group.lastMessageSend != null && - group.lastMessageSend!.isAfter(startOfToday)) { + if (group.lastMessageSend != null && group.lastMessageSend!.isAfter(startOfToday)) { // today a message was already send -> update flame updateFlame = true; } - } else if (group.lastMessageReceived != null && - group.lastMessageReceived!.isAfter(startOfToday)) { + } else if (group.lastMessageReceived != null && group.lastMessageReceived!.isAfter(startOfToday)) { // today a message was already received -> update flame updateFlame = true; } if (updateFlame) { flameCounter += 1; - if (group.lastFlameCounterChange == null || - group.lastFlameCounterChange!.isBefore(timestamp)) { + if (group.lastFlameCounterChange == null || group.lastFlameCounterChange!.isBefore(timestamp)) { // only update if the timestamp is newer lastFlameCounterChange = Value(timestamp); } @@ -170,13 +160,11 @@ Future incFlameCounter( } if (received) { - if (group.lastMessageReceived == null || - group.lastMessageReceived!.isBefore(timestamp)) { + if (group.lastMessageReceived == null || group.lastMessageReceived!.isBefore(timestamp)) { lastMessageReceived = Value(timestamp); } } else { - if (group.lastMessageSend == null || - group.lastMessageSend!.isBefore(timestamp)) { + if (group.lastMessageSend == null || group.lastMessageSend!.isBefore(timestamp)) { lastMessageSend = Value(timestamp); } } @@ -203,3 +191,18 @@ bool isItPossibleToRestoreFlames(Group group) { clock.now().subtract(const Duration(days: 7)), ); } + +Future restoreFlames(String groupId) async { + final group = await twonlyDB.groupsDao.getGroup(groupId); + if (group == null) return; + final now = clock.now(); + await twonlyDB.groupsDao.updateGroup( + groupId, + GroupsCompanion( + flameCounter: Value(group.maxFlameCounter), + lastFlameCounterChange: Value(now), + lastMessageSend: Value(now), + lastMessageReceived: Value(now), + ), + ); +} diff --git a/lib/src/visual/views/chats/chat_messages_components/entries/chat_flame_restored.entry.dart b/lib/src/visual/views/chats/chat_messages_components/entries/chat_flame_restored.entry.dart index 803c41d2..0b28e1a0 100644 --- a/lib/src/visual/views/chats/chat_messages_components/entries/chat_flame_restored.entry.dart +++ b/lib/src/visual/views/chats/chat_messages_components/entries/chat_flame_restored.entry.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/model/protobuf/client/generated/data.pb.dart'; import 'package:twonly/src/utils/misc.dart'; +import 'package:twonly/src/visual/components/animate_icon.comp.dart'; import 'package:twonly/src/visual/elements/better_text.element.dart'; import 'package:twonly/src/visual/views/chats/chat_messages_components/entries/common.dart'; @@ -42,14 +43,27 @@ class ChatFlameRestoredEntry extends StatelessWidget { ), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), decoration: BoxDecoration( - color: Colors.orange, + color: info.color, borderRadius: borderRadius, ), - child: BetterText( - text: context.lang.chatEntryFlameRestored( - data.restoredFlameCounter.toInt(), - ), - textColor: isDarkMode(context) ? Colors.black : Colors.black, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox( + width: 24, + height: 24, + child: EmojiAnimationComp(emoji: '🔥'), + ), + const SizedBox(width: 8), + Flexible( + child: BetterText( + text: context.lang.chatEntryFlameRestored( + data.restoredFlameCounter.toInt(), + ), + textColor: isDarkMode(context) ? Colors.black : Colors.black, + ), + ), + ], ), ); } diff --git a/lib/src/visual/views/chats/chat_messages_components/message_input.dart b/lib/src/visual/views/chats/chat_messages_components/message_input.dart index f2e8a192..5b88e4ed 100644 --- a/lib/src/visual/views/chats/chat_messages_components/message_input.dart +++ b/lib/src/visual/views/chats/chat_messages_components/message_input.dart @@ -20,6 +20,7 @@ import 'package:twonly/src/visual/views/camera/camera_send_to.view.dart'; import 'package:twonly/src/visual/views/chats/chat_messages_components/bottom_sheets/share_additional.bottom_sheet.dart'; import 'package:twonly/src/visual/views/chats/chat_messages_components/entries/chat_audio_entry.dart'; import 'package:twonly/src/visual/views/chats/chat_messages_components/user_discovery_manual_approval.comp.dart'; +import 'package:twonly/src/visual/views/contact/contact_components/restore_flame.comp.dart'; class MessageInput extends StatefulWidget { const MessageInput({ @@ -53,6 +54,7 @@ class _MessageInputState extends State { RecordingState _recordingState = RecordingState.none; Timer? _nextTypingIndicator; DateTime? _lastTextChangeTime; + int? _contactId; Future _sendMessage() async { if (_textFieldController.text == '') return; @@ -83,13 +85,13 @@ class _MessageInputState extends State { ) async { if (widget.textFieldFocus.hasFocus && _lastTextChangeTime != null && - DateTime.now().difference(_lastTextChangeTime!) <= - const Duration(seconds: 6)) { + DateTime.now().difference(_lastTextChangeTime!) <= const Duration(seconds: 6)) { await sendTypingIndication(widget.group.groupId, true); } }); } _initializeControllers(); + _loadContactId(); } @override @@ -205,11 +207,35 @@ class _MessageInputState extends State { ); } + Future _loadContactId() async { + if (widget.group.isDirectChat) { + final members = await twonlyDB.groupsDao.getGroupContact(widget.group.groupId); + if (members.isNotEmpty && mounted) { + setState(() { + _contactId = members.first.userId; + }); + } + } + } + @override Widget build(BuildContext context) { return Column( children: [ UserDiscoveryManualApprovalComp(group: widget.group), + if (_contactId != null) + Container( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: context.color.surfaceContainerHighest, + borderRadius: BorderRadius.circular(16), + ), + clipBehavior: Clip.antiAlias, + child: RestoreFlameComp( + contactId: _contactId!, + flameOnRightSide: true, + ), + ), Padding( padding: const EdgeInsets.only( bottom: 10, @@ -252,9 +278,7 @@ class _MessageInputState extends State { ), child: FaIcon( size: 20, - _emojiShowing - ? FontAwesomeIcons.keyboard - : FontAwesomeIcons.faceSmile, + _emojiShowing ? FontAwesomeIcons.keyboard : FontAwesomeIcons.faceSmile, ), ), ), @@ -266,8 +290,7 @@ class _MessageInputState extends State { controller: _textFieldController, focusNode: widget.textFieldFocus, keyboardType: TextInputType.multiline, - showCursor: - _recordingState != RecordingState.recording, + showCursor: _recordingState != RecordingState.recording, maxLines: 4, minLines: 1, onChanged: (value) async { @@ -318,9 +341,7 @@ class _MessageInputState extends State { _currentDuration, ), style: TextStyle( - color: isDarkMode(context) - ? Colors.white - : Colors.black, + color: isDarkMode(context) ? Colors.white : Colors.black, fontSize: 12, ), ), @@ -370,17 +391,13 @@ class _MessageInputState extends State { GestureDetector( onLongPressMoveUpdate: (details) { if (_audioRecordingLock) return; - if (_recordingOffset.dy - - details.localPosition.dy >= - 100) { + if (_recordingOffset.dy - details.localPosition.dy >= 100) { HapticFeedback.heavyImpact(); setState(() { _audioRecordingLock = true; }); } - if (_recordingOffset.dx - - details.localPosition.dx >= - 90 && + if (_recordingOffset.dx - details.localPosition.dx >= 90 && _recordingState == RecordingState.recording) { _recordingState = RecordingState.none; HapticFeedback.heavyImpact(); @@ -388,13 +405,9 @@ class _MessageInputState extends State { } setState(() { - final a = - _recordingOffset.dx - - details.localPosition.dx; + final a = _recordingOffset.dx - details.localPosition.dx; if (a > 0 && a <= 90) { - _cancelSlideOffset = - _recordingOffset.dx - - details.localPosition.dx; + _cancelSlideOffset = _recordingOffset.dx - details.localPosition.dx; } }); }, @@ -414,8 +427,7 @@ class _MessageInputState extends State { child: Stack( clipBehavior: Clip.none, children: [ - if (_recordingState == RecordingState.recording && - !_audioRecordingLock) + if (_recordingState == RecordingState.recording && !_audioRecordingLock) Positioned.fill( top: -120, left: -5, @@ -426,9 +438,7 @@ class _MessageInputState extends State { height: 60, decoration: BoxDecoration( borderRadius: BorderRadius.circular(90), - color: isDarkMode(context) - ? Colors.black - : Colors.white, + color: isDarkMode(context) ? Colors.black : Colors.white, ), child: const Center( child: Column( @@ -448,8 +458,7 @@ class _MessageInputState extends State { ), ), ), - if (_recordingState == RecordingState.recording && - !_audioRecordingLock) + if (_recordingState == RecordingState.recording && !_audioRecordingLock) Positioned.fill( top: -20, left: -25, @@ -476,15 +485,10 @@ class _MessageInputState extends State { ), child: FaIcon( size: 20, - color: - (_recordingState == - RecordingState.recording) - ? Colors.white - : null, + color: (_recordingState == RecordingState.recording) ? Colors.white : null, (_recordingState == RecordingState.none) ? FontAwesomeIcons.microphone - : (_recordingState == - RecordingState.recording) + : (_recordingState == RecordingState.recording) ? FontAwesomeIcons.stop : FontAwesomeIcons.play, ), @@ -504,9 +508,7 @@ class _MessageInputState extends State { color: context.color.primary, FontAwesomeIcons.solidPaperPlane, ), - onPressed: _audioRecordingLock - ? _stopAudioRecording - : _sendMessage, + onPressed: _audioRecordingLock ? _stopAudioRecording : _sendMessage, ) else IconButton( diff --git a/lib/src/visual/views/contact/contact_components/restore_flame.comp.dart b/lib/src/visual/views/contact/contact_components/restore_flame.comp.dart index 4b2852af..d512eefd 100644 --- a/lib/src/visual/views/contact/contact_components/restore_flame.comp.dart +++ b/lib/src/visual/views/contact/contact_components/restore_flame.comp.dart @@ -1,6 +1,5 @@ import 'dart:async'; -import 'package:clock/clock.dart'; import 'package:drift/drift.dart' show Value; import 'package:fixnum/fixnum.dart'; import 'package:flutter/foundation.dart'; @@ -11,8 +10,7 @@ import 'package:twonly/src/constants/routes.keys.dart'; import 'package:twonly/src/database/tables/messages.table.dart'; import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/model/protobuf/client/generated/data.pb.dart'; -import 'package:twonly/src/model/protobuf/client/generated/messages.pb.dart' - as pb; +import 'package:twonly/src/model/protobuf/client/generated/messages.pb.dart' as pb; import 'package:twonly/src/services/api/messages.api.dart'; import 'package:twonly/src/services/flame.service.dart'; import 'package:twonly/src/services/subscription.service.dart'; @@ -24,9 +22,11 @@ import 'package:twonly/src/visual/elements/better_list_title.element.dart'; class RestoreFlameComp extends StatefulWidget { const RestoreFlameComp({ required this.contactId, + this.flameOnRightSide = false, super.key, }); final int contactId; + final bool flameOnRightSide; @override State createState() => _RestoreFlameCompState(); @@ -60,21 +60,15 @@ class _RestoreFlameCompState extends State { final currentPlan = planFromString( userService.currentUser.subscriptionPlan, ); - if (!isUserAllowed(currentPlan, PremiumFeatures.RestoreFlames) && - kReleaseMode) { + if (!isUserAllowed(currentPlan, PremiumFeatures.RestoreFlames) && kReleaseMode) { await context.push(Routes.settingsSubscription); return; } Log.info( 'Restoring flames from ${_group!.flameCounter} to ${_group!.maxFlameCounter}', ); - await twonlyDB.groupsDao.updateGroup( - _groupId, - GroupsCompanion( - flameCounter: Value(_group!.maxFlameCounter), - lastFlameCounterChange: Value(clock.now()), - ), - ); + + await restoreFlames(_groupId); final addData = AdditionalMessageData( type: AdditionalMessageData_Type.RESTORED_FLAME_COUNTER, @@ -116,6 +110,22 @@ class _RestoreFlameCompState extends State { if (_group == null || !isItPossibleToRestoreFlames(_group!)) { return Container(); } + if (widget.flameOnRightSide) { + return ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 16), + onTap: _restoreFlames, + title: Text( + 'Restore your ${_group!.maxFlameCounter} lost flames', + style: const TextStyle(fontWeight: FontWeight.w500), + ), + trailing: const SizedBox( + width: 24, + child: EmojiAnimationComp( + emoji: '🔥', + ), + ), + ); + } return BetterListTile( onTap: _restoreFlames, leading: const SizedBox( diff --git a/test/features/flame_counter_test.dart b/test/features/flame_counter_test.dart index 2cc18de5..7e7a0d19 100644 --- a/test/features/flame_counter_test.dart +++ b/test/features/flame_counter_test.dart @@ -87,7 +87,7 @@ void main() { ); }); - test('test flame counter', () async { + test('normal flame expiring', () async { final contactId = await getAndCreateUserId(); final contact = (await twonlyDB.contactsDao.getContactById(contactId))!; await twonlyDB.groupsDao.createNewDirectChat( @@ -182,6 +182,21 @@ void main() { counter: 0, isExpiring: false, ); + }); + + test('isRestore Possible', () async { + final contactId = await getAndCreateUserId(); + final contact = (await twonlyDB.contactsDao.getContactById(contactId))!; + await twonlyDB.groupsDao.createNewDirectChat( + contactId, + GroupsCompanion( + groupName: Value( + getContactDisplayName(contact), + ), + ), + ); + + final group = (await twonlyDB.groupsDao.getDirectChat(contactId))!; for (var i = 1; i <= 20; i++) { await withClock( @@ -226,6 +241,48 @@ void main() { ); }); + test('flame restoring', () async { + final contactId = await getAndCreateUserId(); + final contact = (await twonlyDB.contactsDao.getContactById(contactId))!; + await twonlyDB.groupsDao.createNewDirectChat( + contactId, + GroupsCompanion( + groupName: Value( + getContactDisplayName(contact), + ), + ), + ); + + final group = (await twonlyDB.groupsDao.getDirectChat(contactId))!; + + for (var i = 1; i <= 5; i++) { + await withClock( + Clock.fixed(DateTime(2026, 3, i, 1)), + () async { + await incFlameCounter(group.groupId, true, DateTime(2026, 3, i, 2)); + await incFlameCounter(group.groupId, false, DateTime(2026, 3, i, 3)); + }, + ); + } + + await expectFlame(DateTime(2026, 3, 5, 19), group.groupId, 5); + await expectFlame(DateTime(2026, 3, 8, 19), group.groupId, 0); + + await withClock( + Clock.fixed(DateTime(2026, 3, 9, 12)), + () async { + await restoreFlames(group.groupId); + }, + ); + + await expectFlameExpiring( + DateTime(2026, 3, 9, 13), + group.groupId, + counter: 5, + isExpiring: false, + ); + }); + tearDown(() async { await twonlyDB.close(); }); diff --git a/test/mocks/platform_channels.dart b/test/mocks/platform_channels.dart index d75807df..a722b058 100644 --- a/test/mocks/platform_channels.dart +++ b/test/mocks/platform_channels.dart @@ -1,4 +1,4 @@ -// ignore_for_file: avoid_print, avoid_dynamic_calls +// ignore_for_file: avoid_dynamic_calls import 'dart:async'; import 'package:flutter/services.dart';