Seven sheets hand-rolled the Column(min) + BottomSheetHeader + Flexible tree that BottomSheetPageScaffold already produces; each now passes its header arguments to the scaffold. No onBack is supplied, so the widget tree, height, and focus behaviour are unchanged.
822 lines
27 KiB
Dart
822 lines
27 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:material_symbols_icons/symbols.dart';
|
|
import 'package:provider/provider.dart';
|
|
|
|
import '../focus/dpad_navigator.dart';
|
|
import '../focus/focusable_wrapper.dart';
|
|
import '../focus/input_mode_tracker.dart';
|
|
import '../i18n/strings.g.dart';
|
|
import '../media/media_backend.dart';
|
|
import '../media/media_item.dart';
|
|
import '../media/media_server_client.dart';
|
|
import '../models/catalog/catalog_item.dart';
|
|
import '../providers/trackers_provider.dart';
|
|
import '../screens/settings/tracker_service_info.dart';
|
|
import '../services/trackers/tracker.dart';
|
|
import '../services/trackers/tracker_constants.dart';
|
|
import '../services/trackers/tracker_id_resolver.dart';
|
|
import '../utils/app_logger.dart';
|
|
import '../utils/snackbar_helper.dart';
|
|
import 'app_icon.dart';
|
|
import 'backend_badge.dart';
|
|
import 'bottom_sheet_page_scaffold.dart';
|
|
import 'catalog_source_logo.dart';
|
|
import 'clickable_cursor.dart';
|
|
|
|
class RatingBottomSheet extends StatefulWidget {
|
|
final MediaItem item;
|
|
final MediaServerClient? serverClient;
|
|
final ValueChanged<double>? onServerRatingChanged;
|
|
final ValueChanged<bool>? onServerFavoriteChanged;
|
|
|
|
const RatingBottomSheet({
|
|
super.key,
|
|
required this.item,
|
|
required this.serverClient,
|
|
this.onServerRatingChanged,
|
|
this.onServerFavoriteChanged,
|
|
});
|
|
|
|
@override
|
|
State<RatingBottomSheet> createState() => _RatingBottomSheetState();
|
|
}
|
|
|
|
class _RatingBottomSheetState extends State<RatingBottomSheet> {
|
|
static const _autoSaveDelay = Duration(milliseconds: 600);
|
|
|
|
late double _serverStars;
|
|
late double _serverRating;
|
|
late bool _serverFavorite;
|
|
late final FocusNode _serverFocusNode;
|
|
|
|
final Map<TrackerService, int> _trackerScores = {};
|
|
final Map<TrackerService, FocusNode> _trackerFocusNodes = {};
|
|
final Map<String, Timer> _autoSaveTimers = {};
|
|
final Map<String, _TrackerRatingSource> _trackerSourcesByKey = {};
|
|
final Set<String> _pendingAutoSaves = {};
|
|
final Set<String> _loading = {};
|
|
final Map<String, _SectionStatus> _statuses = {};
|
|
TrackerIdResolver? _resolver;
|
|
bool _resolverNeedsFribb = false;
|
|
String? _trackerLoadKey;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_serverFocusNode = FocusNode(debugLabel: 'rating_server');
|
|
final rawServerRating = widget.item.userRating;
|
|
_serverRating = rawServerRating != null && rawServerRating > 0 ? rawServerRating : 0.0;
|
|
_serverStars = _serverRating > 0 ? _serverRating / 2.0 : 0.0;
|
|
_serverFavorite = widget.item.isFavorite == true;
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_flushPendingAutoSavesOnDispose();
|
|
_serverFocusNode.dispose();
|
|
for (final node in _trackerFocusNodes.values) {
|
|
node.dispose();
|
|
}
|
|
_resolver?.clearCache();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
// Trakt's account provider is watched by [_trackerSources] via `context`.
|
|
return Consumer<TrackersProvider>(
|
|
builder: (context, trackers, _) {
|
|
final trackerSources = _trackerSources(context);
|
|
_updateTrackerSourceMap(trackerSources);
|
|
_resolverNeedsFribb = trackers.isMalConnected || trackers.isAnilistConnected;
|
|
_queueTrackerScoreLoad(trackerSources);
|
|
|
|
final serverCaps = widget.serverClient?.capabilities;
|
|
final showServerRow = serverCaps != null && (serverCaps.numericUserRating || serverCaps.userFavorites);
|
|
final focusNodes = <FocusNode>[
|
|
if (showServerRow) _serverFocusNode,
|
|
for (final source in trackerSources) _trackerFocusNode(source.service),
|
|
];
|
|
var focusIndex = 0;
|
|
|
|
// Hugs its content: a handful of rows in a 720px sheet was mostly empty
|
|
// space. The row set is therefore fixed from the first frame — see
|
|
// [_loadTrackerScores], which marks an unratable tracker `notAvailable`
|
|
// rather than removing its row.
|
|
return BottomSheetPageScaffold(
|
|
title: t.rateSheet.title,
|
|
icon: Symbols.star_rounded,
|
|
child: ListView(
|
|
shrinkWrap: true,
|
|
padding: const EdgeInsets.fromLTRB(10, 4, 10, 12),
|
|
children: [
|
|
if (showServerRow)
|
|
_buildServerRow(
|
|
widget.serverClient!,
|
|
_serverFocusNode,
|
|
autofocus: focusIndex == 0,
|
|
onNavigateUp: _navTo(focusNodes, focusIndex - 1),
|
|
onNavigateDown: _navTo(focusNodes, focusIndex++ + 1),
|
|
),
|
|
for (final source in trackerSources)
|
|
_buildTrackerRow(
|
|
source,
|
|
_trackerFocusNode(source.service),
|
|
autofocus: focusIndex == 0,
|
|
onNavigateUp: _navTo(focusNodes, focusIndex - 1),
|
|
onNavigateDown: _navTo(focusNodes, focusIndex++ + 1),
|
|
),
|
|
if (trackerSources.isEmpty)
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 4),
|
|
child: Text(
|
|
t.rateSheet.noConnectedServices,
|
|
style: Theme.of(
|
|
context,
|
|
).textTheme.bodySmall?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _buildServerRow(
|
|
MediaServerClient client,
|
|
FocusNode focusNode, {
|
|
required bool autofocus,
|
|
required VoidCallback? onNavigateUp,
|
|
required VoidCallback? onNavigateDown,
|
|
}) {
|
|
final loading = _loading.contains(_serverKey);
|
|
final status = _statuses[_serverKey];
|
|
final subtitle =
|
|
'${_backendLabel(client.backend)} - ${client.serverName ?? widget.item.serverName ?? t.common.unknown}';
|
|
|
|
if (client.capabilities.numericUserRating) {
|
|
final value = (_serverStars * 2).round().clamp(0, 10).toInt();
|
|
return _RatingRow(
|
|
focusNode: focusNode,
|
|
autofocus: autofocus,
|
|
leading: BackendBadge(backend: client.backend, size: 22),
|
|
title: t.rateSheet.server,
|
|
subtitle: subtitle,
|
|
loading: loading,
|
|
status: status,
|
|
enabled: !loading,
|
|
onNavigateUp: onNavigateUp,
|
|
onNavigateDown: onNavigateDown,
|
|
onDecrease: () => _setServerStarUnits(value - 1),
|
|
onIncrease: () => _setServerStarUnits(value + 1),
|
|
onSubmit: () => unawaited(_submitServerStars()),
|
|
control: _StarRatingControl(
|
|
value: value,
|
|
enabled: !loading,
|
|
onChanged: _setServerStarUnits,
|
|
onSubmitValue: (units) => unawaited(_submitServerStars(stars: units / 2.0)),
|
|
),
|
|
);
|
|
}
|
|
|
|
return _RatingRow(
|
|
focusNode: focusNode,
|
|
autofocus: autofocus,
|
|
leading: BackendBadge(backend: client.backend, size: 22),
|
|
title: t.rateSheet.server,
|
|
subtitle: subtitle,
|
|
loading: loading,
|
|
status: status,
|
|
enabled: !loading,
|
|
onNavigateUp: onNavigateUp,
|
|
onNavigateDown: onNavigateDown,
|
|
onDecrease: () => _setServerFavorite(false),
|
|
onIncrease: () => _setServerFavorite(true),
|
|
onSubmit: () => unawaited(_submitServerFavorite(value: !_serverFavorite)),
|
|
control: _FavoriteControl(
|
|
value: _serverFavorite,
|
|
enabled: !loading,
|
|
onToggle: (next) => unawaited(_submitServerFavorite(value: next)),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildTrackerRow(
|
|
_TrackerRatingSource source,
|
|
FocusNode focusNode, {
|
|
required bool autofocus,
|
|
required VoidCallback? onNavigateUp,
|
|
required VoidCallback? onNavigateDown,
|
|
}) {
|
|
final key = source.service.name;
|
|
final loading = _loading.contains(key);
|
|
final status = _statuses[key];
|
|
final score = _trackerScores[source.service] ?? 0;
|
|
return _RatingRow(
|
|
focusNode: focusNode,
|
|
autofocus: autofocus,
|
|
leading: CatalogSourceLogo(source.logoSource, size: 24),
|
|
title: source.title,
|
|
subtitle: source.username != null ? t.services.connectedAs(username: source.username!) : source.connectedLabel,
|
|
loading: loading,
|
|
status: status,
|
|
enabled: !loading,
|
|
onNavigateUp: onNavigateUp,
|
|
onNavigateDown: onNavigateDown,
|
|
onDecrease: () => _setTrackerScore(source, score - 1),
|
|
onIncrease: () => _setTrackerScore(source, score + 1),
|
|
onSubmit: () => unawaited(_submitTrackerRating(source)),
|
|
control: _StarRatingControl(
|
|
value: score,
|
|
enabled: !loading,
|
|
onChanged: (value) => _setTrackerScore(source, value),
|
|
onSubmitValue: (value) => unawaited(_submitTrackerRating(source, score: value)),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Snapshot of every connected tracker, in the shared display order. Must be
|
|
/// called from a build so the provider reads register a dependency.
|
|
List<_TrackerRatingSource> _trackerSources(BuildContext context) => [
|
|
for (final info in TrackerServiceInfo.all)
|
|
if (info.isConnected(context))
|
|
_TrackerRatingSource(
|
|
service: info.service,
|
|
title: info.displayName,
|
|
username: info.username(context),
|
|
connectedLabel: t.trakt.connected,
|
|
logoSource: info.logoSource,
|
|
ratingSource: info.ratingSource,
|
|
),
|
|
];
|
|
|
|
void _updateTrackerSourceMap(List<_TrackerRatingSource> sources) {
|
|
_trackerSourcesByKey
|
|
..clear()
|
|
..addEntries(sources.map((source) => MapEntry(source.service.name, source)));
|
|
}
|
|
|
|
void _queueTrackerScoreLoad(List<_TrackerRatingSource> sources) {
|
|
final services = sources.map((s) => s.service.name).join(',');
|
|
final key = '${widget.serverClient?.serverId}:${widget.item.id}:$services';
|
|
if (_trackerLoadKey == key) return;
|
|
_trackerLoadKey = key;
|
|
if (sources.isEmpty) return;
|
|
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (!mounted || _trackerLoadKey != key) return;
|
|
unawaited(_loadTrackerScores(sources));
|
|
});
|
|
}
|
|
|
|
/// Every tracker that cannot rate this item keeps its row and shows
|
|
/// `notAvailable`. Removing a row instead would shorten the sheet several
|
|
/// hundred ms after it opens, and because sheets are bottom-anchored that
|
|
/// slides the rows above it — which here are live rating controls — out from
|
|
/// under the user's finger.
|
|
Future<void> _loadTrackerScores(List<_TrackerRatingSource> sources) async {
|
|
setState(() {
|
|
for (final source in sources) {
|
|
_loading.add(source.service.name);
|
|
_statuses.remove(source.service.name);
|
|
}
|
|
});
|
|
|
|
TrackerRatingContext ctx;
|
|
try {
|
|
ctx = await _resolveTrackerContext();
|
|
} on TrackerRatingUnavailableException {
|
|
if (!mounted) return;
|
|
setState(() {
|
|
for (final source in sources) {
|
|
_loading.remove(source.service.name);
|
|
_statuses[source.service.name] = _SectionStatus(t.rateSheet.notAvailable, isError: true);
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
|
|
await Future.wait(
|
|
sources.map((source) async {
|
|
final key = source.service.name;
|
|
try {
|
|
final score = await source.ratingSource.getRating(ctx);
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_trackerScores[source.service] = score ?? 0;
|
|
_statuses.remove(key);
|
|
});
|
|
} on TrackerRatingUnavailableException catch (e) {
|
|
appLogger.d('Rating unavailable', error: e);
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_statuses[key] = _SectionStatus(t.rateSheet.notAvailable, isError: true);
|
|
});
|
|
} catch (e) {
|
|
appLogger.w('Failed to load rating', error: e);
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_statuses[key] = _SectionStatus(t.errors.failedToRate, isError: true);
|
|
});
|
|
} finally {
|
|
if (mounted) {
|
|
setState(() {
|
|
_loading.remove(key);
|
|
});
|
|
}
|
|
}
|
|
}),
|
|
);
|
|
}
|
|
|
|
void _setServerStarUnits(int units) {
|
|
final clamped = units.clamp(0, 10).toInt();
|
|
if ((_serverStars * 2).round() == clamped) return;
|
|
setState(() {
|
|
_serverStars = clamped / 2.0;
|
|
_statuses.remove(_serverKey);
|
|
});
|
|
_scheduleAutoSave(_serverKey, _submitServerStars);
|
|
}
|
|
|
|
void _setServerFavorite(bool value) {
|
|
if (_serverFavorite == value) return;
|
|
setState(() {
|
|
_serverFavorite = value;
|
|
_statuses.remove(_serverKey);
|
|
});
|
|
_scheduleAutoSave(_serverKey, _submitServerFavorite);
|
|
}
|
|
|
|
void _setTrackerScore(_TrackerRatingSource source, int score) {
|
|
final clamped = score.clamp(0, 10).toInt();
|
|
if ((_trackerScores[source.service] ?? 0) == clamped) return;
|
|
setState(() {
|
|
_trackerScores[source.service] = clamped;
|
|
_statuses.remove(source.service.name);
|
|
});
|
|
_scheduleAutoSave(source.service.name, () => _submitTrackerRating(source));
|
|
}
|
|
|
|
Future<void> _submitServerStars({double? stars}) async {
|
|
_cancelAutoSave(_serverKey);
|
|
final value = stars ?? _serverStars;
|
|
if (value <= 0) {
|
|
await _clearServerRating();
|
|
return;
|
|
}
|
|
|
|
final rating = value * 2.0;
|
|
await _run(_serverKey, () async {
|
|
await widget.serverClient!.rate(widget.item, rating);
|
|
_serverRating = rating;
|
|
_serverStars = value;
|
|
widget.onServerRatingChanged?.call(rating);
|
|
});
|
|
}
|
|
|
|
Future<void> _clearServerRating() async {
|
|
_cancelAutoSave(_serverKey);
|
|
await _run(_serverKey, () async {
|
|
await widget.serverClient!.rate(widget.item, -1);
|
|
_serverRating = 0;
|
|
_serverStars = 0;
|
|
widget.onServerRatingChanged?.call(0);
|
|
});
|
|
}
|
|
|
|
Future<void> _submitServerFavorite({bool? value}) async {
|
|
_cancelAutoSave(_serverKey);
|
|
final selected = value ?? _serverFavorite;
|
|
await _run(_serverKey, () async {
|
|
await widget.serverClient!.setFavorite(widget.item, selected);
|
|
_serverFavorite = selected;
|
|
widget.onServerFavoriteChanged?.call(selected);
|
|
});
|
|
}
|
|
|
|
Future<void> _submitTrackerRating(_TrackerRatingSource source, {int? score}) async {
|
|
_cancelAutoSave(source.service.name);
|
|
final value = score ?? _trackerScores[source.service] ?? 0;
|
|
if (value <= 0) {
|
|
await _clearTrackerRating(source);
|
|
return;
|
|
}
|
|
await _run(source.service.name, () async {
|
|
final ctx = await _resolveTrackerContext();
|
|
await source.ratingSource.rate(ctx, value);
|
|
_trackerScores[source.service] = value;
|
|
});
|
|
}
|
|
|
|
Future<void> _clearTrackerRating(_TrackerRatingSource source) async {
|
|
_cancelAutoSave(source.service.name);
|
|
await _run(source.service.name, () async {
|
|
final ctx = await _resolveTrackerContext();
|
|
await source.ratingSource.clearRating(ctx);
|
|
_trackerScores[source.service] = 0;
|
|
});
|
|
}
|
|
|
|
Future<TrackerRatingContext> _resolveTrackerContext() async {
|
|
final client = widget.serverClient;
|
|
if (client == null) throw const TrackerRatingUnavailableException('tracker');
|
|
_resolver ??= TrackerIdResolver(client, needsFribb: () => _resolverNeedsFribb);
|
|
final ctx = await _resolver!.resolveForRating(widget.item);
|
|
if (ctx == null) throw const TrackerRatingUnavailableException('tracker');
|
|
return ctx;
|
|
}
|
|
|
|
Future<void> _run(String key, Future<void> Function() action) async {
|
|
setState(() {
|
|
_loading.add(key);
|
|
_statuses.remove(key);
|
|
});
|
|
|
|
try {
|
|
await action();
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_statuses[key] = _SectionStatus(t.rateSheet.saved);
|
|
});
|
|
} on TrackerRatingUnavailableException catch (e) {
|
|
appLogger.d('Rating unavailable', error: e);
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_statuses[key] = _SectionStatus(t.rateSheet.notAvailable, isError: true);
|
|
});
|
|
} catch (e) {
|
|
appLogger.w('Failed to update rating', error: e);
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_statuses[key] = _SectionStatus(t.errors.failedToRate, isError: true);
|
|
});
|
|
showErrorSnackBar(context, t.errors.failedToRate);
|
|
} finally {
|
|
if (mounted) {
|
|
setState(() {
|
|
_loading.remove(key);
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
void _scheduleAutoSave(String key, Future<void> Function() submit) {
|
|
_pendingAutoSaves.add(key);
|
|
_autoSaveTimers.remove(key)?.cancel();
|
|
_autoSaveTimers[key] = Timer(_autoSaveDelay, () {
|
|
_autoSaveTimers.remove(key);
|
|
_pendingAutoSaves.remove(key);
|
|
unawaited(submit());
|
|
});
|
|
}
|
|
|
|
void _cancelAutoSave(String key) {
|
|
_autoSaveTimers.remove(key)?.cancel();
|
|
_pendingAutoSaves.remove(key);
|
|
}
|
|
|
|
void _flushPendingAutoSavesOnDispose() {
|
|
final pendingKeys = Set<String>.from(_pendingAutoSaves);
|
|
for (final key in pendingKeys) {
|
|
_autoSaveTimers.remove(key)?.cancel();
|
|
}
|
|
_pendingAutoSaves.clear();
|
|
|
|
for (final key in pendingKeys) {
|
|
if (key == _serverKey) {
|
|
unawaited(_saveServerRatingDetached());
|
|
continue;
|
|
}
|
|
final source = _trackerSourcesByKey[key];
|
|
if (source != null) unawaited(_saveTrackerRatingDetached(source));
|
|
}
|
|
}
|
|
|
|
Future<void> _saveServerRatingDetached() async {
|
|
final client = widget.serverClient;
|
|
if (client == null) return;
|
|
|
|
try {
|
|
if (client.capabilities.numericUserRating) {
|
|
final rating = _serverStars <= 0 ? -1.0 : _serverStars * 2.0;
|
|
await client.rate(widget.item, rating);
|
|
widget.onServerRatingChanged?.call(rating < 0 ? 0 : rating);
|
|
return;
|
|
}
|
|
|
|
await client.setFavorite(widget.item, _serverFavorite);
|
|
widget.onServerFavoriteChanged?.call(_serverFavorite);
|
|
} catch (e) {
|
|
appLogger.w('Failed to update rating after sheet close', error: e);
|
|
}
|
|
}
|
|
|
|
Future<void> _saveTrackerRatingDetached(_TrackerRatingSource source) async {
|
|
try {
|
|
final ctx = await _resolveTrackerContext();
|
|
final value = _trackerScores[source.service] ?? 0;
|
|
if (value <= 0) {
|
|
await source.ratingSource.clearRating(ctx);
|
|
} else {
|
|
await source.ratingSource.rate(ctx, value);
|
|
}
|
|
} catch (e) {
|
|
appLogger.w('Failed to update tracker rating after sheet close', error: e);
|
|
}
|
|
}
|
|
|
|
FocusNode _trackerFocusNode(TrackerService service) {
|
|
return _trackerFocusNodes.putIfAbsent(service, () => FocusNode(debugLabel: 'rating_${service.name}'));
|
|
}
|
|
|
|
VoidCallback? _navTo(List<FocusNode> nodes, int index) {
|
|
if (index < 0 || index >= nodes.length) return null;
|
|
return () => nodes[index].requestFocus();
|
|
}
|
|
|
|
String _backendLabel(MediaBackend backend) => backend.dialect?.productName ?? 'Plex';
|
|
}
|
|
|
|
const _serverKey = 'server';
|
|
|
|
class _TrackerRatingSource {
|
|
final TrackerService service;
|
|
final String title;
|
|
final String? username;
|
|
final String connectedLabel;
|
|
final CatalogSourceId logoSource;
|
|
final TrackerRatingSource ratingSource;
|
|
|
|
const _TrackerRatingSource({
|
|
required this.service,
|
|
required this.title,
|
|
required this.username,
|
|
required this.connectedLabel,
|
|
required this.logoSource,
|
|
required this.ratingSource,
|
|
});
|
|
}
|
|
|
|
class _SectionStatus {
|
|
final String text;
|
|
final bool isError;
|
|
|
|
const _SectionStatus(this.text, {this.isError = false});
|
|
}
|
|
|
|
class _RatingRow extends StatelessWidget {
|
|
final FocusNode focusNode;
|
|
final bool autofocus;
|
|
final Widget leading;
|
|
final String title;
|
|
final String subtitle;
|
|
final bool loading;
|
|
final _SectionStatus? status;
|
|
final bool enabled;
|
|
final VoidCallback? onNavigateUp;
|
|
final VoidCallback? onNavigateDown;
|
|
final VoidCallback onDecrease;
|
|
final VoidCallback onIncrease;
|
|
final VoidCallback onSubmit;
|
|
final Widget control;
|
|
|
|
const _RatingRow({
|
|
required this.focusNode,
|
|
required this.autofocus,
|
|
required this.leading,
|
|
required this.title,
|
|
required this.subtitle,
|
|
required this.loading,
|
|
required this.status,
|
|
required this.enabled,
|
|
required this.onNavigateUp,
|
|
required this.onNavigateDown,
|
|
required this.onDecrease,
|
|
required this.onIncrease,
|
|
required this.onSubmit,
|
|
required this.control,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final status = this.status;
|
|
final statusText = status?.isError == true ? status!.text : subtitle;
|
|
final statusColor = status?.isError == true ? theme.colorScheme.error : theme.colorScheme.onSurfaceVariant;
|
|
final controlWidth = MediaQuery.sizeOf(context).width < 390 ? 124.0 : 142.0;
|
|
|
|
return Padding(
|
|
padding: const EdgeInsets.only(bottom: 6),
|
|
child: FocusableWrapper(
|
|
focusNode: focusNode,
|
|
autofocus: autofocus && InputModeTracker.isKeyboardMode(context),
|
|
borderRadius: 12,
|
|
autoScroll: true,
|
|
disableScale: true,
|
|
useBackgroundFocus: true,
|
|
descendantsAreFocusable: false,
|
|
onSelect: enabled ? onSubmit : null,
|
|
onNavigateUp: onNavigateUp,
|
|
onNavigateDown: onNavigateDown,
|
|
onKeyEvent: (_, event) {
|
|
if (!enabled || !event.isActionable) return KeyEventResult.ignored;
|
|
final key = event.logicalKey;
|
|
if (key.isLeftKey) {
|
|
onDecrease();
|
|
return KeyEventResult.handled;
|
|
}
|
|
if (key.isRightKey) {
|
|
onIncrease();
|
|
return KeyEventResult.handled;
|
|
}
|
|
return KeyEventResult.ignored;
|
|
},
|
|
child: Container(
|
|
constraints: const BoxConstraints(minHeight: 48),
|
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5),
|
|
child: Row(
|
|
children: [
|
|
SizedBox(width: 24, height: 24, child: Center(child: leading)),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: Column(
|
|
mainAxisSize: .min,
|
|
crossAxisAlignment: .start,
|
|
children: [
|
|
Text(title, style: theme.textTheme.titleSmall?.copyWith(fontWeight: .w700)),
|
|
Text(
|
|
statusText,
|
|
style: theme.textTheme.bodySmall?.copyWith(color: statusColor),
|
|
maxLines: 1,
|
|
overflow: .ellipsis,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
SizedBox(width: controlWidth, child: control),
|
|
const SizedBox(width: 8),
|
|
SizedBox(
|
|
width: 18,
|
|
height: 18,
|
|
child: _TrailingStatus(loading: loading, status: status),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _TrailingStatus extends StatelessWidget {
|
|
final bool loading;
|
|
final _SectionStatus? status;
|
|
|
|
const _TrailingStatus({required this.loading, required this.status});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
if (loading) return const CircularProgressIndicator(strokeWidth: 2);
|
|
final status = this.status;
|
|
if (status == null) return const SizedBox.shrink();
|
|
final color = status.isError ? Theme.of(context).colorScheme.error : Theme.of(context).colorScheme.primary;
|
|
return Tooltip(
|
|
message: status.text,
|
|
child: AppIcon(
|
|
status.isError ? Symbols.error_rounded : Symbols.check_circle_rounded,
|
|
fill: 1,
|
|
color: color,
|
|
size: 18,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _StarRatingControl extends StatefulWidget {
|
|
final int value;
|
|
final bool enabled;
|
|
final ValueChanged<int> onChanged;
|
|
final ValueChanged<int> onSubmitValue;
|
|
|
|
const _StarRatingControl({
|
|
required this.value,
|
|
required this.enabled,
|
|
required this.onChanged,
|
|
required this.onSubmitValue,
|
|
});
|
|
|
|
@override
|
|
State<_StarRatingControl> createState() => _StarRatingControlState();
|
|
}
|
|
|
|
class _StarRatingControlState extends State<_StarRatingControl> {
|
|
int? _pointerValue;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
return LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
final starWidth = (constraints.maxWidth / 5).clamp(0.0, 27.0).toDouble();
|
|
final iconSize = (starWidth * 0.9).clamp(0.0, 24.0).toDouble();
|
|
return ClickableCursor(
|
|
enabled: widget.enabled,
|
|
child: GestureDetector(
|
|
behavior: HitTestBehavior.opaque,
|
|
onTapDown: widget.enabled ? (details) => _setFromDx(details.localPosition.dx, constraints.maxWidth) : null,
|
|
onTapUp: widget.enabled ? (_) => widget.onSubmitValue(_pointerValue ?? widget.value) : null,
|
|
onPanUpdate: widget.enabled
|
|
? (details) => _setFromDx(details.localPosition.dx, constraints.maxWidth)
|
|
: null,
|
|
onPanEnd: widget.enabled ? (_) => widget.onSubmitValue(_pointerValue ?? widget.value) : null,
|
|
child: SizedBox(
|
|
height: 34,
|
|
child: Row(
|
|
mainAxisAlignment: .end,
|
|
children: List.generate(5, (i) {
|
|
final threshold = (i + 1) * 2;
|
|
final filled = widget.value >= threshold;
|
|
final half = widget.value == threshold - 1;
|
|
return SizedBox(
|
|
width: starWidth,
|
|
child: Center(
|
|
child: AppIcon(
|
|
half ? Symbols.star_half_rounded : Symbols.star_rounded,
|
|
fill: filled || half ? 1 : 0,
|
|
color: filled || half
|
|
? Colors.amber
|
|
: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.34),
|
|
size: iconSize,
|
|
),
|
|
),
|
|
);
|
|
}),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
void _setFromDx(double dx, double width) {
|
|
final safeWidth = width <= 0 ? 1.0 : width;
|
|
final value = ((dx.clamp(0.0, safeWidth) / safeWidth) * 10).round().clamp(0, 10).toInt();
|
|
_pointerValue = value;
|
|
widget.onChanged(value);
|
|
}
|
|
}
|
|
|
|
class _FavoriteControl extends StatelessWidget {
|
|
final bool value;
|
|
final bool enabled;
|
|
final ValueChanged<bool> onToggle;
|
|
|
|
const _FavoriteControl({required this.value, required this.enabled, required this.onToggle});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final scheme = theme.colorScheme;
|
|
return ClickableCursor(
|
|
enabled: enabled,
|
|
child: GestureDetector(
|
|
behavior: HitTestBehavior.opaque,
|
|
onTap: enabled ? () => onToggle(!value) : null,
|
|
child: Container(
|
|
height: 32,
|
|
decoration: BoxDecoration(
|
|
color: scheme.surfaceContainerHighest.withValues(alpha: 0.54),
|
|
borderRadius: const BorderRadius.all(Radius.circular(100)),
|
|
),
|
|
child: Row(
|
|
mainAxisAlignment: .center,
|
|
children: [
|
|
AppIcon(
|
|
Symbols.favorite_rounded,
|
|
fill: value ? 1 : 0,
|
|
color: value ? Colors.redAccent : (enabled ? scheme.onSurfaceVariant : theme.disabledColor),
|
|
size: 18,
|
|
),
|
|
const SizedBox(width: 6),
|
|
Flexible(
|
|
child: Text(
|
|
value ? t.rateSheet.favorited : t.rateSheet.favorite,
|
|
maxLines: 1,
|
|
overflow: .ellipsis,
|
|
style: theme.textTheme.labelMedium?.copyWith(fontWeight: .w700),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|