app_checkpoint 1.0.0
app_checkpoint: ^1.0.0 copied to clipboard
A Flutter package for checkpointing and restoring app state.
import 'dart:io';
import 'package:app_checkpoint/checkpoint.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:path_provider/path_provider.dart';
void main() {
assert(() {
StateSnapshot.enable();
return true;
}());
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) => GestureDetector(
onTap: FocusScope.of(context).unfocus,
child: MaterialApp(
title: 'Checkpoint Example',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.transparent),
appBarTheme: const AppBarTheme(backgroundColor: Colors.black12),
scaffoldBackgroundColor: Colors.black12,
useMaterial3: true,
inputDecorationTheme: const InputDecorationTheme(
labelStyle: TextStyle(color: Colors.white),
enabledBorder: OutlineInputBorder(
borderSide: BorderSide(color: Colors.white24),
),
focusedBorder: OutlineInputBorder(
borderSide: BorderSide(color: Colors.white),
),
),
),
home: const CheckpointExample(title: 'Checkpoint Example'),
),
);
}
/// Example state classes
class UserState {
int age = 0;
String name = '';
String email = '';
Map<String, dynamic> toJson() => {'name': name, 'age': age, 'email': email};
void restore(Map<String, dynamic> json) {
name = json['name'] as String? ?? '';
age = json['age'] as int? ?? 0;
email = json['email'] as String? ?? '';
}
}
class SettingsState {
String theme = 'dark';
String language = 'en';
bool notificationsEnabled = true;
Map<String, dynamic> toJson() => {
'theme': theme,
'language': language,
'notificationsEnabled': notificationsEnabled,
};
void restore(Map<String, dynamic> json) {
theme = json['theme'] as String? ?? 'dark';
language = json['language'] as String? ?? 'en';
notificationsEnabled = json['notificationsEnabled'] as bool? ?? true;
}
}
class CounterState {
int count = 0;
Map<String, dynamic> toJson() => {'count': count};
void restore(Map<String, dynamic> json) => count = json['count'] as int? ?? 0;
}
class CheckpointExample extends StatefulWidget {
const CheckpointExample({required this.title, super.key});
final String title;
@override
State<CheckpointExample> createState() => _CheckpointExampleState();
}
class _CheckpointExampleState extends State<CheckpointExample> {
// State instances
final UserState _userState = UserState();
final SettingsState _settingsState = SettingsState();
final CounterState _counterState = CounterState();
// Storage for persistence
SnapshotStorage? _storage;
// UI state
String _lastSnapshotJson = '';
String _statusMessage = 'Ready';
bool _isLoading = false;
List<String> _savedSnapshotKeys = [];
// New v1 features UI state
final Map<String, bool> _selectedKeysForCapture = {
'user_state': true,
'settings_state': true,
'counter_state': true,
};
final Map<String, bool> _selectedKeysForRestore = {
'user_state': true,
'settings_state': true,
'counter_state': true,
};
final TextEditingController _bugIdController = TextEditingController();
final TextEditingController _reporterController = TextEditingController();
bool _continueOnError = false;
SnapshotValidationResult? _lastValidationResult;
@override
void initState() {
super.initState();
_setupCheckpoint();
_initializeStorage();
}
Future<void> _initializeStorage() async {
// Use SharedPreferences for web, FileStorage for other platforms
try {
if (kIsWeb) {
_storage = SharedPreferencesSnapshotStorage();
} else {
final dir = await getApplicationDocumentsDirectory();
final checkpointDir = Directory('${dir.path}/checkpoints');
_storage = FileSnapshotStorage(directory: checkpointDir);
}
await _loadSavedSnapshots();
} catch (e) {
// Fallback to memory storage if storage fails
debugPrint('Failed to initialize storage: $e');
_storage = MemorySnapshotStorage();
await _loadSavedSnapshots();
}
}
Future<void> _loadSavedSnapshots() async {
if (_storage == null) {
return;
}
final keys = await _storage!.listKeys();
if (mounted) {
setState(() => _savedSnapshotKeys = keys);
}
}
void _setupCheckpoint() {
// Register state contributors
StateSnapshot.register<UserState>(
key: 'user_state',
exporter: () => _userState.toJson(),
importer: (json) {
_userState.restore(json);
setState(() {}); // Update UI
},
);
StateSnapshot.register<SettingsState>(
key: 'settings_state',
exporter: () => _settingsState.toJson(),
importer: (json) {
_settingsState.restore(json);
setState(() {}); // Update UI
},
);
StateSnapshot.register<CounterState>(
key: 'counter_state',
exporter: () => _counterState.toJson(),
importer: (json) {
_counterState.restore(json);
setState(() {}); // Update UI
},
);
// Setup restore hooks
StateSnapshot.onBeforeRestore.add(
() => setState(() => _statusMessage = 'Restoring state...'),
);
StateSnapshot.onAfterRestore.add(
() => setState(() => _statusMessage = 'State restored successfully!'),
);
}
Future<void> _captureSnapshot() async {
setState(() {
_isLoading = true;
_statusMessage = 'Capturing snapshot...';
});
try {
// Build metadata if provided
Map<String, dynamic>? metadata;
if (_bugIdController.text.isNotEmpty ||
_reporterController.text.isNotEmpty) {
metadata = {};
if (_bugIdController.text.isNotEmpty) {
metadata['bug_id'] = _bugIdController.text;
}
if (_reporterController.text.isNotEmpty) {
metadata['reporter'] = _reporterController.text;
}
metadata['timestamp'] = DateTime.now().toIso8601String();
}
// Get selected keys for capture
final selectedKeys = _selectedKeysForCapture.entries
.where((e) => e.value)
.map((e) => e.key)
.toList();
// Capture snapshot with metadata and selective keys
final snapshot = await StateSnapshot.capture(
appVersion: '1.0.0',
metadata: metadata,
keys: selectedKeys.isEmpty ? null : selectedKeys,
);
final jsonString = snapshot.toJsonString(pretty: true);
// Save to storage with timestamp-based key
if (_storage != null) {
final timestamp = DateTime.now().toIso8601String().replaceAll(':', '-');
final key = 'snapshot_$timestamp';
await _storage!.save(snapshot, key);
}
setState(() {
_lastSnapshotJson = jsonString;
_statusMessage = 'Snapshot captured and saved!';
_isLoading = false;
});
await _loadSavedSnapshots();
} catch (e) {
setState(() {
_statusMessage = 'Error: $e';
_isLoading = false;
});
}
}
Future<void> _restoreSnapshot([String? key]) async {
setState(() {
_isLoading = true;
_statusMessage = 'Restoring snapshot...';
_lastValidationResult = null;
});
try {
Snapshot? snapshot;
if (key != null && _storage != null) {
// Load from storage
snapshot = await _storage!.load(key);
if (snapshot == null) {
setState(() {
_statusMessage = 'Snapshot not found: $key';
_isLoading = false;
});
return;
}
} else if (_lastSnapshotJson.isNotEmpty) {
// Use current JSON
final selectedKeys = _selectedKeysForRestore.entries
.where((e) => e.value)
.map((e) => e.key)
.toList();
await StateSnapshot.restoreFromJson(
_lastSnapshotJson,
keys: selectedKeys.isEmpty ? null : selectedKeys,
continueOnError: _continueOnError,
);
setState(() {
_statusMessage = 'Snapshot restored successfully!';
_isLoading = false;
});
return;
} else if (_storage != null) {
// Try to load the latest
snapshot = await _storage!.load();
if (snapshot == null) {
setState(() {
_statusMessage = 'No snapshot to restore. Capture one first!';
_isLoading = false;
});
return;
}
}
// Get selected keys for restore
final selectedKeys = _selectedKeysForRestore.entries
.where((e) => e.value)
.map((e) => e.key)
.toList();
// Restore from loaded snapshot with partial restore
await StateSnapshot.restoreFromJson(
snapshot!.toJsonString(),
keys: selectedKeys.isEmpty ? null : selectedKeys,
continueOnError: _continueOnError,
);
setState(() {
_lastSnapshotJson = snapshot!.toJsonString(pretty: true);
_statusMessage = 'Snapshot restored successfully!';
_isLoading = false;
});
} catch (e) {
setState(() {
_statusMessage = 'Error: $e';
_isLoading = false;
});
}
}
Future<void> _validateSnapshot([String? key]) async {
setState(() {
_isLoading = true;
_statusMessage = 'Validating snapshot...';
});
try {
String jsonString;
if (key != null && _storage != null) {
final snapshot = await _storage!.load(key);
if (snapshot == null) {
setState(() {
_statusMessage = 'Snapshot not found: $key';
_isLoading = false;
});
return;
}
jsonString = snapshot.toJsonString();
} else if (_lastSnapshotJson.isNotEmpty) {
jsonString = _lastSnapshotJson;
} else {
setState(() {
_statusMessage = 'No snapshot to validate. Capture one first!';
_isLoading = false;
});
return;
}
final validation = await StateSnapshot.validateSnapshot(jsonString);
setState(() {
_lastValidationResult = validation;
if (validation.isValid) {
_statusMessage = 'Validation passed!';
} else {
_statusMessage =
'Validation failed: ${validation.errors.length} error(s)';
}
_isLoading = false;
});
} catch (e) {
setState(() {
_statusMessage = 'Validation error: $e';
_isLoading = false;
});
}
}
void _toggleStateRegistration(String key) {
if (SnapshotRegistry.instance.isRegistered(key)) {
StateSnapshot.unregister(key);
setState(() {
_statusMessage = 'Unregistered: $key';
});
} else {
// Re-register based on key
switch (key) {
case 'user_state':
StateSnapshot.register<UserState>(
key: 'user_state',
exporter: () => _userState.toJson(),
importer: (json) {
_userState.restore(json);
setState(() {});
},
);
break;
case 'settings_state':
StateSnapshot.register<SettingsState>(
key: 'settings_state',
exporter: () => _settingsState.toJson(),
importer: (json) {
_settingsState.restore(json);
setState(() {});
},
);
break;
case 'counter_state':
StateSnapshot.register<CounterState>(
key: 'counter_state',
exporter: () => _counterState.toJson(),
importer: (json) {
_counterState.restore(json);
setState(() {});
},
);
break;
}
setState(() {
_statusMessage = 'Registered: $key';
});
}
}
Future<void> _deleteSnapshot(String key) async {
if (_storage == null) {
return;
}
setState(() {
_isLoading = true;
_statusMessage = 'Deleting snapshot...';
});
try {
final deleted = await _storage!.delete(key);
if (deleted) {
setState(() {
_statusMessage = 'Snapshot deleted successfully!';
_isLoading = false;
});
await _loadSavedSnapshots();
} else {
setState(() {
_statusMessage = 'Snapshot not found: $key';
_isLoading = false;
});
}
} catch (e) {
setState(() {
_statusMessage = 'Error: $e';
_isLoading = false;
});
}
}
void _clearSnapshot() => setState(() {
_lastSnapshotJson = '';
_statusMessage = 'Snapshot cleared';
_lastValidationResult = null;
});
@override
void dispose() {
_bugIdController.dispose();
_reporterController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(
title: Text(widget.title, style: const TextStyle(color: Colors.white)),
),
body: SingleChildScrollView(
padding: const EdgeInsets.symmetric(vertical: 16.0, horizontal: 24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Status message
Container(
decoration: BoxDecoration(
color: Colors.black12,
border: Border.all(color: Colors.white24),
),
child: Padding(
padding: const EdgeInsets.symmetric(
vertical: 12.0,
horizontal: 16.0,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Status',
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(color: Colors.white),
),
const SizedBox(height: 4),
Text(
_statusMessage,
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(color: Colors.white),
),
if (_isLoading) ...[
const SizedBox(height: 8),
const LinearProgressIndicator(color: Colors.white),
],
],
),
),
),
const SizedBox(height: 16),
Container(
decoration: BoxDecoration(
color: Colors.black12,
border: Border.all(color: Colors.white24),
),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'User State',
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(color: Colors.white),
),
const SizedBox(height: 8),
TextField(
style: const TextStyle(color: Colors.white),
decoration: const InputDecoration(labelText: 'Name'),
onChanged: (value) {
_userState.name = value;
},
controller: TextEditingController(text: _userState.name),
),
const SizedBox(height: 8),
TextField(
decoration: const InputDecoration(labelText: 'Age'),
keyboardType: TextInputType.number,
style: const TextStyle(color: Colors.white),
onChanged: (value) =>
_userState.age = int.tryParse(value) ?? 0,
controller: TextEditingController(
text: _userState.age.toString(),
),
),
const SizedBox(height: 8),
TextField(
decoration: const InputDecoration(labelText: 'Email'),
style: const TextStyle(color: Colors.white),
onChanged: (value) {
_userState.email = value;
},
controller: TextEditingController(text: _userState.email),
),
],
),
),
),
const SizedBox(height: 8),
Container(
decoration: BoxDecoration(
color: Colors.black12,
border: Border.all(color: Colors.white24),
),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Settings State',
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(color: Colors.white),
),
const SizedBox(height: 8),
DropdownButtonFormField<String>(
style: const TextStyle(color: Colors.white),
dropdownColor: Colors.black87,
decoration: const InputDecoration(
labelText: 'Theme',
border: OutlineInputBorder(),
),
initialValue: _settingsState.theme,
items: const [
DropdownMenuItem(value: 'light', child: Text('Light')),
DropdownMenuItem(value: 'dark', child: Text('Dark')),
],
onChanged: (value) {
if (value != null) {
setState(() => _settingsState.theme = value);
}
},
),
const SizedBox(height: 8),
DropdownButtonFormField<String>(
style: const TextStyle(color: Colors.white),
dropdownColor: Colors.black87,
decoration: const InputDecoration(
labelText: 'Language',
border: OutlineInputBorder(),
),
initialValue: _settingsState.language,
items: const [
DropdownMenuItem(value: 'en', child: Text('English')),
DropdownMenuItem(value: 'es', child: Text('Spanish')),
DropdownMenuItem(value: 'fr', child: Text('French')),
],
onChanged: (value) {
if (value != null) {
setState(() => _settingsState.language = value);
}
},
),
const SizedBox(height: 8),
SwitchListTile(
activeThumbColor: Colors.white,
activeTrackColor: Colors.grey[800],
inactiveThumbColor: Colors.grey[400],
inactiveTrackColor: Colors.grey[200],
title: const Text(
'Notifications',
style: TextStyle(color: Colors.white),
),
value: _settingsState.notificationsEnabled,
onChanged: (value) => setState(
() => _settingsState.notificationsEnabled = value,
),
),
],
),
),
),
const SizedBox(height: 8),
Container(
decoration: BoxDecoration(
color: Colors.black12,
border: Border.all(color: Colors.white24),
),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Counter State',
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(color: Colors.white),
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton(
onPressed: () => setState(() => _counterState.count--),
icon: const Icon(Icons.remove, color: Colors.white),
),
Text(
'${_counterState.count}',
style: Theme.of(context).textTheme.headlineMedium
?.copyWith(color: Colors.white),
),
IconButton(
onPressed: () {
setState(() => _counterState.count++);
},
icon: const Icon(Icons.add, color: Colors.white),
),
],
),
],
),
),
),
const SizedBox(height: 16),
// New v1 Features Section
Container(
decoration: BoxDecoration(
color: Colors.black12,
border: Border.all(color: Colors.white24),
),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'V1 Features',
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(color: Colors.white),
),
const SizedBox(height: 16),
// Metadata input
Text(
'Metadata (Optional)',
style: Theme.of(
context,
).textTheme.titleSmall?.copyWith(color: Colors.white70),
),
const SizedBox(height: 8),
TextField(
controller: _bugIdController,
style: const TextStyle(color: Colors.white),
decoration: const InputDecoration(
labelText: 'Bug ID',
hintText: 'e.g., BUG-123',
),
),
const SizedBox(height: 8),
TextField(
controller: _reporterController,
style: const TextStyle(color: Colors.white),
decoration: const InputDecoration(
labelText: 'Reporter',
hintText: 'e.g., John Doe',
),
),
const SizedBox(height: 16),
// Selective capture
Text(
'Select Keys to Capture',
style: Theme.of(
context,
).textTheme.titleSmall?.copyWith(color: Colors.white70),
),
const SizedBox(height: 8),
..._selectedKeysForCapture.entries.map(
(entry) => CheckboxListTile(
title: Text(
entry.key,
style: const TextStyle(color: Colors.white),
),
value: entry.value,
onChanged: (value) => setState(
() =>
_selectedKeysForCapture[entry.key] = value ?? false,
),
),
),
const SizedBox(height: 16),
// Selective restore
Text(
'Select Keys to Restore',
style: Theme.of(
context,
).textTheme.titleSmall?.copyWith(color: Colors.white70),
),
const SizedBox(height: 8),
..._selectedKeysForRestore.entries.map(
(entry) => CheckboxListTile(
title: Text(
entry.key,
style: const TextStyle(color: Colors.white),
),
value: entry.value,
onChanged: (value) => setState(
() =>
_selectedKeysForRestore[entry.key] = value ?? false,
),
),
),
const SizedBox(height: 16),
// Continue on error toggle
SwitchListTile(
title: const Text(
'Continue on Error',
style: TextStyle(color: Colors.white),
),
subtitle: const Text(
'Continue restoring other keys even if one fails',
style: TextStyle(color: Colors.white60),
),
value: _continueOnError,
onChanged: (value) =>
setState(() => _continueOnError = value),
),
const SizedBox(height: 16),
// State registration controls
Text(
'State Registration',
style: Theme.of(
context,
).textTheme.titleSmall?.copyWith(color: Colors.white70),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
children: ['user_state', 'settings_state', 'counter_state']
.map((key) {
final isRegistered = SnapshotRegistry.instance
.isRegistered(key);
return FilterChip(
label: Text(key),
selected: isRegistered,
onSelected: (_) => _toggleStateRegistration(key),
selectedColor: Colors.green.withValues(alpha: 0.3),
checkmarkColor: Colors.green,
);
})
.toList(),
),
],
),
),
),
const SizedBox(height: 16),
// Action buttons
Row(
children: [
Expanded(
child: ElevatedButton.icon(
onPressed: _isLoading ? null : _captureSnapshot,
icon: const Icon(Icons.camera_alt),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: Colors.black,
),
label: const Text('Capture'),
),
),
const SizedBox(width: 8),
Expanded(
child: ElevatedButton.icon(
onPressed: _isLoading ? null : _restoreSnapshot,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: Colors.black,
),
icon: const Icon(Icons.restore),
label: const Text('Restore'),
),
),
],
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: _isLoading ? null : _validateSnapshot,
icon: const Icon(Icons.check_circle, color: Colors.white),
style: OutlinedButton.styleFrom(
foregroundColor: Colors.white,
side: const BorderSide(color: Colors.white24),
),
label: const Text('Validate'),
),
),
const SizedBox(width: 8),
Expanded(
child: OutlinedButton.icon(
onPressed: _clearSnapshot,
icon: const Icon(Icons.clear, color: Colors.white),
style: OutlinedButton.styleFrom(
foregroundColor: Colors.white,
side: const BorderSide(color: Colors.white24),
),
label: const Text('Clear'),
),
),
],
),
// Validation result display
if (_lastValidationResult != null) ...[
const SizedBox(height: 16),
Container(
decoration: BoxDecoration(
color: _lastValidationResult!.isValid
? Colors.green.withValues(alpha: 0.2)
: Colors.red.withValues(alpha: 0.2),
border: Border.all(
color: _lastValidationResult!.isValid
? Colors.green
: Colors.red,
),
),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
_lastValidationResult!.isValid
? Icons.check_circle
: Icons.error,
color: _lastValidationResult!.isValid
? Colors.green
: Colors.red,
),
const SizedBox(width: 8),
Text(
_lastValidationResult!.isValid
? 'Validation Passed'
: 'Validation Failed',
style: Theme.of(context).textTheme.titleMedium
?.copyWith(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
],
),
if (_lastValidationResult!.errors.isNotEmpty) ...[
const SizedBox(height: 8),
Text(
'Errors:',
style: Theme.of(
context,
).textTheme.titleSmall?.copyWith(color: Colors.red),
),
..._lastValidationResult!.errors.map(
(error) => Padding(
padding: const EdgeInsets.only(left: 16, top: 4),
child: Text(
'• $error',
style: const TextStyle(color: Colors.red),
),
),
),
],
if (_lastValidationResult!.warnings.isNotEmpty) ...[
const SizedBox(height: 8),
Text(
'Warnings:',
style: Theme.of(
context,
).textTheme.titleSmall?.copyWith(color: Colors.orange),
),
..._lastValidationResult!.warnings.map(
(warning) => Padding(
padding: const EdgeInsets.only(left: 16, top: 4),
child: Text(
'• $warning',
style: const TextStyle(color: Colors.orange),
),
),
),
],
],
),
),
),
],
const SizedBox(height: 16),
if (_savedSnapshotKeys.isNotEmpty) ...[
Container(
decoration: BoxDecoration(
color: Colors.black12,
border: Border.all(color: Colors.white24),
),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Saved Snapshots',
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(color: Colors.white),
),
const SizedBox(height: 8),
..._savedSnapshotKeys.map(
(key) => ListTile(
title: Text(
key,
style: const TextStyle(color: Colors.white),
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(
Icons.restore,
color: Colors.white,
),
onPressed: _isLoading
? null
: () => _restoreSnapshot(key),
tooltip: 'Restore',
),
IconButton(
icon: const Icon(Icons.delete, color: Colors.red),
onPressed: _isLoading
? null
: () => _deleteSnapshot(key),
tooltip: 'Delete',
),
],
),
),
),
],
),
),
),
const SizedBox(height: 16),
],
// Snapshot JSON Display
if (_lastSnapshotJson.isNotEmpty) ...[
Container(
decoration: BoxDecoration(
color: Colors.black12,
border: Border.all(color: Colors.white24),
),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Captured Snapshot',
style: Theme.of(context).textTheme.titleMedium
?.copyWith(color: Colors.white),
),
IconButton(
icon: const Icon(Icons.copy, color: Colors.white),
onPressed: () =>
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Snapshot copied to clipboard'),
),
),
),
],
),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(8.0),
decoration: BoxDecoration(
color: Colors.grey[200],
borderRadius: BorderRadius.circular(4),
),
child: SelectableText(
_lastSnapshotJson,
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 12,
),
),
),
],
),
),
),
],
],
),
),
);
}