mirror of
https://github.com/immich-app/immich.git
synced 2025-07-31 15:08:44 -04:00
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:
parent
de67d22bc0
commit
06c78dfa91
@ -182,8 +182,7 @@ class _BetaLandscapeToggle extends HookWidget {
|
|||||||
mainAxisAlignment: MainAxisAlignment.start,
|
mainAxisAlignment: MainAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(height: 100, child: BetaTimelineListTile()),
|
const SizedBox(height: 100, child: BetaTimelineListTile()),
|
||||||
if (Store.isBetaTimelineEnabled)
|
if (Store.isBetaTimelineEnabled) const Expanded(child: BetaSyncSettings()),
|
||||||
const Expanded(child: BetaSyncSettings()),
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,24 +1,13 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:math';
|
|
||||||
|
|
||||||
import 'package:auto_route/auto_route.dart';
|
import 'package:auto_route/auto_route.dart';
|
||||||
import 'package:easy_localization/easy_localization.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/domain/models/album/album.model.dart';
|
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.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/infrastructure/album.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/current_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/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/immich_sliver_app_bar.dart';
|
||||||
import 'package:immich_mobile/widgets/common/search_field.dart';
|
|
||||||
|
|
||||||
@RoutePage()
|
@RoutePage()
|
||||||
class DriftAlbumsPage extends ConsumerStatefulWidget {
|
class DriftAlbumsPage extends ConsumerStatefulWidget {
|
||||||
@ -29,67 +18,12 @@ class DriftAlbumsPage extends ConsumerStatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _DriftAlbumsPageState extends ConsumerState<DriftAlbumsPage> {
|
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 {
|
Future<void> onRefresh() async {
|
||||||
await ref.read(remoteAlbumProvider.notifier).refresh();
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final albums = ref.watch(remoteAlbumProvider.select((s) => s.filteredAlbums));
|
|
||||||
|
|
||||||
final userId = ref.watch(currentUserProvider)?.id;
|
|
||||||
|
|
||||||
return RefreshIndicator(
|
return RefreshIndicator(
|
||||||
onRefresh: onRefresh,
|
onRefresh: onRefresh,
|
||||||
edgeOffset: 100,
|
edgeOffset: 100,
|
||||||
@ -112,616 +46,16 @@ class _DriftAlbumsPageState extends ConsumerState<DriftAlbumsPage> {
|
|||||||
],
|
],
|
||||||
showUploadButton: false,
|
showUploadButton: false,
|
||||||
),
|
),
|
||||||
_SearchBar(
|
AlbumSelector(
|
||||||
searchController: searchController,
|
onAlbumSelected: (album) {
|
||||||
searchFocusNode: searchFocusNode,
|
ref.read(currentRemoteAlbumProvider.notifier).setAlbum(album);
|
||||||
onSearch: onSearch,
|
context.router.push(
|
||||||
filterMode: filterMode,
|
RemoteAlbumRoute(album: album),
|
||||||
onClearSearch: clearSearch,
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
_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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
778
mobile/lib/presentation/widgets/album/album_selector.widget.dart
Normal file
778
mobile/lib/presentation/widgets/album/album_selector.widget.dart
Normal 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(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -1,6 +1,9 @@
|
|||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/constants/enums.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/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_permanent_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_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/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/trash_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_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/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/server_info.provider.dart';
|
||||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||||
|
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||||
|
|
||||||
class GeneralBottomSheet extends ConsumerWidget {
|
class GeneralBottomSheet extends ConsumerWidget {
|
||||||
const GeneralBottomSheet({super.key});
|
const GeneralBottomSheet({super.key});
|
||||||
@ -28,9 +34,39 @@ class GeneralBottomSheet extends ConsumerWidget {
|
|||||||
serverInfoProvider.select((state) => state.serverFeatures.trash),
|
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(
|
return BaseBottomSheet(
|
||||||
initialChildSize: 0.25,
|
initialChildSize: 0.45,
|
||||||
maxChildSize: 0.4,
|
maxChildSize: 0.85,
|
||||||
shouldCloseOnMinExtent: false,
|
shouldCloseOnMinExtent: false,
|
||||||
actions: [
|
actions: [
|
||||||
const ShareActionButton(source: ActionSource.timeline),
|
const ShareActionButton(source: ActionSource.timeline),
|
||||||
@ -59,6 +95,12 @@ class GeneralBottomSheet extends ConsumerWidget {
|
|||||||
const UploadActionButton(source: ActionSource.timeline),
|
const UploadActionButton(source: ActionSource.timeline),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
slivers: [
|
||||||
|
const AddToAlbumHeader(),
|
||||||
|
AlbumSelector(
|
||||||
|
onAlbumSelected: addAssetsToAlbum,
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1689,6 +1689,14 @@ packages:
|
|||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
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:
|
socket_io_client:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
@ -63,6 +63,7 @@ dependencies:
|
|||||||
scrollable_positioned_list: ^0.3.8
|
scrollable_positioned_list: ^0.3.8
|
||||||
share_handler: ^0.0.22
|
share_handler: ^0.0.22
|
||||||
share_plus: ^10.1.4
|
share_plus: ^10.1.4
|
||||||
|
sliver_tools: ^0.2.12
|
||||||
socket_io_client: ^2.0.3+1
|
socket_io_client: ^2.0.3+1
|
||||||
stream_transform: ^2.1.1
|
stream_transform: ^2.1.1
|
||||||
thumbhash: 0.1.0+1
|
thumbhash: 0.1.0+1
|
||||||
|
Loading…
x
Reference in New Issue
Block a user