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,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,
),
),
],
),
],
),
);
}
}