diff --git a/mobile/lib/pages/common/settings.page.dart b/mobile/lib/pages/common/settings.page.dart index 1fdd994639..f5c42c9321 100644 --- a/mobile/lib/pages/common/settings.page.dart +++ b/mobile/lib/pages/common/settings.page.dart @@ -182,8 +182,7 @@ class _BetaLandscapeToggle extends HookWidget { mainAxisAlignment: MainAxisAlignment.start, children: [ const SizedBox(height: 100, child: BetaTimelineListTile()), - if (Store.isBetaTimelineEnabled) - const Expanded(child: BetaSyncSettings()), + if (Store.isBetaTimelineEnabled) const Expanded(child: BetaSyncSettings()), ], ); } diff --git a/mobile/lib/presentation/pages/drift_album.page.dart b/mobile/lib/presentation/pages/drift_album.page.dart index 1f97bb6f45..e22c63ac29 100644 --- a/mobile/lib/presentation/pages/drift_album.page.dart +++ b/mobile/lib/presentation/pages/drift_album.page.dart @@ -1,24 +1,13 @@ 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:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/domain/models/album/album.model.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/models/albums/album_search.model.dart'; -import 'package:immich_mobile/pages/common/large_leading_tile.dart'; -import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'; +import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart'; -import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/routing/router.dart'; -import 'package:immich_mobile/utils/remote_album.utils.dart'; import 'package:immich_mobile/widgets/common/immich_sliver_app_bar.dart'; -import 'package:immich_mobile/widgets/common/search_field.dart'; @RoutePage() class DriftAlbumsPage extends ConsumerStatefulWidget { @@ -29,67 +18,12 @@ class DriftAlbumsPage extends ConsumerStatefulWidget { } class _DriftAlbumsPageState extends ConsumerState { - bool isGrid = false; - final searchController = TextEditingController(); - QuickFilterMode filterMode = QuickFilterMode.all; - final searchFocusNode = FocusNode(); - - @override - void initState() { - super.initState(); - - // Load albums when component mounts - WidgetsBinding.instance.addPostFrameCallback((_) { - ref.read(remoteAlbumProvider.notifier).refresh(); - }); - - searchController.addListener(() { - onSearch(searchController.text, filterMode); - }); - } - - void onSearch(String searchTerm, QuickFilterMode sortMode) { - final userId = ref.watch(currentUserProvider)?.id; - ref.read(remoteAlbumProvider.notifier).searchAlbums(searchTerm, userId, sortMode); - } - Future onRefresh() async { await ref.read(remoteAlbumProvider.notifier).refresh(); } - void toggleViewMode() { - setState(() { - isGrid = !isGrid; - }); - } - - void changeFilter(QuickFilterMode sortMode) { - setState(() { - filterMode = sortMode; - }); - } - - void clearSearch() { - setState(() { - filterMode = QuickFilterMode.all; - searchController.clear(); - ref.read(remoteAlbumProvider.notifier).clearSearch(); - }); - } - - @override - void dispose() { - searchController.dispose(); - searchFocusNode.dispose(); - super.dispose(); - } - @override Widget build(BuildContext context) { - final albums = ref.watch(remoteAlbumProvider.select((s) => s.filteredAlbums)); - - final userId = ref.watch(currentUserProvider)?.id; - return RefreshIndicator( onRefresh: onRefresh, edgeOffset: 100, @@ -112,616 +46,16 @@ class _DriftAlbumsPageState extends ConsumerState { ], showUploadButton: false, ), - _SearchBar( - searchController: searchController, - searchFocusNode: searchFocusNode, - onSearch: onSearch, - filterMode: filterMode, - onClearSearch: clearSearch, + AlbumSelector( + onAlbumSelected: (album) { + ref.read(currentRemoteAlbumProvider.notifier).setAlbum(album); + context.router.push( + RemoteAlbumRoute(album: album), + ); + }, ), - _QuickFilterButtonRow( - filterMode: filterMode, - onChangeFilter: changeFilter, - onSearch: onSearch, - searchController: searchController, - ), - _QuickSortAndViewMode( - isGrid: isGrid, - onToggleViewMode: toggleViewMode, - ), - isGrid - ? _AlbumGrid( - albums: albums, - userId: userId, - ) - : _AlbumList( - albums: albums, - userId: userId, - ), ], ), ); } } - -class _SortButton extends ConsumerStatefulWidget { - const _SortButton(); - - @override - ConsumerState<_SortButton> createState() => _SortButtonState(); -} - -class _SortButtonState extends ConsumerState<_SortButton> { - RemoteAlbumSortMode albumSortOption = RemoteAlbumSortMode.lastModified; - bool albumSortIsReverse = true; - - void onMenuTapped(RemoteAlbumSortMode sortMode) { - final selected = albumSortOption == sortMode; - // Switch direction - if (selected) { - setState(() { - albumSortIsReverse = !albumSortIsReverse; - }); - ref.read(remoteAlbumProvider.notifier).sortFilteredAlbums( - sortMode, - isReverse: albumSortIsReverse, - ); - } else { - setState(() { - albumSortOption = sortMode; - }); - ref.read(remoteAlbumProvider.notifier).sortFilteredAlbums( - sortMode, - isReverse: albumSortIsReverse, - ); - } - } - - @override - Widget build(BuildContext context) { - return MenuAnchor( - style: MenuStyle( - elevation: const WidgetStatePropertyAll(1), - shape: WidgetStateProperty.all( - const RoundedRectangleBorder( - borderRadius: BorderRadius.all( - Radius.circular(24), - ), - ), - ), - padding: const WidgetStatePropertyAll( - EdgeInsets.all(4), - ), - ), - consumeOutsideTap: true, - menuChildren: RemoteAlbumSortMode.values - .map( - (sortMode) => MenuItemButton( - leadingIcon: albumSortOption == sortMode - ? albumSortIsReverse - ? Icon( - Icons.keyboard_arrow_down, - color: albumSortOption == sortMode - ? context.colorScheme.onPrimary - : context.colorScheme.onSurface, - ) - : Icon( - Icons.keyboard_arrow_up_rounded, - color: albumSortOption == sortMode - ? context.colorScheme.onPrimary - : context.colorScheme.onSurface, - ) - : const Icon(Icons.abc, color: Colors.transparent), - onPressed: () => onMenuTapped(sortMode), - style: ButtonStyle( - padding: WidgetStateProperty.all( - const EdgeInsets.fromLTRB(16, 16, 32, 16), - ), - backgroundColor: WidgetStateProperty.all( - albumSortOption == sortMode ? context.colorScheme.primary : Colors.transparent, - ), - shape: WidgetStateProperty.all( - const RoundedRectangleBorder( - borderRadius: BorderRadius.all( - Radius.circular(24), - ), - ), - ), - ), - child: Text( - sortMode.key.t(context: context), - style: context.textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w600, - color: albumSortOption == sortMode - ? 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: albumSortIsReverse - ? const Icon( - Icons.keyboard_arrow_down, - ) - : const Icon( - Icons.keyboard_arrow_up_rounded, - ), - ), - Text( - albumSortOption.key.t(context: context), - style: context.textTheme.bodyLarge?.copyWith( - fontWeight: FontWeight.w500, - color: context.colorScheme.onSurface.withAlpha(225), - ), - ), - ], - ), - ); - }, - ); - } -} - -class _SearchBar extends StatelessWidget { - const _SearchBar({ - required this.searchController, - required this.searchFocusNode, - required this.onSearch, - required this.filterMode, - required this.onClearSearch, - }); - - final TextEditingController searchController; - final FocusNode searchFocusNode; - final void Function(String, QuickFilterMode) onSearch; - final QuickFilterMode filterMode; - final VoidCallback onClearSearch; - - @override - Widget build(BuildContext context) { - return SliverPadding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - sliver: SliverToBoxAdapter( - child: Container( - decoration: BoxDecoration( - border: Border.all( - color: context.colorScheme.onSurface.withAlpha(0), - width: 0, - ), - borderRadius: const BorderRadius.all( - Radius.circular(24), - ), - gradient: LinearGradient( - colors: [ - context.colorScheme.primary.withValues(alpha: 0.075), - context.colorScheme.primary.withValues(alpha: 0.09), - context.colorScheme.primary.withValues(alpha: 0.075), - ], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - transform: const GradientRotation(0.5 * pi), - ), - ), - child: SearchField( - autofocus: false, - contentPadding: const EdgeInsets.all(16), - hintText: 'search_albums'.tr(), - prefixIcon: const Icon(Icons.search_rounded), - suffixIcon: searchController.text.isNotEmpty - ? IconButton( - icon: const Icon(Icons.clear_rounded), - onPressed: onClearSearch, - ) - : null, - controller: searchController, - onChanged: (_) => onSearch(searchController.text, filterMode), - focusNode: searchFocusNode, - onTapOutside: (_) => searchFocusNode.unfocus(), - ), - ), - ), - ); - } -} - -class _QuickFilterButtonRow extends StatelessWidget { - const _QuickFilterButtonRow({ - required this.filterMode, - required this.onChangeFilter, - required this.onSearch, - required this.searchController, - }); - - final QuickFilterMode filterMode; - final void Function(QuickFilterMode) onChangeFilter; - final void Function(String, QuickFilterMode) onSearch; - final TextEditingController searchController; - - @override - Widget build(BuildContext context) { - return SliverPadding( - padding: const EdgeInsets.symmetric(horizontal: 16), - sliver: SliverToBoxAdapter( - child: Wrap( - spacing: 4, - runSpacing: 4, - children: [ - _QuickFilterButton( - label: 'all'.tr(), - isSelected: filterMode == QuickFilterMode.all, - onTap: () { - onChangeFilter(QuickFilterMode.all); - onSearch(searchController.text, QuickFilterMode.all); - }, - ), - _QuickFilterButton( - label: 'shared_with_me'.tr(), - isSelected: filterMode == QuickFilterMode.sharedWithMe, - onTap: () { - onChangeFilter(QuickFilterMode.sharedWithMe); - onSearch( - searchController.text, - QuickFilterMode.sharedWithMe, - ); - }, - ), - _QuickFilterButton( - label: 'my_albums'.tr(), - isSelected: filterMode == QuickFilterMode.myAlbums, - onTap: () { - onChangeFilter(QuickFilterMode.myAlbums); - onSearch( - searchController.text, - QuickFilterMode.myAlbums, - ); - }, - ), - ], - ), - ), - ); - } -} - -class _QuickFilterButton extends StatelessWidget { - const _QuickFilterButton({ - 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: const BorderRadius.all( - Radius.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 _QuickSortAndViewMode extends StatelessWidget { - const _QuickSortAndViewMode({ - required this.isGrid, - required this.onToggleViewMode, - }); - - final bool isGrid; - final VoidCallback onToggleViewMode; - - @override - Widget build(BuildContext context) { - return SliverPadding( - padding: const EdgeInsets.symmetric(horizontal: 16), - sliver: SliverToBoxAdapter( - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const _SortButton(), - IconButton( - icon: Icon( - isGrid ? Icons.view_list_outlined : Icons.grid_view_outlined, - size: 24, - ), - onPressed: onToggleViewMode, - ), - ], - ), - ), - ); - } -} - -class _AlbumList extends ConsumerWidget { - const _AlbumList({ - required this.albums, - required this.userId, - }); - - final List albums; - final String? userId; - - @override - Widget build(BuildContext context, WidgetRef ref) { - if (albums.isEmpty) { - return const SliverToBoxAdapter( - child: Center( - child: Padding( - padding: EdgeInsets.all(20.0), - child: Text('No albums found'), - ), - ), - ); - } - - return SliverPadding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - sliver: SliverList.builder( - itemBuilder: (_, index) { - final album = albums[index]; - - return Padding( - padding: const EdgeInsets.only( - bottom: 8.0, - ), - child: LargeLeadingTile( - title: Text( - album.name, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: context.textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - subtitle: Text( - '${'items_count'.t( - context: context, - args: { - 'count': album.assetCount, - }, - )} • ${album.ownerId != userId ? 'shared_by_user'.t( - context: context, - args: { - 'user': album.ownerName, - }, - ) : 'owned'.t(context: context)}', - overflow: TextOverflow.ellipsis, - style: context.textTheme.bodyMedium?.copyWith( - color: context.colorScheme.onSurfaceSecondary, - ), - ), - onTap: () { - ref.read(currentRemoteAlbumProvider.notifier).setAlbum(album); - context.router.push( - RemoteAlbumRoute(album: album), - ); - }, - leadingPadding: const EdgeInsets.only( - right: 16, - ), - leading: album.thumbnailAssetId != null - ? ClipRRect( - borderRadius: const BorderRadius.all( - Radius.circular(15), - ), - child: SizedBox( - width: 80, - height: 80, - child: Thumbnail( - remoteId: album.thumbnailAssetId, - ), - ), - ) - : SizedBox( - width: 80, - height: 80, - child: Container( - decoration: BoxDecoration( - color: context.colorScheme.surfaceContainer, - borderRadius: const BorderRadius.all(Radius.circular(16)), - border: Border.all( - color: context.colorScheme.outline.withAlpha(50), - width: 1, - ), - ), - child: const Icon( - Icons.photo_album_rounded, - size: 24, - color: Colors.grey, - ), - ), - ), - ), - ); - }, - itemCount: albums.length, - ), - ); - } -} - -class _AlbumGrid extends StatelessWidget { - const _AlbumGrid({ - required this.albums, - required this.userId, - }); - - final List albums; - final String? userId; - - @override - Widget build(BuildContext context) { - if (albums.isEmpty) { - return const SliverToBoxAdapter( - child: Center( - child: Padding( - padding: EdgeInsets.all(20.0), - child: Text('No albums found'), - ), - ), - ); - } - - return SliverPadding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - sliver: SliverGrid( - gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: 250, - mainAxisSpacing: 4, - crossAxisSpacing: 4, - childAspectRatio: .7, - ), - delegate: SliverChildBuilderDelegate( - (context, index) { - final album = albums[index]; - return _GridAlbumCard( - album: album, - userId: userId, - ); - }, - childCount: albums.length, - ), - ), - ); - } -} - -class _GridAlbumCard extends ConsumerWidget { - const _GridAlbumCard({ - required this.album, - required this.userId, - }); - - final RemoteAlbum album; - final String? userId; - - @override - Widget build(BuildContext context, WidgetRef ref) { - return GestureDetector( - onTap: () { - ref.read(currentRemoteAlbumProvider.notifier).setAlbum(album); - context.router.push( - RemoteAlbumRoute(album: album), - ); - }, - child: Card( - elevation: 0, - color: context.colorScheme.surfaceBright, - shape: RoundedRectangleBorder( - borderRadius: const BorderRadius.all( - Radius.circular(16), - ), - side: BorderSide( - color: context.colorScheme.onSurface.withAlpha(25), - width: 1, - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - flex: 2, - child: ClipRRect( - borderRadius: const BorderRadius.vertical( - top: Radius.circular(15), - ), - child: SizedBox( - width: double.infinity, - child: album.thumbnailAssetId != null - ? Thumbnail( - remoteId: album.thumbnailAssetId, - ) - : Container( - color: context.colorScheme.surfaceContainerHighest, - child: const Icon( - Icons.photo_album_rounded, - size: 40, - color: Colors.grey, - ), - ), - ), - ), - ), - Expanded( - flex: 1, - child: Padding( - padding: const EdgeInsets.all(12.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - Text( - album.name, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: context.textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - Text( - '${'items_count'.t( - context: context, - args: { - 'count': album.assetCount, - }, - )} • ${album.ownerId != userId ? 'shared_by_user'.t( - context: context, - args: { - 'user': album.ownerName, - }, - ) : 'owned'.t(context: context)}', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: context.textTheme.labelMedium?.copyWith( - color: context.colorScheme.onSurfaceSecondary, - ), - ), - ], - ), - ), - ), - ], - ), - ), - ); - } -} diff --git a/mobile/lib/presentation/widgets/album/album_selector.widget.dart b/mobile/lib/presentation/widgets/album/album_selector.widget.dart new file mode 100644 index 0000000000..5d9378ecaf --- /dev/null +++ b/mobile/lib/presentation/widgets/album/album_selector.widget.dart @@ -0,0 +1,778 @@ +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:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/album/album.model.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.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/models/albums/album_search.model.dart'; +import 'package:immich_mobile/pages/common/large_leading_tile.dart'; +import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'; +import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; +import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; +import 'package:immich_mobile/providers/user.provider.dart'; +import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/utils/remote_album.utils.dart'; +import 'package:immich_mobile/widgets/common/immich_toast.dart'; +import 'package:immich_mobile/widgets/common/search_field.dart'; +import 'package:sliver_tools/sliver_tools.dart'; + +typedef AlbumSelectorCallback = void Function(RemoteAlbum album); + +class AlbumSelector extends ConsumerStatefulWidget { + final AlbumSelectorCallback onAlbumSelected; + + const AlbumSelector({ + super.key, + required this.onAlbumSelected, + }); + + @override + ConsumerState createState() => _AlbumSelectorState(); +} + +class _AlbumSelectorState extends ConsumerState { + bool isGrid = false; + final searchController = TextEditingController(); + QuickFilterMode filterMode = QuickFilterMode.all; + final searchFocusNode = FocusNode(); + + @override + void initState() { + super.initState(); + + // Load albums when component mounts + WidgetsBinding.instance.addPostFrameCallback((_) { + ref.read(remoteAlbumProvider.notifier).refresh(); + }); + + searchController.addListener(() { + onSearch(searchController.text, filterMode); + }); + } + + void onSearch(String searchTerm, QuickFilterMode sortMode) { + final userId = ref.watch(currentUserProvider)?.id; + ref.read(remoteAlbumProvider.notifier).searchAlbums(searchTerm, userId, sortMode); + } + + Future onRefresh() async { + await ref.read(remoteAlbumProvider.notifier).refresh(); + } + + void toggleViewMode() { + setState(() { + isGrid = !isGrid; + }); + } + + void changeFilter(QuickFilterMode sortMode) { + setState(() { + filterMode = sortMode; + }); + } + + void clearSearch() { + setState(() { + filterMode = QuickFilterMode.all; + searchController.clear(); + ref.read(remoteAlbumProvider.notifier).clearSearch(); + }); + } + + @override + void dispose() { + searchController.dispose(); + searchFocusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final albums = ref.watch(remoteAlbumProvider.select((s) => s.filteredAlbums)); + + final userId = ref.watch(currentUserProvider)?.id; + + return MultiSliver( + children: [ + _SearchBar( + searchController: searchController, + searchFocusNode: searchFocusNode, + onSearch: onSearch, + filterMode: filterMode, + onClearSearch: clearSearch, + ), + _QuickFilterButtonRow( + filterMode: filterMode, + onChangeFilter: changeFilter, + onSearch: onSearch, + searchController: searchController, + ), + _QuickSortAndViewMode( + isGrid: isGrid, + onToggleViewMode: toggleViewMode, + ), + isGrid + ? _AlbumGrid( + albums: albums, + userId: userId, + onAlbumSelected: widget.onAlbumSelected, + ) + : _AlbumList( + albums: albums, + userId: userId, + onAlbumSelected: widget.onAlbumSelected, + ), + ], + ); + } +} + +class _SortButton extends ConsumerStatefulWidget { + const _SortButton(); + + @override + ConsumerState<_SortButton> createState() => _SortButtonState(); +} + +class _SortButtonState extends ConsumerState<_SortButton> { + RemoteAlbumSortMode albumSortOption = RemoteAlbumSortMode.lastModified; + bool albumSortIsReverse = true; + + void onMenuTapped(RemoteAlbumSortMode sortMode) { + final selected = albumSortOption == sortMode; + // Switch direction + if (selected) { + setState(() { + albumSortIsReverse = !albumSortIsReverse; + }); + ref.read(remoteAlbumProvider.notifier).sortFilteredAlbums( + sortMode, + isReverse: albumSortIsReverse, + ); + } else { + setState(() { + albumSortOption = sortMode; + }); + ref.read(remoteAlbumProvider.notifier).sortFilteredAlbums( + sortMode, + isReverse: albumSortIsReverse, + ); + } + } + + @override + Widget build(BuildContext context) { + return MenuAnchor( + style: MenuStyle( + elevation: const WidgetStatePropertyAll(1), + shape: WidgetStateProperty.all( + const RoundedRectangleBorder( + borderRadius: BorderRadius.all( + Radius.circular(24), + ), + ), + ), + padding: const WidgetStatePropertyAll( + EdgeInsets.all(4), + ), + ), + consumeOutsideTap: true, + menuChildren: RemoteAlbumSortMode.values + .map( + (sortMode) => MenuItemButton( + leadingIcon: albumSortOption == sortMode + ? albumSortIsReverse + ? Icon( + Icons.keyboard_arrow_down, + color: albumSortOption == sortMode + ? context.colorScheme.onPrimary + : context.colorScheme.onSurface, + ) + : Icon( + Icons.keyboard_arrow_up_rounded, + color: albumSortOption == sortMode + ? context.colorScheme.onPrimary + : context.colorScheme.onSurface, + ) + : const Icon(Icons.abc, color: Colors.transparent), + onPressed: () => onMenuTapped(sortMode), + style: ButtonStyle( + padding: WidgetStateProperty.all( + const EdgeInsets.fromLTRB(16, 16, 32, 16), + ), + backgroundColor: WidgetStateProperty.all( + albumSortOption == sortMode ? context.colorScheme.primary : Colors.transparent, + ), + shape: WidgetStateProperty.all( + const RoundedRectangleBorder( + borderRadius: BorderRadius.all( + Radius.circular(24), + ), + ), + ), + ), + child: Text( + sortMode.key.t(context: context), + style: context.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + color: albumSortOption == sortMode + ? 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: albumSortIsReverse + ? const Icon( + Icons.keyboard_arrow_down, + ) + : const Icon( + Icons.keyboard_arrow_up_rounded, + ), + ), + Text( + albumSortOption.key.t(context: context), + style: context.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w500, + color: context.colorScheme.onSurface.withAlpha(225), + ), + ), + ], + ), + ); + }, + ); + } +} + +class _SearchBar extends StatelessWidget { + const _SearchBar({ + required this.searchController, + required this.searchFocusNode, + required this.onSearch, + required this.filterMode, + required this.onClearSearch, + }); + + final TextEditingController searchController; + final FocusNode searchFocusNode; + final void Function(String, QuickFilterMode) onSearch; + final QuickFilterMode filterMode; + final VoidCallback onClearSearch; + + @override + Widget build(BuildContext context) { + return SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + sliver: SliverToBoxAdapter( + child: Container( + decoration: BoxDecoration( + border: Border.all( + color: context.colorScheme.onSurface.withAlpha(0), + width: 0, + ), + borderRadius: const BorderRadius.all( + Radius.circular(24), + ), + gradient: LinearGradient( + colors: [ + context.colorScheme.primary.withValues(alpha: 0.075), + context.colorScheme.primary.withValues(alpha: 0.09), + context.colorScheme.primary.withValues(alpha: 0.075), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + transform: const GradientRotation(0.5 * pi), + ), + ), + child: SearchField( + autofocus: false, + contentPadding: const EdgeInsets.all(16), + hintText: 'search_albums'.tr(), + prefixIcon: const Icon(Icons.search_rounded), + suffixIcon: searchController.text.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear_rounded), + onPressed: onClearSearch, + ) + : null, + controller: searchController, + onChanged: (_) => onSearch(searchController.text, filterMode), + focusNode: searchFocusNode, + onTapOutside: (_) => searchFocusNode.unfocus(), + ), + ), + ), + ); + } +} + +class _QuickFilterButtonRow extends StatelessWidget { + const _QuickFilterButtonRow({ + required this.filterMode, + required this.onChangeFilter, + required this.onSearch, + required this.searchController, + }); + + final QuickFilterMode filterMode; + final void Function(QuickFilterMode) onChangeFilter; + final void Function(String, QuickFilterMode) onSearch; + final TextEditingController searchController; + + @override + Widget build(BuildContext context) { + return SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 16), + sliver: SliverToBoxAdapter( + child: Wrap( + spacing: 4, + runSpacing: 4, + children: [ + _QuickFilterButton( + label: 'all'.tr(), + isSelected: filterMode == QuickFilterMode.all, + onTap: () { + onChangeFilter(QuickFilterMode.all); + onSearch(searchController.text, QuickFilterMode.all); + }, + ), + _QuickFilterButton( + label: 'shared_with_me'.tr(), + isSelected: filterMode == QuickFilterMode.sharedWithMe, + onTap: () { + onChangeFilter(QuickFilterMode.sharedWithMe); + onSearch( + searchController.text, + QuickFilterMode.sharedWithMe, + ); + }, + ), + _QuickFilterButton( + label: 'my_albums'.tr(), + isSelected: filterMode == QuickFilterMode.myAlbums, + onTap: () { + onChangeFilter(QuickFilterMode.myAlbums); + onSearch( + searchController.text, + QuickFilterMode.myAlbums, + ); + }, + ), + ], + ), + ), + ); + } +} + +class _QuickFilterButton extends StatelessWidget { + const _QuickFilterButton({ + 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: const BorderRadius.all( + Radius.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 _QuickSortAndViewMode extends StatelessWidget { + const _QuickSortAndViewMode({ + required this.isGrid, + required this.onToggleViewMode, + }); + + final bool isGrid; + final VoidCallback onToggleViewMode; + + @override + Widget build(BuildContext context) { + return SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 16), + sliver: SliverToBoxAdapter( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const _SortButton(), + IconButton( + icon: Icon( + isGrid ? Icons.view_list_outlined : Icons.grid_view_outlined, + size: 24, + ), + onPressed: onToggleViewMode, + ), + ], + ), + ), + ); + } +} + +class _AlbumList extends ConsumerWidget { + const _AlbumList({ + required this.albums, + required this.userId, + required this.onAlbumSelected, + }); + + final List albums; + final String? userId; + final AlbumSelectorCallback onAlbumSelected; + + @override + Widget build(BuildContext context, WidgetRef ref) { + if (albums.isEmpty) { + return const SliverToBoxAdapter( + child: Center( + child: Padding( + padding: EdgeInsets.all(20.0), + child: Text('No albums found'), + ), + ), + ); + } + + return SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + sliver: SliverList.builder( + itemBuilder: (_, index) { + final album = albums[index]; + + return Padding( + padding: const EdgeInsets.only( + bottom: 8.0, + ), + child: LargeLeadingTile( + title: Text( + album.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: context.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + subtitle: Text( + '${'items_count'.t( + context: context, + args: { + 'count': album.assetCount, + }, + )} • ${album.ownerId != userId ? 'shared_by_user'.t( + context: context, + args: { + 'user': album.ownerName, + }, + ) : 'owned'.t(context: context)}', + overflow: TextOverflow.ellipsis, + style: context.textTheme.bodyMedium?.copyWith( + color: context.colorScheme.onSurfaceSecondary, + ), + ), + onTap: () => onAlbumSelected(album), + leadingPadding: const EdgeInsets.only( + right: 16, + ), + leading: album.thumbnailAssetId != null + ? ClipRRect( + borderRadius: const BorderRadius.all( + Radius.circular(15), + ), + child: SizedBox( + width: 80, + height: 80, + child: Thumbnail( + remoteId: album.thumbnailAssetId, + ), + ), + ) + : SizedBox( + width: 80, + height: 80, + child: Container( + decoration: BoxDecoration( + color: context.colorScheme.surfaceContainer, + borderRadius: const BorderRadius.all(Radius.circular(16)), + border: Border.all( + color: context.colorScheme.outline.withAlpha(50), + width: 1, + ), + ), + child: const Icon( + Icons.photo_album_rounded, + size: 24, + color: Colors.grey, + ), + ), + ), + ), + ); + }, + itemCount: albums.length, + ), + ); + } +} + +class _AlbumGrid extends StatelessWidget { + const _AlbumGrid({ + required this.albums, + required this.userId, + required this.onAlbumSelected, + }); + + final List albums; + final String? userId; + final AlbumSelectorCallback onAlbumSelected; + + @override + Widget build(BuildContext context) { + if (albums.isEmpty) { + return const SliverToBoxAdapter( + child: Center( + child: Padding( + padding: EdgeInsets.all(20.0), + child: Text('No albums found'), + ), + ), + ); + } + + return SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + sliver: SliverGrid( + gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 250, + mainAxisSpacing: 4, + crossAxisSpacing: 4, + childAspectRatio: .7, + ), + delegate: SliverChildBuilderDelegate( + (context, index) { + final album = albums[index]; + return _GridAlbumCard( + album: album, + userId: userId, + onAlbumSelected: onAlbumSelected, + ); + }, + childCount: albums.length, + ), + ), + ); + } +} + +class _GridAlbumCard extends ConsumerWidget { + const _GridAlbumCard({ + required this.album, + required this.userId, + required this.onAlbumSelected, + }); + + final RemoteAlbum album; + final String? userId; + final AlbumSelectorCallback onAlbumSelected; + + @override + Widget build(BuildContext context, WidgetRef ref) { + return GestureDetector( + onTap: () => onAlbumSelected(album), + child: Card( + elevation: 0, + color: context.colorScheme.surfaceBright, + shape: RoundedRectangleBorder( + borderRadius: const BorderRadius.all( + Radius.circular(16), + ), + side: BorderSide( + color: context.colorScheme.onSurface.withAlpha(25), + width: 1, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + flex: 2, + child: ClipRRect( + borderRadius: const BorderRadius.vertical( + top: Radius.circular(15), + ), + child: SizedBox( + width: double.infinity, + child: album.thumbnailAssetId != null + ? Thumbnail( + remoteId: album.thumbnailAssetId, + ) + : Container( + color: context.colorScheme.surfaceContainerHighest, + child: const Icon( + Icons.photo_album_rounded, + size: 40, + color: Colors.grey, + ), + ), + ), + ), + ), + Expanded( + flex: 1, + child: Padding( + padding: const EdgeInsets.all(12.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + Text( + album.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: context.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + Text( + '${'items_count'.t( + context: context, + args: { + 'count': album.assetCount, + }, + )} • ${album.ownerId != userId ? 'shared_by_user'.t( + context: context, + args: { + 'user': album.ownerName, + }, + ) : 'owned'.t(context: context)}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: context.textTheme.labelMedium?.copyWith( + color: context.colorScheme.onSurfaceSecondary, + ), + ), + ], + ), + ), + ), + ], + ), + ), + ); + } +} + +class AddToAlbumHeader extends ConsumerWidget { + const AddToAlbumHeader({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + Future onCreateAlbum() async { + final newAlbum = await ref.read(remoteAlbumProvider.notifier).createAlbum( + title: "Untitled Album", + assetIds: ref.read(multiSelectProvider).selectedAssets.map((e) => (e as RemoteAsset).id).toList(), + ); + + if (newAlbum == null) { + ImmichToast.show( + context: context, + toastType: ToastType.error, + msg: 'errors.failed_to_create_album'.tr(), + ); + return; + } + + context.pushRoute(RemoteAlbumRoute(album: newAlbum)); + } + + return SliverPadding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + ), + sliver: SliverToBoxAdapter( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "add_to_album", + style: context.textTheme.titleSmall, + ).tr(), + TextButton.icon( + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), // remove internal padding + minimumSize: const Size(0, 0), // allow shrinking + tapTargetSize: MaterialTapTargetSize.shrinkWrap, // remove extra height + ), + onPressed: onCreateAlbum, + icon: Icon( + Icons.add, + color: context.primaryColor, + ), + label: Text( + "common_create_new_album", + style: TextStyle( + color: context.primaryColor, + fontWeight: FontWeight.bold, + fontSize: 14, + ), + ).tr(), + ), + ], + ), + ), + ); + } +} diff --git a/mobile/lib/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart index c148861b43..12f03a0b25 100644 --- a/mobile/lib/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart @@ -1,6 +1,9 @@ +import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/domain/models/album/album.model.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart'; @@ -14,9 +17,12 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_act import 'package:immich_mobile/presentation/widgets/action_buttons/stack_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart'; +import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart'; import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart'; +import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; +import 'package:immich_mobile/widgets/common/immich_toast.dart'; class GeneralBottomSheet extends ConsumerWidget { const GeneralBottomSheet({super.key}); @@ -28,9 +34,39 @@ class GeneralBottomSheet extends ConsumerWidget { serverInfoProvider.select((state) => state.serverFeatures.trash), ); + Future addAssetsToAlbum(RemoteAlbum album) async { + final selectedAssets = multiselect.selectedAssets; + if (selectedAssets.isEmpty) { + return; + } + + final addedCount = await ref.read(remoteAlbumProvider.notifier).addAssets( + album.id, + selectedAssets.map((e) => (e as RemoteAsset).id).toList(), + ); + + if (addedCount != selectedAssets.length) { + ImmichToast.show( + context: context, + msg: 'add_to_album_bottom_sheet_already_exists'.tr( + namedArgs: {"album": album.name}, + ), + ); + } else { + ImmichToast.show( + context: context, + msg: 'add_to_album_bottom_sheet_added'.tr( + namedArgs: {"album": album.name}, + ), + ); + } + + ref.read(multiSelectProvider.notifier).reset(); + } + return BaseBottomSheet( - initialChildSize: 0.25, - maxChildSize: 0.4, + initialChildSize: 0.45, + maxChildSize: 0.85, shouldCloseOnMinExtent: false, actions: [ const ShareActionButton(source: ActionSource.timeline), @@ -59,6 +95,12 @@ class GeneralBottomSheet extends ConsumerWidget { const UploadActionButton(source: ActionSource.timeline), ], ], + slivers: [ + const AddToAlbumHeader(), + AlbumSelector( + onAlbumSelected: addAssetsToAlbum, + ), + ], ); } } diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 95aa866ea3..e4f4e83a17 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -1689,6 +1689,14 @@ packages: description: flutter source: sdk version: "0.0.0" + sliver_tools: + dependency: "direct main" + description: + name: sliver_tools + sha256: eae28220badfb9d0559207badcbbc9ad5331aac829a88cb0964d330d2a4636a6 + url: "https://pub.dev" + source: hosted + version: "0.2.12" socket_io_client: dependency: "direct main" description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index dcd078f65f..52b576054b 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -63,6 +63,7 @@ dependencies: scrollable_positioned_list: ^0.3.8 share_handler: ^0.0.22 share_plus: ^10.1.4 + sliver_tools: ^0.2.12 socket_io_client: ^2.0.3+1 stream_transform: ^2.1.1 thumbhash: 0.1.0+1