mirror of https://github.com/immich-app/immich.git
parent
bd34be92e6
commit
5990a28870
@ -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)));
|
||||
});
|
||||
@ -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(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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 New Issue