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

View file

@ -241,7 +241,7 @@ class GroupsDao extends DatabaseAccessor<TwonlyDB> with _$GroupsDaoMixin {
)..where((t) => t.groupId.equals(groupId))).getSingleOrNull();
}
Stream<int> 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<TwonlyDB> with _$GroupsDaoMixin {
u.lastMessageSend.isNotNull(),
))
.watchSingleOrNull()
.asyncMap(getFlameCounterFromGroup);
.map(getFlameCounterFromGroup);
}
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
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<void> 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<void> 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)),
);

View file

@ -23,8 +23,9 @@ class FlameCounterWidget extends StatefulWidget {
class _FlameCounterWidgetState extends State<FlameCounterWidget> {
int flameCounter = 0;
bool isBestFriend = false;
bool isExpiring = false;
StreamSubscription<int>? flameCounterSub;
StreamSubscription<({int counter, bool isExpiring})>? flameCounterSub;
@override
void initState() {
@ -52,10 +53,11 @@ class _FlameCounterWidgetState extends State<FlameCounterWidget> {
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<FlameCounterWidget> {
flameEmoji = '🎂';
}
// Override with hourglass when the flame is about to expire
if (isExpiring) flameEmoji = '';
return Row(
children: [
if (widget.prefix) const SizedBox(width: 5),

View file

@ -93,7 +93,7 @@ class _ShareImageView extends State<ShareImageView> {
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<ShareImageView> {
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 {

View file

@ -16,7 +16,7 @@ Future<void> 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<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() {
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)),