SpotiFLAC-Mobile/lib/screens/search_screen.dart
2026-05-03 14:12:53 +07:00

251 lines
8 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/providers/track_provider.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart';
import 'package:spotiflac_android/widgets/animation_utils.dart';
import 'package:spotiflac_android/utils/clickable_metadata.dart';
import 'package:spotiflac_android/widgets/audio_quality_badges.dart';
import 'package:spotiflac_android/widgets/cached_cover_image.dart';
class SearchScreen extends ConsumerStatefulWidget {
final String query;
const SearchScreen({super.key, required this.query});
@override
ConsumerState<SearchScreen> createState() => _SearchScreenState();
}
class _SearchScreenState extends ConsumerState<SearchScreen> {
late TextEditingController _searchController;
@override
void initState() {
super.initState();
_searchController = TextEditingController(text: widget.query);
if (widget.query.isNotEmpty) {
WidgetsBinding.instance.addPostFrameCallback((_) {
ref.read(trackProvider.notifier).search(widget.query);
});
}
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
void _search() {
final query = _searchController.text.trim();
if (query.isNotEmpty) {
ref.read(trackProvider.notifier).search(query);
}
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Scaffold(
appBar: AppBar(
title: TextField(
controller: _searchController,
style: TextStyle(color: colorScheme.onSurface),
decoration: InputDecoration(
hintText: context.l10n.searchTracksHint,
hintStyle: TextStyle(color: colorScheme.onSurfaceVariant),
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
),
onSubmitted: (_) => _search(),
autofocus: widget.query.isEmpty,
),
actions: [
IconButton(
tooltip: MaterialLocalizations.of(context).searchFieldLabel,
icon: const Icon(Icons.search),
onPressed: _search,
),
],
),
body: const _SearchResultsBody(),
);
}
}
class _SearchResultsBody extends ConsumerWidget {
const _SearchResultsBody();
@override
Widget build(BuildContext context, WidgetRef ref) {
final tracks = ref.watch(trackProvider.select((s) => s.tracks));
final isLoading = ref.watch(trackProvider.select((s) => s.isLoading));
final error = ref.watch(trackProvider.select((s) => s.error));
final colorScheme = Theme.of(context).colorScheme;
return Column(
children: [
if (isLoading) LinearProgressIndicator(color: colorScheme.primary),
if (error != null)
Padding(
padding: const EdgeInsets.all(16.0),
child: Text(error, style: TextStyle(color: colorScheme.error)),
),
Expanded(
child: AnimatedStateSwitcher(
child: isLoading && tracks.isEmpty
? const TrackListSkeleton(key: ValueKey('loading'))
: tracks.isEmpty
? _SearchEmptyState(
key: const ValueKey('empty'),
colorScheme: colorScheme,
)
: ListView.builder(
key: const ValueKey('results'),
itemCount: tracks.length,
itemBuilder: (context, index) => StaggeredListItem(
key: ValueKey('search-track-${tracks[index].id}-$index'),
index: index,
child: _SearchTrackTile(track: tracks[index]),
),
),
),
),
],
);
}
}
class _SearchEmptyState extends StatelessWidget {
final ColorScheme colorScheme;
const _SearchEmptyState({super.key, required this.colorScheme});
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.search, size: 64, color: colorScheme.onSurfaceVariant),
const SizedBox(height: 16),
Text(
context.l10n.searchTracksEmptyPrompt,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
);
}
}
class _SearchTrackTile extends ConsumerWidget {
final Track track;
const _SearchTrackTile({required this.track});
void _downloadTrack(BuildContext context, WidgetRef ref) {
final settings = ref.read(settingsProvider);
final extensionState = ref.read(extensionProvider);
final service = resolveEffectiveDownloadService(
settings.defaultService,
extensionState,
);
if (service.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.extensionsNoDownloadProvider)),
);
return;
}
ref.read(downloadQueueProvider.notifier).addToQueue(track, service);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))),
);
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme;
final coverWidget = track.coverUrl != null
? CachedCoverImage(
imageUrl: track.coverUrl!,
width: 48,
height: 48,
borderRadius: BorderRadius.circular(8),
)
: Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant),
);
return ListTile(
leading: coverWidget,
title: Text(track.name, maxLines: 1, overflow: TextOverflow.ellipsis),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClickableArtistName(
artistName: track.artistName,
artistId: track.artistId,
coverUrl: track.coverUrl,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
Row(
children: [
Flexible(
child: ClickableAlbumName(
albumName: track.albumName,
albumId: track.albumId,
artistName: track.artistName,
coverUrl: track.coverUrl,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
),
),
),
...buildQualityBadges(
audioQuality: track.audioQuality,
audioModes: track.audioModes,
colorScheme: colorScheme,
),
],
),
],
),
onLongPress: () => TrackCollectionQuickActions.showTrackOptionsSheet(
context,
ref,
track,
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.download_rounded),
tooltip: context.l10n.dialogDownload,
onPressed: () => _downloadTrack(context, ref),
),
],
),
onTap: () => _downloadTrack(context, ref),
);
}
}