202 lines
8.2 KiB
Dart
202 lines
8.2 KiB
Dart
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/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.
|
|
///
|
|
/// By default, tapping a result navigates straight to [AssetDetailScreen] (the main search-feature use case).
|
|
/// Pass [onAssetSelected] to repurpose this as a reusable asset PICKER instead - e.g. the simulation screen's
|
|
/// ISIN input - in which case a tap pops the dialog and invokes the callback with the picked ISIN/name instead
|
|
/// of navigating anywhere.
|
|
class AssetSearchDialog extends StatelessWidget {
|
|
final ApiClient apiClient;
|
|
final void Function(String isin, String name)? onAssetSelected;
|
|
|
|
const AssetSearchDialog({
|
|
super.key,
|
|
required this.apiClient,
|
|
this.onAssetSelected,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return BlocProvider(
|
|
create: (context) => SearchBloc(
|
|
repository: SearchRepository(apiClient: apiClient),
|
|
)..add(const SearchQueryChanged('')),
|
|
child: _AssetSearchDialogContent(apiClient: apiClient, onAssetSelected: onAssetSelected),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _AssetSearchDialogContent extends StatefulWidget {
|
|
final ApiClient apiClient;
|
|
final void Function(String isin, String name)? onAssetSelected;
|
|
|
|
const _AssetSearchDialogContent({required this.apiClient, this.onAssetSelected});
|
|
|
|
@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 isin = item.isin;
|
|
|
|
final targetId = isin.isNotEmpty ? isin : assetName;
|
|
|
|
return ListTile(
|
|
leading: AssetLogoWidget(
|
|
symbolOrName: targetId,
|
|
imageUrl: item.image,
|
|
size: 36,
|
|
),
|
|
title: Text(assetName, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14)),
|
|
subtitle: isin.isNotEmpty
|
|
? Text('ISIN: $isin', 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);
|
|
if (widget.onAssetSelected != null) {
|
|
widget.onAssetSelected!(isin, assetName);
|
|
return;
|
|
}
|
|
Navigator.push(
|
|
context,
|
|
MaterialPageRoute(
|
|
builder: (_) => AssetDetailScreen(
|
|
isin: isin,
|
|
name: assetName,
|
|
apiClient: widget.apiClient,
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
},
|
|
);
|
|
}
|
|
return const SizedBox.shrink();
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|