From a7fcafadda0b5516bcb7084537a719b5983455b5 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 9 May 2026 11:38:42 -0400 Subject: [PATCH] feat(flutter): DelayedLoading wrapper for skeleton-with-delay Renders nothing for the first [delay] (default 200ms) of sustained loading, then renders the [whileDelayed] child until loading completes. Mirrors the web useDelayed hook so a fast network response doesn't flash a skeleton. Used in the next commit's home screen rewrite; otherwise unused this slice. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../lib/shared/delayed_loading.dart | 66 ++++++++++++++++ .../test/shared/delayed_loading_test.dart | 76 +++++++++++++++++++ 2 files changed, 142 insertions(+) create mode 100644 flutter_client/lib/shared/delayed_loading.dart create mode 100644 flutter_client/test/shared/delayed_loading_test.dart diff --git a/flutter_client/lib/shared/delayed_loading.dart b/flutter_client/lib/shared/delayed_loading.dart new file mode 100644 index 00000000..01b7eebc --- /dev/null +++ b/flutter_client/lib/shared/delayed_loading.dart @@ -0,0 +1,66 @@ +import 'dart:async'; +import 'package:flutter/widgets.dart'; + +/// Renders [whileDelayed] only after [isLoading] has been true +/// continuously for [delay], then renders [whenReady] once loading +/// completes. Mirrors the web `useDelayed` hook: a brief loading flash +/// doesn't trigger a skeleton; sustained loading does. +/// +/// Once [isLoading] flips back to false, the timer resets. +class DelayedLoading extends StatefulWidget { + const DelayedLoading({ + super.key, + required this.isLoading, + required this.whileDelayed, + required this.whenReady, + this.delay = const Duration(milliseconds: 200), + }); + + final bool isLoading; + final Widget whileDelayed; + final Widget whenReady; + final Duration delay; + + @override + State createState() => _DelayedLoadingState(); +} + +class _DelayedLoadingState extends State { + Timer? _timer; + bool _delayPassed = false; + + @override + void initState() { + super.initState(); + _maybeStart(); + } + + @override + void didUpdateWidget(DelayedLoading old) { + super.didUpdateWidget(old); + if (widget.isLoading != old.isLoading) { + _timer?.cancel(); + _delayPassed = false; + _maybeStart(); + } + } + + void _maybeStart() { + if (!widget.isLoading) return; + _timer = Timer(widget.delay, () { + if (mounted) setState(() => _delayPassed = true); + }); + } + + @override + void dispose() { + _timer?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (!widget.isLoading) return widget.whenReady; + return _delayPassed ? widget.whileDelayed : const SizedBox.shrink(); + } +} diff --git a/flutter_client/test/shared/delayed_loading_test.dart b/flutter_client/test/shared/delayed_loading_test.dart new file mode 100644 index 00000000..2273633d --- /dev/null +++ b/flutter_client/test/shared/delayed_loading_test.dart @@ -0,0 +1,76 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:minstrel/shared/delayed_loading.dart'; + +void main() { + testWidgets('renders nothing for the first 100ms of loading', (tester) async { + await tester.pumpWidget(const MaterialApp( + home: DelayedLoading( + isLoading: true, + delay: Duration(milliseconds: 200), + whileDelayed: Text('LOADING'), + whenReady: Text('READY'), + ), + )); + await tester.pump(const Duration(milliseconds: 100)); + expect(find.text('LOADING'), findsNothing); + expect(find.text('READY'), findsNothing); + }); + + testWidgets('renders whileDelayed after delay elapses', (tester) async { + await tester.pumpWidget(const MaterialApp( + home: DelayedLoading( + isLoading: true, + delay: Duration(milliseconds: 200), + whileDelayed: Text('LOADING'), + whenReady: Text('READY'), + ), + )); + await tester.pump(const Duration(milliseconds: 250)); + expect(find.text('LOADING'), findsOneWidget); + }); + + testWidgets('renders whenReady when not loading', (tester) async { + await tester.pumpWidget(const MaterialApp( + home: DelayedLoading( + isLoading: false, + whileDelayed: Text('LOADING'), + whenReady: Text('READY'), + ), + )); + expect(find.text('READY'), findsOneWidget); + }); + + testWidgets('resets timer when isLoading flips false then true', (tester) async { + await tester.pumpWidget(const MaterialApp( + home: DelayedLoading( + isLoading: true, + delay: Duration(milliseconds: 200), + whileDelayed: Text('LOADING'), + whenReady: Text('READY'), + ), + )); + await tester.pump(const Duration(milliseconds: 150)); + // Switch to not-loading, then back to loading. + await tester.pumpWidget(const MaterialApp( + home: DelayedLoading( + isLoading: false, + whileDelayed: Text('LOADING'), + whenReady: Text('READY'), + ), + )); + await tester.pumpWidget(const MaterialApp( + home: DelayedLoading( + isLoading: true, + delay: Duration(milliseconds: 200), + whileDelayed: Text('LOADING'), + whenReady: Text('READY'), + ), + )); + // Original 150ms shouldn't carry over — at 100ms past restart + // we should still see nothing. + await tester.pump(const Duration(milliseconds: 100)); + expect(find.text('LOADING'), findsNothing); + }); +}