diff --git a/apps/AGENTS.md b/apps/AGENTS.md index e42a317..f0f65c4 100644 --- a/apps/AGENTS.md +++ b/apps/AGENTS.md @@ -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 { + final Logger _logger = getLogger('features..'); +} +``` + +### 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 diff --git a/apps/lib/core/logging/error_handler.dart b/apps/lib/core/logging/error_handler.dart new file mode 100644 index 0000000..85ca334 --- /dev/null +++ b/apps/lib/core/logging/error_handler.dart @@ -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); + }; + } +} diff --git a/apps/lib/core/logging/log_config.dart b/apps/lib/core/logging/log_config.dart new file mode 100644 index 0000000..f5a46e4 --- /dev/null +++ b/apps/lib/core/logging/log_config.dart @@ -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', + ); +} diff --git a/apps/lib/core/logging/log_entry.dart b/apps/lib/core/logging/log_entry.dart new file mode 100644 index 0000000..2f3478f --- /dev/null +++ b/apps/lib/core/logging/log_entry.dart @@ -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? 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 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(); + } +} diff --git a/apps/lib/core/logging/log_file_handler.dart b/apps/lib/core/logging/log_file_handler.dart new file mode 100644 index 0000000..a9a0aa3 --- /dev/null +++ b/apps/lib/core/logging/log_file_handler.dart @@ -0,0 +1,36 @@ +import 'dart:io'; +import 'package:path_provider/path_provider.dart'; + +class LogFileHandler { + File? _file; + IOSink? _sink; + + Future 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 flush() async { + await _sink?.flush(); + } + + Future close() async { + await _sink?.close(); + _sink = null; + _file = null; + } + + Future> readAllLines() async { + if (_file == null || !await _file!.exists()) return []; + return await _file!.readAsLines(); + } + + String? get filePath => _file?.path; +} diff --git a/apps/lib/core/logging/log_service.dart b/apps/lib/core/logging/log_service.dart new file mode 100644 index 0000000..3e137cd --- /dev/null +++ b/apps/lib/core/logging/log_service.dart @@ -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 = []; + static const _maxBufferSize = 50; + + LogService._({required LogConfig config}) : _config = config; + + static Future 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? 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? 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? 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? 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> readLogs() async { + return await _fileHandler?.readAllLines() ?? []; + } +} diff --git a/apps/lib/core/logging/logger.dart b/apps/lib/core/logging/logger.dart new file mode 100644 index 0000000..d85fd1b --- /dev/null +++ b/apps/lib/core/logging/logger.dart @@ -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? extra, + StackTrace? stackTrace, + }) { + if (_isNoOp) return; + _service!.debug( + message: message, + module: module, + extra: extra ?? {}, + stackTrace: stackTrace, + ); + } + + void info({ + required String message, + Map? extra, + StackTrace? stackTrace, + }) { + if (_isNoOp) return; + _service!.info( + message: message, + module: module, + extra: extra ?? {}, + stackTrace: stackTrace, + ); + } + + void warning({ + required String message, + Map? 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? 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); diff --git a/apps/lib/features/auth/presentation/bloc/auth_bloc.dart b/apps/lib/features/auth/presentation/bloc/auth_bloc.dart index d275180..1421686 100644 --- a/apps/lib/features/auth/presentation/bloc/auth_bloc.dart +++ b/apps/lib/features/auth/presentation/bloc/auth_bloc.dart @@ -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 { final AuthRepository _repository; + final Logger _logger = getLogger('features.auth.bloc'); AuthBloc(this._repository) : super(AuthInitial()) { on(_onStarted); @@ -29,11 +31,20 @@ class AuthBloc extends Bloc { 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 { } void _onLoggedIn(AuthLoggedIn event, Emitter 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 { ) 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 { AuthSessionInvalidated event, Emitter 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), diff --git a/apps/lib/features/auth/presentation/cubits/login_cubit.dart b/apps/lib/features/auth/presentation/cubits/login_cubit.dart index 5b28e33..e7117c8 100644 --- a/apps/lib/features/auth/presentation/cubits/login_cubit.dart +++ b/apps/lib/features/auth/presentation/cubits/login_cubit.dart @@ -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 { 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 { ), ); 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 { } 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( diff --git a/apps/lib/features/calendar/data/repositories/calendar_repository.dart b/apps/lib/features/calendar/data/repositories/calendar_repository.dart index de3ce67..86341f3 100644 --- a/apps/lib/features/calendar/data/repositories/calendar_repository.dart +++ b/apps/lib/features/calendar/data/repositories/calendar_repository.dart @@ -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> { 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> { } Future getEventById(String id) async { - final response = await _apiClient.get>('$_prefix/$id'); - final data = response.data; - if (data == null) { - throw StateError('Invalid getEventById response: empty payload'); + try { + final response = await _apiClient.get>( + '$_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 getById(String id) { @@ -111,8 +127,18 @@ class CalendarRepository extends CachedRepository> { required bool accept, }) async { final action = accept ? 'accept' : 'reject'; - await _apiClient.post('$_prefix/$itemId/$action'); - await store.clearByPrefix('cache:${CacheScope.token()}:calendar:'); + try { + await _apiClient.post('$_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> _listByRange({ @@ -121,21 +147,34 @@ class CalendarRepository extends CachedRepository> { }) async { final start = Uri.encodeQueryComponent(startAt.toUtc().toIso8601String()); final end = Uri.encodeQueryComponent(endAt.toUtc().toIso8601String()); - final response = await _apiClient.get>( - '$_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>( + '$_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(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(ScheduleItemModel.fromJson) - .toList(growable: false); - await _reminderReconcileService?.reconcileEvents( - events.map(_toReminderSnapshot).toList(growable: false), - ); - return events; } ReminderEventSnapshot _toReminderSnapshot(ScheduleItemModel event) { diff --git a/apps/lib/features/chat/presentation/bloc/chat_bloc.dart b/apps/lib/features/chat/presentation/bloc/chat_bloc.dart index 4e2184d..59564aa 100644 --- a/apps/lib/features/chat/presentation/bloc/chat_bloc.dart +++ b/apps/lib/features/chat/presentation/bloc/chat_bloc.dart @@ -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 implements ChatOrchestrator { + final Logger _logger = getLogger('features.chat.bloc'); + ChatBloc({ AgUiService? service, required ChatApi chatApi, @@ -211,7 +214,16 @@ class ChatBloc extends Cubit 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; } diff --git a/apps/lib/features/chat/presentation/bloc/chat_bloc_attachments.dart b/apps/lib/features/chat/presentation/bloc/chat_bloc_attachments.dart index 59fee76..2976a73 100644 --- a/apps/lib/features/chat/presentation/bloc/chat_bloc_attachments.dart +++ b/apps/lib/features/chat/presentation/bloc/chat_bloc_attachments.dart @@ -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); diff --git a/apps/lib/features/chat/presentation/bloc/chat_bloc_send.dart b/apps/lib/features/chat/presentation/bloc/chat_bloc_send.dart index 1e632d0..b29fc24 100644 --- a/apps/lib/features/chat/presentation/bloc/chat_bloc_send.dart +++ b/apps/lib/features/chat/presentation/bloc/chat_bloc_send.dart @@ -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; } diff --git a/apps/lib/features/contacts/data/repositories/friend_repository.dart b/apps/lib/features/contacts/data/repositories/friend_repository.dart index 07b9a3b..9216716 100644 --- a/apps/lib/features/contacts/data/repositories/friend_repository.dart +++ b/apps/lib/features/contacts/data/repositories/friend_repository.dart @@ -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> 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> } Future> _loadFriendsFromRemote() async { - final response = await _apiClient.get>(_prefix); - final data = response.data; - if (data == null) { - throw StateError('Invalid getFriends response: empty payload'); + try { + final response = await _apiClient.get>(_prefix); + final data = response.data; + if (data == null) { + throw StateError('Invalid getFriends response: empty payload'); + } + return data + .map((item) => item as Map) + .map( + (item) => + FriendUser.fromJson(item['friend'] as Map), + ) + .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) - .map( - (item) => FriendUser.fromJson(item['friend'] as Map), - ) - .toList(growable: false); } @override @@ -55,14 +67,24 @@ class FriendRepositoryImpl extends CachedRepository> } Future _loadRequestById(String friendshipId) async { - final response = await _apiClient.get>( - '$_prefix/requests/$friendshipId', - ); - final data = response.data; - if (data == null) { - throw StateError('Invalid getRequestById response: empty payload'); + try { + final response = await _apiClient.get>( + '$_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> @override Future acceptRequest(String friendshipId) async { - final response = await _apiClient.post>( - '$_prefix/requests/$friendshipId/accept', - ); - final data = response.data; - if (data == null) { - throw StateError('Invalid acceptRequest response: empty payload'); + try { + final response = await _apiClient.post>( + '$_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 declineRequest(String friendshipId) async { - final response = await _apiClient.post>( - '$_prefix/requests/$friendshipId/decline', - ); - final data = response.data; - if (data == null) { - throw StateError('Invalid declineRequest response: empty payload'); + try { + final response = await _apiClient.post>( + '$_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 _invalidateFriendCaches(String friendshipId) { diff --git a/apps/lib/features/home/data/voice_recorder.dart b/apps/lib/features/home/data/voice_recorder.dart index 337679e..0e6cf76 100644 --- a/apps/lib/features/home/data/voice_recorder.dart +++ b/apps/lib/features/home/data/voice_recorder.dart @@ -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 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; diff --git a/apps/lib/features/messages/data/repositories/inbox_repository.dart b/apps/lib/features/messages/data/repositories/inbox_repository.dart index d92d03d..9b76897 100644 --- a/apps/lib/features/messages/data/repositories/inbox_repository.dart +++ b/apps/lib/features/messages/data/repositories/inbox_repository.dart @@ -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> 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> } Future> _loadMessagesFromRemote({bool? isRead}) async { - final queryParams = isRead != null ? '?is_read=$isRead' : ''; - final response = await _apiClient.get>( - '$_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>( + '$_prefix$queryParams', + ); + final data = response.data; + if (data == null) { + throw StateError('Invalid getMessages response: empty payload'); + } + return data + .whereType>() + .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(InboxMessage.fromJson) - .toList(growable: false); } @override Future markAsRead(String messageId) async { - final response = await _apiClient.patch>( - '$_prefix/$messageId/read', - ); - final data = response.data; - if (data == null) { - throw StateError('Invalid markAsRead response: empty payload'); + try { + final response = await _apiClient.patch>( + '$_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) { diff --git a/apps/lib/features/settings/presentation/cubits/automation_jobs_cubit.dart b/apps/lib/features/settings/presentation/cubits/automation_jobs_cubit.dart index 6e4334a..0495d9a 100644 --- a/apps/lib/features/settings/presentation/cubits/automation_jobs_cubit.dart +++ b/apps/lib/features/settings/presentation/cubits/automation_jobs_cubit.dart @@ -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 { final AutomationJobsApi _api; + final Logger _logger = getLogger('features.settings.automation_jobs'); AutomationJobsCubit(this._api) : super(AutomationJobsState()); @@ -50,7 +52,12 @@ class AutomationJobsCubit extends Cubit { 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 { 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 { .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())); } } diff --git a/apps/lib/features/todo/data/repositories/todo_repository.dart b/apps/lib/features/todo/data/repositories/todo_repository.dart index dc64181..9383e16 100644 --- a/apps/lib/features/todo/data/repositories/todo_repository.dart +++ b/apps/lib/features/todo/data/repositories/todo_repository.dart @@ -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> { @@ -10,6 +11,7 @@ class TodoRepository extends CachedRepository> { 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> { 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); } diff --git a/apps/lib/main.dart b/apps/lib/main.dart index 82399c6..f3f19a6 100644 --- a/apps/lib/main.dart +++ b/apps/lib/main.dart @@ -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()); } diff --git a/apps/pubspec.yaml b/apps/pubspec.yaml index 98d37df..40c9f84 100644 --- a/apps/pubspec.yaml +++ b/apps/pubspec.yaml @@ -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: diff --git a/apps/test/app/services/app_prewarm_orchestrator_test.dart b/apps/test/app/services/app_prewarm_orchestrator_test.dart index 0047d75..75afbdd 100644 --- a/apps/test/app/services/app_prewarm_orchestrator_test.dart +++ b/apps/test/app/services/app_prewarm_orchestrator_test.dart @@ -13,12 +13,16 @@ import 'package:social_app/features/messages/data/repositories/inbox_repository. class _FakeApiClient implements IApiClient { @override - Future> delete(String path, {data, Options? options}) { + Future> delete(String path, {dynamic data, Options? options}) { throw UnimplementedError(); } @override - Future> get(String path, {Options? options}) { + Future> get( + String path, { + Map? queryParameters, + Options? options, + }) { throw UnimplementedError(); } @@ -31,17 +35,17 @@ class _FakeApiClient implements IApiClient { } @override - Future> patch(String path, {data, Options? options}) { + Future> patch(String path, {dynamic data, Options? options}) { throw UnimplementedError(); } @override - Future> post(String path, {data, Options? options}) { + Future> post(String path, {dynamic data, Options? options}) { throw UnimplementedError(); } @override - Future> put(String path, {data, Options? options}) { + Future> put(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'); diff --git a/apps/test/core/logging/log_entry_test.dart b/apps/test/core/logging/log_entry_test.dart new file mode 100644 index 0000000..6ef0b5c --- /dev/null +++ b/apps/test/core/logging/log_entry_test.dart @@ -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'); + }); + }); +} diff --git a/apps/test/core/logging/log_service_test.dart b/apps/test/core/logging/log_service_test.dart new file mode 100644 index 0000000..ab0a338 --- /dev/null +++ b/apps/test/core/logging/log_service_test.dart @@ -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(); + }); + }); +} diff --git a/apps/test/data/repositories/shared_repositories_test.dart b/apps/test/data/repositories/shared_repositories_test.dart index 60a7449..9543fb9 100644 --- a/apps/test/data/repositories/shared_repositories_test.dart +++ b/apps/test/data/repositories/shared_repositories_test.dart @@ -22,7 +22,11 @@ class _FakeApiClient implements IApiClient { void setPatch(String path, dynamic data) => _patchResponses[path] = data; @override - Future> get(String path, {Options? options}) async { + Future> get( + String path, { + Map? 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> post(String path, {data, Options? options}) async { + Future> post( + 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> patch(String path, {data, Options? options}) async { + Future> patch( + 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> delete(String path, {data, Options? options}) { + Future> delete(String path, {dynamic data, Options? options}) { throw UnimplementedError(); } @@ -68,7 +80,7 @@ class _FakeApiClient implements IApiClient { } @override - Future> put(String path, {data, Options? options}) { + Future> put(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', }); diff --git a/apps/test/features/settings/data/repositories/user_profile_cache_repository_test.dart b/apps/test/features/settings/data/repositories/user_profile_cache_repository_test.dart index cc143b9..c9f7d37 100644 --- a/apps/test/features/settings/data/repositories/user_profile_cache_repository_test.dart +++ b/apps/test/features/settings/data/repositories/user_profile_cache_repository_test.dart @@ -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(); final repository = UserProfileCacheRepository( store: HybridCacheStore(