diff --git a/mobile/lib/domain/models/exif.model.dart b/mobile/lib/domain/models/exif.model.dart index 46e2352ac8..d0f78b59de 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 2dbe05b9d7..77cae5dbbe 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 96c204ea0e..df4172df99 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 80840d94b4..9ce8317767 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 d4d850d8c1..3f079404f7 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 2e2ae7555b..2ecf70d572 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 07639fbb3a..4d2473e64e 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 4261613a19..47d1cebc39 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 6a067f509f..30ab654447 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 a49a012031..2aff6720aa 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