Source Code added
This commit is contained in:
parent
800376eafd
commit
9efa9bc6dd
3912 changed files with 754770 additions and 2 deletions
37
mobile/lib/pages/library/archive.page.dart
Normal file
37
mobile/lib/pages/library/archive.page.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
34
mobile/lib/pages/library/favorite.page.dart
Normal file
34
mobile/lib/pages/library/favorite.page.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
249
mobile/lib/pages/library/folder/folder.page.dart
Normal file
249
mobile/lib/pages/library/folder/folder.page.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
394
mobile/lib/pages/library/library.page.dart
Normal file
394
mobile/lib/pages/library/library.page.dart
Normal 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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
49
mobile/lib/pages/library/local_albums.page.dart
Normal file
49
mobile/lib/pages/library/local_albums.page.dart
Normal 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)),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
82
mobile/lib/pages/library/locked/locked.page.dart
Normal file
82
mobile/lib/pages/library/locked/locked.page.dart
Normal 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);
|
||||
}
|
||||
121
mobile/lib/pages/library/locked/pin_auth.page.dart
Normal file
121
mobile/lib/pages/library/locked/pin_auth.page.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
140
mobile/lib/pages/library/partner/drift_partner.page.dart
Normal file
140
mobile/lib/pages/library/partner/drift_partner.page.dart
Normal 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()]))),
|
||||
);
|
||||
}
|
||||
}
|
||||
139
mobile/lib/pages/library/partner/partner.page.dart
Normal file
139
mobile/lib/pages/library/partner/partner.page.dart
Normal 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
99
mobile/lib/pages/library/partner/partner_detail.page.dart
Normal file
99
mobile/lib/pages/library/partner/partner_detail.page.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
128
mobile/lib/pages/library/people/people_collection.page.dart
Normal file
128
mobile/lib/pages/library/people/people_collection.page.dart
Normal 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()),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
139
mobile/lib/pages/library/places/places_collection.page.dart
Normal file
139
mobile/lib/pages/library/places/places_collection.page.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
107
mobile/lib/pages/library/shared_link/shared_link.page.dart
Normal file
107
mobile/lib/pages/library/shared_link/shared_link.page.dart
Normal 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(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
397
mobile/lib/pages/library/shared_link/shared_link_edit.page.dart
Normal file
397
mobile/lib/pages/library/shared_link/shared_link_edit.page.dart
Normal 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(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
225
mobile/lib/pages/library/trash.page.dart
Normal file
225
mobile/lib/pages/library/trash.page.dart
Normal 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(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue