import 'dart:async'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../focus/dpad_navigator.dart'; import '../i18n/strings.g.dart'; import '../media/media_kind.dart'; import '../models/seerr/seerr_details.dart'; import '../models/seerr/seerr_media.dart'; import '../models/seerr/seerr_public_settings.dart'; import '../models/seerr/seerr_request.dart'; import '../models/seerr/seerr_service.dart'; import '../services/catalog/seerr_catalog_source.dart'; import '../services/seerr/seerr_constants.dart'; import '../services/seerr/seerr_exceptions.dart'; import '../utils/app_logger.dart'; import '../utils/snackbar_helper.dart'; import 'app_icon.dart'; import 'app_menu.dart'; import 'loading_indicator_box.dart'; import 'overlay_sheet.dart'; import 'stat_chip.dart'; /// Open the Seerr request sheet for a title. Pops with a success snackbar /// once the request is submitted. Future showSeerrRequestSheet( BuildContext context, { required SeerrCatalogSource source, required MediaKind kind, required int tmdbId, required String title, }) { return OverlaySheetController.showAdaptive( context, isScrollControlled: true, showDragHandle: true, builder: (_) => SeerrRequestSheet(source: source, kind: kind, tmdbId: tmdbId, title: title), ); } /// The full Seerr request flow, mirroring the web UI: per-season selection /// with availability states, a 4K toggle, and the advanced destination /// pickers — every section gated by the same instance settings and user /// permissions Seerr itself checks. class SeerrRequestSheet extends StatefulWidget { final SeerrCatalogSource source; final MediaKind kind; final int tmdbId; final String title; const SeerrRequestSheet({ super.key, required this.source, required this.kind, required this.tmdbId, required this.title, }); @override State createState() => _SeerrRequestSheetState(); } class _SeerrRequestSheetState extends State { bool _loading = true; bool _loadFailed = false; SeerrPublicSettings? _settings; SeerrMediaInfo? _mediaInfo; /// TV only: requestable seasons (specials and empty seasons dropped). List _seasons = const []; final Set _selectedSeasons = {}; List _seasonFocusNodes = const []; late final FocusNode _requestButtonFocusNode; bool _is4k = false; /// Advanced options (REQUEST_ADVANCED): all configured instances of the /// matching service; the pickers filter by the 4K toggle. List _allServers = const []; SeerrServiceInstance? _server; SeerrServiceDetail? _serverDetail; bool _serverDetailLoading = false; int? _profileId; String? _rootFolder; int? _languageProfileId; bool _submitting = false; String? _errorText; bool get _isMovie => widget.kind == MediaKind.movie; int get _permissions => widget.source.client.session.permissions; bool get _advancedAllowed => seerrHasPermission(_permissions, [SeerrPermission.requestAdvanced]); bool get _can4k { final settings = _settings; if (settings == null) return false; final enabled = _isMovie ? settings.movie4kEnabled : settings.series4kEnabled; return enabled && seerrHasPermission(_permissions, [ SeerrPermission.request4k, _isMovie ? SeerrPermission.request4kMovie : SeerrPermission.request4kTv, ]); } bool get _partialSeasons => _settings?.partialRequestsEnabled ?? true; List get _serversForVariant => [..._allServers.where((s) => s.is4k == _is4k)]; @override void initState() { super.initState(); _requestButtonFocusNode = FocusNode(debugLabel: 'seerr_request_submit'); unawaited(_load()); } @override void dispose() { for (final node in _seasonFocusNodes) { node.dispose(); } _requestButtonFocusNode.dispose(); super.dispose(); } void _replaceSeasonFocusNodes(List seasons) { for (final node in _seasonFocusNodes) { node.dispose(); } _seasonFocusNodes = [ for (final season in seasons) FocusNode( debugLabel: 'seerr_season_${season.seasonNumber}', onKeyEvent: (node, event) => _handleSeasonKey(season.seasonNumber, event), ), ]; } Future _load() async { setState(() { _loading = true; _loadFailed = false; }); final client = widget.source.client; try { final (settings, servers) = await ( client.getPublicSettings(), _advancedAllowed ? (_isMovie ? client.getRadarrServices() : client.getSonarrServices()) : Future.value(const []), ).wait; SeerrMediaInfo? mediaInfo; var seasons = const []; if (_isMovie) { mediaInfo = (await client.getMovie(widget.tmdbId)).mediaInfo; } else { final tv = await client.getTv(widget.tmdbId); mediaInfo = tv.mediaInfo; seasons = [ for (final season in tv.seasons ?? const []) if (season.seasonNumber > 0 && (season.episodeCount ?? 0) > 0) season, ]; } if (!mounted) return; _replaceSeasonFocusNodes(seasons); setState(() { _settings = settings; _mediaInfo = mediaInfo; _seasons = seasons; _allServers = servers; _loading = false; }); _selectDefaultServer(); } catch (e) { appLogger.w('Seerr: request sheet load failed for tmdb ${widget.tmdbId}', error: e); if (!mounted) return; setState(() { _loading = false; _loadFailed = true; }); } } void _selectDefaultServer() { final candidates = _serversForVariant; final next = candidates.firstWhereOrNull((s) => s.isDefault) ?? candidates.firstOrNull; _adoptServer(next); } void _adoptServer(SeerrServiceInstance? server) { setState(() { _server = server; _serverDetail = null; _profileId = server?.activeProfileId; _rootFolder = server?.activeDirectory; _languageProfileId = server?.activeLanguageProfileId; }); if (server != null && _advancedAllowed) unawaited(_loadServerDetail(server)); } Future _loadServerDetail(SeerrServiceInstance server) async { setState(() => _serverDetailLoading = true); final client = widget.source.client; try { final detail = _isMovie ? await client.getRadarrService(server.id) : await client.getSonarrService(server.id); if (!mounted || _server?.id != server.id) return; setState(() { _serverDetail = detail; _serverDetailLoading = false; _profileId ??= detail.server?.activeProfileId; _rootFolder ??= detail.server?.activeDirectory; _languageProfileId ??= detail.server?.activeLanguageProfileId; }); } catch (e) { // Advanced pickers degrade to server defaults; the request still works. appLogger.w('Seerr: service detail load failed', error: e); if (!mounted || _server?.id != server.id) return; setState(() => _serverDetailLoading = false); } } void _toggle4k(bool value) { setState(() { _is4k = value; _selectedSeasons.removeWhere(_seasonBlocked); }); _selectDefaultServer(); } // ---------- Availability ---------- /// Non-declined requests matching the current 4K variant. Iterable get _activeRequests => (_mediaInfo?.requests ?? const []).where( (r) => (r.is4k ?? false) == _is4k && r.status != SeerrRequestStatus.declined, ); SeerrMediaStatus _variantStatus(SeerrMediaStatus status, SeerrMediaStatus status4k) => _is4k ? status4k : status; /// Why this title/season can't be requested, or null when it can. String? _blockedLabel(SeerrMediaStatus status, {required bool coveredByRequest}) { return switch (status) { SeerrMediaStatus.available => t.seerr.statusAvailable, SeerrMediaStatus.partiallyAvailable => t.seerr.statusPartiallyAvailable, SeerrMediaStatus.processing => t.seerr.statusProcessing, SeerrMediaStatus.pending => t.seerr.statusRequested, SeerrMediaStatus.unknown || SeerrMediaStatus.deleted when coveredByRequest => t.seerr.statusRequested, SeerrMediaStatus.unknown || SeerrMediaStatus.deleted => null, }; } /// Movies block at the title level; shows block per season. String? get _movieBlockedLabel { final info = _mediaInfo; if (info == null) return null; return _blockedLabel(_variantStatus(info.status, info.status4k), coveredByRequest: _activeRequests.isNotEmpty); } String? _seasonBlockedLabel(int seasonNumber) { final info = _mediaInfo; if (info == null) return null; final season = info.seasons?.firstWhereOrNull((s) => s.seasonNumber == seasonNumber); final covered = _activeRequests.any((r) => r.seasons?.any((s) => s.seasonNumber == seasonNumber) ?? false); return _blockedLabel( _variantStatus(season?.status ?? SeerrMediaStatus.unknown, season?.status4k ?? SeerrMediaStatus.unknown), coveredByRequest: covered, ); } bool _seasonBlocked(int seasonNumber) => _seasonBlockedLabel(seasonNumber) != null; List get _requestableSeasons => [ for (final season in _seasons) if (!_seasonBlocked(season.seasonNumber)) season.seasonNumber, ]; bool get _nothingToRequest => _isMovie ? _movieBlockedLabel != null : _requestableSeasons.isEmpty; bool get _canSubmit { if (_submitting || _loading || _loadFailed || _nothingToRequest) return false; if (!_isMovie && _partialSeasons && _selectedSeasons.isEmpty) return false; return true; } // ---------- Submit ---------- Future _submit() async { if (!_canSubmit) return; setState(() { _submitting = true; _errorText = null; }); final advanced = _advancedAllowed && _server != null; final payload = SeerrRequestPayload( mediaType: _isMovie ? 'movie' : 'tv', mediaId: widget.tmdbId, seasons: _isMovie ? null : (_partialSeasons ? (_selectedSeasons.toList()..sort()) : null), is4k: _is4k, serverId: advanced ? _server?.id : null, profileId: advanced ? _profileId : null, rootFolder: advanced ? _rootFolder : null, languageProfileId: advanced ? _languageProfileId : null, ); try { await widget.source.client.createRequest(payload); if (!mounted) return; // The sheet may be hosted by an OverlaySheetHost (no route of its own), // so a bare Navigator.pop would pop the screen underneath instead. OverlaySheetController.closeAdaptive(context); showSuccessSnackBar(context, t.seerr.requestSubmitted); } on SeerrApiException catch (e) { if (!mounted) return; setState(() { _submitting = false; _errorText = e.message; }); } catch (e) { appLogger.w('Seerr: request submit failed', error: e); if (!mounted) return; setState(() { _submitting = false; _errorText = t.seerr.requestFailed(error: '$e'); }); } } bool get _hasVisibleAdvancedControls { if (!_advancedAllowed || _serversForVariant.isEmpty) return false; final detail = _serverDetail; return _serversForVariant.length > 1 || (detail?.profiles?.isNotEmpty ?? false) || (detail?.rootFolders?.isNotEmpty ?? false) || (detail?.languageProfiles?.isNotEmpty ?? false); } void _focusRequestButton() { _requestButtonFocusNode.requestFocus(); final buttonContext = _requestButtonFocusNode.context; if (buttonContext == null) return; unawaited( Scrollable.ensureVisible( buttonContext, alignment: 0.9, duration: const Duration(milliseconds: 200), curve: Curves.easeOut, ), ); } KeyEventResult _handleSeasonKey(int seasonNumber, KeyEvent event) { if (!event.isActionable || !event.logicalKey.isDownKey) { return KeyEventResult.ignored; } if (_requestableSeasons.lastOrNull != seasonNumber || _can4k || _hasVisibleAdvancedControls || !_canSubmit) { return KeyEventResult.ignored; } _focusRequestButton(); return KeyEventResult.handled; } // ---------- UI ---------- @override Widget build(BuildContext context) { final theme = Theme.of(context); return SingleChildScrollView( padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text(t.seerr.request, style: theme.textTheme.titleLarge), const SizedBox(height: 2), Text( widget.title, style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurface.withValues(alpha: 0.7)), maxLines: 2, overflow: TextOverflow.ellipsis, ), const SizedBox(height: 12), if (_loading) const Padding( padding: EdgeInsets.symmetric(vertical: 32), child: Center(child: LoadingIndicatorBox()), ) else if (_loadFailed) _buildLoadError(theme) else ...[ if (_nothingToRequest) _buildNothingToRequest(theme) else ...[ if (!_isMovie && _partialSeasons) ..._buildSeasonSection(theme), if (_can4k) SwitchListTile( value: _is4k, onChanged: _submitting ? null : _toggle4k, title: Text(t.seerr.request4k), secondary: const AppIcon(Symbols.four_k, fill: 1), contentPadding: EdgeInsets.zero, ), if (_advancedAllowed && _serversForVariant.isNotEmpty) ..._buildAdvancedSection(theme), if (_errorText case final String error) ...[ const SizedBox(height: 8), Text(error, style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.error)), ], const SizedBox(height: 16), FilledButton.icon( focusNode: _requestButtonFocusNode, onPressed: _canSubmit ? _submit : null, icon: _submitting ? const LoadingIndicatorBox() : const AppIcon(Symbols.download_rounded, fill: 1), label: Text(t.seerr.request), ), ], ], ], ), ); } Widget _buildLoadError(ThemeData theme) => Padding( padding: const EdgeInsets.symmetric(vertical: 16), child: Column( children: [ Text(t.seerr.requestsLoadFailed, style: theme.textTheme.bodyMedium), const SizedBox(height: 12), OutlinedButton(onPressed: () => unawaited(_load()), child: Text(t.common.retry)), ], ), ); Widget _buildNothingToRequest(ThemeData theme) { final label = _isMovie ? _movieBlockedLabel : null; return Padding( padding: const EdgeInsets.symmetric(vertical: 16), child: Row( children: [ AppIcon(Symbols.check_circle_rounded, fill: 1, color: theme.colorScheme.primary), const SizedBox(width: 10), Expanded(child: Text(label ?? t.seerr.nothingToRequest, style: theme.textTheme.bodyMedium)), ], ), ); } List _buildSeasonSection(ThemeData theme) { final requestable = _requestableSeasons; final allSelected = requestable.isNotEmpty && requestable.every(_selectedSeasons.contains); return [ Text(t.seerr.seasons, style: theme.textTheme.titleSmall), CheckboxListTile( value: allSelected, onChanged: _submitting ? null : (checked) => setState(() { if (checked ?? false) { _selectedSeasons.addAll(requestable); } else { _selectedSeasons.clear(); } }), title: Text(t.seerr.allSeasons), contentPadding: EdgeInsets.zero, controlAffinity: ListTileControlAffinity.leading, ), for (var index = 0; index < _seasons.length; index++) _buildSeasonTile(theme, _seasons[index], index), const SizedBox(height: 8), ]; } Widget _buildSeasonTile(ThemeData theme, SeerrSeason season, int index) { final number = season.seasonNumber; final blockedLabel = _seasonBlockedLabel(number); final episodeCount = season.episodeCount; return CheckboxListTile( focusNode: _seasonFocusNodes[index], value: blockedLabel != null || _selectedSeasons.contains(number), onChanged: blockedLabel != null || _submitting ? null : (checked) => setState(() { if (checked ?? false) { _selectedSeasons.add(number); } else { _selectedSeasons.remove(number); } }), title: Text(season.name ?? t.common.seasonNumber(number: number)), subtitle: episodeCount == null ? null : Text(t.explore.episodeCount(n: episodeCount)), secondary: blockedLabel == null ? null : StatChip(label: blockedLabel), contentPadding: EdgeInsets.zero, controlAffinity: ListTileControlAffinity.leading, ); } List _buildAdvancedSection(ThemeData theme) { final servers = _serversForVariant; final detail = _serverDetail; final profiles = detail?.profiles ?? const []; final folders = detail?.rootFolders ?? const []; final languages = detail?.languageProfiles ?? const []; return [ const SizedBox(height: 8), Row( children: [ Text(t.seerr.advancedOptions, style: theme.textTheme.titleSmall), if (_serverDetailLoading) ...[const SizedBox(width: 10), const LoadingIndicatorBox(size: 14)], ], ), if (servers.length > 1) _PickerTile( icon: Symbols.dns_rounded, label: t.seerr.destinationServer, value: _server?.name ?? '', options: servers, describe: (s) => s.name ?? '#${s.id}', isSelected: (s) => s.id == _server?.id, enabled: !_submitting, onSelected: _adoptServer, ), if (profiles.isNotEmpty) _PickerTile( icon: Symbols.high_quality_rounded, label: t.seerr.qualityProfile, value: profiles.firstWhereOrNull((p) => p.id == _profileId)?.name ?? '', options: profiles, describe: (p) => p.name ?? '#${p.id}', isSelected: (p) => p.id == _profileId, enabled: !_submitting, onSelected: (p) => setState(() => _profileId = p.id), ), if (folders.isNotEmpty) _PickerTile( icon: Symbols.folder_rounded, label: t.seerr.rootFolder, value: _rootFolder ?? '', options: folders, describe: (f) => f.path ?? '#${f.id}', isSelected: (f) => f.path == _rootFolder, enabled: !_submitting, onSelected: (f) => setState(() => _rootFolder = f.path), ), if (languages.isNotEmpty) _PickerTile( icon: Symbols.language_rounded, label: t.seerr.languageProfile, value: languages.firstWhereOrNull((p) => p.id == _languageProfileId)?.name ?? '', options: languages, describe: (p) => p.name ?? '#${p.id}', isSelected: (p) => p.id == _languageProfileId, enabled: !_submitting, onSelected: (p) => setState(() => _languageProfileId = p.id), ), ]; } } /// A "current value" row that opens an [showAppMenu] of options, anchored to /// itself — the same dpad-safe pattern as the watchlist source chooser. class _PickerTile extends StatelessWidget { final IconData icon; final String label; final String value; final List options; final String Function(T) describe; final bool Function(T) isSelected; final bool enabled; final ValueChanged onSelected; const _PickerTile({ required this.icon, required this.label, required this.value, required this.options, required this.describe, required this.isSelected, required this.enabled, required this.onSelected, }); Future _open(BuildContext context) async { final box = context.findRenderObject() as RenderBox?; if (box == null) return; final anchorRect = box.localToGlobal(Offset.zero) & box.size; final picked = await showAppMenu( context, anchorRect: anchorRect, focusFirstItem: true, entries: [ for (final option in options) AppMenuItem(value: option, label: describe(option), selected: isSelected(option)), ], ); if (!context.mounted) return; if (picked != null) onSelected(picked); } @override Widget build(BuildContext context) { return ListTile( leading: AppIcon(icon, fill: 1), title: Text(label), subtitle: value.isEmpty ? null : Text(value, maxLines: 1, overflow: TextOverflow.ellipsis), trailing: const AppIcon(Symbols.unfold_more_rounded, fill: 1), contentPadding: EdgeInsets.zero, enabled: enabled, onTap: () => unawaited(_open(context)), ); } }