Source Code added

This commit is contained in:
Fr4nz D13trich 2026-02-02 15:06:40 +01:00
parent 800376eafd
commit 9efa9bc6dd
3912 changed files with 754770 additions and 2 deletions

View file

@ -0,0 +1,37 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/providers/multiselect.provider.dart';
import 'package:immich_mobile/providers/timeline.provider.dart';
import 'package:immich_mobile/widgets/asset_grid/multiselect_grid.dart';
@RoutePage()
class ArchivePage extends HookConsumerWidget {
const ArchivePage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
AppBar buildAppBar() {
final archiveRenderList = ref.watch(archiveTimelineProvider);
final count = archiveRenderList.value?.totalAssets.toString() ?? "?";
return AppBar(
leading: IconButton(onPressed: () => context.maybePop(), icon: const Icon(Icons.arrow_back_ios_rounded)),
centerTitle: true,
automaticallyImplyLeading: false,
title: const Text('archive_page_title').tr(namedArgs: {'count': count}),
);
}
return Scaffold(
appBar: ref.watch(multiselectProvider) ? null : buildAppBar(),
body: MultiselectGrid(
renderListProvider: archiveTimelineProvider,
unarchive: true,
archiveEnabled: true,
deleteEnabled: true,
editEnabled: true,
),
);
}
}

View file

@ -0,0 +1,34 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/providers/multiselect.provider.dart';
import 'package:immich_mobile/providers/timeline.provider.dart';
import 'package:immich_mobile/widgets/asset_grid/multiselect_grid.dart';
@RoutePage()
class FavoritesPage extends HookConsumerWidget {
const FavoritesPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
AppBar buildAppBar() {
return AppBar(
leading: IconButton(onPressed: () => context.maybePop(), icon: const Icon(Icons.arrow_back_ios_rounded)),
centerTitle: true,
automaticallyImplyLeading: false,
title: const Text('favorites').tr(),
);
}
return Scaffold(
appBar: ref.watch(multiselectProvider) ? null : buildAppBar(),
body: MultiselectGrid(
renderListProvider: favoriteTimelineProvider,
favoriteEnabled: true,
editEnabled: true,
unfavorite: true,
),
);
}
}

View file

@ -0,0 +1,249 @@
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/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/models/folder/recursive_folder.model.dart';
import 'package:immich_mobile/models/folder/root_folder.model.dart';
import 'package:immich_mobile/pages/common/large_leading_tile.dart';
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/folder.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/bytes_units.dart';
import 'package:immich_mobile/widgets/asset_grid/thumbnail_image.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
RecursiveFolder? _findFolderInStructure(RootFolder rootFolder, RecursiveFolder targetFolder) {
for (final folder in rootFolder.subfolders) {
if (targetFolder.path == '/' && folder.path.isEmpty && folder.name == targetFolder.name) {
return folder;
}
if (folder.path == targetFolder.path && folder.name == targetFolder.name) {
return folder;
}
if (folder.subfolders.isNotEmpty) {
final found = _findFolderInStructure(folder, targetFolder);
if (found != null) return found;
}
}
return null;
}
@RoutePage()
class FolderPage extends HookConsumerWidget {
final RecursiveFolder? folder;
const FolderPage({super.key, this.folder});
@override
Widget build(BuildContext context, WidgetRef ref) {
final folderState = ref.watch(folderStructureProvider);
final currentFolder = useState<RecursiveFolder?>(folder);
final sortOrder = useState<SortOrder>(SortOrder.asc);
useEffect(() {
if (folder == null) {
ref.read(folderStructureProvider.notifier).fetchFolders(sortOrder.value);
}
return null;
}, []);
// Update current folder when root structure changes
useEffect(() {
if (folder != null && folderState.hasValue) {
final updatedFolder = _findFolderInStructure(folderState.value!, folder!);
if (updatedFolder != null) {
currentFolder.value = updatedFolder;
}
}
return null;
}, [folderState]);
void onToggleSortOrder() {
final newOrder = sortOrder.value == SortOrder.asc ? SortOrder.desc : SortOrder.asc;
ref.read(folderStructureProvider.notifier).fetchFolders(newOrder);
sortOrder.value = newOrder;
}
return Scaffold(
appBar: AppBar(
title: Text(currentFolder.value?.name ?? tr("folders")),
elevation: 0,
centerTitle: false,
actions: [IconButton(icon: const Icon(Icons.swap_vert), onPressed: onToggleSortOrder)],
),
body: folderState.when(
data: (rootFolder) {
if (folder == null) {
return FolderContent(folder: rootFolder, root: rootFolder, sortOrder: sortOrder.value);
} else {
return FolderContent(folder: currentFolder.value!, root: rootFolder, sortOrder: sortOrder.value);
}
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, stack) {
ImmichToast.show(context: context, msg: "failed_to_load_folder".tr(), toastType: ToastType.error);
return Center(child: const Text("failed_to_load_folder").tr());
},
),
);
}
}
class FolderContent extends HookConsumerWidget {
final RootFolder? folder;
final RootFolder root;
final SortOrder sortOrder;
const FolderContent({super.key, this.folder, required this.root, this.sortOrder = SortOrder.asc});
@override
Widget build(BuildContext context, WidgetRef ref) {
final folderRenderlist = ref.watch(folderRenderListProvider(folder!));
// Initial asset fetch
useEffect(() {
if (folder == null) return;
ref.read(folderRenderListProvider(folder!).notifier).fetchAssets(sortOrder);
return null;
}, [folder]);
if (folder == null) {
return Center(child: const Text("folder_not_found").tr());
}
getSubtitle(int subFolderCount) {
if (subFolderCount > 0) {
return "$subFolderCount ${tr("folders")}".toLowerCase();
}
if (subFolderCount == 1) {
return "1 ${tr("folder")}".toLowerCase();
}
return "";
}
return Column(
children: [
FolderPath(currentFolder: folder!, root: root),
Expanded(
child: folderRenderlist.when(
data: (list) {
if (folder!.subfolders.isEmpty && list.isEmpty) {
return Center(child: const Text("empty_folder").tr());
}
return ListView(
children: [
if (folder!.subfolders.isNotEmpty)
...folder!.subfolders.map(
(subfolder) => LargeLeadingTile(
leading: Icon(Icons.folder, color: context.primaryColor, size: 48),
title: Text(
subfolder.name,
softWrap: false,
overflow: TextOverflow.ellipsis,
style: context.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600),
),
subtitle: subfolder.subfolders.isNotEmpty
? Text(
getSubtitle(subfolder.subfolders.length),
style: context.textTheme.bodyMedium?.copyWith(
color: context.colorScheme.onSurfaceSecondary,
),
)
: null,
onTap: () => context.pushRoute(FolderRoute(folder: subfolder)),
),
),
if (!list.isEmpty && list.allAssets != null && list.allAssets!.isNotEmpty)
...list.allAssets!.map(
(asset) => LargeLeadingTile(
onTap: () {
ref.read(currentAssetProvider.notifier).set(asset);
context.pushRoute(
GalleryViewerRoute(renderList: list, initialIndex: list.allAssets!.indexOf(asset)),
);
},
leading: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(15)),
child: SizedBox(
width: 80,
height: 80,
child: ThumbnailImage(asset: asset, showStorageIndicator: false),
),
),
title: Text(
asset.fileName,
maxLines: 2,
softWrap: false,
overflow: TextOverflow.ellipsis,
style: context.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600),
),
subtitle: Text(
"${asset.exifInfo?.fileSize != null ? formatBytes(asset.exifInfo?.fileSize ?? 0) : ""}${DateFormat.yMMMd().format(asset.fileCreatedAt)}",
style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary),
),
),
),
],
);
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, stack) {
ImmichToast.show(context: context, msg: "failed_to_load_assets".tr(), toastType: ToastType.error);
return Center(child: const Text("failed_to_load_assets").tr());
},
),
),
],
);
}
}
class FolderPath extends StatelessWidget {
final RootFolder currentFolder;
final RootFolder root;
const FolderPath({super.key, required this.currentFolder, required this.root});
@override
Widget build(BuildContext context) {
if (currentFolder.path.isEmpty || currentFolder.path == '/') {
return const SizedBox.shrink();
}
return Container(
width: double.infinity,
alignment: Alignment.centerLeft,
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.start,
children: [
Text(
currentFolder.path,
style: TextStyle(
fontFamily: 'GoogleSansCode',
fontWeight: FontWeight.bold,
fontSize: 14,
color: context.colorScheme.onSurface.withAlpha(175),
),
),
],
),
),
),
);
}
}

