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