mirror of
https://github.com/immich-app/immich.git
synced 2025-01-27 22:22:45 +01:00
Implemented search result page (#37)
This commit is contained in:
parent
bd34be92e6
commit
5990a28870
16 changed files with 467 additions and 17 deletions
mobile/lib
modules
home/ui
search
routing
server/src
|
@ -83,12 +83,8 @@ class ImmichSliverAppBar extends ConsumerWidget {
|
||||||
),
|
),
|
||||||
child: const Icon(Icons.backup_rounded)),
|
child: const Icon(Icons.backup_rounded)),
|
||||||
tooltip: 'Backup Controller',
|
tooltip: 'Backup Controller',
|
||||||
onPressed: () async {
|
onPressed: () {
|
||||||
var onPop = await AutoRouter.of(context).push(const BackupControllerRoute());
|
AutoRouter.of(context).push(const BackupControllerRoute());
|
||||||
|
|
||||||
if (onPop == true) {
|
|
||||||
onPopBack!();
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
_backupState.backupProgress == BackUpProgressEnum.inProgress
|
_backupState.backupProgress == BackUpProgressEnum.inProgress
|
||||||
|
|
|
@ -0,0 +1,111 @@
|
||||||
|
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 {
|
||||||
|
final bool isLoading;
|
||||||
|
final bool isSuccess;
|
||||||
|
final bool isError;
|
||||||
|
final List<ImmichAsset> searchResult;
|
||||||
|
|
||||||
|
SearchresultPageState({
|
||||||
|
required this.isLoading,
|
||||||
|
required this.isSuccess,
|
||||||
|
required this.isError,
|
||||||
|
required this.searchResult,
|
||||||
|
});
|
||||||
|
|
||||||
|
SearchresultPageState copyWith({
|
||||||
|
bool? isLoading,
|
||||||
|
bool? isSuccess,
|
||||||
|
bool? isError,
|
||||||
|
List<ImmichAsset>? searchResult,
|
||||||
|
}) {
|
||||||
|
return SearchresultPageState(
|
||||||
|
isLoading: isLoading ?? this.isLoading,
|
||||||
|
isSuccess: isSuccess ?? this.isSuccess,
|
||||||
|
isError: isError ?? this.isError,
|
||||||
|
searchResult: searchResult ?? this.searchResult,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toMap() {
|
||||||
|
return {
|
||||||
|
'isLoading': isLoading,
|
||||||
|
'isSuccess': isSuccess,
|
||||||
|
'isError': isError,
|
||||||
|
'searchResult': searchResult.map((x) => x.toMap()).toList(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
factory SearchresultPageState.fromMap(Map<String, dynamic> map) {
|
||||||
|
return SearchresultPageState(
|
||||||
|
isLoading: map['isLoading'] ?? false,
|
||||||
|
isSuccess: map['isSuccess'] ?? false,
|
||||||
|
isError: map['isError'] ?? false,
|
||||||
|
searchResult: List<ImmichAsset>.from(map['searchResult']?.map((x) => ImmichAsset.fromMap(x))),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String toJson() => json.encode(toMap());
|
||||||
|
|
||||||
|
factory SearchresultPageState.fromJson(String source) => SearchresultPageState.fromMap(json.decode(source));
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'SearchresultPageState(isLoading: $isLoading, isSuccess: $isSuccess, isError: $isError, searchResult: $searchResult)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
if (identical(this, other)) return true;
|
||||||
|
final listEquals = const DeepCollectionEquality().equals;
|
||||||
|
|
||||||
|
return other is SearchresultPageState &&
|
||||||
|
other.isLoading == isLoading &&
|
||||||
|
other.isSuccess == isSuccess &&
|
||||||
|
other.isError == isError &&
|
||||||
|
listEquals(other.searchResult, searchResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode {
|
||||||
|
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)));
|
||||||
|
});
|
|
@ -1,6 +1,7 @@
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:immich_mobile/shared/models/immich_asset.model.dart';
|
||||||
import 'package:immich_mobile/shared/services/network.service.dart';
|
import 'package:immich_mobile/shared/services/network.service.dart';
|
||||||
|
|
||||||
class SearchService {
|
class SearchService {
|
||||||
|
@ -17,4 +18,22 @@ class SearchService {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<List<ImmichAsset>?> searchAsset(String searchTerm) async {
|
||||||
|
try {
|
||||||
|
var res = await _networkService.postRequest(
|
||||||
|
url: "asset/search",
|
||||||
|
data: {"searchTerm": searchTerm},
|
||||||
|
);
|
||||||
|
|
||||||
|
List<dynamic> decodedData = jsonDecode(res.toString());
|
||||||
|
|
||||||
|
List<ImmichAsset> result = List.from(decodedData.map((a) => ImmichAsset.fromMap(a)));
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint("[ERROR] [searchAsset] ${e.toString()}");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,8 +4,10 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/modules/search/providers/search_page_state.provider.dart';
|
import 'package:immich_mobile/modules/search/providers/search_page_state.provider.dart';
|
||||||
|
|
||||||
class SearchBar extends HookConsumerWidget with PreferredSizeWidget {
|
class SearchBar extends HookConsumerWidget with PreferredSizeWidget {
|
||||||
SearchBar({Key? key, required this.searchFocusNode}) : super(key: key);
|
SearchBar({Key? key, required this.searchFocusNode, required this.onSubmitted}) : super(key: key);
|
||||||
FocusNode searchFocusNode;
|
|
||||||
|
final FocusNode searchFocusNode;
|
||||||
|
final Function(String) onSubmitted;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
@ -19,6 +21,7 @@ class SearchBar extends HookConsumerWidget with PreferredSizeWidget {
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
searchFocusNode.unfocus();
|
searchFocusNode.unfocus();
|
||||||
ref.watch(searchPageStateProvider.notifier).disableSearch();
|
ref.watch(searchPageStateProvider.notifier).disableSearch();
|
||||||
|
searchTermController.clear();
|
||||||
},
|
},
|
||||||
icon: const Icon(Icons.arrow_back_ios_rounded))
|
icon: const Icon(Icons.arrow_back_ios_rounded))
|
||||||
: const Icon(Icons.search_rounded),
|
: const Icon(Icons.search_rounded),
|
||||||
|
@ -27,13 +30,17 @@ class SearchBar extends HookConsumerWidget with PreferredSizeWidget {
|
||||||
focusNode: searchFocusNode,
|
focusNode: searchFocusNode,
|
||||||
autofocus: false,
|
autofocus: false,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
|
searchTermController.clear();
|
||||||
ref.watch(searchPageStateProvider.notifier).getSuggestedSearchTerms();
|
ref.watch(searchPageStateProvider.notifier).getSuggestedSearchTerms();
|
||||||
ref.watch(searchPageStateProvider.notifier).enableSearch();
|
ref.watch(searchPageStateProvider.notifier).enableSearch();
|
||||||
|
ref.watch(searchPageStateProvider.notifier).setSearchTerm("");
|
||||||
|
|
||||||
searchFocusNode.requestFocus();
|
searchFocusNode.requestFocus();
|
||||||
},
|
},
|
||||||
onSubmitted: (searchTerm) {
|
onSubmitted: (searchTerm) {
|
||||||
ref.watch(searchPageStateProvider.notifier).disableSearch();
|
onSubmitted(searchTerm);
|
||||||
searchFocusNode.unfocus();
|
searchTermController.clear();
|
||||||
|
ref.watch(searchPageStateProvider.notifier).setSearchTerm("");
|
||||||
},
|
},
|
||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
ref.watch(searchPageStateProvider.notifier).setSearchTerm(value);
|
ref.watch(searchPageStateProvider.notifier).setSearchTerm(value);
|
||||||
|
|
|
@ -3,8 +3,9 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/modules/search/providers/search_page_state.provider.dart';
|
import 'package:immich_mobile/modules/search/providers/search_page_state.provider.dart';
|
||||||
|
|
||||||
class SearchSuggestionList extends ConsumerWidget {
|
class SearchSuggestionList extends ConsumerWidget {
|
||||||
const SearchSuggestionList({Key? key}) : super(key: key);
|
const SearchSuggestionList({Key? key, required this.onSubmitted}) : super(key: key);
|
||||||
|
|
||||||
|
final Function(String) onSubmitted;
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final searchTerm = ref.watch(searchPageStateProvider).searchTerm;
|
final searchTerm = ref.watch(searchPageStateProvider).searchTerm;
|
||||||
|
@ -20,7 +21,7 @@ class SearchSuggestionList extends ConsumerWidget {
|
||||||
itemBuilder: ((context, index) {
|
itemBuilder: ((context, index) {
|
||||||
return ListTile(
|
return ListTile(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
print("navigate to this search result: ${searchSuggestion[index]} ");
|
onSubmitted(searchSuggestion[index]);
|
||||||
},
|
},
|
||||||
title: Text(searchSuggestion[index]),
|
title: Text(searchSuggestion[index]),
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,9 +1,11 @@
|
||||||
|
import 'package:auto_route/auto_route.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/modules/search/providers/search_page_state.provider.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_bar.dart';
|
||||||
import 'package:immich_mobile/modules/search/ui/search_suggestion_list.dart';
|
import 'package:immich_mobile/modules/search/ui/search_suggestion_list.dart';
|
||||||
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
|
|
||||||
// ignore: must_be_immutable
|
// ignore: must_be_immutable
|
||||||
class SearchPage extends HookConsumerWidget {
|
class SearchPage extends HookConsumerWidget {
|
||||||
|
@ -16,13 +18,22 @@ class SearchPage extends HookConsumerWidget {
|
||||||
final isSearchEnabled = ref.watch(searchPageStateProvider).isSearchEnabled;
|
final isSearchEnabled = ref.watch(searchPageStateProvider).isSearchEnabled;
|
||||||
|
|
||||||
useEffect(() {
|
useEffect(() {
|
||||||
print("search");
|
|
||||||
searchFocusNode = FocusNode();
|
searchFocusNode = FocusNode();
|
||||||
return () => searchFocusNode.dispose();
|
return () => searchFocusNode.dispose();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
_onSearchSubmitted(String searchTerm) async {
|
||||||
|
searchFocusNode.unfocus();
|
||||||
|
ref.watch(searchPageStateProvider.notifier).disableSearch();
|
||||||
|
|
||||||
|
AutoRouter.of(context).push(SearchResultRoute(searchTerm: searchTerm));
|
||||||
|
}
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: SearchBar(searchFocusNode: searchFocusNode),
|
appBar: SearchBar(
|
||||||
|
searchFocusNode: searchFocusNode,
|
||||||
|
onSubmitted: _onSearchSubmitted,
|
||||||
|
),
|
||||||
body: GestureDetector(
|
body: GestureDetector(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
searchFocusNode.unfocus();
|
searchFocusNode.unfocus();
|
||||||
|
@ -58,7 +69,7 @@ class SearchPage extends HookConsumerWidget {
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
isSearchEnabled ? const SearchSuggestionList() : Container(),
|
isSearchEnabled ? SearchSuggestionList(onSubmitted: _onSearchSubmitted) : Container(),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
197
mobile/lib/modules/search/views/search_result_page.dart
Normal file
197
mobile/lib/modules/search/views/search_result_page.dart
Normal file
|
@ -0,0 +1,197 @@
|
||||||
|
import 'package:auto_route/auto_route.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/modules/home/ui/daily_title_text.dart';
|
||||||
|
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/ui/search_suggestion_list.dart';
|
||||||
|
|
||||||
|
class SearchResultPage extends HookConsumerWidget {
|
||||||
|
SearchResultPage({Key? key, required this.searchTerm}) : super(key: key);
|
||||||
|
|
||||||
|
final String searchTerm;
|
||||||
|
late FocusNode searchFocusNode;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
ScrollController _scrollController = useScrollController();
|
||||||
|
final searchTermController = useTextEditingController(text: "");
|
||||||
|
final isNewSearch = useState(false);
|
||||||
|
final currentSearchTerm = useState(searchTerm);
|
||||||
|
|
||||||
|
List<Widget> _imageGridGroup = [];
|
||||||
|
|
||||||
|
useEffect(() {
|
||||||
|
searchFocusNode = FocusNode();
|
||||||
|
|
||||||
|
Future.delayed(Duration.zero, () => ref.read(searchResultPageStateProvider.notifier).search(searchTerm));
|
||||||
|
return () => searchFocusNode.dispose();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
_onSearchSubmitted(String newSearchTerm) {
|
||||||
|
debugPrint("Re-Search with $newSearchTerm");
|
||||||
|
searchFocusNode.unfocus();
|
||||||
|
isNewSearch.value = false;
|
||||||
|
currentSearchTerm.value = newSearchTerm;
|
||||||
|
ref.watch(searchResultPageStateProvider.notifier).search(newSearchTerm);
|
||||||
|
}
|
||||||
|
|
||||||
|
_buildTextField() {
|
||||||
|
return TextField(
|
||||||
|
controller: searchTermController,
|
||||||
|
focusNode: searchFocusNode,
|
||||||
|
autofocus: false,
|
||||||
|
onTap: () {
|
||||||
|
searchTermController.clear();
|
||||||
|
ref.watch(searchPageStateProvider.notifier).setSearchTerm("");
|
||||||
|
searchFocusNode.requestFocus();
|
||||||
|
},
|
||||||
|
textInputAction: TextInputAction.search,
|
||||||
|
onSubmitted: (searchTerm) {
|
||||||
|
if (searchTerm.isNotEmpty) {
|
||||||
|
searchTermController.clear();
|
||||||
|
_onSearchSubmitted(searchTerm);
|
||||||
|
} else {
|
||||||
|
isNewSearch.value = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onChanged: (value) {
|
||||||
|
ref.watch(searchPageStateProvider.notifier).setSearchTerm(value);
|
||||||
|
},
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
hintText: 'New Search',
|
||||||
|
enabledBorder: UnderlineInputBorder(
|
||||||
|
borderSide: BorderSide(color: Colors.transparent),
|
||||||
|
),
|
||||||
|
focusedBorder: UnderlineInputBorder(
|
||||||
|
borderSide: BorderSide(color: Colors.transparent),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
_buildChip() {
|
||||||
|
return Chip(
|
||||||
|
label: Wrap(
|
||||||
|
spacing: 5,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 2.0),
|
||||||
|
child: Text(
|
||||||
|
currentSearchTerm.value,
|
||||||
|
style: TextStyle(color: Theme.of(context).primaryColor),
|
||||||
|
maxLines: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Icon(
|
||||||
|
Icons.close_rounded,
|
||||||
|
color: Theme.of(context).primaryColor,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
backgroundColor: Theme.of(context).primaryColor.withAlpha(50),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
_buildSearchResult() {
|
||||||
|
var searchResultPageState = ref.watch(searchResultPageStateProvider);
|
||||||
|
var assetGroupByDateTime = ref.watch(searchResultGroupByDateTimeProvider);
|
||||||
|
|
||||||
|
if (searchResultPageState.isError) {
|
||||||
|
return const Text("Error");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (searchResultPageState.isLoading) {
|
||||||
|
return const CircularProgressIndicator.adaptive();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (searchResultPageState.isSuccess) {
|
||||||
|
if (searchResultPageState.searchResult.isNotEmpty) {
|
||||||
|
int? lastMonth;
|
||||||
|
|
||||||
|
assetGroupByDateTime.forEach((dateGroup, immichAssetList) {
|
||||||
|
DateTime parseDateGroup = DateTime.parse(dateGroup);
|
||||||
|
int currentMonth = parseDateGroup.month;
|
||||||
|
|
||||||
|
if (lastMonth != null) {
|
||||||
|
if (currentMonth - lastMonth! != 0) {
|
||||||
|
_imageGridGroup.add(
|
||||||
|
MonthlyTitleText(
|
||||||
|
isoDate: dateGroup,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_imageGridGroup.add(
|
||||||
|
DailyTitleText(
|
||||||
|
isoDate: dateGroup,
|
||||||
|
assetGroup: immichAssetList,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
_imageGridGroup.add(
|
||||||
|
ImageGrid(assetGroup: immichAssetList),
|
||||||
|
);
|
||||||
|
|
||||||
|
lastMonth = currentMonth;
|
||||||
|
});
|
||||||
|
|
||||||
|
return DraggableScrollbar.semicircle(
|
||||||
|
backgroundColor: Theme.of(context).primaryColor,
|
||||||
|
controller: _scrollController,
|
||||||
|
heightScrollThumb: 48.0,
|
||||||
|
child: CustomScrollView(
|
||||||
|
controller: _scrollController,
|
||||||
|
slivers: [..._imageGridGroup],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return const Text("No assets found");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Container();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
leading: IconButton(
|
||||||
|
splashRadius: 20,
|
||||||
|
onPressed: () {
|
||||||
|
if (isNewSearch.value) {
|
||||||
|
isNewSearch.value = false;
|
||||||
|
} else {
|
||||||
|
AutoRouter.of(context).pop(true);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.arrow_back_ios_rounded),
|
||||||
|
),
|
||||||
|
title: GestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
isNewSearch.value = true;
|
||||||
|
searchFocusNode.requestFocus();
|
||||||
|
},
|
||||||
|
child: isNewSearch.value ? _buildTextField() : _buildChip()),
|
||||||
|
centerTitle: false,
|
||||||
|
),
|
||||||
|
body: GestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
searchFocusNode.unfocus();
|
||||||
|
ref.watch(searchPageStateProvider.notifier).disableSearch();
|
||||||
|
},
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
_buildSearchResult(),
|
||||||
|
isNewSearch.value ? SearchSuggestionList(onSubmitted: _onSearchSubmitted) : Container(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,7 +1,6 @@
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:auto_route/auto_route.dart';
|
import 'package:auto_route/auto_route.dart';
|
||||||
import 'package:flutter/cupertino.dart';
|
|
||||||
import 'package:immich_mobile/shared/services/network.service.dart';
|
import 'package:immich_mobile/shared/services/network.service.dart';
|
||||||
|
|
||||||
class AuthGuard extends AutoRouteGuard {
|
class AuthGuard extends AutoRouteGuard {
|
||||||
|
|
|
@ -3,6 +3,7 @@ import 'package:flutter/widgets.dart';
|
||||||
import 'package:immich_mobile/modules/login/views/login_page.dart';
|
import 'package:immich_mobile/modules/login/views/login_page.dart';
|
||||||
import 'package:immich_mobile/modules/home/views/home_page.dart';
|
import 'package:immich_mobile/modules/home/views/home_page.dart';
|
||||||
import 'package:immich_mobile/modules/search/views/search_page.dart';
|
import 'package:immich_mobile/modules/search/views/search_page.dart';
|
||||||
|
import 'package:immich_mobile/modules/search/views/search_result_page.dart';
|
||||||
import 'package:immich_mobile/routing/auth_guard.dart';
|
import 'package:immich_mobile/routing/auth_guard.dart';
|
||||||
import 'package:immich_mobile/shared/models/immich_asset.model.dart';
|
import 'package:immich_mobile/shared/models/immich_asset.model.dart';
|
||||||
import 'package:immich_mobile/shared/views/backup_controller_page.dart';
|
import 'package:immich_mobile/shared/views/backup_controller_page.dart';
|
||||||
|
@ -27,6 +28,7 @@ part 'router.gr.dart';
|
||||||
AutoRoute(page: ImageViewerPage, guards: [AuthGuard]),
|
AutoRoute(page: ImageViewerPage, guards: [AuthGuard]),
|
||||||
AutoRoute(page: VideoViewerPage, guards: [AuthGuard]),
|
AutoRoute(page: VideoViewerPage, guards: [AuthGuard]),
|
||||||
AutoRoute(page: BackupControllerPage, guards: [AuthGuard]),
|
AutoRoute(page: BackupControllerPage, guards: [AuthGuard]),
|
||||||
|
AutoRoute(page: SearchResultPage, guards: [AuthGuard]),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
class AppRouter extends _$AppRouter {
|
class AppRouter extends _$AppRouter {
|
||||||
|
|
|
@ -50,6 +50,12 @@ class _$AppRouter extends RootStackRouter {
|
||||||
return MaterialPageX<dynamic>(
|
return MaterialPageX<dynamic>(
|
||||||
routeData: routeData, child: const BackupControllerPage());
|
routeData: routeData, child: const BackupControllerPage());
|
||||||
},
|
},
|
||||||
|
SearchResultRoute.name: (routeData) {
|
||||||
|
final args = routeData.argsAs<SearchResultRouteArgs>();
|
||||||
|
return MaterialPageX<dynamic>(
|
||||||
|
routeData: routeData,
|
||||||
|
child: SearchResultPage(key: args.key, searchTerm: args.searchTerm));
|
||||||
|
},
|
||||||
HomeRoute.name: (routeData) {
|
HomeRoute.name: (routeData) {
|
||||||
return MaterialPageX<dynamic>(
|
return MaterialPageX<dynamic>(
|
||||||
routeData: routeData, child: const HomePage());
|
routeData: routeData, child: const HomePage());
|
||||||
|
@ -85,7 +91,9 @@ class _$AppRouter extends RootStackRouter {
|
||||||
RouteConfig(VideoViewerRoute.name,
|
RouteConfig(VideoViewerRoute.name,
|
||||||
path: '/video-viewer-page', guards: [authGuard]),
|
path: '/video-viewer-page', guards: [authGuard]),
|
||||||
RouteConfig(BackupControllerRoute.name,
|
RouteConfig(BackupControllerRoute.name,
|
||||||
path: '/backup-controller-page', guards: [authGuard])
|
path: '/backup-controller-page', guards: [authGuard]),
|
||||||
|
RouteConfig(SearchResultRoute.name,
|
||||||
|
path: '/search-result-page', guards: [authGuard])
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -185,6 +193,30 @@ class BackupControllerRoute extends PageRouteInfo<void> {
|
||||||
static const String name = 'BackupControllerRoute';
|
static const String name = 'BackupControllerRoute';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// generated route for
|
||||||
|
/// [SearchResultPage]
|
||||||
|
class SearchResultRoute extends PageRouteInfo<SearchResultRouteArgs> {
|
||||||
|
SearchResultRoute({Key? key, required String searchTerm})
|
||||||
|
: super(SearchResultRoute.name,
|
||||||
|
path: '/search-result-page',
|
||||||
|
args: SearchResultRouteArgs(key: key, searchTerm: searchTerm));
|
||||||
|
|
||||||
|
static const String name = 'SearchResultRoute';
|
||||||
|
}
|
||||||
|
|
||||||
|
class SearchResultRouteArgs {
|
||||||
|
const SearchResultRouteArgs({this.key, required this.searchTerm});
|
||||||
|
|
||||||
|
final Key? key;
|
||||||
|
|
||||||
|
final String searchTerm;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'SearchResultRouteArgs{key: $key, searchTerm: $searchTerm}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// generated route for
|
/// generated route for
|
||||||
/// [HomePage]
|
/// [HomePage]
|
||||||
class HomeRoute extends PageRouteInfo<void> {
|
class HomeRoute extends PageRouteInfo<void> {
|
||||||
|
|
|
@ -28,6 +28,7 @@ import { Response as Res } from 'express';
|
||||||
import { GetNewAssetQueryDto } from './dto/get-new-asset-query.dto';
|
import { GetNewAssetQueryDto } from './dto/get-new-asset-query.dto';
|
||||||
import { BackgroundTaskService } from '../../modules/background-task/background-task.service';
|
import { BackgroundTaskService } from '../../modules/background-task/background-task.service';
|
||||||
import { DeleteAssetDto } from './dto/delete-asset.dto';
|
import { DeleteAssetDto } from './dto/delete-asset.dto';
|
||||||
|
import { SearchAssetDto } from './dto/search-asset.dto';
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Controller('asset')
|
@Controller('asset')
|
||||||
|
@ -76,6 +77,11 @@ export class AssetController {
|
||||||
return this.assetService.getAssetSearchTerm(authUser);
|
return this.assetService.getAssetSearchTerm(authUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('/search')
|
||||||
|
async searchAsset(@GetAuthUser() authUser: AuthUserDto, @Body(ValidationPipe) searchAssetDto: SearchAssetDto) {
|
||||||
|
return this.assetService.searchAsset(authUser, searchAssetDto);
|
||||||
|
}
|
||||||
|
|
||||||
@Get('/new')
|
@Get('/new')
|
||||||
async getNewAssets(@GetAuthUser() authUser: AuthUserDto, @Query(ValidationPipe) query: GetNewAssetQueryDto) {
|
async getNewAssets(@GetAuthUser() authUser: AuthUserDto, @Query(ValidationPipe) query: GetNewAssetQueryDto) {
|
||||||
return await this.assetService.getNewAssets(authUser, query.latestDate);
|
return await this.assetService.getNewAssets(authUser, query.latestDate);
|
||||||
|
|
|
@ -13,6 +13,7 @@ import { ServeFileDto } from './dto/serve-file.dto';
|
||||||
import { Response as Res } from 'express';
|
import { Response as Res } from 'express';
|
||||||
import { promisify } from 'util';
|
import { promisify } from 'util';
|
||||||
import { DeleteAssetDto } from './dto/delete-asset.dto';
|
import { DeleteAssetDto } from './dto/delete-asset.dto';
|
||||||
|
import { SearchAssetDto } from './dto/search-asset.dto';
|
||||||
|
|
||||||
const fileInfo = promisify(stat);
|
const fileInfo = promisify(stat);
|
||||||
|
|
||||||
|
@ -277,4 +278,24 @@ export class AssetService {
|
||||||
|
|
||||||
return Array.from(possibleSearchTerm).filter((x) => x != null);
|
return Array.from(possibleSearchTerm).filter((x) => x != null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async searchAsset(authUser: AuthUserDto, searchAssetDto: SearchAssetDto) {
|
||||||
|
const query = `
|
||||||
|
SELECT a.*
|
||||||
|
FROM assets a
|
||||||
|
LEFT JOIN smart_info si ON a.id = si."assetId"
|
||||||
|
LEFT JOIN exif e ON a.id = e."assetId"
|
||||||
|
|
||||||
|
WHERE a."userId" = $1
|
||||||
|
AND
|
||||||
|
(
|
||||||
|
TO_TSVECTOR('english', ARRAY_TO_STRING(si.tags, ',')) @@ PLAINTO_TSQUERY('english', $2) OR
|
||||||
|
e.exif_text_searchable_column @@ PLAINTO_TSQUERY('english', $2)
|
||||||
|
);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const rows = await this.assetRepository.query(query, [authUser.id, searchAssetDto.searchTerm]);
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
6
server/src/api-v1/asset/dto/search-asset.dto.ts
Normal file
6
server/src/api-v1/asset/dto/search-asset.dto.ts
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
import { IsNotEmpty } from 'class-validator';
|
||||||
|
|
||||||
|
export class SearchAssetDto {
|
||||||
|
@IsNotEmpty()
|
||||||
|
searchTerm: string;
|
||||||
|
}
|
|
@ -0,0 +1,25 @@
|
||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class AddExifTextSearchColumn1646249209023 implements MigrationInterface {
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE exif
|
||||||
|
ADD COLUMN IF NOT EXISTS exif_text_searchable_column tsvector
|
||||||
|
GENERATED ALWAYS AS (
|
||||||
|
TO_TSVECTOR('english',
|
||||||
|
COALESCE(make, '') || ' ' ||
|
||||||
|
COALESCE(model, '') || ' ' ||
|
||||||
|
COALESCE(orientation, '') || ' ' ||
|
||||||
|
COALESCE("lensModel", '')
|
||||||
|
)
|
||||||
|
) STORED;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE exif
|
||||||
|
DROP COLUMN IF EXISTS exif_text_searchable_column;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,17 @@
|
||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class CreateExifTextSearchIndex1646249734844 implements MigrationInterface {
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX exif_text_searchable_idx
|
||||||
|
ON exif
|
||||||
|
USING GIN (exif_text_searchable_column);
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
DROP INDEX IF EXISTS exif_text_searchable_idx ON exif;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue