Improved: Flame restore experience
Some checks are pending
Flutter analyze & test / flutter_analyze_and_test (push) Waiting to run

This commit is contained in:
otsmr 2026-05-21 13:10:02 +02:00
parent b7c4832ee2
commit cd5409d021
7 changed files with 168 additions and 81 deletions

View file

@ -5,6 +5,7 @@
- New: Adds an "Ask a Friend" button to new contact suggestions. - New: Adds an "Ask a Friend" button to new contact suggestions.
- New: Adds security profiles. - New: Adds security profiles.
- Improved: Onboarding flow for new users. - Improved: Onboarding flow for new users.
- Improved: Flame restore experience.
- Improved: The blue verification checkmark now displays the total number of verifications. - Improved: The blue verification checkmark now displays the total number of verifications.
- Fix: Issue with receiving messages when user closed app while decrypting - Fix: Issue with receiving messages when user closed app while decrypting
- Fix: Background message fetching reliability. - Fix: Background message fetching reliability.

View file

@ -65,23 +65,18 @@ Future<void> syncFlameCounters({String? forceForGroup}) async {
({int counter, bool isExpiring}) getFlameCounterFromGroup(Group? group) { ({int counter, bool isExpiring}) getFlameCounterFromGroup(Group? group) {
const zero = (counter: 0, isExpiring: false); const zero = (counter: 0, isExpiring: false);
if (group == null) return zero; if (group == null) return zero;
if (group.lastMessageSend == null || if (group.lastMessageSend == null || group.lastMessageReceived == null || group.lastFlameCounterChange == null) {
group.lastMessageReceived == null ||
group.lastFlameCounterChange == null) {
return zero; return zero;
} }
final now = clock.now(); final now = clock.now();
final startOfToday = DateTime(now.year, now.month, now.day); final startOfToday = DateTime(now.year, now.month, now.day);
final twoDaysAgo = startOfToday.subtract(const Duration(days: 2)); final twoDaysAgo = startOfToday.subtract(const Duration(days: 2));
final oneDayAgo = startOfToday.subtract(const Duration(days: 1)); final oneDayAgo = startOfToday.subtract(const Duration(days: 1));
if (group.lastMessageSend!.isAfter(twoDaysAgo) && if (group.lastMessageSend!.isAfter(twoDaysAgo) && group.lastMessageReceived!.isAfter(twoDaysAgo) ||
group.lastMessageReceived!.isAfter(twoDaysAgo) ||
group.lastFlameCounterChange!.isAfter(oneDayAgo)) { group.lastFlameCounterChange!.isAfter(oneDayAgo)) {
// Flame is expiring when today no exchange has happened yet: // Flame is expiring when today no exchange has happened yet:
// both lastMessageSend and lastMessageReceived are before startOfToday. // both lastMessageSend and lastMessageReceived are before startOfToday.
final isExpiring = final isExpiring = group.lastMessageSend!.isBefore(oneDayAgo) || group.lastMessageReceived!.isBefore(oneDayAgo);
group.lastMessageSend!.isBefore(oneDayAgo) ||
group.lastMessageReceived!.isBefore(oneDayAgo);
return (counter: group.flameCounter, isExpiring: isExpiring); return (counter: group.flameCounter, isExpiring: isExpiring);
} else { } else {
return zero; return zero;
@ -122,8 +117,7 @@ Future<void> incFlameCounter(
final now = clock.now(); final now = clock.now();
final startOfToday = DateTime(now.year, now.month, now.day); final startOfToday = DateTime(now.year, now.month, now.day);
final twoDaysAgo = startOfToday.subtract(const Duration(days: 2)); final twoDaysAgo = startOfToday.subtract(const Duration(days: 2));
if (group.lastMessageSend!.isBefore(twoDaysAgo) || if (group.lastMessageSend!.isBefore(twoDaysAgo) || group.lastMessageReceived!.isBefore(twoDaysAgo)) {
group.lastMessageReceived!.isBefore(twoDaysAgo)) {
flameCounter = 0; flameCounter = 0;
} }
} }
@ -135,25 +129,21 @@ Future<void> incFlameCounter(
final now = clock.now(); final now = clock.now();
final startOfToday = DateTime(now.year, now.month, now.day); final startOfToday = DateTime(now.year, now.month, now.day);
if (group.lastFlameCounterChange == null || if (group.lastFlameCounterChange == null || group.lastFlameCounterChange!.isBefore(startOfToday)) {
group.lastFlameCounterChange!.isBefore(startOfToday)) {
// last flame update was yesterday. check if it can be updated. // last flame update was yesterday. check if it can be updated.
var updateFlame = false; var updateFlame = false;
if (received) { if (received) {
if (group.lastMessageSend != null && if (group.lastMessageSend != null && group.lastMessageSend!.isAfter(startOfToday)) {
group.lastMessageSend!.isAfter(startOfToday)) {
// today a message was already send -> update flame // today a message was already send -> update flame
updateFlame = true; updateFlame = true;
} }
} else if (group.lastMessageReceived != null && } else if (group.lastMessageReceived != null && group.lastMessageReceived!.isAfter(startOfToday)) {
group.lastMessageReceived!.isAfter(startOfToday)) {
// today a message was already received -> update flame // today a message was already received -> update flame
updateFlame = true; updateFlame = true;
} }
if (updateFlame) { if (updateFlame) {
flameCounter += 1; flameCounter += 1;
if (group.lastFlameCounterChange == null || if (group.lastFlameCounterChange == null || group.lastFlameCounterChange!.isBefore(timestamp)) {
group.lastFlameCounterChange!.isBefore(timestamp)) {
// only update if the timestamp is newer // only update if the timestamp is newer
lastFlameCounterChange = Value(timestamp); lastFlameCounterChange = Value(timestamp);
} }
@ -170,13 +160,11 @@ Future<void> incFlameCounter(
} }
if (received) { if (received) {
if (group.lastMessageReceived == null || if (group.lastMessageReceived == null || group.lastMessageReceived!.isBefore(timestamp)) {
group.lastMessageReceived!.isBefore(timestamp)) {
lastMessageReceived = Value(timestamp); lastMessageReceived = Value(timestamp);
} }
} else { } else {
if (group.lastMessageSend == null || if (group.lastMessageSend == null || group.lastMessageSend!.isBefore(timestamp)) {
group.lastMessageSend!.isBefore(timestamp)) {
lastMessageSend = Value(timestamp); lastMessageSend = Value(timestamp);
} }
} }
@ -203,3 +191,18 @@ bool isItPossibleToRestoreFlames(Group group) {
clock.now().subtract(const Duration(days: 7)), clock.now().subtract(const Duration(days: 7)),
); );
} }
Future<void> 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),
),
);
}

View file

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:twonly/src/database/twonly.db.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/data.pb.dart';
import 'package:twonly/src/utils/misc.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/elements/better_text.element.dart';
import 'package:twonly/src/visual/views/chats/chat_messages_components/entries/common.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), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.orange, color: info.color,
borderRadius: borderRadius, borderRadius: borderRadius,
), ),
child: BetterText( child: Row(
text: context.lang.chatEntryFlameRestored( mainAxisSize: MainAxisSize.min,
data.restoredFlameCounter.toInt(), children: [
), const SizedBox(
textColor: isDarkMode(context) ? Colors.black : Colors.black, 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,
),
),
],
), ),
); );
} }

