feat(flutter): bump deps to latest + Search screen slice

Major version bumps (riverpod 2->3, go_router 14->17, just_audio 0.9->0.10,
flutter_secure_storage 9->10, google_fonts 6->8, flutter_lints 4->6).
package_info_plus held at 8.x due to win32 conflict with secure_storage 10.x.

Riverpod 3 breaks: AsyncValue.valueOrNull -> .value, StateProvider replaced
with Notifier subclass. just_audio 0.10: ConcatenatingAudioSource -> setAudioSources.

Search slice:
- models/page.dart (generic Page<T> envelope)
- models/search_response.dart (3-facet wrapper)
- api/endpoints/search.dart
- search/search_provider.dart (debounced 250ms)
- search/search_screen.dart (TextField + 3 horizontal/vertical sections)
- Wired /search route + search button on home AppBar
This commit is contained in:
2026-05-08 14:30:01 -04:00
parent 1b3f2e254b
commit 147d6e280e
12 changed files with 581 additions and 45 deletions
@@ -0,0 +1,29 @@
import 'package:dio/dio.dart';
import '../../models/search_response.dart';
/// SearchApi wraps GET /api/search. The server runs three facets (artists,
/// albums, tracks) sharing one limit/offset pair. Each facet returns its
/// own total reflecting the full match count.
class SearchApi {
SearchApi(this._dio);
final Dio _dio;
/// Empty / whitespace-only query is the caller's responsibility to
/// guard against — the server returns 400 bad_request for it.
Future<SearchResponse> search(
String query, {
int limit = 20,
int offset = 0,
}) async {
final r = await _dio.get<Map<String, dynamic>>(
'/api/search',
queryParameters: {
'q': query,
'limit': limit,
'offset': offset,
},
);
return SearchResponse.fromJson(r.data ?? const {});
}
}
@@ -24,6 +24,18 @@ class HomeScreen extends ConsumerWidget {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
return Scaffold(
backgroundColor: fs.obsidian,
appBar: AppBar(
backgroundColor: fs.obsidian,
elevation: 0,
title: Text('Minstrel', style: TextStyle(color: fs.parchment)),
actions: [
IconButton(
icon: Icon(Icons.search, color: fs.parchment),
tooltip: 'Search',
onPressed: () => context.push('/search'),
),
],
),
body: SafeArea(
child: ref.watch(homeProvider).when(
error: (e, _) {
+42
View File
@@ -0,0 +1,42 @@
// Mirrors the server's Page<T> envelope used by paged endpoints
// (/api/search, /api/library/artists, /api/library/albums,
// /api/library/history, /api/me/likes/*).
//
// Wire shape from internal/api/types.go Page[T]:
// { items: T[], total: int, limit: int, offset: int }
//
// Dart generics can't auto-infer T from JSON, so callers pass an
// itemFromJson function alongside the raw map. Parse logic stays in
// each entity's own fromJson; this class only handles the envelope.
class Page<T> {
const Page({
required this.items,
required this.total,
required this.limit,
required this.offset,
});
final List<T> items;
final int total;
final int limit;
final int offset;
factory Page.fromJson(
Map<String, dynamic> j,
T Function(Map<String, dynamic>) itemFromJson,
) {
final raw = (j['items'] as List?) ?? const [];
return Page<T>(
items: raw
.map((e) => itemFromJson((e as Map).cast<String, dynamic>()))
.toList(growable: false),
total: (j['total'] as num?)?.toInt() ?? 0,
limit: (j['limit'] as num?)?.toInt() ?? 0,
offset: (j['offset'] as num?)?.toInt() ?? 0,
);
}
/// Convenience for endpoints that always return the first page or
/// when the caller just wants the items.
bool get hasMore => offset + items.length < total;
}
@@ -0,0 +1,38 @@
import 'album.dart';
import 'artist.dart';
import 'page.dart';
import 'track.dart';
/// Mirrors internal/api/search.go SearchResponse — three pages keyed by
/// facet, each independently paged. The mobile UI usually only walks
/// the first page of each facet (limit 20-50); infinite scroll within a
/// facet is a future enhancement, not v1.
class SearchResponse {
const SearchResponse({
required this.artists,
required this.albums,
required this.tracks,
});
final Page<ArtistRef> artists;
final Page<AlbumRef> albums;
final Page<TrackRef> tracks;
factory SearchResponse.fromJson(Map<String, dynamic> j) => SearchResponse(
artists: Page.fromJson(
(j['artists'] as Map?)?.cast<String, dynamic>() ?? const {},
ArtistRef.fromJson,
),
albums: Page.fromJson(
(j['albums'] as Map?)?.cast<String, dynamic>() ?? const {},
AlbumRef.fromJson,
),
tracks: Page.fromJson(
(j['tracks'] as Map?)?.cast<String, dynamic>() ?? const {},
TrackRef.fromJson,
),
);
bool get isEmpty =>
artists.items.isEmpty && albums.items.isEmpty && tracks.items.isEmpty;
}
+2 -2
View File
@@ -34,8 +34,8 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
return AudioSource.uri(Uri.parse(url), headers: headers);
}).toList();
await _player.setAudioSource(
ConcatenatingAudioSource(children: sources),
await _player.setAudioSources(
sources,
initialIndex: initialIndex,
);
}
@@ -10,8 +10,8 @@ class NowPlayingScreen extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
final media = ref.watch(mediaItemProvider).valueOrNull;
final playback = ref.watch(playbackStateProvider).valueOrNull;
final media = ref.watch(mediaItemProvider).value;
final playback = ref.watch(playbackStateProvider).value;
if (media == null) {
return const Scaffold(body: Center(child: Text('Nothing playing.')));
+2 -2
View File
@@ -11,8 +11,8 @@ class PlayerBar extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
final mediaItem = ref.watch(mediaItemProvider).valueOrNull;
final playback = ref.watch(playbackStateProvider).valueOrNull;
final mediaItem = ref.watch(mediaItemProvider).value;
final playback = ref.watch(playbackStateProvider).value;
if (mediaItem == null) return const SizedBox.shrink();
return Material(
@@ -0,0 +1,37 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../api/endpoints/search.dart';
import '../library/library_providers.dart' show dioProvider;
import '../models/search_response.dart';
final searchApiProvider = FutureProvider<SearchApi>((ref) async {
return SearchApi(await ref.watch(dioProvider.future));
});
/// Current search query text. The screen's TextField writes here on
/// every keystroke; the resultsProvider below debounces.
class SearchQueryNotifier extends Notifier<String> {
@override
String build() => '';
void set(String q) => state = q;
}
final searchQueryProvider =
NotifierProvider<SearchQueryNotifier, String>(SearchQueryNotifier.new);
/// Debounced search results. Returns null for empty queries so the
/// screen can show a neutral empty state instead of "0 results."
///
/// Debounce: 250ms. After the wait, re-reads the live query — if the
/// user has typed more in that window, this attempt is silently
/// abandoned (returns null) and a newer attempt fires on the next
/// rebuild. No request hits the server while the user is mid-typing.
final searchResultsProvider = FutureProvider<SearchResponse?>((ref) async {
final q = ref.watch(searchQueryProvider).trim();
if (q.isEmpty) return null;
await Future<void>.delayed(const Duration(milliseconds: 250));
// Re-read the (possibly newer) query; if the user typed more, bail.
if (ref.read(searchQueryProvider).trim() != q) return null;
final api = await ref.watch(searchApiProvider.future);
return api.search(q);
});
@@ -0,0 +1,198 @@
import 'package:flutter/material.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 '../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(Icons.arrow_back, 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(Icons.clear, color: fs.ash),
onPressed: () {
_controller.clear();
ref.read(searchQueryProvider.notifier).set('');
_focus.requestFocus();
},
),
],
),
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.')
: _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)),
],
),
);
}
}
+2
View File
@@ -10,6 +10,7 @@ import '../library/artist_detail_screen.dart';
import '../library/home_screen.dart';
import '../player/now_playing_screen.dart';
import '../player/player_bar.dart';
import '../search/search_screen.dart';
import 'widgets/version_gate.dart';
/// Exposed as a Provider so its single argument is a real `Ref` (the
@@ -51,6 +52,7 @@ GoRouter buildRouter(Ref ref) {
builder: (_, s) => AlbumDetailScreen(id: s.pathParameters['id']!),
),
GoRoute(path: '/now-playing', builder: (_, __) => const NowPlayingScreen()),
GoRoute(path: '/search', builder: (_, __) => const SearchScreen()),
],
),
],