import 'package:cached_network_image/cached_network_image.dart'; import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../api/endpoints/discover.dart'; import '../api/errors.dart'; import '../cache/mutation_queue.dart'; import '../library/library_providers.dart' show dioProvider; import '../models/lidarr.dart'; import '../shared/widgets/main_app_bar_actions.dart'; import '../theme/theme_extension.dart'; final _discoverApiProvider = FutureProvider((ref) async { return DiscoverApi(await ref.watch(dioProvider.future)); }); class DiscoverScreen extends ConsumerStatefulWidget { const DiscoverScreen({super.key}); @override ConsumerState createState() => _DiscoverScreenState(); } class _DiscoverScreenState extends ConsumerState { final _ctrl = TextEditingController(); LidarrRequestKind _kind = LidarrRequestKind.artist; Future>? _resultsFuture; @override void dispose() { _ctrl.dispose(); super.dispose(); } void _runSearch() { final q = _ctrl.text.trim(); if (q.isEmpty) { setState(() => _resultsFuture = null); return; } setState(() { _resultsFuture = ref .read(_discoverApiProvider.future) .then((api) => api.search(q, _kind)); }); } Future _request(LidarrSearchResult row) async { final fs = Theme.of(context).extension()!; final args = { 'kind': _kind.wire, 'artistMbid': _kind == LidarrRequestKind.artist ? row.mbid : row.artistMbid, 'artistName': _kind == LidarrRequestKind.artist ? row.name : row.secondaryText, 'albumMbid': _kind == LidarrRequestKind.album ? row.mbid : null, 'albumTitle': _kind == LidarrRequestKind.album ? row.name : null, }; try { final api = await ref.read(_discoverApiProvider.future); await api.createRequest( kind: _kind, artistMbid: args['artistMbid'] as String, artistName: args['artistName'] as String, albumMbid: args['albumMbid'], albumTitle: args['albumTitle'], ); if (mounted) { ScaffoldMessenger.of(context).showSnackBar(SnackBar( content: Text('Requested: ${row.name}'), backgroundColor: fs.iron, )); // Re-run search to refresh the `requested` flag on the row. _runSearch(); } } on DioException catch (_) { // Queue for replay so the user's request persists across // network loss. We don't have a drift table for in-flight // requests yet, so the row won't show on the Requests screen // until replay succeeds — accept that for v1. await ref .read(mutationQueueProvider) .enqueue(MutationKinds.requestCreate, args); if (mounted) { ScaffoldMessenger.of(context).showSnackBar(SnackBar( content: Text('Request queued: ${row.name}'), backgroundColor: fs.iron, )); } } } @override Widget build(BuildContext context) { final fs = Theme.of(context).extension()!; 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: Text('Discover', style: TextStyle(color: fs.parchment)), actions: const [MainAppBarActions(currentRoute: '/discover')], ), body: Column(children: [ Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 4), child: Row(children: [ Expanded( child: TextField( controller: _ctrl, style: TextStyle(color: fs.parchment), cursorColor: fs.accent, onSubmitted: (_) => _runSearch(), decoration: InputDecoration( hintText: 'Search Lidarr for new music', hintStyle: TextStyle(color: fs.ash), enabledBorder: OutlineInputBorder( borderSide: BorderSide(color: fs.iron), ), focusedBorder: OutlineInputBorder( borderSide: BorderSide(color: fs.accent), ), suffixIcon: IconButton( icon: Icon(Icons.search, color: fs.ash), onPressed: _runSearch, ), ), ), ), ]), ), Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), child: SegmentedButton( segments: const [ ButtonSegment(value: LidarrRequestKind.artist, label: Text('Artists')), ButtonSegment(value: LidarrRequestKind.album, label: Text('Albums')), ], selected: {_kind}, onSelectionChanged: (s) { setState(() { _kind = s.first; if (_ctrl.text.trim().isNotEmpty) _runSearch(); }); }, ), ), Expanded( child: _resultsFuture == null ? Center( child: Text( 'Type to search, then tap Request to send to Lidarr.', textAlign: TextAlign.center, style: TextStyle(color: fs.ash), ), ) : FutureBuilder>( future: _resultsFuture, builder: (ctx, snap) { if (snap.connectionState != ConnectionState.done) { return const Center(child: CircularProgressIndicator()); } if (snap.hasError) { final err = snap.error; final code = err is DioException ? ApiError.fromDio(err).code : 'unknown'; return Center( child: Text('Search failed: $code', style: TextStyle(color: fs.error)), ); } final rows = snap.data ?? const []; if (rows.isEmpty) { return Center( child: Text('Nothing to add for that search yet.', style: TextStyle(color: fs.ash), textAlign: TextAlign.center), ); } return ListView.separated( itemCount: rows.length, separatorBuilder: (_, __) => Divider(height: 1, color: fs.iron), itemBuilder: (ctx, i) => _ResultTile( row: rows[i], onRequest: () => _request(rows[i]), ), ); }, ), ), ]), ); } } class _ResultTile extends StatelessWidget { const _ResultTile({required this.row, required this.onRequest}); final LidarrSearchResult row; final VoidCallback onRequest; @override Widget build(BuildContext context) { final fs = Theme.of(context).extension()!; final disabled = row.inLibrary || row.requested; return Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), child: Row(children: [ ClipRRect( borderRadius: BorderRadius.circular(6), child: Container( width: 56, height: 56, color: fs.slate, child: row.imageUrl.isEmpty ? Icon(Icons.album, color: fs.ash) : CachedNetworkImage( imageUrl: row.imageUrl, fit: BoxFit.cover, fadeInDuration: const Duration(milliseconds: 120), fadeOutDuration: Duration.zero, errorWidget: (_, __, ___) => Icon(Icons.album, color: fs.ash), ), ), ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(row.name, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: fs.parchment, fontSize: 14)), if (row.secondaryText.isNotEmpty) Text(row.secondaryText, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: fs.ash, fontSize: 12)), ], ), ), const SizedBox(width: 8), if (row.inLibrary) _Pill(label: 'In library', color: fs.ash) else if (row.requested) _Pill(label: 'Requested', color: fs.ash) else FilledButton( onPressed: disabled ? null : onRequest, style: FilledButton.styleFrom( backgroundColor: fs.accent, foregroundColor: fs.parchment, ), child: const Text('Request'), ), ]), ); } } class _Pill extends StatelessWidget { const _Pill({required this.label, required this.color}); final String label; final Color color; @override Widget build(BuildContext context) { final fs = Theme.of(context).extension()!; return Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( color: fs.iron, borderRadius: BorderRadius.circular(4), ), child: Text(label, style: TextStyle(color: color, fontSize: 11)), ); } }