Merge branch 'main' of github.com:immich-app/immich into feat/mobile/backup-with-album-info

This commit is contained in:
Alex
2024-06-17 13:13:47 -07:00
52 changed files with 915 additions and 869 deletions
+2
View File
@@ -295,6 +295,8 @@
"memories_check_back_tomorrow": "Check back tomorrow for more memories",
"memories_start_over": "Start Over",
"memories_swipe_to_close": "Swipe up to close",
"memories_year_ago": "A year ago",
"memories_years_ago": "{} years ago",
"monthly_title_text_date_format": "MMMM y",
"motion_photos_page_title": "Motion Photos",
"multiselect_grid_edit_date_time_err_read_only": "Cannot edit date of read only asset(s), skipping",
+2
View File
@@ -295,6 +295,8 @@
"memories_check_back_tomorrow": "明日もう一度確認してください",
"memories_start_over": "始める",
"memories_swipe_to_close": "上にスワイプして閉じる",
"memories_year_ago": "過去1年間",
"memories_years_ago": "過去{}年間",
"monthly_title_text_date_format": "yyyy年 MM月",
"motion_photos_page_title": "モーションフォト",
"multiselect_grid_edit_date_time_err_read_only": "読み取り専用の項目の日付を変更できません",
+2
View File
@@ -295,6 +295,8 @@
"memories_check_back_tomorrow": "Kom morgen terug voor meer herinneringen",
"memories_start_over": "Opnieuw beginnen",
"memories_swipe_to_close": "Swipe omhoog om te sluiten",
"memories_year_ago": "1 jaar geleden",
"memories_years_ago": "{} jaar geleden",
"monthly_title_text_date_format": "MMMM y",
"motion_photos_page_title": "Bewegende foto's",
"multiselect_grid_edit_date_time_err_read_only": "Kan datum van alleen-lezen asset(s) niet wijzigen, overslaan",
+2
View File
@@ -295,6 +295,8 @@
"memories_check_back_tomorrow": "明天再看",
"memories_start_over": "再看一次",
"memories_swipe_to_close": "上划关闭",
"memories_year_ago": "1年前",
"memories_years_ago": "{}年前",
"monthly_title_text_date_format": "MMMM y",
"motion_photos_page_title": "动图",
"multiselect_grid_edit_date_time_err_read_only": "无法编辑只读项目的日期,跳过",
+2
View File
@@ -295,6 +295,8 @@
"memories_check_back_tomorrow": "明天再看",
"memories_start_over": "再看一次",
"memories_swipe_to_close": "上划关闭",
"memories_year_ago": "1年前",
"memories_years_ago": "{}年前",
"monthly_title_text_date_format": "MMMM y",
"motion_photos_page_title": "动图",
"multiselect_grid_edit_date_time_err_read_only": "无法编辑只读项目的日期,跳过",
+2
View File
@@ -295,6 +295,8 @@
"memories_check_back_tomorrow": "Check back tomorrow for more memories",
"memories_start_over": "Start Over",
"memories_swipe_to_close": "Swipe up to close",
"memories_year_ago": "1年前",
"memories_years_ago": "{}年前",
"monthly_title_text_date_format": "MMMM y",
"motion_photos_page_title": "Motion Photos",
"multiselect_grid_edit_date_time_err_read_only": "Cannot edit date of read only asset(s), skipping",
+1 -1
View File
@@ -298,7 +298,7 @@ class MapPage extends HookConsumerWidget {
),
Positioned(
right: 0,
bottom: 30,
bottom: MediaQuery.of(context).padding.bottom + 16,
child: ElevatedButton(
onPressed: onZoomToLocation,
style: ElevatedButton.styleFrom(
+108 -120
View File
@@ -13,7 +13,7 @@ import 'package:immich_mobile/providers/search/search_page_state.provider.dart';
import 'package:immich_mobile/widgets/search/curated_people_row.dart';
import 'package:immich_mobile/widgets/search/curated_places_row.dart';
import 'package:immich_mobile/widgets/search/person_name_edit_form.dart';
import 'package:immich_mobile/widgets/search/search_row_title.dart';
import 'package:immich_mobile/widgets/search/search_row_section.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
@@ -31,7 +31,7 @@ class SearchPage extends HookConsumerWidget {
final curatedPeople = ref.watch(getAllPeopleProvider);
final isMapEnabled =
ref.watch(serverInfoProvider.select((v) => v.serverFeatures.map));
double imageSize = math.min(context.width / 3, 150);
final double imageSize = math.min(context.width / 3, 150);
TextStyle categoryTitleStyle = const TextStyle(
fontWeight: FontWeight.w500,
@@ -53,16 +53,15 @@ class SearchPage extends HookConsumerWidget {
}
buildPeople() {
return SizedBox(
height: imageSize,
child: curatedPeople.widgetWhen(
onError: (error, stack) => const ScaffoldErrorBody(withIcon: false),
onData: (people) => Padding(
padding: const EdgeInsets.only(
left: 16,
top: 8,
),
return curatedPeople.widgetWhen(
onError: (error, stack) => const ScaffoldErrorBody(withIcon: false),
onData: (people) {
return SearchRowSection(
onViewAllPressed: () => context.pushRoute(const AllPeopleRoute()),
title: "search_page_people".tr(),
isEmpty: people.isEmpty,
child: CuratedPeopleRow(
padding: const EdgeInsets.symmetric(horizontal: 16),
content: people
.map((e) => SearchCuratedContent(label: e.name, id: e.id))
.take(12)
@@ -79,42 +78,46 @@ class SearchPage extends HookConsumerWidget {
showNameEditModel(person.id, person.label),
},
),
),
),
);
},
);
}
buildPlaces() {
return SizedBox(
height: imageSize,
child: places.widgetWhen(
onError: (error, stack) => const ScaffoldErrorBody(withIcon: false),
onData: (data) => CuratedPlacesRow(
isMapEnabled: isMapEnabled,
content: data,
imageSize: imageSize,
onTap: (content, index) {
context.pushRoute(
SearchInputRoute(
prefilter: SearchFilter(
people: {},
location: SearchLocationFilter(
city: content.label,
return places.widgetWhen(
onError: (error, stack) => const ScaffoldErrorBody(withIcon: false),
onData: (data) {
return SearchRowSection(
onViewAllPressed: () => context.pushRoute(const AllPlacesRoute()),
title: "search_page_places".tr(),
isEmpty: !isMapEnabled && data.isEmpty,
child: CuratedPlacesRow(
isMapEnabled: isMapEnabled,
content: data,
imageSize: imageSize,
onTap: (content, index) {
context.pushRoute(
SearchInputRoute(
prefilter: SearchFilter(
people: {},
location: SearchLocationFilter(
city: content.label,
),
camera: SearchCameraFilter(),
date: SearchDateFilter(),
display: SearchDisplayFilters(
isNotInAlbum: false,
isArchive: false,
isFavorite: false,
),
mediaType: AssetType.other,
),
camera: SearchCameraFilter(),
date: SearchDateFilter(),
display: SearchDisplayFilters(
isNotInAlbum: false,
isArchive: false,
isFavorite: false,
),
mediaType: AssetType.other,
),
),
);
},
),
),
);
},
),
);
},
);
}
@@ -160,88 +163,73 @@ class SearchPage extends HookConsumerWidget {
return Scaffold(
appBar: const ImmichAppBar(),
body: Stack(
body: ListView(
children: [
ListView(
children: [
buildSearchButton(),
SearchRowTitle(
title: "search_page_people".tr(),
onViewAllPressed: () =>
context.pushRoute(const AllPeopleRoute()),
buildSearchButton(),
const SizedBox(height: 8.0),
buildPeople(),
const SizedBox(height: 8.0),
buildPlaces(),
const SizedBox(height: 24.0),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
'search_page_your_activity',
style: context.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w500,
),
buildPeople(),
SearchRowTitle(
title: "search_page_places".tr(),
onViewAllPressed: () =>
context.pushRoute(const AllPlacesRoute()),
top: 0,
).tr(),
),
ListTile(
leading: Icon(
Icons.favorite_border_rounded,
color: categoryIconColor,
),
title:
Text('search_page_favorites', style: categoryTitleStyle).tr(),
onTap: () => context.pushRoute(const FavoritesRoute()),
),
const CategoryDivider(),
ListTile(
leading: Icon(
Icons.schedule_outlined,
color: categoryIconColor,
),
title: Text(
'search_page_recently_added',
style: categoryTitleStyle,
).tr(),
onTap: () => context.pushRoute(const RecentlyAddedRoute()),
),
const SizedBox(height: 24.0),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Text(
'search_page_categories',
style: context.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w500,
),
const SizedBox(height: 10.0),
buildPlaces(),
const SizedBox(height: 24.0),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
'search_page_your_activity',
style: context.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w500,
),
).tr(),
),
ListTile(
leading: Icon(
Icons.favorite_border_rounded,
color: categoryIconColor,
),
title: Text('search_page_favorites', style: categoryTitleStyle)
.tr(),
onTap: () => context.pushRoute(const FavoritesRoute()),
),
const CategoryDivider(),
ListTile(
leading: Icon(
Icons.schedule_outlined,
color: categoryIconColor,
),
title: Text(
'search_page_recently_added',
style: categoryTitleStyle,
).tr(),
onTap: () => context.pushRoute(const RecentlyAddedRoute()),
),
const SizedBox(height: 24.0),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Text(
'search_page_categories',
style: context.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w500,
),
).tr(),
),
ListTile(
title:
Text('search_page_videos', style: categoryTitleStyle).tr(),
leading: Icon(
Icons.play_circle_outline,
color: categoryIconColor,
),
onTap: () => context.pushRoute(const AllVideosRoute()),
),
const CategoryDivider(),
ListTile(
title: Text(
'search_page_motion_photos',
style: categoryTitleStyle,
).tr(),
leading: Icon(
Icons.motion_photos_on_outlined,
color: categoryIconColor,
),
onTap: () => context.pushRoute(const AllMotionPhotosRoute()),
),
],
).tr(),
),
ListTile(
title: Text('search_page_videos', style: categoryTitleStyle).tr(),
leading: Icon(
Icons.play_circle_outline,
color: categoryIconColor,
),
onTap: () => context.pushRoute(const AllVideosRoute()),
),
const CategoryDivider(),
ListTile(
title: Text(
'search_page_motion_photos',
style: categoryTitleStyle,
).tr(),
leading: Icon(
Icons.motion_photos_on_outlined,
color: categoryIconColor,
),
onTap: () => context.pushRoute(const AllMotionPhotosRoute()),
),
],
),
@@ -1,87 +0,0 @@
import 'dart:async';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/services/asset_description.service.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/entities/exif_info.entity.dart';
import 'package:immich_mobile/providers/db.provider.dart';
import 'package:isar/isar.dart';
class AssetDescriptionNotifier extends StateNotifier<String> {
final Isar _db;
final AssetDescriptionService _service;
final Asset _asset;
AssetDescriptionNotifier(
this._db,
this._service,
this._asset,
) : super('') {
_fetchLocalDescription();
_fetchRemoteDescription();
}
String get description => state;
/// Fetches the local database value for description
/// and writes it to [state]
void _fetchLocalDescription() async {
final localExifId = _asset.exifInfo?.id;
// Guard [localExifId] null
if (localExifId == null) {
return;
}
// Subscribe to local changes
final exifInfo = await _db.exifInfos.get(localExifId);
// Guard
if (exifInfo?.description == null) {
return;
}
state = exifInfo!.description!;
}
/// Fetches the remote value and sets the state
void _fetchRemoteDescription() async {
final remoteAssetId = _asset.remoteId;
final localExifId = _asset.exifInfo?.id;
// Guard [remoteAssetId] and [localExifId] null
if (remoteAssetId == null || localExifId == null) {
return;
}
// Reads the latest from the remote and writes it to DB in the service
final latest = await _service.readLatest(remoteAssetId, localExifId);
state = latest;
}
/// Sets the description to [description]
/// Uses the service to set the asset value
Future<void> setDescription(String description) async {
state = description;
final remoteAssetId = _asset.remoteId;
final localExifId = _asset.exifInfo?.id;
// Guard [remoteAssetId] and [localExifId] null
if (remoteAssetId == null || localExifId == null) {
return;
}
return _service.setDescription(description, remoteAssetId, localExifId);
}
}
final assetDescriptionProvider = StateNotifierProvider.autoDispose
.family<AssetDescriptionNotifier, String, Asset>(
(ref, asset) => AssetDescriptionNotifier(
ref.watch(dbProvider),
ref.watch(assetDescriptionServiceProvider),
asset,
),
);
@@ -1,4 +1,5 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/entities/exif_info.entity.dart';
import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/providers/db.provider.dart';
@@ -12,46 +13,36 @@ class AssetDescriptionService {
final Isar _db;
final ApiService _api;
setDescription(
String description,
String remoteAssetId,
int localExifId,
Future<void> setDescription(
Asset asset,
String newDescription,
) async {
final remoteAssetId = asset.remoteId;
final localExifId = asset.exifInfo?.id;
// Guard [remoteAssetId] and [localExifId] null
if (remoteAssetId == null || localExifId == null) {
return;
}
final result = await _api.assetsApi.updateAsset(
remoteAssetId,
UpdateAssetDto(description: description),
UpdateAssetDto(description: newDescription),
);
if (result?.exifInfo?.description != null) {
final description = result?.exifInfo?.description;
if (description != null) {
var exifInfo = await _db.exifInfos.get(localExifId);
if (exifInfo != null) {
exifInfo.description = result!.exifInfo!.description;
exifInfo.description = description;
await _db.writeTxn(
() => _db.exifInfos.put(exifInfo),
);
}
}
}
Future<String> readLatest(String assetRemoteId, int localExifId) async {
final latestAssetFromServer =
await _api.assetsApi.getAssetInfo(assetRemoteId);
final localExifInfo = await _db.exifInfos.get(localExifId);
if (latestAssetFromServer != null && localExifInfo != null) {
localExifInfo.description =
latestAssetFromServer.exifInfo?.description ?? '';
await _db.writeTxn(
() => _db.exifInfos.put(localExifInfo),
);
return localExifInfo.description!;
}
return "";
}
}
final assetDescriptionServiceProvider = Provider(
+5 -3
View File
@@ -1,3 +1,4 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/models/memories/memory.model.dart';
@@ -8,8 +9,6 @@ import 'package:isar/isar.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
import '../utils/string_helper.dart';
final memoryServiceProvider = StateProvider<MemoryService>((ref) {
return MemoryService(
ref.watch(apiServiceProvider),
@@ -42,9 +41,12 @@ class MemoryService {
final dbAssets =
await _db.assets.getAllByRemoteId(assets.map((e) => e.id));
if (dbAssets.isNotEmpty) {
final String title = yearsAgo <= 1
? 'memories_year_ago'.tr()
: 'memories_years_ago'.tr(args: [yearsAgo.toString()]);
memories.add(
Memory(
title: '$yearsAgo year${s(yearsAgo)} ago',
title: title,
assets: dbAssets,
),
);
@@ -2,10 +2,11 @@ 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/entities/exif_info.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_description.provider.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/services/asset_description.service.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:logging/logging.dart';
@@ -13,9 +14,11 @@ class DescriptionInput extends HookConsumerWidget {
DescriptionInput({
super.key,
required this.asset,
this.exifInfo,
});
final Asset asset;
final ExifInfo? exifInfo;
final Logger _log = Logger('DescriptionInput');
@override
@@ -25,25 +28,25 @@ class DescriptionInput extends HookConsumerWidget {
final focusNode = useFocusNode();
final isFocus = useState(false);
final isTextEmpty = useState(controller.text.isEmpty);
final descriptionProvider =
ref.watch(assetDescriptionProvider(asset).notifier);
final description = ref.watch(assetDescriptionProvider(asset));
final descriptionProvider = ref.watch(assetDescriptionServiceProvider);
final owner = ref.watch(currentUserProvider);
final hasError = useState(false);
useEffect(
() {
controller.text = description;
isTextEmpty.value = description.isEmpty;
controller.text = exifInfo?.description ?? '';
isTextEmpty.value = exifInfo?.description?.isEmpty ?? true;
return null;
},
[description],
[exifInfo?.description],
);
submitDescription(String description) async {
hasError.value = false;
try {
await descriptionProvider.setDescription(
asset,
description,
);
} catch (error, stack) {
@@ -85,7 +88,7 @@ class DescriptionInput extends HookConsumerWidget {
isFocus.value = false;
focusNode.unfocus();
if (description != controller.text) {
if (exifInfo?.description != controller.text) {
await submitDescription(controller.text);
}
},
@@ -73,7 +73,8 @@ class ExifBottomSheet extends HookConsumerWidget {
child: Column(
children: [
dateWidget,
if (asset.isRemote) DescriptionInput(asset: asset),
if (asset.isRemote)
DescriptionInput(asset: asset, exifInfo: exifInfo),
],
),
),
@@ -132,7 +133,8 @@ class ExifBottomSheet extends HookConsumerWidget {
child: Column(
children: [
dateWidget,
if (asset.isRemote) DescriptionInput(asset: asset),
if (asset.isRemote)
DescriptionInput(asset: asset, exifInfo: exifInfo),
Padding(
padding: EdgeInsets.only(top: asset.isRemote ? 0 : 16.0),
child: ExifLocation(
@@ -2,11 +2,12 @@ import 'package:flutter/material.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/models/search/search_curated_content.model.dart';
import 'package:immich_mobile/widgets/search/thumbnail_with_info.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
class CuratedPeopleRow extends StatelessWidget {
static const double imageSize = 60.0;
final List<SearchCuratedContent> content;
final EdgeInsets? padding;
@@ -24,88 +25,68 @@ class CuratedPeopleRow extends StatelessWidget {
@override
Widget build(BuildContext context) {
const imageSize = 60.0;
// Guard empty [content]
if (content.isEmpty) {
// Return empty thumbnail
return Align(
alignment: Alignment.centerLeft,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: SizedBox(
width: imageSize,
height: imageSize,
child: ThumbnailWithInfo(
textInfo: '',
onTap: () {},
),
),
),
);
}
return ListView.builder(
padding: padding,
scrollDirection: Axis.horizontal,
itemBuilder: (context, index) {
final person = content[index];
final headers = {
"x-immich-user-token": Store.get(StoreKey.accessToken),
};
return Padding(
padding: const EdgeInsets.only(right: 18.0),
child: SizedBox(
width: imageSize,
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
GestureDetector(
onTap: () => onTap?.call(person, index),
child: SizedBox(
height: imageSize,
child: Material(
shape: const CircleBorder(side: BorderSide.none),
elevation: 3,
child: CircleAvatar(
maxRadius: imageSize / 2,
backgroundImage: NetworkImage(
getFaceThumbnailUrl(person.id),
headers: headers,
),
return SizedBox(
height: imageSize + 30,
child: ListView.separated(
padding: padding,
scrollDirection: Axis.horizontal,
separatorBuilder: (context, index) => const SizedBox(width: 16),
itemBuilder: (context, index) {
final person = content[index];
final headers = {
"x-immich-user-token": Store.get(StoreKey.accessToken),
};
return Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
GestureDetector(
onTap: () => onTap?.call(person, index),
child: SizedBox(
height: imageSize,
child: Material(
shape: const CircleBorder(side: BorderSide.none),
elevation: 3,
child: CircleAvatar(
maxRadius: imageSize / 2,
backgroundImage: NetworkImage(
getFaceThumbnailUrl(person.id),
headers: headers,
),
),
),
),
if (person.label == "")
GestureDetector(
onTap: () => onNameTap?.call(person, index),
child: Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Text(
"exif_bottom_sheet_person_add_person",
style: context.textTheme.labelLarge?.copyWith(
color: context.primaryColor,
),
).tr(),
),
)
else
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Text(
person.label,
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis,
style: context.textTheme.labelLarge,
),
),
],
),
),
const SizedBox(height: 8),
_buildPersonLabel(context, person, index),
],
);
},
itemCount: content.length,
),
);
}
Widget _buildPersonLabel(
BuildContext context,
SearchCuratedContent person,
int index,
) {
if (person.label.isEmpty) {
return GestureDetector(
onTap: () => onNameTap?.call(person, index),
child: Text(
"exif_bottom_sheet_person_add_person",
style: context.textTheme.labelLarge?.copyWith(
color: context.primaryColor,
),
);
},
itemCount: content.length,
).tr(),
);
}
return Text(
person.label,
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis,
style: context.textTheme.labelLarge,
);
}
}
+42 -113
View File
@@ -1,135 +1,64 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/widgets/map/map_thumbnail.dart';
import 'package:immich_mobile/widgets/search/curated_row.dart';
import 'package:immich_mobile/models/search/search_curated_content.model.dart';
import 'package:immich_mobile/widgets/search/search_map_thumbnail.dart';
import 'package:immich_mobile/widgets/search/thumbnail_with_info.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
class CuratedPlacesRow extends CuratedRow {
final bool isMapEnabled;
class CuratedPlacesRow extends StatelessWidget {
const CuratedPlacesRow({
super.key,
required super.content,
required this.content,
required this.imageSize,
this.isMapEnabled = true,
super.imageSize,
super.onTap,
this.onTap,
});
final bool isMapEnabled;
final List<SearchCuratedContent> content;
final double imageSize;
/// Callback with the content and the index when tapped
final Function(SearchCuratedContent, int)? onTap;
@override
Widget build(BuildContext context) {
// Calculating the actual index of the content based on the whether map is enabled or not.
// If enabled, inject map as the first item in the list (index 0) and so the actual content will start from index 1
final int actualContentIndex = isMapEnabled ? 1 : 0;
Widget buildMapThumbnail() {
return GestureDetector(
onTap: () => context.pushRoute(
const MapRoute(),
),
child: SizedBox.square(
dimension: imageSize,
child: Stack(
children: [
Padding(
padding: const EdgeInsets.only(right: 10.0),
child: MapThumbnail(
zoom: 2,
centre: const LatLng(
47,
5,
),
height: imageSize,
width: imageSize,
showAttribution: false,
),
),
Padding(
padding: const EdgeInsets.only(right: 10.0),
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
color: Colors.black,
gradient: LinearGradient(
begin: FractionalOffset.topCenter,
end: FractionalOffset.bottomCenter,
colors: [
Colors.blueGrey.withOpacity(0.0),
Colors.black.withOpacity(0.4),
],
stops: const [0.0, 0.4],
),
),
),
),
Align(
alignment: Alignment.bottomCenter,
child: Padding(
padding: const EdgeInsets.only(bottom: 10),
child: const Text(
"search_page_your_map",
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 14,
),
).tr(),
),
),
],
),
),
);
}
// Return empty thumbnail
if (!isMapEnabled && content.isEmpty) {
return Align(
alignment: Alignment.centerLeft,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: SizedBox(
return SizedBox(
height: imageSize,
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(
horizontal: 16,
),
itemBuilder: (context, index) {
// Injecting Map thumbnail as the first element
if (isMapEnabled && index == 0) {
return SearchMapThumbnail(
size: imageSize,
);
}
final actualIndex = index - actualContentIndex;
final object = content[actualIndex];
final thumbnailRequestUrl =
'${Store.get(StoreKey.serverEndpoint)}/assets/${object.id}/thumbnail';
return SizedBox(
width: imageSize,
height: imageSize,
child: ThumbnailWithInfo(
textInfo: '',
onTap: () {},
child: Padding(
padding: const EdgeInsets.only(right: 10.0),
child: ThumbnailWithInfo(
imageUrl: thumbnailRequestUrl,
textInfo: object.label,
onTap: () => onTap?.call(object, actualIndex),
),
),
),
),
);
}
return ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(
horizontal: 16,
);
},
itemCount: content.length + actualContentIndex,
),
itemBuilder: (context, index) {
// Injecting Map thumbnail as the first element
if (isMapEnabled && index == 0) {
return buildMapThumbnail();
}
final actualIndex = index - actualContentIndex;
final object = content[actualIndex];
final thumbnailRequestUrl =
'${Store.get(StoreKey.serverEndpoint)}/assets/${object.id}/thumbnail';
return SizedBox(
width: imageSize,
height: imageSize,
child: Padding(
padding: const EdgeInsets.only(right: 10.0),
child: ThumbnailWithInfo(
imageUrl: thumbnailRequestUrl,
textInfo: object.label,
onTap: () => onTap?.call(object, actualIndex),
),
),
);
},
itemCount: content.length + actualContentIndex,
);
}
}
@@ -1,66 +0,0 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/models/search/search_curated_content.model.dart';
import 'package:immich_mobile/widgets/search/thumbnail_with_info.dart';
import 'package:immich_mobile/entities/store.entity.dart';
class CuratedRow extends StatelessWidget {
final List<SearchCuratedContent> content;
final double imageSize;
/// Callback with the content and the index when tapped
final Function(SearchCuratedContent, int)? onTap;
const CuratedRow({
super.key,
required this.content,
this.imageSize = 200,
this.onTap,
});
@override
Widget build(BuildContext context) {
// Guard empty [content]
if (content.isEmpty) {
// Return empty thumbnail
return Align(
alignment: Alignment.centerLeft,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: SizedBox(
width: imageSize,
height: imageSize,
child: ThumbnailWithInfo(
textInfo: '',
onTap: () {},
),
),
),
);
}
return ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(
horizontal: 16,
),
itemBuilder: (context, index) {
final object = content[index];
final thumbnailRequestUrl =
'${Store.get(StoreKey.serverEndpoint)}/assets/${object.id}/thumbnail';
return SizedBox(
width: imageSize,
height: imageSize,
child: Padding(
padding: const EdgeInsets.only(right: 4.0),
child: ThumbnailWithInfo(
imageUrl: thumbnailRequestUrl,
textInfo: object.label,
onTap: () => onTap?.call(object, index),
),
),
);
},
itemCount: content.length,
);
}
}
@@ -5,6 +5,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/models/search/search_filter.model.dart';
import 'package:immich_mobile/providers/search/search_filter.provider.dart';
import 'package:immich_mobile/widgets/search/search_filter/common/dropdown.dart';
import 'package:openapi/api.dart';
class CameraPicker extends HookConsumerWidget {
@@ -12,6 +13,7 @@ class CameraPicker extends HookConsumerWidget {
final Function(Map<String, String?>) onSelect;
final SearchCameraFilter? filter;
@override
Widget build(BuildContext context, WidgetRef ref) {
final makeTextController = useTextEditingController(text: filter?.make);
@@ -32,90 +34,73 @@ class CameraPicker extends HookConsumerWidget {
),
);
final inputDecorationTheme = InputDecorationTheme(
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(20),
),
contentPadding: const EdgeInsets.only(left: 16),
final makeWidget = SearchDropdown(
dropdownMenuEntries: switch (make) {
AsyncError() => [],
AsyncData(:final value) => value
.map(
(e) => DropdownMenuEntry(
value: e,
label: e,
),
)
.toList(),
_ => [],
},
label: const Text('search_filter_camera_make').tr(),
controller: makeTextController,
leadingIcon: const Icon(Icons.photo_camera_rounded),
onSelected: (value) {
selectedMake.value = value.toString();
onSelect({
'make': selectedMake.value,
'model': selectedModel.value,
});
},
);
final menuStyle = MenuStyle(
shape: WidgetStatePropertyAll<OutlinedBorder>(
RoundedRectangleBorder(
borderRadius: BorderRadius.circular(15),
),
),
final modelWidget = SearchDropdown(
dropdownMenuEntries: switch (models) {
AsyncError() => [],
AsyncData(:final value) => value
.map(
(e) => DropdownMenuEntry(
value: e,
label: e,
),
)
.toList(),
_ => [],
},
label: const Text('search_filter_camera_model').tr(),
controller: modelTextController,
leadingIcon: const Icon(Icons.camera),
onSelected: (value) {
selectedModel.value = value.toString();
onSelect({
'make': selectedMake.value,
'model': selectedModel.value,
});
},
);
return Container(
padding: const EdgeInsets.only(
// bottom: MediaQuery.of(context).viewInsets.bottom,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
if (context.isMobile) {
return Column(
children: [
DropdownMenu(
dropdownMenuEntries: switch (make) {
AsyncError() => [],
AsyncData(:final value) => value
.map(
(e) => DropdownMenuEntry(
value: e,
label: e,
),
)
.toList(),
_ => [],
},
width: context.width * 0.45,
menuHeight: 400,
label: const Text('search_filter_camera_make').tr(),
inputDecorationTheme: inputDecorationTheme,
controller: makeTextController,
menuStyle: menuStyle,
leadingIcon: const Icon(Icons.photo_camera_rounded),
trailingIcon: const Icon(Icons.arrow_drop_down_rounded),
selectedTrailingIcon: const Icon(Icons.arrow_drop_up_rounded),
onSelected: (value) {
selectedMake.value = value.toString();
onSelect({
'make': selectedMake.value,
'model': selectedModel.value,
});
},
),
DropdownMenu(
dropdownMenuEntries: switch (models) {
AsyncError() => [],
AsyncData(:final value) => value
.map(
(e) => DropdownMenuEntry(
value: e,
label: e,
),
)
.toList(),
_ => [],
},
width: context.width * 0.45,
menuHeight: 400,
label: const Text('search_filter_camera_model').tr(),
inputDecorationTheme: inputDecorationTheme,
controller: modelTextController,
menuStyle: menuStyle,
leadingIcon: const Icon(Icons.camera),
trailingIcon: const Icon(Icons.arrow_drop_down_rounded),
selectedTrailingIcon: const Icon(Icons.arrow_drop_up_rounded),
onSelected: (value) {
selectedModel.value = value.toString();
onSelect({
'make': selectedMake.value,
'model': selectedModel.value,
});
},
),
makeWidget,
const SizedBox(height: 8),
modelWidget,
],
),
);
}
return Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
Expanded(child: makeWidget),
const SizedBox(width: 16),
Expanded(child: modelWidget),
],
);
}
}
@@ -0,0 +1,52 @@
import 'package:flutter/material.dart';
class SearchDropdown<T> extends StatelessWidget {
const SearchDropdown({
super.key,
required this.dropdownMenuEntries,
required this.controller,
this.onSelected,
this.label,
this.leadingIcon,
});
final List<DropdownMenuEntry<T>> dropdownMenuEntries;
final TextEditingController controller;
final void Function(T?)? onSelected;
final Widget? label;
final Widget? leadingIcon;
@override
Widget build(BuildContext context) {
final inputDecorationTheme = InputDecorationTheme(
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(20),
),
contentPadding: const EdgeInsets.only(left: 16),
);
final menuStyle = MenuStyle(
shape: WidgetStatePropertyAll<OutlinedBorder>(
RoundedRectangleBorder(
borderRadius: BorderRadius.circular(15),
),
),
);
return LayoutBuilder(
builder: (context, constraints) {
return DropdownMenu(
leadingIcon: leadingIcon,
width: constraints.maxWidth,
dropdownMenuEntries: dropdownMenuEntries,
label: label,
inputDecorationTheme: inputDecorationTheme,
menuStyle: menuStyle,
trailingIcon: const Icon(Icons.arrow_drop_down_rounded),
selectedTrailingIcon: const Icon(Icons.arrow_drop_up_rounded),
onSelected: onSelected,
);
},
);
}
}
@@ -38,7 +38,10 @@ class FilterBottomSheetScaffold extends StatelessWidget {
style: context.textTheme.headlineSmall,
),
),
buildChildWidget(),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: buildChildWidget(),
),
Padding(
padding: const EdgeInsets.all(8.0),
child: Row(
@@ -2,9 +2,9 @@ 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/extensions/build_context_extensions.dart';
import 'package:immich_mobile/models/search/search_filter.model.dart';
import 'package:immich_mobile/providers/search/search_filter.provider.dart';
import 'package:immich_mobile/widgets/search/search_filter/common/dropdown.dart';
import 'package:openapi/api.dart';
class LocationPicker extends HookConsumerWidget {
@@ -48,24 +48,9 @@ class LocationPicker extends HookConsumerWidget {
),
);
final inputDecorationTheme = InputDecorationTheme(
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(20),
),
contentPadding: const EdgeInsets.only(left: 16),
);
final menuStyle = MenuStyle(
shape: WidgetStatePropertyAll<OutlinedBorder>(
RoundedRectangleBorder(
borderRadius: BorderRadius.circular(15),
),
),
);
return Column(
children: [
DropdownMenu(
SearchDropdown(
dropdownMenuEntries: switch (countries) {
AsyncError() => [],
AsyncData(:final value) => value
@@ -78,14 +63,8 @@ class LocationPicker extends HookConsumerWidget {
.toList(),
_ => [],
},
menuHeight: 400,
width: context.width * 0.9,
label: const Text('search_filter_location_country').tr(),
inputDecorationTheme: inputDecorationTheme,
menuStyle: menuStyle,
controller: countryTextController,
trailingIcon: const Icon(Icons.arrow_drop_down_rounded),
selectedTrailingIcon: const Icon(Icons.arrow_drop_up_rounded),
onSelected: (value) {
if (value.toString() == selectedCountry.value) {
return;
@@ -103,7 +82,7 @@ class LocationPicker extends HookConsumerWidget {
const SizedBox(
height: 16,
),
DropdownMenu(
SearchDropdown(
dropdownMenuEntries: switch (states) {
AsyncError() => [],
AsyncData(:final value) => value
@@ -116,14 +95,8 @@ class LocationPicker extends HookConsumerWidget {
.toList(),
_ => [],
},
menuHeight: 400,
width: context.width * 0.9,
label: const Text('search_filter_location_state').tr(),
inputDecorationTheme: inputDecorationTheme,
menuStyle: menuStyle,
controller: stateTextController,
trailingIcon: const Icon(Icons.arrow_drop_down_rounded),
selectedTrailingIcon: const Icon(Icons.arrow_drop_up_rounded),
onSelected: (value) {
if (value.toString() == selectedState.value) {
return;
@@ -140,7 +113,7 @@ class LocationPicker extends HookConsumerWidget {
const SizedBox(
height: 16,
),
DropdownMenu(
SearchDropdown(
dropdownMenuEntries: switch (cities) {
AsyncError() => [],
AsyncData(:final value) => value
@@ -153,14 +126,8 @@ class LocationPicker extends HookConsumerWidget {
.toList(),
_ => [],
},
menuHeight: 400,
width: context.width * 0.9,
label: const Text('search_filter_location_city').tr(),
inputDecorationTheme: inputDecorationTheme,
menuStyle: menuStyle,
controller: cityTextController,
trailingIcon: const Icon(Icons.arrow_drop_down_rounded),
selectedTrailingIcon: const Icon(Icons.arrow_drop_up_rounded),
onSelected: (value) {
selectedCity.value = value.toString();
onSelected({
@@ -0,0 +1,76 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/widgets/map/map_thumbnail.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
class SearchMapThumbnail extends StatelessWidget {
const SearchMapThumbnail({
super.key,
this.size = 60.0,
});
final double size;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => context.pushRoute(
const MapRoute(),
),
child: SizedBox.square(
dimension: size,
child: Stack(
children: [
Padding(
padding: const EdgeInsets.only(right: 10.0),
child: MapThumbnail(
zoom: 2,
centre: const LatLng(
47,
5,
),
height: size,
width: size,
showAttribution: false,
),
),
Padding(
padding: const EdgeInsets.only(right: 10.0),
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
color: Colors.black,
gradient: LinearGradient(
begin: FractionalOffset.topCenter,
end: FractionalOffset.bottomCenter,
colors: [
Colors.blueGrey.withOpacity(0.0),
Colors.black.withOpacity(0.4),
],
stops: const [0.0, 0.4],
),
),
),
),
Align(
alignment: Alignment.bottomCenter,
child: Padding(
padding: const EdgeInsets.only(bottom: 10),
child: const Text(
"search_page_your_map",
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 14,
),
).tr(),
),
),
],
),
),
);
}
}
@@ -0,0 +1,37 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/widgets/search/search_row_title.dart';
class SearchRowSection extends StatelessWidget {
const SearchRowSection({
super.key,
required this.onViewAllPressed,
required this.title,
this.isEmpty = false,
required this.child,
});
final Function() onViewAllPressed;
final String title;
final bool isEmpty;
final Widget child;
@override
Widget build(BuildContext context) {
if (isEmpty) {
return const SizedBox.shrink();
}
return Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: SearchRowTitle(
onViewAllPressed: onViewAllPressed,
title: title,
),
),
child,
],
);
}
}
+21 -30
View File
@@ -3,45 +3,36 @@ import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
class SearchRowTitle extends StatelessWidget {
final Function() onViewAllPressed;
final String title;
final double top;
const SearchRowTitle({
super.key,
required this.onViewAllPressed,
required this.title,
this.top = 12,
});
final Function() onViewAllPressed;
final String title;
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.only(
left: 16.0,
right: 16.0,
top: top,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
title,
style: context.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w500,
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
title,
style: context.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w500,
),
),
TextButton(
onPressed: onViewAllPressed,
child: Text(
'search_page_view_all_button',
style: context.textTheme.labelLarge?.copyWith(
color: context.primaryColor,
),
),
TextButton(
onPressed: onViewAllPressed,
child: Text(
'search_page_view_all_button',
style: context.textTheme.labelLarge?.copyWith(
color: context.primaryColor,
),
).tr(),
),
],
),
).tr(),
),
],
);
}
}