View file

@ -0,0 +1,394 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/generated/intl_keys.g.dart';
import 'package:immich_mobile/providers/album/album.provider.dart';
import 'package:immich_mobile/providers/partner.provider.dart';
import 'package:immich_mobile/providers/search/people.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:immich_mobile/widgets/album/album_thumbnail_card.dart';
import 'package:immich_mobile/widgets/common/immich_app_bar.dart';
import 'package:immich_mobile/widgets/common/user_avatar.dart';
import 'package:immich_mobile/widgets/map/map_thumbnail.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
@RoutePage()
class LibraryPage extends ConsumerWidget {
const LibraryPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
context.locale;
final trashEnabled = ref.watch(serverInfoProvider.select((v) => v.serverFeatures.trash));
return Scaffold(
appBar: const ImmichAppBar(),
body: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: ListView(
shrinkWrap: true,
children: [
Padding(
padding: const EdgeInsets.only(top: 16.0),
child: Row(
children: [
ActionButton(
onPressed: () => context.pushRoute(const FavoritesRoute()),
icon: Icons.favorite_outline_rounded,
label: IntlKeys.favorites.tr(),
),
const SizedBox(width: 8),
ActionButton(
onPressed: () => context.pushRoute(const ArchiveRoute()),
icon: Icons.archive_outlined,
label: IntlKeys.archived.tr(),
),
],
),
),
const SizedBox(height: 8),
Row(
children: [
ActionButton(
onPressed: () => context.pushRoute(const SharedLinkRoute()),
icon: Icons.link_outlined,
label: IntlKeys.shared_links.tr(),
),
SizedBox(width: trashEnabled ? 8 : 0),
trashEnabled
? ActionButton(
onPressed: () => context.pushRoute(const TrashRoute()),
icon: Icons.delete_outline_rounded,
label: IntlKeys.trash.tr(),
)
: const SizedBox.shrink(),
],
),
const SizedBox(height: 12),
const Wrap(
spacing: 8,
runSpacing: 8,
children: [PeopleCollectionCard(), PlacesCollectionCard(), LocalAlbumsCollectionCard()],
),
const SizedBox(height: 12),
const QuickAccessButtons(),
const SizedBox(height: 32),
],
),
),
);
}
}
class QuickAccessButtons extends ConsumerWidget {
const QuickAccessButtons({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final partners = ref.watch(partnerSharedWithProvider);
return Container(
decoration: BoxDecoration(
border: Border.all(color: context.colorScheme.onSurface.withAlpha(10), width: 1),
borderRadius: const BorderRadius.all(Radius.circular(20)),
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: [
ListTile(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.only(
topLeft: const Radius.circular(20),
topRight: const Radius.circular(20),
bottomLeft: Radius.circular(partners.isEmpty ? 20 : 0),
bottomRight: Radius.circular(partners.isEmpty ? 20 : 0),
),
),
leading: const Icon(Icons.folder_outlined, size: 26),
title: Text(
IntlKeys.folders.tr(),
style: context.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w500),
),
onTap: () => context.pushRoute(FolderRoute()),
),
ListTile(
leading: const Icon(Icons.lock_outline_rounded, size: 26),
title: Text(
IntlKeys.locked_folder.tr(),
style: context.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w500),
),
onTap: () => context.pushRoute(const LockedRoute()),
),
ListTile(
leading: const Icon(Icons.group_outlined, size: 26),
title: Text(
IntlKeys.partners.tr(),
style: context.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w500),
),
onTap: () => context.pushRoute(const PartnerRoute()),
),
PartnerList(partners: partners),
],
),
);
}
}
class PartnerList extends ConsumerWidget {
const PartnerList({super.key, required this.partners});
final List<UserDto> partners;
@override
Widget build(BuildContext context, WidgetRef ref) {
return ListView.builder(
physics: const NeverScrollableScrollPhysics(),
itemCount: partners.length,
shrinkWrap: true,
itemBuilder: (context, index) {
final partner = partners[index];
final isLastItem = index == partners.length - 1;
return ListTile(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.only(
bottomLeft: Radius.circular(isLastItem ? 20 : 0),
bottomRight: Radius.circular(isLastItem ? 20 : 0),
),
),
contentPadding: const EdgeInsets.only(left: 12.0, right: 18.0),
leading: userAvatar(context, partner, radius: 16),
title: const Text(
"partner_list_user_photos",
style: TextStyle(fontWeight: FontWeight.w500),
).tr(namedArgs: {'user': partner.name}),
onTap: () => context.pushRoute((PartnerDetailRoute(partner: partner))),
);
},
);
}
}
class PeopleCollectionCard extends ConsumerWidget {
const PeopleCollectionCard({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final people = ref.watch(getAllPeopleProvider);
return LayoutBuilder(
builder: (context, constraints) {
final isTablet = constraints.maxWidth > 600;
final widthFactor = isTablet ? 0.25 : 0.5;
final size = context.width * widthFactor - 20.0;
return GestureDetector(
onTap: () => context.pushRoute(const PeopleCollectionRoute()),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
height: size,
width: size,
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(20)),
gradient: LinearGradient(
colors: [context.colorScheme.primary.withAlpha(30), context.colorScheme.primary.withAlpha(25)],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
),
child: people.widgetWhen(
onLoading: () => const Center(child: CircularProgressIndicator()),
onData: (people) {
return GridView.count(
crossAxisCount: 2,
padding: const EdgeInsets.all(12),
crossAxisSpacing: 8,
mainAxisSpacing: 8,
physics: const NeverScrollableScrollPhysics(),
children: people.take(4).map((person) {
return CircleAvatar(
backgroundImage: NetworkImage(
getFaceThumbnailUrl(person.id),
headers: ApiService.getRequestHeaders(),
),
);
}).toList(),
);
},
),
),
Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
IntlKeys.people.tr(),
style: context.textTheme.titleSmall?.copyWith(
color: context.colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
),
),
],
),
);
},
);
}
}
class LocalAlbumsCollectionCard extends HookConsumerWidget {
const LocalAlbumsCollectionCard({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final albums = ref.watch(localAlbumsProvider);
return LayoutBuilder(
builder: (context, constraints) {
final isTablet = constraints.maxWidth > 600;
final widthFactor = isTablet ? 0.25 : 0.5;
final size = context.width * widthFactor - 20.0;
return GestureDetector(
onTap: () => context.pushRoute(const LocalAlbumsRoute()),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
height: size,
width: size,
child: DecoratedBox(
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(20)),
gradient: LinearGradient(
colors: [context.colorScheme.primary.withAlpha(30), context.colorScheme.primary.withAlpha(25)],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
),
child: GridView.count(
crossAxisCount: 2,
padding: const EdgeInsets.all(12),
crossAxisSpacing: 8,
mainAxisSpacing: 8,
physics: const NeverScrollableScrollPhysics(),
children: albums.take(4).map((album) {
return AlbumThumbnailCard(album: album, showTitle: false);
}).toList(),
),
),
),
Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
IntlKeys.on_this_device.tr(),
style: context.textTheme.titleSmall?.copyWith(
color: context.colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
),
),
],
),
);
},
);
}
}
class PlacesCollectionCard extends StatelessWidget {
const PlacesCollectionCard({super.key});
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final isTablet = constraints.maxWidth > 600;
final widthFactor = isTablet ? 0.25 : 0.5;
final size = context.width * widthFactor - 20.0;
return GestureDetector(
onTap: () => context.pushRoute(PlacesCollectionRoute(currentLocation: null)),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
height: size,
width: size,
child: DecoratedBox(
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(20)),
color: context.colorScheme.secondaryContainer.withAlpha(100),
),
child: IgnorePointer(
child: MapThumbnail(
zoom: 8,
centre: const LatLng(21.44950, -157.91959),
showAttribution: false,
themeMode: context.isDarkTheme ? ThemeMode.dark : ThemeMode.light,
),
),
),
),
Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
IntlKeys.places.tr(),
style: context.textTheme.titleSmall?.copyWith(
color: context.colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
),
),
],
),
);
},
);
}
}
class ActionButton extends StatelessWidget {
final VoidCallback onPressed;
final IconData icon;
final String label;
const ActionButton({super.key, required this.onPressed, required this.icon, required this.label});
@override
Widget build(BuildContext context) {
return Expanded(
child: FilledButton.icon(
onPressed: onPressed,
label: Padding(
padding: const EdgeInsets.only(left: 4.0),
child: Text(label, style: TextStyle(color: context.colorScheme.onSurface, fontSize: 15)),
),
style: FilledButton.styleFrom(
elevation: 0,
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
backgroundColor: context.colorScheme.surfaceContainerLow,
alignment: Alignment.centerLeft,
shape: RoundedRectangleBorder(
borderRadius: const BorderRadius.all(Radius.circular(25)),
side: BorderSide(color: context.colorScheme.onSurface.withAlpha(10), width: 1),
),
),
icon: Icon(icon, color: context.primaryColor),
),
);
}
}

