feat(App): update Finlytic Flutter app UI and blocs
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:finlytic_app/features/favorites/repositories/favorites_repository.dart';
|
||||
import 'favorites_event.dart';
|
||||
import 'favorites_state.dart';
|
||||
|
||||
class FavoritesBloc extends Bloc<FavoritesEvent, FavoritesState> {
|
||||
final FavoritesRepository repository;
|
||||
|
||||
FavoritesBloc({required this.repository}) : super(FavoritesInitial()) {
|
||||
on<LoadFavorites>(_onLoadFavorites);
|
||||
}
|
||||
|
||||
Future<void> _onLoadFavorites(LoadFavorites event, Emitter<FavoritesState> emit) async {
|
||||
emit(FavoritesLoading());
|
||||
try {
|
||||
final favorites = await repository.fetchFavoritesDetails(event.symbols);
|
||||
emit(FavoritesLoaded(favorites));
|
||||
} catch (e) {
|
||||
emit(const FavoritesError("Fehler beim Laden der Favoriten."));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
abstract class FavoritesEvent extends Equatable {
|
||||
const FavoritesEvent();
|
||||
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
class LoadFavorites extends FavoritesEvent {
|
||||
final List<String> symbols;
|
||||
|
||||
const LoadFavorites(this.symbols);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [symbols];
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:finlytic_app/features/favorites/models/favorite_asset_model.dart';
|
||||
|
||||
abstract class FavoritesState extends Equatable {
|
||||
const FavoritesState();
|
||||
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
class FavoritesInitial extends FavoritesState {}
|
||||
|
||||
class FavoritesLoading extends FavoritesState {}
|
||||
|
||||
class FavoritesLoaded extends FavoritesState {
|
||||
final List<FavoriteAssetModel> favorites;
|
||||
|
||||
const FavoritesLoaded(this.favorites);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [favorites];
|
||||
}
|
||||
|
||||
class FavoritesError extends FavoritesState {
|
||||
final String message;
|
||||
|
||||
const FavoritesError(this.message);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [message];
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
class FavoriteAssetModel extends Equatable {
|
||||
final String symbol;
|
||||
final String name;
|
||||
final String isin;
|
||||
final String image;
|
||||
final double currentPrice;
|
||||
final double change24h;
|
||||
|
||||
const FavoriteAssetModel({
|
||||
required this.symbol,
|
||||
required this.name,
|
||||
this.isin = '',
|
||||
this.image = '',
|
||||
required this.currentPrice,
|
||||
required this.change24h,
|
||||
});
|
||||
|
||||
factory FavoriteAssetModel.fromJson(Map<String, dynamic> json) {
|
||||
return FavoriteAssetModel(
|
||||
symbol: json['symbol']?.toString() ?? json['Symbol']?.toString() ?? '',
|
||||
name: json['name']?.toString() ?? json['Name']?.toString() ?? '',
|
||||
isin: json['isin']?.toString() ?? json['Isin']?.toString() ?? json['symbol']?.toString() ?? '',
|
||||
image: json['image']?.toString() ?? json['Image']?.toString() ?? '',
|
||||
currentPrice: (json['currentPrice'] ?? json['CurrentPrice'] ?? json['price'] ?? 0.0).toDouble(),
|
||||
change24h: (json['dailyChangePercent'] ?? json['DailyChangePercent'] ?? json['change24h'] ?? json['Change24h'] ?? 0.0).toDouble(),
|
||||
);
|
||||
}
|
||||
|
||||
FavoriteAssetModel copyWith({
|
||||
String? symbol,
|
||||
String? name,
|
||||
String? isin,
|
||||
String? image,
|
||||
double? currentPrice,
|
||||
double? change24h,
|
||||
}) {
|
||||
return FavoriteAssetModel(
|
||||
symbol: symbol ?? this.symbol,
|
||||
name: name ?? this.name,
|
||||
isin: isin ?? this.isin,
|
||||
image: image ?? this.image,
|
||||
currentPrice: currentPrice ?? this.currentPrice,
|
||||
change24h: change24h ?? this.change24h,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'symbol': symbol,
|
||||
'name': name,
|
||||
'isin': isin,
|
||||
'image': image,
|
||||
'currentPrice': currentPrice,
|
||||
'change24h': change24h,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [symbol, name, isin, image, currentPrice, change24h];
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import 'package:finlytic_app/core/network/api_client.dart';
|
||||
import 'package:finlytic_app/features/favorites/models/favorite_asset_model.dart';
|
||||
import 'package:finlytic_app/core/utils/asset_utils.dart';
|
||||
|
||||
class FavoritesRepository {
|
||||
final ApiClient apiClient;
|
||||
|
||||
FavoritesRepository({required this.apiClient});
|
||||
|
||||
Future<List<FavoriteAssetModel>> fetchFavoritesDetails(List<String> symbols) async {
|
||||
if (symbols.isEmpty) return [];
|
||||
|
||||
try {
|
||||
final res = await apiClient.post('/api/v1/assets/batch', data: {'symbols': symbols});
|
||||
if (res.statusCode == 200 && res.data != null) {
|
||||
final List<dynamic> data = res.data;
|
||||
return data.map((json) => FavoriteAssetModel.fromJson(json)).toList();
|
||||
}
|
||||
return symbols.map((s) => FavoriteAssetModel(
|
||||
symbol: s,
|
||||
name: AssetUtils.getAssetName(s),
|
||||
currentPrice: 0.0,
|
||||
change24h: 0.0,
|
||||
)).toList();
|
||||
} catch (e) {
|
||||
print('Error fetching favorites details: $e');
|
||||
return symbols.map((s) => FavoriteAssetModel(
|
||||
symbol: s,
|
||||
name: AssetUtils.getAssetName(s),
|
||||
currentPrice: 0.0,
|
||||
change24h: 0.0,
|
||||
)).toList();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../../core/network/api_client.dart';
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
import '../../../core/widgets/shimmer_loading.dart';
|
||||
import '../cubit/favorites_cubit.dart';
|
||||
import '../widgets/watchlist_card.dart';
|
||||
|
||||
/// User-bound Watchlist Grid screen with multi-device sync and real-time status.
|
||||
class FavoritesScreen extends StatelessWidget {
|
||||
final ApiClient apiClient;
|
||||
|
||||
const FavoritesScreen({
|
||||
super.key,
|
||||
required this.apiClient,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final activeTheme = AppTheme.activePreset;
|
||||
|
||||
return Scaffold(
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Meine Watchlist & Favoriten', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: activeTheme.textPrimary)),
|
||||
const SizedBox(height: 4),
|
||||
Text('Synchronisiert über alle Geräte hinweg.', style: TextStyle(color: activeTheme.textMuted, fontSize: 13)),
|
||||
const SizedBox(height: 16),
|
||||
Expanded(
|
||||
child: BlocBuilder<FavoritesCubit, FavoritesState>(
|
||||
builder: (context, state) {
|
||||
if (state.isLoading && state.favoriteDetails.isEmpty) {
|
||||
return GridView.builder(
|
||||
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent: 260,
|
||||
mainAxisExtent: 140,
|
||||
crossAxisSpacing: 16,
|
||||
mainAxisSpacing: 16,
|
||||
),
|
||||
itemCount: 6,
|
||||
itemBuilder: (context, index) => ShimmerLoading(width: 260, height: 140),
|
||||
);
|
||||
}
|
||||
|
||||
final favorites = state.favoriteDetails;
|
||||
if (favorites.isEmpty) {
|
||||
return Center(
|
||||
child: Text(
|
||||
'Noch keine Favoriten gespeichert.\nFüge Wertpapiere über die Suchleiste oder Asset-Karten hinzu.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: activeTheme.textMuted, height: 1.4),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return GridView.builder(
|
||||
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent: 260,
|
||||
mainAxisExtent: 140,
|
||||
crossAxisSpacing: 16,
|
||||
mainAxisSpacing: 16,
|
||||
),
|
||||
itemCount: favorites.length,
|
||||
itemBuilder: (context, index) {
|
||||
final asset = favorites[index];
|
||||
return WatchlistCard(
|
||||
asset: asset,
|
||||
apiClient: apiClient,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../core/network/api_client.dart';
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
import '../../../core/utils/asset_utils.dart';
|
||||
import '../../../core/widgets/asset_logo_widget.dart';
|
||||
import '../../../core/widgets/glass_container.dart';
|
||||
import '../../../shared/widgets/favorite_star_button.dart';
|
||||
import '../../asset_detail/views/asset_detail_screen.dart';
|
||||
import '../models/favorite_asset_model.dart';
|
||||
|
||||
/// Watchlist Card item widget for favorited assets displaying brand logo and FavoriteStarButton.
|
||||
class WatchlistCard extends StatelessWidget {
|
||||
final FavoriteAssetModel asset;
|
||||
final ApiClient apiClient;
|
||||
|
||||
const WatchlistCard({
|
||||
super.key,
|
||||
required this.asset,
|
||||
required this.apiClient,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final activeTheme = AppTheme.activePreset;
|
||||
final symbol = asset.symbol;
|
||||
final displayName = asset.name.isNotEmpty ? asset.name : AssetUtils.getAssetName(symbol);
|
||||
final isPositive = asset.change24h >= 0;
|
||||
|
||||
return GlassContainer(
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => AssetDetailScreen(
|
||||
symbol: displayName,
|
||||
apiClient: apiClient,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
AssetLogoWidget(
|
||||
symbolOrName: asset.isin.isNotEmpty ? asset.isin : displayName,
|
||||
imageUrl: asset.image.isNotEmpty ? asset.image : null,
|
||||
size: 32,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
displayName,
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14.5, color: activeTheme.textPrimary),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (symbol != displayName)
|
||||
Text(symbol, style: TextStyle(color: activeTheme.textMuted, fontSize: 10)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
FavoriteStarButton(
|
||||
identifier: symbol.isNotEmpty ? symbol : displayName,
|
||||
symbol: displayName,
|
||||
name: displayName,
|
||||
),
|
||||
],
|
||||
),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text('Live Kurs:', style: TextStyle(color: activeTheme.textMuted, fontSize: 12)),
|
||||
Text('${asset.currentPrice > 0 ? asset.currentPrice.toStringAsFixed(2) : '--.--'} €', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14, color: activeTheme.textPrimary)),
|
||||
],
|
||||
),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text('Tageswachstum:', style: TextStyle(color: activeTheme.textMuted, fontSize: 12)),
|
||||
Text(
|
||||
'${isPositive ? '+' : ''}${asset.change24h.toStringAsFixed(2)}%',
|
||||
style: TextStyle(
|
||||
color: isPositive ? AppTheme.primaryEmerald : AppTheme.accentRed,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user