mirror of
https://github.com/immich-app/immich.git
synced 2025-12-18 17:23:16 +03:00
feat(mobile): star rating
This commit is contained in:
@@ -6,6 +6,7 @@ class ExifInfo {
|
|||||||
final String? orientation;
|
final String? orientation;
|
||||||
final String? timeZone;
|
final String? timeZone;
|
||||||
final DateTime? dateTimeOriginal;
|
final DateTime? dateTimeOriginal;
|
||||||
|
final int? rating;
|
||||||
|
|
||||||
// GPS
|
// GPS
|
||||||
final double? latitude;
|
final double? latitude;
|
||||||
@@ -46,6 +47,7 @@ class ExifInfo {
|
|||||||
this.orientation,
|
this.orientation,
|
||||||
this.timeZone,
|
this.timeZone,
|
||||||
this.dateTimeOriginal,
|
this.dateTimeOriginal,
|
||||||
|
this.rating,
|
||||||
this.isFlipped = false,
|
this.isFlipped = false,
|
||||||
this.latitude,
|
this.latitude,
|
||||||
this.longitude,
|
this.longitude,
|
||||||
@@ -71,6 +73,7 @@ class ExifInfo {
|
|||||||
other.orientation == orientation &&
|
other.orientation == orientation &&
|
||||||
other.timeZone == timeZone &&
|
other.timeZone == timeZone &&
|
||||||
other.dateTimeOriginal == dateTimeOriginal &&
|
other.dateTimeOriginal == dateTimeOriginal &&
|
||||||
|
other.rating == rating &&
|
||||||
other.latitude == latitude &&
|
other.latitude == latitude &&
|
||||||
other.longitude == longitude &&
|
other.longitude == longitude &&
|
||||||
other.city == city &&
|
other.city == city &&
|
||||||
@@ -94,6 +97,7 @@ class ExifInfo {
|
|||||||
isFlipped.hashCode ^
|
isFlipped.hashCode ^
|
||||||
timeZone.hashCode ^
|
timeZone.hashCode ^
|
||||||
dateTimeOriginal.hashCode ^
|
dateTimeOriginal.hashCode ^
|
||||||
|
rating.hashCode ^
|
||||||
latitude.hashCode ^
|
latitude.hashCode ^
|
||||||
longitude.hashCode ^
|
longitude.hashCode ^
|
||||||
city.hashCode ^
|
city.hashCode ^
|
||||||
@@ -118,6 +122,7 @@ orientation: ${orientation ?? 'NA'},
|
|||||||
isFlipped: $isFlipped,
|
isFlipped: $isFlipped,
|
||||||
timeZone: ${timeZone ?? 'NA'},
|
timeZone: ${timeZone ?? 'NA'},
|
||||||
dateTimeOriginal: ${dateTimeOriginal ?? 'NA'},
|
dateTimeOriginal: ${dateTimeOriginal ?? 'NA'},
|
||||||
|
rating: ${rating ?? 'NA'},
|
||||||
latitude: ${latitude ?? 'NA'},
|
latitude: ${latitude ?? 'NA'},
|
||||||
longitude: ${longitude ?? 'NA'},
|
longitude: ${longitude ?? 'NA'},
|
||||||
city: ${city ?? 'NA'},
|
city: ${city ?? 'NA'},
|
||||||
@@ -140,6 +145,7 @@ exposureSeconds: ${exposureSeconds ?? 'NA'},
|
|||||||
String? orientation,
|
String? orientation,
|
||||||
String? timeZone,
|
String? timeZone,
|
||||||
DateTime? dateTimeOriginal,
|
DateTime? dateTimeOriginal,
|
||||||
|
int? rating,
|
||||||
double? latitude,
|
double? latitude,
|
||||||
double? longitude,
|
double? longitude,
|
||||||
String? city,
|
String? city,
|
||||||
@@ -161,6 +167,7 @@ exposureSeconds: ${exposureSeconds ?? 'NA'},
|
|||||||
orientation: orientation ?? this.orientation,
|
orientation: orientation ?? this.orientation,
|
||||||
timeZone: timeZone ?? this.timeZone,
|
timeZone: timeZone ?? this.timeZone,
|
||||||
dateTimeOriginal: dateTimeOriginal ?? this.dateTimeOriginal,
|
dateTimeOriginal: dateTimeOriginal ?? this.dateTimeOriginal,
|
||||||
|
rating: rating ?? this.rating,
|
||||||
isFlipped: isFlipped ?? this.isFlipped,
|
isFlipped: isFlipped ?? this.isFlipped,
|
||||||
latitude: latitude ?? this.latitude,
|
latitude: latitude ?? this.latitude,
|
||||||
longitude: longitude ?? this.longitude,
|
longitude: longitude ?? this.longitude,
|
||||||
|
|||||||
@@ -151,6 +151,7 @@ extension RemoteExifEntityDataDomainEx on RemoteExifEntityData {
|
|||||||
domain.ExifInfo toDto() => domain.ExifInfo(
|
domain.ExifInfo toDto() => domain.ExifInfo(
|
||||||
fileSize: fileSize,
|
fileSize: fileSize,
|
||||||
dateTimeOriginal: dateTimeOriginal,
|
dateTimeOriginal: dateTimeOriginal,
|
||||||
|
rating: rating,
|
||||||
timeZone: timeZone,
|
timeZone: timeZone,
|
||||||
make: make,
|
make: make,
|
||||||
model: model,
|
model: model,
|
||||||
|
|||||||
@@ -255,6 +255,12 @@ class RemoteAssetRepository extends DriftDatabaseRepository {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> updateRating(String assetId, int rating) async {
|
||||||
|
await (_db.remoteExifEntity.update()..where((row) => row.assetId.equals(assetId))).write(
|
||||||
|
RemoteExifEntityCompanion(rating: Value(rating)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Future<int> getCount() {
|
Future<int> getCount() {
|
||||||
return _db.managers.remoteAssetEntity.count();
|
return _db.managers.remoteAssetEntity.count();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'package:auto_route/auto_route.dart';
|
|||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
import 'package:easy_localization/easy_localization.dart';
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_rating_bar/flutter_rating_bar.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/constants/enums.dart';
|
import 'package:immich_mobile/constants/enums.dart';
|
||||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.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/asset_viewer/current_asset.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/current_album.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/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/routes.provider.dart';
|
||||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||||
import 'package:immich_mobile/providers/user.provider.dart';
|
import 'package:immich_mobile/providers/user.provider.dart';
|
||||||
@@ -54,6 +56,12 @@ class AssetDetailBottomSheet extends ConsumerWidget {
|
|||||||
final currentAlbum = ref.watch(currentRemoteAlbumProvider);
|
final currentAlbum = ref.watch(currentRemoteAlbumProvider);
|
||||||
final isArchived = asset is RemoteAsset && asset.visibility == AssetVisibility.archive;
|
final isArchived = asset is RemoteAsset && asset.visibility == AssetVisibility.archive;
|
||||||
final advancedTroubleshooting = ref.watch(settingsProvider.notifier).get(Setting.advancedTroubleshooting);
|
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(
|
final buttonContext = ActionButtonContext(
|
||||||
asset: asset,
|
asset: asset,
|
||||||
@@ -71,7 +79,7 @@ class AssetDetailBottomSheet extends ConsumerWidget {
|
|||||||
|
|
||||||
return BaseBottomSheet(
|
return BaseBottomSheet(
|
||||||
actions: actions,
|
actions: actions,
|
||||||
slivers: const [_AssetDetailBottomSheet()],
|
slivers: [_AssetDetailBottomSheet(isRatingEnabled: isRatingEnabled)],
|
||||||
controller: controller,
|
controller: controller,
|
||||||
initialChildSize: initialChildSize,
|
initialChildSize: initialChildSize,
|
||||||
minChildSize: 0.1,
|
minChildSize: 0.1,
|
||||||
@@ -85,7 +93,9 @@ class AssetDetailBottomSheet extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
String _getDateTime(BuildContext ctx, BaseAsset asset, ExifInfo? exifInfo) {
|
||||||
DateTime dateTime = asset.createdAt.toLocal();
|
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)
|
// Appears in (Albums)
|
||||||
Padding(padding: const EdgeInsets.only(top: 16.0), child: _buildAppearsInList(ref, context)),
|
Padding(padding: const EdgeInsets.only(top: 16.0), child: _buildAppearsInList(ref, context)),
|
||||||
// padding at the bottom to avoid cut-off
|
// padding at the bottom to avoid cut-off
|
||||||
|
|||||||
@@ -357,6 +357,22 @@ class ActionNotifier extends Notifier<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<ActionResult> 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<ActionResult> stack(String userId, ActionSource source) async {
|
Future<ActionResult> stack(String userId, ActionSource source) async {
|
||||||
final ids = _getOwnedRemoteIdsForSource(source);
|
final ids = _getOwnedRemoteIdsForSource(source);
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
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/infrastructure/repositories/user_metadata.repository.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
||||||
|
|
||||||
final userMetadataRepository = Provider<DriftUserMetadataRepository>(
|
final userMetadataRepository = Provider<DriftUserMetadataRepository>(
|
||||||
(ref) => DriftUserMetadataRepository(ref.watch(driftProvider)),
|
(ref) => DriftUserMetadataRepository(ref.watch(driftProvider)),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
final userMetadataProvider = FutureProvider.family<List<UserMetadata>, String>((ref, String userId) async {
|
||||||
|
final repository = ref.watch(userMetadataRepository);
|
||||||
|
return repository.getUserMetadata(userId);
|
||||||
|
});
|
||||||
|
|||||||
@@ -101,6 +101,10 @@ class AssetApiRepository extends ApiRepository {
|
|||||||
Future<void> updateDescription(String assetId, String description) {
|
Future<void> updateDescription(String assetId, String description) {
|
||||||
return _api.updateAsset(assetId, UpdateAssetDto(description: description));
|
return _api.updateAsset(assetId, UpdateAssetDto(description: description));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> updateRating(String assetId, int rating) {
|
||||||
|
return _api.updateAsset(assetId, UpdateAssetDto(rating: rating));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension on StackResponseDto {
|
extension on StackResponseDto {
|
||||||
|
|||||||
@@ -225,6 +225,14 @@ class ActionService {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<bool> 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<void> stack(String userId, List<String> remoteIds) async {
|
Future<void> stack(String userId, List<String> remoteIds) async {
|
||||||
final stack = await _assetApiRepository.stack(remoteIds);
|
final stack = await _assetApiRepository.stack(remoteIds);
|
||||||
await _remoteAssetRepository.stack(userId, stack);
|
await _remoteAssetRepository.stack(userId, stack);
|
||||||
|
|||||||
@@ -665,6 +665,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.27"
|
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:
|
flutter_riverpod:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ dependencies:
|
|||||||
flutter_displaymode: ^0.7.0
|
flutter_displaymode: ^0.7.0
|
||||||
flutter_hooks: ^0.21.3+1
|
flutter_hooks: ^0.21.3+1
|
||||||
flutter_local_notifications: ^17.2.1+2
|
flutter_local_notifications: ^17.2.1+2
|
||||||
|
flutter_rating_bar: ^4.0.1
|
||||||
flutter_secure_storage: ^9.2.4
|
flutter_secure_storage: ^9.2.4
|
||||||
flutter_svg: ^2.2.1
|
flutter_svg: ^2.2.1
|
||||||
flutter_udid: ^4.0.0
|
flutter_udid: ^4.0.0
|
||||||
|
|||||||
Reference in New Issue
Block a user