Files
minstrel/flutter_client/lib/search/search_screen.dart
T
bvandeusen 835592f073 refactor(ui): Lucide migration unit 2 — Icons.* -> LucideIcons.* sweep (#60)
Mechanical sweep across 30 files: every Material Icons.* replaced with
the signed-off Lucide equivalent + a flutter_lucide import per file.
Zero Material Icons.* remain in lib/; no unused imports.

Judgment-call mappings: album->disc_3, library_music->library_big,
playlist_play->list_video, graphic_eq->audio_lines,
system_update->download, restore->archive_restore,
download_done->circle_check_big.

track_actions_sheet like menu row: collapsed `liked ? favorite :
favorite_border` to a single LucideIcons.heart (the row's Like/Unlike
text label conveys state). Icon-only LikeButton + the notification keep
the filled-vs-outline shape per the design decision.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 20:43:05 -04:00

202 lines
6.1 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_lucide/flutter_lucide.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../library/widgets/album_card.dart';
import '../library/widgets/artist_card.dart';
import '../library/widgets/track_row.dart';
import '../models/search_response.dart';
import '../player/player_provider.dart';
import '../shared/widgets/main_app_bar_actions.dart';
import '../theme/theme_extension.dart';
import 'search_provider.dart';
class SearchScreen extends ConsumerStatefulWidget {
const SearchScreen({super.key});
@override
ConsumerState<SearchScreen> createState() => _SearchScreenState();
}
class _SearchScreenState extends ConsumerState<SearchScreen> {
final _controller = TextEditingController();
final _focus = FocusNode();
@override
void initState() {
super.initState();
// Autofocus the field on screen entry; keyboard pops up so the
// user can start typing immediately.
WidgetsBinding.instance.addPostFrameCallback((_) => _focus.requestFocus());
}
@override
void dispose() {
_controller.dispose();
_focus.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
final results = ref.watch(searchResultsProvider);
return Scaffold(
backgroundColor: fs.obsidian,
appBar: AppBar(
backgroundColor: fs.obsidian,
elevation: 0,
leading: IconButton(
icon: Icon(LucideIcons.arrow_left, color: fs.parchment),
onPressed: () => context.pop(),
),
title: TextField(
controller: _controller,
focusNode: _focus,
autofocus: true,
style: TextStyle(color: fs.parchment),
cursorColor: fs.accent,
decoration: InputDecoration(
hintText: 'Search artists, albums, tracks',
hintStyle: TextStyle(color: fs.ash),
border: InputBorder.none,
),
onChanged: (v) => ref.read(searchQueryProvider.notifier).set(v),
textInputAction: TextInputAction.search,
),
actions: [
if (_controller.text.isNotEmpty)
IconButton(
icon: Icon(LucideIcons.x, color: fs.ash),
onPressed: () {
_controller.clear();
ref.read(searchQueryProvider.notifier).set('');
_focus.requestFocus();
},
),
const MainAppBarActions(currentRoute: '/search'),
],
),
body: results.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text('$e', style: TextStyle(color: fs.error)),
),
),
data: (r) => r == null
? const _Hint(message: 'Type to search your library.')
: r.isEmpty
? const _Hint(message: 'No matches for that query.')
: _Results(results: r),
),
);
}
}
class _Hint extends StatelessWidget {
const _Hint({required this.message});
final String message;
@override
Widget build(BuildContext context) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
return Center(
child: Text(message, style: TextStyle(color: fs.ash)),
);
}
}
class _Results extends ConsumerWidget {
const _Results({required this.results});
final SearchResponse results;
@override
Widget build(BuildContext context, WidgetRef ref) {
return ListView(
children: [
if (results.artists.items.isNotEmpty) ...[
_Header(label: 'Artists', count: results.artists.total),
SizedBox(
height: 168,
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 8),
itemCount: results.artists.items.length,
itemBuilder: (ctx, i) {
final a = results.artists.items[i];
return ArtistCard(
artist: a,
onTap: () => ctx.push('/artists/${a.id}'),
);
},
),
),
],
if (results.albums.items.isNotEmpty) ...[
_Header(label: 'Albums', count: results.albums.total),
SizedBox(
height: 200,
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 8),
itemCount: results.albums.items.length,
itemBuilder: (ctx, i) {
final a = results.albums.items[i];
return AlbumCard(
album: a,
onTap: () => ctx.push('/albums/${a.id}'),
);
},
),
),
],
if (results.tracks.items.isNotEmpty) ...[
_Header(label: 'Tracks', count: results.tracks.total),
...results.tracks.items.asMap().entries.map((e) {
final i = e.key;
final t = e.value;
return TrackRow(
track: t,
onTap: () => ref
.read(playerActionsProvider)
.playTracks(results.tracks.items, initialIndex: i),
);
}),
],
const SizedBox(height: 96),
],
);
}
}
class _Header extends StatelessWidget {
const _Header({required this.label, required this.count});
final String label;
final int count;
@override
Widget build(BuildContext context) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
return Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Row(
children: [
Text(
label,
style: TextStyle(
color: fs.parchment,
fontSize: 18,
fontWeight: FontWeight.w500,
),
),
const SizedBox(width: 8),
Text('$count', style: TextStyle(color: fs.ash, fontSize: 13)),
],
),
);
}
}