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.
|
- Prioritize tests for model parsing, service logic, and high-regression interaction flows.
|
||||||
- Simple static UI changes may skip tests.
|
- Simple static UI changes may skip tests.
|
||||||
- Auth/Home/Cache changes must include targeted regression 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 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import '../../../../core/logging/logger.dart';
|
||||||
import '../../data/repositories/auth_repository.dart';
|
import '../../data/repositories/auth_repository.dart';
|
||||||
import 'auth_event.dart';
|
import 'auth_event.dart';
|
||||||
import 'auth_state.dart';
|
import 'auth_state.dart';
|
||||||
|
|
||||||
class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
||||||
final AuthRepository _repository;
|
final AuthRepository _repository;
|
||||||
|
final Logger _logger = getLogger('features.auth.bloc');
|
||||||
|
|
||||||
AuthBloc(this._repository) : super(AuthInitial()) {
|
AuthBloc(this._repository) : super(AuthInitial()) {
|
||||||
on<AuthStarted>(_onStarted);
|
on<AuthStarted>(_onStarted);
|
||||||
@@ -29,11 +31,20 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
|||||||
emit(
|
emit(
|
||||||
const AuthUnauthenticated(reason: AuthUnauthenticatedReason.signedOut),
|
const AuthUnauthenticated(reason: AuthUnauthenticatedReason.signedOut),
|
||||||
);
|
);
|
||||||
} catch (_) {
|
} catch (e, stackTrace) {
|
||||||
|
_logger.error(
|
||||||
|
message: 'Session refresh failed',
|
||||||
|
error: e,
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
);
|
||||||
try {
|
try {
|
||||||
await _repository.clearSessionLocalOnly();
|
await _repository.clearSessionLocalOnly();
|
||||||
} catch (_) {
|
} catch (e, stackTrace) {
|
||||||
// Keep state convergence even when storage cleanup fails.
|
_logger.error(
|
||||||
|
message: 'Failed to clear local session',
|
||||||
|
error: e,
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
emit(
|
emit(
|
||||||
const AuthUnauthenticated(
|
const AuthUnauthenticated(
|
||||||
@@ -45,6 +56,7 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _onLoggedIn(AuthLoggedIn event, Emitter<AuthState> emit) {
|
void _onLoggedIn(AuthLoggedIn event, Emitter<AuthState> emit) {
|
||||||
|
_logger.info(message: 'User logged in', extra: {'user_id': event.user.id});
|
||||||
emit(AuthAuthenticated(user: event.user));
|
emit(AuthAuthenticated(user: event.user));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,8 +66,13 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
|||||||
) async {
|
) async {
|
||||||
try {
|
try {
|
||||||
await _repository.deleteSession();
|
await _repository.deleteSession();
|
||||||
} catch (_) {
|
_logger.info(message: 'User logged out');
|
||||||
// Keep state convergence even when logout cleanup fails.
|
} catch (e, stackTrace) {
|
||||||
|
_logger.error(
|
||||||
|
message: 'Failed to delete session on logout',
|
||||||
|
error: e,
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
emit(
|
emit(
|
||||||
const AuthUnauthenticated(reason: AuthUnauthenticatedReason.signedOut),
|
const AuthUnauthenticated(reason: AuthUnauthenticatedReason.signedOut),
|
||||||
@@ -67,10 +84,15 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
|||||||
AuthSessionInvalidated event,
|
AuthSessionInvalidated event,
|
||||||
Emitter<AuthState> emit,
|
Emitter<AuthState> emit,
|
||||||
) async {
|
) async {
|
||||||
|
_logger.warning(message: 'Session invalidated by server');
|
||||||
try {
|
try {
|
||||||
await _repository.clearSessionLocalOnly();
|
await _repository.clearSessionLocalOnly();
|
||||||
} catch (_) {
|
} catch (e, stackTrace) {
|
||||||
// Keep state convergence even when local cleanup fails.
|
_logger.error(
|
||||||
|
message: 'Failed to clear local session',
|
||||||
|
error: e,
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
emit(
|
emit(
|
||||||
const AuthUnauthenticated(reason: AuthUnauthenticatedReason.expired),
|
const AuthUnauthenticated(reason: AuthUnauthenticatedReason.expired),
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'package:formz/formz.dart';
|
|||||||
import 'package:equatable/equatable.dart';
|
import 'package:equatable/equatable.dart';
|
||||||
import '../../../../data/network/api_exception.dart';
|
import '../../../../data/network/api_exception.dart';
|
||||||
import '../../../../core/l10n/l10n.dart';
|
import '../../../../core/l10n/l10n.dart';
|
||||||
|
import '../../../../core/logging/logger.dart';
|
||||||
import '../../data/repositories/auth_repository.dart';
|
import '../../data/repositories/auth_repository.dart';
|
||||||
import '../../data/models/auth_response.dart';
|
import '../../data/models/auth_response.dart';
|
||||||
import '../../../../shared/forms/inputs.dart';
|
import '../../../../shared/forms/inputs.dart';
|
||||||
@@ -78,6 +79,7 @@ class LoginState extends Equatable {
|
|||||||
|
|
||||||
class LoginCubit extends Cubit<LoginState> {
|
class LoginCubit extends Cubit<LoginState> {
|
||||||
final AuthRepository _repository;
|
final AuthRepository _repository;
|
||||||
|
final Logger _logger = getLogger('features.auth.login');
|
||||||
Timer? _resendTimer;
|
Timer? _resendTimer;
|
||||||
|
|
||||||
LoginCubit(this._repository) : super(const LoginState());
|
LoginCubit(this._repository) : super(const LoginState());
|
||||||
@@ -149,10 +151,16 @@ class LoginCubit extends Cubit<LoginState> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e, stackTrace) {
|
||||||
if (isClosed) {
|
if (isClosed) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
_logger.error(
|
||||||
|
message: 'Failed to send OTP',
|
||||||
|
error: e,
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
extra: {'phone': requestPhone},
|
||||||
|
);
|
||||||
final message = e is ApiException
|
final message = e is ApiException
|
||||||
? e.message
|
? e.message
|
||||||
: L10n.current.authSendCodeFailed;
|
: L10n.current.authSendCodeFailed;
|
||||||
@@ -176,10 +184,11 @@ class LoginCubit extends Cubit<LoginState> {
|
|||||||
}
|
}
|
||||||
emit(state.copyWith(status: FormzSubmissionStatus.success));
|
emit(state.copyWith(status: FormzSubmissionStatus.success));
|
||||||
return response;
|
return response;
|
||||||
} catch (e) {
|
} catch (e, stackTrace) {
|
||||||
if (isClosed) {
|
if (isClosed) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
_logger.error(message: 'Login failed', error: e, stackTrace: stackTrace);
|
||||||
final message = e is ApiException ? e.message : e.toString();
|
final message = e is ApiException ? e.message : e.toString();
|
||||||
emit(
|
emit(
|
||||||
state.copyWith(
|
state.copyWith(
|
||||||
|
|||||||
@@ -4,11 +4,13 @@ import '../../../../data/cache/cache_scope.dart';
|
|||||||
import '../../../../data/network/i_api_client.dart';
|
import '../../../../data/network/i_api_client.dart';
|
||||||
import '../../../../core/notification/models/reminder_alarm.dart';
|
import '../../../../core/notification/models/reminder_alarm.dart';
|
||||||
import '../../../../core/notification/services/reminder_reconcile_service.dart';
|
import '../../../../core/notification/services/reminder_reconcile_service.dart';
|
||||||
|
import '../../../../core/logging/logger.dart';
|
||||||
import '../models/schedule_item_model.dart';
|
import '../models/schedule_item_model.dart';
|
||||||
|
|
||||||
class CalendarRepository extends CachedRepository<List<ScheduleItemModel>> {
|
class CalendarRepository extends CachedRepository<List<ScheduleItemModel>> {
|
||||||
final IApiClient _apiClient;
|
final IApiClient _apiClient;
|
||||||
final ReminderReconcileService? _reminderReconcileService;
|
final ReminderReconcileService? _reminderReconcileService;
|
||||||
|
final Logger _logger = getLogger('features.calendar.repository');
|
||||||
static const _prefix = '/api/v1/schedule-items';
|
static const _prefix = '/api/v1/schedule-items';
|
||||||
|
|
||||||
CalendarRepository({
|
CalendarRepository({
|
||||||
@@ -70,14 +72,28 @@ class CalendarRepository extends CachedRepository<List<ScheduleItemModel>> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<ScheduleItemModel> getEventById(String id) async {
|
Future<ScheduleItemModel> getEventById(String id) async {
|
||||||
final response = await _apiClient.get<Map<String, dynamic>>('$_prefix/$id');
|
try {
|
||||||
final data = response.data;
|
final response = await _apiClient.get<Map<String, dynamic>>(
|
||||||
if (data == null) {
|
'$_prefix/$id',
|
||||||
throw StateError('Invalid getEventById response: empty payload');
|
);
|
||||||
|
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) {
|
Future<ScheduleItemModel> getById(String id) {
|
||||||
@@ -111,8 +127,18 @@ class CalendarRepository extends CachedRepository<List<ScheduleItemModel>> {
|
|||||||
required bool accept,
|
required bool accept,
|
||||||
}) async {
|
}) async {
|
||||||
final action = accept ? 'accept' : 'reject';
|
final action = accept ? 'accept' : 'reject';
|
||||||
await _apiClient.post<void>('$_prefix/$itemId/$action');
|
try {
|
||||||
await store.clearByPrefix('cache:${CacheScope.token()}:calendar:');
|
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({
|
Future<List<ScheduleItemModel>> _listByRange({
|
||||||
@@ -121,21 +147,34 @@ class CalendarRepository extends CachedRepository<List<ScheduleItemModel>> {
|
|||||||
}) async {
|
}) async {
|
||||||
final start = Uri.encodeQueryComponent(startAt.toUtc().toIso8601String());
|
final start = Uri.encodeQueryComponent(startAt.toUtc().toIso8601String());
|
||||||
final end = Uri.encodeQueryComponent(endAt.toUtc().toIso8601String());
|
final end = Uri.encodeQueryComponent(endAt.toUtc().toIso8601String());
|
||||||
final response = await _apiClient.get<List<dynamic>>(
|
try {
|
||||||
'$_prefix?start_at=$start&end_at=$end',
|
final response = await _apiClient.get<List<dynamic>>(
|
||||||
);
|
'$_prefix?start_at=$start&end_at=$end',
|
||||||
final data = response.data;
|
);
|
||||||
if (data == null) {
|
final data = response.data;
|
||||||
throw StateError('Invalid listByRange response: empty payload');
|
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) {
|
ReminderEventSnapshot _toReminderSnapshot(ScheduleItemModel event) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'dart:typed_data';
|
|||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:image_picker/image_picker.dart';
|
import 'package:image_picker/image_picker.dart';
|
||||||
import 'package:social_app/core/chat/chat_api.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/agent_stage.dart';
|
||||||
import 'package:social_app/core/chat/ag_ui_event.dart';
|
import 'package:social_app/core/chat/ag_ui_event.dart';
|
||||||
import 'package:social_app/core/chat/ag_ui_service.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 {
|
class ChatBloc extends Cubit<ChatState> implements ChatOrchestrator {
|
||||||
|
final Logger _logger = getLogger('features.chat.bloc');
|
||||||
|
|
||||||
ChatBloc({
|
ChatBloc({
|
||||||
AgUiService? service,
|
AgUiService? service,
|
||||||
required ChatApi chatApi,
|
required ChatApi chatApi,
|
||||||
@@ -211,7 +214,16 @@ class ChatBloc extends Cubit<ChatState> implements ChatOrchestrator {
|
|||||||
|
|
||||||
final epoch = ++_sessionEpoch;
|
final epoch = ++_sessionEpoch;
|
||||||
_activeUserId = normalizedUserId;
|
_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) {
|
if (epoch != _sessionEpoch) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,7 +75,13 @@ extension _ChatBlocAttachments on ChatBloc {
|
|||||||
final bytes = await _service.fetchAttachmentPreview(previewPath);
|
final bytes = await _service.fetchAttachmentPreview(previewPath);
|
||||||
_attachmentPreviewCache[previewPath] = bytes;
|
_attachmentPreviewCache[previewPath] = bytes;
|
||||||
return 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;
|
return null;
|
||||||
} finally {
|
} finally {
|
||||||
_attachmentPreviewInflight.remove(previewPath);
|
_attachmentPreviewInflight.remove(previewPath);
|
||||||
|
|||||||
@@ -57,11 +57,21 @@ extension _ChatBlocSend on ChatBloc {
|
|||||||
if (epoch != _sessionEpoch) {
|
if (epoch != _sessionEpoch) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
_logger.info(
|
||||||
|
message: 'Chat message sent successfully',
|
||||||
|
extra: {'message_id': messageId},
|
||||||
|
);
|
||||||
_syncUploadedAttachments(
|
_syncUploadedAttachments(
|
||||||
messageId: messageId,
|
messageId: messageId,
|
||||||
uploadedAttachments: sendResult.uploadedAttachments,
|
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) {
|
if (epoch != _sessionEpoch) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import '../../../../data/network/i_api_client.dart';
|
import '../../../../data/network/i_api_client.dart';
|
||||||
import '../../../../data/cache/cache_policy.dart';
|
import '../../../../data/cache/cache_policy.dart';
|
||||||
import '../../../../data/cache/cached_repository.dart';
|
import '../../../../data/cache/cached_repository.dart';
|
||||||
|
import '../../../../core/logging/logger.dart';
|
||||||
import '../models/friend_request.dart';
|
import '../models/friend_request.dart';
|
||||||
|
|
||||||
abstract class FriendRepository {
|
abstract class FriendRepository {
|
||||||
@@ -16,6 +17,7 @@ abstract class FriendRepository {
|
|||||||
class FriendRepositoryImpl extends CachedRepository<List<FriendUser>>
|
class FriendRepositoryImpl extends CachedRepository<List<FriendUser>>
|
||||||
implements FriendRepository {
|
implements FriendRepository {
|
||||||
final IApiClient _apiClient;
|
final IApiClient _apiClient;
|
||||||
|
final Logger _logger = getLogger('features.contacts.friend_repository');
|
||||||
static const _prefix = '/api/v1/friends';
|
static const _prefix = '/api/v1/friends';
|
||||||
|
|
||||||
FriendRepositoryImpl({required IApiClient apiClient, required super.store})
|
FriendRepositoryImpl({required IApiClient apiClient, required super.store})
|
||||||
@@ -36,17 +38,27 @@ class FriendRepositoryImpl extends CachedRepository<List<FriendUser>>
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<List<FriendUser>> _loadFriendsFromRemote() async {
|
Future<List<FriendUser>> _loadFriendsFromRemote() async {
|
||||||
final response = await _apiClient.get<List<dynamic>>(_prefix);
|
try {
|
||||||
final data = response.data;
|
final response = await _apiClient.get<List<dynamic>>(_prefix);
|
||||||
if (data == null) {
|
final data = response.data;
|
||||||
throw StateError('Invalid getFriends response: empty payload');
|
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
|
@override
|
||||||
@@ -55,14 +67,24 @@ class FriendRepositoryImpl extends CachedRepository<List<FriendUser>>
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<FriendRequest> _loadRequestById(String friendshipId) async {
|
Future<FriendRequest> _loadRequestById(String friendshipId) async {
|
||||||
final response = await _apiClient.get<Map<String, dynamic>>(
|
try {
|
||||||
'$_prefix/requests/$friendshipId',
|
final response = await _apiClient.get<Map<String, dynamic>>(
|
||||||
);
|
'$_prefix/requests/$friendshipId',
|
||||||
final data = response.data;
|
);
|
||||||
if (data == null) {
|
final data = response.data;
|
||||||
throw StateError('Invalid getRequestById response: empty payload');
|
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
|
@override
|
||||||
@@ -85,30 +107,50 @@ class FriendRepositoryImpl extends CachedRepository<List<FriendUser>>
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<FriendRequest> acceptRequest(String friendshipId) async {
|
Future<FriendRequest> acceptRequest(String friendshipId) async {
|
||||||
final response = await _apiClient.post<Map<String, dynamic>>(
|
try {
|
||||||
'$_prefix/requests/$friendshipId/accept',
|
final response = await _apiClient.post<Map<String, dynamic>>(
|
||||||
);
|
'$_prefix/requests/$friendshipId/accept',
|
||||||
final data = response.data;
|
);
|
||||||
if (data == null) {
|
final data = response.data;
|
||||||
throw StateError('Invalid acceptRequest response: empty payload');
|
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
|
@override
|
||||||
Future<FriendRequest> declineRequest(String friendshipId) async {
|
Future<FriendRequest> declineRequest(String friendshipId) async {
|
||||||
final response = await _apiClient.post<Map<String, dynamic>>(
|
try {
|
||||||
'$_prefix/requests/$friendshipId/decline',
|
final response = await _apiClient.post<Map<String, dynamic>>(
|
||||||
);
|
'$_prefix/requests/$friendshipId/decline',
|
||||||
final data = response.data;
|
);
|
||||||
if (data == null) {
|
final data = response.data;
|
||||||
throw StateError('Invalid declineRequest response: empty payload');
|
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) {
|
Future<void> _invalidateFriendCaches(String friendshipId) {
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import 'dart:io';
|
|||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:record/record.dart';
|
import 'package:record/record.dart';
|
||||||
|
|
||||||
import '../../../core/l10n/l10n.dart';
|
import '../../../../core/l10n/l10n.dart';
|
||||||
|
import '../../../../core/logging/logger.dart';
|
||||||
|
|
||||||
abstract class VoiceRecorder {
|
abstract class VoiceRecorder {
|
||||||
Future<void> start();
|
Future<void> start();
|
||||||
@@ -13,6 +14,7 @@ abstract class VoiceRecorder {
|
|||||||
|
|
||||||
class RecordVoiceRecorder implements VoiceRecorder {
|
class RecordVoiceRecorder implements VoiceRecorder {
|
||||||
final AudioRecorder _recorder;
|
final AudioRecorder _recorder;
|
||||||
|
final Logger _logger = getLogger('features.home.voice_recorder');
|
||||||
String? _currentPath;
|
String? _currentPath;
|
||||||
|
|
||||||
RecordVoiceRecorder({AudioRecorder? recorder})
|
RecordVoiceRecorder({AudioRecorder? recorder})
|
||||||
@@ -23,10 +25,16 @@ class RecordVoiceRecorder implements VoiceRecorder {
|
|||||||
bool hasPermission;
|
bool hasPermission;
|
||||||
try {
|
try {
|
||||||
hasPermission = await _recorder.hasPermission();
|
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);
|
throw StateError(L10n.current.homeRecorderPluginUnavailable);
|
||||||
}
|
}
|
||||||
if (!hasPermission) {
|
if (!hasPermission) {
|
||||||
|
_logger.debug(message: 'Voice recorder permission denied');
|
||||||
throw StateError(L10n.current.homeRecorderPermissionDenied);
|
throw StateError(L10n.current.homeRecorderPermissionDenied);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,7 +51,12 @@ class RecordVoiceRecorder implements VoiceRecorder {
|
|||||||
),
|
),
|
||||||
path: path,
|
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);
|
throw StateError(L10n.current.homeRecorderPluginUnavailable);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -53,7 +66,12 @@ class RecordVoiceRecorder implements VoiceRecorder {
|
|||||||
String? stoppedPath;
|
String? stoppedPath;
|
||||||
try {
|
try {
|
||||||
stoppedPath = await _recorder.stop();
|
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);
|
throw StateError(L10n.current.homeRecorderPluginUnavailable);
|
||||||
}
|
}
|
||||||
return stoppedPath ?? _currentPath;
|
return stoppedPath ?? _currentPath;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import '../../../../data/network/i_api_client.dart';
|
import '../../../../data/network/i_api_client.dart';
|
||||||
import '../../../../data/cache/cache_policy.dart';
|
import '../../../../data/cache/cache_policy.dart';
|
||||||
import '../../../../data/cache/cached_repository.dart';
|
import '../../../../data/cache/cached_repository.dart';
|
||||||
|
import '../../../../core/logging/logger.dart';
|
||||||
import '../models/inbox_message.dart';
|
import '../models/inbox_message.dart';
|
||||||
|
|
||||||
abstract class InboxRepository {
|
abstract class InboxRepository {
|
||||||
@@ -14,6 +15,7 @@ abstract class InboxRepository {
|
|||||||
class InboxRepositoryImpl extends CachedRepository<List<InboxMessage>>
|
class InboxRepositoryImpl extends CachedRepository<List<InboxMessage>>
|
||||||
implements InboxRepository {
|
implements InboxRepository {
|
||||||
final IApiClient _apiClient;
|
final IApiClient _apiClient;
|
||||||
|
final Logger _logger = getLogger('features.messages.repository');
|
||||||
static const _prefix = '/api/v1/inbox/messages';
|
static const _prefix = '/api/v1/inbox/messages';
|
||||||
|
|
||||||
InboxRepositoryImpl({required IApiClient apiClient, required super.store})
|
InboxRepositoryImpl({required IApiClient apiClient, required super.store})
|
||||||
@@ -41,36 +43,56 @@ class InboxRepositoryImpl extends CachedRepository<List<InboxMessage>>
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<List<InboxMessage>> _loadMessagesFromRemote({bool? isRead}) async {
|
Future<List<InboxMessage>> _loadMessagesFromRemote({bool? isRead}) async {
|
||||||
final queryParams = isRead != null ? '?is_read=$isRead' : '';
|
try {
|
||||||
final response = await _apiClient.get<List<dynamic>>(
|
final queryParams = isRead != null ? '?is_read=$isRead' : '';
|
||||||
'$_prefix$queryParams',
|
final response = await _apiClient.get<List<dynamic>>(
|
||||||
);
|
'$_prefix$queryParams',
|
||||||
final data = response.data;
|
);
|
||||||
if (data == null) {
|
final data = response.data;
|
||||||
throw StateError('Invalid getMessages response: empty payload');
|
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
|
@override
|
||||||
Future<InboxMessage> markAsRead(String messageId) async {
|
Future<InboxMessage> markAsRead(String messageId) async {
|
||||||
final response = await _apiClient.patch<Map<String, dynamic>>(
|
try {
|
||||||
'$_prefix/$messageId/read',
|
final response = await _apiClient.patch<Map<String, dynamic>>(
|
||||||
);
|
'$_prefix/$messageId/read',
|
||||||
final data = response.data;
|
);
|
||||||
if (data == null) {
|
final data = response.data;
|
||||||
throw StateError('Invalid markAsRead response: empty payload');
|
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) {
|
static String _messagesKey(bool? isRead) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:equatable/equatable.dart';
|
import 'package:equatable/equatable.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import '../../../../core/logging/logger.dart';
|
||||||
import '../../data/models/automation_job_model.dart';
|
import '../../data/models/automation_job_model.dart';
|
||||||
import '../../data/apis/automation_jobs_api.dart';
|
import '../../data/apis/automation_jobs_api.dart';
|
||||||
|
|
||||||
@@ -42,6 +43,7 @@ class AutomationJobsState extends Equatable {
|
|||||||
|
|
||||||
class AutomationJobsCubit extends Cubit<AutomationJobsState> {
|
class AutomationJobsCubit extends Cubit<AutomationJobsState> {
|
||||||
final AutomationJobsApi _api;
|
final AutomationJobsApi _api;
|
||||||
|
final Logger _logger = getLogger('features.settings.automation_jobs');
|
||||||
|
|
||||||
AutomationJobsCubit(this._api) : super(AutomationJobsState());
|
AutomationJobsCubit(this._api) : super(AutomationJobsState());
|
||||||
|
|
||||||
@@ -50,7 +52,12 @@ class AutomationJobsCubit extends Cubit<AutomationJobsState> {
|
|||||||
try {
|
try {
|
||||||
final jobs = await _api.list();
|
final jobs = await _api.list();
|
||||||
emit(state.copyWith(jobs: jobs, isLoading: false));
|
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()));
|
emit(state.copyWith(isLoading: false, error: e.toString()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -59,7 +66,13 @@ class AutomationJobsCubit extends Cubit<AutomationJobsState> {
|
|||||||
try {
|
try {
|
||||||
await _api.delete(id);
|
await _api.delete(id);
|
||||||
await loadJobs();
|
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()));
|
emit(state.copyWith(error: e.toString()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -77,7 +90,13 @@ class AutomationJobsCubit extends Cubit<AutomationJobsState> {
|
|||||||
.map((job) => job.id == id ? updated : job)
|
.map((job) => job.id == id ? updated : job)
|
||||||
.toList();
|
.toList();
|
||||||
emit(state.copyWith(jobs: nextJobs));
|
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()));
|
emit(state.copyWith(error: e.toString()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'dart:async';
|
|||||||
import '../../../../data/cache/cache_store.dart';
|
import '../../../../data/cache/cache_store.dart';
|
||||||
import '../../../../data/cache/cache_policy.dart';
|
import '../../../../data/cache/cache_policy.dart';
|
||||||
import '../../../../data/cache/cached_repository.dart';
|
import '../../../../data/cache/cached_repository.dart';
|
||||||
|
import '../../../../core/logging/logger.dart';
|
||||||
import '../apis/todo_api.dart';
|
import '../apis/todo_api.dart';
|
||||||
|
|
||||||
class TodoRepository extends CachedRepository<List<TodoResponse>> {
|
class TodoRepository extends CachedRepository<List<TodoResponse>> {
|
||||||
@@ -10,6 +11,7 @@ class TodoRepository extends CachedRepository<List<TodoResponse>> {
|
|||||||
|
|
||||||
final TodoApi api;
|
final TodoApi api;
|
||||||
final CacheInvalidator invalidator;
|
final CacheInvalidator invalidator;
|
||||||
|
final Logger _logger = getLogger('features.todo.repository');
|
||||||
|
|
||||||
TodoRepository({
|
TodoRepository({
|
||||||
required this.api,
|
required this.api,
|
||||||
@@ -50,7 +52,13 @@ class TodoRepository extends CachedRepository<List<TodoResponse>> {
|
|||||||
try {
|
try {
|
||||||
await api.completeTodo(id);
|
await api.completeTodo(id);
|
||||||
invalidator.invalidate(pendingListKey);
|
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) {
|
if (cached != null) {
|
||||||
await writeCacheEntry(pendingListKey, cached.value);
|
await writeCacheEntry(pendingListKey, cached.value);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,25 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'core/config/env.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/di/injection.dart';
|
||||||
import 'app/app.dart';
|
import 'app/app.dart';
|
||||||
|
|
||||||
void main() async {
|
void main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
final logService = await LogService.create();
|
||||||
|
Logger.setLogService(logService);
|
||||||
|
|
||||||
|
AppErrorHandler().register();
|
||||||
|
|
||||||
await configureDependencies();
|
await configureDependencies();
|
||||||
await Env.init();
|
await Env.init();
|
||||||
|
|
||||||
|
getLogger(
|
||||||
|
'app',
|
||||||
|
).info(message: 'App starting...', extra: {'version': Env.version});
|
||||||
|
|
||||||
runApp(const LinksyApp());
|
runApp(const LinksyApp());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ dependencies:
|
|||||||
image_picker: ^1.0.7
|
image_picker: ^1.0.7
|
||||||
package_info_plus: ^8.0.3
|
package_info_plus: ^8.0.3
|
||||||
url_launcher: ^6.3.1
|
url_launcher: ^6.3.1
|
||||||
|
path_provider: ^2.1.2
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
@@ -13,12 +13,16 @@ import 'package:social_app/features/messages/data/repositories/inbox_repository.
|
|||||||
|
|
||||||
class _FakeApiClient implements IApiClient {
|
class _FakeApiClient implements IApiClient {
|
||||||
@override
|
@override
|
||||||
Future<Response<T>> delete<T>(String path, {data, Options? options}) {
|
Future<Response<T>> delete<T>(String path, {dynamic data, Options? options}) {
|
||||||
throw UnimplementedError();
|
throw UnimplementedError();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@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();
|
throw UnimplementedError();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,17 +35,17 @@ class _FakeApiClient implements IApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Response<T>> patch<T>(String path, {data, Options? options}) {
|
Future<Response<T>> patch<T>(String path, {dynamic data, Options? options}) {
|
||||||
throw UnimplementedError();
|
throw UnimplementedError();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Response<T>> post<T>(String path, {data, Options? options}) {
|
Future<Response<T>> post<T>(String path, {dynamic data, Options? options}) {
|
||||||
throw UnimplementedError();
|
throw UnimplementedError();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Response<T>> put<T>(String path, {data, Options? options}) {
|
Future<Response<T>> put<T>(String path, {dynamic data, Options? options}) {
|
||||||
throw UnimplementedError();
|
throw UnimplementedError();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -127,6 +131,7 @@ void main() {
|
|||||||
prewarmChatHistory: () async {},
|
prewarmChatHistory: () async {},
|
||||||
prewarmCalendarToday: () async {},
|
prewarmCalendarToday: () async {},
|
||||||
prewarmUnreadInbox: () async {},
|
prewarmUnreadInbox: () async {},
|
||||||
|
prewarmCalendarReminderWindow: () async {},
|
||||||
);
|
);
|
||||||
|
|
||||||
await orchestrator.ensureStartedFor('u1');
|
await orchestrator.ensureStartedFor('u1');
|
||||||
@@ -145,6 +150,7 @@ void main() {
|
|||||||
prewarmChatHistory: () => completer.future,
|
prewarmChatHistory: () => completer.future,
|
||||||
prewarmCalendarToday: () => completer.future,
|
prewarmCalendarToday: () => completer.future,
|
||||||
prewarmUnreadInbox: () => completer.future,
|
prewarmUnreadInbox: () => completer.future,
|
||||||
|
prewarmCalendarReminderWindow: () => completer.future,
|
||||||
);
|
);
|
||||||
|
|
||||||
await orchestrator.ensureStartedFor('u1');
|
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;
|
void setPatch(String path, dynamic data) => _patchResponses[path] = data;
|
||||||
|
|
||||||
@override
|
@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)) {
|
if (!_getResponses.containsKey(path)) {
|
||||||
throw StateError('missing GET mock for $path');
|
throw StateError('missing GET mock for $path');
|
||||||
}
|
}
|
||||||
@@ -33,7 +37,11 @@ class _FakeApiClient implements IApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@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)) {
|
if (!_postResponses.containsKey(path)) {
|
||||||
throw StateError('missing POST mock for $path');
|
throw StateError('missing POST mock for $path');
|
||||||
}
|
}
|
||||||
@@ -44,7 +52,11 @@ class _FakeApiClient implements IApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@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)) {
|
if (!_patchResponses.containsKey(path)) {
|
||||||
throw StateError('missing PATCH mock for $path');
|
throw StateError('missing PATCH mock for $path');
|
||||||
}
|
}
|
||||||
@@ -55,7 +67,7 @@ class _FakeApiClient implements IApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Response<T>> delete<T>(String path, {data, Options? options}) {
|
Future<Response<T>> delete<T>(String path, {dynamic data, Options? options}) {
|
||||||
throw UnimplementedError();
|
throw UnimplementedError();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,7 +80,7 @@ class _FakeApiClient implements IApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Response<T>> put<T>(String path, {data, Options? options}) {
|
Future<Response<T>> put<T>(String path, {dynamic data, Options? options}) {
|
||||||
throw UnimplementedError();
|
throw UnimplementedError();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -111,7 +123,7 @@ void main() {
|
|||||||
'id': 'f1',
|
'id': 'f1',
|
||||||
'sender': {'id': 'u1', 'username': 'alice', 'avatar_url': null},
|
'sender': {'id': 'u1', 'username': 'alice', 'avatar_url': null},
|
||||||
'recipient': {'id': 'u2', 'username': 'bob', 'avatar_url': null},
|
'recipient': {'id': 'u2', 'username': 'bob', 'avatar_url': null},
|
||||||
'content': 'hi',
|
'content': {'message': 'hi'},
|
||||||
'status': 'accepted',
|
'status': 'accepted',
|
||||||
'created_at': '2026-03-27T08:00:00Z',
|
'created_at': '2026-03-27T08:00:00Z',
|
||||||
});
|
});
|
||||||
@@ -137,7 +149,7 @@ void main() {
|
|||||||
'id': 'f1',
|
'id': 'f1',
|
||||||
'sender': {'id': 'u1', 'username': 'alice', 'avatar_url': null},
|
'sender': {'id': 'u1', 'username': 'alice', 'avatar_url': null},
|
||||||
'recipient': {'id': 'u2', 'username': 'bob', 'avatar_url': null},
|
'recipient': {'id': 'u2', 'username': 'bob', 'avatar_url': null},
|
||||||
'content': 'hi',
|
'content': {'message': 'hi'},
|
||||||
'status': 'pending',
|
'status': 'pending',
|
||||||
'created_at': '2026-03-27T08:00:00Z',
|
'created_at': '2026-03-27T08:00:00Z',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
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/data/cache/cache_store.dart';
|
||||||
import 'package:social_app/features/contacts/data/models/user_profile.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';
|
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() {
|
void main() {
|
||||||
group('UserProfileCacheRepository', () {
|
group('UserProfileCacheRepository', () {
|
||||||
test('keeps in-memory snapshot and invalidates correctly', () async {
|
test('keeps in-memory snapshot and invalidates correctly', () async {
|
||||||
|
CacheScope.configureProvider(() => 'test-scope');
|
||||||
|
addTearDown(() => CacheScope.resetProvider());
|
||||||
var remoteCalls = 0;
|
var remoteCalls = 0;
|
||||||
final repository = UserProfileCacheRepository(
|
final repository = UserProfileCacheRepository(
|
||||||
store: HybridCacheStore(
|
store: HybridCacheStore(
|
||||||
@@ -40,6 +43,9 @@ void main() {
|
|||||||
test(
|
test(
|
||||||
'invalidate prevents stale in-flight refresh from restoring cache',
|
'invalidate prevents stale in-flight refresh from restoring cache',
|
||||||
() async {
|
() async {
|
||||||
|
CacheScope.configureProvider(() => 'test-scope-2');
|
||||||
|
addTearDown(() => CacheScope.resetProvider());
|
||||||
|
|
||||||
final completer = Completer<UserProfile>();
|
final completer = Completer<UserProfile>();
|
||||||
final repository = UserProfileCacheRepository(
|
final repository = UserProfileCacheRepository(
|
||||||
store: HybridCacheStore(
|
store: HybridCacheStore(
|
||||||
|
|||||||
Reference in New Issue
Block a user