feat(app): clean architecture with typed repositories, DTO models and calendar event logos

This commit is contained in:
2026-08-15 01:03:22 +02:00
parent f08fecde23
commit 15f8f7896e
34 changed files with 1435 additions and 1420 deletions
@@ -4,6 +4,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../core/network/api_client.dart';
import '../../../core/network/signalr_service.dart';
import '../models/favorite_asset_model.dart';
import '../repositories/favorites_repository.dart';
class FavoritesState extends Equatable {
final Set<String> favoriteIsins;
@@ -39,11 +40,16 @@ class FavoritesState extends Equatable {
}
class FavoritesCubit extends Cubit<FavoritesState> {
final ApiClient apiClient;
final FavoritesRepository repository;
final SignalRService? signalRService;
StreamSubscription<Map<String, dynamic>>? _priceSub;
FavoritesCubit({required this.apiClient, this.signalRService}) : super(const FavoritesState()) {
FavoritesCubit({
FavoritesRepository? repository,
ApiClient? apiClient,
this.signalRService,
}) : repository = repository ?? FavoritesRepository(apiClient: apiClient!),
super(const FavoritesState()) {
// 1. Subscribe to SignalR 10-second WebSocket price stream
if (signalRService != null) {
_priceSub = signalRService!.favoritePricesStream.listen((priceMap) {
@@ -58,36 +64,24 @@ class FavoritesCubit extends Cubit<FavoritesState> {
return super.close();
}
/// Loads favorite metadata via REST ONLY WHEN NEEDED (e.g. initial load or list mutation).
/// Loads favorite metadata via repository ONLY WHEN NEEDED.
Future<void> loadFavorites() async {
emit(state.copyWith(isLoading: true));
try {
final ts = DateTime.now().millisecondsSinceEpoch;
final res = await apiClient.get('/api/v1/user/favorites?_t=$ts');
if (res.statusCode == 200 && res.data is List) {
final rawList = res.data as List;
final set = <String>{};
final dedupMap = <String, FavoriteAssetModel>{};
final list = await repository.getFavorites();
final set = <String>{};
for (var item in rawList) {
final model = FavoriteAssetModel.fromJson(Map<String, dynamic>.from(item));
final key = (model.isin.isNotEmpty ? model.isin : (model.symbol.isNotEmpty ? model.symbol : model.name)).toUpperCase();
if (!dedupMap.containsKey(key)) {
dedupMap[key] = model;
}
if (model.isin.isNotEmpty) set.add(model.isin.toUpperCase());
if (model.symbol.isNotEmpty) set.add(model.symbol.toUpperCase());
if (model.name.isNotEmpty) set.add(model.name.toUpperCase());
}
emit(FavoritesState(
favoriteIsins: set,
favoriteDetails: dedupMap.values.toList(),
isLoading: false,
));
} else {
emit(state.copyWith(isLoading: false));
for (var model in list) {
if (model.isin.isNotEmpty) set.add(model.isin.toUpperCase());
if (model.symbol.isNotEmpty) set.add(model.symbol.toUpperCase());
if (model.name.isNotEmpty) set.add(model.name.toUpperCase());
}
emit(FavoritesState(
favoriteIsins: set,
favoriteDetails: list,
isLoading: false,
));
} catch (_) {
emit(state.copyWith(isLoading: false));
}
@@ -132,17 +126,15 @@ class FavoritesCubit extends Cubit<FavoritesState> {
emit(state.copyWith(favoriteIsins: newSet));
// Perform API call in background
// Perform API call via repository
try {
if (isCurrentlyFav) {
await apiClient.delete('/api/v1/user/favorites/$target');
await repository.removeFavorite(target);
} else {
await apiClient.post('/api/v1/user/favorites/$target');
await repository.addFavorite(target);
}
// Re-sync full list to ensure metadata details are fresh
await loadFavorites();
} catch (_) {
// Revert on error
await loadFavorites();
}
}
@@ -159,11 +151,11 @@ class FavoritesCubit extends Cubit<FavoritesState> {
}).toList();
emit(state.copyWith(favoriteDetails: updatedDetails));
await apiClient.post('/api/v1/user/favorites/$symbol/ticker?ticker=$ticker');
await repository.updateFavoriteTicker(symbol, ticker);
await loadFavorites();
} catch (_) {
// Revert/refresh on error
await loadFavorites();
}
}
}
@@ -0,0 +1,49 @@
import '../../../core/network/api_client.dart';
import '../models/favorite_asset_model.dart';
/// Clean Architecture Repository for managing user favorite assets.
class FavoritesRepository {
final ApiClient apiClient;
const FavoritesRepository({required this.apiClient});
/// Fetches the user's favorite asset list from the backend.
Future<List<FavoriteAssetModel>> getFavorites() async {
final ts = DateTime.now().millisecondsSinceEpoch;
final res = await apiClient.get('/api/v1/user/favorites?_t=$ts');
if (res.statusCode == 200 && res.data is List) {
final rawList = res.data as List;
final dedupMap = <String, FavoriteAssetModel>{};
for (var item in rawList) {
if (item is Map<String, dynamic>) {
final model = FavoriteAssetModel.fromJson(item);
final key = (model.isin.isNotEmpty ? model.isin : (model.symbol.isNotEmpty ? model.symbol : model.name)).toUpperCase();
if (!dedupMap.containsKey(key)) {
dedupMap[key] = model;
}
}
}
return dedupMap.values.toList();
}
return [];
}
/// Adds an asset to user favorites by ISIN or symbol.
Future<bool> addFavorite(String identifier) async {
final res = await apiClient.post('/api/v1/user/favorites/$identifier');
return res.statusCode == 200 || res.statusCode == 201;
}
/// Removes an asset from user favorites.
Future<bool> removeFavorite(String identifier) async {
final res = await apiClient.delete('/api/v1/user/favorites/$identifier');
return res.statusCode == 200 || res.statusCode == 204;
}
/// Updates the preferred active ticker for a favorite asset.
Future<bool> updateFavoriteTicker(String symbolOrIsin, String ticker) async {
final res = await apiClient.post('/api/v1/user/favorites/$symbolOrIsin/ticker?ticker=$ticker');
return res.statusCode == 200;
}
}