View file

@ -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/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/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/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 { class MessageInput extends StatefulWidget {
const MessageInput({ const MessageInput({
@ -53,6 +54,7 @@ class _MessageInputState extends State<MessageInput> {
RecordingState _recordingState = RecordingState.none; RecordingState _recordingState = RecordingState.none;
Timer? _nextTypingIndicator; Timer? _nextTypingIndicator;
DateTime? _lastTextChangeTime; DateTime? _lastTextChangeTime;
int? _contactId;
Future<void> _sendMessage() async { Future<void> _sendMessage() async {
if (_textFieldController.text == '') return; if (_textFieldController.text == '') return;
@ -83,13 +85,13 @@ class _MessageInputState extends State<MessageInput> {
) async { ) async {
if (widget.textFieldFocus.hasFocus && if (widget.textFieldFocus.hasFocus &&
_lastTextChangeTime != null && _lastTextChangeTime != null &&
DateTime.now().difference(_lastTextChangeTime!) <= DateTime.now().difference(_lastTextChangeTime!) <= const Duration(seconds: 6)) {
const Duration(seconds: 6)) {
await sendTypingIndication(widget.group.groupId, true); await sendTypingIndication(widget.group.groupId, true);
} }
}); });
} }
_initializeControllers(); _initializeControllers();
_loadContactId();
} }
@override @override
@ -205,11 +207,35 @@ class _MessageInputState extends State<MessageInput> {
); );
} }
Future<void> _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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Column( return Column(
children: [ children: [
UserDiscoveryManualApprovalComp(group: widget.group), 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(
padding: const EdgeInsets.only( padding: const EdgeInsets.only(
bottom: 10, bottom: 10,
@ -252,9 +278,7 @@ class _MessageInputState extends State<MessageInput> {
), ),
child: FaIcon( child: FaIcon(
size: 20, size: 20,
_emojiShowing _emojiShowing ? FontAwesomeIcons.keyboard : FontAwesomeIcons.faceSmile,
? FontAwesomeIcons.keyboard
: FontAwesomeIcons.faceSmile,
), ),
), ),
), ),
@ -266,8 +290,7 @@ class _MessageInputState extends State<MessageInput> {
controller: _textFieldController, controller: _textFieldController,
focusNode: widget.textFieldFocus, focusNode: widget.textFieldFocus,
keyboardType: TextInputType.multiline, keyboardType: TextInputType.multiline,
showCursor: showCursor: _recordingState != RecordingState.recording,
_recordingState != RecordingState.recording,
maxLines: 4, maxLines: 4,
minLines: 1, minLines: 1,
onChanged: (value) async { onChanged: (value) async {
@ -318,9 +341,7 @@ class _MessageInputState extends State<MessageInput> {
_currentDuration, _currentDuration,
), ),
style: TextStyle( style: TextStyle(
color: isDarkMode(context) color: isDarkMode(context) ? Colors.white : Colors.black,
? Colors.white
: Colors.black,
fontSize: 12, fontSize: 12,
), ),
), ),
@ -370,17 +391,13 @@ class _MessageInputState extends State<MessageInput> {
GestureDetector( GestureDetector(
onLongPressMoveUpdate: (details) { onLongPressMoveUpdate: (details) {
if (_audioRecordingLock) return; if (_audioRecordingLock) return;
if (_recordingOffset.dy - if (_recordingOffset.dy - details.localPosition.dy >= 100) {
details.localPosition.dy >=
100) {
HapticFeedback.heavyImpact(); HapticFeedback.heavyImpact();
setState(() { setState(() {
_audioRecordingLock = true; _audioRecordingLock = true;
}); });
} }
if (_recordingOffset.dx - if (_recordingOffset.dx - details.localPosition.dx >= 90 &&
details.localPosition.dx >=
90 &&
_recordingState == RecordingState.recording) { _recordingState == RecordingState.recording) {
_recordingState = RecordingState.none; _recordingState = RecordingState.none;
HapticFeedback.heavyImpact(); HapticFeedback.heavyImpact();
@ -388,13 +405,9 @@ class _MessageInputState extends State<MessageInput> {
} }
setState(() { setState(() {
final a = final a = _recordingOffset.dx - details.localPosition.dx;
_recordingOffset.dx -
details.localPosition.dx;
if (a > 0 && a <= 90) { if (a > 0 && a <= 90) {
_cancelSlideOffset = _cancelSlideOffset = _recordingOffset.dx - details.localPosition.dx;
_recordingOffset.dx -
details.localPosition.dx;
} }
}); });
}, },
@ -414,8 +427,7 @@ class _MessageInputState extends State<MessageInput> {
child: Stack( child: Stack(
clipBehavior: Clip.none, clipBehavior: Clip.none,
children: [ children: [
if (_recordingState == RecordingState.recording && if (_recordingState == RecordingState.recording && !_audioRecordingLock)
!_audioRecordingLock)
Positioned.fill( Positioned.fill(
top: -120, top: -120,
left: -5, left: -5,
@ -426,9 +438,7 @@ class _MessageInputState extends State<MessageInput> {
height: 60, height: 60,
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(90), borderRadius: BorderRadius.circular(90),
color: isDarkMode(context) color: isDarkMode(context) ? Colors.black : Colors.white,
? Colors.black
: Colors.white,
), ),
child: const Center( child: const Center(
child: Column( child: Column(
@ -448,8 +458,7 @@ class _MessageInputState extends State<MessageInput> {
), ),
), ),
), ),
if (_recordingState == RecordingState.recording && if (_recordingState == RecordingState.recording && !_audioRecordingLock)
!_audioRecordingLock)
Positioned.fill( Positioned.fill(
top: -20, top: -20,
left: -25, left: -25,
@ -476,15 +485,10 @@ class _MessageInputState extends State<MessageInput> {
), ),
child: FaIcon( child: FaIcon(
size: 20, size: 20,
color: color: (_recordingState == RecordingState.recording) ? Colors.white : null,
(_recordingState ==
RecordingState.recording)
? Colors.white
: null,
(_recordingState == RecordingState.none) (_recordingState == RecordingState.none)
? FontAwesomeIcons.microphone ? FontAwesomeIcons.microphone
: (_recordingState == : (_recordingState == RecordingState.recording)
RecordingState.recording)
? FontAwesomeIcons.stop ? FontAwesomeIcons.stop
: FontAwesomeIcons.play, : FontAwesomeIcons.play,
), ),
@ -504,9 +508,7 @@ class _MessageInputState extends State<MessageInput> {
color: context.color.primary, color: context.color.primary,
FontAwesomeIcons.solidPaperPlane, FontAwesomeIcons.solidPaperPlane,
), ),
onPressed: _audioRecordingLock onPressed: _audioRecordingLock ? _stopAudioRecording : _sendMessage,
? _stopAudioRecording
: _sendMessage,
) )
else else
IconButton( IconButton(

View file

@ -1,6 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'package:clock/clock.dart';
import 'package:drift/drift.dart' show Value; import 'package:drift/drift.dart' show Value;
import 'package:fixnum/fixnum.dart'; import 'package:fixnum/fixnum.dart';
import 'package:flutter/foundation.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/tables/messages.table.dart';
import 'package:twonly/src/database/twonly.db.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/data.pb.dart';
import 'package:twonly/src/model/protobuf/client/generated/messages.pb.dart' import 'package:twonly/src/model/protobuf/client/generated/messages.pb.dart' as pb;
as pb;
import 'package:twonly/src/services/api/messages.api.dart'; import 'package:twonly/src/services/api/messages.api.dart';
import 'package:twonly/src/services/flame.service.dart'; import 'package:twonly/src/services/flame.service.dart';
import 'package:twonly/src/services/subscription.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 { class RestoreFlameComp extends StatefulWidget {
const RestoreFlameComp({ const RestoreFlameComp({
required this.contactId, required this.contactId,
this.flameOnRightSide = false,
super.key, super.key,
}); });
final int contactId; final int contactId;
final bool flameOnRightSide;
@override @override
State<RestoreFlameComp> createState() => _RestoreFlameCompState(); State<RestoreFlameComp> createState() => _RestoreFlameCompState();
@ -60,21 +60,15 @@ class _RestoreFlameCompState extends State<RestoreFlameComp> {
final currentPlan = planFromString( final currentPlan = planFromString(
userService.currentUser.subscriptionPlan, userService.currentUser.subscriptionPlan,
); );
if (!isUserAllowed(currentPlan, PremiumFeatures.RestoreFlames) && if (!isUserAllowed(currentPlan, PremiumFeatures.RestoreFlames) && kReleaseMode) {
kReleaseMode) {
await context.push(Routes.settingsSubscription); await context.push(Routes.settingsSubscription);
return; return;
} }
Log.info( Log.info(
'Restoring flames from ${_group!.flameCounter} to ${_group!.maxFlameCounter}', 'Restoring flames from ${_group!.flameCounter} to ${_group!.maxFlameCounter}',
); );
await twonlyDB.groupsDao.updateGroup(
_groupId, await restoreFlames(_groupId);
GroupsCompanion(
flameCounter: Value(_group!.maxFlameCounter),
lastFlameCounterChange: Value(clock.now()),
),
);
final addData = AdditionalMessageData( final addData = AdditionalMessageData(
type: AdditionalMessageData_Type.RESTORED_FLAME_COUNTER, type: AdditionalMessageData_Type.RESTORED_FLAME_COUNTER,
@ -116,6 +110,22 @@ class _RestoreFlameCompState extends State<RestoreFlameComp> {
if (_group == null || !isItPossibleToRestoreFlames(_group!)) { if (_group == null || !isItPossibleToRestoreFlames(_group!)) {
return Container(); 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( return BetterListTile(
onTap: _restoreFlames, onTap: _restoreFlames,
leading: const SizedBox( leading: const SizedBox(

View file

@ -87,7 +87,7 @@ void main() {
); );
}); });
test('test flame counter', () async { test('normal flame expiring', () async {
final contactId = await getAndCreateUserId(); final contactId = await getAndCreateUserId();
final contact = (await twonlyDB.contactsDao.getContactById(contactId))!; final contact = (await twonlyDB.contactsDao.getContactById(contactId))!;
await twonlyDB.groupsDao.createNewDirectChat( await twonlyDB.groupsDao.createNewDirectChat(
@ -182,6 +182,21 @@ void main() {
counter: 0, counter: 0,
isExpiring: false, 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++) { for (var i = 1; i <= 20; i++) {
await withClock( 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 { tearDown(() async {
await twonlyDB.close(); await twonlyDB.close();
}); });

View file

@ -1,4 +1,4 @@
// ignore_for_file: avoid_print, avoid_dynamic_calls // ignore_for_file: avoid_dynamic_calls
import 'dart:async'; import 'dart:async';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';