refactor(mobile): widgets (#9291)

* refactor(mobile): widgets

* update
This commit is contained in:
Alex 2024-05-06 23:04:21 -05:00 committed by GitHub
parent 7520ffd6c3
commit 5806a3ce25
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
203 changed files with 318 additions and 318 deletions

View file

@ -1,277 +0,0 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/models/backup/backup_state.model.dart';
import 'package:immich_mobile/providers/backup/backup.provider.dart';
import 'package:immich_mobile/providers/backup/manual_upload.provider.dart';
import 'package:immich_mobile/providers/authentication.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/providers/asset.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/providers/websocket.provider.dart';
import 'package:immich_mobile/shared/ui/app_bar_dialog/app_bar_profile_info.dart';
import 'package:immich_mobile/shared/ui/app_bar_dialog/app_bar_server_info.dart';
import 'package:immich_mobile/shared/ui/confirm_dialog.dart';
import 'package:immich_mobile/utils/bytes_units.dart';
import 'package:url_launcher/url_launcher.dart';
class ImmichAppBarDialog extends HookConsumerWidget {
const ImmichAppBarDialog({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
BackUpState backupState = ref.watch(backupProvider);
final theme = context.themeData;
bool isHorizontal = !context.isMobile;
final horizontalPadding = isHorizontal ? 100.0 : 20.0;
final user = ref.watch(currentUserProvider);
useEffect(
() {
ref.read(backupProvider.notifier).updateServerInfo();
ref.read(currentUserProvider.notifier).refresh();
return null;
},
[],
);
buildTopRow() {
return Stack(
children: [
Align(
alignment: Alignment.topLeft,
child: InkWell(
onTap: () => context.pop(),
child: const Icon(
Icons.close,
size: 20,
),
),
),
Center(
child: Image.asset(
context.isDarkTheme
? 'assets/immich-text-dark.png'
: 'assets/immich-text-light.png',
height: 16,
),
),
],
);
}
buildActionButton(IconData icon, String text, Function() onTap) {
return ListTile(
dense: true,
visualDensity: VisualDensity.standard,
contentPadding: const EdgeInsets.only(left: 30),
minLeadingWidth: 40,
leading: SizedBox(
child: Icon(
icon,
color: theme.textTheme.labelLarge?.color?.withAlpha(250),
size: 20,
),
),
title: Text(
text,
style: theme.textTheme.labelLarge?.copyWith(
color: theme.textTheme.labelLarge?.color?.withAlpha(250),
),
).tr(),
onTap: onTap,
);
}
buildSettingButton() {
return buildActionButton(
Icons.settings_rounded,
"profile_drawer_settings",
() => context.pushRoute(const SettingsRoute()),
);
}
buildAppLogButton() {
return buildActionButton(
Icons.assignment_outlined,
"profile_drawer_app_logs",
() => context.pushRoute(const AppLogRoute()),
);
}
buildSignOutButton() {
return buildActionButton(
Icons.logout_rounded,
"profile_drawer_sign_out",
() async {
showDialog(
context: context,
builder: (BuildContext ctx) {
return ConfirmDialog(
title: "app_bar_signout_dialog_title",
content: "app_bar_signout_dialog_content",
ok: "app_bar_signout_dialog_ok",
onOk: () async {
await ref.read(authenticationProvider.notifier).logout();
ref.read(manualUploadProvider.notifier).cancelBackup();
ref.read(backupProvider.notifier).cancelBackup();
ref.read(assetProvider.notifier).clearAllAsset();
ref.read(websocketProvider.notifier).disconnect();
context.replaceRoute(const LoginRoute());
},
);
},
);
},
);
}
Widget buildStorageInformation() {
var percentage = backupState.serverInfo.diskUsagePercentage / 100;
var usedDiskSpace = backupState.serverInfo.diskUse;
var totalDiskSpace = backupState.serverInfo.diskSize;
if (user != null && user.hasQuota) {
usedDiskSpace = formatBytes(user.quotaUsageInBytes);
totalDiskSpace = formatBytes(user.quotaSizeInBytes);
percentage = user.quotaUsageInBytes / user.quotaSizeInBytes;
}
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 10.0, vertical: 3),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 4),
decoration: BoxDecoration(
color: context.isDarkTheme
? context.scaffoldBackgroundColor
: const Color.fromARGB(255, 225, 229, 240),
),
child: ListTile(
minLeadingWidth: 50,
leading: Icon(
Icons.storage_rounded,
color: theme.primaryColor,
),
title: Text(
"backup_controller_page_server_storage",
style: context.textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w500,
),
).tr(),
isThreeLine: true,
subtitle: Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: LinearProgressIndicator(
minHeight: 5.0,
value: percentage,
backgroundColor: Colors.grey,
color: theme.primaryColor,
),
),
Padding(
padding: const EdgeInsets.only(top: 12.0),
child:
const Text('backup_controller_page_storage_format').tr(
args: [
usedDiskSpace,
totalDiskSpace,
],
),
),
],
),
),
),
),
);
}
buildFooter() {
return Padding(
padding: const EdgeInsets.only(top: 10, bottom: 20),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
InkWell(
onTap: () {
context.pop();
launchUrl(
Uri.parse('https://immich.app'),
mode: LaunchMode.externalApplication,
);
},
child: Text(
"profile_drawer_documentation",
style: context.textTheme.bodySmall,
).tr(),
),
const SizedBox(
width: 20,
child: Text(
"",
textAlign: TextAlign.center,
),
),
InkWell(
onTap: () {
context.pop();
launchUrl(
Uri.parse('https://github.com/immich-app/immich'),
mode: LaunchMode.externalApplication,
);
},
child: Text(
"profile_drawer_github",
style: context.textTheme.bodySmall,
).tr(),
),
],
),
);
}
return Dialog(
clipBehavior: Clip.hardEdge,
alignment: Alignment.topCenter,
insetPadding: EdgeInsets.only(
top: isHorizontal ? 20 : 40,
left: horizontalPadding,
right: horizontalPadding,
bottom: isHorizontal ? 20 : 100,
),
backgroundColor: theme.cardColor,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
child: SizedBox(
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: const EdgeInsets.all(20),
child: buildTopRow(),
),
const AppBarProfileInfoBox(),
buildStorageInformation(),
const AppBarServerInfo(),
buildAppLogButton(),
buildSettingButton(),
buildSignOutButton(),
buildFooter(),
],
),
),
),
);
}
}

View file

@ -1,139 +0,0 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:image_picker/image_picker.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/upload_profile_image.provider.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/shared/ui/user_circle_avatar.dart';
import 'package:immich_mobile/models/authentication/authentication_state.model.dart';
import 'package:immich_mobile/providers/authentication.provider.dart';
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
class AppBarProfileInfoBox extends HookConsumerWidget {
const AppBarProfileInfoBox({
super.key,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
AuthenticationState authState = ref.watch(authenticationProvider);
final uploadProfileImageStatus =
ref.watch(uploadProfileImageProvider).status;
final user = Store.tryGet(StoreKey.currentUser);
buildUserProfileImage() {
if (user == null) {
return const CircleAvatar(
radius: 20,
backgroundImage: AssetImage('assets/immich-logo.png'),
backgroundColor: Colors.transparent,
);
}
final userImage = UserCircleAvatar(
radius: 22,
size: 44,
user: user,
);
if (uploadProfileImageStatus == UploadProfileStatus.loading) {
return const SizedBox(
height: 40,
width: 40,
child: ImmichLoadingIndicator(borderRadius: 20),
);
}
return userImage;
}
pickUserProfileImage() async {
final XFile? image = await ImagePicker().pickImage(
source: ImageSource.gallery,
maxHeight: 1024,
maxWidth: 1024,
);
if (image != null) {
var success =
await ref.watch(uploadProfileImageProvider.notifier).upload(image);
if (success) {
final profileImagePath =
ref.read(uploadProfileImageProvider).profileImagePath;
ref.watch(authenticationProvider.notifier).updateUserProfileImagePath(
profileImagePath,
);
if (user != null) {
user.profileImagePath = profileImagePath;
Store.put(StoreKey.currentUser, user);
ref.read(currentUserProvider.notifier).refresh();
}
}
}
}
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 10.0),
child: Container(
width: double.infinity,
decoration: BoxDecoration(
color: context.isDarkTheme
? context.scaffoldBackgroundColor
: const Color.fromARGB(255, 225, 229, 240),
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(10),
topRight: Radius.circular(10),
),
),
child: ListTile(
minLeadingWidth: 50,
leading: GestureDetector(
onTap: pickUserProfileImage,
child: Stack(
clipBehavior: Clip.none,
children: [
buildUserProfileImage(),
Positioned(
bottom: -5,
right: -8,
child: Material(
color: context.isDarkTheme
? Colors.blueGrey[800]
: Colors.white,
elevation: 3,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(50.0),
),
child: Padding(
padding: const EdgeInsets.all(5.0),
child: Icon(
Icons.camera_alt_outlined,
color: context.primaryColor,
size: 14,
),
),
),
),
],
),
),
title: Text(
authState.name,
style: context.textTheme.titleMedium?.copyWith(
color: context.primaryColor,
fontWeight: FontWeight.w500,
),
),
subtitle: Text(
authState.userEmail,
style: context.textTheme.bodySmall?.copyWith(
color: context.textTheme.bodySmall?.color?.withAlpha(200),
),
),
),
),
);
}
}

View file

@ -1,273 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/models/server_info/server_info.model.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/utils/url_helper.dart';
import 'package:package_info_plus/package_info_plus.dart';
class AppBarServerInfo extends HookConsumerWidget {
const AppBarServerInfo({
super.key,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
ServerInfo serverInfoState = ref.watch(serverInfoProvider);
final appInfo = useState({});
const titleFontSize = 12.0;
const contentFontSize = 11.0;
getPackageInfo() async {
PackageInfo packageInfo = await PackageInfo.fromPlatform();
appInfo.value = {
"version": packageInfo.version,
"buildNumber": packageInfo.buildNumber,
};
}
useEffect(
() {
getPackageInfo();
return null;
},
[],
);
return Padding(
padding: const EdgeInsets.only(left: 10.0, right: 10.0, bottom: 10.0),
child: Container(
decoration: BoxDecoration(
color: context.isDarkTheme
? context.scaffoldBackgroundColor
: const Color.fromARGB(255, 225, 229, 240),
borderRadius: const BorderRadius.only(
bottomLeft: Radius.circular(10),
bottomRight: Radius.circular(10),
),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
serverInfoState.isVersionMismatch
? serverInfoState.versionMismatchErrorMessage
: "profile_drawer_client_server_up_to_date".tr(),
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 11,
color: context.primaryColor,
fontWeight: FontWeight.w500,
),
),
),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 10),
child: Divider(
color: Color.fromARGB(101, 201, 201, 201),
thickness: 1,
),
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Padding(
padding: const EdgeInsets.only(left: 10.0),
child: Text(
"server_info_box_app_version".tr(),
style: TextStyle(
fontSize: titleFontSize,
color: context.textTheme.labelSmall?.color,
fontWeight: FontWeight.w500,
),
),
),
),
Expanded(
flex: 0,
child: Padding(
padding: const EdgeInsets.only(right: 10.0),
child: Text(
"${appInfo.value["version"]} build.${appInfo.value["buildNumber"]}",
style: TextStyle(
fontSize: contentFontSize,
color: context.textTheme.labelSmall?.color
?.withOpacity(0.5),
fontWeight: FontWeight.bold,
),
),
),
),
],
),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 10),
child: Divider(
color: Color.fromARGB(101, 201, 201, 201),
thickness: 1,
),
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Padding(
padding: const EdgeInsets.only(left: 10.0),
child: Text(
"server_info_box_server_version".tr(),
style: TextStyle(
fontSize: titleFontSize,
color: context.textTheme.labelSmall?.color,
fontWeight: FontWeight.w500,
),
),
),
),
Expanded(
flex: 0,
child: Padding(
padding: const EdgeInsets.only(right: 10.0),
child: Text(
serverInfoState.serverVersion.major > 0
? "${serverInfoState.serverVersion.major}.${serverInfoState.serverVersion.minor}.${serverInfoState.serverVersion.patch}"
: "--",
style: TextStyle(
fontSize: contentFontSize,
color: context.textTheme.labelSmall?.color
?.withOpacity(0.5),
fontWeight: FontWeight.bold,
),
),
),
),
],
),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 10),
child: Divider(
color: Color.fromARGB(101, 201, 201, 201),
thickness: 1,
),
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Padding(
padding: const EdgeInsets.only(left: 10.0),
child: Text(
"server_info_box_server_url".tr(),
style: TextStyle(
fontSize: titleFontSize,
color: context.textTheme.labelSmall?.color,
fontWeight: FontWeight.w500,
),
),
),
),
Expanded(
flex: 0,
child: Container(
width: 200,
padding: const EdgeInsets.only(right: 10.0),
child: Tooltip(
verticalOffset: 0,
decoration: BoxDecoration(
color: context.primaryColor.withOpacity(0.9),
borderRadius: BorderRadius.circular(10),
),
textStyle: TextStyle(
color:
context.isDarkTheme ? Colors.black : Colors.white,
fontWeight: FontWeight.bold,
),
message: getServerUrl() ?? '--',
preferBelow: false,
triggerMode: TooltipTriggerMode.tap,
child: Text(
getServerUrl() ?? '--',
style: TextStyle(
fontSize: contentFontSize,
color: context.textTheme.labelSmall?.color
?.withOpacity(0.5),
fontWeight: FontWeight.bold,
overflow: TextOverflow.ellipsis,
),
textAlign: TextAlign.end,
),
),
),
),
],
),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 10),
child: Divider(
color: Color.fromARGB(101, 201, 201, 201),
thickness: 1,
),
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Padding(
padding: const EdgeInsets.only(left: 10.0),
child: Row(
children: [
if (serverInfoState.isNewReleaseAvailable)
const Padding(
padding: EdgeInsets.only(right: 5.0),
child: Icon(
Icons.info,
color: Color.fromARGB(255, 243, 188, 106),
size: 12,
),
),
Text(
"server_info_box_latest_release".tr(),
style: TextStyle(
fontSize: titleFontSize,
color: context.textTheme.labelSmall?.color,
fontWeight: FontWeight.w500,
),
),
],
),
),
),
Expanded(
flex: 0,
child: Padding(
padding: const EdgeInsets.only(right: 10.0),
child: Text(
serverInfoState.latestVersion.major > 0
? "${serverInfoState.latestVersion.major}.${serverInfoState.latestVersion.minor}.${serverInfoState.latestVersion.patch}"
: "--",
style: TextStyle(
fontSize: contentFontSize,
color: context.textTheme.labelSmall?.color
?.withOpacity(0.5),
fontWeight: FontWeight.bold,
),
),
),
),
],
),
],
),
),
),
);
}
}

View file

