feat(explore): Explore tab with catalog detail, search, and Seerr requests
This commit is contained in:
@@ -0,0 +1,549 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:material_symbols_icons/symbols.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<void> showSeerrRequestSheet(
|
||||
BuildContext context, {
|
||||
required SeerrCatalogSource source,
|
||||
required MediaKind kind,
|
||||
required int tmdbId,
|
||||
required String title,
|
||||
}) {
|
||||
return OverlaySheetController.showAdaptive<void>(
|
||||
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<SeerrRequestSheet> createState() => _SeerrRequestSheetState();
|
||||
}
|
||||
|
||||
class _SeerrRequestSheetState extends State<SeerrRequestSheet> {
|
||||
bool _loading = true;
|
||||
bool _loadFailed = false;
|
||||
|
||||
SeerrPublicSettings? _settings;
|
||||
SeerrMediaInfo? _mediaInfo;
|
||||
|
||||
/// TV only: requestable seasons (specials and empty seasons dropped).
|
||||
List<SeerrSeason> _seasons = const [];
|
||||
final Set<int> _selectedSeasons = {};
|
||||
|
||||
bool _is4k = false;
|
||||
|
||||
/// Advanced options (REQUEST_ADVANCED): all configured instances of the
|
||||
/// matching service; the pickers filter by the 4K toggle.
|
||||
List<SeerrServiceInstance> _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<SeerrServiceInstance> get _serversForVariant => [..._allServers.where((s) => s.is4k == _is4k)];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
unawaited(_load());
|
||||
}
|
||||
|
||||
Future<void> _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 <SeerrServiceInstance>[]),
|
||||
).wait;
|
||||
|
||||
SeerrMediaInfo? mediaInfo;
|
||||
var seasons = const <SeerrSeason>[];
|
||||
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 <SeerrSeason>[])
|
||||
if (season.seasonNumber > 0 && (season.episodeCount ?? 0) > 0) season,
|
||||
];
|
||||
}
|
||||
if (!mounted) return;
|
||||
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<void> _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<SeerrRequest> get _activeRequests => (_mediaInfo?.requests ?? const <SeerrRequest>[]).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<int> 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<void> _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');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 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(
|
||||
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<Widget> _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 (final season in _seasons) _buildSeasonTile(theme, season),
|
||||
const SizedBox(height: 8),
|
||||
];
|
||||
}
|
||||
|
||||
Widget _buildSeasonTile(ThemeData theme, SeerrSeason season) {
|
||||
final number = season.seasonNumber;
|
||||
final blockedLabel = _seasonBlockedLabel(number);
|
||||
final episodeCount = season.episodeCount;
|
||||
return CheckboxListTile(
|
||||
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<Widget> _buildAdvancedSection(ThemeData theme) {
|
||||
final servers = _serversForVariant;
|
||||
final detail = _serverDetail;
|
||||
final profiles = detail?.profiles ?? const <SeerrServiceProfile>[];
|
||||
final folders = detail?.rootFolders ?? const <SeerrRootFolder>[];
|
||||
final languages = detail?.languageProfiles ?? const <SeerrServiceProfile>[];
|
||||
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<SeerrServiceInstance>(
|
||||
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<SeerrServiceProfile>(
|
||||
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<SeerrRootFolder>(
|
||||
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<SeerrServiceProfile>(
|
||||
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<T> extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final String value;
|
||||
final List<T> options;
|
||||
final String Function(T) describe;
|
||||
final bool Function(T) isSelected;
|
||||
final bool enabled;
|
||||
final ValueChanged<T> 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<void> _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<T>(
|
||||
context,
|
||||
anchorRect: anchorRect,
|
||||
focusFirstItem: true,
|
||||
entries: [
|
||||
for (final option in options)
|
||||
AppMenuItem<T>(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)),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user