Browse Source

Show curated asset's location in search page (#55)

* Added Tab Navigation Observer to trigger event handling for tab page navigation
* Added query to get access with distinct location
* Showed places in search page as a horizontal list
* Showed location search result on tapped
Alex 3 years ago
parent
commit
8c7080eaef

+ 20 - 5
README.md

@@ -1,13 +1,28 @@
 <p align="center">
-  <img src="design/immich-logo.svg" width="150" title="hover text">
+  <a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/license-MIT-green.svg?color=3F51B5&style=for-the-badge&label=License&logoColor=000000&labelColor=ececec" alt="License: MIT"></a>
+  <a href="https://github.com/alextran1502/immich"><img src="https://img.shields.io/github/stars/alextran1502/immich.svg?style=for-the-badge&logo=github&color=3F51B5&label=Stars&logoColor=000000&labelColor=ececec" alt="Star on Github"></a>
+  <a href="https://immichci.little-home.net/viewType.html?buildTypeId=Immich_BuildAndroidAndGetArtifact&guest=1">
+    <img src="https://img.shields.io/teamcity/http/immichci.little-home.net/s/Immich_BuildAndroidAndGetArtifact.svg?style=for-the-badge&label=Android&logo=teamcity&logoColor=000000&labelColor=ececec" alt="Android Build"/>
+  </a>
+  <a href="https://immichci.little-home.net/viewType.html?buildTypeId=Immich_BuildAndPublishIOSToTestFlight&guest=1">
+    <img src="https://img.shields.io/teamcity/http/immichci.little-home.net/s/Immich_BuildAndPublishIOSToTestFlight.svg?style=for-the-badge&label=iOS&logo=teamcity&logoColor=000000&labelColor=ececec" alt="iOS Build"/>
+  </a>
+  <a href="https://actions-badge.atrox.dev/alextran1502/immich/goto?ref=main">
+    <img alt="Build Status" src="https://img.shields.io/endpoint.svg?url=https%3A%2F%2Factions-badge.atrox.dev%2Falextran1502%2Fimmich%2Fbadge%3Fref%3Dmain&style=for-the-badge&label=Server Docker&logo=docker&labelColor=ececec" />
+  </a>
+  
+  <br/>  
+  <br/>  
+  <br/>  
+  <br/>  
+
+  <p align="center">
+    <img src="design/immich-logo.svg" width="200" title="Immich Logo">
+  </p>
 </p>
 
 # Immich
 
-| Android Build | iOS Build | Server Docker Build |
-| --- | --- | --- |
-| [![Build Status](<https://immichci.little-home.net/app/rest/builds/buildType:(id:Immich_BuildAndroidAndGetArtifact)/statusIcon>)](https://immichci.little-home.net/viewType.html?buildTypeId=Immich_BuildAndroidAndGetArtifact&guest=1) | [![Build Status](<https://immichci.little-home.net/app/rest/builds/buildType:(id:Immich_BuildAndroidAndGetArtifact)/statusIcon>)](https://immichci.little-home.net/viewType.html?buildTypeId=Immich_BuildAndroidAndGetArtifact&guest=1) | ![example workflow](https://github.com/alextran1502/immich/actions/workflows/build_push_server.yml/badge.svg) |
-
 Self-hosted photo and video backup solution directly from your mobile phone.
 
 ![](https://media.giphy.com/media/y8ZeaAigGmNvlSoKhU/giphy.gif)

+ 2 - 1
mobile/lib/main.dart

@@ -4,6 +4,7 @@ import 'package:hive_flutter/hive_flutter.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:immich_mobile/modules/home/providers/asset.provider.dart';
 import 'package:immich_mobile/routing/router.dart';
+import 'package:immich_mobile/routing/tab_navigation_observer.dart';
 import 'package:immich_mobile/shared/providers/app_state.provider.dart';
 import 'package:immich_mobile/shared/providers/backup.provider.dart';
 import 'package:immich_mobile/shared/providers/websocket.provider.dart';
@@ -100,7 +101,7 @@ class _ImmichAppState extends ConsumerState<ImmichApp> with WidgetsBindingObserv
         ),
       ),
       routeInformationParser: _immichRouter.defaultRouteParser(),
-      routerDelegate: _immichRouter.delegate(),
+      routerDelegate: _immichRouter.delegate(navigatorObservers: () => [TabNavigationObserver(ref: ref)]),
     );
   }
 }