View file

@ -0,0 +1,49 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/pages/common/large_leading_tile.dart';
import 'package:immich_mobile/providers/album/album.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/widgets/common/immich_thumbnail.dart';
@RoutePage()
class LocalAlbumsPage extends HookConsumerWidget {
const LocalAlbumsPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final albums = ref.watch(localAlbumsProvider);
return Scaffold(
appBar: AppBar(title: Text('on_this_device'.tr())),
body: ListView.builder(
padding: const EdgeInsets.all(18.0),
itemCount: albums.length,
itemBuilder: (context, index) {
return Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: LargeLeadingTile(
leadingPadding: const EdgeInsets.only(right: 16),
leading: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(15)),
child: ImmichThumbnail(asset: albums[index].thumbnail.value, width: 80, height: 80),
),
title: Text(
albums[index].name,
style: context.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600),
),
subtitle: Text(
'items_count'.t(context: context, args: {'count': albums[index].assetCount}),
style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary),
),
onTap: () => context.pushRoute(AlbumViewerRoute(albumId: albums[index].id)),
),
);
},
),
);
}
}

View file

@ -0,0 +1,82 @@
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/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/auth.provider.dart';
import 'package:immich_mobile/providers/multiselect.provider.dart';
import 'package:immich_mobile/providers/timeline.provider.dart';
import 'package:immich_mobile/widgets/asset_grid/multiselect_grid.dart';
@RoutePage()
class LockedPage extends HookConsumerWidget {
const LockedPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final appLifeCycle = useAppLifecycleState();
final showOverlay = useState(false);
final authProviderNotifier = ref.read(authProvider.notifier);
// lock the page when it is destroyed
useEffect(() {
return () {
authProviderNotifier.lockPinCode();
};
}, []);
useEffect(() {
if (context.mounted) {
if (appLifeCycle == AppLifecycleState.resumed) {
showOverlay.value = false;
} else {
showOverlay.value = true;
}
}
return null;
}, [appLifeCycle]);
return Scaffold(
appBar: ref.watch(multiselectProvider) ? null : const LockPageAppBar(),
body: showOverlay.value
? const SizedBox()
: MultiselectGrid(
renderListProvider: lockedTimelineProvider,
topWidget: Padding(
padding: const EdgeInsets.all(16.0),
child: Center(child: Text('no_locked_photos_message'.tr(), style: context.textTheme.labelLarge)),
),
editEnabled: false,
favoriteEnabled: false,
unfavorite: false,
archiveEnabled: false,
stackEnabled: false,
unarchive: false,
),
);
}
}
class LockPageAppBar extends ConsumerWidget implements PreferredSizeWidget {
const LockPageAppBar({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return AppBar(
leading: IconButton(
onPressed: () {
ref.read(authProvider.notifier).lockPinCode();
context.maybePop();
},
icon: const Icon(Icons.arrow_back_ios_rounded),
),
centerTitle: true,
automaticallyImplyLeading: false,
title: const Text('locked_folder').tr(),
);
}
@override
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
}

View file

@ -0,0 +1,121 @@
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' show useState;
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/local_auth.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/widgets/forms/pin_registration_form.dart';
import 'package:immich_mobile/widgets/forms/pin_verification_form.dart';
@RoutePage()
class PinAuthPage extends HookConsumerWidget {
final bool createPinCode;
const PinAuthPage({super.key, this.createPinCode = false});
@override
Widget build(BuildContext context, WidgetRef ref) {
final localAuthState = ref.watch(localAuthProvider);
final showPinRegistrationForm = useState(createPinCode);
final isBetaTimeline = Store.isBetaTimelineEnabled;
Future<void> registerBiometric(String pinCode) async {
final isRegistered = await ref.read(localAuthProvider.notifier).registerBiometric(context, pinCode);
if (isRegistered) {
context.showSnackBar(
SnackBar(
content: Text('biometric_auth_enabled'.tr(), style: context.textTheme.labelLarge),
duration: const Duration(seconds: 3),
backgroundColor: context.colorScheme.primaryContainer,
),
);
if (isBetaTimeline) {
unawaited(context.replaceRoute(const DriftLockedFolderRoute()));
} else {
unawaited(context.replaceRoute(const LockedRoute()));
}
}
}
enableBiometricAuth() {
showDialog(
context: context,
builder: (buildContext) {
return SimpleDialog(
children: [
Container(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
PinVerificationForm(
description: 'enable_biometric_auth_description'.tr(),
onSuccess: (pinCode) {
Navigator.pop(buildContext);
registerBiometric(pinCode);
},
autoFocus: true,
icon: Icons.fingerprint_rounded,
successIcon: Icons.fingerprint_rounded,
),
],
),
),
],
);
},
);
}
return Scaffold(
appBar: AppBar(title: Text('locked_folder'.tr())),
body: ListView(
shrinkWrap: true,
children: [
Padding(
padding: const EdgeInsets.only(top: 36.0),
child: showPinRegistrationForm.value
? Center(child: PinRegistrationForm(onDone: () => showPinRegistrationForm.value = false))
: Column(
children: [
Center(
child: PinVerificationForm(
autoFocus: true,
onSuccess: (_) {
if (isBetaTimeline) {
context.replaceRoute(const DriftLockedFolderRoute());
} else {
context.replaceRoute(const LockedRoute());
}
},
),
),
const SizedBox(height: 24),
if (localAuthState.canAuthenticate) ...[
Padding(
padding: const EdgeInsets.only(right: 16.0),
child: TextButton.icon(
icon: const Icon(Icons.fingerprint, size: 28),
onPressed: enableBiometricAuth,
label: Text(
'use_biometric'.tr(),
style: context.textTheme.labelLarge?.copyWith(color: context.primaryColor, fontSize: 18),
),
),
),
],
],
),
),
],
),
);
}
}

