Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
6007815
feat: add LogEntry, LogCategory, LogLevel data models with parsing
ericgriffin Mar 27, 2026
c294242
fix: add copyWith and equality to LogEntry per project conventions
ericgriffin Mar 27, 2026
87b6836
feat: add LogFileService with file I/O and rotation
ericgriffin Mar 27, 2026
78b3fe9
fix: add initialization guard and error resilience to LogFileService
ericgriffin Mar 27, 2026
060d201
feat: enhance LoggerService with file writing and log categories
ericgriffin Mar 27, 2026
27dd42e
feat: add debug mode provider with SharedPreferences persistence
ericgriffin Mar 27, 2026
4841504
feat: initialize LogFileService at app startup
ericgriffin Mar 27, 2026
de013f9
feat: add Easter egg debug mode activation and settings Debug section
ericgriffin Mar 27, 2026
60b9ffc
fix: add Debug section to mobile settings layout
ericgriffin Mar 27, 2026
dd3a8b7
feat: add debug log providers with filtering and export actions
ericgriffin Mar 27, 2026
a987120
feat: add debug log viewer page with filter bar and log entry tiles
ericgriffin Mar 27, 2026
ca164aa
feat: add /settings/debug-logs route
ericgriffin Mar 27, 2026
27d7b2c
feat: add onLogEvent Pigeon callback and wire native logs to LoggerSe…
ericgriffin Mar 27, 2026
0e051f5
feat: add Android NativeLogger and route native logs to Flutter
ericgriffin Mar 27, 2026
79e4234
feat: add iOS/macOS NativeLogger and route native logs to Flutter
ericgriffin Mar 27, 2026
1cd604f
feat: add libdivecomputer log callback routing through NativeLogger
ericgriffin Mar 27, 2026
59b6849
fix: remove static from C log callback globals for correct extern lin…
ericgriffin Mar 27, 2026
cdf5e59
fix: migrate remaining NSLog calls to NativeLogger in Swift DiveCompu…
ericgriffin Mar 27, 2026
24cf249
fix: use go_router navigation instead of Navigator.pop when disabling…
ericgriffin Mar 27, 2026
293124b
test: add unit and widget tests for debug log viewer feature
ericgriffin Mar 28, 2026
97c5c0e
fix: update debug log viewer in real time when new entries arrive
ericgriffin Mar 28, 2026
065cd50
Merge branch 'main' into feature/debug-log-viewer
ericgriffin Mar 28, 2026
31b22fe
refactor: tie file logging lifecycle to debug mode toggle
ericgriffin Mar 28, 2026
20a9d49
test: add repository error-handling tests and expand debug log viewer…
ericgriffin Mar 28, 2026
cf04ef9
Merge remote-tracking branch 'origin/main' into feature/debug-log-viewer
ericgriffin Mar 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 87 additions & 27 deletions lib/core/data/repositories/sync_repository.dart
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,8 @@ class SyncRepository {
} catch (e, stackTrace) {
_log.warning(
'Sync metadata read failed, attempting repair',
e,
stackTrace,
error: e,
stackTrace: stackTrace,
);
await _repairSyncMetadataRow();
existing = await query.getSingleOrNull();
Expand All @@ -63,7 +63,11 @@ class SyncRepository {

return (await query.getSingle());
} catch (e, stackTrace) {
_log.error('Failed to get or create sync metadata', e, stackTrace);
_log.error(
'Failed to get or create sync metadata',
error: e,
stackTrace: stackTrace,
);
rethrow;
}
}
Expand Down Expand Up @@ -148,7 +152,11 @@ class SyncRepository {

_log.info('Updated last sync time to: $syncTime');
} catch (e, stackTrace) {
_log.error('Failed to update last sync time', e, stackTrace);
_log.error(
'Failed to update last sync time',
error: e,
stackTrace: stackTrace,
);
rethrow;
}
}
Expand All @@ -169,7 +177,11 @@ class SyncRepository {

_log.info('Set cloud provider to: ${provider?.name}');
} catch (e, stackTrace) {
_log.error('Failed to set cloud provider', e, stackTrace);
_log.error(
'Failed to set cloud provider',
error: e,
stackTrace: stackTrace,
);
rethrow;
}
}
Expand Down Expand Up @@ -201,7 +213,11 @@ class SyncRepository {

_log.info('Set remote file ID');
} catch (e, stackTrace) {
_log.error('Failed to set remote file ID', e, stackTrace);
_log.error(
'Failed to set remote file ID',
error: e,
stackTrace: stackTrace,
);
rethrow;
}
}
Expand Down Expand Up @@ -242,8 +258,8 @@ class SyncRepository {
} catch (e, stackTrace) {
_log.error(
'Failed to mark record pending: $entityType/$recordId',
e,
stackTrace,
error: e,
stackTrace: stackTrace,
);
rethrow;
}
Expand Down Expand Up @@ -276,8 +292,8 @@ class SyncRepository {
} catch (e, stackTrace) {
_log.error(
'Failed to mark record synced: $entityType/$recordId',
e,
stackTrace,
error: e,
stackTrace: stackTrace,
);
rethrow;
}
Expand Down Expand Up @@ -313,8 +329,8 @@ class SyncRepository {
} catch (e, stackTrace) {
_log.error(
'Failed to mark record conflict: $entityType/$recordId',
e,
stackTrace,
error: e,
stackTrace: stackTrace,
);
rethrow;
}
Expand All @@ -327,7 +343,11 @@ class SyncRepository {
..where((t) => t.syncStatus.equals('pending'));
return query.get();
} catch (e, stackTrace) {
_log.error('Failed to get pending records', e, stackTrace);
_log.error(
'Failed to get pending records',
error: e,
stackTrace: stackTrace,
);
rethrow;
}
}
Expand All @@ -339,7 +359,11 @@ class SyncRepository {
..where((t) => t.syncStatus.equals('conflict'));
return query.get();
} catch (e, stackTrace) {
_log.error('Failed to get conflict records', e, stackTrace);
_log.error(
'Failed to get conflict records',
error: e,
stackTrace: stackTrace,
);
rethrow;
}
}
Expand All @@ -350,7 +374,11 @@ class SyncRepository {
final records = await getPendingRecords();
return records.length;
} catch (e, stackTrace) {
_log.error('Failed to get pending count', e, stackTrace);
_log.error(
'Failed to get pending count',
error: e,
stackTrace: stackTrace,
);
return 0;
}
}
Expand All @@ -363,7 +391,11 @@ class SyncRepository {
)..where((t) => t.syncStatus.equals('pending'))).go();
_log.info('Cleared pending sync records');
} catch (e, stackTrace) {
_log.error('Failed to clear pending sync records', e, stackTrace);
_log.error(
'Failed to clear pending sync records',
error: e,
stackTrace: stackTrace,
);
rethrow;
}
}
Expand All @@ -374,7 +406,11 @@ class SyncRepository {
final records = await getConflictRecords();
return records.length;
} catch (e, stackTrace) {
_log.error('Failed to get conflict count', e, stackTrace);
_log.error(
'Failed to get conflict count',
error: e,
stackTrace: stackTrace,
);
return 0;
}
}
Expand All @@ -391,8 +427,8 @@ class SyncRepository {
} catch (e, stackTrace) {
_log.error(
'Failed to clear conflict: $entityType/$recordId',
e,
stackTrace,
error: e,
stackTrace: stackTrace,
);
rethrow;
}
Expand All @@ -404,7 +440,11 @@ class SyncRepository {
await _db.delete(_db.syncRecords).go();
_log.info('Cleared all sync records');
} catch (e, stackTrace) {
_log.error('Failed to clear all sync records', e, stackTrace);
_log.error(
'Failed to clear all sync records',
error: e,
stackTrace: stackTrace,
);
rethrow;
}
}
Expand Down Expand Up @@ -438,8 +478,8 @@ class SyncRepository {
} catch (e, stackTrace) {
_log.error(
'Failed to log deletion: $entityType/$recordId',
e,
stackTrace,
error: e,
stackTrace: stackTrace,
);
rethrow;
}
Expand All @@ -454,7 +494,11 @@ class SyncRepository {
);
return query.get();
} catch (e, stackTrace) {
_log.error('Failed to get deletions since: $since', e, stackTrace);
_log.error(
'Failed to get deletions since: $since',
error: e,
stackTrace: stackTrace,
);
rethrow;
}
}
Expand All @@ -464,7 +508,11 @@ class SyncRepository {
try {
return _db.select(_db.deletionLog).get();
} catch (e, stackTrace) {
_log.error('Failed to get all deletions', e, stackTrace);
_log.error(
'Failed to get all deletions',
error: e,
stackTrace: stackTrace,
);
rethrow;
}
}
Expand Down Expand Up @@ -504,7 +552,11 @@ class SyncRepository {

_log.info('Cleared deletions older than $olderThanDays days');
} catch (e, stackTrace) {
_log.error('Failed to clear old deletions', e, stackTrace);
_log.error(
'Failed to clear old deletions',
error: e,
stackTrace: stackTrace,
);
rethrow;
}
}
Expand All @@ -515,7 +567,11 @@ class SyncRepository {
await _db.delete(_db.deletionLog).go();
_log.info('Cleared all deletions');
} catch (e, stackTrace) {
_log.error('Failed to clear all deletions', e, stackTrace);
_log.error(
'Failed to clear all deletions',
error: e,
stackTrace: stackTrace,
);
rethrow;
}
}
Expand Down Expand Up @@ -557,7 +613,11 @@ class SyncRepository {

_log.info('Reset sync state');
} catch (e, stackTrace) {
_log.error('Failed to reset sync state', e, stackTrace);
_log.error(
'Failed to reset sync state',
error: e,
stackTrace: stackTrace,
);
rethrow;
}
}
Expand Down
120 changes: 120 additions & 0 deletions lib/core/models/log_entry.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
/// Log categories for classifying log entries by source.
enum LogCategory {
app('APP', 'App'),
bluetooth('BLE', 'Bluetooth'),
serial('SER', 'Serial'),
libdc('LDC', 'libdc'),
database('DB', 'Database');

final String tag;
final String displayName;

const LogCategory(this.tag, this.displayName);

/// Parse a tag string back to a LogCategory, or null if unknown.
static LogCategory? fromTag(String tag) {
for (final category in values) {
if (category.tag == tag) return category;
}
return null;
}
}