+ 79 - 0
mobile/lib/modules/search/models/curated_location.model.dart

@@ -0,0 +1,79 @@
+import 'dart:convert';
+
+class CuratedLocation {
+  final String id;
+  final String city;
+  final String resizePath;
+  final String deviceAssetId;
+  final String deviceId;
+
+  CuratedLocation({
+    required this.id,
+    required this.city,
+    required this.resizePath,
+    required this.deviceAssetId,
+    required this.deviceId,
+  });
+
+  CuratedLocation copyWith({
+    String? id,
+    String? city,
+    String? resizePath,
+    String? deviceAssetId,
+    String? deviceId,
+  }) {
+    return CuratedLocation(
+      id: id ?? this.id,
+      city: city ?? this.city,
+      resizePath: resizePath ?? this.resizePath,
+      deviceAssetId: deviceAssetId ?? this.deviceAssetId,
+      deviceId: deviceId ?? this.deviceId,
+    );
+  }
+
+  Map<String, dynamic> toMap() {
+    return {
+      'id': id,
+      'city': city,
+      'resizePath': resizePath,
+      'deviceAssetId': deviceAssetId,
+      'deviceId': deviceId,
+    };
+  }
+
+  factory CuratedLocation.fromMap(Map<String, dynamic> map) {
+    return CuratedLocation(
+      id: map['id'] ?? '',
+      city: map['city'] ?? '',
+      resizePath: map['resizePath'] ?? '',
+      deviceAssetId: map['deviceAssetId'] ?? '',
+      deviceId: map['deviceId'] ?? '',
+    );
+  }
+
+  String toJson() => json.encode(toMap());
+
+  factory CuratedLocation.fromJson(String source) => CuratedLocation.fromMap(json.decode(source));
+
+  @override
+  String toString() {
+    return 'CuratedLocation(id: $id, city: $city, resizePath: $resizePath, deviceAssetId: $deviceAssetId, deviceId: $deviceId)';
+  }
+
+  @override
+  bool operator ==(Object other) {
+    if (identical(this, other)) return true;
+
+    return other is CuratedLocation &&
+        other.id == id &&
+        other.city == city &&
+        other.resizePath == resizePath &&
+        other.deviceAssetId == deviceAssetId &&
+        other.deviceId == deviceId;
+  }
+
+  @override
+  int get hashCode {
+    return id.hashCode ^ city.hashCode ^ resizePath.hashCode ^ deviceAssetId.hashCode ^ deviceId.hashCode;
+  }
+}

+ 78 - 0
mobile/lib/modules/search/models/search_page_state.model.dart