@ -1,462 +0,0 @@
import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/collection_extensions.dart';
import 'package:immich_mobile/providers/album/album.provider.dart';
import 'package:immich_mobile/providers/album/shared_album.provider.dart';
import 'package:immich_mobile/services/album.service.dart';
import 'package:immich_mobile/services/asset_stack.service.dart';
import 'package:immich_mobile/providers/backup/manual_upload.provider.dart';
import 'package:immich_mobile/models/asset_selection_state.dart';
import 'package:immich_mobile/providers/multiselect.provider.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart';
import 'package:immich_mobile/modules/home/ui/control_bottom_app_bar.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/entities/album.entity.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/providers/asset.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
import 'package:immich_mobile/shared/ui/immich_toast.dart';
import 'package:immich_mobile/utils/immich_loading_overlay.dart';
import 'package:immich_mobile/utils/selection_handlers.dart';
class MultiselectGrid extends HookConsumerWidget {
const MultiselectGrid({
super.key,
required this.renderListProvider,
this.onRefresh,
this.buildLoadingIndicator,
this.onRemoveFromAlbum,
this.topWidget,
this.stackEnabled = false,
this.archiveEnabled = false,
this.deleteEnabled = true,
this.favoriteEnabled = true,
this.editEnabled = false,
this.unarchive = false,
this.unfavorite = false,
this.emptyIndicator,
});
final ProviderListenable<AsyncValue<RenderList>> renderListProvider;
final Future<void> Function()? onRefresh;
final Widget Function()? buildLoadingIndicator;
final Future<bool> Function(Iterable<Asset>)? onRemoveFromAlbum;
final Widget? topWidget;
final bool stackEnabled;
final bool archiveEnabled;
final bool unarchive;
final bool deleteEnabled;
final bool favoriteEnabled;
final bool unfavorite;
final bool editEnabled;
final Widget? emptyIndicator;
Widget buildDefaultLoadingIndicator() =>
const Center(child: ImmichLoadingIndicator());
Widget buildEmptyIndicator() =>
emptyIndicator ?? Center(child: const Text("no_assets_to_show").tr());
@override
Widget build(BuildContext context, WidgetRef ref) {
final multiselectEnabled = ref.watch(multiselectProvider.notifier);
final selectionEnabledHook = useState(false);
final selectionAssetState = useState(const AssetSelectionState());
final selection = useState(<Asset>{});
final currentUser = ref.watch(currentUserProvider);
final processing = useProcessingOverlay();
useEffect(
() {
selectionEnabledHook.addListener(() {
multiselectEnabled.state = selectionEnabledHook.value;
});
return () {
// This does not work in tests
if (kReleaseMode) {
selectionEnabledHook.dispose();
}
};
},
[],
);
void selectionListener(
bool multiselect,
Set<Asset> selectedAssets,
) {
selectionEnabledHook.value = multiselect;
selection.value = selectedAssets;
selectionAssetState.value =
AssetSelectionState.fromSelection(selectedAssets);
}
errorBuilder(String? msg) => msg != null && msg.isNotEmpty
? () => ImmichToast.show(
context: context,
msg: msg,
gravity: ToastGravity.BOTTOM,
)
: null;
Iterable<Asset> ownedRemoteSelection({
String? localErrorMessage,
String? ownerErrorMessage,
}) {
final assets = selection.value;
return assets
.remoteOnly(errorCallback: errorBuilder(localErrorMessage))
.ownedOnly(
currentUser,
errorCallback: errorBuilder(ownerErrorMessage),
);
}
Iterable<Asset> remoteSelection({String? errorMessage}) =>
selection.value.remoteOnly(
errorCallback: errorBuilder(errorMessage),
);
void onShareAssets(bool shareLocal) {
processing.value = true;
if (shareLocal) {
// Share = Download + Send to OS specific share sheet
// Filter offline assets since we cannot fetch their original file
final liveAssets = selection.value.nonOfflineOnly(
errorCallback: errorBuilder('asset_action_share_err_offline'.tr()),
);
handleShareAssets(ref, context, liveAssets);
} else {
final ids =
remoteSelection(errorMessage: "home_page_share_err_local".tr())
.map((e) => e.remoteId!);
context.pushRoute(SharedLinkEditRoute(assetsList: ids.toList()));
}
processing.value = false;
selectionEnabledHook.value = false;
}
void onFavoriteAssets() async {
processing.value = true;
try {
final remoteAssets = ownedRemoteSelection(
localErrorMessage: 'home_page_favorite_err_local'.tr(),
ownerErrorMessage: 'home_page_favorite_err_partner'.tr(),
);
if (remoteAssets.isNotEmpty) {
await handleFavoriteAssets(ref, context, remoteAssets.toList());
}
} finally {
processing.value = false;
selectionEnabledHook.value = false;
}
}
void onArchiveAsset() async {
processing.value = true;
try {
final remoteAssets = ownedRemoteSelection(
localErrorMessage: 'home_page_archive_err_local'.tr(),
ownerErrorMessage: 'home_page_archive_err_partner'.tr(),
);
await handleArchiveAssets(ref, context, remoteAssets.toList());
} finally {
processing.value = false;
selectionEnabledHook.value = false;
}
}
void onDelete([bool force = false]) async {
processing.value = true;
try {
final toDelete = selection.value
.ownedOnly(
currentUser,
errorCallback: errorBuilder('home_page_delete_err_partner'.tr()),
)
.toList();
final isDeleted = await ref
.read(assetProvider.notifier)
.deleteAssets(toDelete, force: force);
if (isDeleted) {
final assetOrAssets = toDelete.length > 1 ? 'assets' : 'asset';
final trashOrRemoved = force ? 'deleted permanently' : 'trashed';
ImmichToast.show(
context: context,
msg: '${selection.value.length} $assetOrAssets $trashOrRemoved',
gravity: ToastGravity.BOTTOM,
);
selectionEnabledHook.value = false;
}
} finally {
processing.value = false;
}
}
void onDeleteLocal(bool onlyBackedUp) async {
processing.value = true;
try {
final localIds = selection.value.where((a) => a.isLocal).toList();
final isDeleted = await ref
.read(assetProvider.notifier)
.deleteLocalOnlyAssets(localIds, onlyBackedUp: onlyBackedUp);
if (isDeleted) {
final assetOrAssets = localIds.length > 1 ? 'assets' : 'asset';
ImmichToast.show(
context: context,
msg:
'${localIds.length} $assetOrAssets removed permanently from your device',
gravity: ToastGravity.BOTTOM,
);
selectionEnabledHook.value = false;
}
} finally {
processing.value = false;
}
}
void onDeleteRemote([bool force = false]) async {
processing.value = true;
try {
final toDelete = ownedRemoteSelection(
localErrorMessage: 'home_page_delete_remote_err_local'.tr(),
ownerErrorMessage: 'home_page_delete_err_partner'.tr(),
).toList();
final isDeleted = await ref
.read(assetProvider.notifier)
.deleteRemoteOnlyAssets(toDelete, force: force);
if (isDeleted) {
final assetOrAssets = toDelete.length > 1 ? 'assets' : 'asset';
final trashOrRemoved = force ? 'deleted permanently' : 'trashed';
ImmichToast.show(
context: context,
msg:
'${toDelete.length} $assetOrAssets $trashOrRemoved from the Immich server',
gravity: ToastGravity.BOTTOM,
);
}
} finally {
selectionEnabledHook.value = false;
processing.value = false;
}
}
void onUpload() {
processing.value = true;
selectionEnabledHook.value = false;
try {
ref.read(manualUploadProvider.notifier).uploadAssets(
context,
selection.value.where((a) => a.storage == AssetState.local),
);
} finally {
processing.value = false;
}
}
void onAddToAlbum(Album album) async {
processing.value = true;
try {
final Iterable<Asset> assets = remoteSelection(
errorMessage: "home_page_add_to_album_err_local".tr(),
);
if (assets.isEmpty) {
return;
}
final result =
await ref.read(albumServiceProvider).addAdditionalAssetToAlbum(
assets,
album,
);
if (result != null) {
if (result.alreadyInAlbum.isNotEmpty) {
ImmichToast.show(
context: context,
msg: "home_page_add_to_album_conflicts".tr(
namedArgs: {
"album": album.name,
"added": result.successfullyAdded.toString(),
"failed": result.alreadyInAlbum.length.toString(),
},
),
);
} else {
ImmichToast.show(
context: context,
msg: "home_page_add_to_album_success".tr(
namedArgs: {
"album": album.name,
"added": result.successfullyAdded.toString(),
},
),
toastType: ToastType.success,
);
}
}
} finally {
processing.value = false;
selectionEnabledHook.value = false;
}
}
void onCreateNewAlbum() async {
processing.value = true;
try {
final Iterable<Asset> assets = remoteSelection(
errorMessage: "home_page_add_to_album_err_local".tr(),
);
if (assets.isEmpty) {
return;
}
final result = await ref
.read(albumServiceProvider)
.createAlbumWithGeneratedName(assets);
if (result != null) {
ref.watch(albumProvider.notifier).getAllAlbums();
ref.watch(sharedAlbumProvider.notifier).getAllSharedAlbums();
selectionEnabledHook.value = false;
context.pushRoute(AlbumViewerRoute(albumId: result.id));
}
} finally {
processing.value = false;
}
}
void onStack() async {
try {
processing.value = true;
if (!selectionEnabledHook.value || selection.value.length < 2) {
return;
}
final parent = selection.value.elementAt(0);
selection.value.remove(parent);
await ref.read(assetStackServiceProvider).updateStack(
parent,
childrenToAdd: selection.value.toList(),
);
} finally {
processing.value = false;
selectionEnabledHook.value = false;
}
}
void onEditTime() async {
try {
final remoteAssets = ownedRemoteSelection(
localErrorMessage: 'home_page_favorite_err_local'.tr(),
ownerErrorMessage: 'home_page_favorite_err_partner'.tr(),
);
if (remoteAssets.isNotEmpty) {
handleEditDateTime(ref, context, remoteAssets.toList());
}
} finally {
selectionEnabledHook.value = false;
}
}
void onEditLocation() async {
try {
final remoteAssets = ownedRemoteSelection(
localErrorMessage: 'home_page_favorite_err_local'.tr(),
ownerErrorMessage: 'home_page_favorite_err_partner'.tr(),
);
if (remoteAssets.isNotEmpty) {
handleEditLocation(ref, context, remoteAssets.toList());
}
} finally {
selectionEnabledHook.value = false;
}
}
Future<T> Function() wrapLongRunningFun<T>(
Future<T> Function() fun, {
bool showOverlay = true,
}) =>
() async {
if (showOverlay) processing.value = true;
try {
final result = await fun();
if (result.runtimeType != bool || result == true) {
selectionEnabledHook.value = false;
}
return result;
} finally {
if (showOverlay) processing.value = false;
}
};
return SafeArea(
top: true,
bottom: false,
child: Stack(
children: [
ref.watch(renderListProvider).when(
data: (data) => data.isEmpty &&
(buildLoadingIndicator != null || topWidget == null)
? (buildLoadingIndicator ?? buildEmptyIndicator)()
: ImmichAssetGrid(
renderList: data,
listener: selectionListener,
selectionActive: selectionEnabledHook.value,
onRefresh: onRefresh == null
? null
: wrapLongRunningFun(
onRefresh!,
showOverlay: false,
),
topWidget: topWidget,
showStack: stackEnabled,
),
error: (error, _) => Center(child: Text(error.toString())),
loading: buildLoadingIndicator ?? buildDefaultLoadingIndicator,
),
if (selectionEnabledHook.value)
ControlBottomAppBar(
onShare: onShareAssets,
onFavorite: favoriteEnabled ? onFavoriteAssets : null,
onArchive: archiveEnabled ? onArchiveAsset : null,
onDelete: deleteEnabled ? onDelete : null,
onDeleteServer: deleteEnabled ? onDeleteRemote : null,
/// local file deletion is allowed irrespective of [deleteEnabled] since it has
/// nothing to do with the state of the asset in the Immich server
onDeleteLocal: onDeleteLocal,
onAddToAlbum: onAddToAlbum,
onCreateNewAlbum: onCreateNewAlbum,
onUpload: onUpload,
enabled: !processing.value,
selectionAssetState: selectionAssetState.value,
onStack: stackEnabled ? onStack : null,
onEditTime: editEnabled ? onEditTime : null,
onEditLocation: editEnabled ? onEditLocation : null,
unfavorite: unfavorite,
unarchive: unarchive,
onRemoveFromAlbum: onRemoveFromAlbum != null
? wrapLongRunningFun(
() => onRemoveFromAlbum!(selection.value),
)
: null,
),
],
),
);
}
}

View file

@ -1,58 +0,0 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
class ConfirmDialog extends StatelessWidget {
final Function onOk;
final String title;
final String content;
final String cancel;
final String ok;
const ConfirmDialog({
super.key,
required this.onOk,
required this.title,
required this.content,
this.cancel = "delete_dialog_cancel",
this.ok = "backup_controller_page_background_battery_info_ok",
});
@override
Widget build(BuildContext context) {
void onOkPressed() {
onOk();
context.pop(true);
}
return AlertDialog(
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(10)),
),
title: Text(title).tr(),
content: Text(content).tr(),
actions: [
TextButton(
onPressed: () => context.pop(false),
child: Text(
cancel,
style: TextStyle(
color: context.primaryColor,
fontWeight: FontWeight.bold,
),
).tr(),
),
TextButton(
onPressed: onOkPressed,
child: Text(
ok,
style: TextStyle(
color: Colors.red[400],
fontWeight: FontWeight.bold,
),
).tr(),
),
],
);
}
}

View file

@ -1,260 +0,0 @@
import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/duration_extensions.dart';
import 'package:timezone/timezone.dart' as tz;
import 'package:timezone/timezone.dart';
Future<String?> showDateTimePicker({
required BuildContext context,
DateTime? initialDateTime,
String? initialTZ,
Duration? initialTZOffset,
}) {
return showDialog<String?>(
context: context,
builder: (context) => _DateTimePicker(
initialDateTime: initialDateTime,
initialTZ: initialTZ,
initialTZOffset: initialTZOffset,
),
);
}
String _getFormattedOffset(int offsetInMilli, tz.Location location) {
return "${location.name} (UTC${Duration(milliseconds: offsetInMilli).formatAsOffset()})";
}
class _DateTimePicker extends HookWidget {
final DateTime? initialDateTime;
final String? initialTZ;
final Duration? initialTZOffset;
const _DateTimePicker({
this.initialDateTime,
this.initialTZ,
this.initialTZOffset,
});
_TimeZoneOffset _getInitiationLocation() {
if (initialTZ != null) {
try {
return _TimeZoneOffset.fromLocation(
tz.timeZoneDatabase.get(initialTZ!),
);
} on LocationNotFoundException {
// no-op
}
}
Duration? tzOffset = initialTZOffset ?? initialDateTime?.timeZoneOffset;
if (tzOffset != null) {
final offsetInMilli = tzOffset.inMilliseconds;
// get all locations with matching offset
final locations = tz.timeZoneDatabase.locations.values.where(
(location) => location.currentTimeZone.offset == offsetInMilli,
);
// Prefer locations with abbreviation first
final location = locations.firstWhereOrNull(
(e) => !e.currentTimeZone.abbreviation.contains("0"),
) ??
locations.firstOrNull;
if (location != null) {
return _TimeZoneOffset.fromLocation(location);
}
}
return _TimeZoneOffset.fromLocation(tz.getLocation("UTC"));
}
// returns a list of location<name> along with it's offset in duration
List<_TimeZoneOffset> getAllTimeZones() {
return tz.timeZoneDatabase.locations.values
.where((l) => !l.currentTimeZone.abbreviation.contains("0"))
.map(_TimeZoneOffset.fromLocation)
.sorted()
.toList();
}
@override
Widget build(BuildContext context) {
final date = useState<DateTime>(initialDateTime ?? DateTime.now());
final tzOffset = useState<_TimeZoneOffset>(_getInitiationLocation());
final timeZones = useMemoized(() => getAllTimeZones(), const []);
void pickDate() async {
final now = DateTime.now();
// Handles cases where the date from the asset is far off in the future
final initialDate = date.value.isAfter(now) ? now : date.value;
final newDate = await showDatePicker(
context: context,
initialDate: initialDate,
firstDate: DateTime(1800),
lastDate: now,
);
if (newDate == null) {
return;
}
final newTime = await showTimePicker(
context: context,
initialTime: TimeOfDay.fromDateTime(date.value),
);
if (newTime == null) {
return;
}
date.value = newDate.copyWith(hour: newTime.hour, minute: newTime.minute);
}
void popWithDateTime() {
final formattedDateTime =
DateFormat("yyyy-MM-dd'T'HH:mm:ss").format(date.value);
final dtWithOffset = formattedDateTime +
Duration(milliseconds: tzOffset.value.offsetInMilliseconds)
.formatAsOffset();
context.pop(dtWithOffset);
}
return AlertDialog(
contentPadding: const EdgeInsets.all(30),
alignment: Alignment.center,
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
"edit_date_time_dialog_date_time",
textAlign: TextAlign.center,
).tr(),
TextButton.icon(
onPressed: pickDate,
icon: Text(
DateFormat("dd-MM-yyyy hh:mm a").format(date.value),
style: context.textTheme.bodyLarge
?.copyWith(color: context.primaryColor),
),
label: const Icon(
Icons.edit_outlined,
size: 18,
),
),
const Text(
"edit_date_time_dialog_timezone",
textAlign: TextAlign.center,
).tr(),
DropdownMenu(
menuHeight: 300,
width: 280,
inputDecorationTheme: const InputDecorationTheme(
border: InputBorder.none,
contentPadding: EdgeInsets.zero,
),
trailingIcon: Padding(
padding: const EdgeInsets.only(right: 10),
child: Icon(
Icons.arrow_drop_down,
color: context.primaryColor,
),
),
textStyle: context.textTheme.bodyLarge?.copyWith(
color: context.primaryColor,
),
menuStyle: const MenuStyle(
fixedSize: MaterialStatePropertyAll(Size.fromWidth(350)),
alignment: Alignment(-1.25, 0.5),
),
onSelected: (value) => tzOffset.value = value!,
initialSelection: tzOffset.value,
dropdownMenuEntries: timeZones
.map(
(t) => DropdownMenuEntry<_TimeZoneOffset>(
value: t,
label: t.display,
style: ButtonStyle(
textStyle: MaterialStatePropertyAll(
context.textTheme.bodyMedium,
),
),
),
)
.toList(),
),
],
),
actions: [
TextButton(
onPressed: () => context.pop(),
child: Text(
"action_common_cancel",
style: context.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
color: context.colorScheme.error,
),
).tr(),
),
TextButton(
onPressed: popWithDateTime,
child: Text(
"action_common_update",
style: context.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
color: context.primaryColor,
),
).tr(),
),
],
);
}
}
class _TimeZoneOffset implements Comparable<_TimeZoneOffset> {
final String display;
final Location location;
const _TimeZoneOffset({
required this.display,
required this.location,
});
_TimeZoneOffset copyWith({
String? display,
Location? location,
}) {
return _TimeZoneOffset(
display: display ?? this.display,
location: location ?? this.location,
);
}
int get offsetInMilliseconds => location.currentTimeZone.offset;
_TimeZoneOffset.fromLocation(tz.Location l)
: display = _getFormattedOffset(l.currentTimeZone.offset, l),
location = l;
@override
int compareTo(_TimeZoneOffset other) {
return offsetInMilliseconds.compareTo(other.offsetInMilliseconds);
}
@override
String toString() =>
'_TimeZoneOffset(display: $display, location: $location)';
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is _TimeZoneOffset &&
other.display == display &&
other.offsetInMilliseconds == offsetInMilliseconds;
}
@override
int get hashCode =>
display.hashCode ^ offsetInMilliseconds.hashCode ^ location.hashCode;
}

View file

@ -1,43 +0,0 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
class DelayedLoadingIndicator extends StatelessWidget {
/// The delay to avoid showing the loading indicator
final Duration delay;
/// Defaults to using the [ImmichLoadingIndicator]
final Widget? child;
/// An optional fade in duration to animate the loading
final Duration? fadeInDuration;
const DelayedLoadingIndicator({
super.key,
this.delay = const Duration(seconds: 3),
this.child,
this.fadeInDuration,
});
@override
Widget build(BuildContext context) {
return FutureBuilder(
future: Future.delayed(delay),
builder: (context, snapshot) {
late Widget c;
if (snapshot.connectionState == ConnectionState.done) {
c = child ??
const ImmichLoadingIndicator(
key: ValueKey('loading'),
);
} else {
c = Container(key: const ValueKey('hiding'));
}
return AnimatedSwitcher(
duration: fadeInDuration ?? Duration.zero,
child: c,
);
},
);
}
}

View file

@ -1,58 +0,0 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
class CustomDraggingHandle extends StatelessWidget {
const CustomDraggingHandle({super.key});
@override
Widget build(BuildContext context) {
return Container(
height: 4,
width: 30,
decoration: BoxDecoration(
color: context.themeData.dividerColor,
borderRadius: const BorderRadius.all(Radius.circular(20)),
),
);
}
}
class ControlBoxButton extends StatelessWidget {
const ControlBoxButton({
super.key,
required this.label,
required this.iconData,
this.onPressed,
this.onLongPressed,
});
final String label;
final IconData iconData;
final void Function()? onPressed;
final void Function()? onLongPressed;
@override
Widget build(BuildContext context) {
return MaterialButton(
padding: const EdgeInsets.all(10),
shape: const CircleBorder(),
onPressed: onPressed,
onLongPress: onLongPressed,
minWidth: 75.0,
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(iconData, size: 24),
const SizedBox(height: 8),
Text(
label,
style: const TextStyle(fontSize: 12.0),
maxLines: 2,
textAlign: TextAlign.center,
),
],
),
);
}
}

View file

@ -1,35 +0,0 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/shared/ui/transparent_image.dart';
class FadeInPlaceholderImage extends StatelessWidget {
final Widget placeholder;
final ImageProvider image;
final Duration duration;
final BoxFit fit;
const FadeInPlaceholderImage({
super.key,
required this.placeholder,
required this.image,
this.duration = const Duration(milliseconds: 100),
this.fit = BoxFit.cover,
});
@override
Widget build(BuildContext context) {
return SizedBox.expand(
child: Stack(
fit: StackFit.expand,
children: [
placeholder,
FadeInImage(
fadeInDuration: duration,
image: image,
fit: fit,
placeholder: MemoryImage(kTransparentImage),
),
],
),
);
}
}

View file

@ -1,200 +0,0 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_svg/svg.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/providers/immich_logo_provider.dart';
import 'package:immich_mobile/shared/ui/app_bar_dialog/app_bar_dialog.dart';
import 'package:immich_mobile/shared/ui/user_circle_avatar.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/models/backup/backup_state.model.dart';
import 'package:immich_mobile/models/server_info/server_info.model.dart';
import 'package:immich_mobile/providers/backup/backup.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
class ImmichAppBar extends ConsumerWidget implements PreferredSizeWidget {
@override
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
final Widget? action;
const ImmichAppBar({super.key, this.action});
@override
Widget build(BuildContext context, WidgetRef ref) {
final BackUpState backupState = ref.watch(backupProvider);
final bool isEnableAutoBackup =
backupState.backgroundBackup || backupState.autoBackup;
final ServerInfo serverInfoState = ref.watch(serverInfoProvider);
final immichLogo = ref.watch(immichLogoProvider);
final user = Store.tryGet(StoreKey.currentUser);
final isDarkTheme = context.isDarkTheme;
const widgetSize = 30.0;
buildProfileIndicator() {
return InkWell(
onTap: () => showDialog(
context: context,
useRootNavigator: false,
builder: (ctx) => const ImmichAppBarDialog(),
),
borderRadius: BorderRadius.circular(12),
child: Badge(
label: Container(
decoration: BoxDecoration(
color: Colors.black,
borderRadius: BorderRadius.circular(widgetSize / 2),
),
child: const Icon(
Icons.info,
color: Color.fromARGB(255, 243, 188, 106),
size: widgetSize / 2,
),
),
backgroundColor: Colors.transparent,
alignment: Alignment.bottomRight,
isLabelVisible: serverInfoState.isVersionMismatch ||
((user?.isAdmin ?? false) &&
serverInfoState.isNewReleaseAvailable),
offset: const Offset(2, 2),
child: user == null
? const Icon(
Icons.face_outlined,
size: widgetSize,
)
: UserCircleAvatar(
radius: 15,
size: 27,
user: user,
),
),
);
}
getBackupBadgeIcon() {
final iconColor = isDarkTheme ? Colors.white : Colors.black;
if (isEnableAutoBackup) {
if (backupState.backupProgress == BackUpProgressEnum.inProgress) {
return Container(
padding: const EdgeInsets.all(3.5),
child: CircularProgressIndicator(
strokeWidth: 2,
strokeCap: StrokeCap.round,
valueColor: AlwaysStoppedAnimation<Color>(iconColor),
semanticsLabel: 'backup_controller_page_backup'.tr(),
),
);
} else if (backupState.backupProgress !=
BackUpProgressEnum.inBackground &&
backupState.backupProgress != BackUpProgressEnum.manualInProgress) {
return Icon(
Icons.check_outlined,
size: 9,
color: iconColor,
semanticLabel: 'backup_controller_page_backup'.tr(),
);
}
}
if (!isEnableAutoBackup) {
return Icon(
Icons.cloud_off_rounded,
size: 9,
color: iconColor,
semanticLabel: 'backup_controller_page_backup'.tr(),
);
}
}
buildBackupIndicator() {
final indicatorIcon = getBackupBadgeIcon();
final badgeBackground = isDarkTheme ? Colors.blueGrey[800] : Colors.white;
return InkWell(
onTap: () => context.pushRoute(const BackupControllerRoute()),
borderRadius: BorderRadius.circular(12),
child: Badge(
label: Container(
width: widgetSize / 2,
height: widgetSize / 2,
decoration: BoxDecoration(
color: badgeBackground,
border: Border.all(
color: isDarkTheme ? Colors.black : Colors.grey,
),
borderRadius: BorderRadius.circular(widgetSize / 2),
),
child: indicatorIcon,
),
backgroundColor: Colors.transparent,
alignment: Alignment.bottomRight,
isLabelVisible: indicatorIcon != null,
offset: const Offset(2, 2),
child: Icon(
Icons.backup_rounded,
size: widgetSize,
color: context.primaryColor,
),
),
);
}
return AppBar(
backgroundColor: context.themeData.appBarTheme.backgroundColor,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(
Radius.circular(5),
),
),
automaticallyImplyLeading: false,
centerTitle: false,
title: Builder(
builder: (BuildContext context) {
return Row(
children: [
Builder(
builder: (context) {
final today = DateTime.now();
if (today.month == 4 && today.day == 1) {
if (immichLogo.value == null) {
return const SizedBox.shrink();
}
return Image.memory(
immichLogo.value!,
fit: BoxFit.cover,
height: 80,
);
}
return Padding(
padding: const EdgeInsets.only(top: 3.0),
child: SvgPicture.asset(
context.isDarkTheme
? 'assets/immich-logo-inline-dark.svg'
: 'assets/immich-logo-inline-light.svg',
height: 40,
),
);
},
),
],
);
},
),
actions: [
if (action != null)
Padding(padding: const EdgeInsets.only(right: 20), child: action!),
Padding(
padding: const EdgeInsets.only(right: 20),
child: buildBackupIndicator(),
),
Padding(
padding: const EdgeInsets.only(right: 20),
child: buildProfileIndicator(),
),
],
);
}
}

