feat: add to album on new beta timeline (#20119)

* feat: add to album on new beta timeline

* handle add album button

* tune

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
This commit is contained in:
Brandon Wees 2025-07-25 01:10:33 -05:00 committed by GitHub
parent de67d22bc0
commit 06c78dfa91
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 840 additions and 678 deletions

View File

@ -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()),
],
);
}

View File

@ -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<DriftAlbumsPage> {
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<void> 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<DriftAlbumsPage> {
],
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<RemoteAlbum> 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<RemoteAlbum> 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,
),
),
],
),
),
),
],
),
),
);
}
}

View File

@ -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<AlbumSelector> createState() => _AlbumSelectorState();
}
class _AlbumSelectorState extends ConsumerState<AlbumSelector> {
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<void> 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<RemoteAlbum> 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<RemoteAlbum> 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<void> 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(),
),
],
),
),
);
}
}

View File

@ -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<void> 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,
),
],
);
}
}

View File

@ -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:

View File

@ -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