View file

@ -0,0 +1,140 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/people/partner_user_avatar.widget.dart';
import 'package:immich_mobile/providers/infrastructure/partner.provider.dart';
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
import 'package:immich_mobile/widgets/common/confirm_dialog.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
@RoutePage()
class DriftPartnerPage extends HookConsumerWidget {
const DriftPartnerPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final potentialPartnersAsync = ref.watch(driftAvailablePartnerProvider);
addNewUsersHandler() async {
final potentialPartners = potentialPartnersAsync.value;
if (potentialPartners == null || potentialPartners.isEmpty) {
ImmichToast.show(context: context, msg: "partner_page_no_more_users".tr());
return;
}
final selectedUser = await showDialog<PartnerUserDto>(
context: context,
builder: (context) {
return SimpleDialog(
title: const Text("partner_page_select_partner").tr(),
children: [
for (PartnerUserDto partner in potentialPartners)
SimpleDialogOption(
onPressed: () => context.pop(partner),
child: Row(
children: [
Padding(
padding: const EdgeInsets.only(right: 8),
child: PartnerUserAvatar(partner: partner),
),
Text(partner.name),
],
),
),
],
);
},
);
if (selectedUser != null) {
await ref.read(partnerUsersProvider.notifier).addPartner(selectedUser);
}
}
onDeleteUser(PartnerUserDto partner) {
return showDialog(
context: context,
builder: (BuildContext context) {
return ConfirmDialog(
title: "stop_photo_sharing",
content: "partner_page_stop_sharing_content".tr(namedArgs: {'partner': partner.name}),
onOk: () => ref.read(partnerUsersProvider.notifier).removePartner(partner),
);
},
);
}
return Scaffold(
appBar: AppBar(
title: const Text("partners").t(context: context),
elevation: 0,
centerTitle: false,
actions: [
IconButton(
onPressed: potentialPartnersAsync.whenOrNull(data: (data) => addNewUsersHandler),
icon: const Icon(Icons.person_add),
tooltip: "add_partner".tr(),
),
],
),
body: _SharedToPartnerList(onAddPartner: addNewUsersHandler, onDeletePartner: onDeleteUser),
);
}
}
class _SharedToPartnerList extends ConsumerWidget {
final VoidCallback onAddPartner;
final Function(PartnerUserDto partner) onDeletePartner;
const _SharedToPartnerList({required this.onAddPartner, required this.onDeletePartner});
@override
Widget build(BuildContext context, WidgetRef ref) {
final partnerAsync = ref.watch(driftSharedByPartnerProvider);
return partnerAsync.when(
data: (partners) {
if (partners.isEmpty) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: const Text("partner_page_empty_message", style: TextStyle(fontSize: 14)).tr(),
),
Align(
alignment: Alignment.center,
child: ElevatedButton.icon(
onPressed: onAddPartner,
icon: const Icon(Icons.person_add),
label: const Text("add_partner").tr(),
),
),
],
),
);
}
return ListView.builder(
itemCount: partners.length,
itemBuilder: (context, index) {
final partner = partners[index];
return ListTile(
leading: PartnerUserAvatar(partner: partner),
title: Text(partner.name),
subtitle: Text(partner.email),
trailing: IconButton(icon: const Icon(Icons.person_remove), onPressed: () => onDeletePartner(partner)),
);
},
);
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, stack) => Center(child: Text('error_loading_partners'.tr(args: [error.toString()]))),
);
}
}

View file

@ -0,0 +1,139 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/partner.provider.dart';
import 'package:immich_mobile/services/partner.service.dart';
import 'package:immich_mobile/widgets/common/confirm_dialog.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:immich_mobile/widgets/common/user_avatar.dart';
@RoutePage()
class PartnerPage extends HookConsumerWidget {
const PartnerPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final List<UserDto> partners = ref.watch(partnerSharedByProvider);
final availableUsers = ref.watch(partnerAvailableProvider);
addNewUsersHandler() async {
final users = availableUsers.value;
if (users == null || users.isEmpty) {
ImmichToast.show(context: context, msg: "partner_page_no_more_users".tr());
return;
}
final selectedUser = await showDialog<UserDto>(
context: context,
builder: (context) {
return SimpleDialog(
title: const Text("partner_page_select_partner").tr(),
children: [
for (UserDto u in users)
SimpleDialogOption(
onPressed: () => context.pop(u),
child: Row(
children: [
Padding(padding: const EdgeInsets.only(right: 8), child: userAvatar(context, u)),
Text(u.name),
],
),
),
],
);
},
);
if (selectedUser != null) {
final ok = await ref.read(partnerServiceProvider).addPartner(selectedUser);
if (ok) {
ref.invalidate(partnerSharedByProvider);
} else {
ImmichToast.show(context: context, msg: "partner_page_partner_add_failed".tr(), toastType: ToastType.error);
}
}
}
onDeleteUser(UserDto u) {
return showDialog(
context: context,
builder: (BuildContext context) {
return ConfirmDialog(
title: "stop_photo_sharing",
content: "partner_page_stop_sharing_content".tr(namedArgs: {'partner': u.name}),
onOk: () => ref.read(partnerServiceProvider).removePartner(u),
);
},
);
}
buildUserList(List<UserDto> users) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(left: 16.0, top: 16.0),
child: Text(
"partner_page_shared_to_title",
style: context.textTheme.titleSmall?.copyWith(color: context.colorScheme.onSurface.withAlpha(200)),
).tr(),
),
if (users.isNotEmpty)
ListView.builder(
shrinkWrap: true,
itemCount: users.length,
itemBuilder: ((context, index) {
return ListTile(
leading: userAvatar(context, users[index]),
title: Text(users[index].email, style: context.textTheme.bodyLarge),
trailing: IconButton(
icon: const Icon(Icons.person_remove),
onPressed: () => onDeleteUser(users[index]),
),
);
}),
),
if (users.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: const Text("partner_page_empty_message", style: TextStyle(fontSize: 14)).tr(),
),
Align(
alignment: Alignment.center,
child: ElevatedButton.icon(
onPressed: availableUsers.whenOrNull(data: (data) => addNewUsersHandler),
icon: const Icon(Icons.person_add),
label: const Text("add_partner").tr(),
),
),
],
),
),
],
);
}
return Scaffold(
appBar: AppBar(
title: const Text("partners").tr(),
elevation: 0,
centerTitle: false,
actions: [
IconButton(
onPressed: availableUsers.whenOrNull(data: (data) => addNewUsersHandler),
icon: const Icon(Icons.person_add),
tooltip: "add_partner".tr(),
),
],
),
body: buildUserList(partners),
);
}
}

View file