View file

@ -1,112 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/image/immich_local_image_provider.dart';
import 'package:immich_mobile/providers/image/immich_remote_image_provider.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/thumbnail_placeholder.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:octo_image/octo_image.dart';
class ImmichImage extends StatelessWidget {
const ImmichImage(
this.asset, {
this.width,
this.height,
this.fit = BoxFit.cover,
this.placeholder = const ThumbnailPlaceholder(),
super.key,
});
final Asset? asset;
final Widget? placeholder;
final double? width;
final double? height;
final BoxFit fit;
// Helper function to return the image provider for the asset
// either by using the asset ID or the asset itself
/// [asset] is the Asset to request, or else use [assetId] to get a remote
/// image provider
/// Use [isThumbnail] and [thumbnailSize] if you'd like to request a thumbnail
/// The size of the square thumbnail to request. Ignored if isThumbnail
/// is not true
static ImageProvider imageProvider({
Asset? asset,
String? assetId,
}) {
if (asset == null && assetId == null) {
throw Exception('Must supply either asset or assetId');
}
if (asset == null) {
return ImmichRemoteImageProvider(
assetId: assetId!,
);
}
if (useLocal(asset)) {
return ImmichLocalImageProvider(
asset: asset,
);
} else {
return ImmichRemoteImageProvider(
assetId: asset.remoteId!,
);
}
}
// Whether to use the local asset image provider or a remote one
static bool useLocal(Asset asset) =>
!asset.isRemote ||
asset.isLocal && !Store.get(StoreKey.preferRemoteImage, false);
@override
Widget build(BuildContext context) {
if (asset == null) {
return Container(
color: Colors.grey,
width: width,
height: height,
child: const Center(
child: Icon(Icons.no_photography),
),
);
}
return OctoImage(
fadeInDuration: const Duration(milliseconds: 0),
fadeOutDuration: const Duration(milliseconds: 200),
placeholderBuilder: (context) {
if (placeholder != null) {
// Use the gray box placeholder
return placeholder!;
}
// No placeholder
return const SizedBox();
},
image: ImmichImage.imageProvider(
asset: asset,
),
width: width,
height: height,
fit: fit,
errorBuilder: (context, error, stackTrace) {
if (error is PlatformException &&
error.code == "The asset not found!") {
debugPrint(
"Asset ${asset?.localId} does not exist anymore on device!",
);
} else {
debugPrint(
"Error getting thumb for assetId=${asset?.localId}: $error",
);
}
return Icon(
Icons.image_not_supported_outlined,
color: context.primaryColor,
);
},
);
}
}

View file

@ -1,28 +0,0 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
class ImmichLoadingIndicator extends StatelessWidget {
final double? borderRadius;
const ImmichLoadingIndicator({
super.key,
this.borderRadius,
});
@override
Widget build(BuildContext context) {
return Container(
height: 60,
width: 60,
decoration: BoxDecoration(
color: context.primaryColor.withAlpha(200),
borderRadius: BorderRadius.circular(borderRadius ?? 10),
),
padding: const EdgeInsets.all(15),
child: const CircularProgressIndicator(
color: Colors.white,
strokeWidth: 3,
),
);
}
}

View file

@ -1,25 +0,0 @@
import 'package:flutter/material.dart';
class ImmichLogo extends StatelessWidget {
final double size;
final dynamic heroTag;
const ImmichLogo({
super.key,
this.size = 100,
this.heroTag,
});
@override
Widget build(BuildContext context) {
return Hero(
tag: heroTag,
child: Image(
image: const AssetImage('assets/immich-logo.png'),
width: size,
filterQuality: FilterQuality.high,
isAntiAlias: true,
),
);
}
}

View file

@ -1,88 +0,0 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:immich_mobile/providers/image/immich_local_thumbnail_provider.dart';
import 'package:immich_mobile/providers/image/immich_remote_thumbnail_provider.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/shared/ui/hooks/blurhash_hook.dart';
import 'package:immich_mobile/shared/ui/immich_image.dart';
import 'package:immich_mobile/shared/ui/thumbhash_placeholder.dart';
import 'package:octo_image/octo_image.dart';
class ImmichThumbnail extends HookWidget {
const ImmichThumbnail({
this.asset,
this.width = 250,
this.height = 250,
this.fit = BoxFit.cover,
super.key,
});
final Asset? asset;
final double width;
final double height;
final BoxFit fit;
/// Helper function to return the image provider for the asset thumbnail
/// either by using the asset ID or the asset itself
/// [asset] is the Asset to request, or else use [assetId] to get a remote
/// image provider
static ImageProvider imageProvider({
Asset? asset,
String? assetId,
int thumbnailSize = 256,
}) {
if (asset == null && assetId == null) {
throw Exception('Must supply either asset or assetId');
}
if (asset == null) {
return ImmichRemoteThumbnailProvider(
assetId: assetId!,
);
}
if (ImmichImage.useLocal(asset)) {
return ImmichLocalThumbnailProvider(
asset: asset,
height: thumbnailSize,
width: thumbnailSize,
);
} else {
return ImmichRemoteThumbnailProvider(
assetId: asset.remoteId!,
height: thumbnailSize,
width: thumbnailSize,
);
}
}
@override
Widget build(BuildContext context) {
Uint8List? blurhash = useBlurHashRef(asset).value;
if (asset == null) {
return Container(
color: Colors.grey,
width: width,
height: height,
child: const Center(
child: Icon(Icons.no_photography),
),
);
}
return OctoImage.fromSet(
placeholderFadeInDuration: Duration.zero,
fadeInDuration: Duration.zero,
fadeOutDuration: const Duration(milliseconds: 100),
octoSet: blurHashOrPlaceholder(blurhash),
image: ImmichThumbnail.imageProvider(
asset: asset,
),
width: width,
height: height,
fit: fit,
);
}
}

View file

@ -1,26 +0,0 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
class ImmichTitleText extends StatelessWidget {
final double fontSize;
final Color? color;
const ImmichTitleText({
super.key,
this.fontSize = 48,
this.color,
});
@override
Widget build(BuildContext context) {
return Image(
image: AssetImage(
context.isDarkTheme
? 'assets/immich-text-dark.png'
: 'assets/immich-text-light.png',
),
width: fontSize * 4,
filterQuality: FilterQuality.high,
);
}
}

View file

@ -1,84 +0,0 @@
import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
enum ToastType { info, success, error }
class ImmichToast {
static show({
required BuildContext context,
required String msg,
ToastType toastType = ToastType.info,
ToastGravity gravity = ToastGravity.BOTTOM,
int durationInSecond = 3,
}) {
final fToast = FToast();
fToast.init(context);
Color getColor(ToastType type, BuildContext context) {
switch (type) {
case ToastType.info:
return context.primaryColor;
case ToastType.success:
return const Color.fromARGB(255, 78, 140, 124);
case ToastType.error:
return const Color.fromARGB(255, 220, 48, 85);
}
}
Icon getIcon(ToastType type) {
switch (type) {
case ToastType.info:
return Icon(
Icons.info_outline_rounded,
color: context.primaryColor,
);
case ToastType.success:
return const Icon(
Icons.check_circle_rounded,
color: Color.fromARGB(255, 78, 140, 124),
);
case ToastType.error:
return const Icon(
Icons.error_outline_rounded,
color: Color.fromARGB(255, 240, 162, 156),
);
}
}
fToast.showToast(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 12.0),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(5.0),
color: context.isDarkTheme ? Colors.grey[900] : Colors.grey[50],
border: Border.all(
color: Colors.black12,
width: 1,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
getIcon(toastType),
const SizedBox(
width: 12.0,
),
Flexible(
child: Text(
msg,
style: TextStyle(
color: getColor(toastType, context),
fontWeight: FontWeight.bold,
fontSize: 15,
),
),
),
],
),
),
gravity: gravity,
toastDuration: Duration(seconds: durationInSecond),
);
}
}

View file

@ -1,268 +0,0 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/string_extensions.dart';
import 'package:immich_mobile/modules/map/widgets/map_thumbnail.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
Future<LatLng?> showLocationPicker({
required BuildContext context,
LatLng? initialLatLng,
}) {
return showDialog<LatLng?>(
context: context,
useRootNavigator: false,
builder: (ctx) => _LocationPicker(
initialLatLng: initialLatLng,
),
);
}
enum _LocationPickerMode { map, manual }
class _LocationPicker extends HookWidget {
final LatLng? initialLatLng;
const _LocationPicker({
this.initialLatLng,
});
@override
Widget build(BuildContext context) {
final latitude = useState(initialLatLng?.latitude ?? 0.0);
final longitude = useState(initialLatLng?.longitude ?? 0.0);
final latlng = LatLng(latitude.value, longitude.value);
final pickerMode = useState(_LocationPickerMode.map);
Future<void> onMapTap() async {
final newLatLng = await context.pushRoute<LatLng?>(
MapLocationPickerRoute(initialLatLng: latlng),
);
if (newLatLng != null) {
latitude.value = newLatLng.latitude;
longitude.value = newLatLng.longitude;
}
}
return AlertDialog(
contentPadding: const EdgeInsets.all(30),
alignment: Alignment.center,
content: SingleChildScrollView(
child: pickerMode.value == _LocationPickerMode.map
? _MapPicker(
key: ValueKey(latlng),
latlng: latlng,
onModeSwitch: () =>
pickerMode.value = _LocationPickerMode.manual,
onMapTap: onMapTap,
)
: _ManualPicker(
latlng: latlng,
onModeSwitch: () => pickerMode.value = _LocationPickerMode.map,
onLatUpdated: (value) => latitude.value = value,
onLonUpdated: (value) => longitude.value = value,
),
),
actions: [
TextButton(
onPressed: () => context.pop(),
child: Text(
"action_common_cancel",
style: context.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
color: context.colorScheme.error,
),
).tr(),
),
TextButton(
onPressed: () => context.popRoute(latlng),
child: Text(
"action_common_update",
style: context.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
color: context.primaryColor,
),
).tr(),
),
],
);
}
}
class _ManualPickerInput extends HookWidget {
final String initialValue;
final String decorationText;
final String hintText;
final String errorText;
final FocusNode focusNode;
final bool Function(String value) validator;
final Function(double value) onUpdated;
const _ManualPickerInput({
required this.initialValue,
required this.decorationText,
required this.hintText,
required this.errorText,
required this.focusNode,
required this.validator,
required this.onUpdated,
});
@override
Widget build(BuildContext context) {
final isValid = useState(true);
final controller = useTextEditingController(text: initialValue);
void onEditingComplete() {
isValid.value = validator(controller.text);
if (isValid.value) {
onUpdated(controller.text.toDouble());
}
}
return TextField(
controller: controller,
focusNode: focusNode,
textInputAction: TextInputAction.done,
autofocus: false,
decoration: InputDecoration(
labelText: decorationText.tr(),
labelStyle: TextStyle(
fontWeight: FontWeight.bold,
color: context.primaryColor,
),
floatingLabelBehavior: FloatingLabelBehavior.auto,
border: const OutlineInputBorder(),
hintText: hintText.tr(),
hintStyle: const TextStyle(fontWeight: FontWeight.normal, fontSize: 14),
errorText: isValid.value ? null : errorText.tr(),
),
onEditingComplete: onEditingComplete,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
inputFormatters: [LengthLimitingTextInputFormatter(8)],
onTapOutside: (_) => focusNode.unfocus(),
);
}
}
class _ManualPicker extends HookWidget {
final LatLng latlng;
final Function() onModeSwitch;
final Function(double) onLatUpdated;
final Function(double) onLonUpdated;
const _ManualPicker({
required this.latlng,
required this.onModeSwitch,
required this.onLatUpdated,
required this.onLonUpdated,
});
bool _validateLat(String value) {
final l = double.tryParse(value);
return l != null && l > -90 && l < 90;
}
bool _validateLong(String value) {
final l = double.tryParse(value);
return l != null && l > -180 && l < 180;
}
@override
Widget build(BuildContext context) {
final latitiudeFocusNode = useFocusNode();
final longitudeFocusNode = useFocusNode();
void onLatitudeUpdated(double value) {
onLatUpdated(value);
longitudeFocusNode.requestFocus();
}
void onLongitudeEditingCompleted(double value) {
onLonUpdated(value);
longitudeFocusNode.unfocus();
}
return Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
"edit_location_dialog_title",
textAlign: TextAlign.center,
).tr(),
const SizedBox(height: 12),
TextButton.icon(
icon: const Text("location_picker_choose_on_map").tr(),
label: const Icon(Icons.map_outlined, size: 16),
onPressed: onModeSwitch,
),
const SizedBox(height: 12),
_ManualPickerInput(
initialValue: latlng.latitude.toStringAsFixed(4),
decorationText: "location_picker_latitude",
hintText: "location_picker_latitude_hint",
errorText: "location_picker_latitude_error",
focusNode: latitiudeFocusNode,
validator: _validateLat,
onUpdated: onLatitudeUpdated,
),
const SizedBox(height: 24),
_ManualPickerInput(
initialValue: latlng.longitude.toStringAsFixed(4),
decorationText: "location_picker_longitude",
hintText: "location_picker_longitude_hint",
errorText: "location_picker_longitude_error",
focusNode: latitiudeFocusNode,
validator: _validateLong,
onUpdated: onLongitudeEditingCompleted,
),
],
);
}
}
class _MapPicker extends StatelessWidget {
final LatLng latlng;
final Function() onModeSwitch;
final Function() onMapTap;
const _MapPicker({
required this.latlng,
required this.onModeSwitch,
required this.onMapTap,
super.key,
});
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
"edit_location_dialog_title",
textAlign: TextAlign.center,
).tr(),
const SizedBox(height: 12),
TextButton.icon(
icon: Text(
"${latlng.latitude.toStringAsFixed(4)}, ${latlng.longitude.toStringAsFixed(4)}",
),
label: const Icon(Icons.edit_outlined, size: 16),
onPressed: onModeSwitch,
),
const SizedBox(height: 12),
MapThumbnail(
centre: latlng,
height: 200,
width: 200,
zoom: 8,
showMarkerPin: true,
onTap: (_, __) => onMapTap(),
),
],
);
}
}

View file

