feat(App): update Finlytic Flutter app UI and blocs

This commit is contained in:
2026-08-09 21:01:46 +02:00
parent e7427b7464
commit a708d2977c
591 changed files with 1095105 additions and 0 deletions
@@ -0,0 +1,159 @@
import 'dart:async';
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../core/network/api_client.dart';
import '../../../core/network/signalr_service.dart';
import '../../../core/utils/asset_utils.dart';
import '../models/favorite_asset_model.dart';
class FavoritesState extends Equatable {
final Set<String> favoriteIsins;
final List<FavoriteAssetModel> favoriteDetails;
final bool isLoading;
const FavoritesState({
this.favoriteIsins = const {},
this.favoriteDetails = const [],
this.isLoading = false,
});
bool isFavorite(String identifier) {
if (identifier.isEmpty) return false;
final upper = identifier.toUpperCase();
return favoriteIsins.contains(upper);
}
FavoritesState copyWith({
Set<String>? favoriteIsins,
List<FavoriteAssetModel>? favoriteDetails,
bool? isLoading,
}) {
return FavoritesState(
favoriteIsins: favoriteIsins ?? this.favoriteIsins,
favoriteDetails: favoriteDetails ?? this.favoriteDetails,
isLoading: isLoading ?? this.isLoading,
);
}
@override
List<Object?> get props => [favoriteIsins, favoriteDetails, isLoading];
}
class FavoritesCubit extends Cubit<FavoritesState> {
final ApiClient apiClient;
final SignalRService? signalRService;
StreamSubscription<Map<String, dynamic>>? _priceSub;
FavoritesCubit({required this.apiClient, this.signalRService}) : super(const FavoritesState()) {
// 1. Subscribe to SignalR 10-second WebSocket price stream
if (signalRService != null) {
_priceSub = signalRService!.favoritePricesStream.listen((priceMap) {
updatePrices(priceMap);
});
}
}
@override
Future<void> close() {
_priceSub?.cancel();
return super.close();
}
/// Loads favorite metadata via REST ONLY WHEN NEEDED (e.g. initial load or list mutation).
Future<void> loadFavorites() async {
emit(state.copyWith(isLoading: true));
try {
final res = await apiClient.get('/api/v1/user/favorites');
if (res.statusCode == 200 && res.data is List) {
final rawList = res.data as List;
final set = <String>{};
final dedupMap = <String, FavoriteAssetModel>{};
for (var item in rawList) {
final model = FavoriteAssetModel.fromJson(Map<String, dynamic>.from(item));
AssetUtils.registerAsset(model.isin, model.name, model.image);
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));
}
} catch (_) {
emit(state.copyWith(isLoading: false));
}
}
/// Updates current prices and daily growth % live in-memory when SignalR WebSocket payload arrives.
void updatePrices(Map<String, dynamic> priceMap) {
if (priceMap.isEmpty) return;
final updatedDetails = state.favoriteDetails.map((model) {
final keyIsin = model.isin.toUpperCase();
final keySymbol = model.symbol.toUpperCase();
dynamic priceData = priceMap[keyIsin] ?? priceMap[keySymbol];
if (priceData != null && priceData is Map) {
final double price = (priceData['currentPrice'] ?? priceData['price'] ?? model.currentPrice).toDouble();
final double change = (priceData['dailyChangePercent'] ?? priceData['change24h'] ?? model.change24h).toDouble();
return model.copyWith(currentPrice: price, change24h: change);
}
return model;
}).toList();
emit(state.copyWith(favoriteDetails: updatedDetails));
}
Future<void> toggleFavorite(String identifier, {String? symbol, String? name}) async {
if (identifier.isEmpty) return;
final target = identifier.toUpperCase();
final isCurrentlyFav = state.isFavorite(target);
// Optimistic UI update
final newSet = Set<String>.from(state.favoriteIsins);
if (isCurrentlyFav) {
newSet.remove(target);
if (symbol != null) newSet.remove(symbol.toUpperCase());
if (name != null) newSet.remove(name.toUpperCase());
} else {
newSet.add(target);
if (symbol != null) newSet.add(symbol.toUpperCase());
if (name != null) newSet.add(name.toUpperCase());
}
emit(state.copyWith(favoriteIsins: newSet));
// Perform API call in background
try {
if (isCurrentlyFav) {
await apiClient.delete('/api/v1/user/favorites/$target');
} else {
await apiClient.post('/api/v1/user/favorites/$target');
}
// Re-sync full list to ensure metadata details are fresh
await loadFavorites();
} catch (_) {
// Revert on error
await loadFavorites();
}
}
Future<void> updateFavoriteTicker(String symbol, String ticker) async {
try {
await apiClient.post('/api/v1/user/favorites/$symbol/ticker?ticker=$ticker');
await loadFavorites();
} catch (_) {
// Ignore gracefully
}
}
}