@ -0,0 +1,99 @@
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/domain/models/user.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/asset.provider.dart';
import 'package:immich_mobile/providers/multiselect.provider.dart';
import 'package:immich_mobile/providers/partner.provider.dart';
import 'package:immich_mobile/providers/timeline.provider.dart';
import 'package:immich_mobile/widgets/asset_grid/multiselect_grid.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
@RoutePage()
class PartnerDetailPage extends HookConsumerWidget {
const PartnerDetailPage({super.key, required this.partner});
final UserDto partner;
@override
Widget build(BuildContext context, WidgetRef ref) {
final inTimeline = useState(partner.inTimeline);
bool toggleInProcess = false;
useEffect(() {
Future.microtask(() async => {await ref.read(assetProvider.notifier).getAllAsset()});
return null;
}, []);
void toggleInTimeline() async {
if (toggleInProcess) return;
toggleInProcess = true;
try {
final ok = await ref
.read(partnerSharedWithProvider.notifier)
.updatePartner(partner, inTimeline: !inTimeline.value);
if (ok) {
inTimeline.value = !inTimeline.value;
final action = inTimeline.value ? "shown on" : "hidden from";
ImmichToast.show(
context: context,
toastType: ToastType.success,
durationInSecond: 1,
msg: "${partner.name}'s assets $action your timeline",
);
} else {
ImmichToast.show(
context: context,
toastType: ToastType.error,
durationInSecond: 1,
msg: "Failed to toggle the timeline setting",
);
}
} finally {
toggleInProcess = false;
}
}
return Scaffold(
appBar: ref.watch(multiselectProvider)
? null
: AppBar(title: Text(partner.name), elevation: 0, centerTitle: false),
body: MultiselectGrid(
topWidget: Padding(
padding: const EdgeInsets.only(left: 8.0, right: 8.0, top: 16.0),
child: Container(
decoration: BoxDecoration(
border: Border.all(color: context.colorScheme.onSurface.withAlpha(10), width: 1),
borderRadius: const BorderRadius.all(Radius.circular(20)),
gradient: LinearGradient(
colors: [context.colorScheme.primary.withAlpha(10), context.colorScheme.primary.withAlpha(15)],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
),
child: Padding(
padding: const EdgeInsets.all(8.0),
child: ListTile(
title: Text(
"Show in timeline",
style: context.textTheme.titleSmall?.copyWith(color: context.colorScheme.primary),
),
subtitle: Text(
"Show photos and videos from this user in your timeline",
style: context.textTheme.bodyMedium,
),
trailing: Switch(value: inTimeline.value, onChanged: (_) => toggleInTimeline()),
),
),
),
),
renderListProvider: singleUserTimelineProvider(partner.id),
onRefresh: () => ref.read(assetProvider.notifier).getAllAsset(),
deleteEnabled: false,
favoriteEnabled: false,
),
);
}
}

View file

@ -0,0 +1,128 @@
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/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/search/people.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:immich_mobile/widgets/common/search_field.dart';
import 'package:immich_mobile/widgets/search/person_name_edit_form.dart';
@RoutePage()
class PeopleCollectionPage extends HookConsumerWidget {
const PeopleCollectionPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final people = ref.watch(getAllPeopleProvider);
final headers = ApiService.getRequestHeaders();
final formFocus = useFocusNode();
final ValueNotifier<String?> search = useState(null);
showNameEditModel(String personId, String personName) {
return showDialog(
context: context,
useRootNavigator: false,
builder: (BuildContext context) {
return PersonNameEditForm(personId: personId, personName: personName);
},
);
}
return LayoutBuilder(
builder: (context, constraints) {
final isTablet = constraints.maxWidth > 600;
final isPortrait = context.orientation == Orientation.portrait;
return Scaffold(
appBar: AppBar(
automaticallyImplyLeading: search.value == null,
title: search.value != null
? SearchField(
focusNode: formFocus,
onTapOutside: (_) => formFocus.unfocus(),
onChanged: (value) => search.value = value,
filled: true,
hintText: 'filter_people'.tr(),
autofocus: true,
)
: Text('people'.tr()),
actions: [
IconButton(
icon: Icon(search.value != null ? Icons.close : Icons.search),
onPressed: () {
search.value = search.value == null ? '' : null;
},
),
],
),
body: SafeArea(
child: people.when(
data: (people) {
if (search.value != null) {
people = people.where((person) {
return person.name.toLowerCase().contains(search.value!.toLowerCase());
}).toList();
}
return GridView.builder(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: isTablet ? 6 : 3,
childAspectRatio: 0.85,
mainAxisSpacing: isPortrait && isTablet ? 36 : 0,
),
padding: const EdgeInsets.symmetric(vertical: 32),
itemCount: people.length,
itemBuilder: (context, index) {
final person = people[index];
return Column(
children: [
GestureDetector(
onTap: () {
context.pushRoute(PersonResultRoute(personId: person.id, personName: person.name));
},
child: Material(
shape: const CircleBorder(side: BorderSide.none),
elevation: 3,
child: CircleAvatar(
maxRadius: isTablet ? 120 / 2 : 96 / 2,
backgroundImage: NetworkImage(getFaceThumbnailUrl(person.id), headers: headers),
),
),
),
const SizedBox(height: 12),
GestureDetector(
onTap: () => showNameEditModel(person.id, person.name),
child: person.name.isEmpty
? Text(
'add_a_name'.tr(),
style: context.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w500,
color: context.colorScheme.primary,
),
)
: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Text(
person.name,
overflow: TextOverflow.ellipsis,
style: context.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w500),
),
),
),
],
);
},
);
},
error: (error, stack) => const Text("error"),
loading: () => const Center(child: CircularProgressIndicator()),
),
),
);
},
);
}
}

View file

@ -0,0 +1,139 @@
import 'package:auto_route/auto_route.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/models/search/search_filter.model.dart';
import 'package:immich_mobile/pages/common/large_leading_tile.dart';
import 'package:immich_mobile/providers/search/search_page_state.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/widgets/common/search_field.dart';
import 'package:immich_mobile/widgets/map/map_thumbnail.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
@RoutePage()
class PlacesCollectionPage extends HookConsumerWidget {
const PlacesCollectionPage({super.key, this.currentLocation});
final LatLng? currentLocation;
@override
Widget build(BuildContext context, WidgetRef ref) {
final places = ref.watch(getAllPlacesProvider);
final formFocus = useFocusNode();
final ValueNotifier<String?> search = useState(null);
return Scaffold(
appBar: AppBar(
automaticallyImplyLeading: search.value == null,
title: search.value != null
? SearchField(
autofocus: true,
filled: true,
focusNode: formFocus,
onChanged: (value) => search.value = value,
onTapOutside: (_) => formFocus.unfocus(),
hintText: 'filter_places'.tr(),
)
: Text('places'.tr()),
actions: [
IconButton(
icon: Icon(search.value != null ? Icons.close : Icons.search),
onPressed: () {
search.value = search.value == null ? '' : null;
},
),
],
),
body: ListView(
shrinkWrap: true,
children: [
if (search.value == null)
Padding(
padding: const EdgeInsets.all(16.0),
child: SizedBox(
height: 200,
width: context.width,
child: MapThumbnail(
onTap: (_, __) => context.pushRoute(MapRoute(initialLocation: currentLocation)),
zoom: 8,
centre: currentLocation ?? const LatLng(21.44950, -157.91959),
showAttribution: false,
themeMode: context.isDarkTheme ? ThemeMode.dark : ThemeMode.light,
),
),
),
places.when(
data: (places) {
if (search.value != null) {
places = places.where((place) {
return place.label.toLowerCase().contains(search.value!.toLowerCase());
}).toList();
}
return ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: places.length,
itemBuilder: (context, index) {
final place = places[index];
return PlaceTile(id: place.id, name: place.label);
},
);
},
error: (error, stask) => Text('error_getting_places'.tr()),
loading: () => const Center(child: CircularProgressIndicator()),
),
],
),
);
}
}
class PlaceTile extends StatelessWidget {
const PlaceTile({super.key, required this.id, required this.name});
final String id;
final String name;
@override
Widget build(BuildContext context) {
final thumbnailUrl = '${Store.get(StoreKey.serverEndpoint)}/assets/$id/thumbnail';
void navigateToPlace() {
context.pushRoute(
SearchRoute(
prefilter: SearchFilter(
people: {},
location: SearchLocationFilter(city: name),
camera: SearchCameraFilter(),
date: SearchDateFilter(),
display: SearchDisplayFilters(isNotInAlbum: false, isArchive: false, isFavorite: false),
rating: SearchRatingFilter(),
mediaType: AssetType.other,
),
),
);
}
return LargeLeadingTile(
onTap: () => navigateToPlace(),
title: Text(name, style: context.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w500)),
leading: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(20)),
child: CachedNetworkImage(
width: 80,
height: 80,
fit: BoxFit.cover,
imageUrl: thumbnailUrl,
httpHeaders: ApiService.getRequestHeaders(),
errorWidget: (context, url, error) => const Icon(Icons.image_not_supported_outlined),
),
),
);
}
}

