flutter_log_viewer 0.1.0
flutter_log_viewer: ^0.1.0 copied to clipboard
A compact, resizable in-app floating log viewer for Flutter applications.
import 'dart:async';
import 'package:file_saver/file_saver.dart';
import 'package:file_selector/file_selector.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_log_viewer/flutter_log_viewer.dart';
import 'package:open_filex/open_filex.dart';
void main() => runApp(const ExampleApp());
final messengerKey = GlobalKey<ScaffoldMessengerState>();
final navigatorKey = GlobalKey<NavigatorState>();
bool _isExportLoadingVisible = false;
final logController = LogViewerController(
label: 'Example Logs',
showGuideOnInit: true,
exportHandler: LogViewerExportHandler(
exportStart: _showExportLoading,
exportEnd: _saveExportedLog,
),
toast: (_, message) => _showMessage(message),
);
void _showExportLoading(BuildContext context, LogViewerController controller) {
final dialogContext = navigatorKey.currentContext ?? context;
_isExportLoadingVisible = true;
unawaited(
showDialog<void>(
context: dialogContext,
barrierDismissible: false,
builder: (context) => const Center(
child: Card(
child: Padding(
padding: EdgeInsets.all(18),
child: Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
SizedBox(width: 14),
Text('Exporting logs...'),
],
),
),
),
),
).whenComplete(() => _isExportLoadingVisible = false),
);
}
Future<void> _saveExportedLog(
BuildContext context,
LogViewerController controller,
LogViewerExportFile file,
) async {
final dialogContext = navigatorKey.currentContext ?? context;
if (_isExportLoadingVisible) {
Navigator.of(dialogContext, rootNavigator: true).pop();
}
final extensionStart = file.fileName.lastIndexOf('.');
final name = extensionStart == -1
? file.fileName
: file.fileName.substring(0, extensionStart);
final extension = extensionStart == -1
? 'log'
: file.fileName.substring(extensionStart + 1);
try {
final savedPath = await _saveLogExport(
file,
name: name,
extension: extension,
);
if (savedPath == null) return;
if (_usesMobileFilePanel && savedPath.isNotEmpty) {
final result = await OpenFilex.open(savedPath, type: file.mimeType);
if (result.type != ResultType.done) {
_showMessage(
'Exported, but the file could not be opened: ${result.message}',
long: true,
);
return;
}
}
_showMessage('Exported ${file.fileName}');
} catch (error) {
_showMessage('Export failed: $error', long: true);
}
}
bool get _usesDesktopSaveDialog =>
!kIsWeb &&
switch (defaultTargetPlatform) {
TargetPlatform.linux ||
TargetPlatform.macOS ||
TargetPlatform.windows => true,
_ => false,
};
bool get _usesMobileFilePanel =>
!kIsWeb &&
switch (defaultTargetPlatform) {
TargetPlatform.android || TargetPlatform.iOS => true,
_ => false,
};
Future<String?> _saveLogExport(
LogViewerExportFile file, {
required String name,
required String extension,
}) async {
if (_usesDesktopSaveDialog) {
final location = await getSaveLocation(
suggestedName: defaultTargetPlatform == TargetPlatform.macOS
? name
: file.fileName,
acceptedTypeGroups: const <XTypeGroup>[
XTypeGroup(label: 'Log file', extensions: <String>['log']),
],
);
if (location == null) return null;
await XFile.fromData(
file.bytes,
mimeType: file.mimeType,
name: file.fileName,
).saveTo(location.path);
return location.path;
}
return FileSaver.instance.saveFile(
name: name,
bytes: file.bytes,
fileExtension: extension,
mimeType: MimeType.custom,
customMimeType: file.mimeType,
);
}
void _showMessage(String message, {bool long = false}) {
messengerKey.currentState
?..hideCurrentSnackBar()
..showSnackBar(
SnackBar(
content: Text(message),
behavior: SnackBarBehavior.floating,
duration: Duration(seconds: long ? 4 : 2),
),
);
}
class ExampleApp extends StatelessWidget {
const ExampleApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
navigatorKey: navigatorKey,
scaffoldMessengerKey: messengerKey,
title: 'Flutter Log Viewer',
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.teal),
),
builder: (context, child) => LogViewerOverlay(
controller: logController,
child: child ?? const SizedBox.shrink(),
),
home: const ExampleHomePage(),
);
}
}
class ExampleHomePage extends StatefulWidget {
const ExampleHomePage({super.key});
@override
State<ExampleHomePage> createState() => _ExampleHomePageState();
}
class _ExampleHomePageState extends State<ExampleHomePage> {
Timer? _timer;
int _sequence = 0;
bool get _streaming => _timer?.isActive ?? false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _addInitialLogs());
}
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
void _addInitialLogs() {
logController
..info('Application started', tag: 'Lifecycle')
..debug('Loaded local preferences in 18ms', tag: 'Storage')
..info('Signed in as demo@example.com', tag: 'Auth')
..warning('Using the staging API endpoint', tag: 'Config');
}
void _addLog(LogViewerLevel level) {
_sequence++;
logController.log(
switch (level) {
LogViewerLevel.verbose => 'Cache lookup completed for item $_sequence',
LogViewerLevel.debug => 'State transition dispatched: event_$_sequence',
LogViewerLevel.info => 'User action completed successfully',
LogViewerLevel.warning => 'Request took ${420 + _sequence}ms',
LogViewerLevel.error => 'Unable to decode response payload',
},
level: level,
tag: switch (level) {
LogViewerLevel.verbose => 'Cache',
LogViewerLevel.debug => 'State',
LogViewerLevel.info => 'UI',
LogViewerLevel.warning => 'Network',
LogViewerLevel.error => 'Parser',
},
);
}
void _addBurst() {
for (final level in LogViewerLevel.values) {
_addLog(level);
}
}
void _toggleStream() {
if (_streaming) {
_timer?.cancel();
} else {
_timer = Timer.periodic(
const Duration(milliseconds: 650),
(_) => _addLog(LogViewerLevel.values[_sequence % 4]),
);
}
setState(() {});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Flutter Log Viewer')),
body: ListView(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 220),
children: <Widget>[
Wrap(
spacing: 8,
runSpacing: 8,
children: <Widget>[
FilledButton.tonalIcon(
onPressed: logController.togglePanel,
icon: const Icon(Icons.visibility_outlined, size: 18),
label: const Text('Toggle viewer'),
),
FilledButton.tonalIcon(
onPressed: _toggleStream,
icon: Icon(
_streaming ? Icons.stop : Icons.play_arrow,
size: 18,
),
label: Text(_streaming ? 'Stop stream' : 'Start stream'),
),
FilledButton.tonalIcon(
onPressed: _addBurst,
icon: const Icon(Icons.bolt, size: 18),
label: const Text('Add burst'),
),
],
),
const SizedBox(height: 16),
const Divider(),
const SizedBox(height: 12),
Text('Log levels', style: Theme.of(context).textTheme.titleSmall),
const SizedBox(height: 10),
Wrap(
spacing: 8,
runSpacing: 8,
children: <Widget>[
for (final level in LogViewerLevel.values)
OutlinedButton(
onPressed: () => _addLog(level),
child: Text(level.name.toUpperCase()),
),
],
),
const SizedBox(height: 22),
Text('Viewer state', style: Theme.of(context).textTheme.titleSmall),
const SizedBox(height: 8),
ListenableBuilder(
listenable: logController,
builder: (context, _) => Column(
children: <Widget>[
SwitchListTile.adaptive(
contentPadding: EdgeInsets.zero,
title: const Text('Record logs'),
subtitle: Text(
logController.isRecording
? 'New entries are being accepted.'
: 'Calls are ignored until recording resumes.',
),
value: logController.isRecording,
onChanged: logController.setRecording,
),
ListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Cached entries'),
trailing: Text('${logController.entries.length} / 1000'),
),
],
),
),
const SizedBox(height: 8),
DecoratedBox(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(7),
),
child: const Padding(
padding: EdgeInsets.all(14),
child: Text(
'Try tapping a row, long-pressing and dragging across rows, '
'moving the toolbar, and resizing from the lower-right corner.',
style: TextStyle(fontSize: 12, height: 1.4),
),
),
),
],
),
);
}
}