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 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.

View file

@ -65,23 +65,18 @@ Future<void> 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<void> 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<void> 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<void> 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<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/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,15 +43,28 @@ class ChatFlameRestoredEntry extends StatelessWidget {
),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: Colors.orange,
color: info.color,
borderRadius: borderRadius,
),
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,
),
),
],
),
);
}
}

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/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<MessageInput> {
RecordingState _recordingState = RecordingState.none;
Timer? _nextTypingIndicator;
DateTime? _lastTextChangeTime;
int? _contactId;
Future<void> _sendMessage() async {
if (_textFieldController.text == '') return;
@ -83,13 +85,13 @@ class _MessageInputState extends State<MessageInput> {
) 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<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
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<MessageInput> {
),
child: FaIcon(
size: 20,
_emojiShowing
? FontAwesomeIcons.keyboard
: FontAwesomeIcons.faceSmile,
_emojiShowing ? FontAwesomeIcons.keyboard : FontAwesomeIcons.faceSmile,
),
),
),
@ -266,8 +290,7 @@ class _MessageInputState extends State<MessageInput> {
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<MessageInput> {
_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<MessageInput> {
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<MessageInput> {
}
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<MessageInput> {
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<MessageInput> {
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<MessageInput> {
),
),
),
if (_recordingState == RecordingState.recording &&
!_audioRecordingLock)
if (_recordingState == RecordingState.recording && !_audioRecordingLock)
Positioned.fill(
top: -20,
left: -25,
@ -476,15 +485,10 @@ class _MessageInputState extends State<MessageInput> {
),
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<MessageInput> {
color: context.color.primary,
FontAwesomeIcons.solidPaperPlane,
),
onPressed: _audioRecordingLock
? _stopAudioRecording
: _sendMessage,
onPressed: _audioRecordingLock ? _stopAudioRecording : _sendMessage,
)
else
IconButton(

View file

@ -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<RestoreFlameComp> createState() => _RestoreFlameCompState();
@ -60,21 +60,15 @@ class _RestoreFlameCompState extends State<RestoreFlameComp> {
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<RestoreFlameComp> {
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(

View file

@ -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();
});

View file

@ -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';