feat(App): update Finlytic Flutter app UI and blocs
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user