View file

@ -0,0 +1,107 @@
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/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/models/shared_link/shared_link.model.dart';
import 'package:immich_mobile/providers/shared_link.provider.dart';
import 'package:immich_mobile/widgets/shared_link/shared_link_item.dart';
@RoutePage()
class SharedLinkPage extends HookConsumerWidget {
const SharedLinkPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final sharedLinks = ref.watch(sharedLinksStateProvider);
useEffect(() {
ref.read(sharedLinksStateProvider.notifier).fetchLinks();
return () {
if (!context.mounted) return;
ref.invalidate(sharedLinksStateProvider);
};
}, []);
Widget buildNoShares() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(left: 16.0, top: 16.0),
child: const Text(
"shared_link_manage_links",
style: TextStyle(fontSize: 14, color: Colors.grey, fontWeight: FontWeight.bold),
).tr(),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 10),
child: const Text("you_dont_have_any_shared_links", style: TextStyle(fontSize: 14)).tr(),
),
),
Expanded(
child: Center(
child: Icon(Icons.link_off, size: 100, color: context.themeData.iconTheme.color?.withValues(alpha: 0.5)),
),
),
],
);
}
Widget buildSharesList(List<SharedLink> links) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(left: 16.0, top: 16.0, bottom: 30.0),
child: Text(
"shared_link_manage_links",
style: context.textTheme.labelLarge?.copyWith(color: context.textTheme.labelLarge?.color?.withAlpha(200)),
).tr(),
),
Expanded(
child: LayoutBuilder(
builder: (context, constraints) {
if (constraints.maxWidth > 600) {
// Two column
return GridView.builder(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
mainAxisExtent: 180,
),
itemCount: links.length,
itemBuilder: (context, index) {
return SharedLinkItem(links.elementAt(index));
},
);
}
// Single column
return ListView.builder(
itemCount: links.length,
itemBuilder: (context, index) {
return SharedLinkItem(links.elementAt(index));
},
);
},
),
),
],
);
}
return Scaffold(
appBar: AppBar(title: const Text("shared_link_app_bar_title").tr(), elevation: 0, centerTitle: false),
body: SafeArea(
child: sharedLinks.widgetWhen(
onError: (error, stackTrace) => buildNoShares(),
onData: (links) => links.isNotEmpty ? buildSharesList(links) : buildNoShares(),
),
),
);
}
}

View file