@@ -0,0 +1,78 @@
+import 'dart:convert';
+
+import 'package:collection/collection.dart';
+
+class SearchPageState {
+  final String searchTerm;
+  final bool isSearchEnabled;
+  final List<String> searchSuggestion;
+  final List<String> userSuggestedSearchTerms;
+
+  SearchPageState({
+    required this.searchTerm,
+    required this.isSearchEnabled,
+    required this.searchSuggestion,
+    required this.userSuggestedSearchTerms,
+  });
+
+  SearchPageState copyWith({
+    String? searchTerm,
+    bool? isSearchEnabled,
+    List<String>? searchSuggestion,
+    List<String>? userSuggestedSearchTerms,
+  }) {
+    return SearchPageState(
+      searchTerm: searchTerm ?? this.searchTerm,
+      isSearchEnabled: isSearchEnabled ?? this.isSearchEnabled,
+      searchSuggestion: searchSuggestion ?? this.searchSuggestion,
+      userSuggestedSearchTerms: userSuggestedSearchTerms ?? this.userSuggestedSearchTerms,
+    );
+  }
+
+  Map<String, dynamic> toMap() {
+    return {
+      'searchTerm': searchTerm,
+      'isSearchEnabled': isSearchEnabled,
+      'searchSuggestion': searchSuggestion,
+      'userSuggestedSearchTerms': userSuggestedSearchTerms,
+    };
+  }
+
+  factory SearchPageState.fromMap(Map<String, dynamic> map) {
+    return SearchPageState(
+      searchTerm: map['searchTerm'] ?? '',
+      isSearchEnabled: map['isSearchEnabled'] ?? false,
+      searchSuggestion: List<String>.from(map['searchSuggestion']),
+      userSuggestedSearchTerms: List<String>.from(map['userSuggestedSearchTerms']),
+    );
+  }
+
+  String toJson() => json.encode(toMap());
+
+  factory SearchPageState.fromJson(String source) => SearchPageState.fromMap(json.decode(source));
+
+  @override
+  String toString() {
+    return 'SearchPageState(searchTerm: $searchTerm, isSearchEnabled: $isSearchEnabled, searchSuggestion: $searchSuggestion, userSuggestedSearchTerms: $userSuggestedSearchTerms)';
+  }
+
+  @override
+  bool operator ==(Object other) {
+    if (identical(this, other)) return true;
+    final listEquals = const DeepCollectionEquality().equals;
+
+    return other is SearchPageState &&
+        other.searchTerm == searchTerm &&
+        other.isSearchEnabled == isSearchEnabled &&
+        listEquals(other.searchSuggestion, searchSuggestion) &&
+        listEquals(other.userSuggestedSearchTerms, userSuggestedSearchTerms);
+  }
+
+  @override
+  int get hashCode {
+    return searchTerm.hashCode ^
+        isSearchEnabled.hashCode ^
+        searchSuggestion.hashCode ^
+        userSuggestedSearchTerms.hashCode;
+  }
+}

+ 8 - 43
mobile/lib/modules/search/providers/search_result_page_state.provider.dart → mobile/lib/modules/search/models/search_result_page_state.model.dart

@@ -1,32 +1,28 @@
 import 'dart:convert';
 
 import 'package:collection/collection.dart';
-import 'package:hooks_riverpod/hooks_riverpod.dart';
-
-import 'package:immich_mobile/modules/search/services/search.service.dart';
 import 'package:immich_mobile/shared/models/immich_asset.model.dart';
-import 'package:intl/intl.dart';
 
