Introduces shared seams for paginated views, D-pad reorder, media control routing, async singletons and the device method channel, then points the open-coded copies at them. Also removes unused models and duplicated provider/server plumbing, folds the twice-implemented artifact store in the server, and factors the repeated Flutter toolchain prologue in CI into a composite action.
852 lines
28 KiB
Dart
852 lines
28 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<TrackerService> _hiddenTrackers = {};
|
|
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) {
|
|
final size = MediaQuery.sizeOf(context);
|
|
final maxHeight = size.height * (size.width > 600 ? 0.64 : 0.74);
|
|
|
|
// Trakt's account provider is watched by [_trackerSources] via `context`.
|
|
return Consumer<TrackersProvider>(
|
|
builder: (context, trackers, _) {
|
|
final allTrackerSources = _trackerSources(context);
|
|
final trackerSources = allTrackerSources.where((source) => !_hiddenTrackers.contains(source.service)).toList();
|
|
_updateTrackerSourceMap(trackerSources);
|
|
_resolverNeedsFribb = trackers.isMalConnected || trackers.isAnilistConnected;
|
|
_queueTrackerScoreLoad(allTrackerSources);
|
|
|
|
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;
|
|
|
|
return ConstrainedBox(
|
|
constraints: BoxConstraints(maxHeight: maxHeight),
|
|
child: Column(
|
|
mainAxisSize: .min,
|
|
children: [
|
|
BottomSheetHeader(title: t.rateSheet.title, icon: Symbols.star_rounded),
|
|
Flexible(
|
|
child: ListView(
|
|
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 (allTrackerSources.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));
|
|
});
|
|
}
|
|
|
|
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) {
|
|
if (_hidesWhenUnavailable(source)) {
|
|
_hideTrackerSource(source);
|
|
} else {
|
|
_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;
|
|
if (_hidesWhenUnavailable(source)) {
|
|
setState(() {
|
|
_hideTrackerSource(source);
|
|
});
|
|
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);
|
|
});
|
|
}
|
|
}
|
|
}),
|
|
);
|
|
}
|
|
|
|
bool _hidesWhenUnavailable(_TrackerRatingSource source) {
|
|
return source.service == TrackerService.mal || source.service == TrackerService.anilist;
|
|
}
|
|
|
|
void _hideTrackerSource(_TrackerRatingSource source) {
|
|
final key = source.service.name;
|
|
_hiddenTrackers.add(source.service);
|
|
_loading.remove(key);
|
|
_statuses.remove(key);
|
|
_trackerScores.remove(source.service);
|
|
_autoSaveTimers.remove(key)?.cancel();
|
|
_pendingAutoSaves.remove(key);
|
|
_trackerSourcesByKey.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',
|
|
};
|
|
}
|
|
|
|
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),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|