@ -1,672 +0,0 @@
library photo_view;
import 'package:flutter/material.dart';
import 'package:immich_mobile/shared/ui/photo_view/src/controller/photo_view_controller.dart';
import 'package:immich_mobile/shared/ui/photo_view/src/controller/photo_view_scalestate_controller.dart';
import 'package:immich_mobile/shared/ui/photo_view/src/core/photo_view_core.dart';
import 'package:immich_mobile/shared/ui/photo_view/src/photo_view_computed_scale.dart';
import 'package:immich_mobile/shared/ui/photo_view/src/photo_view_scale_state.dart';
import 'package:immich_mobile/shared/ui/photo_view/src/photo_view_wrappers.dart';
import 'package:immich_mobile/shared/ui/photo_view/src/utils/photo_view_hero_attributes.dart';
export 'src/controller/photo_view_controller.dart';
export 'src/controller/photo_view_scalestate_controller.dart';
export 'src/core/photo_view_gesture_detector.dart'
show PhotoViewGestureDetectorScope, PhotoViewPageViewScrollPhysics;
export 'src/photo_view_computed_scale.dart';
export 'src/photo_view_scale_state.dart';
export 'src/utils/photo_view_hero_attributes.dart';
/// A [StatefulWidget] that contains all the photo view rendering elements.
///
/// Sample code to use within an image:
///
/// ```
/// PhotoView(
/// imageProvider: imageProvider,
/// loadingBuilder: (context, progress) => Center(
/// child: Container(
/// width: 20.0,
/// height: 20.0,
/// child: CircularProgressIndicator(
/// value: _progress == null
/// ? null
/// : _progress.cumulativeBytesLoaded /
/// _progress.expectedTotalBytes,
/// ),
/// ),
/// ),
/// backgroundDecoration: BoxDecoration(color: Colors.black),
/// gaplessPlayback: false,
/// customSize: MediaQuery.of(context).size,
/// heroAttributes: const HeroAttributes(
/// tag: "someTag",
/// transitionOnUserGestures: true,
/// ),
/// scaleStateChangedCallback: this.onScaleStateChanged,
/// enableRotation: true,
/// controller: controller,
/// minScale: PhotoViewComputedScale.contained * 0.8,
/// maxScale: PhotoViewComputedScale.covered * 1.8,
/// initialScale: PhotoViewComputedScale.contained,
/// basePosition: Alignment.center,
/// scaleStateCycle: scaleStateCycle
/// );
/// ```
///
/// You can customize to show an custom child instead of an image:
///
/// ```
/// PhotoView.customChild(
/// child: Container(
/// width: 220.0,
/// height: 250.0,
/// child: const Text(
/// "Hello there, this is a text",
/// )
/// ),
/// childSize: const Size(220.0, 250.0),
/// backgroundDecoration: BoxDecoration(color: Colors.black),
/// gaplessPlayback: false,
/// customSize: MediaQuery.of(context).size,
/// heroAttributes: const HeroAttributes(
/// tag: "someTag",
/// transitionOnUserGestures: true,
/// ),
/// scaleStateChangedCallback: this.onScaleStateChanged,
/// enableRotation: true,
/// controller: controller,
/// minScale: PhotoViewComputedScale.contained * 0.8,
/// maxScale: PhotoViewComputedScale.covered * 1.8,
/// initialScale: PhotoViewComputedScale.contained,
/// basePosition: Alignment.center,
/// scaleStateCycle: scaleStateCycle
/// );
/// ```
/// The [maxScale], [minScale] and [initialScale] options may be [double] or a [PhotoViewComputedScale] constant
///
/// Sample using [maxScale], [minScale] and [initialScale]
///
/// ```
/// PhotoView(
/// imageProvider: imageProvider,
/// minScale: PhotoViewComputedScale.contained * 0.8,
/// maxScale: PhotoViewComputedScale.covered * 1.8,
/// initialScale: PhotoViewComputedScale.contained * 1.1,
/// );
/// ```
///
/// [customSize] is used to define the viewPort size in which the image will be
/// scaled to. This argument is rarely used. By default is the size that this widget assumes.
///
/// The argument [gaplessPlayback] is used to continue showing the old image
/// (`true`), or briefly show nothing (`false`), when the [imageProvider]
/// changes.By default it's set to `false`.
///
/// To use within an hero animation, specify [heroAttributes]. When
/// [heroAttributes] is specified, the image provider retrieval process should
/// be sync.
///
/// Sample using hero animation:
/// ```
/// // screen1
/// ...
/// Hero(
/// tag: "someTag",
/// child: Image.asset(
/// "assets/large-image.jpg",
/// width: 150.0
/// ),
/// )
/// // screen2
/// ...
/// child: PhotoView(
/// imageProvider: AssetImage("assets/large-image.jpg"),
/// heroAttributes: const HeroAttributes(tag: "someTag"),
/// )
/// ```
///
/// **Note: If you don't want to the zoomed image do not overlaps the size of the container, use [ClipRect](https://docs.flutter.io/flutter/widgets/ClipRect-class.html)**
///
/// ## Controllers
///
/// Controllers, when specified to PhotoView widget, enables the author(you) to listen for state updates through a `Stream` and change those values externally.
///
/// While [PhotoViewScaleStateController] is only responsible for the `scaleState`, [PhotoViewController] is responsible for all fields os [PhotoViewControllerValue].
///
/// To use them, pass a instance of those items on [controller] or [scaleStateController];
///
/// Since those follows the standard controller pattern found in widgets like [PageView] and [ScrollView], whoever instantiates it, should [dispose] it afterwards.
///
/// Example of [controller] usage, only listening for state changes:
///
/// ```
/// class _ExampleWidgetState extends State<ExampleWidget> {
///
/// PhotoViewController controller;
/// double scaleCopy;
///
/// @override
/// void initState() {
/// super.initState();
/// controller = PhotoViewController()
/// ..outputStateStream.listen(listener);
/// }
///
/// @override
/// void dispose() {
/// controller.dispose();
/// super.dispose();
/// }
///
/// void listener(PhotoViewControllerValue value){
/// setState((){
/// scaleCopy = value.scale;
/// })
/// }
///
/// @override
/// Widget build(BuildContext context) {
/// return Stack(
/// children: <Widget>[
/// Positioned.fill(
/// child: PhotoView(
/// imageProvider: AssetImage("assets/pudim.png"),
/// controller: controller,
/// );
/// ),
/// Text("Scale applied: $scaleCopy")
/// ],
/// );
/// }
/// }
/// ```
///
/// An example of [scaleStateController] with state changes:
/// ```
/// class _ExampleWidgetState extends State<ExampleWidget> {
///
/// PhotoViewScaleStateController scaleStateController;
///
/// @override
/// void initState() {
/// super.initState();
/// scaleStateController = PhotoViewScaleStateController();
/// }
///
/// @override
/// void dispose() {
/// scaleStateController.dispose();
/// super.dispose();
/// }
///
/// void goBack(){
/// scaleStateController.scaleState = PhotoViewScaleState.originalSize;
/// }
///
/// @override
/// Widget build(BuildContext context) {
/// return Stack(
/// children: <Widget>[
/// Positioned.fill(
/// child: PhotoView(
/// imageProvider: AssetImage("assets/pudim.png"),
/// scaleStateController: scaleStateController,
/// );
/// ),
/// FlatButton(
/// child: Text("Go to original size"),
/// onPressed: goBack,
/// );
/// ],
/// );
/// }
/// }
/// ```
///
class PhotoView extends StatefulWidget {
/// Creates a widget that displays a zoomable image.
///
/// To show an image from the network or from an asset bundle, use their respective
/// image providers, ie: [AssetImage] or [NetworkImage]
///
/// Internally, the image is rendered within an [Image] widget.
const PhotoView({
super.key,
required this.imageProvider,
required this.index,
this.loadingBuilder,
this.backgroundDecoration,
this.wantKeepAlive = false,
this.gaplessPlayback = false,
this.heroAttributes,
this.scaleStateChangedCallback,
this.enableRotation = false,
this.controller,
this.scaleStateController,
this.maxScale,
this.minScale,
this.initialScale,
this.basePosition,
this.scaleStateCycle,
this.onTapUp,
this.onTapDown,
this.onDragStart,
this.onDragEnd,
this.onDragUpdate,
this.onScaleEnd,
this.onLongPressStart,
this.customSize,
this.gestureDetectorBehavior,
this.tightMode,
this.filterQuality,
this.disableGestures,
this.errorBuilder,
this.enablePanAlways,
}) : child = null,
childSize = null;
/// Creates a widget that displays a zoomable child.
///
/// It has been created to resemble [PhotoView] behavior within widgets that aren't an image, such as [Container], [Text] or a svg.
///
/// Instead of a [imageProvider], this constructor will receive a [child] and a [childSize].
///
const PhotoView.customChild({
super.key,
required this.child,
this.childSize,
this.backgroundDecoration,
this.wantKeepAlive = false,
this.heroAttributes,
this.scaleStateChangedCallback,
this.enableRotation = false,
this.controller,
this.scaleStateController,
this.maxScale,
this.minScale,
this.initialScale,
this.basePosition,
this.scaleStateCycle,
this.onTapUp,
this.onTapDown,
this.onDragStart,
this.onDragEnd,
this.onDragUpdate,
this.onScaleEnd,
this.onLongPressStart,
this.customSize,
this.gestureDetectorBehavior,
this.tightMode,
this.filterQuality,
this.disableGestures,
this.enablePanAlways,
}) : errorBuilder = null,
imageProvider = null,
gaplessPlayback = false,
loadingBuilder = null,
index = 0;
/// Given a [imageProvider] it resolves into an zoomable image widget using. It
/// is required
final ImageProvider? imageProvider;
/// While [imageProvider] is not resolved, [loadingBuilder] is called by [PhotoView]
/// into the screen, by default it is a centered [CircularProgressIndicator]
final LoadingBuilder? loadingBuilder;
/// Show loadFailedChild when the image failed to load
final ImageErrorWidgetBuilder? errorBuilder;
/// Changes the background behind image, defaults to `Colors.black`.
final BoxDecoration? backgroundDecoration;
/// This is used to keep the state of an image in the gallery (e.g. scale state).
/// `false` -> resets the state (default)
/// `true` -> keeps the state
final bool wantKeepAlive;
/// This is used to continue showing the old image (`true`), or briefly show
/// nothing (`false`), when the `imageProvider` changes. By default it's set
/// to `false`.
final bool gaplessPlayback;
/// Attributes that are going to be passed to [PhotoViewCore]'s
/// [Hero]. Leave this property undefined if you don't want a hero animation.
final PhotoViewHeroAttributes? heroAttributes;
/// Defines the size of the scaling base of the image inside [PhotoView],
/// by default it is `MediaQuery.of(context).size`.
final Size? customSize;
/// A [Function] to be called whenever the scaleState changes, this happens when the user double taps the content ou start to pinch-in.
final ValueChanged<PhotoViewScaleState>? scaleStateChangedCallback;
/// A flag that enables the rotation gesture support
final bool enableRotation;
/// The specified custom child to be shown instead of a image
final Widget? child;
/// The size of the custom [child]. [PhotoView] uses this value to compute the relation between the child and the container's size to calculate the scale value.
final Size? childSize;
/// Defines the maximum size in which the image will be allowed to assume, it
/// is proportional to the original image size. Can be either a double (absolute value) or a
/// [PhotoViewComputedScale], that can be multiplied by a double
final dynamic maxScale;
/// Defines the minimum size in which the image will be allowed to assume, it
/// is proportional to the original image size. Can be either a double (absolute value) or a
/// [PhotoViewComputedScale], that can be multiplied by a double
final dynamic minScale;
/// Defines the initial size in which the image will be assume in the mounting of the component, it
/// is proportional to the original image size. Can be either a double (absolute value) or a
/// [PhotoViewComputedScale], that can be multiplied by a double
final dynamic initialScale;
/// A way to control PhotoView transformation factors externally and listen to its updates
final PhotoViewControllerBase? controller;
/// A way to control PhotoViewScaleState value externally and listen to its updates
final PhotoViewScaleStateController? scaleStateController;
/// The alignment of the scale origin in relation to the widget size. Default is [Alignment.center]
final Alignment? basePosition;
/// Defines de next [PhotoViewScaleState] given the actual one. Default is [defaultScaleStateCycle]
final ScaleStateCycle? scaleStateCycle;
/// A pointer that will trigger a tap has stopped contacting the screen at a
/// particular location.
final PhotoViewImageTapUpCallback? onTapUp;
/// A pointer that might cause a tap has contacted the screen at a particular
/// location.
final PhotoViewImageTapDownCallback? onTapDown;
/// A pointer that might cause a tap has contacted the screen at a particular
/// location.
final PhotoViewImageDragStartCallback? onDragStart;
/// A pointer that might cause a tap has contacted the screen at a particular
/// location.
final PhotoViewImageDragEndCallback? onDragEnd;
/// A pointer that might cause a tap has contacted the screen at a particular
/// location.
final PhotoViewImageDragUpdateCallback? onDragUpdate;
/// A pointer that will trigger a scale has stopped contacting the screen at a
/// particular location.
final PhotoViewImageScaleEndCallback? onScaleEnd;
/// A pointer that might cause a tap has contacted the screen at a particular
/// location.
final PhotoViewImageLongPressStartCallback? onLongPressStart;
/// [HitTestBehavior] to be passed to the internal gesture detector.
final HitTestBehavior? gestureDetectorBehavior;
/// Enables tight mode, making background container assume the size of the image/child.
/// Useful when inside a [Dialog]
final bool? tightMode;
/// Quality levels for image filters.
final FilterQuality? filterQuality;
// Removes gesture detector if `true`.
// Useful when custom gesture detector is used in child widget.
final bool? disableGestures;
/// Enable pan the widget even if it's smaller than the hole parent widget.
/// Useful when you want to drag a widget without restrictions.
final bool? enablePanAlways;
final int index;
bool get _isCustomChild {
return child != null;
}
@override
State<StatefulWidget> createState() {
return _PhotoViewState();
}
}
class _PhotoViewState extends State<PhotoView>
with AutomaticKeepAliveClientMixin {
// image retrieval
// controller
late bool _controlledController;
late PhotoViewControllerBase _controller;
late bool _controlledScaleStateController;
late PhotoViewScaleStateController _scaleStateController;
@override
void initState() {
super.initState();
if (widget.controller == null) {
_controlledController = true;
_controller = PhotoViewController();
} else {
_controlledController = false;
_controller = widget.controller!;
}
if (widget.scaleStateController == null) {
_controlledScaleStateController = true;
_scaleStateController = PhotoViewScaleStateController();
} else {
_controlledScaleStateController = false;
_scaleStateController = widget.scaleStateController!;
}
_scaleStateController.outputScaleStateStream.listen(scaleStateListener);
}
@override
void didUpdateWidget(PhotoView oldWidget) {
if (widget.controller == null) {
if (!_controlledController) {
_controlledController = true;
_controller = PhotoViewController();
}
} else {
_controlledController = false;
_controller = widget.controller!;
}
if (widget.scaleStateController == null) {
if (!_controlledScaleStateController) {
_controlledScaleStateController = true;
_scaleStateController = PhotoViewScaleStateController();
}
} else {
_controlledScaleStateController = false;
_scaleStateController = widget.scaleStateController!;
}
super.didUpdateWidget(oldWidget);
}
@override
void dispose() {
if (_controlledController) {
_controller.dispose();
}
if (_controlledScaleStateController) {
_scaleStateController.dispose();
}
super.dispose();
}
void scaleStateListener(PhotoViewScaleState scaleState) {
if (widget.scaleStateChangedCallback != null) {
widget.scaleStateChangedCallback!(_scaleStateController.scaleState);
}
}
@override
Widget build(BuildContext context) {
super.build(context);
return LayoutBuilder(
builder: (
BuildContext context,
BoxConstraints constraints,
) {
final computedOuterSize = widget.customSize ?? constraints.biggest;
final backgroundDecoration = widget.backgroundDecoration ??
const BoxDecoration(color: Colors.black);
return widget._isCustomChild
? CustomChildWrapper(
childSize: widget.childSize,
backgroundDecoration: backgroundDecoration,
heroAttributes: widget.heroAttributes,
scaleStateChangedCallback: widget.scaleStateChangedCallback,
enableRotation: widget.enableRotation,
controller: _controller,
scaleStateController: _scaleStateController,
maxScale: widget.maxScale,
minScale: widget.minScale,
initialScale: widget.initialScale,
basePosition: widget.basePosition,
scaleStateCycle: widget.scaleStateCycle,
onTapUp: widget.onTapUp,
onTapDown: widget.onTapDown,
onDragStart: widget.onDragStart,
onDragEnd: widget.onDragEnd,
onDragUpdate: widget.onDragUpdate,
onScaleEnd: widget.onScaleEnd,
onLongPressStart: widget.onLongPressStart,
outerSize: computedOuterSize,
gestureDetectorBehavior: widget.gestureDetectorBehavior,
tightMode: widget.tightMode,
filterQuality: widget.filterQuality,
disableGestures: widget.disableGestures,
enablePanAlways: widget.enablePanAlways,
child: widget.child,
)
: ImageWrapper(
imageProvider: widget.imageProvider!,
loadingBuilder: widget.loadingBuilder,
backgroundDecoration: backgroundDecoration,
gaplessPlayback: widget.gaplessPlayback,
heroAttributes: widget.heroAttributes,
scaleStateChangedCallback: widget.scaleStateChangedCallback,
enableRotation: widget.enableRotation,
controller: _controller,
scaleStateController: _scaleStateController,
maxScale: widget.maxScale,
minScale: widget.minScale,
initialScale: widget.initialScale,
basePosition: widget.basePosition,
scaleStateCycle: widget.scaleStateCycle,
onTapUp: widget.onTapUp,
onTapDown: widget.onTapDown,
onDragStart: widget.onDragStart,
onDragEnd: widget.onDragEnd,
onDragUpdate: widget.onDragUpdate,
onScaleEnd: widget.onScaleEnd,
onLongPressStart: widget.onLongPressStart,
outerSize: computedOuterSize,
gestureDetectorBehavior: widget.gestureDetectorBehavior,
tightMode: widget.tightMode,
filterQuality: widget.filterQuality,
disableGestures: widget.disableGestures,
errorBuilder: widget.errorBuilder,
enablePanAlways: widget.enablePanAlways,
index: widget.index,
);
},
);
}
@override
bool get wantKeepAlive => widget.wantKeepAlive;
}
/// The default [ScaleStateCycle]
PhotoViewScaleState defaultScaleStateCycle(PhotoViewScaleState actual) {
switch (actual) {
case PhotoViewScaleState.initial:
return PhotoViewScaleState.covering;
case PhotoViewScaleState.covering:
return PhotoViewScaleState.originalSize;
case PhotoViewScaleState.originalSize:
return PhotoViewScaleState.initial;
case PhotoViewScaleState.zoomedIn:
case PhotoViewScaleState.zoomedOut:
return PhotoViewScaleState.initial;
default:
return PhotoViewScaleState.initial;
}
}
/// A type definition for a [Function] that receives the actual [PhotoViewScaleState] and returns the next one
/// It is used internally to walk in the "doubletap gesture cycle".
/// It is passed to [PhotoView.scaleStateCycle]
typedef ScaleStateCycle = PhotoViewScaleState Function(
PhotoViewScaleState actual,
);
/// A type definition for a callback when the user taps up the photoview region
typedef PhotoViewImageTapUpCallback = Function(
BuildContext context,
TapUpDetails details,
PhotoViewControllerValue controllerValue,
);
/// A type definition for a callback when the user taps down the photoview region
typedef PhotoViewImageTapDownCallback = Function(
BuildContext context,
TapDownDetails details,
PhotoViewControllerValue controllerValue,
);
/// A type definition for a callback when the user drags up
typedef PhotoViewImageDragStartCallback = Function(
BuildContext context,
DragStartDetails details,
PhotoViewControllerValue controllerValue,
);
/// A type definition for a callback when the user drags
typedef PhotoViewImageDragUpdateCallback = Function(
BuildContext context,
DragUpdateDetails details,
PhotoViewControllerValue controllerValue,
);
/// A type definition for a callback when the user taps down the photoview region
typedef PhotoViewImageDragEndCallback = Function(
BuildContext context,
DragEndDetails details,
PhotoViewControllerValue controllerValue,
);
/// A type definition for a callback when a user finished scale
typedef PhotoViewImageScaleEndCallback = Function(
BuildContext context,
ScaleEndDetails details,
PhotoViewControllerValue controllerValue,
);
/// A type definition for a callback when the user long press start
typedef PhotoViewImageLongPressStartCallback = Function(
BuildContext context,
LongPressStartDetails details,
PhotoViewControllerValue controllerValue,
);
/// A type definition for a callback to show a widget while the image is loading, a [ImageChunkEvent] is passed to inform progress
typedef LoadingBuilder = Widget Function(
BuildContext context,
ImageChunkEvent? event,
int index,
);

View file

