diff --git a/mobile/assets/i18n/en-US.json b/mobile/assets/i18n/en-US.json index bb4f3efd267c6..8c81b5dd48ad1 100644 --- a/mobile/assets/i18n/en-US.json +++ b/mobile/assets/i18n/en-US.json @@ -1,4 +1,15 @@ { + "collections": "Collections", + "on_this_device": "On this device", + "add_a_name": "Add a name", + "places": "Places", + "albums": "Albums", + "people": "People", + "shared_links": "Shared links", + "trash": "Trash", + "archived": "Archived", + "favorites": "Favorites", + "search_albums": "Search albums", "action_common_back": "Back", "action_common_cancel": "Cancel", "action_common_clear": "Clear", diff --git a/mobile/lib/pages/collections/albums/albums_collection.page.dart b/mobile/lib/pages/collections/albums/albums_collection.page.dart new file mode 100644 index 0000000000000..07912b681def1 --- /dev/null +++ b/mobile/lib/pages/collections/albums/albums_collection.page.dart @@ -0,0 +1,405 @@ +import 'dart:async'; +import 'dart:math'; + +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/extensions/theme_extensions.dart'; +import 'package:immich_mobile/pages/common/large_leading_tile.dart'; +import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart'; +import 'package:immich_mobile/providers/album/albumv2.provider.dart'; +import 'package:immich_mobile/providers/user.provider.dart'; +import 'package:immich_mobile/routing/router.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/immich_thumbnail.dart'; + +enum QuickFilterMode { + all, + sharedWithMe, + myAlbums, +} + +@RoutePage() +class AlbumsCollectionPage extends HookConsumerWidget { + const AlbumsCollectionPage({super.key, this.showImmichAppbar = false}); + + final bool showImmichAppbar; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final albums = + ref.watch(albumProviderV2).where((album) => album.isRemote).toList(); + final albumSortOption = ref.watch(albumSortByOptionsProvider); + final albumSortIsReverse = ref.watch(albumSortOrderProvider); + final sorted = albumSortOption.sortFn(albums, albumSortIsReverse); + final isGrid = useState(false); + final searchController = useTextEditingController(); + final debounceTimer = useRef(null); + final filterMode = useState(QuickFilterMode.all); + final userId = ref.watch(currentUserProvider)?.id; + + toggleViewMode() { + isGrid.value = !isGrid.value; + } + + onSearch(String value) { + debounceTimer.value?.cancel(); + debounceTimer.value = Timer(const Duration(milliseconds: 300), () { + filterMode.value = QuickFilterMode.all; + ref.read(albumProviderV2.notifier).searchAlbums(value); + }); + } + + changeFilter(QuickFilterMode mode) { + filterMode.value = mode; + ref.read(albumProviderV2.notifier).filterAlbums(mode); + } + + useEffect( + () { + searchController.addListener(() { + onSearch(searchController.text); + }); + + return () { + searchController.removeListener(() { + onSearch(searchController.text); + }); + debounceTimer.value?.cancel(); + }; + }, + [], + ); + + return Scaffold( + appBar: AppBar( + title: showImmichAppbar + ? null + : Text( + "${'albums'.tr()} ${albums.length}", + ), + bottom: showImmichAppbar + ? const PreferredSize( + preferredSize: Size.fromHeight(0), + child: ImmichAppBar(), + ) + : null, + ), + body: ListView( + shrinkWrap: true, + padding: const EdgeInsets.all(18.0), + children: [ + SearchBar( + backgroundColor: WidgetStatePropertyAll( + context.colorScheme.surfaceContainer, + ), + autoFocus: false, + hintText: "search_albums".tr(), + onChanged: onSearch, + elevation: const WidgetStatePropertyAll(0.25), + controller: searchController, + leading: const Icon(Icons.search_rounded), + padding: WidgetStateProperty.all( + const EdgeInsets.symmetric(horizontal: 16), + ), + shape: WidgetStateProperty.all( + RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + side: BorderSide( + color: context.colorScheme.onSurface.withAlpha(10), + width: 0.5, + ), + ), + ), + ), + const SizedBox(height: 16), + Wrap( + spacing: 4, + runSpacing: 4, + children: [ + QuickFilterButton( + label: 'All', + isSelected: filterMode.value == QuickFilterMode.all, + onTap: () => changeFilter(QuickFilterMode.all), + ), + QuickFilterButton( + label: 'Shared with me', + isSelected: filterMode.value == QuickFilterMode.sharedWithMe, + onTap: () => changeFilter(QuickFilterMode.sharedWithMe), + ), + QuickFilterButton( + label: 'My albums', + isSelected: filterMode.value == QuickFilterMode.myAlbums, + onTap: () => changeFilter(QuickFilterMode.myAlbums), + ), + ], + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const SortButton(), + IconButton( + icon: Icon( + isGrid.value + ? Icons.view_list_rounded + : Icons.grid_view_outlined, + size: 24, + ), + onPressed: toggleViewMode, + ), + ], + ), + const SizedBox(height: 5), + AnimatedSwitcher( + duration: const Duration(milliseconds: 500), + child: isGrid.value + ? GridView.builder( + shrinkWrap: true, + physics: const ClampingScrollPhysics(), + gridDelegate: + const SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 250, + mainAxisSpacing: 12, + crossAxisSpacing: 12, + childAspectRatio: .7, + ), + itemBuilder: (context, index) { + return AlbumThumbnailCard( + album: sorted[index], + onTap: () => context.pushRoute( + AlbumViewerRoute(albumId: sorted[index].id), + ), + showOwner: true, + ); + }, + itemCount: sorted.length, + ) + : ListView.builder( + shrinkWrap: true, + physics: const ClampingScrollPhysics(), + itemCount: sorted.length, + itemBuilder: (context, index) { + return Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: LargeLeadingTile( + title: Text( + sorted[index].name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: context.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + subtitle: sorted[index].ownerId == userId + ? Text( + '${sorted[index].assetCount} items', + overflow: TextOverflow.ellipsis, + style: context.textTheme.bodyMedium?.copyWith( + color: + context.colorScheme.onSurfaceSecondary, + ), + ) + : sorted[index].ownerName != null + ? Text( + '${sorted[index].assetCount} items • ${'album_thumbnail_shared_by'.tr( + args: [ + sorted[index].ownerName!, + ], + )}', + overflow: TextOverflow.ellipsis, + style: context.textTheme.bodyMedium + ?.copyWith( + color: context + .colorScheme.onSurfaceSecondary, + ), + ) + : null, + onTap: () => context.pushRoute( + AlbumViewerRoute(albumId: sorted[index].id), + ), + leadingPadding: const EdgeInsets.only( + right: 16, + ), + leading: ClipRRect( + borderRadius: + const BorderRadius.all(Radius.circular(15)), + child: ImmichThumbnail( + asset: sorted[index].thumbnail.value, + width: 80, + height: 80, + ), + ), + // minVerticalPadding: 1, + ), + ); + }, + ), + ), + ], + ), + ); + } +} + +class QuickFilterButton extends StatelessWidget { + const QuickFilterButton({ + super.key, + required this.isSelected, + required this.onTap, + required this.label, + }); + + final bool isSelected; + final VoidCallback onTap; + final String label; + + @override + Widget build(BuildContext context) { + return TextButton.icon( + onPressed: onTap, + icon: isSelected + ? Icon( + Icons.check_rounded, + color: context.colorScheme.onPrimary, + size: 18, + ) + : const SizedBox.shrink(), + style: ButtonStyle( + backgroundColor: WidgetStateProperty.all( + isSelected ? context.colorScheme.primary : Colors.transparent, + ), + shape: WidgetStateProperty.all( + RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + side: BorderSide( + color: context.colorScheme.onSurface.withAlpha(25), + width: 1, + ), + ), + ), + ), + label: Text( + label, + style: TextStyle( + color: isSelected + ? context.colorScheme.onPrimary + : context.colorScheme.onSurface, + fontSize: 14, + ), + ), + ); + } +} + +class SortButton extends ConsumerWidget { + const SortButton({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final albumSortOption = ref.watch(albumSortByOptionsProvider); + final albumSortIsReverse = ref.watch(albumSortOrderProvider); + + return MenuAnchor( + style: MenuStyle( + shape: WidgetStateProperty.all( + RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + ), + consumeOutsideTap: true, + menuChildren: AlbumSortMode.values + .map( + (mode) => MenuItemButton( + leadingIcon: albumSortOption == mode + ? albumSortIsReverse + ? Icon( + Icons.keyboard_arrow_down, + color: albumSortOption == mode + ? context.colorScheme.onPrimary + : context.colorScheme.onSurface, + ) + : Icon( + Icons.keyboard_arrow_up_rounded, + color: albumSortOption == mode + ? context.colorScheme.onPrimary + : context.colorScheme.onSurface, + ) + : const Icon(Icons.abc, color: Colors.transparent), + onPressed: () { + final selected = albumSortOption == mode; + // Switch direction + if (selected) { + ref + .read(albumSortOrderProvider.notifier) + .changeSortDirection(!albumSortIsReverse); + } else { + ref + .read(albumSortByOptionsProvider.notifier) + .changeSortMode(mode); + } + }, + style: ButtonStyle( + padding: WidgetStateProperty.all(const EdgeInsets.all(8)), + backgroundColor: WidgetStateProperty.all( + albumSortOption == mode + ? context.colorScheme.primary + : Colors.transparent, + ), + shape: WidgetStateProperty.all( + RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + ), + child: Text( + mode.label.tr(), + style: context.textTheme.bodyMedium?.copyWith( + color: albumSortOption == mode + ? context.colorScheme.onPrimary + : context.colorScheme.onSurface, + ), + ), + ), + ) + .toList(), + builder: (context, controller, child) { + return GestureDetector( + onTap: () { + if (controller.isOpen) { + controller.close(); + } else { + controller.open(); + } + }, + child: Row( + children: [ + Padding( + padding: const EdgeInsets.only(right: 5), + child: Transform.rotate( + angle: 90 * pi / 180, + child: Icon( + Icons.compare_arrows_rounded, + size: 18, + color: context.colorScheme.onSurface.withAlpha(225), + ), + ), + ), + Text( + albumSortOption.label.tr(), + style: context.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w500, + color: context.colorScheme.onSurface.withAlpha(225), + ), + ), + ], + ), + ); + }, + ); + } +} diff --git a/mobile/lib/pages/collections/albums/local_albums_collection.page.dart b/mobile/lib/pages/collections/albums/local_albums_collection.page.dart new file mode 100644 index 0000000000000..1be821d3c92da --- /dev/null +++ b/mobile/lib/pages/collections/albums/local_albums_collection.page.dart @@ -0,0 +1,55 @@ +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/providers/album/albumv2.provider.dart'; +import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/widgets/common/immich_thumbnail.dart'; + +@RoutePage() +class LocalAlbumsCollectionPage extends HookConsumerWidget { + const LocalAlbumsCollectionPage({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: ListTile( + contentPadding: const EdgeInsets.all(0), + dense: false, + visualDensity: VisualDensity.comfortable, + leading: ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(15)), + child: ImmichThumbnail( + asset: albums[index].thumbnail.value, + width: 60, + height: 90, + ), + ), + minVerticalPadding: 1, + title: Text( + albums[index].name, + style: context.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + subtitle: Text('${albums[index].assetCount} items'), + onTap: () => context + .pushRoute(AlbumViewerRoute(albumId: albums[index].id)), + ), + ); + }, + ), + ); + } +} diff --git a/mobile/lib/pages/collections/collections.page.dart b/mobile/lib/pages/collections/collections.page.dart new file mode 100644 index 0000000000000..c37103788161f --- /dev/null +++ b/mobile/lib/pages/collections/collections.page.dart @@ -0,0 +1,310 @@ +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/asyncvalue_extensions.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/providers/album/albumv2.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/share_partner_button.dart'; +import 'package:immich_mobile/widgets/map/map_thumbnail.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; + +@RoutePage() +class CollectionsPage extends ConsumerWidget { + const CollectionsPage({super.key}); + @override + Widget build(BuildContext context, WidgetRef ref) { + final trashEnabled = + ref.watch(serverInfoProvider.select((v) => v.serverFeatures.trash)); + + return Scaffold( + appBar: const ImmichAppBar( + showUploadButton: false, + actions: [CreateNewButton(), SharePartnerButton()], + ), + body: Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 16), + child: ListView( + shrinkWrap: true, + children: [ + Row( + children: [ + ActionButton( + onPressed: () => context.pushRoute(const FavoritesRoute()), + icon: Icons.favorite_outline_rounded, + label: 'favorites'.tr(), + ), + const SizedBox(width: 8), + ActionButton( + onPressed: () => context.pushRoute(const ArchiveRoute()), + icon: Icons.archive_outlined, + label: 'archived'.tr(), + ), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + ActionButton( + onPressed: () => context.pushRoute(const SharedLinkRoute()), + icon: Icons.link_outlined, + label: 'shared_links'.tr(), + ), + const SizedBox(width: 8), + trashEnabled + ? ActionButton( + onPressed: () => context.pushRoute(const TrashRoute()), + icon: Icons.delete_outline_rounded, + label: 'trash'.tr(), + ) + : const SizedBox.shrink(), + ], + ), + const SizedBox(height: 24), + const Wrap( + spacing: 8, + runSpacing: 16, + children: [ + PeopleCollectionCard(), + AlbumsCollectionCard(), + AlbumsCollectionCard(isLocal: true), + PlacesCollectionCard(), + ], + ), + ], + ), + ), + ); + } +} + +class PeopleCollectionCard extends ConsumerWidget { + const PeopleCollectionCard({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final people = ref.watch(getAllPeopleProvider); + final size = MediaQuery.of(context).size.width * 0.5 - 20; + return GestureDetector( + onTap: () => context.pushRoute(const PeopleCollectionRoute()), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + height: size, + width: size, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + color: context.colorScheme.secondaryContainer.withAlpha(100), + ), + child: people.widgetWhen( + 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( + 'people'.tr(), + style: context.textTheme.titleSmall?.copyWith( + color: context.colorScheme.onSurface, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ); + } +} + +class AlbumsCollectionCard extends HookConsumerWidget { + final bool isLocal; + + const AlbumsCollectionCard({super.key, this.isLocal = false}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final albums = isLocal + ? ref.watch(localAlbumsProvider) + : ref.watch(remoteAlbumsProvider); + + final size = MediaQuery.of(context).size.width * 0.5 - 20; + + return GestureDetector( + onTap: () => isLocal + ? context.pushRoute( + const LocalAlbumsCollectionRoute(), + ) + : context.pushRoute(AlbumsCollectionRoute()), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + height: size, + width: size, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + color: context.colorScheme.secondaryContainer.withAlpha(100), + ), + 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( + isLocal ? 'on_this_device'.tr() : 'albums'.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) { + final size = MediaQuery.of(context).size.width * 0.5 - 20; + return GestureDetector( + onTap: () => context.pushRoute(const PlacesCollectionRoute()), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + height: size, + width: size, + decoration: BoxDecoration( + borderRadius: BorderRadius.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( + '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: 14, + ), + ), + ), + 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, + ), + ), + ); + } +} + +class CreateNewButton extends StatelessWidget { + const CreateNewButton({super.key}); + @override + Widget build(BuildContext context) { + return InkWell( + onTap: () {}, + borderRadius: const BorderRadius.all(Radius.circular(25)), + child: const Icon( + Icons.add, + size: 32, + ), + ); + } +} diff --git a/mobile/lib/pages/collections/people/people_collection.page.dart b/mobile/lib/pages/collections/people/people_collection.page.dart new file mode 100644 index 0000000000000..b3f688280810c --- /dev/null +++ b/mobile/lib/pages/collections/people/people_collection.page.dart @@ -0,0 +1,104 @@ +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/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/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(); + + showNameEditModel( + String personId, + String personName, + ) { + return showDialog( + context: context, + builder: (BuildContext context) { + return PersonNameEditForm(personId: personId, personName: personName); + }, + ); + } + + return Scaffold( + appBar: AppBar( + title: Text('people'.tr()), + ), + body: people.when( + data: (people) { + return GridView.builder( + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + childAspectRatio: 0.85, + ), + 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: 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 CircularProgressIndicator(), + ), + ); + } +} diff --git a/mobile/lib/pages/collections/places/places_collection.part.dart b/mobile/lib/pages/collections/places/places_collection.part.dart new file mode 100644 index 0000000000000..639c5f0c6c356 --- /dev/null +++ b/mobile/lib/pages/collections/places/places_collection.part.dart @@ -0,0 +1,125 @@ +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:hooks_riverpod/hooks_riverpod.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/map/map_thumbnail.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; + +@RoutePage() +class PlacesCollectionPage extends HookConsumerWidget { + const PlacesCollectionPage({super.key}); + @override + Widget build(BuildContext context, WidgetRef ref) { + final places = ref.watch(getAllPlacesProvider); + + return Scaffold( + appBar: AppBar( + title: Text('places'.tr()), + ), + body: ListView( + shrinkWrap: true, + children: [ + Padding( + padding: const EdgeInsets.all(16.0), + child: SizedBox( + height: 200, + width: context.width, + child: MapThumbnail( + onTap: (_, __) => context.pushRoute(const MapRoute()), + zoom: 8, + centre: const LatLng( + 21.44950, + -157.91959, + ), + showAttribution: false, + themeMode: + context.isDarkTheme ? ThemeMode.dark : ThemeMode.light, + ), + ), + ), + places.when( + data: (places) { + 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) => const Text('Error getting places'), + loading: () => const 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( + SearchInputRoute( + prefilter: SearchFilter( + people: {}, + location: SearchLocationFilter( + city: name, + ), + camera: SearchCameraFilter(), + date: SearchDateFilter(), + display: SearchDisplayFilters( + isNotInAlbum: false, + isArchive: false, + isFavorite: false, + ), + mediaType: AssetType.other, + ), + ), + ); + } + + return LargeLeadingTile( + onTap: () => navigateToPlace(), + title: Text( + name, + style: context.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + leading: ClipRRect( + borderRadius: BorderRadius.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), + ), + ), + ); + } +} diff --git a/mobile/lib/pages/common/large_leading_tile.dart b/mobile/lib/pages/common/large_leading_tile.dart new file mode 100644 index 0000000000000..7f1eb44dc6c76 --- /dev/null +++ b/mobile/lib/pages/common/large_leading_tile.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart'; + +class LargeLeadingTile extends StatelessWidget { + const LargeLeadingTile({ + super.key, + required this.leading, + required this.onTap, + required this.title, + this.subtitle, + this.leadingPadding = const EdgeInsets.symmetric( + vertical: 8, + horizontal: 16.0, + ), + }); + + final Widget leading; + final VoidCallback onTap; + final Widget title; + final Widget? subtitle; + final EdgeInsetsGeometry leadingPadding; + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Padding( + padding: leadingPadding, + child: leading, + ), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: MediaQuery.of(context).size.width * 0.6, + child: title, + ), + subtitle ?? const SizedBox.shrink(), + ], + ), + ], + ), + ); + } +} diff --git a/mobile/lib/pages/common/tab_controller.page.dart b/mobile/lib/pages/common/tab_controller.page.dart index b619e003d2c3a..8866f169c732f 100644 --- a/mobile/lib/pages/common/tab_controller.page.dart +++ b/mobile/lib/pages/common/tab_controller.page.dart @@ -76,24 +76,36 @@ class TabControllerPage extends HookConsumerWidget { selectedIcon: const Icon(Icons.photo_library), label: const Text('tab_controller_nav_photos').tr(), ), + NavigationRailDestination( + padding: const EdgeInsets.all(4), + icon: const Icon(Icons.photo_album_outlined), + selectedIcon: const Icon(Icons.photo_album), + label: const Text('albums').tr(), + ), + NavigationRailDestination( + padding: const EdgeInsets.all(4), + icon: const Icon(Icons.space_dashboard_outlined), + selectedIcon: const Icon(Icons.space_dashboard_rounded), + label: const Text('collections').tr(), + ), NavigationRailDestination( padding: const EdgeInsets.all(4), icon: const Icon(Icons.search_rounded), selectedIcon: const Icon(Icons.search), label: const Text('tab_controller_nav_search').tr(), ), - NavigationRailDestination( - padding: const EdgeInsets.all(4), - icon: const Icon(Icons.share_rounded), - selectedIcon: const Icon(Icons.share), - label: const Text('tab_controller_nav_sharing').tr(), - ), - NavigationRailDestination( - padding: const EdgeInsets.all(4), - icon: const Icon(Icons.photo_album_outlined), - selectedIcon: const Icon(Icons.photo_album), - label: const Text('tab_controller_nav_library').tr(), - ), + // NavigationRailDestination( + // padding: const EdgeInsets.all(4), + // icon: const Icon(Icons.share_rounded), + // selectedIcon: const Icon(Icons.share), + // label: const Text('tab_controller_nav_sharing').tr(), + // ), + // NavigationRailDestination( + // padding: const EdgeInsets.all(4), + // icon: const Icon(Icons.photo_album_outlined), + // selectedIcon: const Icon(Icons.photo_album), + // label: const Text('tab_controller_nav_library').tr(), + // ), ], ); } @@ -125,27 +137,7 @@ class TabControllerPage extends HookConsumerWidget { ), ), NavigationDestination( - label: 'tab_controller_nav_search'.tr(), - icon: const Icon( - Icons.search_rounded, - ), - selectedIcon: Icon( - Icons.search, - color: context.primaryColor, - ), - ), - NavigationDestination( - label: 'tab_controller_nav_sharing'.tr(), - icon: const Icon( - Icons.group_outlined, - ), - selectedIcon: Icon( - Icons.group, - color: context.primaryColor, - ), - ), - NavigationDestination( - label: 'tab_controller_nav_library'.tr(), + label: 'albums'.tr(), icon: const Icon( Icons.photo_album_outlined, ), @@ -156,17 +148,63 @@ class TabControllerPage extends HookConsumerWidget { ), ), ), + NavigationDestination( + label: 'collections'.tr(), + icon: const Icon( + Icons.space_dashboard_outlined, + ), + selectedIcon: buildIcon( + Icon( + Icons.space_dashboard_rounded, + color: context.primaryColor, + ), + ), + ), + NavigationDestination( + label: 'tab_controller_nav_search'.tr(), + icon: const Icon( + Icons.search_rounded, + ), + selectedIcon: Icon( + Icons.search, + color: context.primaryColor, + ), + ), + // NavigationDestination( + // label: 'tab_controller_nav_sharing'.tr(), + // icon: const Icon( + // Icons.group_outlined, + // ), + // selectedIcon: Icon( + // Icons.group, + // color: context.primaryColor, + // ), + // ), + // NavigationDestination( + // label: 'tab_controller_nav_library'.tr(), + // icon: const Icon( + // Icons.photo_album_outlined, + // ), + // selectedIcon: buildIcon( + // Icon( + // Icons.photo_album_rounded, + // color: context.primaryColor, + // ), + // ), + // ), ], ); } final multiselectEnabled = ref.watch(multiselectProvider); return AutoTabsRouter( - routes: const [ - PhotosRoute(), - SearchRoute(), - SharingRoute(), - LibraryRoute(), + routes: [ + const PhotosRoute(), + AlbumsCollectionRoute(showImmichAppbar: true), + const CollectionsRoute(), + // SharingRoute(), + // LibraryRoute(), + const SearchRoute(), ], duration: const Duration(milliseconds: 600), transitionBuilder: (context, child, animation) => FadeTransition( diff --git a/mobile/lib/pages/library/library.page.dart b/mobile/lib/pages/library/library.page.dart index 5f03ed68714c8..99d048a8bd19a 100644 --- a/mobile/lib/pages/library/library.page.dart +++ b/mobile/lib/pages/library/library.page.dart @@ -184,7 +184,7 @@ class LibraryPage extends HookConsumerWidget { final sorted = albumSortOption.sortFn(remote, albumSortIsReverse); final local = albums.where((a) => a.isLocal).toList(); - Widget? shareTrashButton() { + Widget shareTrashButton() { return trashEnabled ? InkWell( onTap: () => context.pushRoute(const TrashRoute()), @@ -195,12 +195,12 @@ class LibraryPage extends HookConsumerWidget { semanticLabel: 'profile_drawer_trash'.tr(), ), ) - : null; + : const SizedBox.shrink(); } return Scaffold( appBar: ImmichAppBar( - action: shareTrashButton(), + actions: [shareTrashButton()], ), body: CustomScrollView( slivers: [ diff --git a/mobile/lib/pages/search/person_result.page.dart b/mobile/lib/pages/search/person_result.page.dart index 55824b8db91f6..8627c65bcccef 100644 --- a/mobile/lib/pages/search/person_result.page.dart +++ b/mobile/lib/pages/search/person_result.page.dart @@ -92,6 +92,7 @@ class PersonResultPage extends HookConsumerWidget { Text( name.value, style: context.textTheme.titleLarge, + overflow: TextOverflow.ellipsis, ), ], ), @@ -125,9 +126,11 @@ class PersonResultPage extends HookConsumerWidget { headers: ApiService.getRequestHeaders(), ), ), - Padding( - padding: const EdgeInsets.only(left: 16.0), - child: buildTitleBlock(), + Expanded( + child: Padding( + padding: const EdgeInsets.only(left: 16.0, right: 16.0), + child: buildTitleBlock(), + ), ), ], ), diff --git a/mobile/lib/pages/sharing/sharing.page.dart b/mobile/lib/pages/sharing/sharing.page.dart index 98d4cfafe9fe5..903fe6a35c4d9 100644 --- a/mobile/lib/pages/sharing/sharing.page.dart +++ b/mobile/lib/pages/sharing/sharing.page.dart @@ -9,6 +9,7 @@ import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dar import 'package:immich_mobile/providers/album/shared_album.provider.dart'; import 'package:immich_mobile/widgets/album/album_thumbnail_card.dart'; import 'package:immich_mobile/providers/partner.provider.dart'; +import 'package:immich_mobile/widgets/common/share_partner_button.dart'; import 'package:immich_mobile/widgets/partner/partner_list.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/providers/user.provider.dart'; @@ -215,25 +216,13 @@ class SharingPage extends HookConsumerWidget { ); } - Widget sharePartnerButton() { - return InkWell( - onTap: () => context.pushRoute(const PartnerRoute()), - borderRadius: const BorderRadius.all(Radius.circular(12)), - child: Icon( - Icons.swap_horizontal_circle_rounded, - size: 25, - semanticLabel: 'partner_page_title'.tr(), - ), - ); - } - return RefreshIndicator( onRefresh: () async { ref.read(sharedAlbumProvider.notifier).getAllSharedAlbums(); }, child: Scaffold( - appBar: ImmichAppBar( - action: sharePartnerButton(), + appBar: const ImmichAppBar( + actions: [SharePartnerButton()], ), body: CustomScrollView( slivers: [ diff --git a/mobile/lib/providers/album/album.provider.dart b/mobile/lib/providers/album/album.provider.dart index ed9dc07f5e5c0..136c9286fe774 100644 --- a/mobile/lib/providers/album/album.provider.dart +++ b/mobile/lib/providers/album/album.provider.dart @@ -12,7 +12,7 @@ import 'package:immich_mobile/utils/renderlist_generator.dart'; import 'package:isar/isar.dart'; class AlbumNotifier extends StateNotifier> { - AlbumNotifier(this._albumService, Isar db) : super([]) { + AlbumNotifier(this._albumService, this.db) : super([]) { final query = db.albums .filter() .owner((q) => q.isarIdEqualTo(Store.get(StoreKey.currentUser).isarId)); @@ -25,6 +25,7 @@ class AlbumNotifier extends StateNotifier> { } final AlbumService _albumService; + final Isar db; late final StreamSubscription> _streamSub; Future getAllAlbums() => Future.wait([ @@ -64,6 +65,16 @@ class AlbumNotifier extends StateNotifier> { _streamSub.cancel(); super.dispose(); } + + void searchAlbums(String value) async { + final query = db.albums + .filter() + .owner((q) => q.isarIdEqualTo(Store.get(StoreKey.currentUser).isarId)) + .nameContains(value, caseSensitive: false); + + final albums = await query.findAll(); + state = albums; + } } final albumProvider = diff --git a/mobile/lib/providers/album/albumv2.provider.dart b/mobile/lib/providers/album/albumv2.provider.dart new file mode 100644 index 0000000000000..23d6329c36ab1 --- /dev/null +++ b/mobile/lib/providers/album/albumv2.provider.dart @@ -0,0 +1,178 @@ +import 'dart:async'; + +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/entities/user.entity.dart'; +import 'package:immich_mobile/pages/collections/albums/albums_collection.page.dart'; +import 'package:immich_mobile/services/album.service.dart'; +import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/entities/album.entity.dart'; +import 'package:immich_mobile/providers/db.provider.dart'; +import 'package:isar/isar.dart'; + +class AlbumNotifierV2 extends StateNotifier> { + AlbumNotifierV2(this._albumService, this.db) : super([]) { + final query = db.albums.where(); + + query.findAll().then((value) { + if (mounted) { + state = value; + } + }); + _streamSub = query.watch().listen((data) => state = data); + } + + final AlbumService _albumService; + final Isar db; + late final StreamSubscription> _streamSub; + + Future refreshAlbums() async { + // Future.wait([ + // _albumService.refreshDeviceAlbums(), + // _albumService.refreshAllRemoteAlbums(), + // ]); + await _albumService.refreshDeviceAlbums(); + await _albumService.refreshRemoteAlbums(isShared: false); + await _albumService.refreshRemoteAlbums(isShared: true); + } + + Future getDeviceAlbums() { + return _albumService.refreshDeviceAlbums(); + } + + Future deleteAlbum(Album album) { + return _albumService.deleteAlbum(album); + } + + Future createAlbum( + String albumTitle, + Set assets, + ) { + return _albumService.createAlbum(albumTitle, assets, []); + } + + Future getAlbumByName(String albumName, {bool remoteOnly = false}) { + return _albumService.getAlbumByName(albumName, remoteOnly); + } + + /// Create an album on the server with the same name as the selected album for backup + /// First this will check if the album already exists on the server with name + /// If it does not exist, it will create the album on the server + Future createSyncAlbum( + String albumName, + ) async { + final album = await getAlbumByName(albumName, remoteOnly: true); + if (album != null) { + return; + } + + await createAlbum(albumName, {}); + } + + void searchAlbums(String value) async { + final query = db.albums + .filter() + .remoteIdIsNotNull() + .nameContains(value, caseSensitive: false); + + final albums = await query.findAll(); + state = albums; + } + + void filterAlbums(QuickFilterMode mode) async { + switch (mode) { + case QuickFilterMode.all: + state = await db.albums.filter().remoteIdIsNotNull().findAll(); + return; + case QuickFilterMode.sharedWithMe: + state = await db.albums + .filter() + .remoteIdIsNotNull() + .owner( + (q) => + q.not().isarIdEqualTo(Store.get(StoreKey.currentUser).isarId), + ) + .findAll(); + return; + case QuickFilterMode.myAlbums: + state = await db.albums + .filter() + .remoteIdIsNotNull() + .owner( + (q) => q.isarIdEqualTo(Store.get(StoreKey.currentUser).isarId), + ) + .findAll(); + return; + } + } + + @override + void dispose() { + _streamSub.cancel(); + super.dispose(); + } +} + +final albumProviderV2 = + StateNotifierProvider.autoDispose>((ref) { + return AlbumNotifierV2( + ref.watch(albumServiceProvider), + ref.watch(dbProvider), + ); +}); + +class RemoteAlbumsNotifier extends StateNotifier> { + RemoteAlbumsNotifier(this.db) : super([]) { + final query = db.albums.filter().remoteIdIsNotNull(); + + query.findAll().then((value) { + if (mounted) { + state = value; + } + }); + + _streamSub = query.watch().listen((data) => state = data); + } + + final Isar db; + late final StreamSubscription> _streamSub; + + @override + void dispose() { + _streamSub.cancel(); + super.dispose(); + } +} + +class LocalAlbumsNotifier extends StateNotifier> { + LocalAlbumsNotifier(this.db) : super([]) { + final query = db.albums.filter().not().remoteIdIsNotNull(); + + query.findAll().then((value) { + if (mounted) { + state = value; + } + }); + + _streamSub = query.watch().listen((data) => state = data); + } + + final Isar db; + late final StreamSubscription> _streamSub; + + @override + void dispose() { + _streamSub.cancel(); + super.dispose(); + } +} + +final localAlbumsProvider = + StateNotifierProvider.autoDispose>((ref) { + return LocalAlbumsNotifier(ref.watch(dbProvider)); +}); + +final remoteAlbumsProvider = + StateNotifierProvider.autoDispose>((ref) { + return RemoteAlbumsNotifier(ref.watch(dbProvider)); +}); diff --git a/mobile/lib/providers/app_life_cycle.provider.dart b/mobile/lib/providers/app_life_cycle.provider.dart index 5561d3fefd683..a80e064f5eb2e 100644 --- a/mobile/lib/providers/app_life_cycle.provider.dart +++ b/mobile/lib/providers/app_life_cycle.provider.dart @@ -63,6 +63,8 @@ class AppLifeCycleNotifier extends StateNotifier { _ref.read(sharedAlbumProvider.notifier).getAllSharedAlbums(); case TabEnum.library: _ref.read(albumProvider.notifier).getAllAlbums(); + case TabEnum.collections: + // nothing to do } } diff --git a/mobile/lib/providers/tab.provider.dart b/mobile/lib/providers/tab.provider.dart index 2abed7c395e50..16dfbfd5eaa12 100644 --- a/mobile/lib/providers/tab.provider.dart +++ b/mobile/lib/providers/tab.provider.dart @@ -1,11 +1,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; -enum TabEnum { - home, - search, - sharing, - library, -} +enum TabEnum { home, search, sharing, library, collections } /// Provides the currently active tab final tabProvider = StateProvider( diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index 6869e7b7047e9..6ba447a066e92 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -13,6 +13,11 @@ import 'package:immich_mobile/pages/backup/backup_album_selection.page.dart'; import 'package:immich_mobile/pages/backup/backup_controller.page.dart'; import 'package:immich_mobile/pages/backup/backup_options.page.dart'; import 'package:immich_mobile/pages/backup/failed_backup_status.page.dart'; +import 'package:immich_mobile/pages/collections/albums/albums_collection.page.dart'; +import 'package:immich_mobile/pages/collections/albums/local_albums_collection.page.dart'; +import 'package:immich_mobile/pages/collections/people/people_collection.page.dart'; +import 'package:immich_mobile/pages/collections/places/places_collection.part.dart'; +import 'package:immich_mobile/pages/collections/collections.page.dart'; import 'package:immich_mobile/pages/common/activities.page.dart'; import 'package:immich_mobile/pages/common/album_additional_shared_user_selection.page.dart'; import 'package:immich_mobile/pages/common/album_asset_selection.page.dart'; @@ -113,6 +118,14 @@ class AppRouter extends RootStackRouter { page: LibraryRoute.page, guards: [_authGuard, _duplicateGuard], ), + AutoRoute( + page: CollectionsRoute.page, + guards: [_authGuard, _duplicateGuard], + ), + AutoRoute( + page: AlbumsCollectionRoute.page, + guards: [_authGuard, _duplicateGuard], + ), ], transitionsBuilder: TransitionsBuilders.fadeIn, ), @@ -135,7 +148,11 @@ class AppRouter extends RootStackRouter { ), AutoRoute(page: EditImageRoute.page), AutoRoute(page: CropImageRoute.page), - AutoRoute(page: FavoritesRoute.page, guards: [_authGuard, _duplicateGuard]), + CustomRoute( + page: FavoritesRoute.page, + guards: [_authGuard, _duplicateGuard], + transitionsBuilder: TransitionsBuilders.slideLeft, + ), AutoRoute(page: AllVideosRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute( page: AllMotionPhotosRoute.page, @@ -181,8 +198,16 @@ class AppRouter extends RootStackRouter { AutoRoute(page: SettingsSubRoute.page, guards: [_duplicateGuard]), AutoRoute(page: AppLogRoute.page, guards: [_duplicateGuard]), AutoRoute(page: AppLogDetailRoute.page, guards: [_duplicateGuard]), - AutoRoute(page: ArchiveRoute.page, guards: [_authGuard, _duplicateGuard]), - AutoRoute(page: PartnerRoute.page, guards: [_authGuard, _duplicateGuard]), + CustomRoute( + page: ArchiveRoute.page, + guards: [_authGuard, _duplicateGuard], + transitionsBuilder: TransitionsBuilders.slideLeft, + ), + CustomRoute( + page: PartnerRoute.page, + guards: [_authGuard, _duplicateGuard], + transitionsBuilder: TransitionsBuilders.slideLeft, + ), AutoRoute( page: PartnerDetailRoute.page, guards: [_authGuard, _duplicateGuard], @@ -198,10 +223,15 @@ class AppRouter extends RootStackRouter { page: AlbumOptionsRoute.page, guards: [_authGuard, _duplicateGuard], ), - AutoRoute(page: TrashRoute.page, guards: [_authGuard, _duplicateGuard]), - AutoRoute( + CustomRoute( + page: TrashRoute.page, + guards: [_authGuard, _duplicateGuard], + transitionsBuilder: TransitionsBuilders.slideLeft, + ), + CustomRoute( page: SharedLinkRoute.page, guards: [_authGuard, _duplicateGuard], + transitionsBuilder: TransitionsBuilders.slideLeft, ), AutoRoute( page: SharedLinkEditRoute.page, @@ -230,6 +260,26 @@ class AppRouter extends RootStackRouter { page: HeaderSettingsRoute.page, guards: [_duplicateGuard], ), + CustomRoute( + page: PeopleCollectionRoute.page, + guards: [_authGuard, _duplicateGuard], + transitionsBuilder: TransitionsBuilders.slideLeft, + ), + CustomRoute( + page: AlbumsCollectionRoute.page, + guards: [_authGuard, _duplicateGuard], + transitionsBuilder: TransitionsBuilders.slideLeft, + ), + CustomRoute( + page: LocalAlbumsCollectionRoute.page, + guards: [_authGuard, _duplicateGuard], + transitionsBuilder: TransitionsBuilders.slideLeft, + ), + CustomRoute( + page: PlacesCollectionRoute.page, + guards: [_authGuard, _duplicateGuard], + transitionsBuilder: TransitionsBuilders.slideLeft, + ), ]; } diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index df4c29fba1c70..0d905457bba4e 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -319,6 +319,53 @@ class AlbumViewerRouteArgs { } } +/// generated route for +/// [AlbumsCollectionPage] +class AlbumsCollectionRoute extends PageRouteInfo { + AlbumsCollectionRoute({ + Key? key, + bool showImmichAppbar = false, + List? children, + }) : super( + AlbumsCollectionRoute.name, + args: AlbumsCollectionRouteArgs( + key: key, + showImmichAppbar: showImmichAppbar, + ), + initialChildren: children, + ); + + static const String name = 'AlbumsCollectionRoute'; + + static PageInfo page = PageInfo( + name, + builder: (data) { + final args = data.argsAs( + orElse: () => const AlbumsCollectionRouteArgs()); + return AlbumsCollectionPage( + key: args.key, + showImmichAppbar: args.showImmichAppbar, + ); + }, + ); +} + +class AlbumsCollectionRouteArgs { + const AlbumsCollectionRouteArgs({ + this.key, + this.showImmichAppbar = false, + }); + + final Key? key; + + final bool showImmichAppbar; + + @override + String toString() { + return 'AlbumsCollectionRouteArgs{key: $key, showImmichAppbar: $showImmichAppbar}'; + } +} + /// generated route for /// [AllMotionPhotosPage] class AllMotionPhotosRoute extends PageRouteInfo { @@ -555,6 +602,25 @@ class ChangePasswordRoute extends PageRouteInfo { ); } +/// generated route for +/// [CollectionsPage] +class CollectionsRoute extends PageRouteInfo { + const CollectionsRoute({List? children}) + : super( + CollectionsRoute.name, + initialChildren: children, + ); + + static const String name = 'CollectionsRoute'; + + static PageInfo page = PageInfo( + name, + builder: (data) { + return const CollectionsPage(); + }, + ); +} + /// generated route for /// [CreateAlbumPage] class CreateAlbumRoute extends PageRouteInfo { @@ -857,6 +923,25 @@ class LibraryRoute extends PageRouteInfo { ); } +/// generated route for +/// [LocalAlbumsCollectionPage] +class LocalAlbumsCollectionRoute extends PageRouteInfo { + const LocalAlbumsCollectionRoute({List? children}) + : super( + LocalAlbumsCollectionRoute.name, + initialChildren: children, + ); + + static const String name = 'LocalAlbumsCollectionRoute'; + + static PageInfo page = PageInfo( + name, + builder: (data) { + return const LocalAlbumsCollectionPage(); + }, + ); +} + /// generated route for /// [LoginPage] class LoginRoute extends PageRouteInfo { @@ -1059,6 +1144,25 @@ class PartnerRoute extends PageRouteInfo { ); } +/// generated route for +/// [PeopleCollectionPage] +class PeopleCollectionRoute extends PageRouteInfo { + const PeopleCollectionRoute({List? children}) + : super( + PeopleCollectionRoute.name, + initialChildren: children, + ); + + static const String name = 'PeopleCollectionRoute'; + + static PageInfo page = PageInfo( + name, + builder: (data) { + return const PeopleCollectionPage(); + }, + ); +} + /// generated route for /// [PermissionOnboardingPage] class PermissionOnboardingRoute extends PageRouteInfo { @@ -1149,6 +1253,25 @@ class PhotosRoute extends PageRouteInfo { ); } +/// generated route for +/// [PlacesCollectionPage] +class PlacesCollectionRoute extends PageRouteInfo { + const PlacesCollectionRoute({List? children}) + : super( + PlacesCollectionRoute.name, + initialChildren: children, + ); + + static const String name = 'PlacesCollectionRoute'; + + static PageInfo page = PageInfo( + name, + builder: (data) { + return const PlacesCollectionPage(); + }, + ); +} + /// generated route for /// [RecentlyAddedPage] class RecentlyAddedRoute extends PageRouteInfo { diff --git a/mobile/lib/routing/tab_navigation_observer.dart b/mobile/lib/routing/tab_navigation_observer.dart index e16fecb32392a..bbb870b8b0e5a 100644 --- a/mobile/lib/routing/tab_navigation_observer.dart +++ b/mobile/lib/routing/tab_navigation_observer.dart @@ -2,6 +2,7 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/foundation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/providers/album/album.provider.dart'; +import 'package:immich_mobile/providers/album/albumv2.provider.dart'; import 'package:immich_mobile/providers/memory.provider.dart'; import 'package:immich_mobile/providers/search/people.provider.dart'; @@ -50,6 +51,10 @@ class TabNavigationObserver extends AutoRouterObserver { ref.read(albumProvider.notifier).getAllAlbums(); } + if (route.name == 'CollectionsRoute') { + ref.read(albumProviderV2.notifier).refreshAlbums(); + } + if (route.name == 'HomeRoute') { ref.invalidate(memoryFutureProvider); Future(() => ref.read(assetProvider.notifier).getAllAsset()); diff --git a/mobile/lib/services/album.service.dart b/mobile/lib/services/album.service.dart index 091049edb59f1..0b519aa6a9b49 100644 --- a/mobile/lib/services/album.service.dart +++ b/mobile/lib/services/album.service.dart @@ -175,6 +175,49 @@ class AlbumService { return changes; } + /// V2 + Future refreshAllRemoteAlbums() async { + if (!_remoteCompleter.isCompleted) { + // guard against concurrent calls + return _remoteCompleter.future; + } + _remoteCompleter = Completer(); + final Stopwatch sw = Stopwatch()..start(); + bool changes = false; + try { + final albumList = await Future.wait([ + // _apiService.albumsApi.getAllAlbums(shared: true), + // _apiService.albumsApi.getAllAlbums(shared: false), + ]); + + // for (int i = 0; i < albumList.length; i++) { + // final albums = albumList[i]; + // final isShared = i == 1; + // if (albums != null) { + // final hasChange = await _syncService.syncRemoteAlbumsToDb( + // albums, + // isShared: isShared, + // loadDetails: (dto) async => dto.assetCount == dto.assets.length + // ? dto + // : (await _apiService.albumsApi.getAlbumInfo(dto.id)) ?? dto, + // ); + + // if (hasChange) { + // changes = true; + // } + // } + // } + } catch (e) { + debugPrint("Error refreshing all albums: $e"); + return false; + } finally { + _remoteCompleter.complete(changes); + } + + debugPrint("refreshAllRemoteAlbums took ${sw.elapsedMilliseconds}ms"); + return changes; + } + Future createAlbum( String albumName, Iterable assets, [ diff --git a/mobile/lib/widgets/album/album_thumbnail_card.dart b/mobile/lib/widgets/album/album_thumbnail_card.dart index 42fa55cdd4459..b728f2b5415fe 100644 --- a/mobile/lib/widgets/album/album_thumbnail_card.dart +++ b/mobile/lib/widgets/album/album_thumbnail_card.dart @@ -12,12 +12,14 @@ class AlbumThumbnailCard extends StatelessWidget { /// Whether or not to show the owner of the album (or "Owned") /// in the subtitle of the album final bool showOwner; + final bool showTitle; const AlbumThumbnailCard({ super.key, required this.album, this.onTap, this.showOwner = false, + this.showTitle = true, }); final Album album; @@ -76,7 +78,7 @@ class AlbumThumbnailCard extends StatelessWidget { : 'album_thumbnail_card_items' .tr(args: ['${album.assetCount}']), ), - if (owner != null) const TextSpan(text: ' · '), + if (owner != null) const TextSpan(text: ' • '), if (owner != null) TextSpan(text: owner), ], ), @@ -102,21 +104,23 @@ class AlbumThumbnailCard extends StatelessWidget { : buildAlbumThumbnail(), ), ), - Padding( - padding: const EdgeInsets.only(top: 8.0), - child: SizedBox( - width: cardSize, - child: Text( - album.name, - overflow: TextOverflow.ellipsis, - style: context.textTheme.bodyMedium?.copyWith( - color: context.colorScheme.onSurface, - fontWeight: FontWeight.w500, + if (showTitle) ...[ + Padding( + padding: const EdgeInsets.only(top: 8.0), + child: SizedBox( + width: cardSize, + child: Text( + album.name, + overflow: TextOverflow.ellipsis, + style: context.textTheme.titleSmall?.copyWith( + color: context.colorScheme.onSurface, + fontWeight: FontWeight.w500, + ), ), ), ), - ), - buildAlbumTextRow(), + buildAlbumTextRow(), + ], ], ), ), diff --git a/mobile/lib/widgets/common/immich_app_bar.dart b/mobile/lib/widgets/common/immich_app_bar.dart index 8e2465fc9ca3d..1831a2d1689ab 100644 --- a/mobile/lib/widgets/common/immich_app_bar.dart +++ b/mobile/lib/widgets/common/immich_app_bar.dart @@ -18,9 +18,10 @@ import 'package:immich_mobile/providers/server_info.provider.dart'; class ImmichAppBar extends ConsumerWidget implements PreferredSizeWidget { @override Size get preferredSize => const Size.fromHeight(kToolbarHeight); - final Widget? action; + final List? actions; + final bool showUploadButton; - const ImmichAppBar({super.key, this.action}); + const ImmichAppBar({super.key, this.actions, this.showUploadButton = true}); @override Widget build(BuildContext context, WidgetRef ref) { @@ -184,12 +185,18 @@ class ImmichAppBar extends ConsumerWidget implements PreferredSizeWidget { }, ), actions: [ - if (action != null) - Padding(padding: const EdgeInsets.only(right: 20), child: action!), - Padding( - padding: const EdgeInsets.only(right: 20), - child: buildBackupIndicator(), - ), + if (actions != null) + ...actions!.map( + (action) => Padding( + padding: const EdgeInsets.only(right: 16), + child: action, + ), + ), + if (showUploadButton) + Padding( + padding: const EdgeInsets.only(right: 20), + child: buildBackupIndicator(), + ), Padding( padding: const EdgeInsets.only(right: 20), child: buildProfileIndicator(), diff --git a/mobile/lib/widgets/common/share_partner_button.dart b/mobile/lib/widgets/common/share_partner_button.dart new file mode 100644 index 0000000000000..6cce18d23827a --- /dev/null +++ b/mobile/lib/widgets/common/share_partner_button.dart @@ -0,0 +1,21 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:immich_mobile/routing/router.dart'; + +class SharePartnerButton extends StatelessWidget { + const SharePartnerButton({super.key}); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: () => context.pushRoute(const PartnerRoute()), + borderRadius: const BorderRadius.all(Radius.circular(12)), + child: Icon( + Icons.swap_horizontal_circle_rounded, + size: 25, + semanticLabel: 'partner_page_title'.tr(), + ), + ); + } +} diff --git a/mobile/lib/widgets/search/search_map_thumbnail.dart b/mobile/lib/widgets/search/search_map_thumbnail.dart index 20747913fb14a..b4a12ab82634b 100644 --- a/mobile/lib/widgets/search/search_map_thumbnail.dart +++ b/mobile/lib/widgets/search/search_map_thumbnail.dart @@ -13,6 +13,7 @@ class SearchMapThumbnail extends StatelessWidget { }); final double size; + final bool showTitle = true; @override Widget build(BuildContext context) {