Last active
March 2, 2024 09:23
-
-
Save PlugFox/93068ea34b43a3c2899eceed43f76a45 to your computer and use it in GitHub Desktop.
Log collector and application initialization with dependencies
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/// Dependencies | |
abstract interface class Dependencies { | |
/// The state from the closest instance of this class. | |
factory Dependencies.of(BuildContext context) => InheritedDependencies.of(context); | |
/// Database | |
abstract final Database database; | |
} | |
final class $MutableDependencies implements Dependencies { | |
$MutableDependencies() : context = <String, Object?>{}; | |
/// Initialization context | |
final Map<Object?, Object?> context; | |
@override | |
late Database database; | |
Dependencies freeze() => _$ImmutableDependencies( | |
database: database, | |
); | |
} | |
final class _$ImmutableDependencies implements Dependencies { | |
_$ImmutableDependencies({ | |
required this.database, | |
}); | |
@override | |
final Database database; | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/// Initializes the app and returns a [Dependencies] object | |
Future<Dependencies> $initializeDependencies({ | |
void Function(int progress, String message)? onProgress, | |
}) async { | |
final dependencies = $MutableDependencies(); | |
final totalSteps = _initializationSteps.length; | |
var currentStep = 0; | |
for (final step in _initializationSteps.entries) { | |
currentStep++; | |
final percent = (currentStep * 100 ~/ totalSteps).clamp(0, 100); | |
onProgress?.call(percent, step.key); | |
l.v6('Initialization | $currentStep/$totalSteps ($percent%) | "${step.key}"'); | |
await step.value(dependencies); | |
} | |
return dependencies.freeze(); | |
} | |
typedef _InitializationStep = FutureOr<void> Function($MutableDependencies dependencies); | |
final Map<String, _InitializationStep> _initializationSteps = <String, _InitializationStep>{ | |
'Platform pre-initialization': (_) => $platformInitialization(), | |
'Initializing the database': (dependencies) => dependencies.database = Database.lazy(), | |
'Shrink database': (dependencies) async { | |
if (!Config.environment.isProduction) { | |
await dependencies.database.transaction(() async { | |
final log = await (dependencies.database.select<Log, LogData>(dependencies.database.log) | |
..orderBy([(tbl) => OrderingTerm(expression: tbl.id, mode: OrderingMode.desc)]) | |
..limit(1, offset: 1000)) | |
.getSingleOrNull(); | |
if (log != null) { | |
await (dependencies.database.delete(dependencies.database.log) | |
..where((tbl) => tbl.time.isSmallerOrEqualValue(log.time))) | |
.go(); | |
} | |
}); | |
} | |
if (DateTime.now().second % 10 == 0) await dependencies.database.customStatement('VACUUM;'); | |
}, | |
/* ... */ | |
'Collect logs': (dependencies) async { | |
if (Config.environment.isProduction) return; | |
await (dependencies.database.select<Log, LogData>(dependencies.database.log) | |
..orderBy([(tbl) => OrderingTerm(expression: tbl.time, mode: OrderingMode.desc)]) | |
..limit(LogBuffer.bufferLimit)) | |
.get() | |
.then<List<LogMessage>>((logs) => logs | |
.map((l) => l.stack != null | |
? LogMessageWithStackTrace( | |
date: DateTime.fromMillisecondsSinceEpoch(l.time * 1000), | |
level: LogLevel.fromValue(l.level), | |
message: l.message, | |
stackTrace: StackTrace.fromString(l.stack!)) | |
: LogMessage( | |
date: DateTime.fromMillisecondsSinceEpoch(l.time * 1000), | |
level: LogLevel.fromValue(l.level), | |
message: l.message, | |
)) | |
.toList()) | |
.then<void>(LogBuffer.instance.addAll); | |
l.bufferTime(const Duration(seconds: 1)).where((logs) => logs.isNotEmpty).listen(LogBuffer.instance.addAll); | |
l | |
.map<LogCompanion>((log) => LogCompanion.insert( | |
level: log.level.level, | |
message: log.message.toString(), | |
time: Value<int>(log.date.millisecondsSinceEpoch ~/ 1000), | |
stack: Value<String?>(switch (log) { LogMessageWithStackTrace l => l.stackTrace.toString(), _ => null }), | |
)) | |
.bufferTime(const Duration(seconds: 15)) | |
.where((logs) => logs.isNotEmpty) | |
.listen( | |
(logs) => dependencies.database.batch((batch) => batch.insertAll(dependencies.database.log, logs)).ignore(), | |
cancelOnError: false, | |
); | |
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
-- Logs table | |
CREATE TABLE IF NOT EXISTS log ( | |
-- req Unique identifier of the log | |
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, | |
-- Time is the timestamp (in seconds) of the log message | |
time INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), | |
-- Level is the severity level (a value between 0 and 6) | |
level INTEGER NOT NULL, | |
-- req Message is the log message or error associated with this log event | |
message TEXT NOT NULL, | |
-- StackTrace a stack trace associated with this log event | |
stack TEXT | |
) STRICT; | |
-- Indexes | |
CREATE INDEX IF NOT EXISTS log_time_idx ON log (time); | |
CREATE INDEX IF NOT EXISTS log_level_idx ON log (level); | |
/* | |
-- Logs search table | |
CREATE TABLE IF NOT EXISTS log_prefix ( | |
-- req Prefix (first 3 chars of word, lowercased) | |
prefix TEXT NOT NULL, -- CHECK(length(prefix) = 3) | |
-- req Unique identifier | |
log_id INTEGER NOT NULL, | |
-- req Word (3 or more chars, lowercased) | |
word TEXT NOT NULL, | |
-- req Word's length | |
len INTEGER NOT NULL, | |
-- Composite primary key | |
PRIMARY KEY (prefix, log_id, word), | |
-- Foreign keys | |
FOREIGN KEY (log_id) | |
REFERENCES log (id) | |
ON UPDATE CASCADE | |
ON DELETE CASCADE | |
) STRICT; | |
-- Indexes | |
CREATE INDEX IF NOT EXISTS log_prefix_prefix_idx ON log_prefix (prefix); | |
CREATE INDEX IF NOT EXISTS log_prefix_log_id_idx ON log_prefix (log_id); | |
CREATE INDEX IF NOT EXISTS log_prefix_len_idx ON log_prefix (len); | |
*/ |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import 'dart:collection' show Queue; | |
import 'package:flutter/foundation.dart' show ChangeNotifier; | |
import 'package:l/l.dart'; | |
/// LogBuffer Singleton class | |
class LogBuffer with ChangeNotifier { | |
static final LogBuffer _internalSingleton = LogBuffer._internal(); | |
static LogBuffer get instance => _internalSingleton; | |
LogBuffer._internal(); | |
static const int bufferLimit = 10000; | |
final Queue<LogMessage> _queue = Queue<LogMessage>(); | |
/// Get the logs | |
Iterable<LogMessage> get logs => _queue; | |
/// Clear the logs | |
void clear() { | |
_queue.clear(); | |
notifyListeners(); | |
} | |
/// Add a log to the buffer | |
void add(LogMessage log) { | |
if (_queue.length >= bufferLimit) _queue.removeFirst(); | |
_queue.add(log); | |
notifyListeners(); | |
} | |
/// Add a list of logs to the buffer | |
void addAll(List<LogMessage> logs) { | |
logs = logs.take(bufferLimit).toList(); | |
if (_queue.length + logs.length >= bufferLimit) { | |
final toRemove = _queue.length + logs.length - bufferLimit; | |
for (var i = 0; i < toRemove; i++) _queue.removeFirst(); | |
} | |
_queue.addAll(logs); | |
notifyListeners(); | |
} | |
@override | |
void dispose() { | |
_queue.clear(); | |
super.dispose(); | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import 'package:flutter/material.dart'; | |
import 'package:flutter/services.dart'; | |
import 'package:l/l.dart'; | |
import 'util.dart'; | |
import 'log_buffer.dart'; | |
/// {@template logs_screen} | |
/// LogsScreen widget. | |
/// {@endtemplate} | |
class LogsScreen extends StatelessWidget { | |
/// {@macro logs_screen} | |
const LogsScreen({super.key}); | |
/// Show the logs screen | |
static Future<void> show(BuildContext context) => Navigator.of(context, rootNavigator: true).push<void>( | |
MaterialPageRoute<void>( | |
builder: (context) => const LogsScreen(), | |
), | |
); | |
@override | |
Widget build(BuildContext context) => const Scaffold(body: _LogsList()); | |
} | |
class _LogsList extends StatefulWidget { | |
const _LogsList(); | |
@override | |
State<_LogsList> createState() => _LogsListState(); | |
} | |
/// State for widget _LogsList. | |
class _LogsListState extends State<_LogsList> { | |
final TextEditingController _controller = TextEditingController(); | |
final LogBuffer buffer = LogBuffer.instance; | |
late List<LogMessage> logs, filteredLogs; | |
@override | |
void initState() { | |
super.initState(); | |
buffer.addListener(_onLogUpdated); | |
_controller.addListener(_filter); | |
_onLogUpdated(); | |
} | |
@override | |
void dispose() { | |
buffer.removeListener(_onLogUpdated); | |
_controller.removeListener(_filter); | |
super.dispose(); | |
} | |
void _onLogUpdated() { | |
logs = buffer.logs.toList(); | |
_filter(); | |
} | |
Future<void> _filter() async { | |
final search = _controller.text.toLowerCase(); | |
final stopwatch = Stopwatch()..start(); | |
final buffer = logs.toList(); | |
try { | |
LogMessage log; | |
var pos = 0; | |
for (var i = 0; i < buffer.length; i++) { | |
if (stopwatch.elapsedMilliseconds > 8) await Future<void>.delayed(Duration.zero); | |
log = logs[i]; | |
if (log.message.toString().toLowerCase().contains(search)) { | |
buffer[pos] = log; | |
pos++; | |
} | |
} | |
filteredLogs = buffer..length = pos; | |
} finally { | |
stopwatch.stop(); | |
} | |
if (mounted) setState(() {}); | |
} | |
@override | |
Widget build(BuildContext context) => CustomScrollView( | |
slivers: <Widget>[ | |
SliverAppBar( | |
title: Text('Logs (${filteredLogs.length})'), | |
actions: <Widget>[ | |
IconButton(icon: const Icon(Icons.delete), onPressed: () => buffer.clear()), | |
const SizedBox(width: 16), | |
], | |
floating: true, | |
pinned: MediaQuery.of(context).size.height > 600, | |
bottom: PreferredSize( | |
preferredSize: const Size.fromHeight(72), | |
child: Padding( | |
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), | |
child: Center( | |
child: TextField( | |
controller: _controller, | |
decoration: const InputDecoration( | |
border: OutlineInputBorder(), | |
hintText: 'Search', | |
prefixIcon: Icon(Icons.search), | |
), | |
), | |
), | |
), | |
), | |
), | |
if (filteredLogs.isEmpty) | |
const SliverFillRemaining( | |
child: Center( | |
child: Text('No logs found'), | |
), | |
) | |
else | |
SliverList( | |
delegate: SliverChildBuilderDelegate( | |
(context, index) => _LogTile(filteredLogs[index], key: ObjectKey(filteredLogs[index])), | |
childCount: filteredLogs.length, | |
), | |
), | |
], | |
); | |
} | |
/// {@template logs_screen} | |
/// _LogTile widget. | |
/// {@endtemplate} | |
class _LogTile extends StatelessWidget { | |
/// {@macro logs_screen} | |
const _LogTile(this.log, {super.key}); | |
final LogMessage log; | |
@override | |
Widget build(BuildContext context) => Column( | |
children: [ | |
ListTile( | |
title: Text(log.message.toString()), | |
subtitle: Text(log.date.format()), | |
leading: _LogIcon(log.level), | |
dense: true, | |
trailing: IconButton( | |
icon: const Icon(Icons.copy), | |
onPressed: () => Clipboard.setData( | |
ClipboardData( | |
text: switch (log) { | |
LogMessageWithStackTrace log => '${log.message}\n${log.stackTrace}', | |
_ => '${log.message}' | |
}, | |
), | |
), | |
), | |
), | |
const Divider(height: 1), | |
], | |
); | |
} | |
class _LogIcon extends StatelessWidget { | |
const _LogIcon(this.level); | |
final LogLevel level; | |
@override | |
Widget build(BuildContext context) => level.when<Widget>( | |
debug: () => const Icon(Icons.bug_report, color: Colors.indigo), | |
info: () => const Icon(Icons.info, color: Colors.blue), | |
warning: () => const Icon(Icons.warning, color: Colors.orange), | |
error: () => const Icon(Icons.error, color: Colors.red), | |
shout: () => const Icon(Icons.campaign, color: Colors.red), | |
v: () => const Icon(Icons.looks_one, color: Colors.grey), | |
vv: () => const Icon(Icons.looks_two, color: Colors.grey), | |
vvv: () => const Icon(Icons.looks_3, color: Colors.grey), | |
vvvv: () => const Icon(Icons.looks_4, color: Colors.grey), | |
vvvvv: () => const Icon(Icons.looks_5, color: Colors.grey), | |
vvvvvv: () => const Icon(Icons.looks_6, color: Colors.grey), | |
); | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment