mirror of
https://github.com/immich-app/immich.git
synced 2026-02-04 08:49:01 +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:
@@ -1911,6 +1911,7 @@
|
|||||||
"search_filter_media_type_title": "Select media type",
|
"search_filter_media_type_title": "Select media type",
|
||||||
"search_filter_ocr": "Search by OCR",
|
"search_filter_ocr": "Search by OCR",
|
||||||
"search_filter_people_title": "Select people",
|
"search_filter_people_title": "Select people",
|
||||||
|
"search_filter_star_rating": "Star Rating",
|
||||||
"search_for": "Search for",
|
"search_for": "Search for",
|
||||||
"search_for_existing_person": "Search for existing person",
|
"search_for_existing_person": "Search for existing person",
|
||||||
"search_no_more_result": "No more results",
|
"search_no_more_result": "No more results",
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ class SearchApiRepository extends ApiRepository {
|
|||||||
takenAfter: filter.date.takenAfter,
|
takenAfter: filter.date.takenAfter,
|
||||||
takenBefore: filter.date.takenBefore,
|
takenBefore: filter.date.takenBefore,
|
||||||
visibility: filter.display.isArchive ? AssetVisibility.archive : AssetVisibility.timeline,
|
visibility: filter.display.isArchive ? AssetVisibility.archive : AssetVisibility.timeline,
|
||||||
|
rating: filter.rating.rating,
|
||||||
isFavorite: filter.display.isFavorite ? true : null,
|
isFavorite: filter.display.isFavorite ? true : null,
|
||||||
isNotInAlbum: filter.display.isNotInAlbum ? true : null,
|
isNotInAlbum: filter.display.isNotInAlbum ? true : null,
|
||||||
personIds: filter.people.map((e) => e.id).toList(),
|
personIds: filter.people.map((e) => e.id).toList(),
|
||||||
@@ -54,6 +55,7 @@ class SearchApiRepository extends ApiRepository {
|
|||||||
takenAfter: filter.date.takenAfter,
|
takenAfter: filter.date.takenAfter,
|
||||||
takenBefore: filter.date.takenBefore,
|
takenBefore: filter.date.takenBefore,
|
||||||
visibility: filter.display.isArchive ? AssetVisibility.archive : AssetVisibility.timeline,
|
visibility: filter.display.isArchive ? AssetVisibility.archive : AssetVisibility.timeline,
|
||||||
|
rating: filter.rating.rating,
|
||||||
isFavorite: filter.display.isFavorite ? true : null,
|
isFavorite: filter.display.isFavorite ? true : null,
|
||||||
isNotInAlbum: filter.display.isNotInAlbum ? true : null,
|
isNotInAlbum: filter.display.isNotInAlbum ? true : null,
|
||||||
personIds: filter.people.map((e) => e.id).toList(),
|
personIds: filter.people.map((e) => e.id).toList(),
|
||||||
|
|||||||
@@ -126,6 +126,41 @@ class SearchDateFilter {
|
|||||||
int get hashCode => takenBefore.hashCode ^ takenAfter.hashCode;
|
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<String, dynamic> toMap() {
|
||||||
|
return <String, dynamic>{'rating': rating};
|
||||||
|
}
|
||||||
|
|
||||||
|
factory SearchRatingFilter.fromMap(Map<String, dynamic> 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<String, dynamic>);
|
||||||
|
|
||||||
|
@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 {
|
class SearchDisplayFilters {
|
||||||
bool isNotInAlbum = false;
|
bool isNotInAlbum = false;
|
||||||
bool isArchive = false;
|
bool isArchive = false;
|
||||||
@@ -183,6 +218,7 @@ class SearchFilter {
|
|||||||
SearchLocationFilter location;
|
SearchLocationFilter location;
|
||||||
SearchCameraFilter camera;
|
SearchCameraFilter camera;
|
||||||
SearchDateFilter date;
|
SearchDateFilter date;
|
||||||
|
SearchRatingFilter rating;
|
||||||
SearchDisplayFilters display;
|
SearchDisplayFilters display;
|
||||||
|
|
||||||
// Enum
|
// Enum
|
||||||
@@ -200,6 +236,7 @@ class SearchFilter {
|
|||||||
required this.camera,
|
required this.camera,
|
||||||
required this.date,
|
required this.date,
|
||||||
required this.display,
|
required this.display,
|
||||||
|
required this.rating,
|
||||||
required this.mediaType,
|
required this.mediaType,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -220,6 +257,7 @@ class SearchFilter {
|
|||||||
display.isNotInAlbum == false &&
|
display.isNotInAlbum == false &&
|
||||||
display.isArchive == false &&
|
display.isArchive == false &&
|
||||||
display.isFavorite == false &&
|
display.isFavorite == false &&
|
||||||
|
rating.rating == null &&
|
||||||
mediaType == AssetType.other;
|
mediaType == AssetType.other;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,6 +273,7 @@ class SearchFilter {
|
|||||||
SearchCameraFilter? camera,
|
SearchCameraFilter? camera,
|
||||||
SearchDateFilter? date,
|
SearchDateFilter? date,
|
||||||
SearchDisplayFilters? display,
|
SearchDisplayFilters? display,
|
||||||
|
SearchRatingFilter? rating,
|
||||||
AssetType? mediaType,
|
AssetType? mediaType,
|
||||||
}) {
|
}) {
|
||||||
return SearchFilter(
|
return SearchFilter(
|
||||||
@@ -249,13 +288,14 @@ class SearchFilter {
|
|||||||
camera: camera ?? this.camera,
|
camera: camera ?? this.camera,
|
||||||
date: date ?? this.date,
|
date: date ?? this.date,
|
||||||
display: display ?? this.display,
|
display: display ?? this.display,
|
||||||
|
rating: rating ?? this.rating,
|
||||||
mediaType: mediaType ?? this.mediaType,
|
mediaType: mediaType ?? this.mediaType,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
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
|
@override
|
||||||
@@ -273,6 +313,7 @@ class SearchFilter {
|
|||||||
other.camera == camera &&
|
other.camera == camera &&
|
||||||
other.date == date &&
|
other.date == date &&
|
||||||
other.display == display &&
|
other.display == display &&
|
||||||
|
other.rating == rating &&
|
||||||
other.mediaType == mediaType;
|
other.mediaType == mediaType;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -289,6 +330,7 @@ class SearchFilter {
|
|||||||
camera.hashCode ^
|
camera.hashCode ^
|
||||||
date.hashCode ^
|
date.hashCode ^
|
||||||
display.hashCode ^
|
display.hashCode ^
|
||||||
|
rating.hashCode ^
|
||||||
mediaType.hashCode;
|
mediaType.hashCode;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ class PlaceTile extends StatelessWidget {
|
|||||||
camera: SearchCameraFilter(),
|
camera: SearchCameraFilter(),
|
||||||
date: SearchDateFilter(),
|
date: SearchDateFilter(),
|
||||||
display: SearchDisplayFilters(isNotInAlbum: false, isArchive: false, isFavorite: false),
|
display: SearchDisplayFilters(isNotInAlbum: false, isArchive: false, isFavorite: false),
|
||||||
|
rating: SearchRatingFilter(),
|
||||||
mediaType: AssetType.other,
|
mediaType: AssetType.other,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ class SearchPage extends HookConsumerWidget {
|
|||||||
date: prefilter?.date ?? SearchDateFilter(),
|
date: prefilter?.date ?? SearchDateFilter(),
|
||||||
display: prefilter?.display ?? SearchDisplayFilters(isNotInAlbum: false, isArchive: false, isFavorite: false),
|
display: prefilter?.display ?? SearchDisplayFilters(isNotInAlbum: false, isArchive: false, isFavorite: false),
|
||||||
mediaType: prefilter?.mediaType ?? AssetType.other,
|
mediaType: prefilter?.mediaType ?? AssetType.other,
|
||||||
|
rating: prefilter?.rating ?? SearchRatingFilter(),
|
||||||
language: "${context.locale.languageCode}-${context.locale.countryCode}",
|
language: "${context.locale.languageCode}-${context.locale.countryCode}",
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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/search/quick_date_picker.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.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/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/providers/search/search_input_focus.provider.dart';
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
import 'package:immich_mobile/widgets/common/feature_check.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/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_chip.dart';
|
||||||
import 'package:immich_mobile/widgets/search/search_filter/search_filter_utils.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()
|
@RoutePage()
|
||||||
class DriftSearchPage extends HookConsumerWidget {
|
class DriftSearchPage extends HookConsumerWidget {
|
||||||
@@ -48,6 +50,7 @@ class DriftSearchPage extends HookConsumerWidget {
|
|||||||
camera: preFilter?.camera ?? SearchCameraFilter(),
|
camera: preFilter?.camera ?? SearchCameraFilter(),
|
||||||
date: preFilter?.date ?? SearchDateFilter(),
|
date: preFilter?.date ?? SearchDateFilter(),
|
||||||
display: preFilter?.display ?? SearchDisplayFilters(isNotInAlbum: false, isArchive: false, isFavorite: false),
|
display: preFilter?.display ?? SearchDisplayFilters(isNotInAlbum: false, isArchive: false, isFavorite: false),
|
||||||
|
rating: preFilter?.rating ?? SearchRatingFilter(),
|
||||||
mediaType: preFilter?.mediaType ?? AssetType.other,
|
mediaType: preFilter?.mediaType ?? AssetType.other,
|
||||||
language: "${context.locale.languageCode}-${context.locale.countryCode}",
|
language: "${context.locale.languageCode}-${context.locale.countryCode}",
|
||||||
assetId: preFilter?.assetId,
|
assetId: preFilter?.assetId,
|
||||||
@@ -62,10 +65,15 @@ class DriftSearchPage extends HookConsumerWidget {
|
|||||||
final cameraCurrentFilterWidget = useState<Widget?>(null);
|
final cameraCurrentFilterWidget = useState<Widget?>(null);
|
||||||
final locationCurrentFilterWidget = useState<Widget?>(null);
|
final locationCurrentFilterWidget = useState<Widget?>(null);
|
||||||
final mediaTypeCurrentFilterWidget = useState<Widget?>(null);
|
final mediaTypeCurrentFilterWidget = useState<Widget?>(null);
|
||||||
|
final ratingCurrentFilterWidget = useState<Widget?>(null);
|
||||||
final displayOptionCurrentFilterWidget = useState<Widget?>(null);
|
final displayOptionCurrentFilterWidget = useState<Widget?>(null);
|
||||||
|
|
||||||
final isSearching = useState(false);
|
final isSearching = useState(false);
|
||||||
|
|
||||||
|
final isRatingEnabled = ref
|
||||||
|
.watch(userMetadataPreferencesProvider)
|
||||||
|
.maybeWhen(data: (prefs) => prefs?.ratingsEnabled ?? false, orElse: () => false);
|
||||||
|
|
||||||
SnackBar searchInfoSnackBar(String message) {
|
SnackBar searchInfoSnackBar(String message) {
|
||||||
return SnackBar(
|
return SnackBar(
|
||||||
content: Text(message, style: context.textTheme.labelLarge),
|
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
|
// DISPLAY OPTION
|
||||||
showDisplayOptionPicker() {
|
showDisplayOptionPicker() {
|
||||||
handleOnSelect(Map<DisplayOption, bool> value) {
|
handleOnSelect(Map<DisplayOption, bool> value) {
|
||||||
@@ -629,6 +666,14 @@ class DriftSearchPage extends HookConsumerWidget {
|
|||||||
label: 'search_filter_media_type'.t(context: context),
|
label: 'search_filter_media_type'.t(context: context),
|
||||||
currentFilter: mediaTypeCurrentFilterWidget.value,
|
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(
|
SearchFilterChip(
|
||||||
icon: Icons.display_settings_outlined,
|
icon: Icons.display_settings_outlined,
|
||||||
onTap: showDisplayOptionPicker,
|
onTap: showDisplayOptionPicker,
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ class SimilarPhotosActionButton extends ConsumerWidget {
|
|||||||
camera: SearchCameraFilter(),
|
camera: SearchCameraFilter(),
|
||||||
date: SearchDateFilter(),
|
date: SearchDateFilter(),
|
||||||
display: SearchDisplayFilters(isNotInAlbum: false, isArchive: false, isFavorite: false),
|
display: SearchDisplayFilters(isNotInAlbum: false, isArchive: false, isFavorite: false),
|
||||||
|
rating: SearchRatingFilter(),
|
||||||
mediaType: AssetType.image,
|
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/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';
|
||||||
import 'package:immich_mobile/providers/infrastructure/album.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/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/providers/user.provider.dart';
|
||||||
import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
@@ -204,6 +206,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() {
|
||||||
@@ -283,6 +288,38 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
|
|||||||
subtitleStyle: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary),
|
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)
|
// 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,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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -359,6 +359,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 {
|
||||||
|
|||||||
@@ -214,6 +214,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);
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ class ExploreGrid extends StatelessWidget {
|
|||||||
camera: SearchCameraFilter(),
|
camera: SearchCameraFilter(),
|
||||||
date: SearchDateFilter(),
|
date: SearchDateFilter(),
|
||||||
display: SearchDisplayFilters(isNotInAlbum: false, isArchive: false, isFavorite: false),
|
display: SearchDisplayFilters(isNotInAlbum: false, isArchive: false, isFavorite: false),
|
||||||
|
rating: SearchRatingFilter(),
|
||||||
mediaType: AssetType.other,
|
mediaType: AssetType.other,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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<int>(
|
||||||
|
key: Key("star_$index"),
|
||||||
|
title: Text('rating_count'.t(args: {'count': (index)})),
|
||||||
|
value: index,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user