-class SearchresultPageState {
+class SearchResultPageState {
   final bool isLoading;
   final bool isSuccess;
   final bool isError;
   final List<ImmichAsset> searchResult;
 
-  SearchresultPageState({
+  SearchResultPageState({
     required this.isLoading,
     required this.isSuccess,
     required this.isError,
     required this.searchResult,
   });
 
-  SearchresultPageState copyWith({
+  SearchResultPageState copyWith({
     bool? isLoading,
     bool? isSuccess,
     bool? isError,
     List<ImmichAsset>? searchResult,
   }) {
-    return SearchresultPageState(
+    return SearchResultPageState(
       isLoading: isLoading ?? this.isLoading,
       isSuccess: isSuccess ?? this.isSuccess,
       isError: isError ?? this.isError,
@@ -43,8 +39,8 @@ class SearchresultPageState {
     };
   }
 
-  factory SearchresultPageState.fromMap(Map<String, dynamic> map) {
-    return SearchresultPageState(
+  factory SearchResultPageState.fromMap(Map<String, dynamic> map) {
+    return SearchResultPageState(
       isLoading: map['isLoading'] ?? false,
       isSuccess: map['isSuccess'] ?? false,
       isError: map['isError'] ?? false,
@@ -54,7 +50,7 @@ class SearchresultPageState {
 
   String toJson() => json.encode(toMap());
 
-  factory SearchresultPageState.fromJson(String source) => SearchresultPageState.fromMap(json.decode(source));
+  factory SearchResultPageState.fromJson(String source) => SearchResultPageState.fromMap(json.decode(source));
 
   @override
   String toString() {
@@ -66,7 +62,7 @@ class SearchresultPageState {
     if (identical(this, other)) return true;
     final listEquals = const DeepCollectionEquality().equals;
 
-    return other is SearchresultPageState &&
+    return other is SearchResultPageState &&
         other.isLoading == isLoading &&
         other.isSuccess == isSuccess &&
         other.isError == isError &&
@@ -78,34 +74,3 @@ class SearchresultPageState {
     return isLoading.hashCode ^ isSuccess.hashCode ^ isError.hashCode ^ searchResult.hashCode;
   }
 }
-
-class SearchResultPageStateNotifier extends StateNotifier<SearchresultPageState> {
-  SearchResultPageStateNotifier()
-      : super(SearchresultPageState(searchResult: [], isError: false, isLoading: true, isSuccess: false));
-
-  final SearchService _searchService = SearchService();
-
-  search(String searchTerm) async {
-    state = state.copyWith(searchResult: [], isError: false, isLoading: true, isSuccess: false);
-
-    List<ImmichAsset>? assets = await _searchService.searchAsset(searchTerm);
-
-    if (assets != null) {
-      state = state.copyWith(searchResult: assets, isError: false, isLoading: false, isSuccess: true);
-    } else {
-      state = state.copyWith(searchResult: [], isError: true, isLoading: false, isSuccess: false);
-    }
-  }
-}
-
-final searchResultPageStateProvider =
-    StateNotifierProvider<SearchResultPageStateNotifier, SearchresultPageState>((ref) {
-  return SearchResultPageStateNotifier();
-});
-
-final searchResultGroupByDateTimeProvider = StateProvider((ref) {
-  var assets = ref.watch(searchResultPageStateProvider).searchResult;
-
-  assets.sortByCompare<DateTime>((e) => DateTime.parse(e.createdAt), (a, b) => b.compareTo(a));
-  return assets.groupListsBy((element) => DateFormat('y-MM-dd').format(DateTime.parse(element.createdAt)));
-});

+ 0 - 0
mobile/lib/modules/search/models/store_model_here.txt


+ 13 - 78
mobile/lib/modules/search/providers/search_page_state.provider.dart

@@ -1,85 +1,9 @@
-import 'dart:convert';
-
-import 'package:collection/collection.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/modules/search/models/curated_location.model.dart';
+import 'package:immich_mobile/modules/search/models/search_page_state.model.dart';
 
 import 'package:immich_mobile/modules/search/services/search.service.dart';
 
-class SearchPageState {
-  final String searchTerm;
-  final bool isSearchEnabled;
-  final List<String> searchSuggestion;
-  final List<String> userSuggestedSearchTerms;
-
-  SearchPageState({
-    required this.searchTerm,
-    required this.isSearchEnabled,
-    required this.searchSuggestion,
-    required this.userSuggestedSearchTerms,
-  });
-
-  SearchPageState copyWith({
-    String? searchTerm,
-    bool? isSearchEnabled,
-    List<String>? searchSuggestion,
-    List<String>? userSuggestedSearchTerms,
-  }) {
-    return SearchPageState(
-      searchTerm: searchTerm ?? this.searchTerm,
-      isSearchEnabled: isSearchEnabled ?? this.isSearchEnabled,
-      searchSuggestion: searchSuggestion ?? this.searchSuggestion,
-      userSuggestedSearchTerms: userSuggestedSearchTerms ?? this.userSuggestedSearchTerms,
-    );
-  }
-
-  Map<String, dynamic> toMap() {
-    return {
-      'searchTerm': searchTerm,
-      'isSearchEnabled': isSearchEnabled,
-      'searchSuggestion': searchSuggestion,
-      'userSuggestedSearchTerms': userSuggestedSearchTerms,
-    };
-  }
-
-  factory SearchPageState.fromMap(Map<String, dynamic> map) {
-    return SearchPageState(
-      searchTerm: map['searchTerm'] ?? '',
-      isSearchEnabled: map['isSearchEnabled'] ?? false,
-      searchSuggestion: List<String>.from(map['searchSuggestion']),
-      userSuggestedSearchTerms: List<String>.from(map['userSuggestedSearchTerms']),
-    );
-  }
-
-  String toJson() => json.encode(toMap());
-
-  factory SearchPageState.fromJson(String source) => SearchPageState.fromMap(json.decode(source));
-
-  @override
-  String toString() {
-    return 'SearchPageState(searchTerm: $searchTerm, isSearchEnabled: $isSearchEnabled, searchSuggestion: $searchSuggestion, userSuggestedSearchTerms: $userSuggestedSearchTerms)';
-  }
-
-  @override
-  bool operator ==(Object other) {
-    if (identical(this, other)) return true;
-    final listEquals = const DeepCollectionEquality().equals;
-
-    return other is SearchPageState &&
-        other.searchTerm == searchTerm &&
-        other.isSearchEnabled == isSearchEnabled &&
-        listEquals(other.searchSuggestion, searchSuggestion) &&
-        listEquals(other.userSuggestedSearchTerms, userSuggestedSearchTerms);
-  }
-
-  @override
-  int get hashCode {
-    return searchTerm.hashCode ^
-        isSearchEnabled.hashCode ^
-        searchSuggestion.hashCode ^
-        userSuggestedSearchTerms.hashCode;
-  }
-}
-
 class SearchPageStateNotifier extends StateNotifier<SearchPageState> {
   SearchPageStateNotifier()
       : super(
@@ -129,3 +53,14 @@ class SearchPageStateNotifier extends StateNotifier<SearchPageState> {
 final searchPageStateProvider = StateNotifierProvider<SearchPageStateNotifier, SearchPageState>((ref) {
   return SearchPageStateNotifier();
 });
+
+final getCuratedLocationProvider = FutureProvider.autoDispose<List<CuratedLocation>>((ref) async {
+  final SearchService _searchService = SearchService();
+
+  var curatedLocation = await _searchService.getCuratedLocation();
+  if (curatedLocation != null) {
+    return curatedLocation;
+  } else {
+    return [];
+  }
+});

+ 37 - 0
mobile/lib/modules/search/providers/search_result_page.provider.dart

@@ -0,0 +1,37 @@
+import 'package:collection/collection.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/modules/search/models/search_result_page_state.model.dart';
+
+import 'package:immich_mobile/modules/search/services/search.service.dart';
+import 'package:immich_mobile/shared/models/immich_asset.model.dart';
+import 'package:intl/intl.dart';
+
+class SearchResultPageNotifier extends StateNotifier<SearchResultPageState> {
+  SearchResultPageNotifier()
+      : super(SearchResultPageState(searchResult: [], isError: false, isLoading: true, isSuccess: false));
+
+  final SearchService _searchService = SearchService();
+
+  void search(String searchTerm) async {
+    state = state.copyWith(searchResult: [], isError: false, isLoading: true, isSuccess: false);
+
+    List<ImmichAsset>? assets = await _searchService.searchAsset(searchTerm);
+
+    if (assets != null) {
+      state = state.copyWith(searchResult: assets, isError: false, isLoading: false, isSuccess: true);
+    } else {
+      state = state.copyWith(searchResult: [], isError: true, isLoading: false, isSuccess: false);
+    }
+  }
+}
+
+final searchResultPageProvider = StateNotifierProvider<SearchResultPageNotifier, SearchResultPageState>((ref) {
+  return SearchResultPageNotifier();
+});
+
+final searchResultGroupByDateTimeProvider = StateProvider((ref) {
+  var assets = ref.watch(searchResultPageProvider).searchResult;
+
+  assets.sortByCompare<DateTime>((e) => DateTime.parse(e.createdAt), (a, b) => b.compareTo(a));
+  return assets.groupListsBy((element) => DateFormat('y-MM-dd').format(DateTime.parse(element.createdAt)));
+});

+ 16 - 0
mobile/lib/modules/search/services/search.service.dart

@@ -1,6 +1,7 @@
 import 'dart:convert';
 
 import 'package:flutter/material.dart';
+import 'package:immich_mobile/modules/search/models/curated_location.model.dart';
 import 'package:immich_mobile/shared/models/immich_asset.model.dart';
 import 'package:immich_mobile/shared/services/network.service.dart';
 
@@ -36,4 +37,19 @@ class SearchService {
       return null;
     }
   }
+
+  Future<List<CuratedLocation>?> getCuratedLocation() async {
+    try {
+      var res = await _networkService.getRequest(url: "asset/allLocation");
+
+      List<dynamic> decodedData = jsonDecode(res.toString());
+
+      List<CuratedLocation> result = List.from(decodedData.map((a) => CuratedLocation.fromMap(a)));
+
+      return result;
+    } catch (e) {
+      debugPrint("[ERROR] [getCuratedLocation] ${e.toString()}");
+      throw Error();
+    }
+  }
 }

+ 126 - 4
mobile/lib/modules/search/views/search_page.dart

@@ -1,7 +1,11 @@
 import 'package:auto_route/auto_route.dart';
+import 'package:cached_network_image/cached_network_image.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter_hooks/flutter_hooks.dart';
+import 'package:hive_flutter/hive_flutter.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/constants/hive_box.dart';
+import 'package:immich_mobile/modules/search/models/curated_location.model.dart';
 import 'package:immich_mobile/modules/search/providers/search_page_state.provider.dart';
 import 'package:immich_mobile/modules/search/ui/search_bar.dart';
 import 'package:immich_mobile/modules/search/ui/search_suggestion_list.dart';
@@ -15,7 +19,9 @@ class SearchPage extends HookConsumerWidget {
 
   @override
   Widget build(BuildContext context, WidgetRef ref) {
+    var box = Hive.box(userInfoBox);
     final isSearchEnabled = ref.watch(searchPageStateProvider).isSearchEnabled;
+    AsyncValue<List<CuratedLocation>> curatedLocation = ref.watch(getCuratedLocationProvider);
 
     useEffect(() {
       searchFocusNode = FocusNode();
@@ -29,6 +35,53 @@ class SearchPage extends HookConsumerWidget {
       AutoRouter.of(context).push(SearchResultRoute(searchTerm: searchTerm));
     }
 
+    _buildPlaces() {
+      return curatedLocation.when(
+        loading: () => const CircularProgressIndicator(),
+        error: (err, stack) => Text('Error: $err'),
+        data: (curatedLocations) {
+          return curatedLocations.isNotEmpty
+              ? SizedBox(
+                  height: MediaQuery.of(context).size.width / 3,
+                  child: ListView.builder(
+                    padding: const EdgeInsets.only(left: 16),
+                    scrollDirection: Axis.horizontal,
+                    itemCount: curatedLocation.value?.length,
+                    itemBuilder: ((context, index) {
+                      CuratedLocation locationInfo = curatedLocations[index];
+                      var thumbnailRequestUrl =
+                          '${box.get(serverEndpointKey)}/asset/file?aid=${locationInfo.deviceAssetId}&did=${locationInfo.deviceId}&isThumb=true';
+
+                      return ThumbnailWithInfo(
+                        imageUrl: thumbnailRequestUrl,
+                        textInfo: locationInfo.city,
+                        onTap: () {
+                          AutoRouter.of(context).push(SearchResultRoute(searchTerm: locationInfo.city));
+                        },
+                      );
+                    }),
+                  ),
+                )
+              : SizedBox(
+                  height: MediaQuery.of(context).size.width / 3,
+                  child: ListView.builder(
+                    padding: const EdgeInsets.only(left: 16),
+                    scrollDirection: Axis.horizontal,
+                    itemCount: 1,
+                    itemBuilder: ((context, index) {
+                      return ThumbnailWithInfo(
+                        imageUrl:
+                            'https://images.unsplash.com/photo-1612178537253-bccd437b730e?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxzZWFyY2h8NXx8Ymxhbmt8ZW58MHx8MHx8&auto=format&fit=crop&w=700&q=60',
+                        textInfo: 'No Places Info Available',
+                        onTap: () {},
+                      );
+                    }),
+                  ),
+                );
+        },
+      );
+    }
+
     return Scaffold(
       appBar: SearchBar(
         searchFocusNode: searchFocusNode,
@@ -41,11 +94,17 @@ class SearchPage extends HookConsumerWidget {
         },
         child: Stack(
           children: [
-            const Center(
-              child: Text("Start typing to search for your photos"),
-            ),
             ListView(
-              children: const [],
+              children: [
+                const Padding(
+                  padding: EdgeInsets.all(16.0),
+                  child: Text(
+                    "Places",
+                    style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
+                  ),
+                ),
+                _buildPlaces(),
+              ],
             ),
             isSearchEnabled ? SearchSuggestionList(onSubmitted: _onSearchSubmitted) : Container(),
           ],
@@ -54,3 +113,66 @@ class SearchPage extends HookConsumerWidget {
     );
   }
 }
+
+class ThumbnailWithInfo extends StatelessWidget {
+  const ThumbnailWithInfo({Key? key, required this.textInfo, required this.imageUrl, required this.onTap})
+      : super(key: key);
+
+  final String textInfo;
+  final String imageUrl;
+  final Function onTap;
+
+  @override
+  Widget build(BuildContext context) {
+    var box = Hive.box(userInfoBox);
+
+    return GestureDetector(
+      onTap: () {
+        onTap();
+      },
+      child: Padding(
+        padding: const EdgeInsets.only(right: 8.0),
+        child: SizedBox(
+          width: MediaQuery.of(context).size.width / 3,
+          height: MediaQuery.of(context).size.width / 3,
+          child: Stack(
+            alignment: Alignment.bottomCenter,
+            children: [
+              Container(
+                foregroundDecoration: BoxDecoration(
+                  borderRadius: BorderRadius.circular(10),
+                  color: Colors.black26,
+                ),
+                child: ClipRRect(
+                  borderRadius: BorderRadius.circular(10),
+                  child: CachedNetworkImage(
+                    width: 150,
+                    height: 150,
+                    fit: BoxFit.cover,
+                    imageUrl: imageUrl,
+                    httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
+                  ),
+                ),
+              ),
+              Positioned(
+                bottom: 8,
+                left: 10,
+                child: SizedBox(
+                  width: MediaQuery.of(context).size.width / 3,
+                  child: Text(
+                    textInfo,
+                    style: const TextStyle(
+                      color: Colors.white,
+                      fontWeight: FontWeight.bold,
+                      fontSize: 12,
+                    ),
+                  ),
+                ),
+              ),
+            ],
+          ),
+        ),
+      ),
+    );
+  }
+}

+ 4 - 4
mobile/lib/modules/search/views/search_result_page.dart

@@ -7,7 +7,7 @@ import 'package:immich_mobile/modules/home/ui/draggable_scrollbar.dart';
 import 'package:immich_mobile/modules/home/ui/image_grid.dart';
 import 'package:immich_mobile/modules/home/ui/monthly_title_text.dart';
 import 'package:immich_mobile/modules/search/providers/search_page_state.provider.dart';
-import 'package:immich_mobile/modules/search/providers/search_result_page_state.provider.dart';
+import 'package:immich_mobile/modules/search/providers/search_result_page.provider.dart';
 import 'package:immich_mobile/modules/search/ui/search_suggestion_list.dart';
 
 class SearchResultPage extends HookConsumerWidget {
@@ -28,7 +28,7 @@ class SearchResultPage extends HookConsumerWidget {
     useEffect(() {
       searchFocusNode = FocusNode();
 
-      Future.delayed(Duration.zero, () => ref.read(searchResultPageStateProvider.notifier).search(searchTerm));
+      Future.delayed(Duration.zero, () => ref.read(searchResultPageProvider.notifier).search(searchTerm));
       return () => searchFocusNode.dispose();
     }, []);
 
@@ -37,7 +37,7 @@ class SearchResultPage extends HookConsumerWidget {
       searchFocusNode.unfocus();
       isNewSearch.value = false;
       currentSearchTerm.value = newSearchTerm;
-      ref.watch(searchResultPageStateProvider.notifier).search(newSearchTerm);
+      ref.watch(searchResultPageProvider.notifier).search(newSearchTerm);
     }
 
     _buildTextField() {
@@ -99,7 +99,7 @@ class SearchResultPage extends HookConsumerWidget {
     }
 
     _buildSearchResult() {
-      var searchResultPageState = ref.watch(searchResultPageStateProvider);
+      var searchResultPageState = ref.watch(searchResultPageProvider);
       var assetGroupByDateTime = ref.watch(searchResultGroupByDateTimeProvider);
 
       if (searchResultPageState.isError) {

+ 30 - 0
mobile/lib/routing/tab_navigation_observer.dart

@@ -0,0 +1,30 @@
+import 'package:auto_route/auto_route.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+
+import 'package:immich_mobile/modules/search/providers/search_page_state.provider.dart';
+
+class TabNavigationObserver extends AutoRouterObserver {
+  /// Riverpod Instance
+  final WidgetRef ref;
+
+  TabNavigationObserver({
+    required this.ref,
+  });
+
+  @override
+  void didInitTabRoute(TabPageRoute route, TabPageRoute? previousRoute) {
+    // Perform tasks on first navigation to SearchRoute
+    if (route.name == 'SearchRoute') {
+      // ref.refresh(getCuratedLocationProvider);
+    }
+  }
+
+  @override
+  Future<void> didChangeTabRoute(TabPageRoute route, TabPageRoute previousRoute) async {
+    // Perform tasks on re-visit to SearchRoute
+    if (route.name == 'SearchRoute') {
+      // Refresh Location State
+      ref.refresh(getCuratedLocationProvider);
+    }
+  }
+}

+ 0 - 30
mobile/test/widget_test.dart

@@ -1,30 +0,0 @@
-// This is a basic Flutter widget test.
-//
-// To perform an interaction with a widget in your test, use the WidgetTester
-// utility that Flutter provides. For example, you can send tap and scroll
-// gestures. You can also use WidgetTester to find child widgets in the widget
-// tree, read text, and verify that the values of widget properties are correct.
-
-import 'package:flutter/material.dart';
-import 'package:flutter_test/flutter_test.dart';
-
-import 'package:immich_mobile/main.dart';
-
-void main() {
-  testWidgets('Counter increments smoke test', (WidgetTester tester) async {
-    // Build our app and trigger a frame.
-    await tester.pumpWidget(const ImmichApp());
-
-    // Verify that our counter starts at 0.
-    expect(find.text('0'), findsOneWidget);
-    expect(find.text('1'), findsNothing);
-
-    // Tap the '+' icon and trigger a frame.
-    await tester.tap(find.byIcon(Icons.add));
-    await tester.pump();
-
-    // Verify that our counter has incremented.
-    expect(find.text('0'), findsNothing);
-    expect(find.text('1'), findsOneWidget);
-  });
-}

+ 5 - 0
server/src/api-v1/asset/asset.controller.ts

@@ -72,6 +72,11 @@ export class AssetController {
     return this.assetService.serveFile(authUser, query, res, headers);
   }
 
+  @Get('/allLocation')
+  async getCuratedLocation(@GetAuthUser() authUser: AuthUserDto) {
+    return this.assetService.getCuratedLocation(authUser);
+  }
+
   @Get('/searchTerm')
   async getAssetSearchTerm(@GetAuthUser() authUser: AuthUserDto) {
     return this.assetService.getAssetSearchTerm(authUser);

+ 16 - 0
server/src/api-v1/asset/asset.service.ts

@@ -303,4 +303,20 @@ export class AssetService {
 
     return rows;
   }
+
+  async getCuratedLocation(authUser: AuthUserDto) {
+    const rows = await this.assetRepository.query(
+      `
+        select distinct on (e.city) a.id, e.city, a."resizePath", a."deviceAssetId", a."deviceId"
+        from assets a
+        left join exif e on a.id = e."assetId"
+        where a."userId" = $1 
+        and e.city is not null
+        and a.type = 'IMAGE';
+      `,
+      [authUser.id],
+    );
+
+    return rows;
+  }
 }