Files
plezy/lib/widgets/rating_bottom_sheet.dart
T
edde746 24a041977b fix(sheets): size sheets to their content instead of 75% of the window
Sheets rendered at the host's maximum height regardless of content, so a
one-item player queue or a two-track picker filled ~75% of a desktop window
with empty space.

BottomSheetPageScaffold now always lays out Column(mainAxisSize: .min) plus
Flexible(child:), and each sheet body shrink-wraps its own scrollable. The
scaffold's shrinkWrap flag is gone: its old true branch put the child on an
unbounded axis, where an over-tall list overflowed instead of clamping and
scrolling. Measured on a 1600x1000 window, the chapter sheet goes from 750px
to 118px for one chapter and the two-column track sheet from 750px to 154px
for one audio and one subtitle track, both still clamping at the cap.

Add SheetSplitColumns for the three side-by-side sheet layouts. A bare
VerticalDivider has no intrinsic height, so it inflated those rows to the cap
on its own; the rule now paints from a Positioned.fill that cannot size the
Stack. IntrinsicHeight is not an option because a Viewport has no intrinsics.

Because sheets are bottom-anchored, a content-driven height moves the sheet's
top edge and everything above the change point. Three surfaces opt out for
that reason and say so at the call site: SubtitleSearchSheet and its language
picker keep filling, since both refilter under an autofocused field;
FiltersBottomSheet holds the outgoing page's height through its loading
transient; and RatingBottomSheet no longer hides MAL/AniList rows
asynchronously, which used to slide live rating controls down two rows several
hundred ms after open. Wrap the shared StateMessageWidget at the filters sheet
boundary rather than editing a widget with 33 filling call sites.

The host gains an AnimatedSize keyed per sheet session so nested pushes ease
while a replacing show adopts its own height, a 720px absolute height ceiling
on desktop windows only, and a min(max(25%, 96px), 60%) drag-dismiss threshold
so short sheets neither close on a nudge nor become undismissable.

Add videoControls.noAudioDevicesAvailable so the audio output page shows a
placeholder instead of a bare header while devices load.
2026-08-08 00:37:17 +02:00

830 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_header.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 Column(
mainAxisSize: .min,
children: [
BottomSheetHeader(title: t.rateSheet.title, icon: Symbols.star_rounded),
Flexible(
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) => switch (backend) {
MediaBackend.plex => 'Plex',
MediaBackend.jellyfin => 'Jellyfin',
MediaBackend.emby => 'Emby',
};
}
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),
),
),
],
),
),
),
);
}
}