Merge branch 'feature/flutter-logging-system' into dev
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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() ?? [];
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user