Show instead of the flame icon when it is about to expire

This commit is contained in:
otsmr 2026-04-29 16:28:52 +02:00
parent a015cb2cb8
commit 9289def783
6 changed files with 102 additions and 18 deletions

View file

@ -5,6 +5,7 @@
- New: Feature to find friends without a phone number - New: Feature to find friends without a phone number
- New: The verification state is now transferred to the scanned user - New: The verification state is now transferred to the scanned user
- New: Registration setup to configure the most important configurations - 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 - Improved: FAQ is now in the app rather than opening in the browser
- Fix: Many smaller issues - Fix: Many smaller issues

View file

@ -241,7 +241,7 @@ class GroupsDao extends DatabaseAccessor<TwonlyDB> with _$GroupsDaoMixin {
)..where((t) => t.groupId.equals(groupId))).getSingleOrNull(); )..where((t) => t.groupId.equals(groupId))).getSingleOrNull();
} }
Stream<int> watchFlameCounter(String groupId) { Stream<({int counter, bool isExpiring})> watchFlameCounter(String groupId) {
return (select(groups)..where( return (select(groups)..where(
(u) => (u) =>
u.groupId.equals(groupId) & u.groupId.equals(groupId) &
@ -249,7 +249,7 @@ class GroupsDao extends DatabaseAccessor<TwonlyDB> with _$GroupsDaoMixin {
u.lastMessageSend.isNotNull(), u.lastMessageSend.isNotNull(),
)) ))
.watchSingleOrNull() .watchSingleOrNull()
.asyncMap(getFlameCounterFromGroup); .map(getFlameCounterFromGroup);
} }
Future<List<Group>> getAllDirectChats() { Future<List<Group>> getAllDirectChats() {

View file

@ -32,16 +32,17 @@ Future<void> 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 // 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( await sendCipherTextToGroup(
group.groupId, group.groupId,
EncryptedContent( EncryptedContent(
flameSync: EncryptedContent_FlameSync( flameSync: EncryptedContent_FlameSync(
flameCounter: Int64(flameCounter), flameCounter: Int64(flameResult.counter),
lastFlameCounterChange: Int64( lastFlameCounterChange: Int64(
group.lastFlameCounterChange!.millisecondsSinceEpoch, group.lastFlameCounterChange!.millisecondsSinceEpoch,
), ),
@ -60,12 +61,13 @@ Future<void> syncFlameCounters({String? forceForGroup}) async {
} }
} }
int getFlameCounterFromGroup(Group? group) { ({int counter, bool isExpiring}) getFlameCounterFromGroup(Group? group) {
if (group == null) return 0; const zero = (counter: 0, isExpiring: false);
if (group == null) return zero;
if (group.lastMessageSend == null || if (group.lastMessageSend == null ||
group.lastMessageReceived == null || group.lastMessageReceived == null ||
group.lastFlameCounterChange == null) { group.lastFlameCounterChange == null) {
return 0; 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);
@ -74,9 +76,14 @@ int getFlameCounterFromGroup(Group? group) {
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)) {
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 { } else {
return 0; return zero;
} }
} }
@ -190,9 +197,9 @@ Future<void> incFlameCounter(
} }
bool isItPossibleToRestoreFlames(Group group) { bool isItPossibleToRestoreFlames(Group group) {
final flameCounter = getFlameCounterFromGroup(group); final flameResult = getFlameCounterFromGroup(group);
return group.maxFlameCounter > 2 && return group.maxFlameCounter > 2 &&
flameCounter < group.maxFlameCounter && flameResult.counter < group.maxFlameCounter &&
group.maxFlameCounterFrom!.isAfter( group.maxFlameCounterFrom!.isAfter(
clock.now().subtract(const Duration(days: 7)), clock.now().subtract(const Duration(days: 7)),
); );

View file

@ -23,8 +23,9 @@ class FlameCounterWidget extends StatefulWidget {
class _FlameCounterWidgetState extends State<FlameCounterWidget> { class _FlameCounterWidgetState extends State<FlameCounterWidget> {
int flameCounter = 0; int flameCounter = 0;
bool isBestFriend = false; bool isBestFriend = false;
bool isExpiring = false;
StreamSubscription<int>? flameCounterSub; StreamSubscription<({int counter, bool isExpiring})>? flameCounterSub;
@override @override
void initState() { void initState() {
@ -52,10 +53,11 @@ class _FlameCounterWidgetState extends State<FlameCounterWidget> {
userService.currentUser.myBestFriendGroupId == groupId && userService.currentUser.myBestFriendGroupId == groupId &&
group.alsoBestFriend; group.alsoBestFriend;
final stream = twonlyDB.groupsDao.watchFlameCounter(groupId); final stream = twonlyDB.groupsDao.watchFlameCounter(groupId);
flameCounterSub = stream.listen((counter) { flameCounterSub = stream.listen((result) {
if (mounted) { if (mounted) {
setState(() { setState(() {
flameCounter = counter; flameCounter = result.counter;
isExpiring = result.isExpiring;
}); });
} }
}); });
@ -75,6 +77,9 @@ class _FlameCounterWidgetState extends State<FlameCounterWidget> {
flameEmoji = '🎂'; flameEmoji = '🎂';
} }
// Override with hourglass when the flame is about to expire
if (isExpiring) flameEmoji = '';
return Row( return Row(
children: [ children: [
if (widget.prefix) const SizedBox(width: 5), if (widget.prefix) const SizedBox(width: 5),

View file

@ -93,7 +93,7 @@ class _ShareImageView extends State<ShareImageView> {
final flameComparison = getFlameCounterFromGroup( final flameComparison = getFlameCounterFromGroup(
b, b,
).compareTo(getFlameCounterFromGroup(a)); ).counter.compareTo(getFlameCounterFromGroup(a).counter);
if (flameComparison != 0) { if (flameComparison != 0) {
return flameComparison; // Sort by flameCounter in descending order return flameComparison; // Sort by flameCounter in descending order
} }
@ -111,7 +111,7 @@ class _ShareImageView extends State<ShareImageView> {
for (final group in groups) { for (final group in groups) {
if (group.pinned) continue; if (group.pinned) continue;
if (!group.archived && if (!group.archived &&
(getFlameCounterFromGroup(group)) > 0 && getFlameCounterFromGroup(group).counter > 0 &&
bestFriends.length < 6) { bestFriends.length < 6) {
bestFriends.add(group); bestFriends.add(group);
} else { } else {

View file

@ -16,7 +16,7 @@ Future<void> expectFlame(DateTime time, String groupId, int counter) async {
() async { () async {
final group = (await twonlyDB.groupsDao.getGroup(groupId))!; final group = (await twonlyDB.groupsDao.getGroup(groupId))!;
expect( expect(
getFlameCounterFromGroup(group), getFlameCounterFromGroup(group).counter,
counter, counter,
reason: StackTrace.current.toString(), reason: StackTrace.current.toString(),
); );
@ -24,6 +24,31 @@ Future<void> expectFlame(DateTime time, String groupId, int counter) async {
); );
} }
Future<void> 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() { void main() {
final mutex = Mutex(); final mutex = Mutex();
var usedUserIds = 0; var usedUserIds = 0;
@ -111,6 +136,52 @@ void main() {
await expectFlame(DateTime(2026, 2, 6, 19), group.groupId, 2); await expectFlame(DateTime(2026, 2, 6, 19), group.groupId, 2);
await expectFlame(DateTime(2026, 3, 1, 19), group.groupId, 0); 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++) { for (var i = 1; i <= 20; i++) {
await withClock( await withClock(
Clock.fixed(DateTime(2026, 3, i, 1)), Clock.fixed(DateTime(2026, 3, i, 1)),