mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
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:
@@ -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,
|
||||
|
||||
+1
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user