/// Log severity levels, ordered from least to most severe.
enum LogLevel {
debug('DEBUG'),
info('INFO'),
warning('WARN'),
error('ERROR');

final String tag;

const LogLevel(this.tag);

/// Parse a tag string back to a LogLevel, or null if unknown.
static LogLevel? fromTag(String tag) {
for (final level in values) {
if (level.tag == tag) return level;
}
return null;
}
}

/// A single parsed log entry from the log file.
class LogEntry {
final DateTime timestamp;
final LogCategory category;
final LogLevel level;
final String message;

const LogEntry({
required this.timestamp,
required this.category,
required this.level,
required this.message,
});

/// Format: [2026-03-27T14:32:01.123] [BLE] [INFO] Connected to device
static final _logLineRegExp = RegExp(
r'^\[(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3})\] '
r'\[([A-Z]+)\] '
r'\[([A-Z]+)\] '
r'(.+)$',
);

/// Format this entry as a structured log line for file output.
String toLogLine() {
final ts = timestamp.toIso8601String().substring(0, 23);
return '[$ts] [${category.tag}] [${level.tag}] $message';
}

/// Try to parse a log line. Returns null if the line is malformed.
static LogEntry? tryParse(String line) {
final match = _logLineRegExp.firstMatch(line);
if (match == null) return null;

final timestamp = DateTime.tryParse(match.group(1)!);
if (timestamp == null) return null;

final category = LogCategory.fromTag(match.group(2)!);
if (category == null) return null;

final level = LogLevel.fromTag(match.group(3)!);
if (level == null) return null;

return LogEntry(
timestamp: timestamp,
category: category,
level: level,
message: match.group(4)!,
);
}

/// Returns a copy of this entry with the given fields replaced.
LogEntry copyWith({
DateTime? timestamp,
LogCategory? category,
LogLevel? level,
String? message,
}) {
return LogEntry(
timestamp: timestamp ?? this.timestamp,
category: category ?? this.category,
level: level ?? this.level,
message: message ?? this.message,
);
}

@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is LogEntry &&
other.timestamp == timestamp &&
other.category == category &&
other.level == level &&
other.message == message;
}

@override
int get hashCode => Object.hash(timestamp, category, level, message);
}
Loading
Loading