@ -0,0 +1,397 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/models/shared_link/shared_link.model.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/shared_link.provider.dart';
import 'package:immich_mobile/services/shared_link.service.dart';
import 'package:immich_mobile/utils/url_helper.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
@RoutePage()
class SharedLinkEditPage extends HookConsumerWidget {
final SharedLink? existingLink;
final List<String>? assetsList;
final String? albumId;
const SharedLinkEditPage({super.key, this.existingLink, this.assetsList, this.albumId});
@override
Widget build(BuildContext context, WidgetRef ref) {
const padding = 20.0;
final themeData = context.themeData;
final colorScheme = context.colorScheme;
final descriptionController = useTextEditingController(text: existingLink?.description ?? "");
final descriptionFocusNode = useFocusNode();
final passwordController = useTextEditingController(text: existingLink?.password ?? "");
final showMetadata = useState(existingLink?.showMetadata ?? true);
final allowDownload = useState(existingLink?.allowDownload ?? true);
final allowUpload = useState(existingLink?.allowUpload ?? false);
final editExpiry = useState(false);
final expiryAfter = useState(0);
final newShareLink = useState("");
Widget buildLinkTitle() {
if (existingLink != null) {
if (existingLink!.type == SharedLinkSource.album) {
return Row(
children: [
const Text('public_album', style: TextStyle(fontWeight: FontWeight.bold)).tr(),
const Text(" | ", style: TextStyle(fontWeight: FontWeight.bold)),
Text(
existingLink!.title,
style: TextStyle(color: colorScheme.primary, fontWeight: FontWeight.bold),
),
],
);
}
if (existingLink!.type == SharedLinkSource.individual) {
return Row(
children: [
const Text('shared_link_individual_shared', style: TextStyle(fontWeight: FontWeight.bold)).tr(),
const Text(" | ", style: TextStyle(fontWeight: FontWeight.bold)),
Expanded(
child: Text(
existingLink!.description ?? "--",
style: TextStyle(color: colorScheme.primary, fontWeight: FontWeight.bold),
overflow: TextOverflow.ellipsis,
),
),
],
);
}
}
return const Text("create_link_to_share_description", style: TextStyle(fontWeight: FontWeight.bold)).tr();
}
Widget buildDescriptionField() {
return TextField(
controller: descriptionController,
enabled: newShareLink.value.isEmpty,
focusNode: descriptionFocusNode,
textInputAction: TextInputAction.done,
autofocus: false,
decoration: InputDecoration(
labelText: 'description'.tr(),
labelStyle: TextStyle(fontWeight: FontWeight.bold, color: colorScheme.primary),
floatingLabelBehavior: FloatingLabelBehavior.always,
border: const OutlineInputBorder(),
hintText: 'shared_link_edit_description_hint'.tr(),
hintStyle: const TextStyle(fontWeight: FontWeight.normal, fontSize: 14),
disabledBorder: OutlineInputBorder(borderSide: BorderSide(color: Colors.grey.withValues(alpha: 0.5))),
),
onTapOutside: (_) => descriptionFocusNode.unfocus(),
);
}
Widget buildPasswordField() {
return TextField(
controller: passwordController,
enabled: newShareLink.value.isEmpty,
autofocus: false,
decoration: InputDecoration(
labelText: 'password'.tr(),
labelStyle: TextStyle(fontWeight: FontWeight.bold, color: colorScheme.primary),
floatingLabelBehavior: FloatingLabelBehavior.always,
border: const OutlineInputBorder(),
hintText: 'shared_link_edit_password_hint'.tr(),
hintStyle: const TextStyle(fontWeight: FontWeight.normal, fontSize: 14),
disabledBorder: OutlineInputBorder(borderSide: BorderSide(color: Colors.grey.withValues(alpha: 0.5))),
),
);
}
Widget buildShowMetaButton() {
return SwitchListTile.adaptive(
value: showMetadata.value,
onChanged: newShareLink.value.isEmpty ? (value) => showMetadata.value = value : null,
activeThumbColor: colorScheme.primary,
dense: true,
title: Text("show_metadata", style: themeData.textTheme.labelLarge?.copyWith(fontWeight: FontWeight.bold)).tr(),
);
}
Widget buildAllowDownloadButton() {
return SwitchListTile.adaptive(
value: allowDownload.value,
onChanged: newShareLink.value.isEmpty ? (value) => allowDownload.value = value : null,
activeThumbColor: colorScheme.primary,
dense: true,
title: Text(
"allow_public_user_to_download",
style: themeData.textTheme.labelLarge?.copyWith(fontWeight: FontWeight.bold),
).tr(),
);
}
Widget buildAllowUploadButton() {
return SwitchListTile.adaptive(
value: allowUpload.value,
onChanged: newShareLink.value.isEmpty ? (value) => allowUpload.value = value : null,
activeThumbColor: colorScheme.primary,
dense: true,
title: Text(
"allow_public_user_to_upload",
style: themeData.textTheme.labelLarge?.copyWith(fontWeight: FontWeight.bold),
).tr(),
);
}
Widget buildEditExpiryButton() {
return SwitchListTile.adaptive(
value: editExpiry.value,
onChanged: newShareLink.value.isEmpty ? (value) => editExpiry.value = value : null,
activeThumbColor: colorScheme.primary,
dense: true,
title: Text(
"change_expiration_time",
style: themeData.textTheme.labelLarge?.copyWith(fontWeight: FontWeight.bold),
).tr(),
);
}
Widget buildExpiryAfterButton() {
return DropdownMenu(
label: Text(
"expire_after",
style: TextStyle(fontWeight: FontWeight.bold, color: colorScheme.primary),
).tr(),
enableSearch: false,
enableFilter: false,
width: context.width - 40,
initialSelection: expiryAfter.value,
enabled: newShareLink.value.isEmpty && (existingLink == null || editExpiry.value),
onSelected: (value) {
expiryAfter.value = value!;
},
dropdownMenuEntries: [
DropdownMenuEntry(value: 0, label: "never".tr()),
DropdownMenuEntry(
value: 30,
label: "shared_link_edit_expire_after_option_minutes".tr(namedArgs: {'count': "30"}),
),
DropdownMenuEntry(value: 60, label: "shared_link_edit_expire_after_option_hour".tr()),
DropdownMenuEntry(
value: 60 * 6,
label: "shared_link_edit_expire_after_option_hours".tr(namedArgs: {'count': "6"}),
),
DropdownMenuEntry(value: 60 * 24, label: "shared_link_edit_expire_after_option_day".tr()),
DropdownMenuEntry(
value: 60 * 24 * 7,
label: "shared_link_edit_expire_after_option_days".tr(namedArgs: {'count': "7"}),
),
DropdownMenuEntry(
value: 60 * 24 * 30,
label: "shared_link_edit_expire_after_option_days".tr(namedArgs: {'count': "30"}),
),
DropdownMenuEntry(
value: 60 * 24 * 30 * 3,
label: "shared_link_edit_expire_after_option_months".tr(namedArgs: {'count': "3"}),
),
DropdownMenuEntry(
value: 60 * 24 * 30 * 12,
label: "shared_link_edit_expire_after_option_year".tr(namedArgs: {'count': "1"}),
),
],
);
}
void copyLinkToClipboard() {
Clipboard.setData(ClipboardData(text: newShareLink.value)).then((_) {
context.scaffoldMessenger.showSnackBar(
SnackBar(
content: Text(
"shared_link_clipboard_copied_massage",
style: context.textTheme.bodyLarge?.copyWith(color: context.primaryColor),
).tr(),
duration: const Duration(seconds: 2),
),
);
});
}
Widget buildNewLinkField() {
return Column(
children: [
const Padding(padding: EdgeInsets.only(top: 20, bottom: 20), child: Divider()),
TextFormField(
readOnly: true,
initialValue: newShareLink.value,
decoration: InputDecoration(
border: const OutlineInputBorder(),
enabledBorder: themeData.inputDecorationTheme.focusedBorder,
suffixIcon: IconButton(onPressed: copyLinkToClipboard, icon: const Icon(Icons.copy)),
),
),
Padding(
padding: const EdgeInsets.only(top: 16.0),
child: Align(
alignment: Alignment.bottomRight,
child: ElevatedButton(
onPressed: () {
context.maybePop();
},
child: const Text("done", style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold)).tr(),
),
),
),
],
);
}
DateTime calculateExpiry() {
return DateTime.now().add(Duration(minutes: expiryAfter.value));
}
Future<void> handleNewLink() async {
final newLink = await ref
.read(sharedLinkServiceProvider)
.createSharedLink(
albumId: albumId,
assetIds: assetsList,
showMeta: showMetadata.value,
allowDownload: allowDownload.value,
allowUpload: allowUpload.value,
description: descriptionController.text.isEmpty ? null : descriptionController.text,
password: passwordController.text.isEmpty ? null : passwordController.text,
expiresAt: expiryAfter.value == 0 ? null : calculateExpiry(),
);
ref.invalidate(sharedLinksStateProvider);
await ref.read(serverInfoProvider.notifier).getServerConfig();
final externalDomain = ref.read(serverInfoProvider.select((s) => s.serverConfig.externalDomain));
var serverUrl = externalDomain.isNotEmpty ? externalDomain : getServerUrl();
if (serverUrl != null && !serverUrl.endsWith('/')) {
serverUrl += '/';
}
if (newLink != null && serverUrl != null) {
newShareLink.value = "${serverUrl}share/${newLink.key}";
copyLinkToClipboard();
} else if (newLink == null) {
ImmichToast.show(
context: context,
gravity: ToastGravity.BOTTOM,
toastType: ToastType.error,
msg: 'shared_link_create_error'.tr(),
);
}
}
Future<void> handleEditLink() async {
bool? download;
bool? upload;
bool? meta;
String? desc;
String? password;
DateTime? expiry;
bool? changeExpiry;
if (allowDownload.value != existingLink!.allowDownload) {
download = allowDownload.value;
}
if (allowUpload.value != existingLink!.allowUpload) {
upload = allowUpload.value;
}
if (showMetadata.value != existingLink!.showMetadata) {
meta = showMetadata.value;
}
if (descriptionController.text != existingLink!.description) {
desc = descriptionController.text;
}
if (passwordController.text != existingLink!.password) {
password = passwordController.text;
}
if (editExpiry.value) {
expiry = expiryAfter.value == 0 ? null : calculateExpiry();
changeExpiry = true;
}
await ref
.read(sharedLinkServiceProvider)
.updateSharedLink(
existingLink!.id,
showMeta: meta,
allowDownload: download,
allowUpload: upload,
description: desc,
password: password,
expiresAt: expiry,
changeExpiry: changeExpiry,
);
ref.invalidate(sharedLinksStateProvider);
await context.maybePop();
}
return Scaffold(
appBar: AppBar(
title: Text(existingLink == null ? "create_link_to_share" : "edit_link").tr(),
elevation: 0,
leading: const CloseButton(),
centerTitle: false,
),
body: SafeArea(
child: ListView(
children: [
Padding(padding: const EdgeInsets.all(padding), child: buildLinkTitle()),
Padding(padding: const EdgeInsets.all(padding), child: buildDescriptionField()),
Padding(padding: const EdgeInsets.all(padding), child: buildPasswordField()),
Padding(
padding: const EdgeInsets.only(left: padding, right: padding, bottom: padding),
child: buildShowMetaButton(),
),
Padding(
padding: const EdgeInsets.only(left: padding, right: padding, bottom: padding),
child: buildAllowDownloadButton(),
),
Padding(
padding: const EdgeInsets.only(left: padding, right: 20, bottom: 20),
child: buildAllowUploadButton(),
),
if (existingLink != null)
Padding(
padding: const EdgeInsets.only(left: padding, right: padding, bottom: padding),
child: buildEditExpiryButton(),
),
Padding(
padding: const EdgeInsets.only(left: padding, right: padding, bottom: padding),
child: buildExpiryAfterButton(),
),
if (newShareLink.value.isEmpty)
Align(
alignment: Alignment.bottomRight,
child: Padding(
padding: const EdgeInsets.only(right: padding + 10, bottom: padding),
child: ElevatedButton(
onPressed: existingLink != null ? handleEditLink : handleNewLink,
child: Text(
existingLink != null ? "shared_link_edit_submit_button" : "create_link",
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
).tr(),
),
),
),
if (newShareLink.value.isNotEmpty)
Padding(
padding: const EdgeInsets.only(left: padding, right: padding, bottom: padding),
child: buildNewLinkField(),
),
],
),
),
);
}
}

