170 lines
5.7 KiB
Dart
170 lines
5.7 KiB
Dart
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 '../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 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>{};
|
|
|
|
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));
|
|
}
|
|
} 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 {
|
|
// Optimistic UI update
|
|
final target = symbol.toUpperCase();
|
|
final updatedDetails = state.favoriteDetails.map((model) {
|
|
if (model.isin.toUpperCase() == target || model.symbol.toUpperCase() == target) {
|
|
return model.copyWith(symbol: ticker);
|
|
}
|
|
return model;
|
|
}).toList();
|
|
emit(state.copyWith(favoriteDetails: updatedDetails));
|
|
|
|
await apiClient.post('/api/v1/user/favorites/$symbol/ticker?ticker=$ticker');
|
|
await loadFavorites();
|
|
} catch (_) {
|
|
// Revert/refresh on error
|
|
await loadFavorites();
|
|
}
|
|
}
|
|
}
|