@ -1,456 +0,0 @@
library photo_view_gallery;
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:immich_mobile/shared/ui/photo_view/photo_view.dart'
show
LoadingBuilder,
PhotoView,
PhotoViewImageTapDownCallback,
PhotoViewImageTapUpCallback,
PhotoViewImageDragStartCallback,
PhotoViewImageDragEndCallback,
PhotoViewImageDragUpdateCallback,
PhotoViewImageScaleEndCallback,
PhotoViewImageLongPressStartCallback,
ScaleStateCycle;
import 'package:immich_mobile/shared/ui/photo_view/src/controller/photo_view_controller.dart';
import 'package:immich_mobile/shared/ui/photo_view/src/controller/photo_view_scalestate_controller.dart';
import 'package:immich_mobile/shared/ui/photo_view/src/core/photo_view_gesture_detector.dart';
import 'package:immich_mobile/shared/ui/photo_view/src/photo_view_scale_state.dart';
import 'package:immich_mobile/shared/ui/photo_view/src/utils/photo_view_hero_attributes.dart';
/// A type definition for a [Function] that receives a index after a page change in [PhotoViewGallery]
typedef PhotoViewGalleryPageChangedCallback = void Function(int index);
/// A type definition for a [Function] that defines a page in [PhotoViewGallery.build]
typedef PhotoViewGalleryBuilder = PhotoViewGalleryPageOptions Function(
BuildContext context,
int index,
);
/// A [StatefulWidget] that shows multiple [PhotoView] widgets in a [PageView]
///
/// Some of [PhotoView] constructor options are passed direct to [PhotoViewGallery] constructor. Those options will affect the gallery in a whole.
///
/// Some of the options may be defined to each image individually, such as `initialScale` or `PhotoViewHeroAttributes`. Those must be passed via each [PhotoViewGalleryPageOptions].
///
/// Example of usage as a list of options:
/// ```
/// PhotoViewGallery(
/// pageOptions: <PhotoViewGalleryPageOptions>[
/// PhotoViewGalleryPageOptions(
/// imageProvider: AssetImage("assets/gallery1.jpg"),
/// heroAttributes: const PhotoViewHeroAttributes(tag: "tag1"),
/// ),
/// PhotoViewGalleryPageOptions(
/// imageProvider: AssetImage("assets/gallery2.jpg"),
/// heroAttributes: const PhotoViewHeroAttributes(tag: "tag2"),
/// maxScale: PhotoViewComputedScale.contained * 0.3
/// ),
/// PhotoViewGalleryPageOptions(
/// imageProvider: AssetImage("assets/gallery3.jpg"),
/// minScale: PhotoViewComputedScale.contained * 0.8,
/// maxScale: PhotoViewComputedScale.covered * 1.1,
/// heroAttributes: const HeroAttributes(tag: "tag3"),
/// ),
/// ],
/// loadingBuilder: (context, progress) => Center(
/// child: Container(
/// width: 20.0,
/// height: 20.0,
/// child: CircularProgressIndicator(
/// value: _progress == null
/// ? null
/// : _progress.cumulativeBytesLoaded /
/// _progress.expectedTotalBytes,
/// ),
/// ),
/// ),
/// backgroundDecoration: widget.backgroundDecoration,
/// pageController: widget.pageController,
/// onPageChanged: onPageChanged,
/// )
/// ```
///
/// Example of usage with builder pattern:
/// ```
/// PhotoViewGallery.builder(
/// scrollPhysics: const BouncingScrollPhysics(),
/// builder: (BuildContext context, int index) {
/// return PhotoViewGalleryPageOptions(
/// imageProvider: AssetImage(widget.galleryItems[index].image),
/// initialScale: PhotoViewComputedScale.contained * 0.8,
/// minScale: PhotoViewComputedScale.contained * 0.8,
/// maxScale: PhotoViewComputedScale.covered * 1.1,
/// heroAttributes: HeroAttributes(tag: galleryItems[index].id),
/// );
/// },
/// itemCount: galleryItems.length,
/// loadingBuilder: (context, progress) => Center(
/// child: Container(
/// width: 20.0,
/// height: 20.0,
/// child: CircularProgressIndicator(
/// value: _progress == null
/// ? null
/// : _progress.cumulativeBytesLoaded /
/// _progress.expectedTotalBytes,
/// ),
/// ),
/// ),
/// backgroundDecoration: widget.backgroundDecoration,
/// pageController: widget.pageController,
/// onPageChanged: onPageChanged,
/// )
/// ```
class PhotoViewGallery extends StatefulWidget {
/// Construct a gallery with static items through a list of [PhotoViewGalleryPageOptions].
const PhotoViewGallery({
super.key,
required this.pageOptions,
this.loadingBuilder,
this.backgroundDecoration,
this.wantKeepAlive = false,
this.gaplessPlayback = false,
this.reverse = false,
this.pageController,
this.onPageChanged,
this.scaleStateChangedCallback,
this.enableRotation = false,
this.scrollPhysics,
this.scrollDirection = Axis.horizontal,
this.customSize,
this.allowImplicitScrolling = false,
}) : itemCount = null,
builder = null;
/// Construct a gallery with dynamic items.
///
/// The builder must return a [PhotoViewGalleryPageOptions].
const PhotoViewGallery.builder({
super.key,
required this.itemCount,
required this.builder,
this.loadingBuilder,
this.backgroundDecoration,
this.wantKeepAlive = false,
this.gaplessPlayback = false,
this.reverse = false,
this.pageController,
this.onPageChanged,
this.scaleStateChangedCallback,
this.enableRotation = false,
this.scrollPhysics,
this.scrollDirection = Axis.horizontal,
this.customSize,
this.allowImplicitScrolling = false,
}) : pageOptions = null,
assert(itemCount != null),
assert(builder != null);
/// A list of options to describe the items in the gallery
final List<PhotoViewGalleryPageOptions>? pageOptions;
/// The count of items in the gallery, only used when constructed via [PhotoViewGallery.builder]
final int? itemCount;
/// Called to build items for the gallery when using [PhotoViewGallery.builder]
final PhotoViewGalleryBuilder? builder;
/// [ScrollPhysics] for the internal [PageView]
final ScrollPhysics? scrollPhysics;
/// Mirror to [PhotoView.loadingBuilder]
final LoadingBuilder? loadingBuilder;
/// Mirror to [PhotoView.backgroundDecoration]
final BoxDecoration? backgroundDecoration;
/// Mirror to [PhotoView.wantKeepAlive]
final bool wantKeepAlive;
/// Mirror to [PhotoView.gaplessPlayback]
final bool gaplessPlayback;
/// Mirror to [PageView.reverse]
final bool reverse;
/// An object that controls the [PageView] inside [PhotoViewGallery]
final PageController? pageController;
/// An callback to be called on a page change
final PhotoViewGalleryPageChangedCallback? onPageChanged;
/// Mirror to [PhotoView.scaleStateChangedCallback]
final ValueChanged<PhotoViewScaleState>? scaleStateChangedCallback;
/// Mirror to [PhotoView.enableRotation]
final bool enableRotation;
/// Mirror to [PhotoView.customSize]
final Size? customSize;
/// The axis along which the [PageView] scrolls. Mirror to [PageView.scrollDirection]
final Axis scrollDirection;
/// When user attempts to move it to the next element, focus will traverse to the next page in the page view.
final bool allowImplicitScrolling;
bool get _isBuilder => builder != null;
@override
State<StatefulWidget> createState() {
return _PhotoViewGalleryState();
}
}
class _PhotoViewGalleryState extends State<PhotoViewGallery> {
late final PageController _controller =
widget.pageController ?? PageController();
void scaleStateChangedCallback(PhotoViewScaleState scaleState) {
if (widget.scaleStateChangedCallback != null) {
widget.scaleStateChangedCallback!(scaleState);
}
}
int get actualPage {
return _controller.hasClients ? _controller.page!.floor() : 0;
}
int get itemCount {
if (widget._isBuilder) {
return widget.itemCount!;
}
return widget.pageOptions!.length;
}
@override
Widget build(BuildContext context) {
// Enable corner hit test
return PhotoViewGestureDetectorScope(
axis: widget.scrollDirection,
child: PageView.builder(
reverse: widget.reverse,
controller: _controller,
onPageChanged: widget.onPageChanged,
itemCount: itemCount,
itemBuilder: _buildItem,
scrollDirection: widget.scrollDirection,
physics: widget.scrollPhysics,
allowImplicitScrolling: widget.allowImplicitScrolling,
),
);
}
Widget _buildItem(BuildContext context, int index) {
final pageOption = _buildPageOption(context, index);
final isCustomChild = pageOption.child != null;
final PhotoView photoView = isCustomChild
? PhotoView.customChild(
key: ObjectKey(index),
childSize: pageOption.childSize,
backgroundDecoration: widget.backgroundDecoration,
wantKeepAlive: widget.wantKeepAlive,
controller: pageOption.controller,
scaleStateController: pageOption.scaleStateController,
customSize: widget.customSize,
scaleStateChangedCallback: scaleStateChangedCallback,
enableRotation: widget.enableRotation,
initialScale: pageOption.initialScale,
minScale: pageOption.minScale,
maxScale: pageOption.maxScale,
scaleStateCycle: pageOption.scaleStateCycle,
onTapUp: pageOption.onTapUp,
onTapDown: pageOption.onTapDown,
onDragStart: pageOption.onDragStart,
onDragEnd: pageOption.onDragEnd,
onDragUpdate: pageOption.onDragUpdate,
onScaleEnd: pageOption.onScaleEnd,
onLongPressStart: pageOption.onLongPressStart,
gestureDetectorBehavior: pageOption.gestureDetectorBehavior,
tightMode: pageOption.tightMode,
filterQuality: pageOption.filterQuality,
basePosition: pageOption.basePosition,
disableGestures: pageOption.disableGestures,
heroAttributes: pageOption.heroAttributes,
child: pageOption.child,
)
: PhotoView(
key: ObjectKey(index),
index: index,
imageProvider: pageOption.imageProvider,
loadingBuilder: widget.loadingBuilder,
backgroundDecoration: widget.backgroundDecoration,
wantKeepAlive: widget.wantKeepAlive,
controller: pageOption.controller,
scaleStateController: pageOption.scaleStateController,
customSize: widget.customSize,
gaplessPlayback: widget.gaplessPlayback,
scaleStateChangedCallback: scaleStateChangedCallback,
enableRotation: widget.enableRotation,
initialScale: pageOption.initialScale,
minScale: pageOption.minScale,
maxScale: pageOption.maxScale,
scaleStateCycle: pageOption.scaleStateCycle,
onTapUp: pageOption.onTapUp,
onTapDown: pageOption.onTapDown,
onDragStart: pageOption.onDragStart,
onDragEnd: pageOption.onDragEnd,
onDragUpdate: pageOption.onDragUpdate,
onScaleEnd: pageOption.onScaleEnd,
onLongPressStart: pageOption.onLongPressStart,
gestureDetectorBehavior: pageOption.gestureDetectorBehavior,
tightMode: pageOption.tightMode,
filterQuality: pageOption.filterQuality,
basePosition: pageOption.basePosition,
disableGestures: pageOption.disableGestures,
errorBuilder: pageOption.errorBuilder,
heroAttributes: pageOption.heroAttributes,
);
return ClipRect(
child: photoView,
);
}
PhotoViewGalleryPageOptions _buildPageOption(
BuildContext context,
int index,
) {
if (widget._isBuilder) {
return widget.builder!(context, index);
}
return widget.pageOptions![index];
}
}
/// A helper class that wraps individual options of a page in [PhotoViewGallery]
///
/// The [maxScale], [minScale] and [initialScale] options may be [double] or a [PhotoViewComputedScale] constant
///
class PhotoViewGalleryPageOptions {
PhotoViewGalleryPageOptions({
Key? key,
required this.imageProvider,
this.heroAttributes,
this.minScale,
this.maxScale,
this.initialScale,
this.controller,
this.scaleStateController,
this.basePosition,
this.scaleStateCycle,
this.onTapUp,
this.onTapDown,
this.onDragStart,
this.onDragEnd,
this.onDragUpdate,
this.onScaleEnd,
this.onLongPressStart,
this.gestureDetectorBehavior,
this.tightMode,
this.filterQuality,
this.disableGestures,
this.errorBuilder,
}) : child = null,
childSize = null,
assert(imageProvider != null);
PhotoViewGalleryPageOptions.customChild({
required this.child,
this.childSize,
this.heroAttributes,
this.minScale,
this.maxScale,
this.initialScale,
this.controller,
this.scaleStateController,
this.basePosition,
this.scaleStateCycle,
this.onTapUp,
this.onTapDown,
this.onDragStart,
this.onDragEnd,
this.onDragUpdate,
this.onScaleEnd,
this.onLongPressStart,
this.gestureDetectorBehavior,
this.tightMode,
this.filterQuality,
this.disableGestures,
}) : errorBuilder = null,
imageProvider = null;
/// Mirror to [PhotoView.imageProvider]
final ImageProvider? imageProvider;
/// Mirror to [PhotoView.heroAttributes]
final PhotoViewHeroAttributes? heroAttributes;
/// Mirror to [PhotoView.minScale]
final dynamic minScale;
/// Mirror to [PhotoView.maxScale]
final dynamic maxScale;
/// Mirror to [PhotoView.initialScale]
final dynamic initialScale;
/// Mirror to [PhotoView.controller]
final PhotoViewController? controller;
/// Mirror to [PhotoView.scaleStateController]
final PhotoViewScaleStateController? scaleStateController;
/// Mirror to [PhotoView.basePosition]
final Alignment? basePosition;
/// Mirror to [PhotoView.child]
final Widget? child;
/// Mirror to [PhotoView.childSize]
final Size? childSize;
/// Mirror to [PhotoView.scaleStateCycle]
final ScaleStateCycle? scaleStateCycle;
/// Mirror to [PhotoView.onTapUp]
final PhotoViewImageTapUpCallback? onTapUp;
/// Mirror to [PhotoView.onDragUp]
final PhotoViewImageDragStartCallback? onDragStart;
/// Mirror to [PhotoView.onDragDown]
final PhotoViewImageDragEndCallback? onDragEnd;
/// Mirror to [PhotoView.onDraUpdate]
final PhotoViewImageDragUpdateCallback? onDragUpdate;
/// Mirror to [PhotoView.onTapDown]
final PhotoViewImageTapDownCallback? onTapDown;
/// Mirror to [PhotoView.onScaleEnd]
final PhotoViewImageScaleEndCallback? onScaleEnd;
/// Mirror to [PhotoView.onLongPressStart]
final PhotoViewImageLongPressStartCallback? onLongPressStart;
/// Mirror to [PhotoView.gestureDetectorBehavior]
final HitTestBehavior? gestureDetectorBehavior;
/// Mirror to [PhotoView.tightMode]
final bool? tightMode;
/// Mirror to [PhotoView.disableGestures]
final bool? disableGestures;
/// Quality levels for image filters.
final FilterQuality? filterQuality;
/// Mirror to [PhotoView.errorBuilder]
final ImageErrorWidgetBuilder? errorBuilder;
}

View file

@ -1,291 +0,0 @@
import 'dart:async';
import 'package:flutter/widgets.dart';
import 'package:immich_mobile/shared/ui/photo_view/src/utils/ignorable_change_notifier.dart';
/// The interface in which controllers will be implemented.
///
/// It concerns storing the state ([PhotoViewControllerValue]) and streaming its updates.
/// [PhotoViewImageWrapper] will respond to user gestures setting thew fields in the instance of a controller.
///
/// Any instance of a controller must be disposed after unmount. So if you instantiate a [PhotoViewController] or your custom implementation, do not forget to dispose it when not using it anymore.
///
/// The controller exposes value fields like [scale] or [rotationFocus]. Usually those fields will be only getters and setters serving as hooks to the internal [PhotoViewControllerValue].
///
/// The default implementation used by [PhotoView] is [PhotoViewController].
///
/// This was created to allow customization (you can create your own controller class)
///
/// Previously it controlled `scaleState` as well, but duw to some [concerns](https://github.com/renancaraujo/photo_view/issues/127)
/// [ScaleStateListener is responsible for tat value now
///
/// As it is a controller, whoever instantiates it, should [dispose] it afterwards.
///
abstract class PhotoViewControllerBase<T extends PhotoViewControllerValue> {
/// The output for state/value updates. Usually a broadcast [Stream]
Stream<T> get outputStateStream;
/// The state value before the last change or the initial state if the state has not been changed.
late T prevValue;
/// The actual state value
late T value;
/// Resets the state to the initial value;
void reset();
/// Closes streams and removes eventual listeners.
void dispose();
/// Add a listener that will ignore updates made internally
///
/// Since it is made for internal use, it is not performatic to use more than one
/// listener. Prefer [outputStateStream]
void addIgnorableListener(VoidCallback callback);
/// Remove a listener that will ignore updates made internally
///
/// Since it is made for internal use, it is not performatic to use more than one
/// listener. Prefer [outputStateStream]
void removeIgnorableListener(VoidCallback callback);
/// The position of the image in the screen given its offset after pan gestures.
late Offset position;
/// The scale factor to transform the child (image or a customChild).
late double? scale;
/// Nevermind this method :D, look away
void setScaleInvisibly(double? scale);
/// The rotation factor to transform the child (image or a customChild).
late double rotation;
/// The center of the rotation transformation. It is a coordinate referring to the absolute dimensions of the image.
Offset? rotationFocusPoint;
/// Update multiple fields of the state with only one update streamed.
void updateMultiple({
Offset? position,
double? scale,
double? rotation,
Offset? rotationFocusPoint,
});
}
/// The state value stored and streamed by [PhotoViewController].
@immutable
class PhotoViewControllerValue {
const PhotoViewControllerValue({
required this.position,
required this.scale,
required this.rotation,
required this.rotationFocusPoint,
});
final Offset position;
final double? scale;
final double rotation;
final Offset? rotationFocusPoint;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is PhotoViewControllerValue &&
runtimeType == other.runtimeType &&
position == other.position &&
scale == other.scale &&
rotation == other.rotation &&
rotationFocusPoint == other.rotationFocusPoint;
@override
int get hashCode =>
position.hashCode ^
scale.hashCode ^
rotation.hashCode ^
rotationFocusPoint.hashCode;
@override
String toString() {
return 'PhotoViewControllerValue{position: $position, scale: $scale, rotation: $rotation, rotationFocusPoint: $rotationFocusPoint}';
}
}
/// The default implementation of [PhotoViewControllerBase].
///
/// Containing a [ValueNotifier] it stores the state in the [value] field and streams
/// updates via [outputStateStream].
///
/// For details of fields and methods, check [PhotoViewControllerBase].
///
class PhotoViewController
implements PhotoViewControllerBase<PhotoViewControllerValue> {
PhotoViewController({
Offset initialPosition = Offset.zero,
double initialRotation = 0.0,
double? initialScale,
}) : _valueNotifier = IgnorableValueNotifier(
PhotoViewControllerValue(
position: initialPosition,
rotation: initialRotation,
scale: initialScale,
rotationFocusPoint: null,
),
),
super() {
initial = value;
prevValue = initial;
_valueNotifier.addListener(_changeListener);
_outputCtrl = StreamController<PhotoViewControllerValue>.broadcast();
_outputCtrl.sink.add(initial);
}
final IgnorableValueNotifier<PhotoViewControllerValue> _valueNotifier;
late PhotoViewControllerValue initial;
late StreamController<PhotoViewControllerValue> _outputCtrl;
@override
Stream<PhotoViewControllerValue> get outputStateStream => _outputCtrl.stream;
@override
late PhotoViewControllerValue prevValue;
@override
void reset() {
value = initial;
}
void _changeListener() {
_outputCtrl.sink.add(value);
}
@override
void addIgnorableListener(VoidCallback callback) {
_valueNotifier.addIgnorableListener(callback);
}
@override
void removeIgnorableListener(VoidCallback callback) {
_valueNotifier.removeIgnorableListener(callback);
}
@override
void dispose() {
_outputCtrl.close();
_valueNotifier.dispose();
}
@override
set position(Offset position) {
if (value.position == position) {
return;
}
prevValue = value;
value = PhotoViewControllerValue(
position: position,
scale: scale,
rotation: rotation,
rotationFocusPoint: rotationFocusPoint,
);
}
@override
Offset get position => value.position;
@override
set scale(double? scale) {
if (value.scale == scale) {
return;
}
prevValue = value;
value = PhotoViewControllerValue(
position: position,
scale: scale,
rotation: rotation,
rotationFocusPoint: rotationFocusPoint,
);
}
@override
double? get scale => value.scale;
@override
void setScaleInvisibly(double? scale) {
if (value.scale == scale) {
return;
}
prevValue = value;
_valueNotifier.updateIgnoring(
PhotoViewControllerValue(
position: position,
scale: scale,
rotation: rotation,
rotationFocusPoint: rotationFocusPoint,
),
);
}
@override
set rotation(double rotation) {
if (value.rotation == rotation) {
return;
}
prevValue = value;
value = PhotoViewControllerValue(
position: position,
scale: scale,
rotation: rotation,
rotationFocusPoint: rotationFocusPoint,
);
}
@override
double get rotation => value.rotation;
@override
set rotationFocusPoint(Offset? rotationFocusPoint) {
if (value.rotationFocusPoint == rotationFocusPoint) {
return;
}
prevValue = value;
value = PhotoViewControllerValue(
position: position,
scale: scale,
rotation: rotation,
rotationFocusPoint: rotationFocusPoint,
);
}
@override
Offset? get rotationFocusPoint => value.rotationFocusPoint;
@override
void updateMultiple({
Offset? position,
double? scale,
double? rotation,
Offset? rotationFocusPoint,
}) {
prevValue = value;
value = PhotoViewControllerValue(
position: position ?? value.position,
scale: scale ?? value.scale,
rotation: rotation ?? value.rotation,
rotationFocusPoint: rotationFocusPoint ?? value.rotationFocusPoint,
);
}
@override
PhotoViewControllerValue get value => _valueNotifier.value;
@override
set value(PhotoViewControllerValue newValue) {
if (_valueNotifier.value == newValue) {
return;
}
_valueNotifier.value = newValue;
}
}

View file

