manual_suspense 1.0.0
manual_suspense: ^1.0.0 copied to clipboard
A programmatically controlled suspense widget inspired by React Suspense. Manage loading states declaratively with fine-grained manual control.
import 'package:flutter/material.dart';
import 'package:manual_suspense/manual_suspense.dart';
void main() => runApp(MyApp());
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Manual Suspense Example',
theme: ThemeData(primarySwatch: Colors.blue, useMaterial3: true),
home: HomePage(),
);
}
}
class HomePage extends StatefulWidget {
const HomePage({super.key});
@override
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
List<String> _data = [];
int _loadCount = 0;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Manual Suspense Demo'), elevation: 2),
body: RefreshIndicator(
onRefresh: _loadData,
child: Column(
children: [
Padding(
padding: EdgeInsets.all(16),
child: Text(
'Pull down to refresh or tap the button below',
style: Theme.of(context).textTheme.titleSmall,
textAlign: TextAlign.center,
),
),
Expanded(
child: ManualSuspense(
suspenseKey: 'fetch-data',
fallback: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text(
'Loading data...',
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
),
],
),
),
child: _data.isEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.inbox_outlined,
size: 64,
color: Colors.grey[400],
),
SizedBox(height: 16),
Text(
'No data loaded yet',
style: TextStyle(
fontSize: 18,
color: Colors.grey[600],
),
),
SizedBox(height: 8),
Text(
'Tap the button to load data',
style: TextStyle(
fontSize: 14,
color: Colors.grey[500],
),
),
],
),
)
: ListView.builder(
padding: EdgeInsets.all(8),
itemCount: _data.length,
itemBuilder: (context, index) => Card(
child: ListTile(
leading: CircleAvatar(child: Text('${index + 1}')),
title: Text(_data[index]),
subtitle: Text('Loaded $_loadCount time(s)'),
trailing: Icon(Icons.chevron_right),
),
),
),
),
),
],
),
),
floatingActionButton: FloatingActionButton.extended(
onPressed: _loadData,
icon: Icon(Icons.refresh),
label: Text('Load Data'),
),
);
}
Future<void> _loadData() async {
// Start the suspense state
SuspenseController.start('fetch-data');
try {
// Simulate API call with delay
await Future.delayed(Duration(seconds: 2));
// Generate mock data
setState(() {
_loadCount++;
_data = List.generate(20, (i) => 'Item ${i + 1} - Load #$_loadCount');
});
// Complete the suspense state
SuspenseController.complete('fetch-data');
// Show success message
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Data loaded successfully!'),
duration: Duration(seconds: 2),
behavior: SnackBarBehavior.floating,
),
);
}
} catch (e) {
// Complete suspense even on error
SuspenseController.complete('fetch-data');
// Show error message
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Error: $e'),
backgroundColor: Colors.red,
duration: Duration(seconds: 3),
behavior: SnackBarBehavior.floating,
),
);
}
}
}
@override
void dispose() {
// Optional: cleanup when leaving the page
SuspenseController.remove('fetch-data');
super.dispose();
}
}