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,57 @@
import 'dart:async';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:finlytic_app/features/search/repositories/search_repository.dart';
import 'search_event.dart';
import 'search_state.dart';
export 'search_event.dart';
export 'search_state.dart';
EventTransformer<Event> debounce<Event>(Duration duration) {
return (events, mapper) => events.switchMap((event) => Stream.value(event).asyncExpand(mapper));
}
extension SwitchMapExtension<T> on Stream<T> {
Stream<R> switchMap<R>(Stream<R> Function(T event) mapper) {
StreamController<R>? controller;
StreamSubscription<T>? inputSub;
StreamSubscription<R>? outputSub;
controller = StreamController<R>(
onListen: () {
inputSub = listen((event) {
outputSub?.cancel();
outputSub = mapper(event).listen(
controller?.add,
onError: controller?.addError,
);
}, onDone: () {
// Wait for last sub to finish or close
});
},
onCancel: () {
outputSub?.cancel();
inputSub?.cancel();
},
);
return controller.stream;
}
}
class SearchBloc extends Bloc<SearchEvent, SearchState> {
final SearchRepository repository;
SearchBloc({required this.repository}) : super(SearchInitial()) {
on<SearchQueryChanged>(_onSearchQueryChanged);
}
Future<void> _onSearchQueryChanged(SearchQueryChanged event, Emitter<SearchState> emit) async {
emit(SearchLoading());
try {
final results = await repository.searchAssets(event.query);
emit(SearchLoaded(results));
} catch (e) {
emit(const SearchError("Fehler bei der Suche."));
}
}
}
@@ -0,0 +1,17 @@
import 'package:equatable/equatable.dart';
abstract class SearchEvent extends Equatable {
const SearchEvent();
@override
List<Object?> get props => [];
}
class SearchQueryChanged extends SearchEvent {
final String query;
const SearchQueryChanged(this.query);
@override
List<Object?> get props => [query];
}
@@ -0,0 +1,31 @@
import 'package:equatable/equatable.dart';
import 'package:finlytic_app/features/search/models/search_result_model.dart';
abstract class SearchState extends Equatable {
const SearchState();
@override
List<Object?> get props => [];
}
class SearchInitial extends SearchState {}
class SearchLoading extends SearchState {}
class SearchLoaded extends SearchState {
final List<SearchResultModel> results;
const SearchLoaded(this.results);
@override
List<Object?> get props => [results];
}
class SearchError extends SearchState {
final String message;
const SearchError(this.message);
@override
List<Object?> get props => [message];
}
@@ -0,0 +1,39 @@
import 'package:equatable/equatable.dart';
class SearchResultModel extends Equatable {
final String isin;
final String name;
final String symbol;
final String? image;
const SearchResultModel({
required this.isin,
required this.name,
required this.symbol,
this.image,
});
factory SearchResultModel.fromJson(Map<String, dynamic> json) {
return SearchResultModel(
isin: json['isin']?.toString() ?? json['Isin']?.toString() ?? '',
name: json['name']?.toString() ?? json['Name']?.toString() ?? '',
symbol: json['symbol']?.toString() ?? json['Symbol']?.toString() ?? '',
image: json['image']?.toString() ?? json['Image']?.toString(),
);
}
Map<String, dynamic> toJson() {
return {
'isin': isin,
'name': name,
'symbol': symbol,
'image': image,
};
}
String get displayName => name.isNotEmpty ? name : (symbol.isNotEmpty ? symbol : isin);
String get isinCode => isin.isNotEmpty ? isin : (symbol != displayName ? symbol : '');
@override
List<Object?> get props => [isin, name, symbol, image];
}
@@ -0,0 +1,28 @@
import 'package:finlytic_app/core/network/api_client.dart';
import 'package:finlytic_app/features/search/models/search_result_model.dart';
import 'package:finlytic_app/core/utils/asset_utils.dart';
class SearchRepository {
final ApiClient apiClient;
SearchRepository({required this.apiClient});
Future<List<SearchResultModel>> searchAssets(String query) async {
try {
final res = await apiClient.get('/api/v1/assets/search', queryParameters: {'q': query});
if (res.statusCode == 200 && res.data != null) {
final list = res.data as List;
final results = list.map((json) => SearchResultModel.fromJson(json)).toList();
for (final item in results) {
if (item.isinCode.isNotEmpty && item.displayName.isNotEmpty) {
AssetUtils.registerAsset(item.isinCode, item.displayName, item.image);
}
}
return results;
}
return [];
} catch (_) {
return [];
}
}
}
@@ -0,0 +1,193 @@
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/utils/asset_utils.dart';
import '../../../core/widgets/asset_logo_widget.dart';
import '../../../core/widgets/shimmer_loading.dart';
import '../../../shared/widgets/favorite_star_button.dart';
import '../../asset_detail/views/asset_detail_screen.dart';
import '../bloc/search_bloc.dart';
import '../repositories/search_repository.dart';
/// Focused Spotlight Asset Search Dialog with reactive Favorite Star Button, Hero transitions, and Shimmer loading states.
class AssetSearchDialog extends StatelessWidget {
final ApiClient apiClient;
const AssetSearchDialog({
super.key,
required this.apiClient,
});
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => SearchBloc(
repository: SearchRepository(apiClient: apiClient),
)..add(const SearchQueryChanged('')),
child: _AssetSearchDialogContent(apiClient: apiClient),
);
}
}
class _AssetSearchDialogContent extends StatefulWidget {
final ApiClient apiClient;
const _AssetSearchDialogContent({required this.apiClient});
@override
State<_AssetSearchDialogContent> createState() => _AssetSearchDialogContentState();
}
class _AssetSearchDialogContentState extends State<_AssetSearchDialogContent> {
final _searchController = TextEditingController();
@override
Widget build(BuildContext context) {
final activeTheme = AppTheme.activePreset;
return Dialog(
insetPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 40),
backgroundColor: activeTheme.cardSurface,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(activeTheme.borderRadius + 2),
side: BorderSide(color: activeTheme.glassBorder, width: 1),
),
child: Container(
width: 600,
height: 520,
padding: const EdgeInsets.all(20),
child: Column(
children: [
BlocBuilder<SearchBloc, SearchState>(
builder: (context, state) {
final isLoading = state is SearchLoading;
return TextField(
controller: _searchController,
autofocus: true,
decoration: InputDecoration(
hintText: 'Aktienname oder ISIN suchen...',
prefixIcon: Icon(Icons.search, color: activeTheme.primaryColor),
suffixIcon: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (isLoading)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2, color: activeTheme.primaryColor),
),
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.pop(context),
),
],
),
),
onChanged: (query) {
context.read<SearchBloc>().add(SearchQueryChanged(query));
},
);
},
),
const SizedBox(height: 16),
Expanded(
child: BlocBuilder<SearchBloc, SearchState>(
builder: (context, state) {
if (state is SearchLoading) {
return ListView.builder(
itemCount: 5,
itemBuilder: (context, index) => Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
children: [
ShimmerLoading(width: 36, height: 36, borderRadius: 10),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ShimmerLoading(width: 140, height: 14),
const SizedBox(height: 6),
ShimmerLoading(width: 80, height: 10),
],
),
),
],
),
),
);
}
if (state is SearchError) {
return Center(
child: Text(state.message, style: TextStyle(color: activeTheme.textMuted, fontSize: 13)),
);
}
if (state is SearchLoaded) {
final results = state.results;
if (results.isEmpty) {
return Center(
child: Text('Keine Wertpapiere gefunden', style: TextStyle(color: activeTheme.textMuted, fontSize: 13)),
);
}
return ListView.builder(
itemCount: results.length,
itemBuilder: (context, index) {
final item = results[index];
final assetName = item.displayName;
final isinCode = item.isinCode;
if (isinCode.isNotEmpty && assetName.isNotEmpty) {
AssetUtils.registerAsset(isinCode, assetName, item.image);
}
final targetId = isinCode.isNotEmpty ? isinCode : assetName;
return ListTile(
leading: AssetLogoWidget(
symbolOrName: targetId,
imageUrl: item.image,
size: 36,
),
title: Text(assetName, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14)),
subtitle: isinCode.isNotEmpty
? Text('ISIN: $isinCode', style: TextStyle(color: activeTheme.textMuted, fontSize: 11))
: null,
trailing: SizedBox(
width: 40,
height: 40,
child: FavoriteStarButton(
identifier: targetId,
symbol: assetName,
name: assetName,
),
),
onTap: () {
Navigator.pop(context);
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => AssetDetailScreen(
symbol: assetName,
apiClient: widget.apiClient,
),
),
);
},
);
},
);
}
return const SizedBox.shrink();
},
),
),
],
),
),
);
}
}