@ -1,214 +0,0 @@
import 'package:flutter/widgets.dart';
import 'package:immich_mobile/shared/ui/photo_view/photo_view.dart'
show
PhotoViewControllerBase,
PhotoViewScaleState,
PhotoViewScaleStateController,
ScaleStateCycle;
import 'package:immich_mobile/shared/ui/photo_view/src/core/photo_view_core.dart';
import 'package:immich_mobile/shared/ui/photo_view/src/utils/photo_view_utils.dart';
/// A class to hold internal layout logic to sync both controller states
///
/// It reacts to layout changes (eg: enter landscape or widget resize) and syncs the two controllers.
mixin PhotoViewControllerDelegate on State<PhotoViewCore> {
PhotoViewControllerBase get controller => widget.controller;
PhotoViewScaleStateController get scaleStateController =>
widget.scaleStateController;
ScaleBoundaries get scaleBoundaries => widget.scaleBoundaries;
ScaleStateCycle get scaleStateCycle => widget.scaleStateCycle;
Alignment get basePosition => widget.basePosition;
Function(double prevScale, double nextScale)? _animateScale;
/// Mark if scale need recalculation, useful for scale boundaries changes.
bool markNeedsScaleRecalc = true;
void initDelegate() {
controller.addIgnorableListener(_blindScaleListener);
scaleStateController.addIgnorableListener(_blindScaleStateListener);
}
void _blindScaleStateListener() {
if (!scaleStateController.hasChanged) {
return;
}
if (_animateScale == null || scaleStateController.isZooming) {
controller.setScaleInvisibly(scale);
return;
}
final double prevScale = controller.scale ??
getScaleForScaleState(
scaleStateController.prevScaleState,
scaleBoundaries,
);
final double nextScale = getScaleForScaleState(
scaleStateController.scaleState,
scaleBoundaries,
);
_animateScale!(prevScale, nextScale);
}
void addAnimateOnScaleStateUpdate(
void Function(double prevScale, double nextScale) animateScale,
) {
_animateScale = animateScale;
}
void _blindScaleListener() {
if (!widget.enablePanAlways) {
controller.position = clampPosition();
}
if (controller.scale == controller.prevValue.scale) {
return;
}
final PhotoViewScaleState newScaleState =
(scale > scaleBoundaries.initialScale)
? PhotoViewScaleState.zoomedIn
: PhotoViewScaleState.zoomedOut;
scaleStateController.setInvisibly(newScaleState);
}
Offset get position => controller.position;
double get scale {
// for figuring out initial scale
final needsRecalc = markNeedsScaleRecalc &&
!scaleStateController.scaleState.isScaleStateZooming;
final scaleExistsOnController = controller.scale != null;
if (needsRecalc || !scaleExistsOnController) {
final newScale = getScaleForScaleState(
scaleStateController.scaleState,
scaleBoundaries,
);
markNeedsScaleRecalc = false;
scale = newScale;
return newScale;
}
return controller.scale!;
}
set scale(double scale) => controller.setScaleInvisibly(scale);
void updateMultiple({
Offset? position,
double? scale,
double? rotation,
Offset? rotationFocusPoint,
}) {
controller.updateMultiple(
position: position,
scale: scale,
rotation: rotation,
rotationFocusPoint: rotationFocusPoint,
);
}
void updateScaleStateFromNewScale(double newScale) {
PhotoViewScaleState newScaleState = PhotoViewScaleState.initial;
if (scale != scaleBoundaries.initialScale) {
newScaleState = (newScale > scaleBoundaries.initialScale)
? PhotoViewScaleState.zoomedIn
: PhotoViewScaleState.zoomedOut;
}
scaleStateController.setInvisibly(newScaleState);
}
void nextScaleState() {
final PhotoViewScaleState scaleState = scaleStateController.scaleState;
if (scaleState == PhotoViewScaleState.zoomedIn ||
scaleState == PhotoViewScaleState.zoomedOut) {
scaleStateController.scaleState = scaleStateCycle(scaleState);
return;
}
final double originalScale = getScaleForScaleState(
scaleState,
scaleBoundaries,
);
double prevScale = originalScale;
PhotoViewScaleState prevScaleState = scaleState;
double nextScale = originalScale;
PhotoViewScaleState nextScaleState = scaleState;
do {
prevScale = nextScale;
prevScaleState = nextScaleState;
nextScaleState = scaleStateCycle(prevScaleState);
nextScale = getScaleForScaleState(nextScaleState, scaleBoundaries);
} while (prevScale == nextScale && scaleState != nextScaleState);
if (originalScale == nextScale) {
return;
}
scaleStateController.scaleState = nextScaleState;
}
CornersRange cornersX({double? scale}) {
final double s = scale ?? this.scale;
final double computedWidth = scaleBoundaries.childSize.width * s;
final double screenWidth = scaleBoundaries.outerSize.width;
final double positionX = basePosition.x;
final double widthDiff = computedWidth - screenWidth;
final double minX = ((positionX - 1).abs() / 2) * widthDiff * -1;
final double maxX = ((positionX + 1).abs() / 2) * widthDiff;
return CornersRange(minX, maxX);
}
CornersRange cornersY({double? scale}) {
final double s = scale ?? this.scale;
final double computedHeight = scaleBoundaries.childSize.height * s;
final double screenHeight = scaleBoundaries.outerSize.height;
final double positionY = basePosition.y;
final double heightDiff = computedHeight - screenHeight;
final double minY = ((positionY - 1).abs() / 2) * heightDiff * -1;
final double maxY = ((positionY + 1).abs() / 2) * heightDiff;
return CornersRange(minY, maxY);
}
Offset clampPosition({Offset? position, double? scale}) {
final double s = scale ?? this.scale;
final Offset p = position ?? this.position;
final double computedWidth = scaleBoundaries.childSize.width * s;
final double computedHeight = scaleBoundaries.childSize.height * s;
final double screenWidth = scaleBoundaries.outerSize.width;
final double screenHeight = scaleBoundaries.outerSize.height;
double finalX = 0.0;
if (screenWidth < computedWidth) {
final cornersX = this.cornersX(scale: s);
finalX = p.dx.clamp(cornersX.min, cornersX.max);
}
double finalY = 0.0;
if (screenHeight < computedHeight) {
final cornersY = this.cornersY(scale: s);
finalY = p.dy.clamp(cornersY.min, cornersY.max);
}
return Offset(finalX, finalY);
}
@override
void dispose() {
_animateScale = null;
controller.removeIgnorableListener(_blindScaleListener);
scaleStateController.removeIgnorableListener(_blindScaleStateListener);
super.dispose();
}
}

View file

@ -1,97 +0,0 @@
import 'dart:async';
import 'dart:ui';
import 'package:flutter/widgets.dart' show VoidCallback;
import 'package:immich_mobile/shared/ui/photo_view/src/photo_view_scale_state.dart';
import 'package:immich_mobile/shared/ui/photo_view/src/utils/ignorable_change_notifier.dart';
typedef ScaleStateListener = void Function(double prevScale, double nextScale);
/// A controller responsible only by [scaleState].
///
/// Scale state is a common value with represents the step in which the [PhotoView.scaleStateCycle] is.
/// This cycle is triggered by the "doubleTap" gesture.
///
/// Any change in its [scaleState] should animate the scale of image/content.
///
/// As it is a controller, whoever instantiates it, should [dispose] it afterwards.
///
/// The updates should be done via [scaleState] setter and the updated listened via [outputScaleStateStream]
///
class PhotoViewScaleStateController {
late final IgnorableValueNotifier<PhotoViewScaleState> _scaleStateNotifier =
IgnorableValueNotifier(PhotoViewScaleState.initial)
..addListener(_scaleStateChangeListener);
final StreamController<PhotoViewScaleState> _outputScaleStateCtrl =
StreamController<PhotoViewScaleState>.broadcast()
..sink.add(PhotoViewScaleState.initial);
/// The output for state/value updates
Stream<PhotoViewScaleState> get outputScaleStateStream =>
_outputScaleStateCtrl.stream;
/// The state value before the last change or the initial state if the state has not been changed.
PhotoViewScaleState prevScaleState = PhotoViewScaleState.initial;
/// The actual state value
PhotoViewScaleState get scaleState => _scaleStateNotifier.value;
/// Updates scaleState and notify all listeners (and the stream)
set scaleState(PhotoViewScaleState newValue) {
if (_scaleStateNotifier.value == newValue) {
return;
}
prevScaleState = _scaleStateNotifier.value;
_scaleStateNotifier.value = newValue;
}
/// Checks if its actual value is different than previousValue
bool get hasChanged => prevScaleState != scaleState;
/// Check if is `zoomedIn` & `zoomedOut`
bool get isZooming =>
scaleState == PhotoViewScaleState.zoomedIn ||
scaleState == PhotoViewScaleState.zoomedOut;
/// Resets the state to the initial value;
void reset() {
prevScaleState = scaleState;
scaleState = PhotoViewScaleState.initial;
}
/// Closes streams and removes eventual listeners
void dispose() {
_outputScaleStateCtrl.close();
_scaleStateNotifier.dispose();
}
/// Nevermind this method :D, look away
void setInvisibly(PhotoViewScaleState newValue) {
if (_scaleStateNotifier.value == newValue) {
return;
}
prevScaleState = _scaleStateNotifier.value;
_scaleStateNotifier.updateIgnoring(newValue);
}
void _scaleStateChangeListener() {
_outputScaleStateCtrl.sink.add(scaleState);
}
/// Add a listener that will ignore updates made internally
///
/// Since it is made for internal use, it is not performatic to use more than one
/// listener. Prefer [outputScaleStateStream]
void addIgnorableListener(VoidCallback callback) {
_scaleStateNotifier.addIgnorableListener(callback);
}
/// Remove a listener that will ignore updates made internally
///
/// Since it is made for internal use, it is not performatic to use more than one
/// listener. Prefer [outputScaleStateStream]
void removeIgnorableListener(VoidCallback callback) {
_scaleStateNotifier.removeIgnorableListener(callback);
}
}

View file

