diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ba97d43..9958a75c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ - New: Feature to find friends without a phone number - New: The verification state is now transferred to the scanned user - New: Registration setup to configure the most important configurations +- Improved: Show ⌛ instead of the flame icon when it is about to expire - Improved: FAQ is now in the app rather than opening in the browser - Fix: Many smaller issues diff --git a/lib/src/database/daos/groups.dao.dart b/lib/src/database/daos/groups.dao.dart index 82319226..67812253 100644 --- a/lib/src/database/daos/groups.dao.dart +++ b/lib/src/database/daos/groups.dao.dart @@ -241,7 +241,7 @@ class GroupsDao extends DatabaseAccessor with _$GroupsDaoMixin { )..where((t) => t.groupId.equals(groupId))).getSingleOrNull(); } - Stream watchFlameCounter(String groupId) { + Stream<({int counter, bool isExpiring})> watchFlameCounter(String groupId) { return (select(groups)..where( (u) => u.groupId.equals(groupId) & @@ -249,7 +249,7 @@ class GroupsDao extends DatabaseAccessor with _$GroupsDaoMixin { u.lastMessageSend.isNotNull(), )) .watchSingleOrNull() - .asyncMap(getFlameCounterFromGroup); + .map(getFlameCounterFromGroup); } Future> getAllDirectChats() { diff --git a/lib/src/services/flame.service.dart b/lib/src/services/flame.service.dart index 5c5cf88d..0ea211f4 100644 --- a/lib/src/services/flame.service.dart +++ b/lib/src/services/flame.service.dart @@ -32,16 +32,17 @@ Future syncFlameCounters({String? forceForGroup}) async { } } - final flameCounter = getFlameCounterFromGroup(group); + final flameResult = getFlameCounterFromGroup(group); // only sync when flame counter is higher three or when they are bestFriends - if (flameCounter <= 2 && bestFriend.groupId != group.groupId) continue; + if (flameResult.counter <= 2 && bestFriend.groupId != group.groupId) + continue; await sendCipherTextToGroup( group.groupId, EncryptedContent( flameSync: EncryptedContent_FlameSync( - flameCounter: Int64(flameCounter), + flameCounter: Int64(flameResult.counter), lastFlameCounterChange: Int64( group.lastFlameCounterChange!.millisecondsSinceEpoch, ), @@ -60,12 +61,13 @@ Future syncFlameCounters({String? forceForGroup}) async { } } -int getFlameCounterFromGroup(Group? group) { - if (group == null) return 0; +({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) { - return 0; + return zero; } final now = clock.now(); final startOfToday = DateTime(now.year, now.month, now.day); @@ -74,9 +76,14 @@ int getFlameCounterFromGroup(Group? group) { if (group.lastMessageSend!.isAfter(twoDaysAgo) && group.lastMessageReceived!.isAfter(twoDaysAgo) || group.lastFlameCounterChange!.isAfter(oneDayAgo)) { - return group.flameCounter; + // 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); + return (counter: group.flameCounter, isExpiring: isExpiring); } else { - return 0; + return zero; } } @@ -190,9 +197,9 @@ Future incFlameCounter( } bool isItPossibleToRestoreFlames(Group group) { - final flameCounter = getFlameCounterFromGroup(group); + final flameResult = getFlameCounterFromGroup(group); return group.maxFlameCounter > 2 && - flameCounter < group.maxFlameCounter && + flameResult.counter < group.maxFlameCounter && group.maxFlameCounterFrom!.isAfter( clock.now().subtract(const Duration(days: 7)), ); diff --git a/lib/src/visual/components/flame_counter.comp.dart b/lib/src/visual/components/flame_counter.comp.dart index 5ba0afc2..7ff8ec35 100644 --- a/lib/src/visual/components/flame_counter.comp.dart +++ b/lib/src/visual/components/flame_counter.comp.dart @@ -23,8 +23,9 @@ class FlameCounterWidget extends StatefulWidget { class _FlameCounterWidgetState extends State { int flameCounter = 0; bool isBestFriend = false; + bool isExpiring = false; - StreamSubscription? flameCounterSub; + StreamSubscription<({int counter, bool isExpiring})>? flameCounterSub; @override void initState() { @@ -52,10 +53,11 @@ class _FlameCounterWidgetState extends State { userService.currentUser.myBestFriendGroupId == groupId && group.alsoBestFriend; final stream = twonlyDB.groupsDao.watchFlameCounter(groupId); - flameCounterSub = stream.listen((counter) { + flameCounterSub = stream.listen((result) { if (mounted) { setState(() { - flameCounter = counter; + flameCounter = result.counter; + isExpiring = result.isExpiring; }); } }); @@ -75,6 +77,9 @@ class _FlameCounterWidgetState extends State { flameEmoji = '🎂'; } + // Override with hourglass when the flame is about to expire + if (isExpiring) flameEmoji = '⌛'; + return Row( children: [ if (widget.prefix) const SizedBox(width: 5), diff --git a/lib/src/visual/views/camera/share_image_contact_selection.view.dart b/lib/src/visual/views/camera/share_image_contact_selection.view.dart index a0d3e9cc..74433e08 100644 --- a/lib/src/visual/views/camera/share_image_contact_selection.view.dart +++ b/lib/src/visual/views/camera/share_image_contact_selection.view.dart @@ -93,7 +93,7 @@ class _ShareImageView extends State { final flameComparison = getFlameCounterFromGroup( b, - ).compareTo(getFlameCounterFromGroup(a)); + ).counter.compareTo(getFlameCounterFromGroup(a).counter); if (flameComparison != 0) { return flameComparison; // Sort by flameCounter in descending order } @@ -111,7 +111,7 @@ class _ShareImageView extends State { for (final group in groups) { if (group.pinned) continue; if (!group.archived && - (getFlameCounterFromGroup(group)) > 0 && + getFlameCounterFromGroup(group).counter > 0 && bestFriends.length < 6) { bestFriends.add(group); } else { diff --git a/test/features/flame_counter_test.dart b/test/features/flame_counter_test.dart index 25554e73..4f597547 100644 --- a/test/features/flame_counter_test.dart +++ b/test/features/flame_counter_test.dart @@ -16,7 +16,7 @@ Future expectFlame(DateTime time, String groupId, int counter) async { () async { final group = (await twonlyDB.groupsDao.getGroup(groupId))!; expect( - getFlameCounterFromGroup(group), + getFlameCounterFromGroup(group).counter, counter, reason: StackTrace.current.toString(), ); @@ -24,6 +24,31 @@ Future expectFlame(DateTime time, String groupId, int counter) async { ); } +Future expectFlameExpiring( + DateTime time, + String groupId, { + required int counter, + required bool isExpiring, +}) async { + await withClock( + Clock.fixed(time), + () async { + final group = (await twonlyDB.groupsDao.getGroup(groupId))!; + final result = getFlameCounterFromGroup(group); + expect( + result.counter, + counter, + reason: 'counter mismatch — ${StackTrace.current}', + ); + expect( + result.isExpiring, + isExpiring, + reason: 'isExpiring mismatch — ${StackTrace.current}', + ); + }, + ); +} + void main() { final mutex = Mutex(); var usedUserIds = 0; @@ -111,6 +136,52 @@ void main() { await expectFlame(DateTime(2026, 2, 6, 19), group.groupId, 2); await expectFlame(DateTime(2026, 3, 1, 19), group.groupId, 0); + // ── isExpiring tests ────────────────────────────────────────────────────── + // After the Feb-5/Feb-6 exchanges the flame is 2. + // On Feb-7 no exchange has happened yet → isExpiring should be true. + await expectFlameExpiring( + DateTime(2026, 2, 7, 10), + group.groupId, + counter: 2, + isExpiring: false, + ); + // Once both sides exchange on Feb-7, isExpiring becomes false. + await withClock( + Clock.fixed(DateTime(2026, 2, 7, 12)), + () async { + await incFlameCounter(group.groupId, true, DateTime(2026, 2, 7, 10)); + await incFlameCounter(group.groupId, false, DateTime(2026, 2, 7, 11)); + }, + ); + await expectFlameExpiring( + DateTime(2026, 2, 7, 13), + group.groupId, + counter: 3, + isExpiring: false, + ); + // On Feb-8, before any exchange, isExpiring is true again. + await expectFlameExpiring( + DateTime(2026, 2, 8, 9), + group.groupId, + counter: 3, + isExpiring: false, + ); + // On Feb-9, last exchange was Feb-7 — still within the two-day window, + // so the flame is still alive but expiring. + await expectFlameExpiring( + DateTime(2026, 2, 9, 9), + group.groupId, + counter: 3, + isExpiring: true, + ); + // On Feb-10, last exchange (Feb-7) is now before twoDaysAgo → flame is gone. + await expectFlameExpiring( + DateTime(2026, 2, 10, 9), + group.groupId, + counter: 0, + isExpiring: false, + ); + for (var i = 1; i <= 20; i++) { await withClock( Clock.fixed(DateTime(2026, 3, i, 1)),