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/models/albums/album_search.model.dart'; import 'package:immich_mobile/pages/common/large_leading_tile.dart'; import 'package:immich_mobile/providers/album/album.provider.dart'; import 'package:immich_mobile/providers/album/album_sort_by_options.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'; @RoutePage() class AlbumsPage extends HookConsumerWidget { const AlbumsPage({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final albums = ref.watch(albumProvider).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; final searchFocusNode = useFocusNode(); toggleViewMode() { isGrid.value = !isGrid.value; } onSearch(String searchTerm, QuickFilterMode mode) { debounceTimer.value?.cancel(); debounceTimer.value = Timer(const Duration(milliseconds: 300), () { ref.read(albumProvider.notifier).searchAlbums(searchTerm, mode); }); } changeFilter(QuickFilterMode mode) { filterMode.value = mode; } useEffect( () { searchController.addListener(() { onSearch(searchController.text, filterMode.value); }); return () { searchController.removeListener(() { onSearch(searchController.text, filterMode.value); }); debounceTimer.value?.cancel(); }; }, [], ); clearSearch() { filterMode.value = QuickFilterMode.all; searchController.clear(); onSearch('', QuickFilterMode.all); } return Scaffold( appBar: ImmichAppBar( showUploadButton: false, actions: [ IconButton( icon: Icon( Icons.add_rounded, size: 28, ), onPressed: () => context.pushRoute( CreateAlbumRoute(), ), ), ], ), body: RefreshIndicator( displacement: 70, onRefresh: () async { await ref.read(albumProvider.notifier).refreshRemoteAlbums(); }, child: ListView( shrinkWrap: true, padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12), children: [ Container( decoration: BoxDecoration( border: Border.all( color: context.colorScheme.onSurface.withAlpha(0), width: 0, ), borderRadius: BorderRadius.circular(24), gradient: LinearGradient( colors: [ context.colorScheme.primary.withOpacity(0.075), context.colorScheme.primary.withOpacity(0.09), context.colorScheme.primary.withOpacity(0.075), ], begin: Alignment.topLeft, end: Alignment.bottomRight, transform: GradientRotation(0.5 * pi), ), ), child: TextField( autofocus: false, decoration: InputDecoration( contentPadding: EdgeInsets.all(16), border: OutlineInputBorder( borderRadius: BorderRadius.circular(25), borderSide: BorderSide( color: context.colorScheme.surfaceDim, ), ), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(25), borderSide: BorderSide( color: context.colorScheme.surfaceContainer, ), ), disabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(25), borderSide: BorderSide( color: context.colorScheme.surfaceDim, ), ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(25), borderSide: BorderSide( color: context.colorScheme.primary.withAlpha(100), ), ), hintText: 'search_albums'.tr(), hintStyle: context.textTheme.bodyLarge?.copyWith( color: context.colorScheme.onSurfaceSecondary, ), prefixIcon: const Icon(Icons.search_rounded), suffixIcon: searchController.text.isNotEmpty ? IconButton( icon: const Icon(Icons.clear_rounded), onPressed: clearSearch, ) : const SizedBox.shrink(), ), controller: searchController, onChanged: (_) => onSearch(searchController.text, filterMode.value), focusNode: searchFocusNode, onTapOutside: (_) => searchFocusNode.unfocus(), ), ), const SizedBox(height: 8), Wrap( spacing: 4, runSpacing: 4, children: [ QuickFilterButton( label: 'all'.tr(), isSelected: filterMode.value == QuickFilterMode.all, onTap: () { changeFilter(QuickFilterMode.all); onSearch(searchController.text, QuickFilterMode.all); }, ), QuickFilterButton( label: 'shared_with_me'.tr(), isSelected: filterMode.value == QuickFilterMode.sharedWithMe, onTap: () { changeFilter(QuickFilterMode.sharedWithMe); onSearch( searchController.text, QuickFilterMode.sharedWithMe, ); }, ), QuickFilterButton( label: 'my_albums'.tr(), isSelected: filterMode.value == QuickFilterMode.myAlbums, onTap: () { changeFilter(QuickFilterMode.myAlbums); onSearch( searchController.text, QuickFilterMode.myAlbums, ); }, ), ], ), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ const SortButton(), IconButton( icon: Icon( isGrid.value ? Icons.view_list_outlined : 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 NeverScrollableScrollPhysics(), 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( onPressed: onTap, 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, ), ), ), ), child: 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( elevation: WidgetStatePropertyAll(1), shape: WidgetStateProperty.all( RoundedRectangleBorder( borderRadius: BorderRadius.circular(24), ), ), padding: WidgetStatePropertyAll( EdgeInsets.all(4), ), ), 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.fromLTRB(16, 16, 32, 16), ), backgroundColor: WidgetStateProperty.all( albumSortOption == mode ? context.colorScheme.primary : Colors.transparent, ), shape: WidgetStateProperty.all( RoundedRectangleBorder( borderRadius: BorderRadius.circular(24), ), ), ), child: Text( mode.label.tr(), style: context.textTheme.titleSmall?.copyWith( fontWeight: FontWeight.w600, color: albumSortOption == mode ? context.colorScheme.onPrimary : context.colorScheme.onSurface.withAlpha(185), ), ), ), ) .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), ), ), ], ), ); }, ); } }