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) <noreply@anthropic.com>
This commit is contained in:
@@ -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<DelayedLoading> createState() => _DelayedLoadingState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DelayedLoadingState extends State<DelayedLoading> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user