mirror of
https://github.com/immich-app/immich.git
synced 2025-07-31 15:08:44 -04:00
feat(mobile): drift search page (#19811)
* feat(mobile): drift search page * migrate to drift page --------- Co-authored-by: Alex <alex.tran1502@gmail.com>
This commit is contained in:
parent
2046dcc5b4
commit
8491fe459d
@ -106,6 +106,7 @@ custom_lint:
|
||||
- lib/utils/{image_url_builder,openapi_patching}.dart # utils are fine
|
||||
- test/modules/utils/openapi_patching_test.dart # filename is self-explanatory...
|
||||
- lib/domain/services/sync_stream.service.dart # Making sure to comply with the type from database
|
||||
- lib/domain/services/search.service.dart
|
||||
|
||||
# refactor
|
||||
- lib/models/map/map_marker.model.dart
|
||||
|
38
mobile/lib/domain/models/search_result.model.dart
Normal file
38
mobile/lib/domain/models/search_result.model.dart
Normal file
@ -0,0 +1,38 @@
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
|
||||
class SearchResult {
|
||||
final List<BaseAsset> assets;
|
||||
final int? nextPage;
|
||||
|
||||
const SearchResult({
|
||||
required this.assets,
|
||||
this.nextPage,
|
||||
});
|
||||
|
||||
int get totalAssets => assets.length;
|
||||
|
||||
SearchResult copyWith({
|
||||
List<BaseAsset>? assets,
|
||||
int? nextPage,
|
||||
}) {
|
||||
return SearchResult(
|
||||
assets: assets ?? this.assets,
|
||||
nextPage: nextPage ?? this.nextPage,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() => 'SearchResult(assets: $assets, nextPage: $nextPage)';
|
||||
|
||||
@override
|
||||
bool operator ==(covariant SearchResult other) {
|
||||
if (identical(this, other)) return true;
|
||||
final listEquals = const DeepCollectionEquality().equals;
|
||||
|
||||
return listEquals(other.assets, assets) && other.nextPage == nextPage;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => assets.hashCode ^ nextPage.hashCode;
|
||||
}
|
92
mobile/lib/domain/services/search.service.dart
Normal file
92
mobile/lib/domain/services/search.service.dart
Normal file
@ -0,0 +1,92 @@
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/search_result.model.dart';
|
||||
import 'package:immich_mobile/extensions/string_extensions.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/search_api.repository.dart';
|
||||
import 'package:immich_mobile/models/search/search_filter.model.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:openapi/api.dart' as api show AssetVisibility;
|
||||
import 'package:openapi/api.dart' hide AssetVisibility;
|
||||
|
||||
class SearchService {
|
||||
final _log = Logger("SearchService");
|
||||
final SearchApiRepository _searchApiRepository;
|
||||
|
||||
SearchService(this._searchApiRepository);
|
||||
|
||||
Future<List<String>?> getSearchSuggestions(
|
||||
SearchSuggestionType type, {
|
||||
String? country,
|
||||
String? state,
|
||||
String? make,
|
||||
String? model,
|
||||
}) async {
|
||||
try {
|
||||
return await _searchApiRepository.getSearchSuggestions(
|
||||
type,
|
||||
country: country,
|
||||
state: state,
|
||||
make: make,
|
||||
model: model,
|
||||
);
|
||||
} catch (e) {
|
||||
_log.warning("Failed to get search suggestions", e);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
Future<SearchResult?> search(SearchFilter filter, int page) async {
|
||||
try {
|
||||
final response = await _searchApiRepository.search(filter, page);
|
||||
|
||||
if (response == null || response.assets.items.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return SearchResult(
|
||||
assets: response.assets.items.map((e) => e.toDto()).toList(),
|
||||
nextPage: response.assets.nextPage?.toInt(),
|
||||
);
|
||||
} catch (error, stackTrace) {
|
||||
_log.severe("Failed to search for assets", error, stackTrace);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
extension on AssetResponseDto {
|
||||
RemoteAsset toDto() {
|
||||
return RemoteAsset(
|
||||
id: id,
|
||||
name: originalFileName,
|
||||
checksum: checksum,
|
||||
createdAt: fileCreatedAt,
|
||||
updatedAt: updatedAt,
|
||||
ownerId: ownerId,
|
||||
visibility: switch (visibility) {
|
||||
api.AssetVisibility.timeline => AssetVisibility.timeline,
|
||||
api.AssetVisibility.hidden => AssetVisibility.hidden,
|
||||
api.AssetVisibility.archive => AssetVisibility.archive,
|
||||
api.AssetVisibility.locked => AssetVisibility.locked,
|
||||
_ => AssetVisibility.timeline,
|
||||
},
|
||||
durationInSeconds: duration.toDuration()?.inSeconds ?? 0,
|
||||
height: exifInfo?.exifImageHeight?.toInt(),
|
||||
width: exifInfo?.exifImageWidth?.toInt(),
|
||||
isFavorite: isFavorite,
|
||||
livePhotoVideoId: livePhotoVideoId,
|
||||
thumbHash: thumbhash,
|
||||
localId: null,
|
||||
type: type.toAssetType(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
extension on AssetTypeEnum {
|
||||
AssetType toAssetType() => switch (this) {
|
||||
AssetTypeEnum.IMAGE => AssetType.image,
|
||||
AssetTypeEnum.VIDEO => AssetType.video,
|
||||
AssetTypeEnum.AUDIO => AssetType.audio,
|
||||
AssetTypeEnum.OTHER => AssetType.other,
|
||||
_ => throw Exception('Unknown AssetType value: $this'),
|
||||
};
|
||||
}
|
@ -65,6 +65,9 @@ class TimelineFactory {
|
||||
|
||||
TimelineService place(String place) =>
|
||||
TimelineService(_timelineRepository.place(place, groupBy));
|
||||
|
||||
TimelineService fromAssets(List<BaseAsset> assets) =>
|
||||
TimelineService(_timelineRepository.fromAssets(assets));
|
||||
}
|
||||
|
||||
class TimelineService {
|
||||
|
@ -0,0 +1,87 @@
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'
|
||||
hide AssetVisibility;
|
||||
import 'package:immich_mobile/infrastructure/repositories/api.repository.dart';
|
||||
import 'package:immich_mobile/models/search/search_filter.model.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
class SearchApiRepository extends ApiRepository {
|
||||
final SearchApi _api;
|
||||
const SearchApiRepository(this._api);
|
||||
|
||||
Future<SearchResponseDto?> search(SearchFilter filter, int page) {
|
||||
AssetTypeEnum? type;
|
||||
if (filter.mediaType.index == AssetType.image.index) {
|
||||
type = AssetTypeEnum.IMAGE;
|
||||
} else if (filter.mediaType.index == AssetType.video.index) {
|
||||
type = AssetTypeEnum.VIDEO;
|
||||
}
|
||||
|
||||
if (filter.context != null && filter.context!.isNotEmpty) {
|
||||
return _api.searchSmart(
|
||||
SmartSearchDto(
|
||||
query: filter.context!,
|
||||
language: filter.language,
|
||||
country: filter.location.country,
|
||||
state: filter.location.state,
|
||||
city: filter.location.city,
|
||||
make: filter.camera.make,
|
||||
model: filter.camera.model,
|
||||
takenAfter: filter.date.takenAfter,
|
||||
takenBefore: filter.date.takenBefore,
|
||||
visibility: filter.display.isArchive
|
||||
? AssetVisibility.archive
|
||||
: AssetVisibility.timeline,
|
||||
isFavorite: filter.display.isFavorite ? true : null,
|
||||
isNotInAlbum: filter.display.isNotInAlbum ? true : null,
|
||||
personIds: filter.people.map((e) => e.id).toList(),
|
||||
type: type,
|
||||
page: page,
|
||||
size: 1000,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return _api.searchAssets(
|
||||
MetadataSearchDto(
|
||||
originalFileName: filter.filename != null && filter.filename!.isNotEmpty
|
||||
? filter.filename
|
||||
: null,
|
||||
country: filter.location.country,
|
||||
description:
|
||||
filter.description != null && filter.description!.isNotEmpty
|
||||
? filter.description
|
||||
: null,
|
||||
state: filter.location.state,
|
||||
city: filter.location.city,
|
||||
make: filter.camera.make,
|
||||
model: filter.camera.model,
|
||||
takenAfter: filter.date.takenAfter,
|
||||
takenBefore: filter.date.takenBefore,
|
||||
visibility: filter.display.isArchive
|
||||
? AssetVisibility.archive
|
||||
: AssetVisibility.timeline,
|
||||
isFavorite: filter.display.isFavorite ? true : null,
|
||||
isNotInAlbum: filter.display.isNotInAlbum ? true : null,
|
||||
personIds: filter.people.map((e) => e.id).toList(),
|
||||
type: type,
|
||||
page: page,
|
||||
size: 1000,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<String>?> getSearchSuggestions(
|
||||
SearchSuggestionType type, {
|
||||
String? country,
|
||||
String? state,
|
||||
String? make,
|
||||
String? model,
|
||||
}) =>
|
||||
_api.getSearchSuggestions(
|
||||
type,
|
||||
country: country,
|
||||
state: state,
|
||||
make: make,
|
||||
model: model,
|
||||
);
|
||||
}
|
@ -302,6 +302,12 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
|
||||
.get();
|
||||
}
|
||||
|
||||
TimelineQuery fromAssets(List<BaseAsset> assets) => (
|
||||
bucketSource: () => Stream.value(_generateBuckets(assets.length)),
|
||||
assetSource: (offset, count) =>
|
||||
Future.value(assets.skip(offset).take(count).toList()),
|
||||
);
|
||||
|
||||
TimelineQuery remote(String ownerId, GroupAssetsBy groupBy) =>
|
||||
_remoteQueryBuilder(
|
||||
filter: (row) =>
|
||||
|
@ -133,7 +133,7 @@ class _TabShellPageState extends ConsumerState<TabShellPage> {
|
||||
return AutoTabsRouter(
|
||||
routes: [
|
||||
const MainTimelineRoute(),
|
||||
SearchRoute(),
|
||||
DriftSearchRoute(),
|
||||
const DriftAlbumsRoute(),
|
||||
const DriftLibraryRoute(),
|
||||
],
|
||||
|
@ -22,16 +22,6 @@ final _features = [
|
||||
icon: Icons.timeline_rounded,
|
||||
onTap: (ctx, _) => ctx.pushRoute(const TabShellRoute()),
|
||||
),
|
||||
_Feature(
|
||||
name: 'Video',
|
||||
icon: Icons.video_collection_outlined,
|
||||
onTap: (ctx, _) => ctx.pushRoute(const DriftVideoRoute()),
|
||||
),
|
||||
_Feature(
|
||||
name: 'Recently Taken',
|
||||
icon: Icons.schedule_outlined,
|
||||
onTap: (ctx, _) => ctx.pushRoute(const DriftRecentlyTakenRoute()),
|
||||
),
|
||||
_Feature(
|
||||
name: 'Selection Mode Timeline',
|
||||
icon: Icons.developer_mode_rounded,
|
||||
|
925
mobile/lib/presentation/pages/search/drift_search.page.dart
Normal file
925
mobile/lib/presentation/pages/search/drift_search.page.dart
Normal file
@ -0,0 +1,925 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/person.model.dart';
|
||||
import 'package:immich_mobile/domain/models/timeline.model.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/models/search/search_filter.model.dart';
|
||||
import 'package:immich_mobile/presentation/pages/search/paginated_search.provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
import 'package:immich_mobile/providers/search/search_input_focus.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/widgets/common/search_field.dart';
|
||||
import 'package:immich_mobile/widgets/search/search_filter/camera_picker.dart';
|
||||
import 'package:immich_mobile/widgets/search/search_filter/display_option_picker.dart';
|
||||
import 'package:immich_mobile/widgets/search/search_filter/filter_bottom_sheet_scaffold.dart';
|
||||
import 'package:immich_mobile/widgets/search/search_filter/location_picker.dart';
|
||||
import 'package:immich_mobile/widgets/search/search_filter/media_type_picker.dart';
|
||||
import 'package:immich_mobile/widgets/search/search_filter/people_picker.dart';
|
||||
import 'package:immich_mobile/widgets/search/search_filter/search_filter_chip.dart';
|
||||
import 'package:immich_mobile/widgets/search/search_filter/search_filter_utils.dart';
|
||||
|
||||
@RoutePage()
|
||||
class DriftSearchPage extends HookConsumerWidget {
|
||||
const DriftSearchPage({super.key, this.preFilter});
|
||||
|
||||
final SearchFilter? preFilter;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final textSearchType = useState<TextSearchType>(TextSearchType.context);
|
||||
final searchHintText =
|
||||
useState<String>('sunrise_on_the_beach'.t(context: context));
|
||||
final textSearchController = useTextEditingController();
|
||||
final filter = useState<SearchFilter>(
|
||||
SearchFilter(
|
||||
people: preFilter?.people ?? {},
|
||||
location: preFilter?.location ?? SearchLocationFilter(),
|
||||
camera: preFilter?.camera ?? SearchCameraFilter(),
|
||||
date: preFilter?.date ?? SearchDateFilter(),
|
||||
display: preFilter?.display ??
|
||||
SearchDisplayFilters(
|
||||
isNotInAlbum: false,
|
||||
isArchive: false,
|
||||
isFavorite: false,
|
||||
),
|
||||
mediaType: preFilter?.mediaType ?? AssetType.other,
|
||||
language:
|
||||
"${context.locale.languageCode}-${context.locale.countryCode}",
|
||||
),
|
||||
);
|
||||
|
||||
final previousFilter = useState<SearchFilter?>(null);
|
||||
|
||||
final peopleCurrentFilterWidget = useState<Widget?>(null);
|
||||
final dateRangeCurrentFilterWidget = useState<Widget?>(null);
|
||||
final cameraCurrentFilterWidget = useState<Widget?>(null);
|
||||
final locationCurrentFilterWidget = useState<Widget?>(null);
|
||||
final mediaTypeCurrentFilterWidget = useState<Widget?>(null);
|
||||
final displayOptionCurrentFilterWidget = useState<Widget?>(null);
|
||||
|
||||
final isSearching = useState(false);
|
||||
|
||||
SnackBar searchInfoSnackBar(String message) {
|
||||
return SnackBar(
|
||||
content: Text(
|
||||
message,
|
||||
style: context.textTheme.labelLarge,
|
||||
),
|
||||
showCloseIcon: true,
|
||||
behavior: SnackBarBehavior.fixed,
|
||||
closeIconColor: context.colorScheme.onSurface,
|
||||
);
|
||||
}
|
||||
|
||||
search() async {
|
||||
if (filter.value.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (preFilter == null && filter.value == previousFilter.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
isSearching.value = true;
|
||||
ref.watch(paginatedSearchProvider.notifier).clear();
|
||||
final hasResult = await ref
|
||||
.watch(paginatedSearchProvider.notifier)
|
||||
.search(filter.value);
|
||||
|
||||
if (!hasResult) {
|
||||
context.showSnackBar(
|
||||
searchInfoSnackBar('search_no_result'.t(context: context)),
|
||||
);
|
||||
}
|
||||
|
||||
previousFilter.value = filter.value;
|
||||
isSearching.value = false;
|
||||
}
|
||||
|
||||
loadMoreSearchResult() async {
|
||||
isSearching.value = true;
|
||||
final hasResult = await ref
|
||||
.watch(paginatedSearchProvider.notifier)
|
||||
.search(filter.value);
|
||||
|
||||
if (!hasResult) {
|
||||
context.showSnackBar(
|
||||
searchInfoSnackBar('search_no_more_result'.t(context: context)),
|
||||
);
|
||||
}
|
||||
|
||||
isSearching.value = false;
|
||||
}
|
||||
|
||||
searchPreFilter() {
|
||||
if (preFilter != null) {
|
||||
Future.delayed(
|
||||
Duration.zero,
|
||||
() {
|
||||
search();
|
||||
|
||||
if (preFilter!.location.city != null) {
|
||||
locationCurrentFilterWidget.value = Text(
|
||||
preFilter!.location.city!,
|
||||
style: context.textTheme.labelLarge,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(
|
||||
() {
|
||||
Future.microtask(
|
||||
() => ref.invalidate(paginatedSearchProvider),
|
||||
);
|
||||
searchPreFilter();
|
||||
|
||||
return null;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
showPeoplePicker() {
|
||||
handleOnSelect(Set<Person> value) {
|
||||
filter.value = filter.value.copyWith(
|
||||
people: value,
|
||||
);
|
||||
|
||||
peopleCurrentFilterWidget.value = Text(
|
||||
value
|
||||
.map((e) => e.name != '' ? e.name : 'no_name'.t(context: context))
|
||||
.join(', '),
|
||||
style: context.textTheme.labelLarge,
|
||||
);
|
||||
}
|
||||
|
||||
handleClear() {
|
||||
filter.value = filter.value.copyWith(
|
||||
people: {},
|
||||
);
|
||||
|
||||
peopleCurrentFilterWidget.value = null;
|
||||
search();
|
||||
}
|
||||
|
||||
showFilterBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
child: FractionallySizedBox(
|
||||
heightFactor: 0.8,
|
||||
child: FilterBottomSheetScaffold(
|
||||
title: 'search_filter_people_title'.t(context: context),
|
||||
expanded: true,
|
||||
onSearch: search,
|
||||
onClear: handleClear,
|
||||
child: PeoplePicker(
|
||||
onSelect: handleOnSelect,
|
||||
filter: filter.value.people,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
showLocationPicker() {
|
||||
handleOnSelect(Map<String, String?> value) {
|
||||
filter.value = filter.value.copyWith(
|
||||
location: SearchLocationFilter(
|
||||
country: value['country'],
|
||||
city: value['city'],
|
||||
state: value['state'],
|
||||
),
|
||||
);
|
||||
|
||||
final locationText = <String>[];
|
||||
if (value['country'] != null) {
|
||||
locationText.add(value['country']!);
|
||||
}
|
||||
|
||||
if (value['state'] != null) {
|
||||
locationText.add(value['state']!);
|
||||
}
|
||||
|
||||
if (value['city'] != null) {
|
||||
locationText.add(value['city']!);
|
||||
}
|
||||
|
||||
locationCurrentFilterWidget.value = Text(
|
||||
locationText.join(', '),
|
||||
style: context.textTheme.labelLarge,
|
||||
);
|
||||
}
|
||||
|
||||
handleClear() {
|
||||
filter.value = filter.value.copyWith(
|
||||
location: SearchLocationFilter(),
|
||||
);
|
||||
|
||||
locationCurrentFilterWidget.value = null;
|
||||
search();
|
||||
}
|
||||
|
||||
showFilterBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
isDismissible: true,
|
||||
child: FilterBottomSheetScaffold(
|
||||
title: 'search_filter_location_title'.t(context: context),
|
||||
onSearch: search,
|
||||
onClear: handleClear,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16.0),
|
||||
child: Container(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: context.viewInsets.bottom,
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: LocationPicker(
|
||||
onSelected: handleOnSelect,
|
||||
filter: filter.value.location,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
showCameraPicker() {
|
||||
handleOnSelect(Map<String, String?> value) {
|
||||
filter.value = filter.value.copyWith(
|
||||
camera: SearchCameraFilter(
|
||||
make: value['make'],
|
||||
model: value['model'],
|
||||
),
|
||||
);
|
||||
|
||||
cameraCurrentFilterWidget.value = Text(
|
||||
'${value['make'] ?? ''} ${value['model'] ?? ''}',
|
||||
style: context.textTheme.labelLarge,
|
||||
);
|
||||
}
|
||||
|
||||
handleClear() {
|
||||
filter.value = filter.value.copyWith(
|
||||
camera: SearchCameraFilter(),
|
||||
);
|
||||
|
||||
cameraCurrentFilterWidget.value = null;
|
||||
search();
|
||||
}
|
||||
|
||||
showFilterBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
isDismissible: true,
|
||||
child: FilterBottomSheetScaffold(
|
||||
title: 'search_filter_camera_title'.t(context: context),
|
||||
onSearch: search,
|
||||
onClear: handleClear,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: CameraPicker(
|
||||
onSelect: handleOnSelect,
|
||||
filter: filter.value.camera,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
showDatePicker() async {
|
||||
final firstDate = DateTime(1900);
|
||||
final lastDate = DateTime.now();
|
||||
|
||||
final date = await showDateRangePicker(
|
||||
context: context,
|
||||
firstDate: firstDate,
|
||||
lastDate: lastDate,
|
||||
currentDate: DateTime.now(),
|
||||
initialDateRange: DateTimeRange(
|
||||
start: filter.value.date.takenAfter ?? lastDate,
|
||||
end: filter.value.date.takenBefore ?? lastDate,
|
||||
),
|
||||
helpText: 'search_filter_date_title'.t(context: context),
|
||||
cancelText: 'cancel'.t(context: context),
|
||||
confirmText: 'select'.t(context: context),
|
||||
saveText: 'save'.t(context: context),
|
||||
errorFormatText: 'invalid_date_format'.t(context: context),
|
||||
errorInvalidText: 'invalid_date'.t(context: context),
|
||||
fieldStartHintText: 'start_date'.t(context: context),
|
||||
fieldEndHintText: 'end_date'.t(context: context),
|
||||
initialEntryMode: DatePickerEntryMode.calendar,
|
||||
keyboardType: TextInputType.text,
|
||||
);
|
||||
|
||||
if (date == null) {
|
||||
filter.value = filter.value.copyWith(
|
||||
date: SearchDateFilter(),
|
||||
);
|
||||
|
||||
dateRangeCurrentFilterWidget.value = null;
|
||||
search();
|
||||
return;
|
||||
}
|
||||
|
||||
filter.value = filter.value.copyWith(
|
||||
date: SearchDateFilter(
|
||||
takenAfter: date.start,
|
||||
takenBefore: date.end.add(
|
||||
const Duration(
|
||||
hours: 23,
|
||||
minutes: 59,
|
||||
seconds: 59,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// If date range is less than 24 hours, set the end date to the end of the day
|
||||
if (date.end.difference(date.start).inHours < 24) {
|
||||
dateRangeCurrentFilterWidget.value = Text(
|
||||
DateFormat.yMMMd().format(date.start.toLocal()),
|
||||
style: context.textTheme.labelLarge,
|
||||
);
|
||||
} else {
|
||||
dateRangeCurrentFilterWidget.value = Text(
|
||||
'search_filter_date_interval'.t(
|
||||
context: context,
|
||||
args: {
|
||||
"start": DateFormat.yMMMd().format(date.start.toLocal()),
|
||||
"end": DateFormat.yMMMd().format(date.end.toLocal()),
|
||||
},
|
||||
),
|
||||
style: context.textTheme.labelLarge,
|
||||
);
|
||||
}
|
||||
|
||||
search();
|
||||
}
|
||||
|
||||
// MEDIA PICKER
|
||||
showMediaTypePicker() {
|
||||
handleOnSelected(AssetType assetType) {
|
||||
filter.value = filter.value.copyWith(
|
||||
mediaType: assetType,
|
||||
);
|
||||
|
||||
mediaTypeCurrentFilterWidget.value = Text(
|
||||
assetType == AssetType.image
|
||||
? 'image'.t(context: context)
|
||||
: assetType == AssetType.video
|
||||
? 'video'.t(context: context)
|
||||
: 'all'.t(context: context),
|
||||
style: context.textTheme.labelLarge,
|
||||
);
|
||||
}
|
||||
|
||||
handleClear() {
|
||||
filter.value = filter.value.copyWith(
|
||||
mediaType: AssetType.other,
|
||||
);
|
||||
|
||||
mediaTypeCurrentFilterWidget.value = null;
|
||||
search();
|
||||
}
|
||||
|
||||
showFilterBottomSheet(
|
||||
context: context,
|
||||
child: FilterBottomSheetScaffold(
|
||||
title: 'search_filter_media_type_title'.t(context: context),
|
||||
onSearch: search,
|
||||
onClear: handleClear,
|
||||
child: MediaTypePicker(
|
||||
onSelect: handleOnSelected,
|
||||
filter: filter.value.mediaType,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// DISPLAY OPTION
|
||||
showDisplayOptionPicker() {
|
||||
handleOnSelect(Map<DisplayOption, bool> value) {
|
||||
final filterText = <String>[];
|
||||
value.forEach((key, value) {
|
||||
switch (key) {
|
||||
case DisplayOption.notInAlbum:
|
||||
filter.value = filter.value.copyWith(
|
||||
display: filter.value.display.copyWith(
|
||||
isNotInAlbum: value,
|
||||
),
|
||||
);
|
||||
if (value) {
|
||||
filterText.add(
|
||||
'search_filter_display_option_not_in_album'
|
||||
.t(context: context),
|
||||
);
|
||||
}
|
||||
break;
|
||||
case DisplayOption.archive:
|
||||
filter.value = filter.value.copyWith(
|
||||
display: filter.value.display.copyWith(
|
||||
isArchive: value,
|
||||
),
|
||||
);
|
||||
if (value) {
|
||||
filterText.add('archive'.t(context: context));
|
||||
}
|
||||
break;
|
||||
case DisplayOption.favorite:
|
||||
filter.value = filter.value.copyWith(
|
||||
display: filter.value.display.copyWith(
|
||||
isFavorite: value,
|
||||
),
|
||||
);
|
||||
if (value) {
|
||||
filterText.add('favorite'.t(context: context));
|
||||
}
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
if (filterText.isEmpty) {
|
||||
displayOptionCurrentFilterWidget.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
displayOptionCurrentFilterWidget.value = Text(
|
||||
filterText.join(', '),
|
||||
style: context.textTheme.labelLarge,
|
||||
);
|
||||
}
|
||||
|
||||
handleClear() {
|
||||
filter.value = filter.value.copyWith(
|
||||
display: SearchDisplayFilters(
|
||||
isNotInAlbum: false,
|
||||
isArchive: false,
|
||||
isFavorite: false,
|
||||
),
|
||||
);
|
||||
|
||||
displayOptionCurrentFilterWidget.value = null;
|
||||
search();
|
||||
}
|
||||
|
||||
showFilterBottomSheet(
|
||||
context: context,
|
||||
child: FilterBottomSheetScaffold(
|
||||
title: 'display_options'.t(context: context),
|
||||
onSearch: search,
|
||||
onClear: handleClear,
|
||||
child: DisplayOptionPicker(
|
||||
onSelect: handleOnSelect,
|
||||
filter: filter.value.display,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
handleTextSubmitted(String value) {
|
||||
switch (textSearchType.value) {
|
||||
case TextSearchType.context:
|
||||
filter.value = filter.value.copyWith(
|
||||
filename: '',
|
||||
context: value,
|
||||
description: '',
|
||||
);
|
||||
|
||||
break;
|
||||
case TextSearchType.filename:
|
||||
filter.value = filter.value.copyWith(
|
||||
filename: value,
|
||||
context: '',
|
||||
description: '',
|
||||
);
|
||||
|
||||
break;
|
||||
case TextSearchType.description:
|
||||
filter.value = filter.value.copyWith(
|
||||
filename: '',
|
||||
context: '',
|
||||
description: value,
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
search();
|
||||
}
|
||||
|
||||
IconData getSearchPrefixIcon() => switch (textSearchType.value) {
|
||||
TextSearchType.context => Icons.image_search_rounded,
|
||||
TextSearchType.filename => Icons.abc_rounded,
|
||||
TextSearchType.description => Icons.text_snippet_outlined,
|
||||
};
|
||||
|
||||
return Scaffold(
|
||||
resizeToAvoidBottomInset: false,
|
||||
appBar: AppBar(
|
||||
automaticallyImplyLeading: true,
|
||||
actions: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 16.0),
|
||||
child: MenuAnchor(
|
||||
style: MenuStyle(
|
||||
elevation: const WidgetStatePropertyAll(1),
|
||||
shape: WidgetStateProperty.all(
|
||||
const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.all(
|
||||
Radius.circular(24),
|
||||
),
|
||||
),
|
||||
),
|
||||
padding: const WidgetStatePropertyAll(
|
||||
EdgeInsets.all(4),
|
||||
),
|
||||
),
|
||||
builder: (
|
||||
BuildContext context,
|
||||
MenuController controller,
|
||||
Widget? child,
|
||||
) {
|
||||
return IconButton(
|
||||
onPressed: () {
|
||||
if (controller.isOpen) {
|
||||
controller.close();
|
||||
} else {
|
||||
controller.open();
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.more_vert_rounded),
|
||||
tooltip: 'Show text search menu',
|
||||
);
|
||||
},
|
||||
menuChildren: [
|
||||
MenuItemButton(
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.image_search_rounded),
|
||||
title: Text(
|
||||
'search_by_context'.t(context: context),
|
||||
style: context.textTheme.bodyLarge?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
color: textSearchType.value == TextSearchType.context
|
||||
? context.colorScheme.primary
|
||||
: null,
|
||||
),
|
||||
),
|
||||
selectedColor: context.colorScheme.primary,
|
||||
selected: textSearchType.value == TextSearchType.context,
|
||||
),
|
||||
onPressed: () {
|
||||
textSearchType.value = TextSearchType.context;
|
||||
searchHintText.value =
|
||||
'sunrise_on_the_beach'.t(context: context);
|
||||
},
|
||||
),
|
||||
MenuItemButton(
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.abc_rounded),
|
||||
title: Text(
|
||||
'search_filter_filename'.t(context: context),
|
||||
style: context.textTheme.bodyLarge?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
color: textSearchType.value == TextSearchType.filename
|
||||
? context.colorScheme.primary
|
||||
: null,
|
||||
),
|
||||
),
|
||||
selectedColor: context.colorScheme.primary,
|
||||
selected: textSearchType.value == TextSearchType.filename,
|
||||
),
|
||||
onPressed: () {
|
||||
textSearchType.value = TextSearchType.filename;
|
||||
searchHintText.value =
|
||||
'file_name_or_extension'.t(context: context);
|
||||
},
|
||||
),
|
||||
MenuItemButton(
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.text_snippet_outlined),
|
||||
title: Text(
|
||||
'search_by_description'.t(context: context),
|
||||
style: context.textTheme.bodyLarge?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
color:
|
||||
textSearchType.value == TextSearchType.description
|
||||
? context.colorScheme.primary
|
||||
: null,
|
||||
),
|
||||
),
|
||||
selectedColor: context.colorScheme.primary,
|
||||
selected:
|
||||
textSearchType.value == TextSearchType.description,
|
||||
),
|
||||
onPressed: () {
|
||||
textSearchType.value = TextSearchType.description;
|
||||
searchHintText.value =
|
||||
'search_by_description_example'.t(context: context);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
title: Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: context.colorScheme.onSurface.withAlpha(0),
|
||||
width: 0,
|
||||
),
|
||||
borderRadius: const BorderRadius.all(
|
||||
Radius.circular(24),
|
||||
),
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
context.colorScheme.primary.withValues(alpha: 0.075),
|
||||
context.colorScheme.primary.withValues(alpha: 0.09),
|
||||
context.colorScheme.primary.withValues(alpha: 0.075),
|
||||
],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
),
|
||||
child: SearchField(
|
||||
hintText: searchHintText.value,
|
||||
key: const Key('search_text_field'),
|
||||
controller: textSearchController,
|
||||
contentPadding: preFilter != null
|
||||
? const EdgeInsets.only(left: 24)
|
||||
: const EdgeInsets.all(8),
|
||||
prefixIcon: preFilter != null
|
||||
? null
|
||||
: Icon(
|
||||
getSearchPrefixIcon(),
|
||||
color: context.colorScheme.primary,
|
||||
),
|
||||
onSubmitted: handleTextSubmitted,
|
||||
focusNode: ref.watch(searchInputFocusProvider),
|
||||
),
|
||||
),
|
||||
),
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.only(top: 12.0),
|
||||
sliver: SliverToBoxAdapter(
|
||||
child: SizedBox(
|
||||
height: 50,
|
||||
child: ListView(
|
||||
key: const Key('search_filter_chip_list'),
|
||||
shrinkWrap: true,
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
children: [
|
||||
SearchFilterChip(
|
||||
icon: Icons.people_alt_outlined,
|
||||
onTap: showPeoplePicker,
|
||||
label: 'people'.t(context: context),
|
||||
currentFilter: peopleCurrentFilterWidget.value,
|
||||
),
|
||||
SearchFilterChip(
|
||||
icon: Icons.location_on_outlined,
|
||||
onTap: showLocationPicker,
|
||||
label: 'search_filter_location'.t(context: context),
|
||||
currentFilter: locationCurrentFilterWidget.value,
|
||||
),
|
||||
SearchFilterChip(
|
||||
icon: Icons.camera_alt_outlined,
|
||||
onTap: showCameraPicker,
|
||||
label: 'camera'.t(context: context),
|
||||
currentFilter: cameraCurrentFilterWidget.value,
|
||||
),
|
||||
SearchFilterChip(
|
||||
icon: Icons.date_range_outlined,
|
||||
onTap: showDatePicker,
|
||||
label: 'search_filter_date'.t(context: context),
|
||||
currentFilter: dateRangeCurrentFilterWidget.value,
|
||||
),
|
||||
SearchFilterChip(
|
||||
key: const Key('media_type_chip'),
|
||||
icon: Icons.video_collection_outlined,
|
||||
onTap: showMediaTypePicker,
|
||||
label: 'search_filter_media_type'.t(context: context),
|
||||
currentFilter: mediaTypeCurrentFilterWidget.value,
|
||||
),
|
||||
SearchFilterChip(
|
||||
icon: Icons.display_settings_outlined,
|
||||
onTap: showDisplayOptionPicker,
|
||||
label:
|
||||
'search_filter_display_options'.t(context: context),
|
||||
currentFilter: displayOptionCurrentFilterWidget.value,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (isSearching.value)
|
||||
const SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
)
|
||||
else
|
||||
_SearchResultGrid(onScrollEnd: loadMoreSearchResult),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SearchResultGrid extends ConsumerWidget {
|
||||
final VoidCallback onScrollEnd;
|
||||
|
||||
const _SearchResultGrid({required this.onScrollEnd});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final searchResult = ref.watch(paginatedSearchProvider);
|
||||
|
||||
if (searchResult.totalAssets == 0) {
|
||||
return const _SearchEmptyContent();
|
||||
}
|
||||
|
||||
return NotificationListener<ScrollEndNotification>(
|
||||
onNotification: (notification) {
|
||||
final isBottomSheetNotification = notification.context
|
||||
?.findAncestorWidgetOfExactType<DraggableScrollableSheet>() !=
|
||||
null;
|
||||
|
||||
final metrics = notification.metrics;
|
||||
final isVerticalScroll = metrics.axis == Axis.vertical;
|
||||
|
||||
if (metrics.pixels >= metrics.maxScrollExtent &&
|
||||
isVerticalScroll &&
|
||||
!isBottomSheetNotification) {
|
||||
onScrollEnd();
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
child: SliverFillRemaining(
|
||||
child: ProviderScope(
|
||||
overrides: [
|
||||
timelineServiceProvider.overrideWith(
|
||||
(ref) {
|
||||
final timelineService = ref
|
||||
.watch(timelineFactoryProvider)
|
||||
.fromAssets(searchResult.assets);
|
||||
ref.onDispose(timelineService.dispose);
|
||||
return timelineService;
|
||||
},
|
||||
),
|
||||
],
|
||||
child: Timeline(
|
||||
key: ValueKey(searchResult.totalAssets),
|
||||
appBar: null,
|
||||
groupBy: GroupAssetsBy.none,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SearchEmptyContent extends StatelessWidget {
|
||||
const _SearchEmptyContent();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SliverToBoxAdapter(
|
||||
child: ListView(
|
||||
shrinkWrap: true,
|
||||
children: [
|
||||
const SizedBox(height: 40),
|
||||
Center(
|
||||
child: Image.asset(
|
||||
context.isDarkTheme
|
||||
? 'assets/polaroid-dark.png'
|
||||
: 'assets/polaroid-light.png',
|
||||
height: 125,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Center(
|
||||
child: Text(
|
||||
'search_page_search_photos_videos'.t(context: context),
|
||||
style: context.textTheme.labelLarge,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16),
|
||||
child: _QuickLinkList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _QuickLinkList extends StatelessWidget {
|
||||
const _QuickLinkList();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: const BorderRadius.all(
|
||||
Radius.circular(20),
|
||||
),
|
||||
border: Border.all(
|
||||
color: context.colorScheme.outline.withAlpha(10),
|
||||
width: 1,
|
||||
),
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
context.colorScheme.primary.withAlpha(10),
|
||||
context.colorScheme.primary.withAlpha(15),
|
||||
context.colorScheme.primary.withAlpha(20),
|
||||
],
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
),
|
||||
),
|
||||
child: ListView(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
children: [
|
||||
_QuickLink(
|
||||
title: 'recently_taken'.t(context: context),
|
||||
icon: Icons.schedule_outlined,
|
||||
isTop: true,
|
||||
onTap: () => context.pushRoute(const DriftRecentlyTakenRoute()),
|
||||
),
|
||||
_QuickLink(
|
||||
title: 'videos'.t(context: context),
|
||||
icon: Icons.play_circle_outline_rounded,
|
||||
onTap: () => context.pushRoute(const DriftVideoRoute()),
|
||||
),
|
||||
_QuickLink(
|
||||
title: 'favorites'.t(context: context),
|
||||
icon: Icons.favorite_border_rounded,
|
||||
isBottom: true,
|
||||
onTap: () => context.pushRoute(const DriftFavoriteRoute()),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _QuickLink extends StatelessWidget {
|
||||
final String title;
|
||||
final IconData icon;
|
||||
final VoidCallback onTap;
|
||||
final bool isTop;
|
||||
final bool isBottom;
|
||||
|
||||
const _QuickLink({
|
||||
required this.title,
|
||||
required this.icon,
|
||||
required this.onTap,
|
||||
this.isTop = false,
|
||||
this.isBottom = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final borderRadius = BorderRadius.only(
|
||||
topLeft: Radius.circular(isTop ? 20 : 0),
|
||||
topRight: Radius.circular(isTop ? 20 : 0),
|
||||
bottomLeft: Radius.circular(isBottom ? 20 : 0),
|
||||
bottomRight: Radius.circular(isBottom ? 20 : 0),
|
||||
);
|
||||
|
||||
return ListTile(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: borderRadius,
|
||||
),
|
||||
leading: Icon(
|
||||
icon,
|
||||
size: 26,
|
||||
),
|
||||
title: Text(
|
||||
title,
|
||||
style: context.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
}
|
@ -0,0 +1,40 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/search_result.model.dart';
|
||||
import 'package:immich_mobile/domain/services/search.service.dart';
|
||||
import 'package:immich_mobile/models/search/search_filter.model.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/search.provider.dart';
|
||||
|
||||
final paginatedSearchProvider =
|
||||
StateNotifierProvider<PaginatedSearchNotifier, SearchResult>(
|
||||
(ref) => PaginatedSearchNotifier(ref.watch(searchServiceProvider)),
|
||||
);
|
||||
|
||||
class PaginatedSearchNotifier extends StateNotifier<SearchResult> {
|
||||
final SearchService _searchService;
|
||||
|
||||
PaginatedSearchNotifier(this._searchService)
|
||||
: super(const SearchResult(assets: [], nextPage: 1));
|
||||
|
||||
Future<bool> search(SearchFilter filter) async {
|
||||
if (state.nextPage == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final result = await _searchService.search(filter, state.nextPage!);
|
||||
|
||||
if (result == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
state = SearchResult(
|
||||
assets: [...state.assets, ...result.assets],
|
||||
nextPage: result.nextPage,
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
clear() {
|
||||
state = const SearchResult(assets: [], nextPage: 1);
|
||||
}
|
||||
}
|
@ -15,6 +15,7 @@ class TimelineArgs {
|
||||
final double spacing;
|
||||
final int columnCount;
|
||||
final bool showStorageIndicator;
|
||||
final GroupAssetsBy? groupBy;
|
||||
|
||||
const TimelineArgs({
|
||||
required this.maxWidth,
|
||||
@ -22,6 +23,7 @@ class TimelineArgs {
|
||||
this.spacing = kTimelineSpacing,
|
||||
this.columnCount = kTimelineColumnCount,
|
||||
this.showStorageIndicator = false,
|
||||
this.groupBy,
|
||||
});
|
||||
|
||||
@override
|
||||
@ -30,7 +32,8 @@ class TimelineArgs {
|
||||
maxWidth == other.maxWidth &&
|
||||
maxHeight == other.maxHeight &&
|
||||
columnCount == other.columnCount &&
|
||||
showStorageIndicator == other.showStorageIndicator;
|
||||
showStorageIndicator == other.showStorageIndicator &&
|
||||
groupBy == other.groupBy;
|
||||
}
|
||||
|
||||
@override
|
||||
@ -39,7 +42,8 @@ class TimelineArgs {
|
||||
maxHeight.hashCode ^
|
||||
spacing.hashCode ^
|
||||
columnCount.hashCode ^
|
||||
showStorageIndicator.hashCode;
|
||||
showStorageIndicator.hashCode ^
|
||||
groupBy.hashCode;
|
||||
}
|
||||
|
||||
class TimelineState {
|
||||
@ -97,8 +101,9 @@ final timelineSegmentProvider = StreamProvider.autoDispose<List<Segment>>(
|
||||
final availableTileWidth = args.maxWidth - (spacing * (columnCount - 1));
|
||||
final tileExtent = math.max(0, availableTileWidth) / columnCount;
|
||||
|
||||
final groupBy = GroupAssetsBy
|
||||
.values[ref.watch(settingsProvider).get(Setting.groupAssetsBy)];
|
||||
final groupBy = args.groupBy ??
|
||||
GroupAssetsBy
|
||||
.values[ref.watch(settingsProvider).get(Setting.groupAssetsBy)];
|
||||
|
||||
final timelineService = ref.watch(timelineServiceProvider);
|
||||
yield* timelineService.watchBuckets().map((buckets) {
|
||||
|
@ -7,6 +7,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/setting.model.dart';
|
||||
import 'package:immich_mobile/domain/models/timeline.model.dart';
|
||||
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
@ -27,8 +28,13 @@ class Timeline extends StatelessWidget {
|
||||
this.topSliverWidget,
|
||||
this.topSliverWidgetHeight,
|
||||
this.showStorageIndicator = false,
|
||||
this.appBar,
|
||||
this.appBar = const ImmichSliverAppBar(
|
||||
floating: true,
|
||||
pinned: false,
|
||||
snap: false,
|
||||
),
|
||||
this.bottomSheet = const GeneralBottomSheet(),
|
||||
this.groupBy,
|
||||
});
|
||||
|
||||
final Widget? topSliverWidget;
|
||||
@ -36,6 +42,8 @@ class Timeline extends StatelessWidget {
|
||||
final bool showStorageIndicator;
|
||||
final Widget? appBar;
|
||||
final Widget? bottomSheet;
|
||||
final GroupAssetsBy? groupBy;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
@ -50,6 +58,7 @@ class Timeline extends StatelessWidget {
|
||||
settingsProvider.select((s) => s.get(Setting.tilesPerRow)),
|
||||
),
|
||||
showStorageIndicator: showStorageIndicator,
|
||||
groupBy: groupBy,
|
||||
),
|
||||
),
|
||||
],
|
||||
@ -112,13 +121,17 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
||||
return asyncSegments.widgetWhen(
|
||||
onData: (segments) {
|
||||
final childCount = (segments.lastOrNull?.lastIndex ?? -1) + 1;
|
||||
final statusBarHeight = context.padding.top;
|
||||
final double appBarExpandedHeight =
|
||||
widget.appBar != null && widget.appBar is MesmerizingSliverAppBar
|
||||
? 200
|
||||
: 0;
|
||||
final totalAppBarHeight = statusBarHeight + kToolbarHeight;
|
||||
final topPadding = context.padding.top +
|
||||
(widget.appBar == null ? 0 : kToolbarHeight) +
|
||||
10;
|
||||
|
||||
const scrubberBottomPadding = 100.0;
|
||||
final bottomPadding = context.padding.bottom +
|
||||
(widget.appBar == null ? 0 : scrubberBottomPadding);
|
||||
|
||||
return PrimaryScrollController(
|
||||
controller: _scrollController,
|
||||
@ -127,8 +140,8 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
||||
Scrubber(
|
||||
layoutSegments: segments,
|
||||
timelineHeight: maxHeight,
|
||||
topPadding: totalAppBarHeight + 10,
|
||||
bottomPadding: context.padding.bottom + scrubberBottomPadding,
|
||||
topPadding: topPadding,
|
||||
bottomPadding: bottomPadding,
|
||||
monthSegmentSnappingOffset:
|
||||
widget.topSliverWidgetHeight ?? 0 + appBarExpandedHeight,
|
||||
child: CustomScrollView(
|
||||
@ -137,13 +150,8 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
||||
slivers: [
|
||||
if (isSelectionMode)
|
||||
const SelectionSliverAppBar()
|
||||
else
|
||||
widget.appBar ??
|
||||
const ImmichSliverAppBar(
|
||||
floating: true,
|
||||
pinned: false,
|
||||
snap: false,
|
||||
),
|
||||
else if (widget.appBar != null)
|
||||
widget.appBar!,
|
||||
if (widget.topSliverWidget != null) widget.topSliverWidget!,
|
||||
_SliverSegmentedList(
|
||||
segments: segments,
|
||||
@ -188,21 +196,22 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
||||
child: _MultiSelectStatusButton(),
|
||||
),
|
||||
),
|
||||
Consumer(
|
||||
builder: (_, consumerRef, child) {
|
||||
final isMultiSelectEnabled = consumerRef.watch(
|
||||
multiSelectProvider.select(
|
||||
(s) => s.isEnabled,
|
||||
),
|
||||
);
|
||||
if (widget.bottomSheet != null)
|
||||
Consumer(
|
||||
builder: (_, consumerRef, child) {
|
||||
final isMultiSelectEnabled = consumerRef.watch(
|
||||
multiSelectProvider.select(
|
||||
(s) => s.isEnabled,
|
||||
),
|
||||
);
|
||||
|
||||
if (isMultiSelectEnabled) {
|
||||
return child!;
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
child: widget.bottomSheet,
|
||||
),
|
||||
if (isMultiSelectEnabled) {
|
||||
return child!;
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
child: widget.bottomSheet,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
|
12
mobile/lib/providers/infrastructure/search.provider.dart
Normal file
12
mobile/lib/providers/infrastructure/search.provider.dart
Normal file
@ -0,0 +1,12 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/services/search.service.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/search_api.repository.dart';
|
||||
import 'package:immich_mobile/providers/api.provider.dart';
|
||||
|
||||
final searchApiRepositoryProvider = Provider(
|
||||
(ref) => SearchApiRepository(ref.watch(apiServiceProvider).searchApi),
|
||||
);
|
||||
|
||||
final searchServiceProvider = Provider(
|
||||
(ref) => SearchService(ref.watch(searchApiRepositoryProvider)),
|
||||
);
|
@ -91,6 +91,7 @@ import 'package:immich_mobile/presentation/pages/drift_remote_album.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/drift_trash.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/drift_video.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/local_timeline.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/search/drift_search.page.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.page.dart';
|
||||
import 'package:immich_mobile/providers/api.provider.dart';
|
||||
import 'package:immich_mobile/providers/gallery_permission.provider.dart';
|
||||
@ -189,7 +190,7 @@ class AppRouter extends RootStackRouter {
|
||||
guards: [_authGuard, _duplicateGuard],
|
||||
),
|
||||
AutoRoute(
|
||||
page: SearchRoute.page,
|
||||
page: DriftSearchRoute.page,
|
||||
guards: [_authGuard, _duplicateGuard],
|
||||
maintainState: false,
|
||||
),
|
||||
|
@ -991,6 +991,45 @@ class DriftRecentlyTakenRoute extends PageRouteInfo<void> {
|
||||
);
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [DriftSearchPage]
|
||||
class DriftSearchRoute extends PageRouteInfo<DriftSearchRouteArgs> {
|
||||
DriftSearchRoute({
|
||||
Key? key,
|
||||
SearchFilter? preFilter,
|
||||
List<PageRouteInfo>? children,
|
||||
}) : super(
|
||||
DriftSearchRoute.name,
|
||||
args: DriftSearchRouteArgs(key: key, preFilter: preFilter),
|
||||
initialChildren: children,
|
||||
);
|
||||
|
||||
static const String name = 'DriftSearchRoute';
|
||||
|
||||
static PageInfo page = PageInfo(
|
||||
name,
|
||||
builder: (data) {
|
||||
final args = data.argsAs<DriftSearchRouteArgs>(
|
||||
orElse: () => const DriftSearchRouteArgs(),
|
||||
);
|
||||
return DriftSearchPage(key: args.key, preFilter: args.preFilter);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
class DriftSearchRouteArgs {
|
||||
const DriftSearchRouteArgs({this.key, this.preFilter});
|
||||
|
||||
final Key? key;
|
||||
|
||||
final SearchFilter? preFilter;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'DriftSearchRouteArgs{key: $key, preFilter: $preFilter}';
|
||||
}
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [DriftTrashPage]
|
||||
class DriftTrashRoute extends PageRouteInfo<void> {
|
||||
|
@ -1,10 +1,11 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/string_extensions.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/search_api.repository.dart';
|
||||
import 'package:immich_mobile/models/search/search_filter.model.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/models/search/search_result.model.dart';
|
||||
import 'package:immich_mobile/providers/api.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/search.provider.dart';
|
||||
import 'package:immich_mobile/repositories/asset.repository.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
@ -14,15 +15,21 @@ final searchServiceProvider = Provider(
|
||||
(ref) => SearchService(
|
||||
ref.watch(apiServiceProvider),
|
||||
ref.watch(assetRepositoryProvider),
|
||||
ref.watch(searchApiRepositoryProvider),
|
||||
),
|
||||
);
|
||||
|
||||
class SearchService {
|
||||
final ApiService _apiService;
|
||||
final AssetRepository _assetRepository;
|
||||
final SearchApiRepository _searchApiRepository;
|
||||
|
||||
final _log = Logger("SearchService");
|
||||
SearchService(this._apiService, this._assetRepository);
|
||||
SearchService(
|
||||
this._apiService,
|
||||
this._assetRepository,
|
||||
this._searchApiRepository,
|
||||
);
|
||||
|
||||
Future<List<String>?> getSearchSuggestions(
|
||||
SearchSuggestionType type, {
|
||||
@ -32,7 +39,7 @@ class SearchService {
|
||||
String? model,
|
||||
}) async {
|
||||
try {
|
||||
return await _apiService.searchApi.getSearchSuggestions(
|
||||
return await _searchApiRepository.getSearchSuggestions(
|
||||
type,
|
||||
country: country,
|
||||
state: state,
|
||||
@ -47,76 +54,15 @@ class SearchService {
|
||||
|
||||
Future<SearchResult?> search(SearchFilter filter, int page) async {
|
||||
try {
|
||||
SearchResponseDto? response;
|
||||
AssetTypeEnum? type;
|
||||
if (filter.mediaType == AssetType.image) {
|
||||
type = AssetTypeEnum.IMAGE;
|
||||
} else if (filter.mediaType == AssetType.video) {
|
||||
type = AssetTypeEnum.VIDEO;
|
||||
}
|
||||
|
||||
if (filter.context != null && filter.context!.isNotEmpty) {
|
||||
response = await _apiService.searchApi.searchSmart(
|
||||
SmartSearchDto(
|
||||
query: filter.context!,
|
||||
language: filter.language,
|
||||
country: filter.location.country,
|
||||
state: filter.location.state,
|
||||
city: filter.location.city,
|
||||
make: filter.camera.make,
|
||||
model: filter.camera.model,
|
||||
takenAfter: filter.date.takenAfter,
|
||||
takenBefore: filter.date.takenBefore,
|
||||
visibility: filter.display.isArchive
|
||||
? AssetVisibility.archive
|
||||
: AssetVisibility.timeline,
|
||||
isFavorite: filter.display.isFavorite ? true : null,
|
||||
isNotInAlbum: filter.display.isNotInAlbum ? true : null,
|
||||
personIds: filter.people.map((e) => e.id).toList(),
|
||||
type: type,
|
||||
page: page,
|
||||
size: 1000,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
response = await _apiService.searchApi.searchAssets(
|
||||
MetadataSearchDto(
|
||||
originalFileName:
|
||||
filter.filename != null && filter.filename!.isNotEmpty
|
||||
? filter.filename
|
||||
: null,
|
||||
country: filter.location.country,
|
||||
description:
|
||||
filter.description != null && filter.description!.isNotEmpty
|
||||
? filter.description
|
||||
: null,
|
||||
state: filter.location.state,
|
||||
city: filter.location.city,
|
||||
make: filter.camera.make,
|
||||
model: filter.camera.model,
|
||||
takenAfter: filter.date.takenAfter,
|
||||
takenBefore: filter.date.takenBefore,
|
||||
visibility: filter.display.isArchive
|
||||
? AssetVisibility.archive
|
||||
: AssetVisibility.timeline,
|
||||
isFavorite: filter.display.isFavorite ? true : null,
|
||||
isNotInAlbum: filter.display.isNotInAlbum ? true : null,
|
||||
personIds: filter.people.map((e) => e.id).toList(),
|
||||
type: type,
|
||||
page: page,
|
||||
size: 1000,
|
||||
),
|
||||
);
|
||||
}
|
||||
final response = await _searchApiRepository.search(filter, page);
|
||||
|
||||
if (response == null || response.assets.items.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return SearchResult(
|
||||
assets: await _assetRepository.getAllByRemoteId(
|
||||
response.assets.items.map((e) => e.id),
|
||||
),
|
||||
assets: await _assetRepository
|
||||
.getAllByRemoteId(response.assets.items.map((e) => e.id)),
|
||||
nextPage: response.assets.nextPage?.toInt(),
|
||||
);
|
||||
} catch (error, stackTrace) {
|
||||
|
@ -1,4 +1,5 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/search.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
|
||||
import 'package:immich_mobile/repositories/activity_api.repository.dart';
|
||||
import 'package:immich_mobile/repositories/album_api.repository.dart';
|
||||
@ -15,4 +16,5 @@ void invalidateAllApiRepositoryProviders(WidgetRef ref) {
|
||||
ref.invalidate(personApiRepositoryProvider);
|
||||
ref.invalidate(assetApiRepositoryProvider);
|
||||
ref.invalidate(timelineRepositoryProvider);
|
||||
ref.invalidate(searchApiRepositoryProvider);
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user