feat(mobile): star rating (#24457)

* feat(mobile): star rating

* refactor: use custom rating bar & provider

* refactor: remove user prop from provider

* feat: clear, padding, star size, impl suggestions

* chore: switch to rounded star icons

* fix: alignment & gesturedetector

* feat: rating search filter
This commit is contained in:
Yaros
2026-01-23 16:47:46 +01:00
committed by GitHub
parent 3cb284c15a
commit 574d9c34ff
18 changed files with 349 additions and 1 deletions
@@ -18,6 +18,7 @@ import 'package:immich_mobile/presentation/widgets/bottom_sheet/general_bottom_s
import 'package:immich_mobile/presentation/widgets/search/quick_date_picker.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/infrastructure/user_metadata.provider.dart';
import 'package:immich_mobile/providers/search/search_input_focus.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/widgets/common/feature_check.dart';
@@ -30,6 +31,7 @@ import 'package:immich_mobile/widgets/search/search_filter/media_type_picker.dar
import 'package:immich_mobile/widgets/search/search_filter/people_picker.dart';
import 'package:immich_mobile/widgets/search/search_filter/search_filter_chip.dart';
import 'package:immich_mobile/widgets/search/search_filter/search_filter_utils.dart';
import 'package:immich_mobile/widgets/search/search_filter/star_rating_picker.dart';
@RoutePage()
class DriftSearchPage extends HookConsumerWidget {
@@ -48,6 +50,7 @@ class DriftSearchPage extends HookConsumerWidget {
camera: preFilter?.camera ?? SearchCameraFilter(),
date: preFilter?.date ?? SearchDateFilter(),
display: preFilter?.display ?? SearchDisplayFilters(isNotInAlbum: false, isArchive: false, isFavorite: false),
rating: preFilter?.rating ?? SearchRatingFilter(),
mediaType: preFilter?.mediaType ?? AssetType.other,
language: "${context.locale.languageCode}-${context.locale.countryCode}",
assetId: preFilter?.assetId,
@@ -62,10 +65,15 @@ class DriftSearchPage extends HookConsumerWidget {
final cameraCurrentFilterWidget = useState<Widget?>(null);
final locationCurrentFilterWidget = useState<Widget?>(null);
final mediaTypeCurrentFilterWidget = useState<Widget?>(null);
final ratingCurrentFilterWidget = useState<Widget?>(null);
final displayOptionCurrentFilterWidget = useState<Widget?>(null);
final isSearching = useState(false);
final isRatingEnabled = ref
.watch(userMetadataPreferencesProvider)
.maybeWhen(data: (prefs) => prefs?.ratingsEnabled ?? false, orElse: () => false);
SnackBar searchInfoSnackBar(String message) {
return SnackBar(
content: Text(message, style: context.textTheme.labelLarge),
@@ -369,6 +377,35 @@ class DriftSearchPage extends HookConsumerWidget {
);
}
// STAR RATING PICKER
showStarRatingPicker() {
handleOnSelected(SearchRatingFilter rating) {
filter.value = filter.value.copyWith(rating: rating);
ratingCurrentFilterWidget.value = Text(
'rating_count'.t(args: {'count': rating.rating!}),
style: context.textTheme.labelLarge,
);
}
handleClear() {
filter.value = filter.value.copyWith(rating: SearchRatingFilter(rating: null));
ratingCurrentFilterWidget.value = null;
search();
}
showFilterBottomSheet(
context: context,
isScrollControlled: true,
child: FilterBottomSheetScaffold(
title: 'rating'.t(context: context),
onSearch: search,
onClear: handleClear,
child: StarRatingPicker(onSelect: handleOnSelected, filter: filter.value.rating),
),
);
}
// DISPLAY OPTION
showDisplayOptionPicker() {
handleOnSelect(Map<DisplayOption, bool> value) {
@@ -629,6 +666,14 @@ class DriftSearchPage extends HookConsumerWidget {
label: 'search_filter_media_type'.t(context: context),
currentFilter: mediaTypeCurrentFilterWidget.value,
),
if (isRatingEnabled) ...[
SearchFilterChip(
icon: Icons.star_outline_rounded,
onTap: showStarRatingPicker,
label: 'search_filter_star_rating'.t(context: context),
currentFilter: ratingCurrentFilterWidget.value,
),
],
SearchFilterChip(
icon: Icons.display_settings_outlined,
onTap: showDisplayOptionPicker,
@@ -34,6 +34,7 @@ class SimilarPhotosActionButton extends ConsumerWidget {
camera: SearchCameraFilter(),
date: SearchDateFilter(),
display: SearchDisplayFilters(isNotInAlbum: false, isArchive: false, isFavorite: false),
rating: SearchRatingFilter(),
mediaType: AssetType.image,
),
);
@@ -16,11 +16,13 @@ import 'package:immich_mobile/presentation/widgets/album/album_tile.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_sheet/sheet_location_details.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_sheet/sheet_people_details.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/rating_bar.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/user_metadata.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:immich_mobile/routing/router.dart';
@@ -204,6 +206,9 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
final cameraTitle = _getCameraInfoTitle(exifInfo);
final lensTitle = exifInfo?.lens != null && exifInfo!.lens!.isNotEmpty ? exifInfo.lens : null;
final isOwner = ref.watch(currentUserProvider)?.id == (asset is RemoteAsset ? asset.ownerId : null);
final isRatingEnabled = ref
.watch(userMetadataPreferencesProvider)
.maybeWhen(data: (prefs) => prefs?.ratingsEnabled ?? false, orElse: () => false);
// Build file info tile based on asset type
Widget buildFileInfoTile() {
@@ -283,6 +288,38 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
subtitleStyle: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary),
),
],
// Rating bar
if (isRatingEnabled) ...[
Padding(
padding: const EdgeInsets.only(left: 16.0, top: 16.0),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 8,
children: [
Text(
'rating'.t(context: context).toUpperCase(),
style: context.textTheme.labelMedium?.copyWith(
color: context.textTheme.labelMedium?.color?.withAlpha(200),
fontWeight: FontWeight.w600,
),
),
RatingBar(
initialRating: exifInfo?.rating?.toDouble() ?? 0,
filledColor: context.themeData.colorScheme.primary,
unfilledColor: context.themeData.colorScheme.onSurface.withAlpha(100),
itemSize: 40,
onRatingUpdate: (rating) async {
await ref.read(actionProvider.notifier).updateRating(ActionSource.viewer, rating.round());
},
onClearRating: () async {
await ref.read(actionProvider.notifier).updateRating(ActionSource.viewer, 0);
},
),
],
),
),
],
// Appears in (Albums)
Padding(padding: const EdgeInsets.only(top: 16.0), child: _buildAppearsInList(ref, context)),
// padding at the bottom to avoid cut-off
@@ -0,0 +1,125 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
class RatingBar extends StatefulWidget {
final double initialRating;
final int itemCount;
final double itemSize;
final Color filledColor;
final Color unfilledColor;
final ValueChanged<int>? onRatingUpdate;
final VoidCallback? onClearRating;
final Widget? itemBuilder;
final double starPadding;
const RatingBar({
super.key,
this.initialRating = 0.0,
this.itemCount = 5,
this.itemSize = 40.0,
this.filledColor = Colors.amber,
this.unfilledColor = Colors.grey,
this.onRatingUpdate,
this.onClearRating,
this.itemBuilder,
this.starPadding = 4.0,
});
@override
State<RatingBar> createState() => _RatingBarState();
}
class _RatingBarState extends State<RatingBar> {
late double _currentRating;
@override
void initState() {
super.initState();
_currentRating = widget.initialRating;
}
void _updateRating(Offset localPosition, bool isRTL, {bool isTap = false}) {
final totalWidth = widget.itemCount * widget.itemSize + (widget.itemCount - 1) * widget.starPadding;
double dx = localPosition.dx;
if (isRTL) dx = totalWidth - dx;
double newRating;
if (dx <= 0) {
newRating = 0;
} else if (dx >= totalWidth) {
newRating = widget.itemCount.toDouble();
} else {
double starWithPadding = widget.itemSize + widget.starPadding;
int tappedIndex = (dx / starWithPadding).floor().clamp(0, widget.itemCount - 1);
newRating = tappedIndex + 1.0;
if (isTap && newRating == _currentRating && _currentRating != 0) {
newRating = 0;
}
}
if (_currentRating != newRating) {
setState(() {
_currentRating = newRating;
});
widget.onRatingUpdate?.call(newRating.round());
}
}
@override
Widget build(BuildContext context) {
final isRTL = Directionality.of(context) == TextDirection.rtl;
final double visualAlignmentOffset = 5.0;
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Transform.translate(
offset: Offset(isRTL ? visualAlignmentOffset : -visualAlignmentOffset, 0),
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTapDown: (details) => _updateRating(details.localPosition, isRTL, isTap: true),
onPanUpdate: (details) => _updateRating(details.localPosition, isRTL, isTap: false),
child: Row(
mainAxisSize: MainAxisSize.min,
textDirection: isRTL ? TextDirection.rtl : TextDirection.ltr,
children: List.generate(widget.itemCount * 2 - 1, (i) {
if (i.isOdd) {
return SizedBox(width: widget.starPadding);
}
int index = i ~/ 2;
bool filled = _currentRating > index;
return widget.itemBuilder ??
Icon(
Icons.star_rounded,
size: widget.itemSize,
color: filled ? widget.filledColor : widget.unfilledColor,
);
}),
),
),
),
if (_currentRating > 0)
Padding(
padding: const EdgeInsets.only(top: 12.0),
child: GestureDetector(
onTap: () {
setState(() {
_currentRating = 0;
});
widget.onClearRating?.call();
},
child: Text(
'rating_clear'.t(context: context),
style: TextStyle(color: context.themeData.colorScheme.primary),
),
),
),
],
);
}
}