improved performance and battery usage on older devices

This commit is contained in:
2026-09-25 23:58:08 +02:00
parent ed8e52f475
commit 56a497985b
70 changed files with 1631 additions and 621 deletions
+7
View File
@@ -0,0 +1,7 @@
import 'dart:convert';
/// Content equality for API models that define no `==` but serialise to JSON.
/// Comparing the encoded strings is cheaper than a deep map walk and treats
/// both sides identically (same `toJson`, same field order).
bool sameJson(Object? a, Object? b) =>
a != null && b != null && jsonEncode(a) == jsonEncode(b);
+75
View File
@@ -0,0 +1,75 @@
import 'package:rrule/rrule.dart';
/// Memoised RRULE expansion.
///
/// `RecurrenceRule.getInstances` always starts at the series anchor, so asking
/// for one week of a daily event created two years ago walks ~750 occurrences
/// (tens of ms each time on older phones) — and the calendar asks once per
/// week and event, the home widget twice per publish. Here each series keeps
/// its parsed rule, the occurrences found so far and the live iterator, so
/// later queries only extend the expansion instead of restarting it.
class RecurrenceOccurrences {
RecurrenceOccurrences._();
static const int _maxSeries = 64;
static final Map<(String, DateTime), _Series> _cache = {};
/// UTC occurrences of [rule] anchored at [anchorUtc] in
/// `[fromUtc, toUtc)`, in ascending order. Throws like
/// [RecurrenceRule.fromString] for an invalid rule.
static List<DateTime> between(
String rule,
DateTime anchorUtc,
DateTime fromUtc,
DateTime toUtc,
) {
final key = (rule, anchorUtc);
var series = _cache.remove(key);
series ??= _Series(RecurrenceRule.fromString(rule), anchorUtc);
// Re-insert to keep the map in least-recently-used order.
_cache[key] = series;
if (_cache.length > _maxSeries) _cache.remove(_cache.keys.first);
return series.between(fromUtc, toUtc);
}
/// Drops all memoised series (tests).
static void clear() => _cache.clear();
}
class _Series {
final Iterator<DateTime> _iterator;
final List<DateTime> _found = [];
bool _exhausted = false;
_Series(RecurrenceRule rule, DateTime anchorUtc)
: _iterator = rule.getInstances(start: anchorUtc).iterator;
List<DateTime> between(DateTime fromUtc, DateTime toUtc) {
while (!_exhausted && (_found.isEmpty || _found.last.isBefore(toUtc))) {
if (_iterator.moveNext()) {
_found.add(_iterator.current);
} else {
_exhausted = true;
}
}
final start = _lowerBound(fromUtc);
return [
for (var i = start; i < _found.length && _found[i].isBefore(toUtc); i++)
_found[i],
];
}
int _lowerBound(DateTime value) {
var low = 0;
var high = _found.length;
while (low < high) {
final mid = (low + high) >> 1;
if (_found[mid].isBefore(value)) {
low = mid + 1;
} else {
high = mid;
}
}
return low;
}
}
+25
View File
@@ -0,0 +1,25 @@
import 'package:flutter/widgets.dart';
/// Wraps [provider] so it decodes at most [scale] × the screen's longest
/// physical side (capped at [maxPx]). Camera-sized photos otherwise decode at
/// full resolution — 50–200 MB each — which gets the app killed on
/// low-memory devices.
ImageProvider screenBoundImage(
BuildContext context,
ImageProvider provider, {
double scale = 1,
int maxPx = 4096,
}) {
final bound =
(MediaQuery.sizeOf(context).longestSide *
MediaQuery.devicePixelRatioOf(context) *
scale)
.round()
.clamp(1, maxPx);
return ResizeImage(
provider,
width: bound,
height: bound,
policy: ResizeImagePolicy.fit,
);
}
+21
View File
@@ -0,0 +1,21 @@
import '../model/account_data.dart';
/// Joins concurrent calls into the one already running for the same account
/// session. A run left over from a signed-out session never blocks the next
/// account's first call.
class SessionSingleFlight {
Future<void>? _running;
int? _epoch;
Future<void> run(Future<void> Function() action) {
final epoch = AccountData().sessionEpoch;
final running = _running;
if (running != null && _epoch == epoch) return running;
_epoch = epoch;
late final Future<void> current;
current = action().whenComplete(() {
if (identical(_running, current)) _running = null;
});
return _running = current;
}
}