mirror of
https://github.com/immich-app/immich.git
synced 2025-12-14 17:23:36 +03:00
Compare commits
3 Commits
revert-sve
...
feat/star-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d2cb376f77 | ||
|
|
63c1c9e376 | ||
|
|
46e2d6e71e |
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,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/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_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/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/asset_viewer/sheet_tile.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.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/action.provider.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';
|
||||||
@@ -71,7 +73,7 @@ class AssetDetailBottomSheet extends ConsumerWidget {
|
|||||||
|
|
||||||
return BaseBottomSheet(
|
return BaseBottomSheet(
|
||||||
actions: actions,
|
actions: actions,
|
||||||
slivers: const [_AssetDetailBottomSheet()],
|
slivers: [const _AssetDetailBottomSheet()],
|
||||||
controller: controller,
|
controller: controller,
|
||||||
initialChildSize: initialChildSize,
|
initialChildSize: initialChildSize,
|
||||||
minChildSize: 0.1,
|
minChildSize: 0.1,
|
||||||
@@ -233,6 +235,9 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
|
|||||||
final cameraTitle = _getCameraInfoTitle(exifInfo);
|
final cameraTitle = _getCameraInfoTitle(exifInfo);
|
||||||
final lensTitle = exifInfo?.lens != null && exifInfo!.lens!.isNotEmpty ? exifInfo.lens : null;
|
final lensTitle = exifInfo?.lens != null && exifInfo!.lens!.isNotEmpty ? exifInfo.lens : null;
|
||||||
final isOwner = ref.watch(currentUserProvider)?.id == (asset is RemoteAsset ? asset.ownerId : 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
|
// Build file info tile based on asset type
|
||||||
Widget buildFileInfoTile() {
|
Widget buildFileInfoTile() {
|
||||||
@@ -323,6 +328,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(
|
||||||
|
initialRating: exifInfo?.rating?.toDouble() ?? 0,
|
||||||
|
filledColor: context.themeData.colorScheme.primary,
|
||||||
|
unfilledColor: context.themeData.colorScheme.onSurface.withAlpha(100),
|
||||||
|
itemSize: 32,
|
||||||
|
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
|
||||||
|
|||||||
@@ -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<int>? 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<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;
|
||||||
|
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);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,22 @@
|
|||||||
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';
|
||||||
|
import 'package:immich_mobile/providers/user.provider.dart';
|
||||||
|
|
||||||
final userMetadataRepository = Provider<DriftUserMetadataRepository>(
|
final userMetadataRepository = Provider<DriftUserMetadataRepository>(
|
||||||
(ref) => DriftUserMetadataRepository(ref.watch(driftProvider)),
|
(ref) => DriftUserMetadataRepository(ref.watch(driftProvider)),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
final userMetadataProvider = FutureProvider<List<UserMetadata>>((ref) async {
|
||||||
|
final repository = ref.watch(userMetadataRepository);
|
||||||
|
final user = ref.watch(currentUserProvider);
|
||||||
|
if (user == null) return [];
|
||||||
|
return repository.getUserMetadata(user.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
final userMetadataPreferencesProvider = FutureProvider<Preferences?>((ref) async {
|
||||||
|
final metadataList = await ref.watch(userMetadataProvider.future);
|
||||||
|
final metadataWithPrefs = metadataList.firstWhere((meta) => meta.preferences != null);
|
||||||
|
return metadataWithPrefs.preferences;
|
||||||
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user