1
0
mirror of https://github.com/immich-app/immich.git synced 2024-12-25 10:43:13 +02:00

Implemented search result page (#37)

This commit is contained in:
Alex 2022-03-02 16:44:24 -06:00 committed by GitHub
parent bd34be92e6
commit 5990a28870
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 467 additions and 17 deletions

View File

@ -83,12 +83,8 @@ class ImmichSliverAppBar extends ConsumerWidget {
),
child: const Icon(Icons.backup_rounded)),
tooltip: 'Backup Controller',
onPressed: () async {
var onPop = await AutoRouter.of(context).push(const BackupControllerRoute());
if (onPop == true) {
onPopBack!();
}
onPressed: () {
AutoRouter.of(context).push(const BackupControllerRoute());
},
),
_backupState.backupProgress == BackUpProgressEnum.inProgress

View File

@ -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)));
});

View File

@ -1,6 +1,7 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:immich_mobile/shared/models/immich_asset.model.dart';
import 'package:immich_mobile/shared/services/network.service.dart';
class SearchService {
@ -17,4 +18,22 @@ class SearchService {
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;
}
}
}

View File

@ -4,8 +4,10 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/search/providers/search_page_state.provider.dart';
class SearchBar extends HookConsumerWidget with PreferredSizeWidget {
SearchBar({Key? key, required this.searchFocusNode}) : super(key: key);
FocusNode searchFocusNode;
SearchBar({Key? key, required this.searchFocusNode, required this.onSubmitted}) : super(key: key);
final FocusNode searchFocusNode;
final Function(String) onSubmitted;
@override
Widget build(BuildContext context, WidgetRef ref) {
@ -19,6 +21,7 @@ class SearchBar extends HookConsumerWidget with PreferredSizeWidget {
onPressed: () {
searchFocusNode.unfocus();
ref.watch(searchPageStateProvider.notifier).disableSearch();
searchTermController.clear();
},
icon: const Icon(Icons.arrow_back_ios_rounded))
: const Icon(Icons.search_rounded),
@ -27,13 +30,17 @@ class SearchBar extends HookConsumerWidget with PreferredSizeWidget {
focusNode: searchFocusNode,
autofocus: false,
onTap: () {
searchTermController.clear();
ref.watch(searchPageStateProvider.notifier).getSuggestedSearchTerms();
ref.watch(searchPageStateProvider.notifier).enableSearch();
ref.watch(searchPageStateProvider.notifier).setSearchTerm("");
searchFocusNode.requestFocus();
},
onSubmitted: (searchTerm) {
ref.watch(searchPageStateProvider.notifier).disableSearch();
searchFocusNode.unfocus();
onSubmitted(searchTerm);
searchTermController.clear();
ref.watch(searchPageStateProvider.notifier).setSearchTerm("");
},
onChanged: (value) {
ref.watch(searchPageStateProvider.notifier).setSearchTerm(value);

View File

@ -3,8 +3,9 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/search/providers/search_page_state.provider.dart';
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
Widget build(BuildContext context, WidgetRef ref) {
final searchTerm = ref.watch(searchPageStateProvider).searchTerm;
@ -20,7 +21,7 @@ class SearchSuggestionList extends ConsumerWidget {
itemBuilder: ((context, index) {
return ListTile(
onTap: () {
print("navigate to this search result: ${searchSuggestion[index]} ");
onSubmitted(searchSuggestion[index]);
},
title: Text(searchSuggestion[index]),
);

View File

@ -1,9 +1,11 @@
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/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';
import 'package:immich_mobile/routing/router.dart';
// ignore: must_be_immutable
class SearchPage extends HookConsumerWidget {
@ -16,13 +18,22 @@ class SearchPage extends HookConsumerWidget {
final isSearchEnabled = ref.watch(searchPageStateProvider).isSearchEnabled;
useEffect(() {
print("search");
searchFocusNode = FocusNode();
return () => searchFocusNode.dispose();
}, []);
_onSearchSubmitted(String searchTerm) async {
searchFocusNode.unfocus();
ref.watch(searchPageStateProvider.notifier).disableSearch();
AutoRouter.of(context).push(SearchResultRoute(searchTerm: searchTerm));
}
return Scaffold(
appBar: SearchBar(searchFocusNode: searchFocusNode),
appBar: SearchBar(
searchFocusNode: searchFocusNode,
onSubmitted: _onSearchSubmitted,
),
body: GestureDetector(
onTap: () {
searchFocusNode.unfocus();
@ -58,7 +69,7 @@ class SearchPage extends HookConsumerWidget {
),
],
),
isSearchEnabled ? const SearchSuggestionList() : Container(),
isSearchEnabled ? SearchSuggestionList(onSubmitted: _onSearchSubmitted) : Container(),
],
),
),

View 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(),
],
),
),
);
}
}

View File

@ -1,7 +1,6 @@
import 'dart:convert';
import 'package:auto_route/auto_route.dart';
import 'package:flutter/cupertino.dart';
import 'package:immich_mobile/shared/services/network.service.dart';
class AuthGuard extends AutoRouteGuard {

View File

@ -3,6 +3,7 @@ import 'package:flutter/widgets.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/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/shared/models/immich_asset.model.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: VideoViewerPage, guards: [AuthGuard]),
AutoRoute(page: BackupControllerPage, guards: [AuthGuard]),
AutoRoute(page: SearchResultPage, guards: [AuthGuard]),
],
)
class AppRouter extends _$AppRouter {

View File

@ -50,6 +50,12 @@ class _$AppRouter extends RootStackRouter {
return MaterialPageX<dynamic>(
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) {
return MaterialPageX<dynamic>(
routeData: routeData, child: const HomePage());
@ -85,7 +91,9 @@ class _$AppRouter extends RootStackRouter {
RouteConfig(VideoViewerRoute.name,
path: '/video-viewer-page', guards: [authGuard]),
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';
}
/// 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
/// [HomePage]
class HomeRoute extends PageRouteInfo<void> {

View File

@ -28,6 +28,7 @@ import { Response as Res } from 'express';
import { GetNewAssetQueryDto } from './dto/get-new-asset-query.dto';
import { BackgroundTaskService } from '../../modules/background-task/background-task.service';
import { DeleteAssetDto } from './dto/delete-asset.dto';
import { SearchAssetDto } from './dto/search-asset.dto';
@UseGuards(JwtAuthGuard)
@Controller('asset')
@ -76,6 +77,11 @@ export class AssetController {
return this.assetService.getAssetSearchTerm(authUser);
}
@Post('/search')
async searchAsset(@GetAuthUser() authUser: AuthUserDto, @Body(ValidationPipe) searchAssetDto: SearchAssetDto) {
return this.assetService.searchAsset(authUser, searchAssetDto);
}
@Get('/new')
async getNewAssets(@GetAuthUser() authUser: AuthUserDto, @Query(ValidationPipe) query: GetNewAssetQueryDto) {
return await this.assetService.getNewAssets(authUser, query.latestDate);

View File

@ -13,6 +13,7 @@ import { ServeFileDto } from './dto/serve-file.dto';
import { Response as Res } from 'express';
import { promisify } from 'util';
import { DeleteAssetDto } from './dto/delete-asset.dto';
import { SearchAssetDto } from './dto/search-asset.dto';
const fileInfo = promisify(stat);
@ -277,4 +278,24 @@ export class AssetService {
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;
}
}

View File

@ -0,0 +1,6 @@
import { IsNotEmpty } from 'class-validator';
export class SearchAssetDto {
@IsNotEmpty()
searchTerm: string;
}

View File

@ -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;
`);
}
}

View File

@ -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;
`);
}
}