@ -1,467 +0,0 @@
import 'package:flutter/widgets.dart';
import 'package:immich_mobile/shared/ui/photo_view/photo_view.dart'
show
PhotoViewScaleState,
PhotoViewHeroAttributes,
PhotoViewImageTapDownCallback,
PhotoViewImageTapUpCallback,
PhotoViewImageScaleEndCallback,
PhotoViewImageDragEndCallback,
PhotoViewImageDragStartCallback,
PhotoViewImageDragUpdateCallback,
PhotoViewImageLongPressStartCallback,
ScaleStateCycle;
import 'package:immich_mobile/shared/ui/photo_view/src/controller/photo_view_controller.dart';
import 'package:immich_mobile/shared/ui/photo_view/src/controller/photo_view_controller_delegate.dart';
import 'package:immich_mobile/shared/ui/photo_view/src/controller/photo_view_scalestate_controller.dart';
import 'package:immich_mobile/shared/ui/photo_view/src/core/photo_view_gesture_detector.dart';
import 'package:immich_mobile/shared/ui/photo_view/src/core/photo_view_hit_corners.dart';
import 'package:immich_mobile/shared/ui/photo_view/src/utils/photo_view_utils.dart';
const _defaultDecoration = BoxDecoration(
color: Color.fromRGBO(0, 0, 0, 1.0),
);
/// Internal widget in which controls all animations lifecycle, core responses
/// to user gestures, updates to the controller state and mounts the entire PhotoView Layout
class PhotoViewCore extends StatefulWidget {
const PhotoViewCore({
super.key,
required this.imageProvider,
required this.backgroundDecoration,
required this.gaplessPlayback,
required this.heroAttributes,
required this.enableRotation,
required this.onTapUp,
required this.onTapDown,
required this.onDragStart,
required this.onDragEnd,
required this.onDragUpdate,
required this.onScaleEnd,
required this.onLongPressStart,
required this.gestureDetectorBehavior,
required this.controller,
required this.scaleBoundaries,
required this.scaleStateCycle,
required this.scaleStateController,
required this.basePosition,
required this.tightMode,
required this.filterQuality,
required this.disableGestures,
required this.enablePanAlways,
}) : customChild = null;
const PhotoViewCore.customChild({
super.key,
required this.customChild,
required this.backgroundDecoration,
this.heroAttributes,
required this.enableRotation,
this.onTapUp,
this.onTapDown,
this.onDragStart,
this.onDragEnd,
this.onDragUpdate,
this.onScaleEnd,
this.onLongPressStart,
this.gestureDetectorBehavior,
required this.controller,
required this.scaleBoundaries,
required this.scaleStateCycle,
required this.scaleStateController,
required this.basePosition,
required this.tightMode,
required this.filterQuality,
required this.disableGestures,
required this.enablePanAlways,
}) : imageProvider = null,
gaplessPlayback = false;
final Decoration? backgroundDecoration;
final ImageProvider? imageProvider;
final bool? gaplessPlayback;
final PhotoViewHeroAttributes? heroAttributes;
final bool enableRotation;
final Widget? customChild;
final PhotoViewControllerBase controller;
final PhotoViewScaleStateController scaleStateController;
final ScaleBoundaries scaleBoundaries;
final ScaleStateCycle scaleStateCycle;
final Alignment basePosition;
final PhotoViewImageTapUpCallback? onTapUp;
final PhotoViewImageTapDownCallback? onTapDown;
final PhotoViewImageScaleEndCallback? onScaleEnd;
final PhotoViewImageDragStartCallback? onDragStart;
final PhotoViewImageDragEndCallback? onDragEnd;
final PhotoViewImageDragUpdateCallback? onDragUpdate;
final PhotoViewImageLongPressStartCallback? onLongPressStart;
final HitTestBehavior? gestureDetectorBehavior;
final bool tightMode;
final bool disableGestures;
final bool enablePanAlways;
final FilterQuality filterQuality;
@override
State<StatefulWidget> createState() {
return PhotoViewCoreState();
}
bool get hasCustomChild => customChild != null;
}
class PhotoViewCoreState extends State<PhotoViewCore>
with
TickerProviderStateMixin,
PhotoViewControllerDelegate,
HitCornersDetector {
Offset? _normalizedPosition;
double? _scaleBefore;
double? _rotationBefore;
late final AnimationController _scaleAnimationController;
Animation<double>? _scaleAnimation;
late final AnimationController _positionAnimationController;
Animation<Offset>? _positionAnimation;
late final AnimationController _rotationAnimationController =
AnimationController(vsync: this)..addListener(handleRotationAnimation);
Animation<double>? _rotationAnimation;
PhotoViewHeroAttributes? get heroAttributes => widget.heroAttributes;
late ScaleBoundaries cachedScaleBoundaries = widget.scaleBoundaries;
void handleScaleAnimation() {
scale = _scaleAnimation!.value;
}
void handlePositionAnimate() {
controller.position = _positionAnimation!.value;
}
void handleRotationAnimation() {
controller.rotation = _rotationAnimation!.value;
}
void onScaleStart(ScaleStartDetails details) {
_rotationBefore = controller.rotation;
_scaleBefore = scale;
_normalizedPosition = details.focalPoint - controller.position;
_scaleAnimationController.stop();
_positionAnimationController.stop();
_rotationAnimationController.stop();
}
void onScaleUpdate(ScaleUpdateDetails details) {
final double newScale = _scaleBefore! * details.scale;
final Offset delta = details.focalPoint - _normalizedPosition!;
updateScaleStateFromNewScale(newScale);
updateMultiple(
scale: newScale,
position: widget.enablePanAlways
? delta
: clampPosition(position: delta * details.scale),
rotation:
widget.enableRotation ? _rotationBefore! + details.rotation : null,
rotationFocusPoint: widget.enableRotation ? details.focalPoint : null,
);
}
void onScaleEnd(ScaleEndDetails details) {
final double s = scale;
final Offset p = controller.position;
final double maxScale = scaleBoundaries.maxScale;
final double minScale = scaleBoundaries.minScale;
widget.onScaleEnd?.call(context, details, controller.value);
//animate back to maxScale if gesture exceeded the maxScale specified
if (s > maxScale) {
final double scaleComebackRatio = maxScale / s;
animateScale(s, maxScale);
final Offset clampedPosition = clampPosition(
position: p * scaleComebackRatio,
scale: maxScale,
);
animatePosition(p, clampedPosition);
return;
}
//animate back to minScale if gesture fell smaller than the minScale specified
if (s < minScale) {
final double scaleComebackRatio = minScale / s;
animateScale(s, minScale);
animatePosition(
p,
clampPosition(
position: p * scaleComebackRatio,
scale: minScale,
),
);
return;
}
// get magnitude from gesture velocity
final double magnitude = details.velocity.pixelsPerSecond.distance;
// animate velocity only if there is no scale change and a significant magnitude
if (_scaleBefore! / s == 1.0 && magnitude >= 400.0) {
final Offset direction = details.velocity.pixelsPerSecond / magnitude;
animatePosition(
p,
clampPosition(position: p + direction * 100.0),
);
}
}
void onDoubleTap() {
nextScaleState();
}
void animateScale(double from, double to) {
_scaleAnimation = Tween<double>(
begin: from,
end: to,
).animate(_scaleAnimationController);
_scaleAnimationController
..value = 0.0
..fling(velocity: 0.4);
}
void animatePosition(Offset from, Offset to) {
_positionAnimation = Tween<Offset>(begin: from, end: to)
.animate(_positionAnimationController);
_positionAnimationController
..value = 0.0
..fling(velocity: 0.4);
}
void animateRotation(double from, double to) {
_rotationAnimation = Tween<double>(begin: from, end: to)
.animate(_rotationAnimationController);
_rotationAnimationController
..value = 0.0
..fling(velocity: 0.4);
}
void onAnimationStatus(AnimationStatus status) {
if (status == AnimationStatus.completed) {
onAnimationStatusCompleted();
}
}
/// Check if scale is equal to initial after scale animation update
void onAnimationStatusCompleted() {
if (scaleStateController.scaleState != PhotoViewScaleState.initial &&
scale == scaleBoundaries.initialScale) {
scaleStateController.setInvisibly(PhotoViewScaleState.initial);
}
}
@override
void initState() {
super.initState();
initDelegate();
addAnimateOnScaleStateUpdate(animateOnScaleStateUpdate);
cachedScaleBoundaries = widget.scaleBoundaries;
_scaleAnimationController = AnimationController(vsync: this)
..addListener(handleScaleAnimation)
..addStatusListener(onAnimationStatus);
_positionAnimationController = AnimationController(vsync: this)
..addListener(handlePositionAnimate);
}
void animateOnScaleStateUpdate(double prevScale, double nextScale) {
animateScale(prevScale, nextScale);
animatePosition(controller.position, Offset.zero);
animateRotation(controller.rotation, 0.0);
}
@override
void dispose() {
_scaleAnimationController.removeStatusListener(onAnimationStatus);
_scaleAnimationController.dispose();
_positionAnimationController.dispose();
_rotationAnimationController.dispose();
super.dispose();
}
void onTapUp(TapUpDetails details) {
widget.onTapUp?.call(context, details, controller.value);
}
void onTapDown(TapDownDetails details) {
widget.onTapDown?.call(context, details, controller.value);
}
@override
Widget build(BuildContext context) {
// Check if we need a recalc on the scale
if (widget.scaleBoundaries != cachedScaleBoundaries) {
markNeedsScaleRecalc = true;
cachedScaleBoundaries = widget.scaleBoundaries;
}
return StreamBuilder(
stream: controller.outputStateStream,
initialData: controller.prevValue,
builder: (
BuildContext context,
AsyncSnapshot<PhotoViewControllerValue> snapshot,
) {
if (snapshot.hasData) {
final PhotoViewControllerValue value = snapshot.data!;
final useImageScale = widget.filterQuality != FilterQuality.none;
final computedScale = useImageScale ? 1.0 : scale;
final matrix = Matrix4.identity()
..translate(value.position.dx, value.position.dy)
..scale(computedScale)
..rotateZ(value.rotation);
final Widget customChildLayout = CustomSingleChildLayout(
delegate: _CenterWithOriginalSizeDelegate(
scaleBoundaries.childSize,
basePosition,
useImageScale,
),
child: _buildHero(),
);
final child = Container(
constraints: widget.tightMode
? BoxConstraints.tight(scaleBoundaries.childSize * scale)
: null,
decoration: widget.backgroundDecoration ?? _defaultDecoration,
child: Center(
child: Transform(
transform: matrix,
alignment: basePosition,
child: customChildLayout,
),
),
);
if (widget.disableGestures) {
return child;
}
return PhotoViewGestureDetector(
onDoubleTap: nextScaleState,
onScaleStart: onScaleStart,
onScaleUpdate: onScaleUpdate,
onScaleEnd: onScaleEnd,
onDragStart: widget.onDragStart != null
? (details) => widget.onDragStart!(context, details, value)
: null,
onDragEnd: widget.onDragEnd != null
? (details) => widget.onDragEnd!(context, details, value)
: null,
onDragUpdate: widget.onDragUpdate != null
? (details) => widget.onDragUpdate!(context, details, value)
: null,
hitDetector: this,
onTapUp: widget.onTapUp != null
? (details) => widget.onTapUp!(context, details, value)
: null,
onTapDown: widget.onTapDown != null
? (details) => widget.onTapDown!(context, details, value)
: null,
onLongPressStart: widget.onLongPressStart != null
? (details) => widget.onLongPressStart!(context, details, value)
: null,
child: child,
);
} else {
return Container();
}
},
);
}
Widget _buildHero() {
return heroAttributes != null
? Hero(
tag: heroAttributes!.tag,
createRectTween: heroAttributes!.createRectTween,
flightShuttleBuilder: heroAttributes!.flightShuttleBuilder,
placeholderBuilder: heroAttributes!.placeholderBuilder,
transitionOnUserGestures: heroAttributes!.transitionOnUserGestures,
child: _buildChild(),
)
: _buildChild();
}
Widget _buildChild() {
return widget.hasCustomChild
? widget.customChild!
: Image(
image: widget.imageProvider!,
gaplessPlayback: widget.gaplessPlayback ?? false,
filterQuality: widget.filterQuality,
width: scaleBoundaries.childSize.width * scale,
fit: BoxFit.cover,
);
}
}
class _CenterWithOriginalSizeDelegate extends SingleChildLayoutDelegate {
const _CenterWithOriginalSizeDelegate(
this.subjectSize,
this.basePosition,
this.useImageScale,
);
final Size subjectSize;
final Alignment basePosition;
final bool useImageScale;
@override
Offset getPositionForChild(Size size, Size childSize) {
final childWidth = useImageScale ? childSize.width : subjectSize.width;
final childHeight = useImageScale ? childSize.height : subjectSize.height;
final halfWidth = (size.width - childWidth) / 2;
final halfHeight = (size.height - childHeight) / 2;
final double offsetX = halfWidth * (basePosition.x + 1);
final double offsetY = halfHeight * (basePosition.y + 1);
return Offset(offsetX, offsetY);
}
@override
BoxConstraints getConstraintsForChild(BoxConstraints constraints) {
return useImageScale
? const BoxConstraints()
: BoxConstraints.tight(subjectSize);
}
@override
bool shouldRelayout(_CenterWithOriginalSizeDelegate oldDelegate) {
return oldDelegate != this;
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is _CenterWithOriginalSizeDelegate &&
runtimeType == other.runtimeType &&
subjectSize == other.subjectSize &&
basePosition == other.basePosition &&
useImageScale == other.useImageScale;
@override
int get hashCode =>
subjectSize.hashCode ^ basePosition.hashCode ^ useImageScale.hashCode;
}

View file

@ -1,301 +0,0 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/widgets.dart';
import 'photo_view_hit_corners.dart';
/// Credit to [eduribas](https://github.com/eduribas/photo_view/commit/508d9b77dafbcf88045b4a7fee737eed4064ea2c)
/// for the gist
class PhotoViewGestureDetector extends StatelessWidget {
const PhotoViewGestureDetector({
super.key,
this.hitDetector,
this.onScaleStart,
this.onScaleUpdate,
this.onScaleEnd,
this.onDoubleTap,
this.onDragStart,
this.onDragEnd,
this.onDragUpdate,
this.onLongPressStart,
this.child,
this.onTapUp,
this.onTapDown,
this.behavior,
});
final GestureDoubleTapCallback? onDoubleTap;
final HitCornersDetector? hitDetector;
final GestureScaleStartCallback? onScaleStart;
final GestureScaleUpdateCallback? onScaleUpdate;
final GestureScaleEndCallback? onScaleEnd;
final GestureDragEndCallback? onDragEnd;
final GestureDragStartCallback? onDragStart;
final GestureDragUpdateCallback? onDragUpdate;
final GestureTapUpCallback? onTapUp;
final GestureTapDownCallback? onTapDown;
final GestureLongPressStartCallback? onLongPressStart;
final Widget? child;
final HitTestBehavior? behavior;
@override
Widget build(BuildContext context) {
final scope = PhotoViewGestureDetectorScope.of(context);
final Axis? axis = scope?.axis;
final touchSlopFactor = scope?.touchSlopFactor ?? 2;
final Map<Type, GestureRecognizerFactory> gestures =
<Type, GestureRecognizerFactory>{};
if (onTapDown != null || onTapUp != null) {
gestures[TapGestureRecognizer] =
GestureRecognizerFactoryWithHandlers<TapGestureRecognizer>(
() => TapGestureRecognizer(debugOwner: this),
(TapGestureRecognizer instance) {
instance
..onTapDown = onTapDown
..onTapUp = onTapUp;
},
);
}
if (onDragStart != null || onDragEnd != null || onDragUpdate != null) {
gestures[VerticalDragGestureRecognizer] =
GestureRecognizerFactoryWithHandlers<VerticalDragGestureRecognizer>(
() => VerticalDragGestureRecognizer(debugOwner: this),
(VerticalDragGestureRecognizer instance) {
instance
..onStart = onDragStart
..onUpdate = onDragUpdate
..onEnd = onDragEnd;
},
);
}
gestures[DoubleTapGestureRecognizer] =
GestureRecognizerFactoryWithHandlers<DoubleTapGestureRecognizer>(
() => DoubleTapGestureRecognizer(debugOwner: this),
(DoubleTapGestureRecognizer instance) {
instance.onDoubleTap = onDoubleTap;
},
);
gestures[PhotoViewGestureRecognizer] =
GestureRecognizerFactoryWithHandlers<PhotoViewGestureRecognizer>(
() => PhotoViewGestureRecognizer(
hitDetector: hitDetector,
debugOwner: this,
validateAxis: axis,
touchSlopFactor: touchSlopFactor,
),
(PhotoViewGestureRecognizer instance) {
instance
..onStart = onScaleStart
..onUpdate = onScaleUpdate
..onEnd = onScaleEnd;
},
);
gestures[LongPressGestureRecognizer] =
GestureRecognizerFactoryWithHandlers<LongPressGestureRecognizer>(
() => LongPressGestureRecognizer(debugOwner: this),
(LongPressGestureRecognizer instance) {
instance.onLongPressStart = onLongPressStart;
});
return RawGestureDetector(
behavior: behavior,
gestures: gestures,
child: child,
);
}
}
class PhotoViewGestureRecognizer extends ScaleGestureRecognizer {
PhotoViewGestureRecognizer({
this.hitDetector,
super.debugOwner,
this.validateAxis,
this.touchSlopFactor = 1,
PointerDeviceKind? kind,
}) : super(supportedDevices: null);
final HitCornersDetector? hitDetector;
final Axis? validateAxis;
final double touchSlopFactor;
Map<int, Offset> _pointerLocations = <int, Offset>{};
Offset? _initialFocalPoint;
Offset? _currentFocalPoint;
double? _initialSpan;
double? _currentSpan;
bool ready = true;
@override
void addAllowedPointer(PointerDownEvent event) {
if (ready) {
ready = false;
_pointerLocations = <int, Offset>{};
}
super.addAllowedPointer(event);
}
@override
void didStopTrackingLastPointer(int pointer) {
ready = true;
super.didStopTrackingLastPointer(pointer);
}
@override
void handleEvent(PointerEvent event) {
if (validateAxis != null) {
bool didChangeConfiguration = false;
if (event is PointerMoveEvent) {
if (!event.synthesized) {
_pointerLocations[event.pointer] = event.position;
}
} else if (event is PointerDownEvent) {
_pointerLocations[event.pointer] = event.position;
didChangeConfiguration = true;
} else if (event is PointerUpEvent || event is PointerCancelEvent) {
_pointerLocations.remove(event.pointer);
didChangeConfiguration = true;
}
_updateDistances();
if (didChangeConfiguration) {
// cf super._reconfigure
_initialFocalPoint = _currentFocalPoint;
_initialSpan = _currentSpan;
}
_decideIfWeAcceptEvent(event);
}
super.handleEvent(event);
}
void _updateDistances() {
// cf super._update
final int count = _pointerLocations.keys.length;
// Compute the focal point
Offset focalPoint = Offset.zero;
for (final int pointer in _pointerLocations.keys) {
focalPoint += _pointerLocations[pointer]!;
}
_currentFocalPoint =
count > 0 ? focalPoint / count.toDouble() : Offset.zero;
// Span is the average deviation from focal point. Horizontal and vertical
// spans are the average deviations from the focal point's horizontal and
// vertical coordinates, respectively.
double totalDeviation = 0.0;
for (final int pointer in _pointerLocations.keys) {
totalDeviation +=
(_currentFocalPoint! - _pointerLocations[pointer]!).distance;
}
_currentSpan = count > 0 ? totalDeviation / count : 0.0;
}
void _decideIfWeAcceptEvent(PointerEvent event) {
final move = _initialFocalPoint! - _currentFocalPoint!;
final bool shouldMove = validateAxis == Axis.vertical
? hitDetector!.shouldMove(move, Axis.vertical)
: hitDetector!.shouldMove(move, Axis.horizontal);
if (shouldMove || _pointerLocations.keys.length > 1) {
final double spanDelta = (_currentSpan! - _initialSpan!).abs();
final double focalPointDelta =
(_currentFocalPoint! - _initialFocalPoint!).distance;
// warning: do not compare `focalPointDelta` to `kPanSlop`
// `ScaleGestureRecognizer` uses `kPanSlop`, but `HorizontalDragGestureRecognizer` uses `kTouchSlop`
// and PhotoView recognizer may compete with the `HorizontalDragGestureRecognizer` from a containing `PageView`
// setting `touchSlopFactor` to 2 restores default `ScaleGestureRecognizer` behaviour as `kPanSlop = kTouchSlop * 2.0`
// setting `touchSlopFactor` in [0, 1] will allow this recognizer to accept the gesture before the one from `PageView`
if (spanDelta > kScaleSlop ||
focalPointDelta > kTouchSlop * touchSlopFactor) {
acceptGesture(event.pointer);
}
}
}
}
/// An [InheritedWidget] responsible to give a axis aware scope to [PhotoViewGestureRecognizer].
///
/// When using this, PhotoView will test if the content zoomed has hit edge every time user pinches,
/// if so, it will let parent gesture detectors win the gesture arena
///
/// Useful when placing PhotoView inside a gesture sensitive context,
/// such as [PageView], [Dismissible], [BottomSheet].
///
/// Usage example:
/// ```
/// PhotoViewGestureDetectorScope(
/// axis: Axis.vertical,
/// child: PhotoView(
/// imageProvider: AssetImage("assets/pudim.jpg"),
/// ),
/// );
/// ```
class PhotoViewGestureDetectorScope extends InheritedWidget {
const PhotoViewGestureDetectorScope({
super.key,
this.axis,
this.touchSlopFactor = .2,
required super.child,
});
static PhotoViewGestureDetectorScope? of(BuildContext context) {
final PhotoViewGestureDetectorScope? scope = context
.dependOnInheritedWidgetOfExactType<PhotoViewGestureDetectorScope>();
return scope;
}
final Axis? axis;
// in [0, 1[
// 0: most reactive but will not let tap recognizers accept gestures
// <1: less reactive but gives the most leeway to other recognizers
// 1: will not be able to compete with a `HorizontalDragGestureRecognizer` up the widget tree
final double touchSlopFactor;
@override
bool updateShouldNotify(PhotoViewGestureDetectorScope oldWidget) {
return axis != oldWidget.axis &&
touchSlopFactor != oldWidget.touchSlopFactor;
}
}
// `PageView` contains a `Scrollable` which sets up a `HorizontalDragGestureRecognizer`
// this recognizer will win in the gesture arena when the drag distance reaches `kTouchSlop`
// we cannot change that, but we can prevent the scrollable from panning until this threshold is reached
// and let other recognizers accept the gesture instead
class PhotoViewPageViewScrollPhysics extends ScrollPhysics {
const PhotoViewPageViewScrollPhysics({
this.touchSlopFactor = 0.1,
super.parent,
});
// in [0, 1]
// 0: most reactive but will not let PhotoView recognizers accept gestures
// 1: less reactive but gives the most leeway to PhotoView recognizers
final double touchSlopFactor;
@override
PhotoViewPageViewScrollPhysics applyTo(ScrollPhysics? ancestor) {
return PhotoViewPageViewScrollPhysics(
touchSlopFactor: touchSlopFactor,
parent: buildParent(ancestor),
);
}
@override
double get dragStartDistanceMotionThreshold => kTouchSlop * touchSlopFactor;
}

View file

@ -1,81 +0,0 @@
import 'package:flutter/widgets.dart';
import 'package:immich_mobile/shared/ui/photo_view/src/controller/photo_view_controller_delegate.dart'
show PhotoViewControllerDelegate;
mixin HitCornersDetector on PhotoViewControllerDelegate {
HitCorners _hitCornersX() {
final double childWidth = scaleBoundaries.childSize.width * scale;
final double screenWidth = scaleBoundaries.outerSize.width;
if (screenWidth >= childWidth) {
return const HitCorners(true, true);
}
final x = -position.dx;
final cornersX = this.cornersX();
return HitCorners(x <= cornersX.min, x >= cornersX.max);
}
HitCorners _hitCornersY() {
final double childHeight = scaleBoundaries.childSize.height * scale;
final double screenHeight = scaleBoundaries.outerSize.height;
if (screenHeight >= childHeight) {
return const HitCorners(true, true);
}
final y = -position.dy;
final cornersY = this.cornersY();
return HitCorners(y <= cornersY.min, y >= cornersY.max);
}
bool _shouldMoveAxis(
HitCorners hitCorners,
double mainAxisMove,
double crossAxisMove,
) {
if (mainAxisMove == 0) {
return false;
}
if (!hitCorners.hasHitAny) {
return true;
}
final axisBlocked = hitCorners.hasHitBoth ||
(hitCorners.hasHitMax ? mainAxisMove > 0 : mainAxisMove < 0);
if (axisBlocked) {
return false;
}
return true;
}
bool _shouldMoveX(Offset move) {
final hitCornersX = _hitCornersX();
final mainAxisMove = move.dx;
final crossAxisMove = move.dy;
return _shouldMoveAxis(hitCornersX, mainAxisMove, crossAxisMove);
}
bool _shouldMoveY(Offset move) {
final hitCornersY = _hitCornersY();
final mainAxisMove = move.dy;
final crossAxisMove = move.dx;
return _shouldMoveAxis(hitCornersY, mainAxisMove, crossAxisMove);
}
bool shouldMove(Offset move, Axis mainAxis) {
if (mainAxis == Axis.vertical) {
return _shouldMoveY(move);
}
return _shouldMoveX(move);
}
}
class HitCorners {
const HitCorners(this.hasHitMin, this.hasHitMax);
final bool hasHitMin;
final bool hasHitMax;
bool get hasHitAny => hasHitMin || hasHitMax;
bool get hasHitBoth => hasHitMin && hasHitMax;
}

View file

@ -1,36 +0,0 @@
/// A class that work as a enum. It overloads the operator `*` saving the double as a multiplier.
///
/// ```
/// PhotoViewComputedScale.contained * 2
/// ```
///
class PhotoViewComputedScale {
const PhotoViewComputedScale._internal(this._value, [this.multiplier = 1.0]);
final String _value;
final double multiplier;
@override
String toString() => 'Enum.$_value';
static const contained = PhotoViewComputedScale._internal('contained');
static const covered = PhotoViewComputedScale._internal('covered');
PhotoViewComputedScale operator *(double multiplier) {
return PhotoViewComputedScale._internal(_value, multiplier);
}
PhotoViewComputedScale operator /(double divider) {
return PhotoViewComputedScale._internal(_value, 1 / divider);
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is PhotoViewComputedScale &&
runtimeType == other.runtimeType &&
_value == other._value;
@override
int get hashCode => _value.hashCode;
}

View file

@ -1,44 +0,0 @@
import 'package:flutter/material.dart';
class PhotoViewDefaultError extends StatelessWidget {
const PhotoViewDefaultError({super.key, required this.decoration});
final BoxDecoration decoration;
@override
Widget build(BuildContext context) {
return DecoratedBox(
decoration: decoration,
child: Center(
child: Icon(
Icons.broken_image,
color: Colors.grey[400],
size: 40.0,
),
),
);
}
}
class PhotoViewDefaultLoading extends StatelessWidget {
const PhotoViewDefaultLoading({super.key, this.event});
final ImageChunkEvent? event;
@override
Widget build(BuildContext context) {
final expectedBytes = event?.expectedTotalBytes;
final loadedBytes = event?.cumulativeBytesLoaded;
final value = loadedBytes != null && expectedBytes != null
? loadedBytes / expectedBytes
: null;
return Center(
child: SizedBox(
width: 20.0,
height: 20.0,
child: CircularProgressIndicator(value: value),
),
);
}
}

View file

@ -1,12 +0,0 @@
/// A way to represent the step of the "doubletap gesture cycle" in which PhotoView is.
enum PhotoViewScaleState {
initial,
covering,
originalSize,
zoomedIn,
zoomedOut;
bool get isScaleStateZooming =>
this == PhotoViewScaleState.zoomedIn ||
this == PhotoViewScaleState.zoomedOut;
}

View file

@ -1,336 +0,0 @@
import 'package:flutter/widgets.dart';
import '../photo_view.dart';
import 'core/photo_view_core.dart';
import 'photo_view_default_widgets.dart';
import 'utils/photo_view_utils.dart';
class ImageWrapper extends StatefulWidget {
const ImageWrapper({
super.key,
required this.imageProvider,
required this.loadingBuilder,
required this.backgroundDecoration,
required this.gaplessPlayback,
required this.heroAttributes,
required this.scaleStateChangedCallback,
required this.enableRotation,
required this.controller,
required this.scaleStateController,
required this.maxScale,
required this.minScale,
required this.initialScale,
required this.basePosition,
required this.scaleStateCycle,
required this.onTapUp,
required this.onTapDown,
required this.onDragStart,
required this.onDragEnd,
required this.onDragUpdate,
required this.onScaleEnd,
required this.onLongPressStart,
required this.outerSize,
required this.gestureDetectorBehavior,
required this.tightMode,
required this.filterQuality,
required this.disableGestures,
required this.errorBuilder,
required this.enablePanAlways,
required this.index,
});
final ImageProvider imageProvider;
final LoadingBuilder? loadingBuilder;
final ImageErrorWidgetBuilder? errorBuilder;
final BoxDecoration backgroundDecoration;
final bool gaplessPlayback;
final PhotoViewHeroAttributes? heroAttributes;
final ValueChanged<PhotoViewScaleState>? scaleStateChangedCallback;
final bool enableRotation;
final dynamic maxScale;
final dynamic minScale;
final dynamic initialScale;
final PhotoViewControllerBase controller;
final PhotoViewScaleStateController scaleStateController;
final Alignment? basePosition;
final ScaleStateCycle? scaleStateCycle;
final PhotoViewImageTapUpCallback? onTapUp;
final PhotoViewImageTapDownCallback? onTapDown;
final PhotoViewImageDragStartCallback? onDragStart;
final PhotoViewImageDragEndCallback? onDragEnd;
final PhotoViewImageDragUpdateCallback? onDragUpdate;
final PhotoViewImageScaleEndCallback? onScaleEnd;
final PhotoViewImageLongPressStartCallback? onLongPressStart;
final Size outerSize;
final HitTestBehavior? gestureDetectorBehavior;
final bool? tightMode;
final FilterQuality? filterQuality;
final bool? disableGestures;
final bool? enablePanAlways;
final int index;
@override
createState() => _ImageWrapperState();
}
class _ImageWrapperState extends State<ImageWrapper> {
ImageStreamListener? _imageStreamListener;
ImageStream? _imageStream;
ImageChunkEvent? _loadingProgress;
ImageInfo? _imageInfo;
bool _loading = true;
Size? _imageSize;
Object? _lastException;
StackTrace? _lastStack;
@override
void dispose() {
super.dispose();
_stopImageStream();
}
@override
void didChangeDependencies() {
_resolveImage();
super.didChangeDependencies();
}
@override
void didUpdateWidget(ImageWrapper oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.imageProvider != oldWidget.imageProvider) {
_resolveImage();
}
}
// retrieve image from the provider
void _resolveImage() {
final ImageStream newStream = widget.imageProvider.resolve(
const ImageConfiguration(),
);
_updateSourceStream(newStream);
}
ImageStreamListener _getOrCreateListener() {
void handleImageChunk(ImageChunkEvent event) {
setState(() {
_loadingProgress = event;
_lastException = null;
});
}
void handleImageFrame(ImageInfo info, bool synchronousCall) {
setupCB() {
_imageSize = Size(
info.image.width.toDouble(),
info.image.height.toDouble(),
);
_loading = false;
_imageInfo = _imageInfo;
_loadingProgress = null;
_lastException = null;
_lastStack = null;
}
synchronousCall ? setupCB() : setState(setupCB);
}
void handleError(dynamic error, StackTrace? stackTrace) {
setState(() {
_loading = false;
_lastException = error;
_lastStack = stackTrace;
});
assert(() {
if (widget.errorBuilder == null) {
throw error;
}
return true;
}());
}
_imageStreamListener = ImageStreamListener(
handleImageFrame,
onChunk: handleImageChunk,
onError: handleError,
);
return _imageStreamListener!;
}
void _updateSourceStream(ImageStream newStream) {
if (_imageStream?.key == newStream.key) {
return;
}
_imageStream?.removeListener(_imageStreamListener!);
_imageStream = newStream;
_imageStream!.addListener(_getOrCreateListener());
}
void _stopImageStream() {
_imageStream?.removeListener(_imageStreamListener!);
}
@override
Widget build(BuildContext context) {
if (_loading) {
return _buildLoading(context);
}
if (_lastException != null) {
return _buildError(context);
}
final scaleBoundaries = ScaleBoundaries(
widget.minScale ?? 0.0,
widget.maxScale ?? double.infinity,
widget.initialScale ?? PhotoViewComputedScale.contained,
widget.outerSize,
_imageSize!,
);
return PhotoViewCore(
imageProvider: widget.imageProvider,
backgroundDecoration: widget.backgroundDecoration,
gaplessPlayback: widget.gaplessPlayback,
enableRotation: widget.enableRotation,
heroAttributes: widget.heroAttributes,
basePosition: widget.basePosition ?? Alignment.center,
controller: widget.controller,
scaleStateController: widget.scaleStateController,
scaleStateCycle: widget.scaleStateCycle ?? defaultScaleStateCycle,
scaleBoundaries: scaleBoundaries,
onTapUp: widget.onTapUp,
onTapDown: widget.onTapDown,
onDragStart: widget.onDragStart,
onDragEnd: widget.onDragEnd,
onDragUpdate: widget.onDragUpdate,
onScaleEnd: widget.onScaleEnd,
onLongPressStart: widget.onLongPressStart,
gestureDetectorBehavior: widget.gestureDetectorBehavior,
tightMode: widget.tightMode ?? false,
filterQuality: widget.filterQuality ?? FilterQuality.none,
disableGestures: widget.disableGestures ?? false,
enablePanAlways: widget.enablePanAlways ?? false,
);
}
Widget _buildLoading(BuildContext context) {
if (widget.loadingBuilder != null) {
return widget.loadingBuilder!(context, _loadingProgress, widget.index);
}
return PhotoViewDefaultLoading(
event: _loadingProgress,
);
}
Widget _buildError(
BuildContext context,
) {
if (widget.errorBuilder != null) {
return widget.errorBuilder!(context, _lastException!, _lastStack);
}
return PhotoViewDefaultError(
decoration: widget.backgroundDecoration,
);
}
}
class CustomChildWrapper extends StatelessWidget {
const CustomChildWrapper({
super.key,
this.child,
required this.childSize,
required this.backgroundDecoration,
this.heroAttributes,
this.scaleStateChangedCallback,
required this.enableRotation,
required this.controller,
required this.scaleStateController,
required this.maxScale,
required this.minScale,
required this.initialScale,
required this.basePosition,
required this.scaleStateCycle,
this.onTapUp,
this.onTapDown,
this.onDragStart,
this.onDragEnd,
this.onDragUpdate,
this.onScaleEnd,
this.onLongPressStart,
required this.outerSize,
this.gestureDetectorBehavior,
required this.tightMode,
required this.filterQuality,
required this.disableGestures,
required this.enablePanAlways,
});
final Widget? child;
final Size? childSize;
final Decoration backgroundDecoration;
final PhotoViewHeroAttributes? heroAttributes;
final ValueChanged<PhotoViewScaleState>? scaleStateChangedCallback;
final bool enableRotation;
final PhotoViewControllerBase controller;
final PhotoViewScaleStateController scaleStateController;
final dynamic maxScale;
final dynamic minScale;
final dynamic initialScale;
final Alignment? basePosition;
final ScaleStateCycle? scaleStateCycle;
final PhotoViewImageTapUpCallback? onTapUp;
final PhotoViewImageTapDownCallback? onTapDown;
final PhotoViewImageDragStartCallback? onDragStart;
final PhotoViewImageDragEndCallback? onDragEnd;
final PhotoViewImageDragUpdateCallback? onDragUpdate;
final PhotoViewImageScaleEndCallback? onScaleEnd;
final PhotoViewImageLongPressStartCallback? onLongPressStart;
final Size outerSize;
final HitTestBehavior? gestureDetectorBehavior;
final bool? tightMode;
final FilterQuality? filterQuality;
final bool? disableGestures;
final bool? enablePanAlways;
@override
Widget build(BuildContext context) {
final scaleBoundaries = ScaleBoundaries(
minScale ?? 0.0,
maxScale ?? double.infinity,
initialScale ?? PhotoViewComputedScale.contained,
outerSize,
childSize ?? outerSize,
);
return PhotoViewCore.customChild(
customChild: child,
backgroundDecoration: backgroundDecoration,
enableRotation: enableRotation,
heroAttributes: heroAttributes,
controller: controller,
scaleStateController: scaleStateController,
scaleStateCycle: scaleStateCycle ?? defaultScaleStateCycle,
basePosition: basePosition ?? Alignment.center,
scaleBoundaries: scaleBoundaries,
onTapUp: onTapUp,
onTapDown: onTapDown,
onDragStart: onDragStart,
onDragEnd: onDragEnd,
onDragUpdate: onDragUpdate,
onScaleEnd: onScaleEnd,
onLongPressStart: onLongPressStart,
gestureDetectorBehavior: gestureDetectorBehavior,
tightMode: tightMode ?? false,
filterQuality: filterQuality ?? FilterQuality.none,
disableGestures: disableGestures ?? false,
enablePanAlways: enablePanAlways ?? false,
);
}
}

View file

@ -1,109 +0,0 @@
import 'package:flutter/foundation.dart';
/// A [ChangeNotifier] that has a second collection of listeners: the ignorable ones
///
/// Those listeners will be fired when [notifyListeners] fires and will be ignored
/// when [notifySomeListeners] fires.
///
/// The common collection of listeners inherited from [ChangeNotifier] will be fired
/// every time.
class IgnorableChangeNotifier extends ChangeNotifier {
ObserverList<VoidCallback>? _ignorableListeners =
ObserverList<VoidCallback>();
bool _debugAssertNotDisposed() {
assert(() {
if (_ignorableListeners == null) {
AssertionError([
'A $runtimeType was used after being disposed.',
'Once you have called dispose() on a $runtimeType, it can no longer be used.',
]);
}
return true;
}());
return true;
}
@override
bool get hasListeners {
return super.hasListeners || (_ignorableListeners?.isNotEmpty ?? false);
}
void addIgnorableListener(listener) {
assert(_debugAssertNotDisposed());
_ignorableListeners!.add(listener);
}
void removeIgnorableListener(listener) {
assert(_debugAssertNotDisposed());
_ignorableListeners!.remove(listener);
}
@override
void dispose() {
_ignorableListeners = null;
super.dispose();
}
@protected
@override
@visibleForTesting
void notifyListeners() {
super.notifyListeners();
if (_ignorableListeners != null) {
final List<VoidCallback> localListeners =
List<VoidCallback>.from(_ignorableListeners!);
for (VoidCallback listener in localListeners) {
try {
if (_ignorableListeners!.contains(listener)) {
listener();
}
} catch (exception, stack) {
FlutterError.reportError(
FlutterErrorDetails(
exception: exception,
stack: stack,
library: 'Photoview library',
),
);
}
}
}
}
/// Ignores the ignoreables
@protected
void notifySomeListeners() {
super.notifyListeners();
}
}
/// Just like [ValueNotifier] except it extends [IgnorableChangeNotifier] which has
/// listeners that wont fire when [updateIgnoring] is called.
class IgnorableValueNotifier<T> extends IgnorableChangeNotifier
implements ValueListenable<T> {
IgnorableValueNotifier(this._value);
@override
T get value => _value;
T _value;
set value(T newValue) {
if (_value == newValue) {
return;
}
_value = newValue;
notifyListeners();
}
void updateIgnoring(T newValue) {
if (_value == newValue) {
return;
}
_value = newValue;
notifySomeListeners();
}
@override
String toString() => '${describeIdentity(this)}($value)';
}

View file

@ -1,28 +0,0 @@
import 'package:flutter/widgets.dart';
/// Data class that holds the attributes that are going to be passed to
/// [PhotoViewImageWrapper]'s [Hero].
class PhotoViewHeroAttributes {
const PhotoViewHeroAttributes({
required this.tag,
this.createRectTween,
this.flightShuttleBuilder,
this.placeholderBuilder,
this.transitionOnUserGestures = false,
});
/// Mirror to [Hero.tag]
final Object tag;
/// Mirror to [Hero.createRectTween]
final CreateRectTween? createRectTween;
/// Mirror to [Hero.flightShuttleBuilder]
final HeroFlightShuttleBuilder? flightShuttleBuilder;
/// Mirror to [Hero.placeholderBuilder]
final HeroPlaceholderBuilder? placeholderBuilder;
/// Mirror to [Hero.transitionOnUserGestures]
final bool transitionOnUserGestures;
}

View file

@ -1,145 +0,0 @@
import 'dart:math' as math;
import 'dart:ui' show Size;
import "package:immich_mobile/shared/ui/photo_view/src/photo_view_computed_scale.dart";
import 'package:immich_mobile/shared/ui/photo_view/src/photo_view_scale_state.dart';
/// Given a [PhotoViewScaleState], returns a scale value considering [scaleBoundaries].
double getScaleForScaleState(
PhotoViewScaleState scaleState,
ScaleBoundaries scaleBoundaries,
) {
switch (scaleState) {
case PhotoViewScaleState.initial:
case PhotoViewScaleState.zoomedIn:
case PhotoViewScaleState.zoomedOut:
return _clampSize(scaleBoundaries.initialScale, scaleBoundaries);
case PhotoViewScaleState.covering:
return _clampSize(
_scaleForCovering(
scaleBoundaries.outerSize,
scaleBoundaries.childSize,
),
scaleBoundaries,
);
case PhotoViewScaleState.originalSize:
return _clampSize(1.0, scaleBoundaries);
// Will never be reached
default:
return 0;
}
}
/// Internal class to wraps custom scale boundaries (min, max and initial)
/// Also, stores values regarding the two sizes: the container and teh child.
class ScaleBoundaries {
const ScaleBoundaries(
this._minScale,
this._maxScale,
this._initialScale,
this.outerSize,
this.childSize,
);
final dynamic _minScale;
final dynamic _maxScale;
final dynamic _initialScale;
final Size outerSize;
final Size childSize;
double get minScale {
assert(_minScale is double || _minScale is PhotoViewComputedScale);
if (_minScale == PhotoViewComputedScale.contained) {
return _scaleForContained(outerSize, childSize) *
(_minScale as PhotoViewComputedScale).multiplier; // ignore: avoid_as
}
if (_minScale == PhotoViewComputedScale.covered) {
return _scaleForCovering(outerSize, childSize) *
(_minScale as PhotoViewComputedScale).multiplier; // ignore: avoid_as
}
assert(_minScale >= 0.0);
return _minScale;
}
double get maxScale {
assert(_maxScale is double || _maxScale is PhotoViewComputedScale);
if (_maxScale == PhotoViewComputedScale.contained) {
return (_scaleForContained(outerSize, childSize) *
(_maxScale as PhotoViewComputedScale) // ignore: avoid_as
.multiplier)
.clamp(minScale, double.infinity);
}
if (_maxScale == PhotoViewComputedScale.covered) {
return (_scaleForCovering(outerSize, childSize) *
(_maxScale as PhotoViewComputedScale) // ignore: avoid_as
.multiplier)
.clamp(minScale, double.infinity);
}
return _maxScale.clamp(minScale, double.infinity);
}
double get initialScale {
assert(_initialScale is double || _initialScale is PhotoViewComputedScale);
if (_initialScale == PhotoViewComputedScale.contained) {
return _scaleForContained(outerSize, childSize) *
(_initialScale as PhotoViewComputedScale) // ignore: avoid_as
.multiplier;
}
if (_initialScale == PhotoViewComputedScale.covered) {
return _scaleForCovering(outerSize, childSize) *
(_initialScale as PhotoViewComputedScale) // ignore: avoid_as
.multiplier;
}
return _initialScale.clamp(minScale, maxScale);
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is ScaleBoundaries &&
runtimeType == other.runtimeType &&
_minScale == other._minScale &&
_maxScale == other._maxScale &&
_initialScale == other._initialScale &&
outerSize == other.outerSize &&
childSize == other.childSize;
@override
int get hashCode =>
_minScale.hashCode ^
_maxScale.hashCode ^
_initialScale.hashCode ^
outerSize.hashCode ^
childSize.hashCode;
}
double _scaleForContained(Size size, Size childSize) {
final double imageWidth = childSize.width;
final double imageHeight = childSize.height;
final double screenWidth = size.width;
final double screenHeight = size.height;
return math.min(screenWidth / imageWidth, screenHeight / imageHeight);
}
double _scaleForCovering(Size size, Size childSize) {
final double imageWidth = childSize.width;
final double imageHeight = childSize.height;
final double screenWidth = size.width;
final double screenHeight = size.height;
return math.max(screenWidth / imageWidth, screenHeight / imageHeight);
}
double _clampSize(double size, ScaleBoundaries scaleBoundaries) {
return size.clamp(scaleBoundaries.minScale, scaleBoundaries.maxScale);
}
/// Simple class to store a min and a max value
class CornersRange {
const CornersRange(this.min, this.max);
final double min;
final double max;
}

View file

@ -1,46 +0,0 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
// Error widget to be used in Scaffold when an AsyncError is received
class ScaffoldErrorBody extends StatelessWidget {
final bool withIcon;
final String? errorMsg;
const ScaffoldErrorBody({super.key, this.withIcon = true, this.errorMsg});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
"scaffold_body_error_occurred",
style: context.textTheme.displayMedium,
textAlign: TextAlign.center,
).tr(),
if (withIcon)
Center(
child: Padding(
padding: const EdgeInsets.only(top: 15),
child: Icon(
Icons.error_outline,
size: 100,
color: context.themeData.iconTheme.color?.withOpacity(0.5),
),
),
),
if (withIcon && errorMsg != null)
Padding(
padding: const EdgeInsets.all(20),
child: Text(
errorMsg!,
style: context.textTheme.displaySmall,
textAlign: TextAlign.center,
),
),
],
);
}
}