View file

@ -0,0 +1,225 @@
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:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/asset.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/timeline.provider.dart';
import 'package:immich_mobile/providers/trash.provider.dart';
import 'package:immich_mobile/utils/immich_loading_overlay.dart';
import 'package:immich_mobile/widgets/asset_grid/delete_dialog.dart';
import 'package:immich_mobile/widgets/asset_grid/immich_asset_grid.dart';
import 'package:immich_mobile/widgets/common/confirm_dialog.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
@RoutePage()
class TrashPage extends HookConsumerWidget {
const TrashPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final trashRenderList = ref.watch(trashTimelineProvider);
final trashDays = ref.watch(serverInfoProvider.select((v) => v.serverConfig.trashDays));
final selectionEnabledHook = useState(false);
final selection = useState(<Asset>{});
final processing = useProcessingOverlay();
void selectionListener(bool multiselect, Set<Asset> selectedAssets) {
selectionEnabledHook.value = multiselect;
selection.value = selectedAssets;
}
onEmptyTrash() async {
processing.value = true;
await ref.read(trashProvider.notifier).emptyTrash();
processing.value = false;
selectionEnabledHook.value = false;
if (context.mounted) {
ImmichToast.show(context: context, msg: 'trash_emptied'.tr(), gravity: ToastGravity.BOTTOM);
}
}
handleEmptyTrash() async {
await showDialog(
context: context,
builder: (context) => ConfirmDialog(
onOk: () => onEmptyTrash(),
title: "empty_trash".tr(),
ok: "ok".tr(),
content: "trash_page_empty_trash_dialog_content".tr(),
),
);
}
Future<void> onPermanentlyDelete() async {
processing.value = true;
try {
if (selection.value.isNotEmpty) {
final isRemoved = await ref.read(assetProvider.notifier).deleteAssets(selection.value, force: true);
if (isRemoved) {
if (context.mounted) {
ImmichToast.show(
context: context,
msg: 'assets_deleted_permanently'.tr(namedArgs: {'count': "${selection.value.length}"}),
gravity: ToastGravity.BOTTOM,
);
}
}
}
} finally {
processing.value = false;
selectionEnabledHook.value = false;
}
}
handlePermanentDelete() async {
await showDialog(
context: context,
builder: (context) => DeleteDialog(alert: "delete_dialog_alert_remote", onDelete: () => onPermanentlyDelete()),
);
}
Future<void> handleRestoreAll() async {
processing.value = true;
await ref.read(trashProvider.notifier).restoreTrash();
processing.value = false;
selectionEnabledHook.value = false;
}
Future<void> handleRestore() async {
processing.value = true;
try {
if (selection.value.isNotEmpty) {
final result = await ref.read(trashProvider.notifier).restoreAssets(selection.value);
if (result && context.mounted) {
ImmichToast.show(
context: context,
msg: 'assets_restored_successfully'.tr(namedArgs: {'count': "${selection.value.length}"}),
gravity: ToastGravity.BOTTOM,
);
}
}
} finally {
processing.value = false;
selectionEnabledHook.value = false;
}
}
String getAppBarTitle(String count) {
if (selectionEnabledHook.value) {
return selection.value.isNotEmpty ? "${selection.value.length}" : "trash_page_select_assets_btn".tr();
}
return 'trash_page_title'.tr(namedArgs: {'count': count});
}
AppBar buildAppBar(String count) {
return AppBar(
leading: IconButton(
onPressed: !selectionEnabledHook.value
? () => context.maybePop()
: () {
selectionEnabledHook.value = false;
selection.value = {};
},
icon: !selectionEnabledHook.value
? const Icon(Icons.arrow_back_ios_rounded)
: const Icon(Icons.close_rounded),
),
centerTitle: !selectionEnabledHook.value,
automaticallyImplyLeading: false,
title: Text(getAppBarTitle(count)),
actions: <Widget>[
if (!selectionEnabledHook.value)
PopupMenuButton<void Function()>(
itemBuilder: (context) {
return [
PopupMenuItem(value: () => selectionEnabledHook.value = true, child: const Text('select').tr()),
PopupMenuItem(value: handleEmptyTrash, child: const Text('empty_trash').tr()),
];
},
onSelected: (fn) => fn(),
),
],
);
}
Widget buildBottomBar() {
return SafeArea(
child: Align(
alignment: Alignment.bottomCenter,
child: SizedBox(
height: 64,
child: Container(
color: context.themeData.canvasColor,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
TextButton.icon(
icon: Icon(Icons.delete_forever, color: Colors.red[400]),
label: Text(
selection.value.isEmpty ? 'trash_page_delete_all'.tr() : 'delete'.tr(),
style: TextStyle(fontSize: 14, color: Colors.red[400], fontWeight: FontWeight.bold),
),
onPressed: processing.value
? null
: selection.value.isEmpty
? handleEmptyTrash
: handlePermanentDelete,
),
TextButton.icon(
icon: const Icon(Icons.history_rounded),
label: Text(
selection.value.isEmpty ? 'trash_page_restore_all'.tr() : 'restore'.tr(),
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
),
onPressed: processing.value
? null
: selection.value.isEmpty
? handleRestoreAll
: handleRestore,
),
],
),
),
),
),
);
}
return Scaffold(
appBar: trashRenderList.maybeWhen(
orElse: () => buildAppBar("?"),
data: (data) => buildAppBar(data.totalAssets.toString()),
),
body: trashRenderList.widgetWhen(
onData: (data) => data.isEmpty
? Center(child: Text('trash_page_no_assets'.tr()))
: Stack(
children: [
SafeArea(
child: ImmichAssetGrid(
renderList: data,
listener: selectionListener,
selectionActive: selectionEnabledHook.value,
showMultiSelectIndicator: false,
showStack: true,
topWidget: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 24),
child: const Text("trash_page_info").tr(namedArgs: {"days": "$trashDays"}),
),
),
),
if (selectionEnabledHook.value) buildBottomBar(),
],
),
),
);
}
}