flutter_live_coverage 1.0.0
flutter_live_coverage: ^1.0.0 copied to clipboard
Flutter runtime code coverage plugin supporting Release mode. Collects line-level coverage data with LCOV output and S3 upload support.
import 'package:flutter/material.dart';
import 'package:flutter_live_coverage/flutter_live_coverage.dart';
// Note: In actual usage, you need to run the instrumentation command first:
// dart run flutter_live_coverage:coverage_instrument -p ./your_app
// Then import the generated coverage_init.dart file
void main() {
// Example: Initialize with inline source map (use generated file in real projects)
_initDemoCoverage();
runApp(const MyApp());
}
/// Demo coverage initialization
void _initDemoCoverage() {
// In real projects, you should use CLI-generated initialization code:
// import 'coverage_init.dart';
// initCoverage();
// Here we use an empty source map for demonstration
const demoSourceMap = '''
{
"files": {},
"lines": {}
}
''';
CodeCoverageApi.instance.initialize(demoSourceMap);
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Code Coverage Demo',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
useMaterial3: true,
),
home: const CoverageDemoPage(),
);
}
}
class CoverageDemoPage extends StatefulWidget {
const CoverageDemoPage({super.key});
@override
State<CoverageDemoPage> createState() => _CoverageDemoPageState();
}
class _CoverageDemoPageState extends State<CoverageDemoPage> {
final _api = CodeCoverageApi.instance;
CoverageStats? _stats;
String _status = 'Ready';
bool _isLoading = false;
List<CoverageFileInfo> _localFiles = [];
@override
void initState() {
super.initState();
_refreshStats();
_loadLocalFiles();
}
void _refreshStats() {
setState(() {
_stats = _api.getStats();
});
}
Future<void> _loadLocalFiles() async {
final files = await _api.listLocalFiles();
setState(() {
_localFiles = files;
});
}
Future<void> _saveLocally() async {
setState(() {
_isLoading = true;
_status = 'Saving...';
});
try {
final file = await _api.saveToFile(testName: 'demo-test');
setState(() {
_status = 'Saved: ${file.path}';
});
await _loadLocalFiles();
} catch (e) {
setState(() {
_status = 'Save failed: $e';
});
} finally {
setState(() {
_isLoading = false;
});
}
}
Future<void> _configureS3() async {
// Show configuration dialog
final result = await showDialog<S3Config>(
context: context,
builder: (context) => const S3ConfigDialog(),
);
if (result != null) {
_api.configureS3(result);
setState(() {
_status = 'S3 configured: ${result.bucket}';
});
}
}
Future<void> _uploadToS3() async {
if (!_api.isS3Configured) {
setState(() {
_status = 'Please configure S3 first';
});
return;
}
setState(() {
_isLoading = true;
_status = 'Uploading to S3...';
});
try {
final result = await _api.uploadToS3(testName: 'demo-test');
setState(() {
if (result.success) {
_status = 'Upload success: ${result.url}';
} else {
_status = 'Upload failed: ${result.error}';
}
});
} catch (e) {
setState(() {
_status = 'Upload error: $e';
});
} finally {
setState(() {
_isLoading = false;
});
}
}
Future<void> _exportAll() async {
setState(() {
_isLoading = true;
_status = 'Exporting...';
});
try {
final result = await _api.exportCoverage(
testName: 'demo-export',
uploadToS3: _api.isS3Configured,
// Auto-delete local file after successful S3 upload to save space
deleteLocalOnUploadSuccess: _api.isS3Configured,
);
setState(() {
final parts = <String>[];
if (result.localFileDeleted) {
parts.add('Local: deleted after upload');
} else if (result.localFile != null) {
parts.add('Local: saved');
}
if (result.s3Result != null) {
parts.add('S3: ${result.s3Result!.success ? "success" : "failed"}');
}
parts.add('Coverage: ${result.stats.coveragePercentage.toStringAsFixed(2)}%');
_status = parts.join(', ');
});
await _loadLocalFiles();
} catch (e) {
setState(() {
_status = 'Export failed: $e';
});
} finally {
setState(() {
_isLoading = false;
});
}
}
void _resetCoverage() {
_api.reset();
_refreshStats();
setState(() {
_status = 'Coverage data reset';
});
}
Future<void> _cleanupFiles() async {
final count = await _api.cleanupLocalFiles(keepCount: 5);
await _loadLocalFiles();
setState(() {
_status = 'Cleaned up $count old files';
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Code Coverage Demo'),
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Statistics card
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Coverage Statistics',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
if (_stats != null) ...[
Text('Files: ${_stats!.totalFiles}'),
Text('Total lines: ${_stats!.totalLines}'),
Text('Executed lines: ${_stats!.executedLines}'),
Text(
'Coverage: ${_stats!.coveragePercentage.toStringAsFixed(2)}%',
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 18,
),
),
] else
const Text('No data'),
const SizedBox(height: 8),
ElevatedButton.icon(
onPressed: _refreshStats,
icon: const Icon(Icons.refresh),
label: const Text('Refresh'),
),
],
),
),
),
const SizedBox(height: 16),
// Action buttons
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Actions',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
ElevatedButton.icon(
onPressed: _isLoading ? null : _saveLocally,
icon: const Icon(Icons.save),
label: const Text('Save Local'),
),
ElevatedButton.icon(
onPressed: _configureS3,
icon: Icon(
_api.isS3Configured
? Icons.cloud_done
: Icons.cloud_off,
),
label: const Text('Configure S3'),
),
ElevatedButton.icon(
onPressed:
_isLoading || !_api.isS3Configured
? null
: _uploadToS3,
icon: const Icon(Icons.cloud_upload),
label: const Text('Upload S3'),
),
ElevatedButton.icon(
onPressed: _isLoading ? null : _exportAll,
icon: const Icon(Icons.import_export),
label: const Text('Export All'),
),
OutlinedButton.icon(
onPressed: _resetCoverage,
icon: const Icon(Icons.restart_alt),
label: const Text('Reset'),
),
],
),
],
),
),
),
const SizedBox(height: 16),
// Status display
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Status',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
if (_isLoading)
const LinearProgressIndicator()
else
Text(_status),
],
),
),
),
const SizedBox(height: 16),
// Local files list
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Local Files (${_localFiles.length})',
style: Theme.of(context).textTheme.titleLarge,
),
IconButton(
onPressed: _cleanupFiles,
icon: const Icon(Icons.delete_sweep),
tooltip: 'Cleanup old files',
),
],
),
const SizedBox(height: 8),
if (_localFiles.isEmpty)
const Text('No saved files')
else
...List.generate(
_localFiles.length > 5 ? 5 : _localFiles.length,
(index) {
final file = _localFiles[index];
return ListTile(
leading: Icon(
file.format == CoverageFormat.lcov
? Icons.description
: Icons.data_object,
),
title: Text(
file.fileName,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(file.formattedSize),
dense: true,
);
},
),
if (_localFiles.length > 5)
Text(
'... and ${_localFiles.length - 5} more files',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
),
],
),
),
);
}
}
/// S3 configuration dialog
class S3ConfigDialog extends StatefulWidget {
const S3ConfigDialog({super.key});
@override
State<S3ConfigDialog> createState() => _S3ConfigDialogState();
}
class _S3ConfigDialogState extends State<S3ConfigDialog> {
final _formKey = GlobalKey<FormState>();
final _bucketController = TextEditingController();
final _regionController = TextEditingController(text: 'us-west-2');
final _accessKeyController = TextEditingController();
final _secretKeyController = TextEditingController();
final _prefixController = TextEditingController(text: 'coverage');
@override
void dispose() {
_bucketController.dispose();
_regionController.dispose();
_accessKeyController.dispose();
_secretKeyController.dispose();
_prefixController.dispose();
super.dispose();
}
void _submit() {
if (_formKey.currentState!.validate()) {
Navigator.of(context).pop(S3Config(
bucket: _bucketController.text,
region: _regionController.text,
accessKeyId: _accessKeyController.text,
secretAccessKey: _secretKeyController.text,
prefix: _prefixController.text.isNotEmpty ? _prefixController.text : null,
));
}
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Configure S3'),
content: Form(
key: _formKey,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextFormField(
controller: _bucketController,
decoration: const InputDecoration(labelText: 'Bucket'),
validator: (v) => v?.isEmpty ?? true ? 'Required' : null,
),
TextFormField(
controller: _regionController,
decoration: const InputDecoration(labelText: 'Region'),
validator: (v) => v?.isEmpty ?? true ? 'Required' : null,
),
TextFormField(
controller: _accessKeyController,
decoration: const InputDecoration(labelText: 'Access Key ID'),
validator: (v) => v?.isEmpty ?? true ? 'Required' : null,
),
TextFormField(
controller: _secretKeyController,
decoration: const InputDecoration(labelText: 'Secret Access Key'),
obscureText: true,
validator: (v) => v?.isEmpty ?? true ? 'Required' : null,
),
TextFormField(
controller: _prefixController,
decoration: const InputDecoration(labelText: 'Prefix (optional)'),
),
],
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: _submit,
child: const Text('OK'),
),
],
);
}
}