From 46e2d6e71e2a8394443f988fb262e35b8129c87f Mon Sep 17 00:00:00 2001 From: Yaros Date: Mon, 8 Dec 2025 15:10:51 +0100 Subject: [PATCH 1/7] feat(mobile): star rating --- mobile/lib/domain/models/exif.model.dart | 7 +++ .../infrastructure/entities/exif.entity.dart | 1 + .../repositories/remote_asset.repository.dart | 6 +++ .../asset_viewer/bottom_sheet.widget.dart | 44 ++++++++++++++++++- .../infrastructure/action.provider.dart | 16 +++++++ .../user_metadata.provider.dart | 6 +++ .../repositories/asset_api.repository.dart | 4 ++ mobile/lib/services/action.service.dart | 8 ++++ mobile/pubspec.lock | 8 ++++ mobile/pubspec.yaml | 1 + 10 files changed, 99 insertions(+), 2 deletions(-) diff --git a/mobile/lib/domain/models/exif.model.dart b/mobile/lib/domain/models/exif.model.dart index 46e2352ac87e1..d0f78b59de318 100644 --- a/mobile/lib/domain/models/exif.model.dart +++ b/mobile/lib/domain/models/exif.model.dart @@ -6,6 +6,7 @@ class ExifInfo { final String? orientation; final String? timeZone; final DateTime? dateTimeOriginal; + final int? rating; // GPS final double? latitude; @@ -46,6 +47,7 @@ class ExifInfo { this.orientation, this.timeZone, this.dateTimeOriginal, + this.rating, this.isFlipped = false, this.latitude, this.longitude, @@ -71,6 +73,7 @@ class ExifInfo { other.orientation == orientation && other.timeZone == timeZone && other.dateTimeOriginal == dateTimeOriginal && + other.rating == rating && other.latitude == latitude && other.longitude == longitude && other.city == city && @@ -94,6 +97,7 @@ class ExifInfo { isFlipped.hashCode ^ timeZone.hashCode ^ dateTimeOriginal.hashCode ^ + rating.hashCode ^ latitude.hashCode ^ longitude.hashCode ^ city.hashCode ^ @@ -118,6 +122,7 @@ orientation: ${orientation ?? 'NA'}, isFlipped: $isFlipped, timeZone: ${timeZone ?? 'NA'}, dateTimeOriginal: ${dateTimeOriginal ?? 'NA'}, +rating: ${rating ?? 'NA'}, latitude: ${latitude ?? 'NA'}, longitude: ${longitude ?? 'NA'}, city: ${city ?? 'NA'}, @@ -140,6 +145,7 @@ exposureSeconds: ${exposureSeconds ?? 'NA'}, String? orientation, String? timeZone, DateTime? dateTimeOriginal, + int? rating, double? latitude, double? longitude, String? city, @@ -161,6 +167,7 @@ exposureSeconds: ${exposureSeconds ?? 'NA'}, orientation: orientation ?? this.orientation, timeZone: timeZone ?? this.timeZone, dateTimeOriginal: dateTimeOriginal ?? this.dateTimeOriginal, + rating: rating ?? this.rating, isFlipped: isFlipped ?? this.isFlipped, latitude: latitude ?? this.latitude, longitude: longitude ?? this.longitude, diff --git a/mobile/lib/infrastructure/entities/exif.entity.dart b/mobile/lib/infrastructure/entities/exif.entity.dart index 2dbe05b9d7a7f..77cae5dbbeeb6 100644 --- a/mobile/lib/infrastructure/entities/exif.entity.dart +++ b/mobile/lib/infrastructure/entities/exif.entity.dart @@ -151,6 +151,7 @@ extension RemoteExifEntityDataDomainEx on RemoteExifEntityData { domain.ExifInfo toDto() => domain.ExifInfo( fileSize: fileSize, dateTimeOriginal: dateTimeOriginal, + rating: rating, timeZone: timeZone, make: make, model: model, diff --git a/mobile/lib/infrastructure/repositories/remote_asset.repository.dart b/mobile/lib/infrastructure/repositories/remote_asset.repository.dart index 96c204ea0e3b6..df4172df99473 100644 --- a/mobile/lib/infrastructure/repositories/remote_asset.repository.dart +++ b/mobile/lib/infrastructure/repositories/remote_asset.repository.dart @@ -255,6 +255,12 @@ class RemoteAssetRepository extends DriftDatabaseRepository { ); } + Future updateRating(String assetId, int rating) async { + await (_db.remoteExifEntity.update()..where((row) => row.assetId.equals(assetId))).write( + RemoteExifEntityCompanion(rating: Value(rating)), + ); + } + Future getCount() { return _db.managers.remoteAssetEntity.count(); } diff --git a/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart index 80840d94b4dde..9ce8317767d4a 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart @@ -4,6 +4,7 @@ import 'package:auto_route/auto_route.dart'; import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_rating_bar/flutter_rating_bar.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; @@ -23,6 +24,7 @@ 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/current_album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/setting.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/user_metadata.provider.dart'; import 'package:immich_mobile/providers/routes.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; @@ -54,6 +56,12 @@ class AssetDetailBottomSheet extends ConsumerWidget { final currentAlbum = ref.watch(currentRemoteAlbumProvider); final isArchived = asset is RemoteAsset && asset.visibility == AssetVisibility.archive; final advancedTroubleshooting = ref.watch(settingsProvider.notifier).get(Setting.advancedTroubleshooting); + final isRatingEnabled = ref + .watch(userMetadataProvider(ref.watch(currentUserProvider)?.id ?? '')) + .maybeWhen( + data: (metadataList) => metadataList.any((meta) => meta.preferences?.ratingsEnabled ?? false), + orElse: () => false, + ); final buttonContext = ActionButtonContext( asset: asset, @@ -71,7 +79,7 @@ class AssetDetailBottomSheet extends ConsumerWidget { return BaseBottomSheet( actions: actions, - slivers: const [_AssetDetailBottomSheet()], + slivers: [_AssetDetailBottomSheet(isRatingEnabled: isRatingEnabled)], controller: controller, initialChildSize: initialChildSize, minChildSize: 0.1, @@ -85,7 +93,9 @@ class AssetDetailBottomSheet extends ConsumerWidget { } class _AssetDetailBottomSheet extends ConsumerWidget { - const _AssetDetailBottomSheet(); + final bool isRatingEnabled; + + const _AssetDetailBottomSheet({required this.isRatingEnabled}); String _getDateTime(BuildContext ctx, BaseAsset asset, ExifInfo? exifInfo) { DateTime dateTime = asset.createdAt.toLocal(); @@ -323,6 +333,36 @@ class _AssetDetailBottomSheet extends ConsumerWidget { ), ), ], + // Rating bar + if (isRatingEnabled) ...[ + Padding( + padding: const EdgeInsets.only(left: 16.0), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 16), + Text( + 'rating'.t(context: context).toUpperCase(), + style: context.textTheme.labelMedium?.copyWith( + color: context.textTheme.labelMedium?.color?.withAlpha(200), + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + RatingBar.builder( + initialRating: exifInfo?.rating?.toDouble() ?? 0, + itemBuilder: (context, _) => Icon(Icons.star, color: context.themeData.colorScheme.primary), + itemSize: 32, + glow: false, + onRatingUpdate: (rating) async { + await ref.read(actionProvider.notifier).updateRating(ActionSource.viewer, rating.round()); + }, + ), + ], + ), + ), + ], // Appears in (Albums) Padding(padding: const EdgeInsets.only(top: 16.0), child: _buildAppearsInList(ref, context)), // padding at the bottom to avoid cut-off diff --git a/mobile/lib/providers/infrastructure/action.provider.dart b/mobile/lib/providers/infrastructure/action.provider.dart index d4d850d8c1116..3f079404f7f3a 100644 --- a/mobile/lib/providers/infrastructure/action.provider.dart +++ b/mobile/lib/providers/infrastructure/action.provider.dart @@ -357,6 +357,22 @@ class ActionNotifier extends Notifier { } } + Future updateRating(ActionSource source, int rating) async { + final ids = _getRemoteIdsForSource(source); + if (ids.length != 1) { + _logger.warning('updateRating called with multiple assets, expected single asset'); + return ActionResult(count: ids.length, success: false, error: 'Expected single asset for rating update'); + } + + try { + final isUpdated = await _service.updateRating(ids.first, rating); + return ActionResult(count: 1, success: isUpdated); + } catch (error, stack) { + _logger.severe('Failed to update rating for asset', error, stack); + return ActionResult(count: 1, success: false, error: error.toString()); + } + } + Future stack(String userId, ActionSource source) async { final ids = _getOwnedRemoteIdsForSource(source); try { diff --git a/mobile/lib/providers/infrastructure/user_metadata.provider.dart b/mobile/lib/providers/infrastructure/user_metadata.provider.dart index 2e2ae7555bf1a..2ecf70d57257c 100644 --- a/mobile/lib/providers/infrastructure/user_metadata.provider.dart +++ b/mobile/lib/providers/infrastructure/user_metadata.provider.dart @@ -1,7 +1,13 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/user_metadata.model.dart'; import 'package:immich_mobile/infrastructure/repositories/user_metadata.repository.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; final userMetadataRepository = Provider( (ref) => DriftUserMetadataRepository(ref.watch(driftProvider)), ); + +final userMetadataProvider = FutureProvider.family, String>((ref, String userId) async { + final repository = ref.watch(userMetadataRepository); + return repository.getUserMetadata(userId); +}); diff --git a/mobile/lib/repositories/asset_api.repository.dart b/mobile/lib/repositories/asset_api.repository.dart index 07639fbb3aca1..4d2473e64e37e 100644 --- a/mobile/lib/repositories/asset_api.repository.dart +++ b/mobile/lib/repositories/asset_api.repository.dart @@ -101,6 +101,10 @@ class AssetApiRepository extends ApiRepository { Future updateDescription(String assetId, String description) { return _api.updateAsset(assetId, UpdateAssetDto(description: description)); } + + Future updateRating(String assetId, int rating) { + return _api.updateAsset(assetId, UpdateAssetDto(rating: rating)); + } } extension on StackResponseDto { diff --git a/mobile/lib/services/action.service.dart b/mobile/lib/services/action.service.dart index 4261613a19e99..47d1cebc3905c 100644 --- a/mobile/lib/services/action.service.dart +++ b/mobile/lib/services/action.service.dart @@ -225,6 +225,14 @@ class ActionService { return true; } + Future updateRating(String assetId, int rating) async { + // update remote first, then local to ensure consistency + await _assetApiRepository.updateRating(assetId, rating); + await _remoteAssetRepository.updateRating(assetId, rating); + + return true; + } + Future stack(String userId, List remoteIds) async { final stack = await _assetApiRepository.stack(remoteIds); await _remoteAssetRepository.stack(userId, stack); diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 6a067f509f517..30ab654447f91 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -665,6 +665,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.27" + flutter_rating_bar: + dependency: "direct main" + description: + name: flutter_rating_bar + sha256: d2af03469eac832c591a1eba47c91ecc871fe5708e69967073c043b2d775ed93 + url: "https://pub.dev" + source: hosted + version: "4.0.1" flutter_riverpod: dependency: transitive description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index a49a012031c56..2aff6720aa79f 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -33,6 +33,7 @@ dependencies: flutter_displaymode: ^0.7.0 flutter_hooks: ^0.21.3+1 flutter_local_notifications: ^17.2.1+2 + flutter_rating_bar: ^4.0.1 flutter_secure_storage: ^9.2.4 flutter_svg: ^2.2.1 flutter_udid: ^4.0.0 From 63c1c9e3760e00dbd0343b009c7da720079b6199 Mon Sep 17 00:00:00 2001 From: Yaros Date: Wed, 10 Dec 2025 19:13:13 +0100 Subject: [PATCH 2/7] refactor: use custom rating bar & provider --- .../asset_viewer/bottom_sheet.widget.dart | 23 ++--- .../asset_viewer/rating_bar.widget.dart | 84 +++++++++++++++++++ .../user_metadata.provider.dart | 6 ++ mobile/pubspec.lock | 8 -- mobile/pubspec.yaml | 1 - 5 files changed, 99 insertions(+), 23 deletions(-) create mode 100644 mobile/lib/presentation/widgets/asset_viewer/rating_bar.widget.dart diff --git a/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart index 9ce8317767d4a..8baadb449898c 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart @@ -4,7 +4,6 @@ import 'package:auto_route/auto_route.dart'; import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_rating_bar/flutter_rating_bar.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; @@ -17,6 +16,7 @@ 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'; @@ -56,12 +56,6 @@ class AssetDetailBottomSheet extends ConsumerWidget { final currentAlbum = ref.watch(currentRemoteAlbumProvider); final isArchived = asset is RemoteAsset && asset.visibility == AssetVisibility.archive; final advancedTroubleshooting = ref.watch(settingsProvider.notifier).get(Setting.advancedTroubleshooting); - final isRatingEnabled = ref - .watch(userMetadataProvider(ref.watch(currentUserProvider)?.id ?? '')) - .maybeWhen( - data: (metadataList) => metadataList.any((meta) => meta.preferences?.ratingsEnabled ?? false), - orElse: () => false, - ); final buttonContext = ActionButtonContext( asset: asset, @@ -79,7 +73,7 @@ class AssetDetailBottomSheet extends ConsumerWidget { return BaseBottomSheet( actions: actions, - slivers: [_AssetDetailBottomSheet(isRatingEnabled: isRatingEnabled)], + slivers: [const _AssetDetailBottomSheet()], controller: controller, initialChildSize: initialChildSize, minChildSize: 0.1, @@ -93,9 +87,7 @@ class AssetDetailBottomSheet extends ConsumerWidget { } class _AssetDetailBottomSheet extends ConsumerWidget { - final bool isRatingEnabled; - - const _AssetDetailBottomSheet({required this.isRatingEnabled}); + const _AssetDetailBottomSheet(); String _getDateTime(BuildContext ctx, BaseAsset asset, ExifInfo? exifInfo) { DateTime dateTime = asset.createdAt.toLocal(); @@ -243,6 +235,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(ref.watch(currentUserProvider)?.id ?? '')) + .maybeWhen(data: (prefs) => prefs?.ratingsEnabled ?? false, orElse: () => false); // Build file info tile based on asset type Widget buildFileInfoTile() { @@ -350,11 +345,11 @@ class _AssetDetailBottomSheet extends ConsumerWidget { ), ), const SizedBox(height: 8), - RatingBar.builder( + RatingBar( initialRating: exifInfo?.rating?.toDouble() ?? 0, - itemBuilder: (context, _) => Icon(Icons.star, color: context.themeData.colorScheme.primary), + filledColor: context.themeData.colorScheme.primary, + unfilledColor: context.themeData.colorScheme.onSurface.withAlpha(100), itemSize: 32, - glow: false, onRatingUpdate: (rating) async { await ref.read(actionProvider.notifier).updateRating(ActionSource.viewer, rating.round()); }, diff --git a/mobile/lib/presentation/widgets/asset_viewer/rating_bar.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/rating_bar.widget.dart new file mode 100644 index 0000000000000..cc278725b2189 --- /dev/null +++ b/mobile/lib/presentation/widgets/asset_viewer/rating_bar.widget.dart @@ -0,0 +1,84 @@ +import 'package:flutter/material.dart'; + +class RatingBar extends StatefulWidget { + final double initialRating; + final int itemCount; + final double itemSize; + final Color filledColor; + final Color unfilledColor; + final ValueChanged? onRatingUpdate; + final Widget? itemBuilder; + + 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.itemBuilder, + }); + + @override + State createState() => _RatingBarState(); +} + +class _RatingBarState extends State { + 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; + 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 { + int tappedIndex = (dx ~/ widget.itemSize).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; + + return 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, (index) { + bool filled = _currentRating > index; + return widget.itemBuilder ?? + Icon(Icons.star, size: widget.itemSize, color: filled ? widget.filledColor : widget.unfilledColor); + }), + ), + ); + } +} diff --git a/mobile/lib/providers/infrastructure/user_metadata.provider.dart b/mobile/lib/providers/infrastructure/user_metadata.provider.dart index 2ecf70d57257c..87f82baac5caa 100644 --- a/mobile/lib/providers/infrastructure/user_metadata.provider.dart +++ b/mobile/lib/providers/infrastructure/user_metadata.provider.dart @@ -11,3 +11,9 @@ final userMetadataProvider = FutureProvider.family, String>(( final repository = ref.watch(userMetadataRepository); return repository.getUserMetadata(userId); }); + +final userMetadataPreferencesProvider = FutureProvider.family((ref, String userId) async { + final metadataList = await ref.watch(userMetadataProvider(userId).future); + final metadataWithPrefs = metadataList.firstWhere((meta) => meta.preferences != null); + return metadataWithPrefs.preferences; +}); diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 30ab654447f91..6a067f509f517 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -665,14 +665,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.27" - flutter_rating_bar: - dependency: "direct main" - description: - name: flutter_rating_bar - sha256: d2af03469eac832c591a1eba47c91ecc871fe5708e69967073c043b2d775ed93 - url: "https://pub.dev" - source: hosted - version: "4.0.1" flutter_riverpod: dependency: transitive description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 2aff6720aa79f..a49a012031c56 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -33,7 +33,6 @@ dependencies: flutter_displaymode: ^0.7.0 flutter_hooks: ^0.21.3+1 flutter_local_notifications: ^17.2.1+2 - flutter_rating_bar: ^4.0.1 flutter_secure_storage: ^9.2.4 flutter_svg: ^2.2.1 flutter_udid: ^4.0.0 From d2cb376f77001689c5c3973d51e3f44bae993814 Mon Sep 17 00:00:00 2001 From: Yaros Date: Thu, 11 Dec 2025 20:40:23 +0100 Subject: [PATCH 3/7] refactor: remove user prop from provider --- .../widgets/asset_viewer/bottom_sheet.widget.dart | 2 +- .../infrastructure/user_metadata.provider.dart | 11 +++++++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart index 8baadb449898c..967496c53064f 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart @@ -236,7 +236,7 @@ class _AssetDetailBottomSheet extends ConsumerWidget { 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(ref.watch(currentUserProvider)?.id ?? '')) + .watch(userMetadataPreferencesProvider) .maybeWhen(data: (prefs) => prefs?.ratingsEnabled ?? false, orElse: () => false); // Build file info tile based on asset type diff --git a/mobile/lib/providers/infrastructure/user_metadata.provider.dart b/mobile/lib/providers/infrastructure/user_metadata.provider.dart index 87f82baac5caa..9a463463f5560 100644 --- a/mobile/lib/providers/infrastructure/user_metadata.provider.dart +++ b/mobile/lib/providers/infrastructure/user_metadata.provider.dart @@ -2,18 +2,21 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/user_metadata.model.dart'; import 'package:immich_mobile/infrastructure/repositories/user_metadata.repository.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; +import 'package:immich_mobile/providers/user.provider.dart'; final userMetadataRepository = Provider( (ref) => DriftUserMetadataRepository(ref.watch(driftProvider)), ); -final userMetadataProvider = FutureProvider.family, String>((ref, String userId) async { +final userMetadataProvider = FutureProvider>((ref) async { final repository = ref.watch(userMetadataRepository); - return repository.getUserMetadata(userId); + final user = ref.watch(currentUserProvider); + if (user == null) return []; + return repository.getUserMetadata(user.id); }); -final userMetadataPreferencesProvider = FutureProvider.family((ref, String userId) async { - final metadataList = await ref.watch(userMetadataProvider(userId).future); +final userMetadataPreferencesProvider = FutureProvider((ref) async { + final metadataList = await ref.watch(userMetadataProvider.future); final metadataWithPrefs = metadataList.firstWhere((meta) => meta.preferences != null); return metadataWithPrefs.preferences; }); From 3dec648dfcdc0605a4926353dc0408b7a267a791 Mon Sep 17 00:00:00 2001 From: Yaros Date: Sat, 20 Dec 2025 11:34:13 +0100 Subject: [PATCH 4/7] feat: clear, padding, star size, impl suggestions --- .../asset_viewer/bottom_sheet.widget.dart | 12 ++-- .../asset_viewer/rating_bar.widget.dart | 56 ++++++++++++++----- 2 files changed, 48 insertions(+), 20 deletions(-) diff --git a/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart index 967496c53064f..17f39c587bb76 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart @@ -73,7 +73,7 @@ class AssetDetailBottomSheet extends ConsumerWidget { return BaseBottomSheet( actions: actions, - slivers: [const _AssetDetailBottomSheet()], + slivers: const [_AssetDetailBottomSheet()], controller: controller, initialChildSize: initialChildSize, minChildSize: 0.1, @@ -331,12 +331,12 @@ class _AssetDetailBottomSheet extends ConsumerWidget { // Rating bar if (isRatingEnabled) ...[ Padding( - padding: const EdgeInsets.only(left: 16.0), + padding: const EdgeInsets.only(left: 16.0, top: 16.0), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, + spacing: 8, children: [ - const SizedBox(height: 16), Text( 'rating'.t(context: context).toUpperCase(), style: context.textTheme.labelMedium?.copyWith( @@ -344,15 +344,17 @@ class _AssetDetailBottomSheet extends ConsumerWidget { fontWeight: FontWeight.w600, ), ), - const SizedBox(height: 8), RatingBar( initialRating: exifInfo?.rating?.toDouble() ?? 0, filledColor: context.themeData.colorScheme.primary, unfilledColor: context.themeData.colorScheme.onSurface.withAlpha(100), - itemSize: 32, + 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); + }, ), ], ), diff --git a/mobile/lib/presentation/widgets/asset_viewer/rating_bar.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/rating_bar.widget.dart index cc278725b2189..08a9a05d672b5 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/rating_bar.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/rating_bar.widget.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; class RatingBar extends StatefulWidget { final double initialRating; @@ -7,7 +8,9 @@ class RatingBar extends StatefulWidget { final Color filledColor; final Color unfilledColor; final ValueChanged? onRatingUpdate; + final VoidCallback? onClearRating; final Widget? itemBuilder; + final double starPadding; const RatingBar({ super.key, @@ -17,7 +20,9 @@ class RatingBar extends StatefulWidget { this.filledColor = Colors.amber, this.unfilledColor = Colors.grey, this.onRatingUpdate, + this.onClearRating, this.itemBuilder, + this.starPadding = 4.0, }); @override @@ -34,7 +39,7 @@ class _RatingBarState extends State { } void _updateRating(Offset localPosition, bool isRTL, {bool isTap = false}) { - final totalWidth = widget.itemCount * widget.itemSize; + final totalWidth = widget.itemCount * widget.itemSize + (widget.itemCount - 1) * widget.starPadding; double dx = localPosition.dx; if (isRTL) dx = totalWidth - dx; @@ -46,7 +51,8 @@ class _RatingBarState extends State { } else if (dx >= totalWidth) { newRating = widget.itemCount.toDouble(); } else { - int tappedIndex = (dx ~/ widget.itemSize).clamp(0, widget.itemCount - 1); + 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) { @@ -66,19 +72,39 @@ class _RatingBarState extends State { Widget build(BuildContext context) { final isRTL = Directionality.of(context) == TextDirection.rtl; - return 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, (index) { - bool filled = _currentRating > index; - return widget.itemBuilder ?? - Icon(Icons.star, size: widget.itemSize, color: filled ? widget.filledColor : widget.unfilledColor); - }), - ), + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + 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, size: widget.itemSize, color: filled ? widget.filledColor : widget.unfilledColor); + }), + ), + ), + if (_currentRating > 0) + TextButton( + onPressed: () => { + setState(() { + _currentRating = 0; + }), + widget.onClearRating?.call(), + }, + child: Text('rating_clear'.t(context: context)), + ), + ], ); } } From 6abbb64fa21434561434a63e860ccbc4aa4e91f4 Mon Sep 17 00:00:00 2001 From: Yaros Date: Tue, 13 Jan 2026 14:55:24 +0100 Subject: [PATCH 5/7] chore: switch to rounded star icons --- .../widgets/asset_viewer/rating_bar.widget.dart | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/mobile/lib/presentation/widgets/asset_viewer/rating_bar.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/rating_bar.widget.dart index 08a9a05d672b5..4e42c5ba4a467 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/rating_bar.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/rating_bar.widget.dart @@ -90,7 +90,11 @@ class _RatingBarState extends State { int index = i ~/ 2; bool filled = _currentRating > index; return widget.itemBuilder ?? - Icon(Icons.star, size: widget.itemSize, color: filled ? widget.filledColor : widget.unfilledColor); + Icon( + Icons.star_rounded, + size: widget.itemSize, + color: filled ? widget.filledColor : widget.unfilledColor, + ); }), ), ), From 9b6d361dff8f0b407d26b54c649f98405cc2ef63 Mon Sep 17 00:00:00 2001 From: Yaros Date: Tue, 13 Jan 2026 19:19:54 +0100 Subject: [PATCH 6/7] fix: alignment & gesturedetector --- .../asset_viewer/rating_bar.widget.dart | 67 +++++++++++-------- 1 file changed, 39 insertions(+), 28 deletions(-) diff --git a/mobile/lib/presentation/widgets/asset_viewer/rating_bar.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/rating_bar.widget.dart index 4e42c5ba4a467..64090dc5c2562 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/rating_bar.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/rating_bar.widget.dart @@ -1,4 +1,5 @@ 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 { @@ -71,42 +72,52 @@ class _RatingBarState extends State { @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: [ - 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, - ); - }), + 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) - TextButton( - onPressed: () => { - setState(() { - _currentRating = 0; - }), - widget.onClearRating?.call(), - }, - child: Text('rating_clear'.t(context: context)), + 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), + ), + ), ), ], ); From 498537ee13edee212f36eabcbb359b71a7b3d591 Mon Sep 17 00:00:00 2001 From: Yaros Date: Wed, 14 Jan 2026 15:07:06 +0100 Subject: [PATCH 7/7] feat: rating search filter --- i18n/en.json | 1 + .../repositories/search_api.repository.dart | 2 + .../models/search/search_filter.model.dart | 44 +++++++++++++++++- .../places/places_collection.page.dart | 1 + mobile/lib/pages/search/search.page.dart | 1 + .../pages/search/drift_search.page.dart | 45 +++++++++++++++++++ .../similar_photos_action_button.widget.dart | 1 + mobile/lib/widgets/search/explore_grid.dart | 1 + .../search_filter/star_rating_picker.dart | 35 +++++++++++++++ 9 files changed, 130 insertions(+), 1 deletion(-) create mode 100644 mobile/lib/widgets/search/search_filter/star_rating_picker.dart diff --git a/i18n/en.json b/i18n/en.json index 473bd6f37b231..cee1f5e5e07e2 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1868,6 +1868,7 @@ "search_filter_media_type_title": "Select media type", "search_filter_ocr": "Search by OCR", "search_filter_people_title": "Select people", + "search_filter_star_rating": "Star Rating", "search_for": "Search for", "search_for_existing_person": "Search for existing person", "search_no_more_result": "No more results", diff --git a/mobile/lib/infrastructure/repositories/search_api.repository.dart b/mobile/lib/infrastructure/repositories/search_api.repository.dart index 34870dc1b32a0..043a42b1a43a9 100644 --- a/mobile/lib/infrastructure/repositories/search_api.repository.dart +++ b/mobile/lib/infrastructure/repositories/search_api.repository.dart @@ -31,6 +31,7 @@ class SearchApiRepository extends ApiRepository { takenAfter: filter.date.takenAfter, takenBefore: filter.date.takenBefore, visibility: filter.display.isArchive ? AssetVisibility.archive : AssetVisibility.timeline, + rating: filter.rating.rating, isFavorite: filter.display.isFavorite ? true : null, isNotInAlbum: filter.display.isNotInAlbum ? true : null, personIds: filter.people.map((e) => e.id).toList(), @@ -54,6 +55,7 @@ class SearchApiRepository extends ApiRepository { takenAfter: filter.date.takenAfter, takenBefore: filter.date.takenBefore, visibility: filter.display.isArchive ? AssetVisibility.archive : AssetVisibility.timeline, + rating: filter.rating.rating, isFavorite: filter.display.isFavorite ? true : null, isNotInAlbum: filter.display.isNotInAlbum ? true : null, personIds: filter.people.map((e) => e.id).toList(), diff --git a/mobile/lib/models/search/search_filter.model.dart b/mobile/lib/models/search/search_filter.model.dart index 93322f50313f2..2d45913fcb63c 100644 --- a/mobile/lib/models/search/search_filter.model.dart +++ b/mobile/lib/models/search/search_filter.model.dart @@ -126,6 +126,41 @@ class SearchDateFilter { int get hashCode => takenBefore.hashCode ^ takenAfter.hashCode; } +class SearchRatingFilter { + int? rating; + SearchRatingFilter({this.rating}); + + SearchRatingFilter copyWith({int? rating}) { + return SearchRatingFilter(rating: rating ?? this.rating); + } + + Map toMap() { + return {'rating': rating}; + } + + factory SearchRatingFilter.fromMap(Map map) { + return SearchRatingFilter(rating: map['rating'] != null ? map['rating'] as int : null); + } + + String toJson() => json.encode(toMap()); + + factory SearchRatingFilter.fromJson(String source) => + SearchRatingFilter.fromMap(json.decode(source) as Map); + + @override + String toString() => 'SearchRatingFilter(rating: $rating)'; + + @override + bool operator ==(covariant SearchRatingFilter other) { + if (identical(this, other)) return true; + + return other.rating == rating; + } + + @override + int get hashCode => rating.hashCode; +} + class SearchDisplayFilters { bool isNotInAlbum = false; bool isArchive = false; @@ -183,6 +218,7 @@ class SearchFilter { SearchLocationFilter location; SearchCameraFilter camera; SearchDateFilter date; + SearchRatingFilter rating; SearchDisplayFilters display; // Enum @@ -200,6 +236,7 @@ class SearchFilter { required this.camera, required this.date, required this.display, + required this.rating, required this.mediaType, }); @@ -220,6 +257,7 @@ class SearchFilter { display.isNotInAlbum == false && display.isArchive == false && display.isFavorite == false && + rating.rating == null && mediaType == AssetType.other; } @@ -235,6 +273,7 @@ class SearchFilter { SearchCameraFilter? camera, SearchDateFilter? date, SearchDisplayFilters? display, + SearchRatingFilter? rating, AssetType? mediaType, }) { return SearchFilter( @@ -249,13 +288,14 @@ class SearchFilter { camera: camera ?? this.camera, date: date ?? this.date, display: display ?? this.display, + rating: rating ?? this.rating, mediaType: mediaType ?? this.mediaType, ); } @override String toString() { - return 'SearchFilter(context: $context, filename: $filename, description: $description, language: $language, ocr: $ocr, people: $people, location: $location, camera: $camera, date: $date, display: $display, mediaType: $mediaType, assetId: $assetId)'; + return 'SearchFilter(context: $context, filename: $filename, description: $description, language: $language, ocr: $ocr, people: $people, location: $location, camera: $camera, date: $date, display: $display, rating: $rating, mediaType: $mediaType, assetId: $assetId)'; } @override @@ -273,6 +313,7 @@ class SearchFilter { other.camera == camera && other.date == date && other.display == display && + other.rating == rating && other.mediaType == mediaType; } @@ -289,6 +330,7 @@ class SearchFilter { camera.hashCode ^ date.hashCode ^ display.hashCode ^ + rating.hashCode ^ mediaType.hashCode; } } diff --git a/mobile/lib/pages/library/places/places_collection.page.dart b/mobile/lib/pages/library/places/places_collection.page.dart index f376709316ee2..d6511cb25bdad 100644 --- a/mobile/lib/pages/library/places/places_collection.page.dart +++ b/mobile/lib/pages/library/places/places_collection.page.dart @@ -113,6 +113,7 @@ class PlaceTile extends StatelessWidget { camera: SearchCameraFilter(), date: SearchDateFilter(), display: SearchDisplayFilters(isNotInAlbum: false, isArchive: false, isFavorite: false), + rating: SearchRatingFilter(), mediaType: AssetType.other, ), ), diff --git a/mobile/lib/pages/search/search.page.dart b/mobile/lib/pages/search/search.page.dart index 902110f6a8aef..dbd32ac94b025 100644 --- a/mobile/lib/pages/search/search.page.dart +++ b/mobile/lib/pages/search/search.page.dart @@ -43,6 +43,7 @@ class SearchPage extends HookConsumerWidget { date: prefilter?.date ?? SearchDateFilter(), display: prefilter?.display ?? SearchDisplayFilters(isNotInAlbum: false, isArchive: false, isFavorite: false), mediaType: prefilter?.mediaType ?? AssetType.other, + rating: prefilter?.rating ?? SearchRatingFilter(), language: "${context.locale.languageCode}-${context.locale.countryCode}", ), ); diff --git a/mobile/lib/presentation/pages/search/drift_search.page.dart b/mobile/lib/presentation/pages/search/drift_search.page.dart index 58ca892f5f1d2..16655e98f621a 100644 --- a/mobile/lib/presentation/pages/search/drift_search.page.dart +++ b/mobile/lib/presentation/pages/search/drift_search.page.dart @@ -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(null); final locationCurrentFilterWidget = useState(null); final mediaTypeCurrentFilterWidget = useState(null); + final ratingCurrentFilterWidget = useState(null); final displayOptionCurrentFilterWidget = useState(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 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, diff --git a/mobile/lib/presentation/widgets/action_buttons/similar_photos_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/similar_photos_action_button.widget.dart index 65ba744ec3a05..294ddfd1f5cbb 100644 --- a/mobile/lib/presentation/widgets/action_buttons/similar_photos_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/similar_photos_action_button.widget.dart @@ -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, ), ); diff --git a/mobile/lib/widgets/search/explore_grid.dart b/mobile/lib/widgets/search/explore_grid.dart index a6e1cf5aac921..6af20df02909b 100644 --- a/mobile/lib/widgets/search/explore_grid.dart +++ b/mobile/lib/widgets/search/explore_grid.dart @@ -55,6 +55,7 @@ class ExploreGrid extends StatelessWidget { camera: SearchCameraFilter(), date: SearchDateFilter(), display: SearchDisplayFilters(isNotInAlbum: false, isArchive: false, isFavorite: false), + rating: SearchRatingFilter(), mediaType: AssetType.other, ), ), diff --git a/mobile/lib/widgets/search/search_filter/star_rating_picker.dart b/mobile/lib/widgets/search/search_filter/star_rating_picker.dart new file mode 100644 index 0000000000000..5591b0e264c4a --- /dev/null +++ b/mobile/lib/widgets/search/search_filter/star_rating_picker.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/models/search/search_filter.model.dart'; + +class StarRatingPicker extends HookWidget { + const StarRatingPicker({super.key, required this.onSelect, this.filter}); + final Function(SearchRatingFilter) onSelect; + final SearchRatingFilter? filter; + + @override + Widget build(BuildContext context) { + final selectedRating = useState(filter); + + return RadioGroup( + groupValue: selectedRating.value?.rating, + onChanged: (int? newValue) { + if (newValue == null) return; + final newFilter = SearchRatingFilter(rating: newValue); + selectedRating.value = newFilter; + onSelect(newFilter); + }, + child: Column( + children: List.generate( + 6, + (index) => RadioListTile( + key: Key("star_$index"), + title: Text('rating_count'.t(args: {'count': (index)})), + value: index, + ), + ), + ), + ); + } +}