mirror of
https://github.com/immich-app/immich.git
synced 2026-07-07 04:57:05 -07:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a1039261be | |||
| fbc3a8c7ec | |||
| 2f87ce154b |
@@ -10,6 +10,7 @@ import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/timeline.repository.dart';
|
||||
import 'package:immich_mobile/utils/async_mutex.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
typedef TimelineAssetSource = Future<List<BaseAsset>> Function(int index, int count);
|
||||
|
||||
@@ -89,6 +90,8 @@ class TimelineFactory {
|
||||
TimelineService(_timelineRepository.map(userIds, options, groupBy));
|
||||
}
|
||||
|
||||
const _kFirstEmissionAlarmDelay = Duration(seconds: 10);
|
||||
|
||||
class TimelineService {
|
||||
final TimelineAssetSource _assetSource;
|
||||
final TimelineBucketSource _bucketSource;
|
||||
@@ -97,6 +100,8 @@ class TimelineService {
|
||||
int _bufferOffset = 0;
|
||||
List<BaseAsset> _buffer = [];
|
||||
StreamSubscription? _bucketSubscription;
|
||||
Timer? _firstEmissionAlarm;
|
||||
final DateTime _builtAt = DateTime.now();
|
||||
|
||||
int _totalAssets = 0;
|
||||
int get totalAssets => _totalAssets;
|
||||
@@ -105,9 +110,29 @@ class TimelineService {
|
||||
: this._(assetSource: query.assetSource, bucketSource: query.bucketSource, origin: query.origin);
|
||||
|
||||
TimelineService._({required this._assetSource, required this._bucketSource, required this.origin}) {
|
||||
_firstEmissionAlarm = Timer(_kFirstEmissionAlarmDelay, () {
|
||||
Logger('TimelineProbe').severe(
|
||||
'[${origin.name}] no bucket emission ${_kFirstEmissionAlarmDelay.inSeconds}s after service build, '
|
||||
'first fetch looks stuck',
|
||||
);
|
||||
});
|
||||
_bucketSubscription = _bucketSource().listen((buckets) {
|
||||
final alarm = _firstEmissionAlarm;
|
||||
if (alarm != null) {
|
||||
_firstEmissionAlarm = null;
|
||||
if (alarm.isActive) {
|
||||
alarm.cancel();
|
||||
} else {
|
||||
Logger(
|
||||
'TimelineProbe',
|
||||
).severe('[${origin.name}] first bucket emission after ${DateTime.now().difference(_builtAt).inSeconds}s');
|
||||
}
|
||||
}
|
||||
_mutex.run(() async {
|
||||
final totalAssets = buckets.fold<int>(0, (acc, bucket) => acc + bucket.assetCount);
|
||||
Logger(
|
||||
'TimelineProbe',
|
||||
).fine('[${origin.name}] bucket emission: ${buckets.length} buckets / $totalAssets assets');
|
||||
|
||||
if (totalAssets == 0) {
|
||||
_bufferOffset = 0;
|
||||
@@ -234,6 +259,8 @@ class TimelineService {
|
||||
}
|
||||
|
||||
Future<void> dispose() async {
|
||||
_firstEmissionAlarm?.cancel();
|
||||
_firstEmissionAlarm = null;
|
||||
await _bucketSubscription?.cancel();
|
||||
_bucketSubscription = null;
|
||||
_buffer = [];
|
||||
|
||||
+14
-5
@@ -58,6 +58,17 @@ void main() async {
|
||||
await workerManagerPatch.init(dynamicSpawning: true, isolatesCount: max(Platform.numberOfProcessors - 1, 5));
|
||||
await migrateDatabaseIfNeeded(drift);
|
||||
|
||||
final viewSize = WidgetsBinding.instance.platformDispatcher.implicitView?.physicalSize;
|
||||
final launchContext =
|
||||
'prewarm=${Platform.environment['ActivePrewarm'] ?? '0'}, '
|
||||
'view=${viewSize == null ? 'none' : '${viewSize.width}x${viewSize.height}'}, '
|
||||
'lifecycle=${WidgetsBinding.instance.lifecycleState}';
|
||||
if (viewSize == null || viewSize.isEmpty) {
|
||||
Logger('LaunchProbe').warning('launch with zero view bounds: $launchContext');
|
||||
} else {
|
||||
Logger('LaunchProbe').info('launch: $launchContext');
|
||||
}
|
||||
|
||||
runApp(ProviderScope(overrides: [driftProvider.overrideWith(driftOverride(drift))], child: const MainWidget()));
|
||||
} catch (error, stack) {
|
||||
runApp(BootstrapErrorWidget(error: error.toString(), stack: stack.toString()));
|
||||
@@ -123,28 +134,26 @@ class ImmichApp extends ConsumerStatefulWidget {
|
||||
}
|
||||
|
||||
class ImmichAppState extends ConsumerState<ImmichApp> with WidgetsBindingObserver {
|
||||
final _lifecycleLog = Logger('AppLifeCycle');
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
_lifecycleLog.info(state.name);
|
||||
switch (state) {
|
||||
case AppLifecycleState.resumed:
|
||||
dPrint(() => "[APP STATE] resumed");
|
||||
ref.read(appStateProvider.notifier).handleAppResume();
|
||||
unawaited(ref.read(viewIntentHandlerProvider).onAppResumed());
|
||||
break;
|
||||
case AppLifecycleState.inactive:
|
||||
dPrint(() => "[APP STATE] inactive");
|
||||
ref.read(appStateProvider.notifier).handleAppInactivity();
|
||||
break;
|
||||
case AppLifecycleState.paused:
|
||||
dPrint(() => "[APP STATE] paused");
|
||||
ref.read(appStateProvider.notifier).handleAppPause();
|
||||
break;
|
||||
case AppLifecycleState.detached:
|
||||
dPrint(() => "[APP STATE] detached");
|
||||
ref.read(appStateProvider.notifier).handleAppDetached();
|
||||
break;
|
||||
case AppLifecycleState.hidden:
|
||||
dPrint(() => "[APP STATE] hidden");
|
||||
ref.read(appStateProvider.notifier).handleAppHidden();
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:immich_mobile/presentation/widgets/timeline/fixed/segment_builde
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
class TimelineArgs {
|
||||
final double maxWidth;
|
||||
@@ -86,13 +87,15 @@ class TimelineStateNotifier extends Notifier<TimelineState> {
|
||||
// This provider watches the buckets from the timeline service & args and serves the segments.
|
||||
// It should be used only after the timeline service and timeline args provider is overridden
|
||||
final timelineSegmentProvider = StreamProvider.autoDispose<List<Segment>>((ref) async* {
|
||||
final args = ref.watch(timelineArgsProvider);
|
||||
final columnCount = args.columnCount;
|
||||
final spacing = args.spacing;
|
||||
final availableTileWidth = args.maxWidth - (spacing * (columnCount - 1));
|
||||
// maxHeight is left out on purpose, a height-only change must not restart the bucket stream
|
||||
final (maxWidth, columnCount, spacing, groupByArg) = ref.watch(
|
||||
timelineArgsProvider.select((args) => (args.maxWidth, args.columnCount, args.spacing, args.groupBy)),
|
||||
);
|
||||
final availableTileWidth = maxWidth - (spacing * (columnCount - 1));
|
||||
final tileExtent = math.max(0, availableTileWidth) / columnCount;
|
||||
Logger('TimelineProbe').info('segments: maxWidth=$maxWidth tileExtent=$tileExtent columns=$columnCount');
|
||||
|
||||
final groupBy = args.groupBy ?? ref.watch(appConfigProvider.select((config) => config.timeline.groupAssetsBy));
|
||||
final groupBy = groupByArg ?? ref.watch(appConfigProvider.select((config) => config.timeline.groupAssetsBy));
|
||||
|
||||
final timelineService = ref.watch(timelineServiceProvider);
|
||||
yield* timelineService.watchBuckets().map((buckets) {
|
||||
|
||||
@@ -28,8 +28,9 @@ import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_sliver_app_bar.dart';
|
||||
import 'package:immich_mobile/widgets/common/mesmerizing_sliver_app_bar.dart';
|
||||
import 'package:immich_mobile/widgets/common/selection_sliver_app_bar.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
class Timeline extends StatelessWidget {
|
||||
class Timeline extends ConsumerWidget {
|
||||
const Timeline({
|
||||
super.key,
|
||||
this.topSliverWidget,
|
||||
@@ -62,35 +63,41 @@ class Timeline extends StatelessWidget {
|
||||
final Widget? loadingWidget;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final columnCount = ref.watch(appConfigProvider.select((config) => config.timeline.tilesPerRow));
|
||||
return LayoutBuilder(
|
||||
builder: (_, constraints) => ProviderScope(
|
||||
overrides: [
|
||||
timelineArgsProvider.overrideWith(
|
||||
(ref) => TimelineArgs(
|
||||
maxWidth: constraints.maxWidth,
|
||||
maxHeight: constraints.maxHeight,
|
||||
columnCount: ref.watch(appConfigProvider.select((config) => config.timeline.tilesPerRow)),
|
||||
showStorageIndicator: showStorageIndicator,
|
||||
withStack: withStack,
|
||||
groupBy: groupBy,
|
||||
builder: (_, constraints) {
|
||||
Logger('TimelineProbe').fine('layout pass ${constraints.maxWidth}x${constraints.maxHeight}');
|
||||
return ProviderScope(
|
||||
overrides: [
|
||||
// overrideWithValue keeps the scoped args in sync with the latest constraints on rebuilds,
|
||||
// a function override would stay locked to the first frame's constraints for the whole session
|
||||
timelineArgsProvider.overrideWithValue(
|
||||
TimelineArgs(
|
||||
maxWidth: constraints.maxWidth,
|
||||
maxHeight: constraints.maxHeight,
|
||||
columnCount: columnCount,
|
||||
showStorageIndicator: showStorageIndicator,
|
||||
withStack: withStack,
|
||||
groupBy: groupBy,
|
||||
),
|
||||
),
|
||||
if (readOnly) readonlyModeProvider.overrideWith(() => _AlwaysReadOnlyNotifier()),
|
||||
],
|
||||
child: _SliverTimeline(
|
||||
topSliverWidget: topSliverWidget,
|
||||
topSliverWidgetHeight: topSliverWidgetHeight,
|
||||
bottomSliverWidget: bottomSliverWidget,
|
||||
appBar: appBar,
|
||||
bottomSheet: bottomSheet,
|
||||
withScrubber: withScrubber,
|
||||
persistentBottomBar: persistentBottomBar,
|
||||
snapToMonth: snapToMonth,
|
||||
maxWidth: constraints.maxWidth,
|
||||
loadingWidget: loadingWidget,
|
||||
),
|
||||
if (readOnly) readonlyModeProvider.overrideWith(() => _AlwaysReadOnlyNotifier()),
|
||||
],
|
||||
child: _SliverTimeline(
|
||||
topSliverWidget: topSliverWidget,
|
||||
topSliverWidgetHeight: topSliverWidgetHeight,
|
||||
bottomSliverWidget: bottomSliverWidget,
|
||||
appBar: appBar,
|
||||
bottomSheet: bottomSheet,
|
||||
withScrubber: withScrubber,
|
||||
persistentBottomBar: persistentBottomBar,
|
||||
snapToMonth: snapToMonth,
|
||||
maxWidth: constraints.maxWidth,
|
||||
loadingWidget: loadingWidget,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -106,6 +113,8 @@ class _AlwaysReadOnlyNotifier extends ReadOnlyModeNotifier {
|
||||
void toggleReadonlyMode() {}
|
||||
}
|
||||
|
||||
const _kZeroWidthAlarmDelay = Duration(seconds: 5);
|
||||
|
||||
class _SliverTimeline extends ConsumerStatefulWidget {
|
||||
const _SliverTimeline({
|
||||
this.topSliverWidget,
|
||||
@@ -149,10 +158,23 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
||||
double _scaleFactor = 3.0;
|
||||
double _baseScaleFactor = 3.0;
|
||||
int? _restoreAssetIndex;
|
||||
Timer? _zeroWidthAlarm;
|
||||
DateTime? _zeroWidthSince;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
Logger('TimelineProbe').info('timeline mounted maxWidth=${widget.maxWidth}');
|
||||
if ((widget.maxWidth ?? 0) <= 0) {
|
||||
_zeroWidthSince = DateTime.now();
|
||||
_zeroWidthAlarm = Timer(_kZeroWidthAlarmDelay, () {
|
||||
if (mounted && (widget.maxWidth ?? 0) <= 0) {
|
||||
Logger(
|
||||
'TimelineProbe',
|
||||
).severe('timeline width still 0 ${_kZeroWidthAlarmDelay.inSeconds}s after mount, tiles have no extent');
|
||||
}
|
||||
});
|
||||
}
|
||||
_scrollController = ScrollController(onAttach: _restoreAssetPosition);
|
||||
_eventSubscription = EventStream.shared.listen(_onEvent);
|
||||
|
||||
@@ -168,13 +190,22 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
||||
void didUpdateWidget(covariant _SliverTimeline oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.maxWidth != oldWidget.maxWidth) {
|
||||
final asyncSegments = ref.read(timelineSegmentProvider);
|
||||
asyncSegments.whenData((segments) {
|
||||
final index = _getCurrentAssetIndex(segments);
|
||||
// Refresh to wait for new segments to be generated with the updated width before restoring the scroll position
|
||||
final _ = ref.refresh(timelineArgsProvider);
|
||||
_restoreAssetIndex = index;
|
||||
});
|
||||
Logger('TimelineProbe').info('timeline maxWidth ${oldWidget.maxWidth} -> ${widget.maxWidth}');
|
||||
if ((widget.maxWidth ?? 0) > 0 && _zeroWidthSince != null) {
|
||||
final stuckFor = DateTime.now().difference(_zeroWidthSince!);
|
||||
_zeroWidthAlarm?.cancel();
|
||||
_zeroWidthSince = null;
|
||||
if (stuckFor >= _kZeroWidthAlarmDelay) {
|
||||
Logger(
|
||||
'TimelineProbe',
|
||||
).severe('timeline width recovered to ${widget.maxWidth} after ${stuckFor.inSeconds}s at 0');
|
||||
}
|
||||
}
|
||||
// The updated args already regenerate the segments, only remember the scroll position to restore it afterwards
|
||||
final segments = ref.read(timelineSegmentProvider).valueOrNull;
|
||||
if (segments != null && _scrollController.hasClients) {
|
||||
_restoreAssetIndex = _getCurrentAssetIndex(segments);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -246,6 +277,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_zeroWidthAlarm?.cancel();
|
||||
_scrollController.dispose();
|
||||
_eventSubscription?.cancel();
|
||||
super.dispose();
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/timeline.repository.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart';
|
||||
@@ -17,8 +19,12 @@ final timelineArgsProvider = Provider.autoDispose<TimelineArgs>(
|
||||
final timelineServiceProvider = Provider<TimelineService>(
|
||||
(ref) {
|
||||
final timelineUsers = ref.watch(timelineUsersProvider).valueOrNull ?? [];
|
||||
Logger('TimelineProbe').info('main timeline service built with ${timelineUsers.length} users');
|
||||
final timelineService = ref.watch(timelineFactoryProvider).main(timelineUsers);
|
||||
ref.onDispose(timelineService.dispose);
|
||||
ref.onDispose(() {
|
||||
Logger('TimelineProbe').info('main timeline service disposed');
|
||||
timelineService.dispose();
|
||||
});
|
||||
return timelineService;
|
||||
},
|
||||
// Empty dependencies to inform the framework that this provider
|
||||
@@ -39,5 +45,14 @@ final timelineUsersProvider = StreamProvider<List<String>>((ref) {
|
||||
return Stream.value([]);
|
||||
}
|
||||
|
||||
return ref.watch(timelineRepositoryProvider).watchTimelineUserIds(currentUserId);
|
||||
// Drift re-emits a fresh but content-identical list on unrelated table updates,
|
||||
// which would dispose and rebuild the timeline service mid-load
|
||||
return ref
|
||||
.watch(timelineRepositoryProvider)
|
||||
.watchTimelineUserIds(currentUserId)
|
||||
.distinct(const ListEquality<String>().equals)
|
||||
.map((users) {
|
||||
Logger('TimelineProbe').info('timeline users emission: ${users.length}');
|
||||
return users;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:fake_async/fake_async.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/domain/models/timeline.model.dart';
|
||||
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
void main() {
|
||||
late List<LogRecord> records;
|
||||
late StreamSubscription<LogRecord> logSubscription;
|
||||
|
||||
bool isNoEmissionAlarm(LogRecord r) => r.level == Level.SEVERE && r.message.contains('no bucket emission');
|
||||
|
||||
setUp(() {
|
||||
records = [];
|
||||
Logger.root.level = Level.INFO;
|
||||
logSubscription = Logger.root.onRecord.listen(records.add);
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
Logger.root.level = Level.OFF;
|
||||
return logSubscription.cancel();
|
||||
});
|
||||
|
||||
test('logs a severe signature when the first bucket emission never arrives', () {
|
||||
fakeAsync((async) {
|
||||
final ctrl = StreamController<List<Bucket>>();
|
||||
final sut = TimelineService((
|
||||
assetSource: (index, count) async => const [],
|
||||
bucketSource: () => ctrl.stream,
|
||||
origin: TimelineOrigin.main,
|
||||
));
|
||||
|
||||
async.elapse(const Duration(seconds: 9));
|
||||
expect(records.where(isNoEmissionAlarm), isEmpty);
|
||||
|
||||
async.elapse(const Duration(seconds: 2));
|
||||
expect(records.where(isNoEmissionAlarm), isNotEmpty);
|
||||
|
||||
sut.dispose();
|
||||
ctrl.close();
|
||||
});
|
||||
});
|
||||
|
||||
test('stays quiet when the first bucket emission arrives in time', () {
|
||||
fakeAsync((async) {
|
||||
final ctrl = StreamController<List<Bucket>>();
|
||||
final sut = TimelineService((
|
||||
assetSource: (index, count) async => const [],
|
||||
bucketSource: () => ctrl.stream,
|
||||
origin: TimelineOrigin.main,
|
||||
));
|
||||
|
||||
ctrl.add(const []);
|
||||
async.elapse(const Duration(seconds: 15));
|
||||
expect(records.where(isNoEmissionAlarm), isEmpty);
|
||||
|
||||
sut.dispose();
|
||||
ctrl.close();
|
||||
});
|
||||
});
|
||||
|
||||
test('stays quiet when the service is disposed before the alarm', () {
|
||||
fakeAsync((async) {
|
||||
final ctrl = StreamController<List<Bucket>>();
|
||||
final sut = TimelineService((
|
||||
assetSource: (index, count) async => const [],
|
||||
bucketSource: () => ctrl.stream,
|
||||
origin: TimelineOrigin.main,
|
||||
));
|
||||
|
||||
async.elapse(const Duration(seconds: 2));
|
||||
sut.dispose();
|
||||
async.elapse(const Duration(seconds: 15));
|
||||
expect(records.where(isNoEmissionAlarm), isEmpty);
|
||||
|
||||
ctrl.close();
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/config/app_config.dart';
|
||||
import 'package:immich_mobile/domain/models/timeline.model.dart';
|
||||
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
// A first fetch that never delivers - the state a suspended or storm-starved
|
||||
// bucket watch is stuck in when the timeline mounts on a zero-sized first frame
|
||||
class _FrozenBucketService implements TimelineService {
|
||||
final _ctrl = StreamController<List<Bucket>>.broadcast();
|
||||
|
||||
@override
|
||||
Stream<List<Bucket>> Function() get watchBuckets =>
|
||||
() => _ctrl.stream;
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
class _EmptyBucketService implements TimelineService {
|
||||
@override
|
||||
Stream<List<Bucket>> Function() get watchBuckets =>
|
||||
() => Stream.value(const []);
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('timeline args follow constraints after a zero-sized first frame while buckets are still loading', (
|
||||
tester,
|
||||
) async {
|
||||
tester.view.physicalSize = Size.zero;
|
||||
tester.view.devicePixelRatio = 3.0;
|
||||
addTearDown(tester.view.reset);
|
||||
|
||||
TimelineArgs? probed;
|
||||
final probe = Consumer(
|
||||
builder: (_, ref, __) {
|
||||
probed = ref.watch(timelineArgsProvider);
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
timelineServiceProvider.overrideWithValue(_FrozenBucketService()),
|
||||
appConfigProvider.overrideWithValue(const AppConfig()),
|
||||
],
|
||||
child: MaterialApp(home: Timeline(withScrubber: false, readOnly: true, loadingWidget: probe)),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
expect(probed, isNotNull);
|
||||
expect(probed!.maxWidth, 0.0);
|
||||
|
||||
tester.view.physicalSize = const Size(1206, 2622);
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
expect(
|
||||
probed!.maxWidth,
|
||||
402.0,
|
||||
reason: 'args locked to the zero-sized first frame leave the timeline blank for the whole session',
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('timeline args follow constraints after a zero-sized first frame once buckets resolve', (tester) async {
|
||||
tester.view.physicalSize = Size.zero;
|
||||
tester.view.devicePixelRatio = 3.0;
|
||||
addTearDown(tester.view.reset);
|
||||
|
||||
TimelineArgs? probed;
|
||||
final probe = SliverToBoxAdapter(
|
||||
child: Consumer(
|
||||
builder: (_, ref, __) {
|
||||
probed = ref.watch(timelineArgsProvider);
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
timelineServiceProvider.overrideWithValue(_EmptyBucketService()),
|
||||
appConfigProvider.overrideWithValue(const AppConfig()),
|
||||
],
|
||||
child: MaterialApp(
|
||||
home: Timeline(
|
||||
withScrubber: false,
|
||||
readOnly: true,
|
||||
appBar: const SliverToBoxAdapter(child: SizedBox.shrink()),
|
||||
topSliverWidget: probe,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
tester.view.physicalSize = const Size(1206, 2622);
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
expect(probed, isNotNull);
|
||||
expect(probed!.maxWidth, 402.0);
|
||||
});
|
||||
|
||||
testWidgets('a timeline stuck at zero width logs a severe blank timeline signature after 5s', (tester) async {
|
||||
final records = <LogRecord>[];
|
||||
Logger.root.level = Level.INFO;
|
||||
addTearDown(() => Logger.root.level = Level.OFF);
|
||||
final logSubscription = Logger.root.onRecord.listen(records.add);
|
||||
addTearDown(logSubscription.cancel);
|
||||
|
||||
bool isZeroWidthAlarm(LogRecord r) => r.level == Level.SEVERE && r.message.contains('width still 0');
|
||||
|
||||
tester.view.physicalSize = Size.zero;
|
||||
tester.view.devicePixelRatio = 3.0;
|
||||
addTearDown(tester.view.reset);
|
||||
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
timelineServiceProvider.overrideWithValue(_FrozenBucketService()),
|
||||
appConfigProvider.overrideWithValue(const AppConfig()),
|
||||
],
|
||||
child: const MaterialApp(home: Timeline(withScrubber: false, readOnly: true)),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pump(const Duration(seconds: 2));
|
||||
expect(records.where(isZeroWidthAlarm), isEmpty);
|
||||
|
||||
await tester.pump(const Duration(seconds: 4));
|
||||
expect(records.where(isZeroWidthAlarm), isNotEmpty);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user