Merge branch 'feature/flutter-logging-system' into dev

This commit is contained in:
qzl
2026-04-01 15:11:53 +08:00
25 changed files with 1136 additions and 114 deletions
+88
View File
@@ -111,3 +111,91 @@ This file governs `apps/**` (Flutter). Keep rules strict, short, and reusable.
- Prioritize tests for model parsing, service logic, and high-regression interaction flows.
- Simple static UI changes may skip tests.
- Auth/Home/Cache changes must include targeted regression tests.
## Logging Conventions (Must)
### Logger Setup
```dart
import 'core/logging/logger.dart';
class SomeBloc extends Cubit<SomeState> {
final Logger _logger = getLogger('features.<feature>.<component>');
}
```
### Log Level Policy
| Level | When to Use | Noise Level |
|-------|-------------|-------------|
| **error** | All exceptions and failures - MUST log every error site | Required, never skip |
| **warning** | Degraded behavior, retry, fallback, malformed data | Minimal, only when action taken |
| **info** | Key business events (login, logout, send message) | Minimal, only milestone events |
| **debug** | Detailed flow tracing (only in debug builds) | High, avoid in release |
### Error Logging Requirements
**Every try-catch that handles an exception MUST log it:**
```dart
try {
await _repository.someOperation();
} catch (e, stackTrace) {
_logger.error(
message: 'Operation failed: $operationName',
error: e,
stackTrace: stackTrace,
extra: {'context': 'relevant_data'},
);
// handle error
}
```
### Info Logging Requirements
**Only log these milestone events:**
- User login/logout
- Message sent/received
- Data sync completed
- Important state transitions
```dart
_logger.info(
message: 'User logged in',
extra: {'user_id': user.id},
);
```
### Warning Logging Requirements
**Only log when taking corrective action:**
- Retrying after failure
- Using fallback data
- Skipping malformed data
- Deprecation warnings
```dart
_logger.warning(
message: 'Cache miss, loading from remote',
extra: {'key': cacheKey},
);
```
### Module Naming Convention
| Feature | Module Path |
|---------|------------|
| auth | `features.auth` |
| calendar | `features.calendar` |
| chat | `features.chat` |
| contacts | `features.contacts` |
| home | `features.home` |
| messages | `features.messages` |
| settings | `features.settings` |
| todo | `features.todo` |
### Prohibited Practices
- **Never** log sensitive data: passwords, tokens, PII, message content
- **Never** log at debug level in production (release mode)
- **Never** skip error logging even if you "handle" the error
- **Never** log for every iteration in loops - only on failures
+18
View File
@@ -0,0 +1,18 @@
import 'package:flutter/foundation.dart';
import 'logger.dart';
class AppErrorHandler {
final Logger _logger = getLogger('flutter.error');
void register() {
FlutterError.onError = (details) {
_logger.error(
message: 'FlutterError: ${details.exceptionAsString()}',
error: details.exceptionAsString(),
stackTrace: details.stack ?? StackTrace.current,
extra: {'context': 'FlutterError.onError'},
);
FlutterError.presentError(details);
};
}
}
+27
View File
@@ -0,0 +1,27 @@
import 'log_entry.dart';
enum LogOutput { console, file }
class LogConfig {
final LogLevel minLevel;
final LogOutput output;
final String logFileName;
final String logDir;
const LogConfig({
this.minLevel = LogLevel.debug,
this.output = LogOutput.console,
this.logFileName = 'app.log',
this.logDir = 'logs',
});
static LogConfig forDebug() =>
const LogConfig(minLevel: LogLevel.debug, output: LogOutput.console);
static LogConfig forRelease() => const LogConfig(
minLevel: LogLevel.warning,
output: LogOutput.file,
logFileName: 'app.log',
logDir: 'logs',
);
}
+71
View File
@@ -0,0 +1,71 @@
enum LogLevel { debug, info, warning, error }
class LogEntry {
final DateTime timestamp;
final LogLevel level;
final String message;
final String module;
final String? funcName;
final int? lineNo;
final String? errorType;
final String? stackTrace;
final Map<String, dynamic>? extra;
LogEntry({
required this.timestamp,
required this.level,
required this.message,
required this.module,
this.funcName,
this.lineNo,
this.errorType,
this.stackTrace,
this.extra,
});
Map<String, dynamic> toJson() => {
'timestamp': timestamp.toIso8601String(),
'level': level.name,
'message': message,
'module': module,
if (funcName != null) 'func_name': funcName,
if (lineNo != null) 'line_no': lineNo,
if (errorType != null) 'error_type': errorType,
if (stackTrace != null) 'stack_trace': stackTrace,
if (extra != null && extra!.isNotEmpty) 'extra': extra,
};
String toConsoleString() {
final ts = timestamp.toIso8601String();
final location = [
if (funcName != null) funcName,
if (lineNo != null) '@$lineNo',
].join('');
final locationStr = location.isNotEmpty ? ' [$location]' : '';
final errorStr = errorType != null ? ' [$errorType]' : '';
final extraStr = extra != null && extra!.isNotEmpty ? ' $extra' : '';
return '$ts ${level.name.toUpperCase().padRight(7)} [$module$locationStr]$errorStr $message$extraStr';
}
String toFileString() {
final sb = StringBuffer();
sb.writeln('[$timestamp] ${level.name.toUpperCase()} [$module]');
if (funcName != null || lineNo != null) {
sb.write(' at ${funcName ?? ''}');
if (lineNo != null) sb.write(':$lineNo');
sb.writeln();
}
sb.writeln(' $message');
if (errorType != null) {
sb.writeln(' Error: $errorType');
}
if (stackTrace != null) {
sb.writeln(' StackTrace:');
sb.writeln(stackTrace);
}
if (extra != null && extra!.isNotEmpty) {
sb.writeln(' Extra: $extra');
}
return sb.toString();
}
}
@@ -0,0 +1,36 @@
import 'dart:io';
import 'package:path_provider/path_provider.dart';
class LogFileHandler {
File? _file;
IOSink? _sink;
Future<void> init(String logDir, String logFileName) async {
final dir = await getApplicationDocumentsDirectory();
final logPath = '${dir.path}/$logDir';
await Directory(logPath).create(recursive: true);
_file = File('$logPath/$logFileName');
_sink = _file!.openWrite(mode: FileMode.append);
}
void write(String content) {
_sink?.writeln(content);
}
Future<void> flush() async {
await _sink?.flush();
}
Future<void> close() async {
await _sink?.close();
_sink = null;
_file = null;
}
Future<List<String>> readAllLines() async {
if (_file == null || !await _file!.exists()) return [];
return await _file!.readAsLines();
}
String? get filePath => _file?.path;
}
+171
View File
@@ -0,0 +1,171 @@
import 'package:flutter/foundation.dart';
import 'log_config.dart';
import 'log_entry.dart';
import 'log_file_handler.dart';
class LogService {
final LogConfig _config;
LogFileHandler? _fileHandler;
final _buffer = <String>[];
static const _maxBufferSize = 50;
LogService._({required LogConfig config}) : _config = config;
static Future<LogService> create({LogConfig? config}) async {
final isRelease = kReleaseMode;
final effectiveConfig =
config ?? (isRelease ? LogConfig.forRelease() : LogConfig.forDebug());
final service = LogService._(config: effectiveConfig);
if (effectiveConfig.output == LogOutput.file) {
service._fileHandler = LogFileHandler();
await service._fileHandler!.init(
effectiveConfig.logDir,
effectiveConfig.logFileName,
);
}
return service;
}
String? get logFilePath => _fileHandler?.filePath;
void _log(LogEntry entry) {
if (entry.level.index < _config.minLevel.index) return;
if (_config.output == LogOutput.console) {
debugPrint(entry.toConsoleString());
if (entry.stackTrace != null) {
debugPrint(entry.stackTrace!);
}
} else {
_buffer.add(entry.toFileString());
if (_buffer.length >= _maxBufferSize) {
_flushBuffer();
}
}
}
void _flushBuffer() {
for (final line in _buffer) {
_fileHandler?.write(line);
}
_buffer.clear();
_fileHandler?.flush();
}
(String?, int?) _extractLocation(StackTrace stackTrace) {
final frames = stackTrace.toString().split('\n');
for (final frame in frames) {
if (frame.contains('.dart')) {
final match = RegExp(
r'#\d+\s+(.+?)\s+\((.+?):(\d+)\)',
).firstMatch(frame);
if (match != null) {
return (match.group(1), int.tryParse(match.group(3) ?? ''));
}
}
}
return (null, null);
}
void debug({
required String message,
required String module,
Map<String, dynamic>? extra,
StackTrace? stackTrace,
}) {
final trace = stackTrace ?? StackTrace.current;
final (funcName, lineNo) = _extractLocation(trace);
_log(
LogEntry(
timestamp: DateTime.now(),
level: LogLevel.debug,
message: message,
module: module,
funcName: funcName,
lineNo: lineNo,
extra: extra,
stackTrace: trace.toString(),
),
);
}
void info({
required String message,
required String module,
Map<String, dynamic>? extra,
StackTrace? stackTrace,
}) {
final trace = stackTrace ?? StackTrace.current;
final (funcName, lineNo) = _extractLocation(trace);
_log(
LogEntry(
timestamp: DateTime.now(),
level: LogLevel.info,
message: message,
module: module,
funcName: funcName,
lineNo: lineNo,
extra: extra,
stackTrace: trace.toString(),
),
);
}
void warning({
required String message,
required String module,
Map<String, dynamic>? extra,
StackTrace? stackTrace,
}) {
final trace = stackTrace ?? StackTrace.current;
final (funcName, lineNo) = _extractLocation(trace);
_log(
LogEntry(
timestamp: DateTime.now(),
level: LogLevel.warning,
message: message,
module: module,
funcName: funcName,
lineNo: lineNo,
extra: extra,
stackTrace: trace.toString(),
),
);
}
void error({
required String message,
required Object error,
required StackTrace stackTrace,
required String module,
Map<String, dynamic>? extra,
}) {
final (funcName, lineNo) = _extractLocation(stackTrace);
_log(
LogEntry(
timestamp: DateTime.now(),
level: LogLevel.error,
message: message,
module: module,
funcName: funcName,
lineNo: lineNo,
errorType: error.runtimeType.toString(),
stackTrace: stackTrace.toString(),
extra: extra,
),
);
}
void flush() {
_flushBuffer();
_fileHandler?.flush();
}
Future<List<String>> readLogs() async {
return await _fileHandler?.readAllLines() ?? [];
}
}
+89
View File
@@ -0,0 +1,89 @@
import 'package:flutter/foundation.dart';
import 'log_config.dart';
import 'log_entry.dart';
import 'log_service.dart';
LogService? _globalLogService;
class Logger {
final String module;
final LogService? _service;
final bool _isNoOp;
Logger(this.module, this._service) : _isNoOp = _service == null;
factory Logger.get(String module) {
return Logger(module, _globalLogService);
}
static void setLogService(LogService service) {
_globalLogService = service;
}
static LogService? _ensureService() {
return _globalLogService;
}
void debug({
required String message,
Map<String, dynamic>? extra,
StackTrace? stackTrace,
}) {
if (_isNoOp) return;
_service!.debug(
message: message,
module: module,
extra: extra ?? {},
stackTrace: stackTrace,
);
}
void info({
required String message,
Map<String, dynamic>? extra,
StackTrace? stackTrace,
}) {
if (_isNoOp) return;
_service!.info(
message: message,
module: module,
extra: extra ?? {},
stackTrace: stackTrace,
);
}
void warning({
required String message,
Map<String, dynamic>? extra,
StackTrace? stackTrace,
}) {
if (_isNoOp) return;
_service!.warning(
message: message,
module: module,
extra: extra ?? {},
stackTrace: stackTrace,
);
}
void error({
required String message,
required Object error,
required StackTrace stackTrace,
Map<String, dynamic>? extra,
}) {
if (_isNoOp) {
debugPrint('[$module] ERROR: $message, error: $error');
return;
}
_service!.error(
message: message,
error: error,
stackTrace: stackTrace,
module: module,
extra: extra,
);
}
}
Logger getLogger(String module) => Logger.get(module);
@@ -1,10 +1,12 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../core/logging/logger.dart';
import '../../data/repositories/auth_repository.dart';
import 'auth_event.dart';
import 'auth_state.dart';
class AuthBloc extends Bloc<AuthEvent, AuthState> {
final AuthRepository _repository;
final Logger _logger = getLogger('features.auth.bloc');
AuthBloc(this._repository) : super(AuthInitial()) {
on<AuthStarted>(_onStarted);
@@ -29,11 +31,20 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
emit(
const AuthUnauthenticated(reason: AuthUnauthenticatedReason.signedOut),
);
} catch (_) {
} catch (e, stackTrace) {
_logger.error(
message: 'Session refresh failed',
error: e,
stackTrace: stackTrace,
);
try {
await _repository.clearSessionLocalOnly();
} catch (_) {
// Keep state convergence even when storage cleanup fails.
} catch (e, stackTrace) {
_logger.error(
message: 'Failed to clear local session',
error: e,
stackTrace: stackTrace,
);
} finally {
emit(
const AuthUnauthenticated(
@@ -45,6 +56,7 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
}
void _onLoggedIn(AuthLoggedIn event, Emitter<AuthState> emit) {
_logger.info(message: 'User logged in', extra: {'user_id': event.user.id});
emit(AuthAuthenticated(user: event.user));
}
@@ -54,8 +66,13 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
) async {
try {
await _repository.deleteSession();
} catch (_) {
// Keep state convergence even when logout cleanup fails.
_logger.info(message: 'User logged out');
} catch (e, stackTrace) {
_logger.error(
message: 'Failed to delete session on logout',
error: e,
stackTrace: stackTrace,
);
} finally {
emit(
const AuthUnauthenticated(reason: AuthUnauthenticatedReason.signedOut),
@@ -67,10 +84,15 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
AuthSessionInvalidated event,
Emitter<AuthState> emit,
) async {
_logger.warning(message: 'Session invalidated by server');
try {
await _repository.clearSessionLocalOnly();
} catch (_) {
// Keep state convergence even when local cleanup fails.
} catch (e, stackTrace) {
_logger.error(
message: 'Failed to clear local session',
error: e,
stackTrace: stackTrace,
);
} finally {
emit(
const AuthUnauthenticated(reason: AuthUnauthenticatedReason.expired),
@@ -5,6 +5,7 @@ import 'package:formz/formz.dart';
import 'package:equatable/equatable.dart';
import '../../../../data/network/api_exception.dart';
import '../../../../core/l10n/l10n.dart';
import '../../../../core/logging/logger.dart';
import '../../data/repositories/auth_repository.dart';
import '../../data/models/auth_response.dart';
import '../../../../shared/forms/inputs.dart';
@@ -78,6 +79,7 @@ class LoginState extends Equatable {
class LoginCubit extends Cubit<LoginState> {
final AuthRepository _repository;
final Logger _logger = getLogger('features.auth.login');
Timer? _resendTimer;
LoginCubit(this._repository) : super(const LoginState());
@@ -149,10 +151,16 @@ class LoginCubit extends Cubit<LoginState> {
),
);
return true;
} catch (e) {
} catch (e, stackTrace) {
if (isClosed) {
return false;
}
_logger.error(
message: 'Failed to send OTP',
error: e,
stackTrace: stackTrace,
extra: {'phone': requestPhone},
);
final message = e is ApiException
? e.message
: L10n.current.authSendCodeFailed;
@@ -176,10 +184,11 @@ class LoginCubit extends Cubit<LoginState> {
}
emit(state.copyWith(status: FormzSubmissionStatus.success));
return response;
} catch (e) {
} catch (e, stackTrace) {
if (isClosed) {
return null;
}
_logger.error(message: 'Login failed', error: e, stackTrace: stackTrace);
final message = e is ApiException ? e.message : e.toString();
emit(
state.copyWith(
@@ -4,11 +4,13 @@ import '../../../../data/cache/cache_scope.dart';
import '../../../../data/network/i_api_client.dart';
import '../../../../core/notification/models/reminder_alarm.dart';
import '../../../../core/notification/services/reminder_reconcile_service.dart';
import '../../../../core/logging/logger.dart';
import '../models/schedule_item_model.dart';
class CalendarRepository extends CachedRepository<List<ScheduleItemModel>> {
final IApiClient _apiClient;
final ReminderReconcileService? _reminderReconcileService;
final Logger _logger = getLogger('features.calendar.repository');
static const _prefix = '/api/v1/schedule-items';
CalendarRepository({
@@ -70,14 +72,28 @@ class CalendarRepository extends CachedRepository<List<ScheduleItemModel>> {
}
Future<ScheduleItemModel> getEventById(String id) async {
final response = await _apiClient.get<Map<String, dynamic>>('$_prefix/$id');
final data = response.data;
if (data == null) {
throw StateError('Invalid getEventById response: empty payload');
try {
final response = await _apiClient.get<Map<String, dynamic>>(
'$_prefix/$id',
);
final data = response.data;
if (data == null) {
throw StateError('Invalid getEventById response: empty payload');
}
final event = ScheduleItemModel.fromJson(data);
await _reminderReconcileService?.reconcileEvent(
_toReminderSnapshot(event),
);
return event;
} catch (e, stackTrace) {
_logger.error(
message: 'Failed to get event by id',
error: e,
stackTrace: stackTrace,
extra: {'event_id': id},
);
rethrow;
}
final event = ScheduleItemModel.fromJson(data);
await _reminderReconcileService?.reconcileEvent(_toReminderSnapshot(event));
return event;
}
Future<ScheduleItemModel> getById(String id) {
@@ -111,8 +127,18 @@ class CalendarRepository extends CachedRepository<List<ScheduleItemModel>> {
required bool accept,
}) async {
final action = accept ? 'accept' : 'reject';
await _apiClient.post<void>('$_prefix/$itemId/$action');
await store.clearByPrefix('cache:${CacheScope.token()}:calendar:');
try {
await _apiClient.post<void>('$_prefix/$itemId/$action');
await store.clearByPrefix('cache:${CacheScope.token()}:calendar:');
} catch (e, stackTrace) {
_logger.error(
message: 'Failed to $action subscription',
error: e,
stackTrace: stackTrace,
extra: {'item_id': itemId, 'action': action},
);
rethrow;
}
}
Future<List<ScheduleItemModel>> _listByRange({
@@ -121,21 +147,34 @@ class CalendarRepository extends CachedRepository<List<ScheduleItemModel>> {
}) async {
final start = Uri.encodeQueryComponent(startAt.toUtc().toIso8601String());
final end = Uri.encodeQueryComponent(endAt.toUtc().toIso8601String());
final response = await _apiClient.get<List<dynamic>>(
'$_prefix?start_at=$start&end_at=$end',
);
final data = response.data;
if (data == null) {
throw StateError('Invalid listByRange response: empty payload');
try {
final response = await _apiClient.get<List<dynamic>>(
'$_prefix?start_at=$start&end_at=$end',
);
final data = response.data;
if (data == null) {
throw StateError('Invalid listByRange response: empty payload');
}
final events = data
.whereType<Map<String, dynamic>>()
.map(ScheduleItemModel.fromJson)
.toList(growable: false);
await _reminderReconcileService?.reconcileEvents(
events.map(_toReminderSnapshot).toList(growable: false),
);
return events;
} catch (e, stackTrace) {
_logger.error(
message: 'Failed to list events by range',
error: e,
stackTrace: stackTrace,
extra: {
'start_at': startAt.toIso8601String(),
'end_at': endAt.toIso8601String(),
},
);
rethrow;
}
final events = data
.whereType<Map<String, dynamic>>()
.map(ScheduleItemModel.fromJson)
.toList(growable: false);
await _reminderReconcileService?.reconcileEvents(
events.map(_toReminderSnapshot).toList(growable: false),
);
return events;
}
ReminderEventSnapshot _toReminderSnapshot(ScheduleItemModel event) {
@@ -4,6 +4,7 @@ import 'dart:typed_data';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:image_picker/image_picker.dart';
import 'package:social_app/core/chat/chat_api.dart';
import 'package:social_app/core/logging/logger.dart';
import 'package:social_app/core/chat/agent_stage.dart';
import 'package:social_app/core/chat/ag_ui_event.dart';
import 'package:social_app/core/chat/ag_ui_service.dart';
@@ -107,6 +108,8 @@ class ChatState implements ChatOrchestratorState {
}
class ChatBloc extends Cubit<ChatState> implements ChatOrchestrator {
final Logger _logger = getLogger('features.chat.bloc');
ChatBloc({
AgUiService? service,
required ChatApi chatApi,
@@ -211,7 +214,16 @@ class ChatBloc extends Cubit<ChatState> implements ChatOrchestrator {
final epoch = ++_sessionEpoch;
_activeUserId = normalizedUserId;
await _service.setUserContext(normalizedUserId);
try {
await _service.setUserContext(normalizedUserId);
} catch (e, stackTrace) {
_logger.error(
message: 'Failed to set user context',
error: e,
stackTrace: stackTrace,
extra: {'user_id': normalizedUserId},
);
}
if (epoch != _sessionEpoch) {
return;
}
@@ -75,7 +75,13 @@ extension _ChatBlocAttachments on ChatBloc {
final bytes = await _service.fetchAttachmentPreview(previewPath);
_attachmentPreviewCache[previewPath] = bytes;
return bytes;
} catch (_) {
} catch (e, stackTrace) {
_logger.error(
message: 'Failed to load attachment preview',
error: e,
stackTrace: stackTrace,
extra: {'preview_path': previewPath},
);
return null;
} finally {
_attachmentPreviewInflight.remove(previewPath);
@@ -57,11 +57,21 @@ extension _ChatBlocSend on ChatBloc {
if (epoch != _sessionEpoch) {
return;
}
_logger.info(
message: 'Chat message sent successfully',
extra: {'message_id': messageId},
);
_syncUploadedAttachments(
messageId: messageId,
uploadedAttachments: sendResult.uploadedAttachments,
);
} catch (error) {
} catch (error, stackTrace) {
_logger.error(
message: 'Failed to send chat message',
error: error,
stackTrace: stackTrace,
extra: {'message': content},
);
if (epoch != _sessionEpoch) {
return;
}
@@ -1,6 +1,7 @@
import '../../../../data/network/i_api_client.dart';
import '../../../../data/cache/cache_policy.dart';
import '../../../../data/cache/cached_repository.dart';
import '../../../../core/logging/logger.dart';
import '../models/friend_request.dart';
abstract class FriendRepository {
@@ -16,6 +17,7 @@ abstract class FriendRepository {
class FriendRepositoryImpl extends CachedRepository<List<FriendUser>>
implements FriendRepository {
final IApiClient _apiClient;
final Logger _logger = getLogger('features.contacts.friend_repository');
static const _prefix = '/api/v1/friends';
FriendRepositoryImpl({required IApiClient apiClient, required super.store})
@@ -36,17 +38,27 @@ class FriendRepositoryImpl extends CachedRepository<List<FriendUser>>
}
Future<List<FriendUser>> _loadFriendsFromRemote() async {
final response = await _apiClient.get<List<dynamic>>(_prefix);
final data = response.data;
if (data == null) {
throw StateError('Invalid getFriends response: empty payload');
try {
final response = await _apiClient.get<List<dynamic>>(_prefix);
final data = response.data;
if (data == null) {
throw StateError('Invalid getFriends response: empty payload');
}
return data
.map((item) => item as Map<String, dynamic>)
.map(
(item) =>
FriendUser.fromJson(item['friend'] as Map<String, dynamic>),
)
.toList(growable: false);
} catch (e, stackTrace) {
_logger.error(
message: 'Failed to load friends from remote',
error: e,
stackTrace: stackTrace,
);
rethrow;
}
return data
.map((item) => item as Map<String, dynamic>)
.map(
(item) => FriendUser.fromJson(item['friend'] as Map<String, dynamic>),
)
.toList(growable: false);
}
@override
@@ -55,14 +67,24 @@ class FriendRepositoryImpl extends CachedRepository<List<FriendUser>>
}
Future<FriendRequest> _loadRequestById(String friendshipId) async {
final response = await _apiClient.get<Map<String, dynamic>>(
'$_prefix/requests/$friendshipId',
);
final data = response.data;
if (data == null) {
throw StateError('Invalid getRequestById response: empty payload');
try {
final response = await _apiClient.get<Map<String, dynamic>>(
'$_prefix/requests/$friendshipId',
);
final data = response.data;
if (data == null) {
throw StateError('Invalid getRequestById response: empty payload');
}
return FriendRequest.fromJson(data);
} catch (e, stackTrace) {
_logger.error(
message: 'Failed to load friend request by id',
error: e,
stackTrace: stackTrace,
extra: {'friendship_id': friendshipId},
);
rethrow;
}
return FriendRequest.fromJson(data);
}
@override
@@ -85,30 +107,50 @@ class FriendRepositoryImpl extends CachedRepository<List<FriendUser>>
@override
Future<FriendRequest> acceptRequest(String friendshipId) async {
final response = await _apiClient.post<Map<String, dynamic>>(
'$_prefix/requests/$friendshipId/accept',
);
final data = response.data;
if (data == null) {
throw StateError('Invalid acceptRequest response: empty payload');
try {
final response = await _apiClient.post<Map<String, dynamic>>(
'$_prefix/requests/$friendshipId/accept',
);
final data = response.data;
if (data == null) {
throw StateError('Invalid acceptRequest response: empty payload');
}
final request = FriendRequest.fromJson(data);
await _invalidateFriendCaches(friendshipId);
return request;
} catch (e, stackTrace) {
_logger.error(
message: 'Failed to accept friend request',
error: e,
stackTrace: stackTrace,
extra: {'friendship_id': friendshipId},
);
rethrow;
}
final request = FriendRequest.fromJson(data);
await _invalidateFriendCaches(friendshipId);
return request;
}
@override
Future<FriendRequest> declineRequest(String friendshipId) async {
final response = await _apiClient.post<Map<String, dynamic>>(
'$_prefix/requests/$friendshipId/decline',
);
final data = response.data;
if (data == null) {
throw StateError('Invalid declineRequest response: empty payload');
try {
final response = await _apiClient.post<Map<String, dynamic>>(
'$_prefix/requests/$friendshipId/decline',
);
final data = response.data;
if (data == null) {
throw StateError('Invalid declineRequest response: empty payload');
}
final request = FriendRequest.fromJson(data);
await _invalidateFriendCaches(friendshipId);
return request;
} catch (e, stackTrace) {
_logger.error(
message: 'Failed to decline friend request',
error: e,
stackTrace: stackTrace,
extra: {'friendship_id': friendshipId},
);
rethrow;
}
final request = FriendRequest.fromJson(data);
await _invalidateFriendCaches(friendshipId);
return request;
}
Future<void> _invalidateFriendCaches(String friendshipId) {
@@ -3,7 +3,8 @@ import 'dart:io';
import 'package:flutter/services.dart';
import 'package:record/record.dart';
import '../../../core/l10n/l10n.dart';
import '../../../../core/l10n/l10n.dart';
import '../../../../core/logging/logger.dart';
abstract class VoiceRecorder {
Future<void> start();
@@ -13,6 +14,7 @@ abstract class VoiceRecorder {
class RecordVoiceRecorder implements VoiceRecorder {
final AudioRecorder _recorder;
final Logger _logger = getLogger('features.home.voice_recorder');
String? _currentPath;
RecordVoiceRecorder({AudioRecorder? recorder})
@@ -23,10 +25,16 @@ class RecordVoiceRecorder implements VoiceRecorder {
bool hasPermission;
try {
hasPermission = await _recorder.hasPermission();
} on MissingPluginException catch (_) {
} on MissingPluginException catch (e, stackTrace) {
_logger.error(
message: 'Voice recorder plugin unavailable',
error: e,
stackTrace: stackTrace,
);
throw StateError(L10n.current.homeRecorderPluginUnavailable);
}
if (!hasPermission) {
_logger.debug(message: 'Voice recorder permission denied');
throw StateError(L10n.current.homeRecorderPermissionDenied);
}
@@ -43,7 +51,12 @@ class RecordVoiceRecorder implements VoiceRecorder {
),
path: path,
);
} on MissingPluginException catch (_) {
} on MissingPluginException catch (e, stackTrace) {
_logger.error(
message: 'Failed to start voice recording',
error: e,
stackTrace: stackTrace,
);
throw StateError(L10n.current.homeRecorderPluginUnavailable);
}
}
@@ -53,7 +66,12 @@ class RecordVoiceRecorder implements VoiceRecorder {
String? stoppedPath;
try {
stoppedPath = await _recorder.stop();
} on MissingPluginException catch (_) {
} on MissingPluginException catch (e, stackTrace) {
_logger.error(
message: 'Failed to stop voice recording',
error: e,
stackTrace: stackTrace,
);
throw StateError(L10n.current.homeRecorderPluginUnavailable);
}
return stoppedPath ?? _currentPath;
@@ -1,6 +1,7 @@
import '../../../../data/network/i_api_client.dart';
import '../../../../data/cache/cache_policy.dart';
import '../../../../data/cache/cached_repository.dart';
import '../../../../core/logging/logger.dart';
import '../models/inbox_message.dart';
abstract class InboxRepository {
@@ -14,6 +15,7 @@ abstract class InboxRepository {
class InboxRepositoryImpl extends CachedRepository<List<InboxMessage>>
implements InboxRepository {
final IApiClient _apiClient;
final Logger _logger = getLogger('features.messages.repository');
static const _prefix = '/api/v1/inbox/messages';
InboxRepositoryImpl({required IApiClient apiClient, required super.store})
@@ -41,36 +43,56 @@ class InboxRepositoryImpl extends CachedRepository<List<InboxMessage>>
}
Future<List<InboxMessage>> _loadMessagesFromRemote({bool? isRead}) async {
final queryParams = isRead != null ? '?is_read=$isRead' : '';
final response = await _apiClient.get<List<dynamic>>(
'$_prefix$queryParams',
);
final data = response.data;
if (data == null) {
throw StateError('Invalid getMessages response: empty payload');
try {
final queryParams = isRead != null ? '?is_read=$isRead' : '';
final response = await _apiClient.get<List<dynamic>>(
'$_prefix$queryParams',
);
final data = response.data;
if (data == null) {
throw StateError('Invalid getMessages response: empty payload');
}
return data
.whereType<Map<String, dynamic>>()
.map(InboxMessage.fromJson)
.toList(growable: false);
} catch (e, stackTrace) {
_logger.error(
message: 'Failed to load messages from remote',
error: e,
stackTrace: stackTrace,
extra: {'is_read': isRead},
);
rethrow;
}
return data
.whereType<Map<String, dynamic>>()
.map(InboxMessage.fromJson)
.toList(growable: false);
}
@override
Future<InboxMessage> markAsRead(String messageId) async {
final response = await _apiClient.patch<Map<String, dynamic>>(
'$_prefix/$messageId/read',
);
final data = response.data;
if (data == null) {
throw StateError('Invalid markAsRead response: empty payload');
try {
final response = await _apiClient.patch<Map<String, dynamic>>(
'$_prefix/$messageId/read',
);
final data = response.data;
if (data == null) {
throw StateError('Invalid markAsRead response: empty payload');
}
final message = InboxMessage.fromJson(data);
await Future.wait([
removeCacheKey(_messagesKey(false)),
removeCacheKey(_messagesKey(true)),
removeCacheKey(_messagesKey(null)),
]);
return message;
} catch (e, stackTrace) {
_logger.error(
message: 'Failed to mark message as read',
error: e,
stackTrace: stackTrace,
extra: {'message_id': messageId},
);
rethrow;
}
final message = InboxMessage.fromJson(data);
await Future.wait([
removeCacheKey(_messagesKey(false)),
removeCacheKey(_messagesKey(true)),
removeCacheKey(_messagesKey(null)),
]);
return message;
}
static String _messagesKey(bool? isRead) {
@@ -1,5 +1,6 @@
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../core/logging/logger.dart';
import '../../data/models/automation_job_model.dart';
import '../../data/apis/automation_jobs_api.dart';
@@ -42,6 +43,7 @@ class AutomationJobsState extends Equatable {
class AutomationJobsCubit extends Cubit<AutomationJobsState> {
final AutomationJobsApi _api;
final Logger _logger = getLogger('features.settings.automation_jobs');
AutomationJobsCubit(this._api) : super(AutomationJobsState());
@@ -50,7 +52,12 @@ class AutomationJobsCubit extends Cubit<AutomationJobsState> {
try {
final jobs = await _api.list();
emit(state.copyWith(jobs: jobs, isLoading: false));
} catch (e) {
} catch (e, stackTrace) {
_logger.error(
message: 'Failed to load automation jobs',
error: e,
stackTrace: stackTrace,
);
emit(state.copyWith(isLoading: false, error: e.toString()));
}
}
@@ -59,7 +66,13 @@ class AutomationJobsCubit extends Cubit<AutomationJobsState> {
try {
await _api.delete(id);
await loadJobs();
} catch (e) {
} catch (e, stackTrace) {
_logger.error(
message: 'Failed to delete automation job',
error: e,
stackTrace: stackTrace,
extra: {'job_id': id},
);
emit(state.copyWith(error: e.toString()));
}
}
@@ -77,7 +90,13 @@ class AutomationJobsCubit extends Cubit<AutomationJobsState> {
.map((job) => job.id == id ? updated : job)
.toList();
emit(state.copyWith(jobs: nextJobs));
} catch (e) {
} catch (e, stackTrace) {
_logger.error(
message: 'Failed to update automation job status',
error: e,
stackTrace: stackTrace,
extra: {'job_id': id, 'enabled': enabled},
);
emit(state.copyWith(error: e.toString()));
}
}
@@ -3,6 +3,7 @@ import 'dart:async';
import '../../../../data/cache/cache_store.dart';
import '../../../../data/cache/cache_policy.dart';
import '../../../../data/cache/cached_repository.dart';
import '../../../../core/logging/logger.dart';
import '../apis/todo_api.dart';
class TodoRepository extends CachedRepository<List<TodoResponse>> {
@@ -10,6 +11,7 @@ class TodoRepository extends CachedRepository<List<TodoResponse>> {
final TodoApi api;
final CacheInvalidator invalidator;
final Logger _logger = getLogger('features.todo.repository');
TodoRepository({
required this.api,
@@ -50,7 +52,13 @@ class TodoRepository extends CachedRepository<List<TodoResponse>> {
try {
await api.completeTodo(id);
invalidator.invalidate(pendingListKey);
} catch (error) {
} catch (error, stackTrace) {
_logger.error(
message: 'Failed to complete todo',
error: error,
stackTrace: stackTrace,
extra: {'todo_id': id},
);
if (cached != null) {
await writeCacheEntry(pendingListKey, cached.value);
}
+13
View File
@@ -1,12 +1,25 @@
import 'package:flutter/material.dart';
import 'core/config/env.dart';
import 'core/logging/logger.dart';
import 'core/logging/log_service.dart';
import 'core/logging/error_handler.dart';
import 'app/di/injection.dart';
import 'app/app.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
final logService = await LogService.create();
Logger.setLogService(logService);
AppErrorHandler().register();
await configureDependencies();
await Env.init();
getLogger(
'app',
).info(message: 'App starting...', extra: {'version': Env.version});
runApp(const LinksyApp());
}
+1
View File
@@ -28,6 +28,7 @@ dependencies:
image_picker: ^1.0.7
package_info_plus: ^8.0.3
url_launcher: ^6.3.1
path_provider: ^2.1.2
dev_dependencies:
flutter_test:
@@ -13,12 +13,16 @@ import 'package:social_app/features/messages/data/repositories/inbox_repository.
class _FakeApiClient implements IApiClient {
@override
Future<Response<T>> delete<T>(String path, {data, Options? options}) {
Future<Response<T>> delete<T>(String path, {dynamic data, Options? options}) {
throw UnimplementedError();
}
@override
Future<Response<T>> get<T>(String path, {Options? options}) {
Future<Response<T>> get<T>(
String path, {
Map<String, String>? queryParameters,
Options? options,
}) {
throw UnimplementedError();
}
@@ -31,17 +35,17 @@ class _FakeApiClient implements IApiClient {
}
@override
Future<Response<T>> patch<T>(String path, {data, Options? options}) {
Future<Response<T>> patch<T>(String path, {dynamic data, Options? options}) {
throw UnimplementedError();
}
@override
Future<Response<T>> post<T>(String path, {data, Options? options}) {
Future<Response<T>> post<T>(String path, {dynamic data, Options? options}) {
throw UnimplementedError();
}
@override
Future<Response<T>> put<T>(String path, {data, Options? options}) {
Future<Response<T>> put<T>(String path, {dynamic data, Options? options}) {
throw UnimplementedError();
}
}
@@ -127,6 +131,7 @@ void main() {
prewarmChatHistory: () async {},
prewarmCalendarToday: () async {},
prewarmUnreadInbox: () async {},
prewarmCalendarReminderWindow: () async {},
);
await orchestrator.ensureStartedFor('u1');
@@ -145,6 +150,7 @@ void main() {
prewarmChatHistory: () => completer.future,
prewarmCalendarToday: () => completer.future,
prewarmUnreadInbox: () => completer.future,
prewarmCalendarReminderWindow: () => completer.future,
);
await orchestrator.ensureStartedFor('u1');
+183
View File
@@ -0,0 +1,183 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:social_app/core/logging/log_entry.dart';
void main() {
group('LogEntry', () {
test('toJson should serialize correctly with all fields', () {
final entry = LogEntry(
timestamp: DateTime(2026, 4, 1, 10, 0, 0),
level: LogLevel.error,
message: 'Test error',
module: 'test.module',
funcName: 'testFunc',
lineNo: 42,
errorType: 'TestError',
stackTrace: 'test stack',
extra: {'key': 'value'},
);
final json = entry.toJson();
expect(json['timestamp'], '2026-04-01T10:00:00.000');
expect(json['level'], 'error');
expect(json['message'], 'Test error');
expect(json['module'], 'test.module');
expect(json['func_name'], 'testFunc');
expect(json['line_no'], 42);
expect(json['error_type'], 'TestError');
expect(json['stack_trace'], 'test stack');
expect(json['extra'], {'key': 'value'});
});
test('toJson should omit null fields', () {
final entry = LogEntry(
timestamp: DateTime(2026, 4, 1, 10, 0, 0),
level: LogLevel.info,
message: 'Test',
module: 'test',
);
final json = entry.toJson();
expect(json.containsKey('func_name'), false);
expect(json.containsKey('line_no'), false);
expect(json.containsKey('error_type'), false);
expect(json.containsKey('stack_trace'), false);
expect(json.containsKey('extra'), false);
});
test('toJson should omit extra when empty', () {
final entry = LogEntry(
timestamp: DateTime(2026, 4, 1, 10, 0, 0),
level: LogLevel.info,
message: 'Test',
module: 'test',
extra: {},
);
final json = entry.toJson();
expect(json.containsKey('extra'), false);
});
test('toConsoleString should format debug level correctly', () {
final entry = LogEntry(
timestamp: DateTime(2026, 4, 1, 10, 0, 0),
level: LogLevel.debug,
message: 'Debug message',
module: 'test.module',
funcName: 'testFunc',
lineNo: 42,
);
final result = entry.toConsoleString();
expect(result, contains('DEBUG'));
expect(result, contains('test.module'));
expect(result, contains('testFunc'));
expect(result, contains('@42'));
expect(result, contains('Debug message'));
});
test('toConsoleString should format error level with error type', () {
final entry = LogEntry(
timestamp: DateTime(2026, 4, 1, 10, 0, 0),
level: LogLevel.error,
message: 'Error occurred',
module: 'test.module',
funcName: 'handleError',
lineNo: 100,
errorType: 'SocketException',
);
final result = entry.toConsoleString();
expect(result, contains('ERROR'));
expect(result, contains('[SocketException]'));
expect(result, contains('handleError'));
expect(result, contains('@100'));
});
test('toConsoleString should format without location when null', () {
final entry = LogEntry(
timestamp: DateTime(2026, 4, 1, 10, 0, 0),
level: LogLevel.info,
message: 'Simple message',
module: 'simple.module',
);
final result = entry.toConsoleString();
expect(result, contains('INFO'));
expect(result, contains('simple.module'));
expect(result, contains('Simple message'));
});
test('toFileString should format correctly with all fields', () {
final entry = LogEntry(
timestamp: DateTime(2026, 4, 1, 10, 0, 0),
level: LogLevel.error,
message: 'Test error',
module: 'test.module',
funcName: 'testFunc',
lineNo: 42,
errorType: 'TestError',
stackTrace: 'test stack trace',
extra: {'key': 'value'},
);
final result = entry.toFileString();
expect(result, contains('[test.module]'));
expect(result, contains('at testFunc:42'));
expect(result, contains('Test error'));
expect(result, contains('Error: TestError'));
expect(result, contains('StackTrace:'));
expect(result, contains('test stack trace'));
expect(result, contains('Extra:'));
});
test('toFileString should format with only funcName (no lineNo)', () {
final entry = LogEntry(
timestamp: DateTime(2026, 4, 1, 10, 0, 0),
level: LogLevel.warning,
message: 'Warning message',
module: 'warn.module',
funcName: 'warningFunc',
);
final result = entry.toFileString();
expect(result, contains('WARNING [warn.module]'));
expect(result, contains('at warningFunc'));
expect(result, isNot(contains(':42')));
});
test('toFileString should format with only lineNo (no funcName)', () {
final entry = LogEntry(
timestamp: DateTime(2026, 4, 1, 10, 0, 0),
level: LogLevel.warning,
message: 'Warning at line',
module: 'warn.module',
lineNo: 99,
);
final result = entry.toFileString();
expect(result, contains('at :99'));
});
test('LogLevel ordering is correct', () {
expect(LogLevel.debug.index, lessThan(LogLevel.info.index));
expect(LogLevel.info.index, lessThan(LogLevel.warning.index));
expect(LogLevel.warning.index, lessThan(LogLevel.error.index));
});
test('LogLevel names are lowercase', () {
expect(LogLevel.debug.name, 'debug');
expect(LogLevel.info.name, 'info');
expect(LogLevel.warning.name, 'warning');
expect(LogLevel.error.name, 'error');
});
});
}
@@ -0,0 +1,94 @@
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:social_app/core/logging/log_config.dart';
import 'package:social_app/core/logging/log_entry.dart';
import 'package:social_app/core/logging/log_service.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('LogService', () {
test('LogConfig.forDebug creates correct config', () {
final config = LogConfig.forDebug();
expect(config.minLevel, LogLevel.debug);
expect(config.output, LogOutput.console);
});
test('LogConfig.forRelease creates correct config', () {
final config = LogConfig.forRelease();
expect(config.minLevel, LogLevel.warning);
expect(config.output, LogOutput.file);
expect(config.logFileName, 'app.log');
expect(config.logDir, 'logs');
});
test('LogConfig default values are correct', () {
const config = LogConfig();
expect(config.minLevel, LogLevel.debug);
expect(config.output, LogOutput.console);
expect(config.logFileName, 'app.log');
expect(config.logDir, 'logs');
});
test('LogService with console output logs all levels', () async {
final service = await LogService.create(
config: const LogConfig(minLevel: LogLevel.debug),
);
service.debug(message: 'debug msg', module: 'test');
service.info(message: 'info msg', module: 'test', extra: {'a': 1});
service.warning(message: 'warn msg', module: 'test', extra: {'a': 1});
service.error(
message: 'error msg',
error: Exception('test'),
stackTrace: StackTrace.current,
module: 'test',
);
service.flush();
});
test('LogService filters by minLevel - info', () async {
final service = await LogService.create(
config: const LogConfig(minLevel: LogLevel.info),
);
service.debug(message: 'debug msg', module: 'test');
service.flush();
});
test('error method extracts error type from exception', () async {
final service = await LogService.create(
config: const LogConfig(minLevel: LogLevel.error),
);
try {
throw ArgumentError('test error');
} catch (e, st) {
service.error(
message: 'Test error occurred',
error: e,
stackTrace: st,
module: 'test.module',
extra: {'test': true},
);
}
service.flush();
});
test('debug method captures StackTrace.current', () async {
final service = await LogService.create(
config: const LogConfig(minLevel: LogLevel.debug),
);
service.debug(message: 'Debug with location', module: 'test.location');
service.flush();
});
});
}
@@ -22,7 +22,11 @@ class _FakeApiClient implements IApiClient {
void setPatch(String path, dynamic data) => _patchResponses[path] = data;
@override
Future<Response<T>> get<T>(String path, {Options? options}) async {
Future<Response<T>> get<T>(
String path, {
Map<String, String>? queryParameters,
Options? options,
}) async {
if (!_getResponses.containsKey(path)) {
throw StateError('missing GET mock for $path');
}
@@ -33,7 +37,11 @@ class _FakeApiClient implements IApiClient {
}
@override
Future<Response<T>> post<T>(String path, {data, Options? options}) async {
Future<Response<T>> post<T>(
String path, {
dynamic data,
Options? options,
}) async {
if (!_postResponses.containsKey(path)) {
throw StateError('missing POST mock for $path');
}
@@ -44,7 +52,11 @@ class _FakeApiClient implements IApiClient {
}
@override
Future<Response<T>> patch<T>(String path, {data, Options? options}) async {
Future<Response<T>> patch<T>(
String path, {
dynamic data,
Options? options,
}) async {
if (!_patchResponses.containsKey(path)) {
throw StateError('missing PATCH mock for $path');
}
@@ -55,7 +67,7 @@ class _FakeApiClient implements IApiClient {
}
@override
Future<Response<T>> delete<T>(String path, {data, Options? options}) {
Future<Response<T>> delete<T>(String path, {dynamic data, Options? options}) {
throw UnimplementedError();
}
@@ -68,7 +80,7 @@ class _FakeApiClient implements IApiClient {
}
@override
Future<Response<T>> put<T>(String path, {data, Options? options}) {
Future<Response<T>> put<T>(String path, {dynamic data, Options? options}) {
throw UnimplementedError();
}
}
@@ -111,7 +123,7 @@ void main() {
'id': 'f1',
'sender': {'id': 'u1', 'username': 'alice', 'avatar_url': null},
'recipient': {'id': 'u2', 'username': 'bob', 'avatar_url': null},
'content': 'hi',
'content': {'message': 'hi'},
'status': 'accepted',
'created_at': '2026-03-27T08:00:00Z',
});
@@ -137,7 +149,7 @@ void main() {
'id': 'f1',
'sender': {'id': 'u1', 'username': 'alice', 'avatar_url': null},
'recipient': {'id': 'u2', 'username': 'bob', 'avatar_url': null},
'content': 'hi',
'content': {'message': 'hi'},
'status': 'pending',
'created_at': '2026-03-27T08:00:00Z',
});
@@ -1,6 +1,7 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:social_app/data/cache/cache_scope.dart';
import 'package:social_app/data/cache/cache_store.dart';
import 'package:social_app/features/contacts/data/models/user_profile.dart';
import 'package:social_app/features/settings/data/repositories/user_profile_cache_repository.dart';
@@ -8,6 +9,8 @@ import 'package:social_app/features/settings/data/repositories/user_profile_cach
void main() {
group('UserProfileCacheRepository', () {
test('keeps in-memory snapshot and invalidates correctly', () async {
CacheScope.configureProvider(() => 'test-scope');
addTearDown(() => CacheScope.resetProvider());
var remoteCalls = 0;
final repository = UserProfileCacheRepository(
store: HybridCacheStore(
@@ -40,6 +43,9 @@ void main() {
test(
'invalidate prevents stale in-flight refresh from restoring cache',
() async {
CacheScope.configureProvider(() => 'test-scope-2');
addTearDown(() => CacheScope.resetProvider());
final completer = Completer<UserProfile>();
final repository = UserProfileCacheRepository(
store: HybridCacheStore(