From 106effca2e8d4bcd584c4ddad8e90ac8a35b40d1 Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 28 Oct 2025 13:54:41 -0500 Subject: [PATCH] feat: check server feature to render OCR search option (#23325) --- .../server_info/server_features.model.dart | 15 +++++--- .../pages/search/drift_search.page.dart | 32 +++++++++------- mobile/lib/utils/openapi_patching.dart | 5 +++ mobile/lib/widgets/common/feature_check.dart | 38 +++++++++++++++++++ 4 files changed, 71 insertions(+), 19 deletions(-) create mode 100644 mobile/lib/widgets/common/feature_check.dart diff --git a/mobile/lib/models/server_info/server_features.model.dart b/mobile/lib/models/server_info/server_features.model.dart index 20b9f29619..049628a8d2 100644 --- a/mobile/lib/models/server_info/server_features.model.dart +++ b/mobile/lib/models/server_info/server_features.model.dart @@ -5,33 +5,37 @@ class ServerFeatures { final bool map; final bool oauthEnabled; final bool passwordLogin; + final bool ocr; const ServerFeatures({ required this.trash, required this.map, required this.oauthEnabled, required this.passwordLogin, + this.ocr = false, }); - ServerFeatures copyWith({bool? trash, bool? map, bool? oauthEnabled, bool? passwordLogin}) { + ServerFeatures copyWith({bool? trash, bool? map, bool? oauthEnabled, bool? passwordLogin, bool? ocr}) { return ServerFeatures( trash: trash ?? this.trash, map: map ?? this.map, oauthEnabled: oauthEnabled ?? this.oauthEnabled, passwordLogin: passwordLogin ?? this.passwordLogin, + ocr: ocr ?? this.ocr, ); } @override String toString() { - return 'ServerFeatures(trash: $trash, map: $map, oauthEnabled: $oauthEnabled, passwordLogin: $passwordLogin)'; + return 'ServerFeatures(trash: $trash, map: $map, oauthEnabled: $oauthEnabled, passwordLogin: $passwordLogin, ocr: $ocr)'; } ServerFeatures.fromDto(ServerFeaturesDto dto) : trash = dto.trash, map = dto.map, oauthEnabled = dto.oauth, - passwordLogin = dto.passwordLogin; + passwordLogin = dto.passwordLogin, + ocr = dto.ocr; @override bool operator ==(covariant ServerFeatures other) { @@ -40,11 +44,12 @@ class ServerFeatures { return other.trash == trash && other.map == map && other.oauthEnabled == oauthEnabled && - other.passwordLogin == passwordLogin; + other.passwordLogin == passwordLogin && + other.ocr == ocr; } @override int get hashCode { - return trash.hashCode ^ map.hashCode ^ oauthEnabled.hashCode ^ passwordLogin.hashCode; + return trash.hashCode ^ map.hashCode ^ oauthEnabled.hashCode ^ passwordLogin.hashCode ^ ocr.hashCode; } } diff --git a/mobile/lib/presentation/pages/search/drift_search.page.dart b/mobile/lib/presentation/pages/search/drift_search.page.dart index 965e31678e..d631395465 100644 --- a/mobile/lib/presentation/pages/search/drift_search.page.dart +++ b/mobile/lib/presentation/pages/search/drift_search.page.dart @@ -19,6 +19,7 @@ import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart import 'package:immich_mobile/providers/infrastructure/timeline.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'; import 'package:immich_mobile/widgets/common/search_field.dart'; import 'package:immich_mobile/widgets/search/search_filter/camera_picker.dart'; import 'package:immich_mobile/widgets/search/search_filter/display_option_picker.dart'; @@ -503,23 +504,26 @@ class DriftSearchPage extends HookConsumerWidget { searchHintText.value = 'search_by_description_example'.t(context: context); }, ), - MenuItemButton( - child: ListTile( - leading: const Icon(Icons.document_scanner_outlined), - title: Text( - 'search_by_ocr'.t(context: context), - style: context.textTheme.bodyLarge?.copyWith( - fontWeight: FontWeight.w500, - color: textSearchType.value == TextSearchType.ocr ? context.colorScheme.primary : null, + FeatureCheck( + feature: (features) => features.ocr, + child: MenuItemButton( + child: ListTile( + leading: const Icon(Icons.document_scanner_outlined), + title: Text( + 'search_by_ocr'.t(context: context), + style: context.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w500, + color: textSearchType.value == TextSearchType.ocr ? context.colorScheme.primary : null, + ), ), + selectedColor: context.colorScheme.primary, + selected: textSearchType.value == TextSearchType.ocr, ), - selectedColor: context.colorScheme.primary, - selected: textSearchType.value == TextSearchType.ocr, + onPressed: () { + textSearchType.value = TextSearchType.ocr; + searchHintText.value = 'search_by_ocr_example'.t(context: context); + }, ), - onPressed: () { - textSearchType.value = TextSearchType.ocr; - searchHintText.value = 'search_by_ocr_example'.t(context: context); - }, ), ], ), diff --git a/mobile/lib/utils/openapi_patching.dart b/mobile/lib/utils/openapi_patching.dart index 33199d5225..0a3fa7e91d 100644 --- a/mobile/lib/utils/openapi_patching.dart +++ b/mobile/lib/utils/openapi_patching.dart @@ -46,6 +46,11 @@ dynamic upgradeDto(dynamic value, String targetType) { addDefault(value, 'profileChangedAt', DateTime.now().toIso8601String()); addDefault(value, 'hasProfileImage', false); } + case 'ServerFeaturesDto': + if (value is Map) { + addDefault(value, 'ocr', false); + } + break; } } diff --git a/mobile/lib/widgets/common/feature_check.dart b/mobile/lib/widgets/common/feature_check.dart new file mode 100644 index 0000000000..ebaa0acfe7 --- /dev/null +++ b/mobile/lib/widgets/common/feature_check.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/models/server_info/server_features.model.dart'; +import 'package:immich_mobile/providers/server_info.provider.dart'; + +/// A utility widget that conditionally renders its child based on a server feature flag. +/// +/// Example usage: +/// ```dart +/// FeatureCheck( +/// feature: (features) => features.ocr, +/// child: Text('OCR is enabled'), +/// fallback: Text('OCR is not available'), +/// ) +/// ``` +class FeatureCheck extends ConsumerWidget { + /// A function that extracts the specific feature flag from ServerFeatures + final bool Function(ServerFeatures) feature; + + /// The widget to display when the feature is enabled + final Widget child; + + /// Optional widget to display when the feature is disabled + final Widget? fallback; + + const FeatureCheck({super.key, required this.feature, required this.child, this.fallback}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final serverFeatures = ref.watch(serverInfoProvider.select((s) => s.serverFeatures)); + final isFeatureEnabled = feature(serverFeatures); + if (isFeatureEnabled) { + return child; + } + + return fallback ?? const SizedBox.shrink(); + } +}