View file

@ -1,22 +0,0 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
class ShareDialog extends StatelessWidget {
const ShareDialog({super.key});
@override
Widget build(BuildContext context) {
return AlertDialog(
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
const CircularProgressIndicator(),
Container(
margin: const EdgeInsets.only(top: 12),
child: const Text('share_dialog_preparing').tr(),
),
],
),
);
}
}

View file

@ -1,48 +0,0 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/thumbnail_placeholder.dart';
import 'package:immich_mobile/shared/ui/fade_in_placeholder_image.dart';
import 'package:octo_image/octo_image.dart';
/// Simple set to show [OctoPlaceholder.circularProgressIndicator] as
/// placeholder and [OctoError.icon] as error.
OctoSet blurHashOrPlaceholder(
Uint8List? blurhash, {
BoxFit? fit,
Text? errorMessage,
}) {
return OctoSet(
placeholderBuilder: blurHashPlaceholderBuilder(blurhash, fit: fit),
errorBuilder: blurHashErrorBuilder(blurhash, fit: fit),
);
}
OctoPlaceholderBuilder blurHashPlaceholderBuilder(
Uint8List? blurhash, {
BoxFit? fit,
}) {
return (context) => blurhash == null
? const ThumbnailPlaceholder()
: FadeInPlaceholderImage(
placeholder: const ThumbnailPlaceholder(),
image: MemoryImage(blurhash),
fit: fit ?? BoxFit.cover,
);
}
OctoErrorBuilder blurHashErrorBuilder(
Uint8List? blurhash, {
BoxFit? fit,
Text? message,
IconData? icon,
Color? iconColor,
double? iconSize,
}) {
return OctoError.placeholderWithErrorIcon(
blurHashPlaceholderBuilder(blurhash, fit: fit),
message: message,
icon: icon,
iconColor: iconColor,
iconSize: iconSize,
);
}

View file

@ -1,68 +0,0 @@
import 'dart:typed_data';
final Uint8List kTransparentImage = Uint8List.fromList(<int>[
0x89,
0x50,
0x4E,
0x47,
0x0D,
0x0A,
0x1A,
0x0A,
0x00,
0x00,
0x00,
0x0D,
0x49,
0x48,
0x44,
0x52,
0x00,
0x00,
0x00,
0x01,
0x00,
0x00,
0x00,
0x01,
0x08,
0x06,
0x00,
0x00,
0x00,
0x1F,
0x15,
0xC4,
0x89,
0x00,
0x00,
0x00,
0x0A,
0x49,
0x44,
0x41,
0x54,
0x78,
0x9C,
0x63,
0x00,
0x01,
0x00,
0x00,
0x05,
0x00,
0x01,
0x0D,
0x0A,
0x2D,
0xB4,
0x00,
0x00,
0x00,
0x00,
0x49,
0x45,
0x4E,
0x44,
0xAE,
]);

View file

@ -1,23 +0,0 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/entities/user.entity.dart';
Widget userAvatar(BuildContext context, User u, {double? radius}) {
final url =
"${Store.get(StoreKey.serverEndpoint)}/user/profile-image/${u.id}";
final nameFirstLetter = u.name.isNotEmpty ? u.name[0] : "";
return CircleAvatar(
radius: radius,
backgroundColor: context.primaryColor.withAlpha(50),
foregroundImage: CachedNetworkImageProvider(
url,
headers: {"x-immich-user-token": Store.get(StoreKey.accessToken)},
cacheKey: "user-${u.id}-profile",
),
// silence errors if user has no profile image, use initials as fallback
onForegroundImageError: (exception, stackTrace) {},
child: Text(nameFirstLetter.toUpperCase()),
);
}

View file

@ -1,62 +0,0 @@
import 'dart:math';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/entities/user.entity.dart';
import 'package:immich_mobile/shared/ui/transparent_image.dart';
// ignore: must_be_immutable
class UserCircleAvatar extends ConsumerWidget {
final User user;
double radius;
double size;
UserCircleAvatar({
super.key,
this.radius = 22,
this.size = 44,
required this.user,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
bool isDarkTheme = Theme.of(context).brightness == Brightness.dark;
final profileImageUrl =
'${Store.get(StoreKey.serverEndpoint)}/user/profile-image/${user.id}?d=${Random().nextInt(1024)}';
final textIcon = Text(
user.name[0].toUpperCase(),
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 12,
color: isDarkTheme && user.avatarColor == AvatarColorEnum.primary
? Colors.black
: Colors.white,
),
);
return CircleAvatar(
backgroundColor: user.avatarColor.toColor(),
radius: radius,
child: user.profileImagePath.isEmpty
? textIcon
: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(50)),
child: CachedNetworkImage(
fit: BoxFit.cover,
cacheKey: user.profileImagePath,
width: size,
height: size,
placeholder: (_, __) => Image.memory(kTransparentImage),
imageUrl: profileImageUrl,
httpHeaders: {
"x-immich-user-token": Store.get(StoreKey.accessToken),
},
fadeInDuration: const Duration(milliseconds: 300),
errorWidget: (context, error, stackTrace) => textIcon,
),
),
);
}
}