Merge branch 'main' into feature/bottom-buttons-order

This commit is contained in:
idubnori
2026-01-10 01:12:32 +09:00
committed by GitHub
498 changed files with 23787 additions and 8282 deletions
+2 -2
View File
@@ -35,8 +35,8 @@ platform :android do
task: 'bundle',
build_type: 'Release',
properties: {
"android.injected.version.code" => 3028,
"android.injected.version.name" => "2.3.1",
"android.injected.version.code" => 3030,
"android.injected.version.name" => "2.4.1",
}
)
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')
+2 -1
View File
@@ -33,4 +33,5 @@ Runner/GeneratedPluginRegistrant.*
!default.perspectivev3
fastlane/report.xml
Gemfile.lock
Gemfile.lock
certs/
+1 -1
View File
@@ -80,7 +80,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2.2.1</string>
<string>2.4.1</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
+72 -28
View File
@@ -44,7 +44,7 @@ def get_version_from_pubspec
end
# Helper method to configure code signing for all targets
def configure_code_signing(bundle_id_suffix: "")
def configure_code_signing(bundle_id_suffix: "", profile_name_main:, profile_name_share:, profile_name_widget:)
bundle_suffix = bundle_id_suffix.empty? ? "" : ".#{bundle_id_suffix}"
# Runner (main app)
@@ -54,7 +54,7 @@ end
team_id: ENV["FASTLANE_TEAM_ID"] || TEAM_ID,
code_sign_identity: CODE_SIGN_IDENTITY,
bundle_identifier: "#{BASE_BUNDLE_ID}#{bundle_suffix}",
profile_name: "#{BASE_BUNDLE_ID}#{bundle_suffix} AppStore",
profile_name: profile_name_main,
targets: ["Runner"]
)
@@ -65,7 +65,7 @@ end
team_id: ENV["FASTLANE_TEAM_ID"] || TEAM_ID,
code_sign_identity: CODE_SIGN_IDENTITY,
bundle_identifier: "#{BASE_BUNDLE_ID}#{bundle_suffix}.ShareExtension",
profile_name: "#{BASE_BUNDLE_ID}#{bundle_suffix}.ShareExtension AppStore",
profile_name: profile_name_share,
targets: ["ShareExtension"]
)
@@ -76,7 +76,7 @@ end
team_id: ENV["FASTLANE_TEAM_ID"] || TEAM_ID,
code_sign_identity: CODE_SIGN_IDENTITY,
bundle_identifier: "#{BASE_BUNDLE_ID}#{bundle_suffix}.Widget",
profile_name: "#{BASE_BUNDLE_ID}#{bundle_suffix}.Widget AppStore",
profile_name: profile_name_widget,
targets: ["WidgetExtension"]
)
end
@@ -87,7 +87,10 @@ end
bundle_id_suffix: "",
configuration: "Release",
distribute_external: true,
version_number: nil
version_number: nil,
profile_name_main:,
profile_name_share:,
profile_name_widget:
)
bundle_suffix = bundle_id_suffix.empty? ? "" : ".#{bundle_id_suffix}"
app_identifier = "#{BASE_BUNDLE_ID}#{bundle_suffix}"
@@ -115,9 +118,9 @@ end
xcargs: "-skipMacroValidation CODE_SIGN_IDENTITY='#{CODE_SIGN_IDENTITY}' CODE_SIGN_STYLE=Manual",
export_options: {
provisioningProfiles: {
"#{app_identifier}" => "#{app_identifier} AppStore",
"#{app_identifier}.ShareExtension" => "#{app_identifier}.ShareExtension AppStore",
"#{app_identifier}.Widget" => "#{app_identifier}.Widget AppStore"
"#{app_identifier}" => profile_name_main,
"#{app_identifier}.ShareExtension" => profile_name_share,
"#{app_identifier}.Widget" => profile_name_widget
},
signingStyle: "manual",
signingCertificate: CODE_SIGN_IDENTITY
@@ -136,20 +139,35 @@ end
lane :gha_testflight_dev do
api_key = get_api_key
# Install development provisioning profiles
install_provisioning_profile(path: "profile_dev.mobileprovision")
install_provisioning_profile(path: "profile_dev_share.mobileprovision")
install_provisioning_profile(path: "profile_dev_widget.mobileprovision")
# Download and install provisioning profiles from App Store Connect
# Certificate is imported by GHA workflow into build.keychain
# Capture profile names after each sigh call
sigh(api_key: api_key, app_identifier: "#{BASE_BUNDLE_ID}.development", force: true)
main_profile_name = lane_context[SharedValues::SIGH_NAME]
# Configure code signing for dev bundle IDs
configure_code_signing(bundle_id_suffix: "development")
sigh(api_key: api_key, app_identifier: "#{BASE_BUNDLE_ID}.development.ShareExtension", force: true)
share_profile_name = lane_context[SharedValues::SIGH_NAME]
sigh(api_key: api_key, app_identifier: "#{BASE_BUNDLE_ID}.development.Widget", force: true)
widget_profile_name = lane_context[SharedValues::SIGH_NAME]
# Configure code signing for dev bundle IDs using the downloaded profile names
configure_code_signing(
bundle_id_suffix: "development",
profile_name_main: main_profile_name,
profile_name_share: share_profile_name,
profile_name_widget: widget_profile_name
)
# Build and upload
build_and_upload(
api_key: api_key,
bundle_id_suffix: "development",
configuration: "Profile",
distribute_external: false
distribute_external: false,
profile_name_main: main_profile_name,
profile_name_share: share_profile_name,
profile_name_widget: widget_profile_name
)
end
@@ -157,20 +175,33 @@ end
lane :gha_release_prod do
api_key = get_api_key
# Install provisioning profiles
install_provisioning_profile(path: "profile.mobileprovision")
install_provisioning_profile(path: "profile_share.mobileprovision")
install_provisioning_profile(path: "profile_widget.mobileprovision")
# Download and install provisioning profiles from App Store Connect
# Certificate is imported by GHA workflow into build.keychain
sigh(api_key: api_key, app_identifier: BASE_BUNDLE_ID, force: true)
main_profile_name = lane_context[SharedValues::SIGH_NAME]
sigh(api_key: api_key, app_identifier: "#{BASE_BUNDLE_ID}.ShareExtension", force: true)
share_profile_name = lane_context[SharedValues::SIGH_NAME]
sigh(api_key: api_key, app_identifier: "#{BASE_BUNDLE_ID}.Widget", force: true)
widget_profile_name = lane_context[SharedValues::SIGH_NAME]
# Configure code signing for production bundle IDs
configure_code_signing
configure_code_signing(
profile_name_main: main_profile_name,
profile_name_share: share_profile_name,
profile_name_widget: widget_profile_name
)
# Build and upload with version number
build_and_upload(
api_key: api_key,
version_number: get_version_from_pubspec,
distribute_external: false,
profile_name_main: main_profile_name,
profile_name_share: share_profile_name,
profile_name_widget: widget_profile_name
)
end
@@ -215,13 +246,26 @@ end
# Use the same build process as production, just skip the upload
# This ensures PR builds validate the same way as production builds
# Install provisioning profiles (use development profiles for PR builds)
install_provisioning_profile(path: "profile_dev.mobileprovision")
install_provisioning_profile(path: "profile_dev_share.mobileprovision")
install_provisioning_profile(path: "profile_dev_widget.mobileprovision")
api_key = get_api_key
# Download and install provisioning profiles from App Store Connect
# Certificate is imported by GHA workflow into build.keychain
sigh(api_key: api_key, app_identifier: "#{BASE_BUNDLE_ID}.development", force: true)
main_profile_name = lane_context[SharedValues::SIGH_NAME]
sigh(api_key: api_key, app_identifier: "#{BASE_BUNDLE_ID}.development.ShareExtension", force: true)
share_profile_name = lane_context[SharedValues::SIGH_NAME]
sigh(api_key: api_key, app_identifier: "#{BASE_BUNDLE_ID}.development.Widget", force: true)
widget_profile_name = lane_context[SharedValues::SIGH_NAME]
# Configure code signing for dev bundle IDs
configure_code_signing(bundle_id_suffix: "development")
configure_code_signing(
bundle_id_suffix: "development",
profile_name_main: main_profile_name,
profile_name_share: share_profile_name,
profile_name_widget: widget_profile_name
)
# Build the app (same as gha_testflight_dev but without upload)
build_app(
@@ -233,9 +277,9 @@ end
xcargs: "-skipMacroValidation CODE_SIGN_IDENTITY='#{CODE_SIGN_IDENTITY}' CODE_SIGN_STYLE=Manual",
export_options: {
provisioningProfiles: {
"#{BASE_BUNDLE_ID}.development" => "#{BASE_BUNDLE_ID}.development AppStore",
"#{BASE_BUNDLE_ID}.development.ShareExtension" => "#{BASE_BUNDLE_ID}.development.ShareExtension AppStore",
"#{BASE_BUNDLE_ID}.development.Widget" => "#{BASE_BUNDLE_ID}.development.Widget AppStore"
"#{BASE_BUNDLE_ID}.development" => main_profile_name,
"#{BASE_BUNDLE_ID}.development.ShareExtension" => share_profile_name,
"#{BASE_BUNDLE_ID}.development.Widget" => widget_profile_name
},
signingStyle: "manual",
signingCertificate: CODE_SIGN_IDENTITY
+4
View File
@@ -9,3 +9,7 @@ enum SortUserBy { id }
enum ActionSource { timeline, viewer }
enum ButtonPosition { bottomBar, kebabMenu, other }
enum CleanupStep { selectDate, filterOptions, scan, delete }
enum AssetFilterType { all, photosOnly, videosOnly }
+36 -30
View File
@@ -6,6 +6,8 @@ import 'package:immich_mobile/infrastructure/repositories/local_asset.repository
import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart';
import 'package:immich_mobile/infrastructure/utils/exif.converter.dart';
typedef _AssetVideoDimension = ({double? width, double? height, bool isFlipped});
class AssetService {
final RemoteAssetRepository _remoteAssetRepository;
final DriftLocalAssetRepository _localAssetRepository;
@@ -58,44 +60,48 @@ class AssetService {
}
Future<double> getAspectRatio(BaseAsset asset) async {
bool isFlipped;
double? width;
double? height;
final dimension = asset is LocalAsset
? await _getLocalAssetDimensions(asset)
: await _getRemoteAssetDimensions(asset as RemoteAsset);
if (asset.hasRemote) {
final exif = await getExif(asset);
isFlipped = ExifDtoConverter.isOrientationFlipped(exif?.orientation);
width = asset.width?.toDouble();
height = asset.height?.toDouble();
} else if (asset is LocalAsset) {
isFlipped = CurrentPlatform.isAndroid && (asset.orientation == 90 || asset.orientation == 270);
width = asset.width?.toDouble();
height = asset.height?.toDouble();
} else {
isFlipped = false;
if (dimension.width == null || dimension.height == null || dimension.height == 0) {
return 1.0;
}
return dimension.isFlipped ? dimension.height! / dimension.width! : dimension.width! / dimension.height!;
}
Future<_AssetVideoDimension> _getLocalAssetDimensions(LocalAsset asset) async {
double? width = asset.width?.toDouble();
double? height = asset.height?.toDouble();
int orientation = asset.orientation;
if (width == null || height == null) {
if (asset.hasRemote) {
final id = asset is LocalAsset ? asset.remoteId! : (asset as RemoteAsset).id;
final remoteAsset = await _remoteAssetRepository.get(id);
width = remoteAsset?.width?.toDouble();
height = remoteAsset?.height?.toDouble();
} else {
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).localId!;
final localAsset = await _localAssetRepository.get(id);
width = localAsset?.width?.toDouble();
height = localAsset?.height?.toDouble();
}
final fetched = await _localAssetRepository.get(asset.id);
width = fetched?.width?.toDouble();
height = fetched?.height?.toDouble();
orientation = fetched?.orientation ?? 0;
}
final orientedWidth = isFlipped ? height : width;
final orientedHeight = isFlipped ? width : height;
if (orientedWidth != null && orientedHeight != null && orientedHeight > 0) {
return orientedWidth / orientedHeight;
// On Android, local assets need orientation correction for 90°/270° rotations
// On iOS, the Photos framework pre-corrects dimensions
final isFlipped = CurrentPlatform.isAndroid && (orientation == 90 || orientation == 270);
return (width: width, height: height, isFlipped: isFlipped);
}
Future<_AssetVideoDimension> _getRemoteAssetDimensions(RemoteAsset asset) async {
double? width = asset.width?.toDouble();
double? height = asset.height?.toDouble();
if (width == null || height == null) {
final fetched = await _remoteAssetRepository.get(asset.id);
width = fetched?.width?.toDouble();
height = fetched?.height?.toDouble();
}
return 1.0;
final exif = await getExif(asset);
final isFlipped = ExifDtoConverter.isOrientationFlipped(exif?.orientation);
return (width: width, height: height, isFlipped: isFlipped);
}
Future<List<(String, String)>> getPlaces(String userId) {
@@ -360,6 +360,7 @@ extension on Iterable<PlatformAlbum> {
name: e.name,
updatedAt: tryFromSecondsSinceEpoch(e.updatedAt, isUtc: true) ?? DateTime.timestamp(),
assetCount: e.assetCount,
isIosSharedAlbum: e.isCloud,
),
).toList();
}
@@ -79,6 +79,9 @@ class TimelineFactory {
TimelineService fromAssets(List<BaseAsset> assets, TimelineOrigin type) =>
TimelineService(_timelineRepository.fromAssets(assets, type));
TimelineService fromAssetsWithBuckets(List<BaseAsset> assets, TimelineOrigin type) =>
TimelineService(_timelineRepository.fromAssetsWithBuckets(assets, type));
TimelineService map(String userId, LatLngBounds bounds) =>
TimelineService(_timelineRepository.map(userId, bounds, groupBy));
}
@@ -33,6 +33,7 @@ extension LocalAlbumEntityDataHelper on LocalAlbumEntityData {
assetCount: assetCount,
backupSelection: backupSelection,
linkedRemoteAlbumId: linkedRemoteAlbumId,
isIosSharedAlbum: isIosSharedAlbum,
);
}
}
@@ -1,6 +1,7 @@
import 'package:collection/collection.dart';
import 'package:drift/drift.dart';
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/infrastructure/entities/local_album.entity.dart';
@@ -126,4 +127,49 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository {
}
return result;
}
Future<List<LocalAsset>> getRemovalCandidates(
String userId,
DateTime cutoffDate, {
AssetFilterType filterType = AssetFilterType.all,
bool keepFavorites = true,
}) async {
final iosSharedAlbumAssets = _db.localAlbumAssetEntity.selectOnly()
..addColumns([_db.localAlbumAssetEntity.assetId])
..join([
innerJoin(
_db.localAlbumEntity,
_db.localAlbumAssetEntity.albumId.equalsExp(_db.localAlbumEntity.id),
useColumns: false,
),
])
..where(_db.localAlbumEntity.isIosSharedAlbum.equals(true));
final query = _db.localAssetEntity.select().join([
innerJoin(_db.remoteAssetEntity, _db.localAssetEntity.checksum.equalsExp(_db.remoteAssetEntity.checksum)),
]);
Expression<bool> whereClause =
_db.localAssetEntity.createdAt.isSmallerOrEqualValue(cutoffDate) &
_db.remoteAssetEntity.ownerId.equals(userId) &
_db.remoteAssetEntity.deletedAt.isNull();
// Exclude assets that are in iOS shared albums
whereClause = whereClause & _db.localAssetEntity.id.isNotInQuery(iosSharedAlbumAssets);
if (filterType == AssetFilterType.photosOnly) {
whereClause = whereClause & _db.localAssetEntity.type.equalsValue(AssetType.image);
} else if (filterType == AssetFilterType.videosOnly) {
whereClause = whereClause & _db.localAssetEntity.type.equalsValue(AssetType.video);
}
if (keepFavorites) {
whereClause = whereClause & _db.localAssetEntity.isFavorite.equals(false);
}
query.where(whereClause);
final rows = await query.get();
return rows.map((row) => row.readTable(_db.localAssetEntity).toDto()).toList();
}
}
@@ -253,6 +253,24 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
origin: origin,
);
TimelineQuery fromAssetsWithBuckets(List<BaseAsset> assets, TimelineOrigin origin) {
// Sort assets by date descending and group by day
final sorted = List<BaseAsset>.from(assets)..sort((a, b) => b.createdAt.compareTo(a.createdAt));
final Map<DateTime, int> bucketCounts = {};
for (final asset in sorted) {
final date = DateTime(asset.createdAt.year, asset.createdAt.month, asset.createdAt.day);
bucketCounts[date] = (bucketCounts[date] ?? 0) + 1;
}
final buckets = bucketCounts.entries.map((e) => TimeBucket(date: e.key, assetCount: e.value)).toList();
return (
bucketSource: () => Stream.value(buckets),
assetSource: (offset, count) => Future.value(sorted.skip(offset).take(count).toList(growable: false)),
origin: origin,
);
}
TimelineQuery remote(String ownerId, GroupAssetsBy groupBy) => _remoteQueryBuilder(
filter: (row) =>
row.deletedAt.isNull() & row.visibility.equalsValue(AssetVisibility.timeline) & row.ownerId.equals(ownerId),
+8
View File
@@ -42,6 +42,7 @@ import 'package:immich_mobile/utils/http_ssl_options.dart';
import 'package:immich_mobile/utils/licenses.dart';
import 'package:immich_mobile/utils/migration.dart';
import 'package:immich_mobile/wm_executor.dart';
import 'package:immich_ui/immich_ui.dart';
import 'package:intl/date_symbol_data_local.dart';
import 'package:logging/logging.dart';
import 'package:timezone/data/latest.dart';
@@ -252,6 +253,13 @@ class ImmichAppState extends ConsumerState<ImmichApp> with WidgetsBindingObserve
themeMode: ref.watch(immichThemeModeProvider),
darkTheme: getThemeData(colorScheme: immichTheme.dark, locale: context.locale),
theme: getThemeData(colorScheme: immichTheme.light, locale: context.locale),
builder: (context, child) => ImmichTranslationProvider(
translations: ImmichTranslations(
submit: "submit".t(context: context),
password: "password".t(context: context),
),
child: ImmichThemeProvider(colorScheme: context.colorScheme, child: child!),
),
routerConfig: router.config(
deepLinkBuilder: _deepLinkBuilder,
navigatorObservers: () => [AppNavigationObserver(ref: ref)],
@@ -1,5 +1,4 @@
import 'dart:async';
import 'dart:io';
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
@@ -242,8 +241,7 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState<DriftBackupAlbum
),
),
if (Platform.isAndroid)
_SelectAllButton(filteredAlbums: filteredAlbums, selectedBackupAlbums: selectedBackupAlbums),
_SelectAllButton(filteredAlbums: filteredAlbums, selectedBackupAlbums: selectedBackupAlbums),
],
),
),
@@ -12,6 +12,7 @@ import 'package:immich_mobile/widgets/settings/asset_viewer_settings/asset_viewe
import 'package:immich_mobile/widgets/settings/backup_settings/backup_settings.dart';
import 'package:immich_mobile/widgets/settings/backup_settings/drift_backup_settings.dart';
import 'package:immich_mobile/widgets/settings/beta_sync_settings/sync_status_and_actions.dart';
import 'package:immich_mobile/widgets/settings/free_up_space_settings.dart';
import 'package:immich_mobile/widgets/settings/language_settings.dart';
import 'package:immich_mobile/widgets/settings/networking_settings/networking_settings.dart';
import 'package:immich_mobile/widgets/settings/notification_setting.dart';
@@ -22,6 +23,7 @@ enum SettingSection {
advanced('advanced', Icons.build_outlined, "advanced_settings_tile_subtitle"),
assetViewer('asset_viewer_settings_title', Icons.image_outlined, "asset_viewer_settings_subtitle"),
backup('backup', Icons.cloud_upload_outlined, "backup_settings_subtitle"),
freeUpSpace('free_up_space', Icons.cleaning_services_outlined, "free_up_space_settings_subtitle"),
languages('language', Icons.language, "setting_languages_subtitle"),
networking('networking_settings', Icons.wifi, "networking_subtitle"),
notifications('notifications', Icons.notifications_none_rounded, "setting_notifications_subtitle"),
@@ -38,6 +40,7 @@ enum SettingSection {
SettingSection.assetViewer => const AssetViewerSettings(),
SettingSection.backup =>
Store.tryGet(StoreKey.betaTimeline) ?? false ? const DriftBackupSettings() : const BackupSettings(),
SettingSection.freeUpSpace => const FreeUpSpaceSettings(),
SettingSection.languages => const LanguageSettings(),
SettingSection.networking => const NetworkingSettings(),
SettingSection.notifications => const NotificationSetting(),
@@ -0,0 +1,42 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
@RoutePage()
class CleanupPreviewPage extends StatelessWidget {
final List<LocalAsset> assets;
const CleanupPreviewPage({super.key, required this.assets});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('cleanup_preview_title'.t(context: context, args: {'count': assets.length.toString()})),
centerTitle: true,
elevation: 0,
scrolledUnderElevation: 0,
backgroundColor: context.colorScheme.surface,
),
body: ProviderScope(
overrides: [
timelineServiceProvider.overrideWith((ref) {
final timelineService = ref
.watch(timelineFactoryProvider)
.fromAssetsWithBuckets(assets.cast<BaseAsset>(), TimelineOrigin.search);
ref.onDispose(timelineService.dispose);
return timelineService;
}),
],
child: const Timeline(appBar: null, bottomSheet: null, groupBy: GroupAssetsBy.day, readOnly: true),
),
);
}
}
@@ -19,6 +19,17 @@ List<Widget> _showcaseBuilder(Function(ImmichVariant variant, ImmichColor color)
return children;
}
class _ComponentTitle extends StatelessWidget {
final String title;
const _ComponentTitle(this.title);
@override
Widget build(BuildContext context) {
return Text(title, style: context.textTheme.titleLarge);
}
}
@RoutePage()
class ImmichUIShowcasePage extends StatelessWidget {
const ImmichUIShowcasePage({super.key});
@@ -35,13 +46,51 @@ class ImmichUIShowcasePage extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text("IconButton", style: context.textTheme.titleLarge),
const _ComponentTitle("IconButton"),
..._showcaseBuilder(
(variant, color) =>
ImmichIconButton(icon: Icons.favorite, color: color, variant: variant, onTap: () {}),
ImmichIconButton(icon: Icons.favorite, color: color, variant: variant, onPressed: () {}),
),
const _ComponentTitle("CloseButton"),
..._showcaseBuilder(
(variant, color) => ImmichCloseButton(color: color, variant: variant, onPressed: () {}),
),
const _ComponentTitle("TextButton"),
ImmichTextButton(
labelText: "Text Button",
onPressed: () {},
variant: ImmichVariant.filled,
color: ImmichColor.primary,
),
ImmichTextButton(
labelText: "Text Button",
onPressed: () {},
variant: ImmichVariant.filled,
color: ImmichColor.primary,
loading: true,
),
ImmichTextButton(
labelText: "Text Button",
onPressed: () {},
variant: ImmichVariant.ghost,
color: ImmichColor.primary,
),
ImmichTextButton(
labelText: "Text Button",
onPressed: () {},
variant: ImmichVariant.ghost,
color: ImmichColor.primary,
loading: true,
),
const _ComponentTitle("Form"),
ImmichForm(
onSubmit: () {},
child: const Column(
spacing: 10,
children: [ImmichTextInput(label: "Title", hintText: "Enter a title")],
),
),
Text("CloseButton", style: context.textTheme.titleLarge),
..._showcaseBuilder((variant, color) => ImmichCloseButton(color: color, variant: variant, onTap: () {})),
],
),
),
@@ -171,67 +171,6 @@ class _RemoteAlbumPageState extends ConsumerState<RemoteAlbumPage> {
unawaited(context.pushRoute(DriftActivitiesRoute(album: _album)));
}
Future<void> showOptionSheet(BuildContext context) async {
final user = ref.watch(currentUserProvider);
final isOwner = user != null ? user.id == _album.ownerId : false;
final canAddPhotos =
await ref.read(remoteAlbumServiceProvider).getUserRole(_album.id, user?.id ?? '') == AlbumUserRole.editor;
unawaited(
showModalBottomSheet(
context: context,
backgroundColor: context.colorScheme.surface,
isScrollControlled: false,
builder: (context) {
return DriftRemoteAlbumOption(
onDeleteAlbum: isOwner
? () async {
await deleteAlbum(context);
if (context.mounted) {
context.pop();
}
}
: null,
onAddUsers: isOwner
? () async {
await addUsers(context);
context.pop();
}
: null,
onAddPhotos: isOwner || canAddPhotos
? () async {
await addAssets(context);
context.pop();
}
: null,
onToggleAlbumOrder: isOwner
? () async {
await toggleAlbumOrder();
context.pop();
}
: null,
onEditAlbum: isOwner
? () async {
context.pop();
await showEditTitleAndDescription(context);
}
: null,
onCreateSharedLink: isOwner
? () async {
context.pop();
unawaited(context.pushRoute(SharedLinkEditRoute(albumId: _album.id)));
}
: null,
onShowOptions: () {
context.pop();
context.pushRoute(DriftAlbumOptionsRoute(album: _album));
},
);
},
),
);
}
@override
Widget build(BuildContext context) {
final user = ref.watch(currentUserProvider);
@@ -249,8 +188,16 @@ class _RemoteAlbumPageState extends ConsumerState<RemoteAlbumPage> {
child: Timeline(
appBar: RemoteAlbumSliverAppBar(
icon: Icons.photo_album_outlined,
onShowOptions: () => showOptionSheet(context),
onToggleAlbumOrder: isOwner ? () => toggleAlbumOrder() : null,
kebabMenu: _AlbumKebabMenu(
album: _album,
onDeleteAlbum: () => deleteAlbum(context),
onAddUsers: () => addUsers(context),
onAddPhotos: () => addAssets(context),
onToggleAlbumOrder: () => toggleAlbumOrder(),
onEditAlbum: () => showEditTitleAndDescription(context),
onCreateSharedLink: () => unawaited(context.pushRoute(SharedLinkEditRoute(albumId: _album.id))),
onShowOptions: () => context.pushRoute(DriftAlbumOptionsRoute(album: _album)),
),
onEditTitle: isOwner ? () => showEditTitleAndDescription(context) : null,
onActivity: () => showActivity(context),
),
@@ -414,3 +361,77 @@ class _EditAlbumDialogState extends ConsumerState<_EditAlbumDialog> {
);
}
}
class _AlbumKebabMenu extends ConsumerWidget {
final RemoteAlbum album;
final VoidCallback? onDeleteAlbum;
final VoidCallback? onAddUsers;
final VoidCallback? onAddPhotos;
final VoidCallback? onToggleAlbumOrder;
final VoidCallback? onEditAlbum;
final VoidCallback? onCreateSharedLink;
final VoidCallback? onShowOptions;
const _AlbumKebabMenu({
required this.album,
this.onDeleteAlbum,
this.onAddUsers,
this.onAddPhotos,
this.onToggleAlbumOrder,
this.onEditAlbum,
this.onCreateSharedLink,
this.onShowOptions,
});
double _calculateScrollProgress(FlexibleSpaceBarSettings? settings) {
if (settings?.maxExtent == null || settings?.minExtent == null) {
return 1.0;
}
final deltaExtent = settings!.maxExtent - settings.minExtent;
if (deltaExtent <= 0.0) {
return 1.0;
}
return (1.0 - (settings.currentExtent - settings.minExtent) / deltaExtent).clamp(0.0, 1.0);
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final settings = context.dependOnInheritedWidgetOfExactType<FlexibleSpaceBarSettings>();
final scrollProgress = _calculateScrollProgress(settings);
final iconColor = Color.lerp(Colors.white, context.primaryColor, scrollProgress);
final iconShadows = [
if (scrollProgress < 0.95)
Shadow(offset: const Offset(0, 2), blurRadius: 5, color: Colors.black.withValues(alpha: 0.5))
else
const Shadow(offset: Offset(0, 2), blurRadius: 0, color: Colors.transparent),
];
final user = ref.watch(currentUserProvider);
final isOwner = user != null && user.id == album.ownerId;
return FutureBuilder<bool>(
future: ref
.read(remoteAlbumServiceProvider)
.getUserRole(album.id, user?.id ?? '')
.then((role) => role == AlbumUserRole.editor),
builder: (context, snapshot) {
final canAddPhotos = snapshot.data ?? false;
return DriftRemoteAlbumOption(
iconColor: iconColor,
iconShadows: iconShadows,
onDeleteAlbum: isOwner ? onDeleteAlbum : null,
onAddUsers: isOwner ? onAddUsers : null,
onAddPhotos: isOwner || canAddPhotos ? onAddPhotos : null,
onToggleAlbumOrder: isOwner ? onToggleAlbumOrder : null,
onEditAlbum: isOwner ? onEditAlbum : null,
onCreateSharedLink: isOwner ? onCreateSharedLink : null,
onShowOptions: onShowOptions,
);
},
);
}
}
@@ -37,7 +37,7 @@ class DriftCropImagePage extends HookWidget {
icon: Icons.done_rounded,
color: ImmichColor.primary,
variant: ImmichVariant.ghost,
onTap: () async {
onPressed: () async {
final croppedImage = await cropController.croppedImage();
unawaited(context.pushRoute(DriftEditImageRoute(asset: asset, image: croppedImage, isEdited: true)));
},
@@ -79,13 +79,13 @@ class DriftCropImagePage extends HookWidget {
icon: Icons.rotate_left,
variant: ImmichVariant.ghost,
color: ImmichColor.secondary,
onTap: () => cropController.rotateLeft(),
onPressed: () => cropController.rotateLeft(),
),
ImmichIconButton(
icon: Icons.rotate_right,
variant: ImmichVariant.ghost,
color: ImmichColor.secondary,
onTap: () => cropController.rotateRight(),
onPressed: () => cropController.rotateRight(),
),
],
),
@@ -53,7 +53,7 @@ class BaseActionButton extends ConsumerWidget {
style: MenuItemButton.styleFrom(alignment: Alignment.centerLeft, padding: const EdgeInsets.all(16)),
leadingIcon: Icon(iconData, color: effectiveIconColor),
onPressed: onPressed,
child: Text(label, style: theme.textTheme.labelLarge?.copyWith(fontSize: 16)),
child: Text(label, style: theme.textTheme.labelLarge?.copyWith(fontSize: 16, color: iconColor)),
);
}
@@ -92,6 +92,8 @@ class AssetViewer extends ConsumerStatefulWidget {
if (asset.isVideo || asset.isMotionPhoto) {
ref.read(videoPlaybackValueProvider.notifier).reset();
ref.read(videoPlayerControlsProvider.notifier).pause();
// Hide controls by default for videos and motion photos
ref.read(assetViewerProvider.notifier).setControls(false);
}
}
}
@@ -525,7 +527,13 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
void _onScaleStateChanged(PhotoViewScaleState scaleState) {
if (scaleState != PhotoViewScaleState.initial) {
ref.read(assetViewerProvider.notifier).setControls(false);
ref.read(videoPlayerControlsProvider.notifier).pause();
return;
}
if (!showingBottomSheet) {
ref.read(assetViewerProvider.notifier).setControls(true);
}
}
@@ -119,7 +119,7 @@ class _GeneralBottomSheetState extends ConsumerState<GeneralBottomSheet> {
const MoveToLockFolderActionButton(source: ActionSource.timeline),
if (multiselect.selectedAssets.length > 1) const StackActionButton(source: ActionSource.timeline),
if (multiselect.hasStacked) const UnStackActionButton(source: ActionSource.timeline),
const DeleteActionButton(source: ActionSource.timeline),
if (multiselect.hasLocal || multiselect.hasMerged) const DeleteActionButton(source: ActionSource.timeline),
],
if (multiselect.hasLocal || multiselect.hasMerged) const DeleteLocalActionButton(source: ActionSource.timeline),
if (multiselect.hasLocal) const UploadActionButton(source: ActionSource.timeline),
@@ -12,6 +12,8 @@ import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/map_bottom_sheet.widget.dart';
import 'package:immich_mobile/presentation/widgets/map/map.state.dart';
import 'package:immich_mobile/presentation/widgets/map/map_utils.dart';
import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/async_mutex.dart';
import 'package:immich_mobile/utils/debounce.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
@@ -114,6 +116,14 @@ class _DriftMapState extends ConsumerState<DriftMap> {
return;
}
// When the AssetViewer is open, the DriftMap route stays alive in the background.
// If we continue to update bounds, the map-scoped timeline service gets recreated and the previous one disposed,
// which can invalidate the TimelineService instance that was passed into AssetViewerRoute (causing "loading forever").
final currentRoute = ref.read(currentRouteNameProvider);
if (currentRoute == AssetViewerRoute.name || currentRoute == GalleryViewerRoute.name) {
return;
}
final bounds = await controller.getVisibleRegion();
unawaited(
_reloadMutex.run(() async {
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
class DriftRemoteAlbumOption extends ConsumerWidget {
const DriftRemoteAlbumOption({
@@ -14,6 +15,8 @@ class DriftRemoteAlbumOption extends ConsumerWidget {
this.onToggleAlbumOrder,
this.onEditAlbum,
this.onShowOptions,
this.iconColor,
this.iconShadows,
});
final VoidCallback? onAddPhotos;
@@ -24,73 +27,131 @@ class DriftRemoteAlbumOption extends ConsumerWidget {
final VoidCallback? onToggleAlbumOrder;
final VoidCallback? onEditAlbum;
final VoidCallback? onShowOptions;
final Color? iconColor;
final List<Shadow>? iconShadows;
@override
Widget build(BuildContext context, WidgetRef ref) {
TextStyle textStyle = Theme.of(context).textTheme.bodyLarge!.copyWith(fontWeight: FontWeight.w600);
final theme = context.themeData;
final menuChildren = <Widget>[];
return SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 24.0),
child: ListView(
shrinkWrap: true,
children: [
if (onEditAlbum != null)
ListTile(
leading: const Icon(Icons.edit),
title: Text('edit_album'.t(context: context), style: textStyle),
onTap: onEditAlbum,
),
if (onAddPhotos != null)
ListTile(
leading: const Icon(Icons.add_a_photo),
title: Text('add_photos'.t(context: context), style: textStyle),
onTap: onAddPhotos,
),
if (onAddUsers != null)
ListTile(
leading: const Icon(Icons.group_add),
title: Text('album_viewer_page_share_add_users'.t(context: context), style: textStyle),
onTap: onAddUsers,
),
if (onLeaveAlbum != null)
ListTile(
leading: const Icon(Icons.person_remove_rounded),
title: Text('leave_album'.t(context: context), style: textStyle),
onTap: onLeaveAlbum,
),
if (onToggleAlbumOrder != null)
ListTile(
leading: const Icon(Icons.swap_vert_rounded),
title: Text('change_display_order'.t(context: context), style: textStyle),
onTap: onToggleAlbumOrder,
),
if (onCreateSharedLink != null)
ListTile(
leading: const Icon(Icons.link),
title: Text('create_shared_link'.t(context: context), style: textStyle),
onTap: onCreateSharedLink,
),
if (onShowOptions != null)
ListTile(
leading: const Icon(Icons.settings),
title: Text('options'.t(context: context), style: textStyle),
onTap: onShowOptions,
),
if (onDeleteAlbum != null) ...[
const Divider(indent: 16, endIndent: 16),
ListTile(
leading: Icon(Icons.delete, color: context.isDarkTheme ? Colors.red[400] : Colors.red[800]),
title: Text(
'delete_album'.t(context: context),
style: textStyle.copyWith(color: context.isDarkTheme ? Colors.red[400] : Colors.red[800]),
),
onTap: onDeleteAlbum,
),
],
],
if (onEditAlbum != null) {
menuChildren.add(
BaseActionButton(
label: 'edit_album'.t(context: context),
iconData: Icons.edit,
onPressed: onEditAlbum,
menuItem: true,
),
);
}
if (onAddPhotos != null) {
menuChildren.add(
BaseActionButton(
label: 'add_photos'.t(context: context),
iconData: Icons.add_a_photo,
onPressed: onAddPhotos,
menuItem: true,
),
);
}
if (onAddUsers != null) {
menuChildren.add(
BaseActionButton(
label: 'album_viewer_page_share_add_users'.t(context: context),
iconData: Icons.group_add,
onPressed: onAddUsers,
menuItem: true,
),
);
}
if (onLeaveAlbum != null) {
menuChildren.add(
BaseActionButton(
label: 'leave_album'.t(context: context),
iconData: Icons.person_remove_rounded,
onPressed: onLeaveAlbum,
menuItem: true,
),
);
}
if (onToggleAlbumOrder != null) {
menuChildren.add(
BaseActionButton(
label: 'change_display_order'.t(context: context),
iconData: Icons.swap_vert_rounded,
onPressed: onToggleAlbumOrder,
menuItem: true,
),
);
}
if (onCreateSharedLink != null) {
menuChildren.add(
BaseActionButton(
label: 'create_shared_link'.t(context: context),
iconData: Icons.link,
onPressed: onCreateSharedLink,
menuItem: true,
),
);
}
if (onShowOptions != null) {
menuChildren.add(
BaseActionButton(
label: 'options'.t(context: context),
iconData: Icons.settings,
onPressed: onShowOptions,
menuItem: true,
),
);
}
if (onDeleteAlbum != null) {
menuChildren.add(const Divider(height: 1));
menuChildren.add(
BaseActionButton(
label: 'delete_album'.t(context: context),
iconData: Icons.delete,
iconColor: context.isDarkTheme ? Colors.red[400] : Colors.red[800],
onPressed: onDeleteAlbum,
menuItem: true,
),
);
}
return MenuAnchor(
consumeOutsideTap: true,
style: MenuStyle(
backgroundColor: WidgetStatePropertyAll(theme.scaffoldBackgroundColor),
surfaceTintColor: const WidgetStatePropertyAll(Colors.grey),
elevation: const WidgetStatePropertyAll(4),
shape: const WidgetStatePropertyAll(
RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(12))),
),
padding: const WidgetStatePropertyAll(EdgeInsets.symmetric(vertical: 6)),
),
menuChildren: [
ConstrainedBox(
constraints: const BoxConstraints(minWidth: 150),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: menuChildren,
),
),
],
builder: (context, controller, child) {
return IconButton(
icon: Icon(Icons.more_vert_rounded, color: iconColor ?? Colors.white, shadows: iconShadows),
onPressed: () => controller.isOpen ? controller.close() : controller.open(),
);
},
);
}
}
@@ -42,6 +42,7 @@ class Timeline extends StatelessWidget {
this.withScrubber = true,
this.snapToMonth = true,
this.initialScrollOffset,
this.readOnly = false,
});
final Widget? topSliverWidget;
@@ -54,6 +55,7 @@ class Timeline extends StatelessWidget {
final bool withScrubber;
final bool snapToMonth;
final double? initialScrollOffset;
final bool readOnly;
@override
Widget build(BuildContext context) {
@@ -73,6 +75,7 @@ class Timeline extends StatelessWidget {
groupBy: groupBy,
),
),
if (readOnly) readonlyModeProvider.overrideWith(() => _AlwaysReadOnlyNotifier()),
],
child: _SliverTimeline(
topSliverWidget: topSliverWidget,
@@ -89,6 +92,17 @@ class Timeline extends StatelessWidget {
}
}
class _AlwaysReadOnlyNotifier extends ReadOnlyModeNotifier {
@override
bool build() => true;
@override
void setReadonlyMode(bool value) {}
@override
void toggleReadonlyMode() {}
}
class _SliverTimeline extends ConsumerStatefulWidget {
const _SliverTimeline({
this.topSliverWidget,
@@ -11,6 +11,7 @@ import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/services/share_intent_service.dart';
import 'package:immich_mobile/services/upload.service.dart';
import 'package:logging/logging.dart';
import 'package:path/path.dart';
final shareIntentUploadProvider = StateNotifierProvider<ShareIntentUploadStateNotifier, List<ShareIntentAttachment>>(
@@ -25,6 +26,7 @@ class ShareIntentUploadStateNotifier extends StateNotifier<List<ShareIntentAttac
final AppRouter router;
final UploadService _uploadService;
final ShareIntentService _shareIntentService;
final Logger _logger = Logger('ShareIntentUploadStateNotifier');
ShareIntentUploadStateNotifier(this.router, this._uploadService, this._shareIntentService) : super([]) {
_uploadService.taskStatusStream.listen(_updateUploadStatus);
@@ -86,6 +88,21 @@ class ShareIntentUploadStateNotifier extends StateNotifier<List<ShareIntentAttac
for (final attachment in state)
if (attachment.id == taskId.toInt()) attachment.copyWith(status: uploadStatus) else attachment,
];
if (task.status == TaskStatus.failed) {
String? error;
final exception = task.exception;
if (exception != null && exception is TaskHttpException) {
final message = tryJsonDecode(exception.description)?['message'] as String?;
if (message != null) {
final responseCode = exception.httpResponseCode;
error = "${exception.exceptionType}, response code $responseCode: $message";
}
}
error ??= task.exception?.toString();
_logger.warning("Upload failed for asset: ${task.task.filename}, error: $error");
}
}
void _taskProgressCallback(TaskProgressUpdate update) {
@@ -5,16 +5,21 @@ import 'package:immich_mobile/providers/sync_status.provider.dart';
final backgroundSyncProvider = Provider<BackgroundSyncManager>((ref) {
final syncStatusNotifier = ref.read(syncStatusProvider.notifier);
final backupProvider = ref.read(driftBackupProvider.notifier);
final manager = BackgroundSyncManager(
onRemoteSyncStart: () {
syncStatusNotifier.startRemoteSync();
backupProvider.updateError(BackupError.none);
final backupProvider = ref.read(driftBackupProvider.notifier);
if (backupProvider.mounted) {
backupProvider.updateError(BackupError.none);
}
},
onRemoteSyncComplete: (isSuccess) {
syncStatusNotifier.completeRemoteSync();
backupProvider.updateError(isSuccess == true ? BackupError.none : BackupError.syncFailed);
final backupProvider = ref.read(driftBackupProvider.notifier);
if (backupProvider.mounted) {
backupProvider.updateError(isSuccess == true ? BackupError.none : BackupError.syncFailed);
}
},
onRemoteSyncError: syncStatusNotifier.errorRemoteSync,
onLocalSyncStart: syncStatusNotifier.startLocalSync,
@@ -212,8 +212,8 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
),
) {
{
_uploadService.taskStatusStream.listen(_handleTaskStatusUpdate);
_uploadService.taskProgressStream.listen(_handleTaskProgressUpdate);
_statusSubscription = _uploadService.taskStatusStream.listen(_handleTaskStatusUpdate);
_progressSubscription = _uploadService.taskProgressStream.listen(_handleTaskProgressUpdate);
}
}
@@ -224,6 +224,10 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
/// Remove upload item from state
void _removeUploadItem(String taskId) {
if (!mounted) {
_logger.warning("Skip _removeUploadItem: notifier disposed");
return;
}
if (state.uploadItems.containsKey(taskId)) {
final updatedItems = Map<String, DriftUploadStatus>.from(state.uploadItems);
updatedItems.remove(taskId);
@@ -232,6 +236,10 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
}
void _handleTaskStatusUpdate(TaskStatusUpdate update) {
if (!mounted) {
_logger.warning("Skip _handleTaskStatusUpdate: notifier disposed");
return;
}
final taskId = update.task.taskId;
switch (update.status) {
@@ -291,6 +299,10 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
}
void _handleTaskProgressUpdate(TaskProgressUpdate update) {
if (!mounted) {
_logger.warning("Skip _handleTaskProgressUpdate: notifier disposed");
return;
}
final taskId = update.task.taskId;
final filename = update.task.displayName;
final progress = update.progress;
@@ -332,7 +344,15 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
}
Future<void> getBackupStatus(String userId) async {
if (!mounted) {
_logger.warning("Skip getBackupStatus (pre-call): notifier disposed");
return;
}
final counts = await _uploadService.getBackupCounts(userId);
if (!mounted) {
_logger.warning("Skip getBackupStatus (post-call): notifier disposed");
return;
}
state = state.copyWith(
totalCount: counts.total,
@@ -343,6 +363,10 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
}
void updateError(BackupError error) async {
if (!mounted) {
_logger.warning("Skip updateError: notifier disposed");
return;
}
state = state.copyWith(error: error);
}
@@ -360,10 +384,18 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
}
Future<void> cancel() async {
if (!mounted) {
_logger.warning("Skip cancel (pre-call): notifier disposed");
return;
}
dPrint(() => "Canceling backup tasks...");
state = state.copyWith(enqueueCount: 0, enqueueTotalCount: 0, isCanceling: true, error: BackupError.none);
final activeTaskCount = await _uploadService.cancelBackup();
if (!mounted) {
_logger.warning("Skip cancel (post-call): notifier disposed");
return;
}
if (activeTaskCount > 0) {
dPrint(() => "$activeTaskCount tasks left, continuing to cancel...");
@@ -376,9 +408,17 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
}
Future<void> handleBackupResume(String userId) async {
if (!mounted) {
_logger.warning("Skip handleBackupResume (pre-call): notifier disposed");
return;
}
_logger.info("Resuming backup tasks...");
state = state.copyWith(error: BackupError.none);
final tasks = await _uploadService.getActiveTasks(kBackupGroup);
if (!mounted) {
_logger.warning("Skip handleBackupResume (post-call): notifier disposed");
return;
}
_logger.info("Found ${tasks.length} tasks");
if (tasks.isEmpty) {
+106
View File
@@ -0,0 +1,106 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/services/cleanup.service.dart';
class CleanupState {
final DateTime? selectedDate;
final List<LocalAsset> assetsToDelete;
final bool isScanning;
final bool isDeleting;
final AssetFilterType filterType;
final bool keepFavorites;
const CleanupState({
this.selectedDate,
this.assetsToDelete = const [],
this.isScanning = false,
this.isDeleting = false,
this.filterType = AssetFilterType.all,
this.keepFavorites = true,
});
CleanupState copyWith({
DateTime? selectedDate,
List<LocalAsset>? assetsToDelete,
bool? isScanning,
bool? isDeleting,
AssetFilterType? filterType,
bool? keepFavorites,
}) {
return CleanupState(
selectedDate: selectedDate ?? this.selectedDate,
assetsToDelete: assetsToDelete ?? this.assetsToDelete,
isScanning: isScanning ?? this.isScanning,
isDeleting: isDeleting ?? this.isDeleting,
filterType: filterType ?? this.filterType,
keepFavorites: keepFavorites ?? this.keepFavorites,
);
}
}
final cleanupProvider = StateNotifierProvider<CleanupNotifier, CleanupState>((ref) {
return CleanupNotifier(ref.watch(cleanupServiceProvider), ref.watch(currentUserProvider)?.id);
});
class CleanupNotifier extends StateNotifier<CleanupState> {
final CleanupService _cleanupService;
final String? _userId;
CleanupNotifier(this._cleanupService, this._userId) : super(const CleanupState());
void setSelectedDate(DateTime? date) {
state = state.copyWith(selectedDate: date, assetsToDelete: []);
}
void setFilterType(AssetFilterType filterType) {
state = state.copyWith(filterType: filterType, assetsToDelete: []);
}
void setKeepFavorites(bool keepFavorites) {
state = state.copyWith(keepFavorites: keepFavorites, assetsToDelete: []);
}
Future<void> scanAssets() async {
if (_userId == null || state.selectedDate == null) {
return;
}
state = state.copyWith(isScanning: true);
try {
final assets = await _cleanupService.getRemovalCandidates(
_userId,
state.selectedDate!,
filterType: state.filterType,
keepFavorites: state.keepFavorites,
);
state = state.copyWith(assetsToDelete: assets, isScanning: false);
} catch (e) {
state = state.copyWith(isScanning: false);
rethrow;
}
}
Future<int> deleteAssets() async {
if (state.assetsToDelete.isEmpty) {
return 0;
}
state = state.copyWith(isDeleting: true);
try {
final deletedCount = await _cleanupService.deleteLocalAssets(state.assetsToDelete.map((a) => a.id).toList());
state = state.copyWith(assetsToDelete: [], isDeleting: false);
return deletedCount;
} catch (e) {
state = state.copyWith(isDeleting: false);
rethrow;
}
}
void reset() {
state = const CleanupState();
}
}
+2
View File
@@ -88,6 +88,7 @@ import 'package:immich_mobile/presentation/pages/drift_album_options.page.dart';
import 'package:immich_mobile/presentation/pages/drift_archive.page.dart';
import 'package:immich_mobile/presentation/pages/drift_asset_selection_timeline.page.dart';
import 'package:immich_mobile/presentation/pages/drift_asset_troubleshoot.page.dart';
import 'package:immich_mobile/presentation/pages/cleanup_preview.page.dart';
import 'package:immich_mobile/presentation/pages/drift_create_album.page.dart';
import 'package:immich_mobile/presentation/pages/drift_favorite.page.dart';
import 'package:immich_mobile/presentation/pages/drift_library.page.dart';
@@ -338,6 +339,7 @@ class AppRouter extends RootStackRouter {
AutoRoute(page: AssetTroubleshootRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: DownloadInfoRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: ImmichUIShowcaseRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: CleanupPreviewRoute.page, guards: [_authGuard, _duplicateGuard]),
// required to handle all deeplinks in deep_link.service.dart
// auto_route_library#1722
RedirectRoute(path: '*', redirectTo: '/'),
+37
View File
@@ -611,6 +611,43 @@ class ChangePasswordRoute extends PageRouteInfo<void> {
);
}
/// generated route for
/// [CleanupPreviewPage]
class CleanupPreviewRoute extends PageRouteInfo<CleanupPreviewRouteArgs> {
CleanupPreviewRoute({
Key? key,
required List<LocalAsset> assets,
List<PageRouteInfo>? children,
}) : super(
CleanupPreviewRoute.name,
args: CleanupPreviewRouteArgs(key: key, assets: assets),
initialChildren: children,
);
static const String name = 'CleanupPreviewRoute';
static PageInfo page = PageInfo(
name,
builder: (data) {
final args = data.argsAs<CleanupPreviewRouteArgs>();
return CleanupPreviewPage(key: args.key, assets: args.assets);
},
);
}
class CleanupPreviewRouteArgs {
const CleanupPreviewRouteArgs({this.key, required this.assets});
final Key? key;
final List<LocalAsset> assets;
@override
String toString() {
return 'CleanupPreviewRouteArgs{key: $key, assets: $assets}';
}
}
/// generated route for
/// [CreateAlbumPage]
class CreateAlbumRoute extends PageRouteInfo<CreateAlbumRouteArgs> {
+45
View File
@@ -0,0 +1,45 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/repositories/asset_media.repository.dart';
final cleanupServiceProvider = Provider<CleanupService>((ref) {
return CleanupService(ref.watch(localAssetRepository), ref.watch(assetMediaRepositoryProvider));
});
class CleanupService {
final DriftLocalAssetRepository _localAssetRepository;
final AssetMediaRepository _assetMediaRepository;
const CleanupService(this._localAssetRepository, this._assetMediaRepository);
Future<List<LocalAsset>> getRemovalCandidates(
String userId,
DateTime cutoffDate, {
AssetFilterType filterType = AssetFilterType.all,
bool keepFavorites = true,
}) {
return _localAssetRepository.getRemovalCandidates(
userId,
cutoffDate,
filterType: filterType,
keepFavorites: keepFavorites,
);
}
Future<int> deleteLocalAssets(List<String> localIds) async {
if (localIds.isEmpty) {
return 0;
}
final deletedIds = await _assetMediaRepository.deleteAll(localIds);
if (deletedIds.isNotEmpty) {
await _localAssetRepository.delete(deletedIds);
return deletedIds.length;
}
return 0;
}
}
+24 -1
View File
@@ -31,7 +31,7 @@ import 'package:isar/isar.dart';
// ignore: import_rule_photo_manager
import 'package:photo_manager/photo_manager.dart';
const int targetVersion = 19;
const int targetVersion = 20;
Future<void> migrateDatabaseIfNeeded(Isar db, Drift drift) async {
final hasVersion = Store.tryGet(StoreKey.version) != null;
@@ -86,6 +86,10 @@ Future<void> migrateDatabaseIfNeeded(Isar db, Drift drift) async {
}
}
if (version < 20 && Store.isBetaTimelineEnabled) {
await _syncLocalAlbumIsIosSharedAlbum(drift);
}
if (targetVersion >= 12) {
await Store.put(StoreKey.version, targetVersion);
return;
@@ -258,6 +262,25 @@ Future<bool> _populateLocalAssetTime(Drift db) async {
}
}
Future<void> _syncLocalAlbumIsIosSharedAlbum(Drift db) async {
try {
final nativeApi = NativeSyncApi();
final albums = await nativeApi.getAlbums();
await db.batch((batch) {
for (final album in albums) {
batch.update(
db.localAlbumEntity,
LocalAlbumEntityCompanion(isIosSharedAlbum: Value(album.isCloud)),
where: (t) => t.id.equals(album.id),
);
}
});
dPrint(() => "[MIGRATION] Successfully updated isIosSharedAlbum for ${albums.length} albums");
} catch (error) {
dPrint(() => "[MIGRATION] Error while syncing local album isIosSharedAlbum: $error");
}
}
Future<void> migrateDeviceAssetToSqlite(Isar db, Drift drift) async {
try {
final isarDeviceAssets = await db.deviceAssetEntitys.where().findAll();
@@ -53,6 +53,7 @@ class BackupInfoCard extends StatelessWidget {
info,
style: context.textTheme.titleLarge?.copyWith(
color: context.colorScheme.onSurface.withAlpha(isLoading ? 50 : 255),
fontFeatures: const [FontFeature.tabularFigures()],
),
),
if (isLoading)
@@ -4,6 +4,7 @@ import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/providers/backup/backup_album.provider.dart';
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
import 'package:immich_mobile/routing/router.dart';
@@ -41,6 +42,13 @@ class DriftAlbumInfoListTile extends HookConsumerWidget {
return Icon(Icons.circle, color: context.colorScheme.surfaceContainerHighest);
}
Widget buildSubtitle() {
return Text(
album.isIosSharedAlbum ? '${album.assetCount} (iCloud Shared Album)' : album.assetCount.toString(),
style: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary),
);
}
return GestureDetector(
onDoubleTap: () {
ref.watch(hapticFeedbackProvider.notifier).selectionClick();
@@ -73,8 +81,8 @@ class DriftAlbumInfoListTile extends HookConsumerWidget {
}
},
leading: buildIcon(),
title: Text(album.name, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold)),
subtitle: Text(album.assetCount.toString()),
title: Text(album.name, style: context.textTheme.titleSmall),
subtitle: buildSubtitle(),
trailing: IconButton(
onPressed: () {
context.pushRoute(LocalTimelineRoute(album: album));
@@ -24,15 +24,13 @@ class RemoteAlbumSliverAppBar extends ConsumerStatefulWidget {
const RemoteAlbumSliverAppBar({
super.key,
this.icon = Icons.camera,
this.onShowOptions,
this.onToggleAlbumOrder,
required this.kebabMenu,
this.onEditTitle,
this.onActivity,
});
final IconData icon;
final void Function()? onShowOptions;
final void Function()? onToggleAlbumOrder;
final Widget kebabMenu;
final void Function()? onEditTitle;
final void Function()? onActivity;
@@ -91,21 +89,12 @@ class _MesmerizingSliverAppBarState extends ConsumerState<RemoteAlbumSliverAppBa
onPressed: () => context.maybePop(),
),
actions: [
if (widget.onToggleAlbumOrder != null)
IconButton(
icon: Icon(Icons.swap_vert_rounded, color: actionIconColor, shadows: actionIconShadows),
onPressed: widget.onToggleAlbumOrder,
),
if (currentAlbum.isActivityEnabled && currentAlbum.isShared)
IconButton(
icon: Icon(Icons.chat_outlined, color: actionIconColor, shadows: actionIconShadows),
onPressed: widget.onActivity,
),
if (widget.onShowOptions != null)
IconButton(
icon: Icon(Icons.more_vert, color: actionIconColor, shadows: actionIconShadows),
onPressed: widget.onShowOptions,
),
widget.kebabMenu,
],
title: Builder(
builder: (context) {
+115 -138
View File
@@ -14,6 +14,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/providers/auth.provider.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/backup/backup.provider.dart';
@@ -29,12 +30,7 @@ import 'package:immich_mobile/utils/version_compatibility.dart';
import 'package:immich_mobile/widgets/common/immich_logo.dart';
import 'package:immich_mobile/widgets/common/immich_title_text.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:immich_mobile/widgets/forms/login/email_input.dart';
import 'package:immich_mobile/widgets/forms/login/loading_icon.dart';
import 'package:immich_mobile/widgets/forms/login/login_button.dart';
import 'package:immich_mobile/widgets/forms/login/o_auth_login_button.dart';
import 'package:immich_mobile/widgets/forms/login/password_input.dart';
import 'package:immich_mobile/widgets/forms/login/server_endpoint_input.dart';
import 'package:immich_ui/immich_ui.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
import 'package:package_info_plus/package_info_plus.dart';
@@ -45,16 +41,33 @@ class LoginForm extends HookConsumerWidget {
final log = Logger('LoginForm');
String? _validateUrl(String? url) {
if (url == null || url.isEmpty) return null;
final parsedUrl = Uri.tryParse(url);
if (parsedUrl == null || !parsedUrl.isAbsolute || !parsedUrl.scheme.startsWith("http") || parsedUrl.host.isEmpty) {
return 'login_form_err_invalid_url'.tr();
}
return null;
}
String? _validateEmail(String? email) {
if (email == null || email == '') return null;
if (email.endsWith(' ')) return 'login_form_err_trailing_whitespace'.tr();
if (email.startsWith(' ')) return 'login_form_err_leading_whitespace'.tr();
if (email.contains(' ') || !email.contains('@')) {
return 'login_form_err_invalid_email'.tr();
}
return null;
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final emailController = useTextEditingController.fromValue(TextEditingValue.empty);
final passwordController = useTextEditingController.fromValue(TextEditingValue.empty);
final serverEndpointController = useTextEditingController.fromValue(TextEditingValue.empty);
final emailFocusNode = useFocusNode();
final passwordFocusNode = useFocusNode();
final serverEndpointFocusNode = useFocusNode();
final isLoading = useState<bool>(false);
final isLoadingServer = useState<bool>(false);
final isOauthEnable = useState<bool>(false);
final isPasswordLoginEnable = useState<bool>(false);
final oAuthButtonLabel = useState<String>('OAuth');
@@ -96,7 +109,6 @@ class LoginForm extends HookConsumerWidget {
}
try {
isLoadingServer.value = true;
final endpoint = await ref.read(authProvider.notifier).validateServerUrl(serverUrl);
// Fetch and load server config and features
@@ -120,7 +132,6 @@ class LoginForm extends HookConsumerWidget {
);
isOauthEnable.value = false;
isPasswordLoginEnable.value = true;
isLoadingServer.value = false;
} on HandshakeException {
ImmichToast.show(
context: context,
@@ -130,7 +141,6 @@ class LoginForm extends HookConsumerWidget {
);
isOauthEnable.value = false;
isPasswordLoginEnable.value = true;
isLoadingServer.value = false;
} catch (e) {
ImmichToast.show(
context: context,
@@ -140,10 +150,7 @@ class LoginForm extends HookConsumerWidget {
);
isOauthEnable.value = false;
isPasswordLoginEnable.value = true;
isLoadingServer.value = false;
}
isLoadingServer.value = false;
}
useEffect(() {
@@ -230,8 +237,6 @@ class LoginForm extends HookConsumerWidget {
login() async {
TextInput.finishAutofillContext();
isLoading.value = true;
// Invalidate all api repository provider instance to take into account new access token
invalidateAllApiRepositoryProviders(ref);
@@ -261,8 +266,6 @@ class LoginForm extends HookConsumerWidget {
toastType: ToastType.error,
gravity: ToastGravity.TOP,
);
} finally {
isLoading.value = false;
}
}
@@ -306,8 +309,6 @@ class LoginForm extends HookConsumerWidget {
codeChallenge,
);
isLoading.value = true;
// Invalidate all api repository provider instance to take into account new access token
invalidateAllApiRepositoryProviders(ref);
} catch (error, stack) {
@@ -319,7 +320,6 @@ class LoginForm extends HookConsumerWidget {
toastType: ToastType.error,
gravity: ToastGravity.TOP,
);
isLoading.value = false;
return;
}
@@ -338,7 +338,6 @@ class LoginForm extends HookConsumerWidget {
.saveAuthInfo(accessToken: loginResponseDto.accessToken);
if (isSuccess) {
isLoading.value = false;
final permission = ref.watch(galleryPermissionNotifier);
final isBeta = Store.isBetaTimelineEnabled;
if (!isBeta && (permission.isGranted || permission.isLimited)) {
@@ -364,9 +363,7 @@ class LoginForm extends HookConsumerWidget {
toastType: ToastType.error,
gravity: ToastGravity.TOP,
);
} finally {
isLoading.value = false;
}
} finally {}
} else {
ImmichToast.show(
context: context,
@@ -374,66 +371,10 @@ class LoginForm extends HookConsumerWidget {
toastType: ToastType.info,
gravity: ToastGravity.TOP,
);
isLoading.value = false;
return;
}
}
buildSelectServer() {
const buttonRadius = 25.0;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ServerEndpointInput(
controller: serverEndpointController,
focusNode: serverEndpointFocusNode,
onSubmit: getServerAuthSettings,
),
const SizedBox(height: 18),
Row(
children: [
Expanded(
child: ElevatedButton.icon(
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(buttonRadius),
bottomLeft: Radius.circular(buttonRadius),
),
),
),
onPressed: () => context.pushRoute(const SettingsRoute()),
icon: const Icon(Icons.settings_rounded),
label: const Text(""),
),
),
const SizedBox(width: 1),
Expanded(
flex: 3,
child: ElevatedButton.icon(
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.only(
topRight: Radius.circular(buttonRadius),
bottomRight: Radius.circular(buttonRadius),
),
),
),
onPressed: isLoadingServer.value ? null : getServerAuthSettings,
icon: const Icon(Icons.arrow_forward_rounded),
label: const Text('next', style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold)).tr(),
),
),
],
),
const SizedBox(height: 18),
if (isLoadingServer.value) const LoadingIcon(),
],
);
}
buildVersionCompatWarning() {
checkVersionMismatch();
@@ -455,66 +396,102 @@ class LoginForm extends HookConsumerWidget {
);
}
buildLogin() {
return AutofillGroup(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
buildVersionCompatWarning(),
Text(
sanitizeUrl(serverEndpointController.text),
style: context.textTheme.displaySmall,
textAlign: TextAlign.center,
final serverSelectionOrLogin = serverEndpoint.value == null
? Padding(
padding: const EdgeInsets.only(top: ImmichSpacing.md),
child: Column(
mainAxisSize: MainAxisSize.max,
children: [
ImmichForm(
submitText: 'next'.t(context: context),
submitIcon: Icons.arrow_forward_rounded,
onSubmit: getServerAuthSettings,
child: ImmichTextInput(
controller: serverEndpointController,
label: 'login_form_endpoint_url'.t(context: context),
hintText: 'login_form_endpoint_hint'.t(context: context),
validator: _validateUrl,
keyboardAction: TextInputAction.next,
keyboardType: TextInputType.url,
autofillHints: const [AutofillHints.url],
onSubmit: (ctx, _) => ImmichForm.of(ctx).submit(),
),
),
ImmichTextButton(
labelText: 'settings'.t(context: context),
icon: Icons.settings,
variant: ImmichVariant.ghost,
onPressed: () => context.pushRoute(const SettingsRoute()),
),
],
),
if (isPasswordLoginEnable.value) ...[
const SizedBox(height: 18),
EmailInput(
controller: emailController,
focusNode: emailFocusNode,
onSubmit: passwordFocusNode.requestFocus,
),
const SizedBox(height: 8),
PasswordInput(controller: passwordController, focusNode: passwordFocusNode, onSubmit: login),
],
// Note: This used to have an AnimatedSwitcher, but was removed
// because of https://github.com/flutter/flutter/issues/120874
isLoading.value
? const LoadingIcon()
: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisAlignment: MainAxisAlignment.center,
children: [
const SizedBox(height: 18),
if (isPasswordLoginEnable.value) LoginButton(onPressed: login),
if (isOauthEnable.value) ...[
if (isPasswordLoginEnable.value)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Divider(color: context.isDarkTheme ? Colors.white : Colors.black),
),
OAuthLoginButton(
serverEndpointController: serverEndpointController,
buttonLabel: oAuthButtonLabel.value,
isLoading: isLoading,
onPressed: oAuthLogin,
)
: AutofillGroup(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.max,
children: [
buildVersionCompatWarning(),
Padding(
padding: const EdgeInsets.only(bottom: ImmichSpacing.md),
child: Text(
sanitizeUrl(serverEndpointController.text),
style: context.textTheme.displaySmall,
textAlign: TextAlign.center,
),
),
if (isPasswordLoginEnable.value)
ImmichForm(
submitText: 'login'.t(context: context),
submitIcon: Icons.login_rounded,
onSubmit: login,
child: Column(
spacing: ImmichSpacing.md,
children: [
ImmichTextInput(
controller: emailController,
label: 'email'.t(context: context),
hintText: 'login_form_email_hint'.t(context: context),
validator: _validateEmail,
keyboardAction: TextInputAction.next,
keyboardType: TextInputType.emailAddress,
autofillHints: const [AutofillHints.email],
onSubmit: (_, _) => passwordFocusNode.requestFocus(),
),
ImmichPasswordInput(
controller: passwordController,
focusNode: passwordFocusNode,
label: 'password'.t(context: context),
hintText: 'login_form_password_hint'.t(context: context),
keyboardAction: TextInputAction.go,
onSubmit: (ctx, _) => ImmichForm.of(ctx).submit(),
),
],
],
),
),
if (!isOauthEnable.value && !isPasswordLoginEnable.value) Center(child: const Text('login_disabled').tr()),
const SizedBox(height: 12),
TextButton.icon(
icon: const Icon(Icons.arrow_back),
onPressed: () => serverEndpoint.value = null,
label: const Text('back').tr(),
if (isOauthEnable.value)
ImmichForm(
submitText: oAuthButtonLabel.value,
submitIcon: Icons.pin_outlined,
onSubmit: oAuthLogin,
child: isPasswordLoginEnable.value
? Padding(
padding: const EdgeInsets.only(left: 18.0, right: 18.0, top: 12.0),
child: Divider(color: context.isDarkTheme ? Colors.white : Colors.black, height: 5),
)
: const SizedBox.shrink(),
),
if (!isOauthEnable.value && !isPasswordLoginEnable.value)
Center(child: const Text('login_disabled').tr()),
ImmichTextButton(
labelText: 'back'.t(context: context),
icon: Icons.arrow_back,
variant: ImmichVariant.ghost,
onPressed: () => serverEndpoint.value = null,
),
],
),
],
),
);
}
final serverSelectionOrLogin = serverEndpoint.value == null ? buildSelectServer() : buildLogin();
);
return LayoutBuilder(
builder: (context, constraints) {
@@ -1,31 +0,0 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
class OAuthLoginButton extends ConsumerWidget {
final TextEditingController serverEndpointController;
final ValueNotifier<bool> isLoading;
final String buttonLabel;
final Function() onPressed;
const OAuthLoginButton({
super.key,
required this.serverEndpointController,
required this.isLoading,
required this.buttonLabel,
required this.onPressed,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
return ElevatedButton.icon(
style: ElevatedButton.styleFrom(
backgroundColor: context.primaryColor.withAlpha(230),
padding: const EdgeInsets.symmetric(vertical: 12),
),
onPressed: onPressed,
icon: const Icon(Icons.pin_rounded),
label: Text(buttonLabel, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold)),
);
}
}
@@ -1,37 +0,0 @@
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';
class PasswordInput extends HookConsumerWidget {
final TextEditingController controller;
final FocusNode? focusNode;
final Function()? onSubmit;
const PasswordInput({super.key, required this.controller, this.focusNode, this.onSubmit});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isPasswordVisible = useState<bool>(false);
return TextFormField(
obscureText: !isPasswordVisible.value,
controller: controller,
decoration: InputDecoration(
labelText: 'password'.tr(),
border: const OutlineInputBorder(),
hintText: 'login_form_password_hint'.tr(),
hintStyle: const TextStyle(fontWeight: FontWeight.normal, fontSize: 14),
suffixIcon: IconButton(
onPressed: () => isPasswordVisible.value = !isPasswordVisible.value,
icon: Icon(isPasswordVisible.value ? Icons.visibility_off_sharp : Icons.visibility_sharp),
),
),
autofillHints: const [AutofillHints.password],
keyboardType: TextInputType.text,
onFieldSubmitted: (_) => onSubmit?.call(),
focusNode: focusNode,
textInputAction: TextInputAction.go,
);
}
}
@@ -1,46 +0,0 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/utils/url_helper.dart';
class ServerEndpointInput extends StatelessWidget {
final TextEditingController controller;
final FocusNode focusNode;
final Function()? onSubmit;
const ServerEndpointInput({super.key, required this.controller, required this.focusNode, this.onSubmit});
String? _validateInput(String? url) {
if (url == null || url.isEmpty) return null;
final parsedUrl = Uri.tryParse(sanitizeUrl(url));
if (parsedUrl == null || !parsedUrl.isAbsolute || !parsedUrl.scheme.startsWith("http") || parsedUrl.host.isEmpty) {
return 'login_form_err_invalid_url'.tr();
}
return null;
}
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(top: 16.0),
child: TextFormField(
controller: controller,
decoration: InputDecoration(
labelText: 'login_form_endpoint_url'.tr(),
border: const OutlineInputBorder(),
hintText: 'login_form_endpoint_hint'.tr(),
errorMaxLines: 4,
),
validator: _validateInput,
autovalidateMode: AutovalidateMode.always,
focusNode: focusNode,
autofillHints: const [AutofillHints.url],
keyboardType: TextInputType.url,
autocorrect: false,
onFieldSubmitted: (_) => onSubmit?.call(),
textInputAction: TextInputAction.go,
),
);
}
}
@@ -0,0 +1,702 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/providers/cleanup.provider.dart';
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
import 'package:immich_mobile/routing/router.dart';
class FreeUpSpaceSettings extends ConsumerStatefulWidget {
const FreeUpSpaceSettings({super.key});
@override
ConsumerState<FreeUpSpaceSettings> createState() => _FreeUpSpaceSettingsState();
}
class _FreeUpSpaceSettingsState extends ConsumerState<FreeUpSpaceSettings> {
CleanupStep _currentStep = CleanupStep.selectDate;
bool _hasScanned = false;
void _resetState() {
ref.read(cleanupProvider.notifier).reset();
_hasScanned = false;
}
CleanupStep get _calculatedStep {
final state = ref.read(cleanupProvider);
if (state.assetsToDelete.isNotEmpty) {
return CleanupStep.delete;
}
if (state.selectedDate != null) {
return CleanupStep.filterOptions;
}
return CleanupStep.selectDate;
}
void _goToFiltersStep() {
ref.read(hapticFeedbackProvider.notifier).mediumImpact();
setState(() => _currentStep = CleanupStep.filterOptions);
}
void _goToScanStep() {
ref.read(hapticFeedbackProvider.notifier).mediumImpact();
setState(() => _currentStep = CleanupStep.scan);
}
void _setPresetDate(int daysAgo) {
ref.read(hapticFeedbackProvider.notifier).mediumImpact();
final date = DateTime.now().subtract(Duration(days: daysAgo));
ref.read(cleanupProvider.notifier).setSelectedDate(date);
setState(() => _hasScanned = false);
}
bool _isPresetSelected(int? daysAgo) {
final state = ref.read(cleanupProvider);
if (state.selectedDate == null) return false;
final expectedDate = daysAgo != null ? DateTime.now().subtract(Duration(days: daysAgo)) : DateTime(2000);
// Check if dates match (ignoring time component)
return state.selectedDate!.year == expectedDate.year &&
state.selectedDate!.month == expectedDate.month &&
state.selectedDate!.day == expectedDate.day;
}
Future<void> _selectDate() async {
final state = ref.read(cleanupProvider);
ref.read(hapticFeedbackProvider.notifier).mediumImpact();
final DateTime? picked = await showDatePicker(
context: context,
initialDate: state.selectedDate ?? DateTime.now(),
firstDate: DateTime(2000),
lastDate: DateTime.now(),
);
if (picked != null) {
ref.read(cleanupProvider.notifier).setSelectedDate(picked);
}
}
Future<void> _scanAssets() async {
ref.read(hapticFeedbackProvider.notifier).mediumImpact();
await ref.read(cleanupProvider.notifier).scanAssets();
final state = ref.read(cleanupProvider);
setState(() {
_hasScanned = true;
if (state.assetsToDelete.isNotEmpty) {
_currentStep = CleanupStep.delete;
}
});
}
Future<void> _deleteAssets() async {
final state = ref.read(cleanupProvider);
if (state.assetsToDelete.isEmpty || state.selectedDate == null) {
return;
}
ref.read(hapticFeedbackProvider.notifier).mediumImpact();
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) =>
_DeleteConfirmationDialog(assetCount: state.assetsToDelete.length, cutoffDate: state.selectedDate!),
);
if (confirmed != true) {
return;
}
final deletedCount = await ref.read(cleanupProvider.notifier).deleteAssets();
if (mounted && deletedCount > 0) {
ref.read(hapticFeedbackProvider.notifier).heavyImpact();
await showDialog<void>(
context: context,
builder: (ctx) => _DeleteSuccessDialog(deletedCount: deletedCount),
);
}
setState(() => _currentStep = CleanupStep.selectDate);
}
void _showAssetsPreview(List<LocalAsset> assets) {
ref.read(hapticFeedbackProvider.notifier).mediumImpact();
context.pushRoute(CleanupPreviewRoute(assets: assets));
}
@override
Widget build(BuildContext context) {
final state = ref.watch(cleanupProvider);
final hasDate = state.selectedDate != null;
final hasAssets = _hasScanned && state.assetsToDelete.isNotEmpty;
StepStyle styleForState(StepState stepState, {bool isDestructive = false}) {
switch (stepState) {
case StepState.complete:
return StepStyle(
color: context.colorScheme.primary,
indexStyle: TextStyle(color: context.colorScheme.onPrimary, fontWeight: FontWeight.w500),
);
case StepState.disabled:
return StepStyle(
color: context.colorScheme.onSurface.withValues(alpha: 0.38),
indexStyle: TextStyle(color: context.colorScheme.surface, fontWeight: FontWeight.w500),
);
case StepState.indexed:
case StepState.editing:
case StepState.error:
if (isDestructive) {
return StepStyle(
color: context.colorScheme.error,
indexStyle: TextStyle(color: context.colorScheme.onError, fontWeight: FontWeight.w500),
);
}
return StepStyle(
color: context.colorScheme.onSurface.withValues(alpha: 0.6),
indexStyle: TextStyle(color: context.colorScheme.surface, fontWeight: FontWeight.w500),
);
}
}
final step1State = hasDate ? StepState.complete : StepState.indexed;
final step2State = hasDate ? StepState.complete : StepState.disabled;
final step3State = hasAssets
? StepState.complete
: hasDate
? StepState.indexed
: StepState.disabled;
final step4State = hasAssets ? StepState.indexed : StepState.disabled;
String getFilterSubtitle() {
final parts = <String>[];
switch (state.filterType) {
case AssetFilterType.all:
parts.add('all'.t(context: context));
case AssetFilterType.photosOnly:
parts.add('photos_only'.t(context: context));
case AssetFilterType.videosOnly:
parts.add('videos_only'.t(context: context));
}
if (state.keepFavorites) {
parts.add('keep_favorites'.t(context: context));
}
return parts.join('');
}
return PopScope(
onPopInvokedWithResult: (didPop, result) {
if (didPop) {
_resetState();
}
},
child: SingleChildScrollView(
child: Column(
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: context.colorScheme.surfaceContainerLow,
borderRadius: const BorderRadius.all(Radius.circular(12)),
border: Border.all(color: context.primaryColor.withValues(alpha: 0.25)),
),
child: Text(
'free_up_space_description'.t(context: context),
style: context.textTheme.labelLarge?.copyWith(fontSize: 15),
),
),
),
Stepper(
physics: const NeverScrollableScrollPhysics(),
currentStep: _currentStep.index,
onStepTapped: (step) {
// Only allow going back or to completed steps
if (step <= _calculatedStep.index) {
setState(() => _currentStep = CleanupStep.values[step]);
}
},
controlsBuilder: (_, __) => const SizedBox.shrink(),
steps: [
// Step 1: Select Cutoff Date
Step(
stepStyle: styleForState(step1State),
title: Text(
'select_cutoff_date'.t(context: context),
style: context.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: step1State == StepState.complete
? context.colorScheme.primary
: context.colorScheme.onSurface,
),
),
subtitle: hasDate
? Text(
DateFormat.yMMMd().format(state.selectedDate!),
style: context.textTheme.bodyMedium?.copyWith(
color: context.colorScheme.primary,
fontWeight: FontWeight.w500,
),
)
: null,
content: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('cutoff_date_description'.t(context: context), style: context.textTheme.labelLarge),
const SizedBox(height: 16),
GridView.count(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
crossAxisCount: 3,
mainAxisSpacing: 8,
crossAxisSpacing: 8,
childAspectRatio: 1.4,
children: [
_DatePresetCard(
value: '30',
unit: 'cutoff_day'.t(context: context, args: {'count': '30'}),
onTap: () => _setPresetDate(30),
isSelected: _isPresetSelected(30),
),
_DatePresetCard(
value: '60',
unit: 'cutoff_day'.t(context: context, args: {'count': '60'}),
onTap: () => _setPresetDate(60),
isSelected: _isPresetSelected(60),
),
_DatePresetCard(
value: '90',
unit: 'cutoff_day'.t(context: context, args: {'count': '90'}),
onTap: () => _setPresetDate(90),
isSelected: _isPresetSelected(90),
),
_DatePresetCard(
value: '1',
unit: 'cutoff_year'.t(context: context, args: {'count': '1'}),
onTap: () => _setPresetDate(365),
isSelected: _isPresetSelected(365),
),
_DatePresetCard(
value: '2',
unit: 'cutoff_year'.t(context: context, args: {'count': '2'}),
onTap: () => _setPresetDate(730),
isSelected: _isPresetSelected(730),
),
_DatePresetCard(
value: '3',
unit: 'cutoff_year'.t(context: context, args: {'count': '3'}),
onTap: () => _setPresetDate(1095),
isSelected: _isPresetSelected(1095),
),
],
),
const SizedBox(height: 16),
OutlinedButton.icon(
onPressed: _selectDate,
icon: const Icon(Icons.calendar_today),
label: Text('custom_date'.t(context: context)),
style: OutlinedButton.styleFrom(minimumSize: const Size(double.infinity, 48)),
),
const SizedBox(height: 16),
ElevatedButton.icon(
onPressed: hasDate ? () => _goToFiltersStep() : null,
icon: const Icon(Icons.arrow_forward),
label: Text('continue'.t(context: context)),
style: ElevatedButton.styleFrom(minimumSize: const Size(double.infinity, 48)),
),
],
),
isActive: true,
state: step1State,
),
// Step 2: Select Filter Options
Step(
stepStyle: styleForState(step2State),
title: Text(
'filter_options'.t(context: context),
style: context.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: step2State == StepState.complete
? context.colorScheme.primary
: step2State == StepState.disabled
? context.colorScheme.onSurface.withValues(alpha: 0.38)
: context.colorScheme.onSurface,
),
),
subtitle: hasDate
? Text(
getFilterSubtitle(),
style: context.textTheme.bodyMedium?.copyWith(
color: context.colorScheme.primary,
fontWeight: FontWeight.w500,
),
)
: null,
content: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('cleanup_filter_description'.t(context: context), style: context.textTheme.labelLarge),
const SizedBox(height: 16),
SegmentedButton<AssetFilterType>(
segments: [
ButtonSegment(
value: AssetFilterType.all,
label: Text('all'.t(context: context)),
icon: const Icon(Icons.photo_library),
),
ButtonSegment(
value: AssetFilterType.photosOnly,
label: Text('photos'.t(context: context)),
icon: const Icon(Icons.photo),
),
ButtonSegment(
value: AssetFilterType.videosOnly,
label: Text('videos'.t(context: context)),
icon: const Icon(Icons.videocam),
),
],
selected: {state.filterType},
onSelectionChanged: (selection) {
ref.read(cleanupProvider.notifier).setFilterType(selection.first);
setState(() => _hasScanned = false);
},
),
const SizedBox(height: 16),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: Text('keep_favorites'.t(context: context), style: context.textTheme.titleSmall),
subtitle: Text(
'keep_favorites_description'.t(context: context),
style: context.textTheme.labelLarge,
),
value: state.keepFavorites,
onChanged: (value) {
ref.read(cleanupProvider.notifier).setKeepFavorites(value);
setState(() => _hasScanned = false);
},
),
const SizedBox(height: 16),
ElevatedButton.icon(
onPressed: _goToScanStep,
icon: const Icon(Icons.arrow_forward),
label: Text('continue'.t(context: context)),
style: ElevatedButton.styleFrom(minimumSize: const Size(double.infinity, 48)),
),
],
),
isActive: hasDate,
state: step2State,
),
// Step 3: Scan Assets
Step(
stepStyle: styleForState(step3State),
title: Text(
'scan'.t(context: context),
style: context.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: step3State == StepState.complete
? context.colorScheme.primary
: step3State == StepState.disabled
? context.colorScheme.onSurface.withValues(alpha: 0.38)
: context.colorScheme.onSurface,
),
),
subtitle: _hasScanned
? Text(
'cleanup_found_assets'.t(
context: context,
args: {'count': state.assetsToDelete.length.toString()},
),
style: context.textTheme.bodyMedium?.copyWith(
color: state.assetsToDelete.isNotEmpty
? context.colorScheme.primary
: context.colorScheme.onSurface.withValues(alpha: 0.6),
fontWeight: FontWeight.w500,
),
)
: null,
content: Column(
children: [
Text(
'cleanup_step3_description'.t(context: context),
style: context.textTheme.labelLarge?.copyWith(fontSize: 15),
),
if (CurrentPlatform.isIOS) ...[
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: context.colorScheme.primaryContainer.withValues(alpha: 0.3),
borderRadius: const BorderRadius.all(Radius.circular(12)),
),
child: Row(
children: [
Icon(Icons.info_outline, color: context.colorScheme.primary),
const SizedBox(width: 12),
Expanded(
child: Text(
'cleanup_icloud_shared_albums_excluded'.t(context: context),
style: context.textTheme.labelLarge,
),
),
],
),
),
],
const SizedBox(height: 16),
state.isScanning
? SizedBox(
width: 28,
height: 28,
child: CircularProgressIndicator(
strokeWidth: 2,
backgroundColor: context.colorScheme.primary.withAlpha(50),
),
)
: ElevatedButton.icon(
onPressed: state.isScanning ? null : _scanAssets,
icon: const Icon(Icons.search),
label: Text(_hasScanned ? 'rescan'.t(context: context) : 'scan'.t(context: context)),
style: ElevatedButton.styleFrom(minimumSize: const Size(double.infinity, 48)),
),
if (_hasScanned && state.assetsToDelete.isEmpty) ...[
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.orange.withValues(alpha: 0.1),
borderRadius: const BorderRadius.all(Radius.circular(8)),
),
child: Row(
children: [
const Icon(Icons.info, color: Colors.orange),
const SizedBox(width: 12),
Expanded(
child: Text(
'cleanup_no_assets_found'.t(context: context),
style: context.textTheme.bodyMedium,
),
),
],
),
),
],
],
),
isActive: hasDate,
state: step3State,
),
// Step 4: Delete Assets
Step(
stepStyle: styleForState(step4State, isDestructive: true),
title: Text(
'move_to_device_trash'.t(context: context),
style: context.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: step4State == StepState.disabled
? context.colorScheme.onSurface.withValues(alpha: 0.38)
: context.colorScheme.error,
),
),
content: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: context.colorScheme.errorContainer.withValues(alpha: 0.3),
borderRadius: const BorderRadius.all(Radius.circular(12)),
border: Border.all(color: context.colorScheme.error.withValues(alpha: 0.3)),
),
child: hasAssets
? Text(
'cleanup_step4_summary'.t(
context: context,
args: {
'count': state.assetsToDelete.length.toString(),
'date': DateFormat.yMMMd().format(state.selectedDate!),
},
),
style: context.textTheme.labelLarge?.copyWith(fontSize: 15),
)
: null,
),
const SizedBox(height: 16),
OutlinedButton.icon(
onPressed: () => _showAssetsPreview(state.assetsToDelete),
icon: const Icon(Icons.preview),
label: Text('preview'.t(context: context)),
style: OutlinedButton.styleFrom(minimumSize: const Size(double.infinity, 48)),
),
const SizedBox(height: 12),
ElevatedButton.icon(
onPressed: state.isDeleting ? null : _deleteAssets,
icon: state.isDeleting
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
)
: const Icon(Icons.delete_forever),
label: Text(
state.isDeleting
? 'cleanup_deleting'.t(context: context)
: 'move_to_device_trash'.t(context: context),
),
style: ElevatedButton.styleFrom(
backgroundColor: context.colorScheme.error,
foregroundColor: context.colorScheme.onError,
minimumSize: const Size(double.infinity, 56),
textStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
),
),
],
),
isActive: hasAssets,
state: step4State,
),
],
),
],
),
),
);
}
}
class _DeleteConfirmationDialog extends StatelessWidget {
final int assetCount;
final DateTime cutoffDate;
const _DeleteConfirmationDialog({required this.assetCount, required this.cutoffDate});
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text('cleanup_confirm_prompt_title'.t(context: context)),
content: Text(
'cleanup_confirm_description'.t(
context: context,
args: {'count': assetCount.toString(), 'date': DateFormat.yMMMd().format(cutoffDate)},
),
style: context.textTheme.labelLarge?.copyWith(fontSize: 15),
),
actions: [
TextButton(
onPressed: () => context.pop(false),
child: Text('cancel'.t(context: context)),
),
ElevatedButton(
onPressed: () => context.pop(true),
style: ElevatedButton.styleFrom(
backgroundColor: context.colorScheme.error,
foregroundColor: context.colorScheme.onError,
),
child: Text('confirm'.t(context: context)),
),
],
);
}
}
class _DeleteSuccessDialog extends StatelessWidget {
final int deletedCount;
const _DeleteSuccessDialog({required this.deletedCount});
@override
Widget build(BuildContext context) {
return AlertDialog(
icon: Icon(Icons.check_circle, color: context.colorScheme.primary, size: 48),
title: Text('success'.t(context: context)),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'cleanup_deleted_assets'.t(context: context, args: {'count': deletedCount.toString()}),
style: context.textTheme.labelLarge?.copyWith(fontSize: 16),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
Text(
'cleanup_trash_hint'.t(context: context),
style: context.textTheme.labelLarge?.copyWith(fontSize: 16, color: context.primaryColor),
textAlign: TextAlign.center,
),
],
),
actions: [
ElevatedButton(
onPressed: () => context.pop(),
child: Text('done'.t(context: context)),
),
],
);
}
}
class _DatePresetCard extends StatelessWidget {
final String value;
final String unit;
final VoidCallback onTap;
final bool isSelected;
const _DatePresetCard({required this.value, required this.unit, required this.onTap, required this.isSelected});
@override
Widget build(BuildContext context) {
return Material(
color: isSelected ? context.colorScheme.primaryContainer.withAlpha(100) : context.colorScheme.surfaceContainer,
borderRadius: const BorderRadius.all(Radius.circular(12)),
child: InkWell(
onTap: onTap,
borderRadius: const BorderRadius.all(Radius.circular(12)),
child: Container(
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(12)),
border: Border.all(color: isSelected ? context.colorScheme.primary : Colors.transparent, width: 1),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
value,
style: context.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
color: isSelected ? context.colorScheme.primary : context.colorScheme.onSurface,
),
),
Text(
unit,
style: context.textTheme.bodySmall?.copyWith(
color: isSelected
? context.colorScheme.primary
: context.colorScheme.onSurface.withValues(alpha: 0.7),
),
),
],
),
),
),
);
}
}
+1 -1
View File
@@ -33,7 +33,7 @@ migration:
dart run drift_dev make-migrations
translation:
npm --prefix ../web run format:i18n
npm --prefix ../i18n run format:fix
dart run easy_localization:generate -S ../i18n
dart run bin/generate_keys.dart
dart format lib/generated/codegen_loader.g.dart
+11 -3
View File
@@ -3,7 +3,7 @@ Immich API
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
- API version: 2.3.1
- API version: 2.4.1
- Generator version: 7.8.0
- Build package: org.openapitools.codegen.languages.DartClientCodegen
@@ -100,6 +100,7 @@ Class | Method | HTTP request | Description
*AssetsApi* | [**copyAsset**](doc//AssetsApi.md#copyasset) | **PUT** /assets/copy | Copy asset
*AssetsApi* | [**deleteAssetMetadata**](doc//AssetsApi.md#deleteassetmetadata) | **DELETE** /assets/{id}/metadata/{key} | Delete asset metadata by key
*AssetsApi* | [**deleteAssets**](doc//AssetsApi.md#deleteassets) | **DELETE** /assets | Delete assets
*AssetsApi* | [**deleteBulkAssetMetadata**](doc//AssetsApi.md#deletebulkassetmetadata) | **DELETE** /assets/metadata | Delete asset metadata
*AssetsApi* | [**downloadAsset**](doc//AssetsApi.md#downloadasset) | **GET** /assets/{id}/original | Download original asset
*AssetsApi* | [**getAllUserAssetsByDeviceId**](doc//AssetsApi.md#getalluserassetsbydeviceid) | **GET** /assets/device/{deviceId} | Retrieve assets by device ID
*AssetsApi* | [**getAssetInfo**](doc//AssetsApi.md#getassetinfo) | **GET** /assets/{id} | Retrieve an asset
@@ -114,6 +115,7 @@ Class | Method | HTTP request | Description
*AssetsApi* | [**updateAsset**](doc//AssetsApi.md#updateasset) | **PUT** /assets/{id} | Update an asset
*AssetsApi* | [**updateAssetMetadata**](doc//AssetsApi.md#updateassetmetadata) | **PUT** /assets/{id}/metadata | Update asset metadata
*AssetsApi* | [**updateAssets**](doc//AssetsApi.md#updateassets) | **PUT** /assets | Update assets
*AssetsApi* | [**updateBulkAssetMetadata**](doc//AssetsApi.md#updatebulkassetmetadata) | **PUT** /assets/metadata | Upsert asset metadata
*AssetsApi* | [**uploadAsset**](doc//AssetsApi.md#uploadasset) | **POST** /assets | Upload asset
*AssetsApi* | [**viewAsset**](doc//AssetsApi.md#viewasset) | **GET** /assets/{id}/thumbnail | View asset thumbnail
*AuthenticationApi* | [**changePassword**](doc//AuthenticationApi.md#changepassword) | **POST** /auth/change-password | Change password
@@ -199,6 +201,7 @@ Class | Method | HTTP request | Description
*PeopleApi* | [**updatePeople**](doc//PeopleApi.md#updatepeople) | **PUT** /people | Update people
*PeopleApi* | [**updatePerson**](doc//PeopleApi.md#updateperson) | **PUT** /people/{id} | Update person
*PluginsApi* | [**getPlugin**](doc//PluginsApi.md#getplugin) | **GET** /plugins/{id} | Retrieve a plugin
*PluginsApi* | [**getPluginTriggers**](doc//PluginsApi.md#getplugintriggers) | **GET** /plugins/triggers | List all plugin triggers
*PluginsApi* | [**getPlugins**](doc//PluginsApi.md#getplugins) | **GET** /plugins | List all plugins
*QueuesApi* | [**emptyQueue**](doc//QueuesApi.md#emptyqueue) | **DELETE** /queues/{name}/jobs | Empty a queue
*QueuesApi* | [**getQueue**](doc//QueuesApi.md#getqueue) | **GET** /queues/{name} | Retrieve a queue
@@ -357,7 +360,11 @@ Class | Method | HTTP request | Description
- [AssetMediaResponseDto](doc//AssetMediaResponseDto.md)
- [AssetMediaSize](doc//AssetMediaSize.md)
- [AssetMediaStatus](doc//AssetMediaStatus.md)
- [AssetMetadataKey](doc//AssetMetadataKey.md)
- [AssetMetadataBulkDeleteDto](doc//AssetMetadataBulkDeleteDto.md)
- [AssetMetadataBulkDeleteItemDto](doc//AssetMetadataBulkDeleteItemDto.md)
- [AssetMetadataBulkResponseDto](doc//AssetMetadataBulkResponseDto.md)
- [AssetMetadataBulkUpsertDto](doc//AssetMetadataBulkUpsertDto.md)
- [AssetMetadataBulkUpsertItemDto](doc//AssetMetadataBulkUpsertItemDto.md)
- [AssetMetadataResponseDto](doc//AssetMetadataResponseDto.md)
- [AssetMetadataUpsertDto](doc//AssetMetadataUpsertDto.md)
- [AssetMetadataUpsertItemDto](doc//AssetMetadataUpsertItemDto.md)
@@ -465,9 +472,10 @@ Class | Method | HTTP request | Description
- [PinCodeSetupDto](doc//PinCodeSetupDto.md)
- [PlacesResponseDto](doc//PlacesResponseDto.md)
- [PluginActionResponseDto](doc//PluginActionResponseDto.md)
- [PluginContext](doc//PluginContext.md)
- [PluginContextType](doc//PluginContextType.md)
- [PluginFilterResponseDto](doc//PluginFilterResponseDto.md)
- [PluginResponseDto](doc//PluginResponseDto.md)
- [PluginTriggerResponseDto](doc//PluginTriggerResponseDto.md)
- [PluginTriggerType](doc//PluginTriggerType.md)
- [PurchaseResponse](doc//PurchaseResponse.md)
- [PurchaseUpdate](doc//PurchaseUpdate.md)
+7 -2
View File
@@ -109,7 +109,11 @@ part 'model/asset_jobs_dto.dart';
part 'model/asset_media_response_dto.dart';
part 'model/asset_media_size.dart';
part 'model/asset_media_status.dart';
part 'model/asset_metadata_key.dart';
part 'model/asset_metadata_bulk_delete_dto.dart';
part 'model/asset_metadata_bulk_delete_item_dto.dart';
part 'model/asset_metadata_bulk_response_dto.dart';
part 'model/asset_metadata_bulk_upsert_dto.dart';
part 'model/asset_metadata_bulk_upsert_item_dto.dart';
part 'model/asset_metadata_response_dto.dart';
part 'model/asset_metadata_upsert_dto.dart';
part 'model/asset_metadata_upsert_item_dto.dart';
@@ -217,9 +221,10 @@ part 'model/pin_code_reset_dto.dart';
part 'model/pin_code_setup_dto.dart';
part 'model/places_response_dto.dart';
part 'model/plugin_action_response_dto.dart';
part 'model/plugin_context.dart';
part 'model/plugin_context_type.dart';
part 'model/plugin_filter_response_dto.dart';
part 'model/plugin_response_dto.dart';
part 'model/plugin_trigger_response_dto.dart';
part 'model/plugin_trigger_type.dart';
part 'model/purchase_response.dart';
part 'model/purchase_update.dart';
+124 -17
View File
@@ -186,12 +186,12 @@ class AssetsApi {
///
/// * [String] id (required):
///
/// * [AssetMetadataKey] key (required):
Future<Response> deleteAssetMetadataWithHttpInfo(String id, AssetMetadataKey key,) async {
/// * [String] key (required):
Future<Response> deleteAssetMetadataWithHttpInfo(String id, String key,) async {
// ignore: prefer_const_declarations
final apiPath = r'/assets/{id}/metadata/{key}'
.replaceAll('{id}', id)
.replaceAll('{key}', key.toString());
.replaceAll('{key}', key);
// ignore: prefer_final_locals
Object? postBody;
@@ -222,8 +222,8 @@ class AssetsApi {
///
/// * [String] id (required):
///
/// * [AssetMetadataKey] key (required):
Future<void> deleteAssetMetadata(String id, AssetMetadataKey key,) async {
/// * [String] key (required):
Future<void> deleteAssetMetadata(String id, String key,) async {
final response = await deleteAssetMetadataWithHttpInfo(id, key,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
@@ -278,6 +278,54 @@ class AssetsApi {
}
}
/// Delete asset metadata
///
/// Delete metadata key-value pairs for multiple assets.
///
/// Note: This method returns the HTTP [Response].
///
/// Parameters:
///
/// * [AssetMetadataBulkDeleteDto] assetMetadataBulkDeleteDto (required):
Future<Response> deleteBulkAssetMetadataWithHttpInfo(AssetMetadataBulkDeleteDto assetMetadataBulkDeleteDto,) async {
// ignore: prefer_const_declarations
final apiPath = r'/assets/metadata';
// ignore: prefer_final_locals
Object? postBody = assetMetadataBulkDeleteDto;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>['application/json'];
return apiClient.invokeAPI(
apiPath,
'DELETE',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Delete asset metadata
///
/// Delete metadata key-value pairs for multiple assets.
///
/// Parameters:
///
/// * [AssetMetadataBulkDeleteDto] assetMetadataBulkDeleteDto (required):
Future<void> deleteBulkAssetMetadata(AssetMetadataBulkDeleteDto assetMetadataBulkDeleteDto,) async {
final response = await deleteBulkAssetMetadataWithHttpInfo(assetMetadataBulkDeleteDto,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
}
/// Download original asset
///
/// Downloads the original file of the specified asset.
@@ -552,12 +600,12 @@ class AssetsApi {
///
/// * [String] id (required):
///
/// * [AssetMetadataKey] key (required):
Future<Response> getAssetMetadataByKeyWithHttpInfo(String id, AssetMetadataKey key,) async {
/// * [String] key (required):
Future<Response> getAssetMetadataByKeyWithHttpInfo(String id, String key,) async {
// ignore: prefer_const_declarations
final apiPath = r'/assets/{id}/metadata/{key}'
.replaceAll('{id}', id)
.replaceAll('{key}', key.toString());
.replaceAll('{key}', key);
// ignore: prefer_final_locals
Object? postBody;
@@ -588,8 +636,8 @@ class AssetsApi {
///
/// * [String] id (required):
///
/// * [AssetMetadataKey] key (required):
Future<AssetMetadataResponseDto?> getAssetMetadataByKey(String id, AssetMetadataKey key,) async {
/// * [String] key (required):
Future<AssetMetadataResponseDto?> getAssetMetadataByKey(String id, String key,) async {
final response = await getAssetMetadataByKeyWithHttpInfo(id, key,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
@@ -1228,6 +1276,65 @@ class AssetsApi {
}
}
/// Upsert asset metadata
///
/// Upsert metadata key-value pairs for multiple assets.
///
/// Note: This method returns the HTTP [Response].
///
/// Parameters:
///
/// * [AssetMetadataBulkUpsertDto] assetMetadataBulkUpsertDto (required):
Future<Response> updateBulkAssetMetadataWithHttpInfo(AssetMetadataBulkUpsertDto assetMetadataBulkUpsertDto,) async {
// ignore: prefer_const_declarations
final apiPath = r'/assets/metadata';
// ignore: prefer_final_locals
Object? postBody = assetMetadataBulkUpsertDto;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>['application/json'];
return apiClient.invokeAPI(
apiPath,
'PUT',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Upsert asset metadata
///
/// Upsert metadata key-value pairs for multiple assets.
///
/// Parameters:
///
/// * [AssetMetadataBulkUpsertDto] assetMetadataBulkUpsertDto (required):
Future<List<AssetMetadataBulkResponseDto>?> updateBulkAssetMetadata(AssetMetadataBulkUpsertDto assetMetadataBulkUpsertDto,) async {
final response = await updateBulkAssetMetadataWithHttpInfo(assetMetadataBulkUpsertDto,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
final responseBody = await _decodeBodyBytes(response);
return (await apiClient.deserializeAsync(responseBody, 'List<AssetMetadataBulkResponseDto>') as List)
.cast<AssetMetadataBulkResponseDto>()
.toList(growable: false);
}
return null;
}
/// Upload asset
///
/// Uploads a new asset to the server.
@@ -1246,8 +1353,6 @@ class AssetsApi {
///
/// * [DateTime] fileModifiedAt (required):
///
/// * [List<AssetMetadataUpsertItemDto>] metadata (required):
///
/// * [String] key:
///
/// * [String] slug:
@@ -1263,10 +1368,12 @@ class AssetsApi {
///
/// * [String] livePhotoVideoId:
///
/// * [List<AssetMetadataUpsertItemDto>] metadata:
///
/// * [MultipartFile] sidecarData:
///
/// * [AssetVisibility] visibility:
Future<Response> uploadAssetWithHttpInfo(MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, List<AssetMetadataUpsertItemDto> metadata, { String? key, String? slug, String? xImmichChecksum, String? duration, String? filename, bool? isFavorite, String? livePhotoVideoId, MultipartFile? sidecarData, AssetVisibility? visibility, }) async {
Future<Response> uploadAssetWithHttpInfo(MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? slug, String? xImmichChecksum, String? duration, String? filename, bool? isFavorite, String? livePhotoVideoId, List<AssetMetadataUpsertItemDto>? metadata, MultipartFile? sidecarData, AssetVisibility? visibility, }) async {
// ignore: prefer_const_declarations
final apiPath = r'/assets';
@@ -1373,8 +1480,6 @@ class AssetsApi {
///
/// * [DateTime] fileModifiedAt (required):
///
/// * [List<AssetMetadataUpsertItemDto>] metadata (required):
///
/// * [String] key:
///
/// * [String] slug:
@@ -1390,11 +1495,13 @@ class AssetsApi {
///
/// * [String] livePhotoVideoId:
///
/// * [List<AssetMetadataUpsertItemDto>] metadata:
///
/// * [MultipartFile] sidecarData:
///
/// * [AssetVisibility] visibility:
Future<AssetMediaResponseDto?> uploadAsset(MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, List<AssetMetadataUpsertItemDto> metadata, { String? key, String? slug, String? xImmichChecksum, String? duration, String? filename, bool? isFavorite, String? livePhotoVideoId, MultipartFile? sidecarData, AssetVisibility? visibility, }) async {
final response = await uploadAssetWithHttpInfo(assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, metadata, key: key, slug: slug, xImmichChecksum: xImmichChecksum, duration: duration, filename: filename, isFavorite: isFavorite, livePhotoVideoId: livePhotoVideoId, sidecarData: sidecarData, visibility: visibility, );
Future<AssetMediaResponseDto?> uploadAsset(MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? slug, String? xImmichChecksum, String? duration, String? filename, bool? isFavorite, String? livePhotoVideoId, List<AssetMetadataUpsertItemDto>? metadata, MultipartFile? sidecarData, AssetVisibility? visibility, }) async {
final response = await uploadAssetWithHttpInfo(assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, key: key, slug: slug, xImmichChecksum: xImmichChecksum, duration: duration, filename: filename, isFavorite: isFavorite, livePhotoVideoId: livePhotoVideoId, metadata: metadata, sidecarData: sidecarData, visibility: visibility, );
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
+51
View File
@@ -73,6 +73,57 @@ class PluginsApi {
return null;
}
/// List all plugin triggers
///
/// Retrieve a list of all available plugin triggers.
///
/// Note: This method returns the HTTP [Response].
Future<Response> getPluginTriggersWithHttpInfo() async {
// ignore: prefer_const_declarations
final apiPath = r'/plugins/triggers';
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>[];
return apiClient.invokeAPI(
apiPath,
'GET',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// List all plugin triggers
///
/// Retrieve a list of all available plugin triggers.
Future<List<PluginTriggerResponseDto>?> getPluginTriggers() async {
final response = await getPluginTriggersWithHttpInfo();
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
final responseBody = await _decodeBodyBytes(response);
return (await apiClient.deserializeAsync(responseBody, 'List<PluginTriggerResponseDto>') as List)
.cast<PluginTriggerResponseDto>()
.toList(growable: false);
}
return null;
}
/// List all plugins
///
/// Retrieve a list of plugins available to the authenticated user.
+10 -3
View File
@@ -160,7 +160,9 @@ class SharedLinksApi {
/// Parameters:
///
/// * [String] albumId:
Future<Response> getAllSharedLinksWithHttpInfo({ String? albumId, }) async {
///
/// * [String] id:
Future<Response> getAllSharedLinksWithHttpInfo({ String? albumId, String? id, }) async {
// ignore: prefer_const_declarations
final apiPath = r'/shared-links';
@@ -174,6 +176,9 @@ class SharedLinksApi {
if (albumId != null) {
queryParams.addAll(_queryParams('', 'albumId', albumId));
}
if (id != null) {
queryParams.addAll(_queryParams('', 'id', id));
}
const contentTypes = <String>[];
@@ -196,8 +201,10 @@ class SharedLinksApi {
/// Parameters:
///
/// * [String] albumId:
Future<List<SharedLinkResponseDto>?> getAllSharedLinks({ String? albumId, }) async {
final response = await getAllSharedLinksWithHttpInfo( albumId: albumId, );
///
/// * [String] id:
Future<List<SharedLinkResponseDto>?> getAllSharedLinks({ String? albumId, String? id, }) async {
final response = await getAllSharedLinksWithHttpInfo( albumId: albumId, id: id, );
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
+14 -4
View File
@@ -266,8 +266,16 @@ class ApiClient {
return AssetMediaSizeTypeTransformer().decode(value);
case 'AssetMediaStatus':
return AssetMediaStatusTypeTransformer().decode(value);
case 'AssetMetadataKey':
return AssetMetadataKeyTypeTransformer().decode(value);
case 'AssetMetadataBulkDeleteDto':
return AssetMetadataBulkDeleteDto.fromJson(value);
case 'AssetMetadataBulkDeleteItemDto':
return AssetMetadataBulkDeleteItemDto.fromJson(value);
case 'AssetMetadataBulkResponseDto':
return AssetMetadataBulkResponseDto.fromJson(value);
case 'AssetMetadataBulkUpsertDto':
return AssetMetadataBulkUpsertDto.fromJson(value);
case 'AssetMetadataBulkUpsertItemDto':
return AssetMetadataBulkUpsertItemDto.fromJson(value);
case 'AssetMetadataResponseDto':
return AssetMetadataResponseDto.fromJson(value);
case 'AssetMetadataUpsertDto':
@@ -482,12 +490,14 @@ class ApiClient {
return PlacesResponseDto.fromJson(value);
case 'PluginActionResponseDto':
return PluginActionResponseDto.fromJson(value);
case 'PluginContext':
return PluginContextTypeTransformer().decode(value);
case 'PluginContextType':
return PluginContextTypeTypeTransformer().decode(value);
case 'PluginFilterResponseDto':
return PluginFilterResponseDto.fromJson(value);
case 'PluginResponseDto':
return PluginResponseDto.fromJson(value);
case 'PluginTriggerResponseDto':
return PluginTriggerResponseDto.fromJson(value);
case 'PluginTriggerType':
return PluginTriggerTypeTypeTransformer().decode(value);
case 'PurchaseResponse':
+2 -5
View File
@@ -67,9 +67,6 @@ String parameterToString(dynamic value) {
if (value is AssetMediaStatus) {
return AssetMediaStatusTypeTransformer().encode(value).toString();
}
if (value is AssetMetadataKey) {
return AssetMetadataKeyTypeTransformer().encode(value).toString();
}
if (value is AssetOrder) {
return AssetOrderTypeTransformer().encode(value).toString();
}
@@ -127,8 +124,8 @@ String parameterToString(dynamic value) {
if (value is Permission) {
return PermissionTypeTransformer().encode(value).toString();
}
if (value is PluginContext) {
return PluginContextTypeTransformer().encode(value).toString();
if (value is PluginContextType) {
return PluginContextTypeTypeTransformer().encode(value).toString();
}
if (value is PluginTriggerType) {
return PluginTriggerTypeTypeTransformer().encode(value).toString();
@@ -0,0 +1,99 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class AssetMetadataBulkDeleteDto {
/// Returns a new [AssetMetadataBulkDeleteDto] instance.
AssetMetadataBulkDeleteDto({
this.items = const [],
});
List<AssetMetadataBulkDeleteItemDto> items;
@override
bool operator ==(Object other) => identical(this, other) || other is AssetMetadataBulkDeleteDto &&
_deepEquality.equals(other.items, items);
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(items.hashCode);
@override
String toString() => 'AssetMetadataBulkDeleteDto[items=$items]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'items'] = this.items;
return json;
}
/// Returns a new [AssetMetadataBulkDeleteDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static AssetMetadataBulkDeleteDto? fromJson(dynamic value) {
upgradeDto(value, "AssetMetadataBulkDeleteDto");
if (value is Map) {
final json = value.cast<String, dynamic>();
return AssetMetadataBulkDeleteDto(
items: AssetMetadataBulkDeleteItemDto.listFromJson(json[r'items']),
);
}
return null;
}
static List<AssetMetadataBulkDeleteDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <AssetMetadataBulkDeleteDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = AssetMetadataBulkDeleteDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, AssetMetadataBulkDeleteDto> mapFromJson(dynamic json) {
final map = <String, AssetMetadataBulkDeleteDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = AssetMetadataBulkDeleteDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of AssetMetadataBulkDeleteDto-objects as value to a dart map
static Map<String, List<AssetMetadataBulkDeleteDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<AssetMetadataBulkDeleteDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = AssetMetadataBulkDeleteDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'items',
};
}
@@ -0,0 +1,107 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class AssetMetadataBulkDeleteItemDto {
/// Returns a new [AssetMetadataBulkDeleteItemDto] instance.
AssetMetadataBulkDeleteItemDto({
required this.assetId,
required this.key,
});
String assetId;
String key;
@override
bool operator ==(Object other) => identical(this, other) || other is AssetMetadataBulkDeleteItemDto &&
other.assetId == assetId &&
other.key == key;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(assetId.hashCode) +
(key.hashCode);
@override
String toString() => 'AssetMetadataBulkDeleteItemDto[assetId=$assetId, key=$key]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'assetId'] = this.assetId;
json[r'key'] = this.key;
return json;
}
/// Returns a new [AssetMetadataBulkDeleteItemDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static AssetMetadataBulkDeleteItemDto? fromJson(dynamic value) {
upgradeDto(value, "AssetMetadataBulkDeleteItemDto");
if (value is Map) {
final json = value.cast<String, dynamic>();
return AssetMetadataBulkDeleteItemDto(
assetId: mapValueOfType<String>(json, r'assetId')!,
key: mapValueOfType<String>(json, r'key')!,
);
}
return null;
}
static List<AssetMetadataBulkDeleteItemDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <AssetMetadataBulkDeleteItemDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = AssetMetadataBulkDeleteItemDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, AssetMetadataBulkDeleteItemDto> mapFromJson(dynamic json) {
final map = <String, AssetMetadataBulkDeleteItemDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = AssetMetadataBulkDeleteItemDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of AssetMetadataBulkDeleteItemDto-objects as value to a dart map
static Map<String, List<AssetMetadataBulkDeleteItemDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<AssetMetadataBulkDeleteItemDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = AssetMetadataBulkDeleteItemDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'assetId',
'key',
};
}
@@ -0,0 +1,123 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class AssetMetadataBulkResponseDto {
/// Returns a new [AssetMetadataBulkResponseDto] instance.
AssetMetadataBulkResponseDto({
required this.assetId,
required this.key,
required this.updatedAt,
required this.value,
});
String assetId;
String key;
DateTime updatedAt;
Object value;
@override
bool operator ==(Object other) => identical(this, other) || other is AssetMetadataBulkResponseDto &&
other.assetId == assetId &&
other.key == key &&
other.updatedAt == updatedAt &&
other.value == value;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(assetId.hashCode) +
(key.hashCode) +
(updatedAt.hashCode) +
(value.hashCode);
@override
String toString() => 'AssetMetadataBulkResponseDto[assetId=$assetId, key=$key, updatedAt=$updatedAt, value=$value]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'assetId'] = this.assetId;
json[r'key'] = this.key;
json[r'updatedAt'] = this.updatedAt.toUtc().toIso8601String();
json[r'value'] = this.value;
return json;
}
/// Returns a new [AssetMetadataBulkResponseDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static AssetMetadataBulkResponseDto? fromJson(dynamic value) {
upgradeDto(value, "AssetMetadataBulkResponseDto");
if (value is Map) {
final json = value.cast<String, dynamic>();
return AssetMetadataBulkResponseDto(
assetId: mapValueOfType<String>(json, r'assetId')!,
key: mapValueOfType<String>(json, r'key')!,
updatedAt: mapDateTime(json, r'updatedAt', r'')!,
value: mapValueOfType<Object>(json, r'value')!,
);
}
return null;
}
static List<AssetMetadataBulkResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <AssetMetadataBulkResponseDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = AssetMetadataBulkResponseDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, AssetMetadataBulkResponseDto> mapFromJson(dynamic json) {
final map = <String, AssetMetadataBulkResponseDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = AssetMetadataBulkResponseDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of AssetMetadataBulkResponseDto-objects as value to a dart map
static Map<String, List<AssetMetadataBulkResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<AssetMetadataBulkResponseDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = AssetMetadataBulkResponseDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'assetId',
'key',
'updatedAt',
'value',
};
}
@@ -0,0 +1,99 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class AssetMetadataBulkUpsertDto {
/// Returns a new [AssetMetadataBulkUpsertDto] instance.
AssetMetadataBulkUpsertDto({
this.items = const [],
});
List<AssetMetadataBulkUpsertItemDto> items;
@override
bool operator ==(Object other) => identical(this, other) || other is AssetMetadataBulkUpsertDto &&
_deepEquality.equals(other.items, items);
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(items.hashCode);
@override
String toString() => 'AssetMetadataBulkUpsertDto[items=$items]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'items'] = this.items;
return json;
}
/// Returns a new [AssetMetadataBulkUpsertDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static AssetMetadataBulkUpsertDto? fromJson(dynamic value) {
upgradeDto(value, "AssetMetadataBulkUpsertDto");
if (value is Map) {
final json = value.cast<String, dynamic>();
return AssetMetadataBulkUpsertDto(
items: AssetMetadataBulkUpsertItemDto.listFromJson(json[r'items']),
);
}
return null;
}
static List<AssetMetadataBulkUpsertDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <AssetMetadataBulkUpsertDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = AssetMetadataBulkUpsertDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, AssetMetadataBulkUpsertDto> mapFromJson(dynamic json) {
final map = <String, AssetMetadataBulkUpsertDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = AssetMetadataBulkUpsertDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of AssetMetadataBulkUpsertDto-objects as value to a dart map
static Map<String, List<AssetMetadataBulkUpsertDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<AssetMetadataBulkUpsertDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = AssetMetadataBulkUpsertDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'items',
};
}
@@ -0,0 +1,115 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class AssetMetadataBulkUpsertItemDto {
/// Returns a new [AssetMetadataBulkUpsertItemDto] instance.
AssetMetadataBulkUpsertItemDto({
required this.assetId,
required this.key,
required this.value,
});
String assetId;
String key;
Object value;
@override
bool operator ==(Object other) => identical(this, other) || other is AssetMetadataBulkUpsertItemDto &&
other.assetId == assetId &&
other.key == key &&
other.value == value;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(assetId.hashCode) +
(key.hashCode) +
(value.hashCode);
@override
String toString() => 'AssetMetadataBulkUpsertItemDto[assetId=$assetId, key=$key, value=$value]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'assetId'] = this.assetId;
json[r'key'] = this.key;
json[r'value'] = this.value;
return json;
}
/// Returns a new [AssetMetadataBulkUpsertItemDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static AssetMetadataBulkUpsertItemDto? fromJson(dynamic value) {
upgradeDto(value, "AssetMetadataBulkUpsertItemDto");
if (value is Map) {
final json = value.cast<String, dynamic>();
return AssetMetadataBulkUpsertItemDto(
assetId: mapValueOfType<String>(json, r'assetId')!,
key: mapValueOfType<String>(json, r'key')!,
value: mapValueOfType<Object>(json, r'value')!,
);
}
return null;
}
static List<AssetMetadataBulkUpsertItemDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <AssetMetadataBulkUpsertItemDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = AssetMetadataBulkUpsertItemDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, AssetMetadataBulkUpsertItemDto> mapFromJson(dynamic json) {
final map = <String, AssetMetadataBulkUpsertItemDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = AssetMetadataBulkUpsertItemDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of AssetMetadataBulkUpsertItemDto-objects as value to a dart map
static Map<String, List<AssetMetadataBulkUpsertItemDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<AssetMetadataBulkUpsertItemDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = AssetMetadataBulkUpsertItemDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'assetId',
'key',
'value',
};
}
-82
View File
@@ -1,82 +0,0 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class AssetMetadataKey {
/// Instantiate a new enum with the provided [value].
const AssetMetadataKey._(this.value);
/// The underlying value of this enum member.
final String value;
@override
String toString() => value;
String toJson() => value;
static const mobileApp = AssetMetadataKey._(r'mobile-app');
/// List of all possible values in this [enum][AssetMetadataKey].
static const values = <AssetMetadataKey>[
mobileApp,
];
static AssetMetadataKey? fromJson(dynamic value) => AssetMetadataKeyTypeTransformer().decode(value);
static List<AssetMetadataKey> listFromJson(dynamic json, {bool growable = false,}) {
final result = <AssetMetadataKey>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = AssetMetadataKey.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
}
/// Transformation class that can [encode] an instance of [AssetMetadataKey] to String,
/// and [decode] dynamic data back to [AssetMetadataKey].
class AssetMetadataKeyTypeTransformer {
factory AssetMetadataKeyTypeTransformer() => _instance ??= const AssetMetadataKeyTypeTransformer._();
const AssetMetadataKeyTypeTransformer._();
String encode(AssetMetadataKey data) => data.value;
/// Decodes a [dynamic value][data] to a AssetMetadataKey.
///
/// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
/// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
/// cannot be decoded successfully, then an [UnimplementedError] is thrown.
///
/// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
/// and users are still using an old app with the old code.
AssetMetadataKey? decode(dynamic data, {bool allowNull = true}) {
if (data != null) {
switch (data) {
case r'mobile-app': return AssetMetadataKey.mobileApp;
default:
if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data');
}
}
}
return null;
}
/// Singleton [AssetMetadataKeyTypeTransformer] instance.
static AssetMetadataKeyTypeTransformer? _instance;
}
+2 -2
View File
@@ -18,7 +18,7 @@ class AssetMetadataResponseDto {
required this.value,
});
AssetMetadataKey key;
String key;
DateTime updatedAt;
@@ -57,7 +57,7 @@ class AssetMetadataResponseDto {
final json = value.cast<String, dynamic>();
return AssetMetadataResponseDto(
key: AssetMetadataKey.fromJson(json[r'key'])!,
key: mapValueOfType<String>(json, r'key')!,
updatedAt: mapDateTime(json, r'updatedAt', r'')!,
value: mapValueOfType<Object>(json, r'value')!,
);
@@ -17,7 +17,7 @@ class AssetMetadataUpsertItemDto {
required this.value,
});
AssetMetadataKey key;
String key;
Object value;
@@ -51,7 +51,7 @@ class AssetMetadataUpsertItemDto {
final json = value.cast<String, dynamic>();
return AssetMetadataUpsertItemDto(
key: AssetMetadataKey.fromJson(json[r'key'])!,
key: mapValueOfType<String>(json, r'key')!,
value: mapValueOfType<Object>(json, r'value')!,
);
}
+2 -2
View File
@@ -32,7 +32,7 @@ class PluginActionResponseDto {
Object? schema;
List<PluginContext> supportedContexts;
List<PluginContextType> supportedContexts;
String title;
@@ -90,7 +90,7 @@ class PluginActionResponseDto {
methodName: mapValueOfType<String>(json, r'methodName')!,
pluginId: mapValueOfType<String>(json, r'pluginId')!,
schema: mapValueOfType<Object>(json, r'schema'),
supportedContexts: PluginContext.listFromJson(json[r'supportedContexts']),
supportedContexts: PluginContextType.listFromJson(json[r'supportedContexts']),
title: mapValueOfType<String>(json, r'title')!,
);
}
@@ -11,9 +11,9 @@
part of openapi.api;
class PluginContext {
class PluginContextType {
/// Instantiate a new enum with the provided [value].
const PluginContext._(this.value);
const PluginContextType._(this.value);
/// The underlying value of this enum member.
final String value;
@@ -23,24 +23,24 @@ class PluginContext {
String toJson() => value;
static const asset = PluginContext._(r'asset');
static const album = PluginContext._(r'album');
static const person = PluginContext._(r'person');
static const asset = PluginContextType._(r'asset');
static const album = PluginContextType._(r'album');
static const person = PluginContextType._(r'person');
/// List of all possible values in this [enum][PluginContext].
static const values = <PluginContext>[
/// List of all possible values in this [enum][PluginContextType].
static const values = <PluginContextType>[
asset,
album,
person,
];
static PluginContext? fromJson(dynamic value) => PluginContextTypeTransformer().decode(value);
static PluginContextType? fromJson(dynamic value) => PluginContextTypeTypeTransformer().decode(value);
static List<PluginContext> listFromJson(dynamic json, {bool growable = false,}) {
final result = <PluginContext>[];
static List<PluginContextType> listFromJson(dynamic json, {bool growable = false,}) {
final result = <PluginContextType>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = PluginContext.fromJson(row);
final value = PluginContextType.fromJson(row);
if (value != null) {
result.add(value);
}
@@ -50,16 +50,16 @@ class PluginContext {
}
}
/// Transformation class that can [encode] an instance of [PluginContext] to String,
/// and [decode] dynamic data back to [PluginContext].
class PluginContextTypeTransformer {
factory PluginContextTypeTransformer() => _instance ??= const PluginContextTypeTransformer._();
/// Transformation class that can [encode] an instance of [PluginContextType] to String,
/// and [decode] dynamic data back to [PluginContextType].
class PluginContextTypeTypeTransformer {
factory PluginContextTypeTypeTransformer() => _instance ??= const PluginContextTypeTypeTransformer._();
const PluginContextTypeTransformer._();
const PluginContextTypeTypeTransformer._();
String encode(PluginContext data) => data.value;
String encode(PluginContextType data) => data.value;
/// Decodes a [dynamic value][data] to a PluginContext.
/// Decodes a [dynamic value][data] to a PluginContextType.
///
/// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
/// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
@@ -67,12 +67,12 @@ class PluginContextTypeTransformer {
///
/// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
/// and users are still using an old app with the old code.
PluginContext? decode(dynamic data, {bool allowNull = true}) {
PluginContextType? decode(dynamic data, {bool allowNull = true}) {
if (data != null) {
switch (data) {
case r'asset': return PluginContext.asset;
case r'album': return PluginContext.album;
case r'person': return PluginContext.person;
case r'asset': return PluginContextType.asset;
case r'album': return PluginContextType.album;
case r'person': return PluginContextType.person;
default:
if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data');
@@ -82,7 +82,7 @@ class PluginContextTypeTransformer {
return null;
}
/// Singleton [PluginContextTypeTransformer] instance.
static PluginContextTypeTransformer? _instance;
/// Singleton [PluginContextTypeTypeTransformer] instance.
static PluginContextTypeTypeTransformer? _instance;
}
+2 -2
View File
@@ -32,7 +32,7 @@ class PluginFilterResponseDto {
Object? schema;
List<PluginContext> supportedContexts;
List<PluginContextType> supportedContexts;
String title;
@@ -90,7 +90,7 @@ class PluginFilterResponseDto {
methodName: mapValueOfType<String>(json, r'methodName')!,
pluginId: mapValueOfType<String>(json, r'pluginId')!,
schema: mapValueOfType<Object>(json, r'schema'),
supportedContexts: PluginContext.listFromJson(json[r'supportedContexts']),
supportedContexts: PluginContextType.listFromJson(json[r'supportedContexts']),
title: mapValueOfType<String>(json, r'title')!,
);
}
+107
View File
@@ -0,0 +1,107 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class PluginTriggerResponseDto {
/// Returns a new [PluginTriggerResponseDto] instance.
PluginTriggerResponseDto({
required this.contextType,
required this.type,
});
PluginContextType contextType;
PluginTriggerType type;
@override
bool operator ==(Object other) => identical(this, other) || other is PluginTriggerResponseDto &&
other.contextType == contextType &&
other.type == type;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(contextType.hashCode) +
(type.hashCode);
@override
String toString() => 'PluginTriggerResponseDto[contextType=$contextType, type=$type]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'contextType'] = this.contextType;
json[r'type'] = this.type;
return json;
}
/// Returns a new [PluginTriggerResponseDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static PluginTriggerResponseDto? fromJson(dynamic value) {
upgradeDto(value, "PluginTriggerResponseDto");
if (value is Map) {
final json = value.cast<String, dynamic>();
return PluginTriggerResponseDto(
contextType: PluginContextType.fromJson(json[r'contextType'])!,
type: PluginTriggerType.fromJson(json[r'type'])!,
);
}
return null;
}
static List<PluginTriggerResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <PluginTriggerResponseDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = PluginTriggerResponseDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, PluginTriggerResponseDto> mapFromJson(dynamic json) {
final map = <String, PluginTriggerResponseDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = PluginTriggerResponseDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of PluginTriggerResponseDto-objects as value to a dart map
static Map<String, List<PluginTriggerResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<PluginTriggerResponseDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = PluginTriggerResponseDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'contextType',
'type',
};
}
+2 -2
View File
@@ -19,7 +19,7 @@ class SyncAssetMetadataDeleteV1 {
String assetId;
AssetMetadataKey key;
String key;
@override
bool operator ==(Object other) => identical(this, other) || other is SyncAssetMetadataDeleteV1 &&
@@ -52,7 +52,7 @@ class SyncAssetMetadataDeleteV1 {
return SyncAssetMetadataDeleteV1(
assetId: mapValueOfType<String>(json, r'assetId')!,
key: AssetMetadataKey.fromJson(json[r'key'])!,
key: mapValueOfType<String>(json, r'key')!,
);
}
return null;
+2 -2
View File
@@ -20,7 +20,7 @@ class SyncAssetMetadataV1 {
String assetId;
AssetMetadataKey key;
String key;
Object value;
@@ -58,7 +58,7 @@ class SyncAssetMetadataV1 {
return SyncAssetMetadataV1(
assetId: mapValueOfType<String>(json, r'assetId')!,
key: AssetMetadataKey.fromJson(json[r'key'])!,
key: mapValueOfType<String>(json, r'key')!,
value: mapValueOfType<Object>(json, r'value')!,
);
}
+2 -76
View File
@@ -40,7 +40,7 @@ class WorkflowResponseDto {
String ownerId;
WorkflowResponseDtoTriggerTypeEnum triggerType;
PluginTriggerType triggerType;
@override
bool operator ==(Object other) => identical(this, other) || other is WorkflowResponseDto &&
@@ -105,7 +105,7 @@ class WorkflowResponseDto {
id: mapValueOfType<String>(json, r'id')!,
name: mapValueOfType<String>(json, r'name'),
ownerId: mapValueOfType<String>(json, r'ownerId')!,
triggerType: WorkflowResponseDtoTriggerTypeEnum.fromJson(json[r'triggerType'])!,
triggerType: PluginTriggerType.fromJson(json[r'triggerType'])!,
);
}
return null;
@@ -165,77 +165,3 @@ class WorkflowResponseDto {
};
}
class WorkflowResponseDtoTriggerTypeEnum {
/// Instantiate a new enum with the provided [value].
const WorkflowResponseDtoTriggerTypeEnum._(this.value);
/// The underlying value of this enum member.
final String value;
@override
String toString() => value;
String toJson() => value;
static const assetCreate = WorkflowResponseDtoTriggerTypeEnum._(r'AssetCreate');
static const personRecognized = WorkflowResponseDtoTriggerTypeEnum._(r'PersonRecognized');
/// List of all possible values in this [enum][WorkflowResponseDtoTriggerTypeEnum].
static const values = <WorkflowResponseDtoTriggerTypeEnum>[
assetCreate,
personRecognized,
];
static WorkflowResponseDtoTriggerTypeEnum? fromJson(dynamic value) => WorkflowResponseDtoTriggerTypeEnumTypeTransformer().decode(value);
static List<WorkflowResponseDtoTriggerTypeEnum> listFromJson(dynamic json, {bool growable = false,}) {
final result = <WorkflowResponseDtoTriggerTypeEnum>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = WorkflowResponseDtoTriggerTypeEnum.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
}
/// Transformation class that can [encode] an instance of [WorkflowResponseDtoTriggerTypeEnum] to String,
/// and [decode] dynamic data back to [WorkflowResponseDtoTriggerTypeEnum].
class WorkflowResponseDtoTriggerTypeEnumTypeTransformer {
factory WorkflowResponseDtoTriggerTypeEnumTypeTransformer() => _instance ??= const WorkflowResponseDtoTriggerTypeEnumTypeTransformer._();
const WorkflowResponseDtoTriggerTypeEnumTypeTransformer._();
String encode(WorkflowResponseDtoTriggerTypeEnum data) => data.value;
/// Decodes a [dynamic value][data] to a WorkflowResponseDtoTriggerTypeEnum.
///
/// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
/// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
/// cannot be decoded successfully, then an [UnimplementedError] is thrown.
///
/// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
/// and users are still using an old app with the old code.
WorkflowResponseDtoTriggerTypeEnum? decode(dynamic data, {bool allowNull = true}) {
if (data != null) {
switch (data) {
case r'AssetCreate': return WorkflowResponseDtoTriggerTypeEnum.assetCreate;
case r'PersonRecognized': return WorkflowResponseDtoTriggerTypeEnum.personRecognized;
default:
if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data');
}
}
}
return null;
}
/// Singleton [WorkflowResponseDtoTriggerTypeEnumTypeTransformer] instance.
static WorkflowResponseDtoTriggerTypeEnumTypeTransformer? _instance;
}
+20 -3
View File
@@ -18,6 +18,7 @@ class WorkflowUpdateDto {
this.enabled,
this.filters = const [],
this.name,
this.triggerType,
});
List<WorkflowActionItemDto> actions;
@@ -48,13 +49,22 @@ class WorkflowUpdateDto {
///
String? name;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
PluginTriggerType? triggerType;
@override
bool operator ==(Object other) => identical(this, other) || other is WorkflowUpdateDto &&
_deepEquality.equals(other.actions, actions) &&
other.description == description &&
other.enabled == enabled &&
_deepEquality.equals(other.filters, filters) &&
other.name == name;
other.name == name &&
other.triggerType == triggerType;
@override
int get hashCode =>
@@ -63,10 +73,11 @@ class WorkflowUpdateDto {
(description == null ? 0 : description!.hashCode) +
(enabled == null ? 0 : enabled!.hashCode) +
(filters.hashCode) +
(name == null ? 0 : name!.hashCode);
(name == null ? 0 : name!.hashCode) +
(triggerType == null ? 0 : triggerType!.hashCode);
@override
String toString() => 'WorkflowUpdateDto[actions=$actions, description=$description, enabled=$enabled, filters=$filters, name=$name]';
String toString() => 'WorkflowUpdateDto[actions=$actions, description=$description, enabled=$enabled, filters=$filters, name=$name, triggerType=$triggerType]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@@ -87,6 +98,11 @@ class WorkflowUpdateDto {
} else {
// json[r'name'] = null;
}
if (this.triggerType != null) {
json[r'triggerType'] = this.triggerType;
} else {
// json[r'triggerType'] = null;
}
return json;
}
@@ -104,6 +120,7 @@ class WorkflowUpdateDto {
enabled: mapValueOfType<bool>(json, r'enabled'),
filters: WorkflowFilterItemDto.listFromJson(json[r'filters']),
name: mapValueOfType<String>(json, r'name'),
triggerType: PluginTriggerType.fromJson(json[r'triggerType']),
);
}
return null;
+9 -2
View File
@@ -1,3 +1,10 @@
export 'src/buttons/close_button.dart';
export 'src/buttons/icon_button.dart';
export 'src/components/close_button.dart';
export 'src/components/form.dart';
export 'src/components/icon_button.dart';
export 'src/components/password_input.dart';
export 'src/components/text_button.dart';
export 'src/components/text_input.dart';
export 'src/constants.dart';
export 'src/theme.dart';
export 'src/translation.dart';
export 'src/types.dart';
@@ -1,15 +1,16 @@
import 'package:flutter/material.dart';
import 'package:immich_ui/src/buttons/icon_button.dart';
import 'package:immich_ui/src/types.dart';
import 'icon_button.dart';
class ImmichCloseButton extends StatelessWidget {
final VoidCallback? onTap;
final VoidCallback? onPressed;
final ImmichVariant variant;
final ImmichColor color;
const ImmichCloseButton({
super.key,
this.onTap,
this.onPressed,
this.color = ImmichColor.primary,
this.variant = ImmichVariant.ghost,
});
@@ -20,6 +21,6 @@ class ImmichCloseButton extends StatelessWidget {
icon: Icons.close,
color: color,
variant: variant,
onTap: onTap ?? () => Navigator.of(context).pop(),
onPressed: onPressed ?? () => Navigator.of(context).pop(),
);
}
@@ -0,0 +1,98 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:immich_ui/immich_ui.dart';
import 'package:immich_ui/src/internal.dart';
class ImmichForm extends StatefulWidget {
final String? submitText;
final IconData? submitIcon;
final FutureOr<void> Function()? onSubmit;
final Widget child;
const ImmichForm({
super.key,
this.submitText,
this.submitIcon,
required this.onSubmit,
required this.child,
});
@override
State<ImmichForm> createState() => ImmichFormState();
static ImmichFormState of(BuildContext context) {
final scope = context.dependOnInheritedWidgetOfExactType<_ImmichFormScope>();
if (scope == null) {
throw FlutterError(
'ImmichForm.of() called with a context that does not contain an ImmichForm.\n'
'No ImmichForm ancestor could be found starting from the context that was passed to '
'ImmichForm.of(). This usually happens when the context provided is '
'from a widget above the ImmichForm.\n'
'The context used was:\n'
'$context',
);
}
return scope._formState;
}
}
class ImmichFormState extends State<ImmichForm> {
final _formKey = GlobalKey<FormState>();
bool _isLoading = false;
FutureOr<void> submit() async {
final isValid = _formKey.currentState?.validate() ?? false;
if (!isValid) {
return;
}
setState(() {
_isLoading = true;
});
try {
await widget.onSubmit?.call();
} finally {
if (mounted) {
setState(() {
_isLoading = false;
});
}
}
}
@override
Widget build(BuildContext context) {
final submitText = widget.submitText ?? context.translations.submit;
return _ImmichFormScope(
formState: this,
child: Form(
key: _formKey,
child: Column(
spacing: ImmichSpacing.md,
children: [
widget.child,
ImmichTextButton(
labelText: submitText,
icon: widget.submitIcon,
variant: ImmichVariant.filled,
loading: _isLoading,
onPressed: submit,
disabled: widget.onSubmit == null,
),
],
),
),
);
}
}
class _ImmichFormScope extends InheritedWidget {
const _ImmichFormScope({required super.child, required ImmichFormState formState}) : _formState = formState;
final ImmichFormState _formState;
@override
bool updateShouldNotify(_ImmichFormScope oldWidget) => oldWidget._formState != _formState;
}
@@ -3,42 +3,48 @@ import 'package:immich_ui/src/types.dart';
class ImmichIconButton extends StatelessWidget {
final IconData icon;
final VoidCallback onTap;
final VoidCallback onPressed;
final ImmichVariant variant;
final ImmichColor color;
final bool disabled;
const ImmichIconButton({
super.key,
required this.icon,
required this.onTap,
required this.onPressed,
this.color = ImmichColor.primary,
this.variant = ImmichVariant.filled,
this.disabled = false,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final background = switch (variant) {
ImmichVariant.filled => switch (color) {
ImmichColor.primary => Theme.of(context).colorScheme.primary,
ImmichColor.secondary => Theme.of(context).colorScheme.secondary,
ImmichColor.primary => colorScheme.primary,
ImmichColor.secondary => colorScheme.secondary,
},
ImmichVariant.ghost => Colors.transparent,
};
final foreground = switch (variant) {
ImmichVariant.filled => switch (color) {
ImmichColor.primary => Theme.of(context).colorScheme.onPrimary,
ImmichColor.secondary => Theme.of(context).colorScheme.onSecondary,
ImmichColor.primary => colorScheme.onPrimary,
ImmichColor.secondary => colorScheme.onSecondary,
},
ImmichVariant.ghost => switch (color) {
ImmichColor.primary => Theme.of(context).colorScheme.primary,
ImmichColor.secondary => Theme.of(context).colorScheme.secondary,
ImmichColor.primary => colorScheme.primary,
ImmichColor.secondary => colorScheme.secondary,
},
};
final effectiveOnPressed = disabled ? null : onPressed;
return IconButton(
icon: Icon(icon),
onPressed: onTap,
onPressed: effectiveOnPressed,
style: IconButton.styleFrom(
backgroundColor: background,
foregroundColor: foreground,
@@ -0,0 +1,58 @@
import 'package:flutter/material.dart';
import 'package:immich_ui/src/components/text_input.dart';
import 'package:immich_ui/src/internal.dart';
class ImmichPasswordInput extends StatefulWidget {
final String? label;
final String? hintText;
final TextEditingController? controller;
final FocusNode? focusNode;
final String? Function(String?)? validator;
final void Function(BuildContext, String)? onSubmit;
final TextInputAction? keyboardAction;
const ImmichPasswordInput({
super.key,
this.controller,
this.focusNode,
this.label,
this.hintText,
this.validator,
this.onSubmit,
this.keyboardAction,
});
@override
State createState() => _ImmichPasswordInputState();
}
class _ImmichPasswordInputState extends State<ImmichPasswordInput> {
bool _visible = false;
void _toggleVisibility() {
setState(() {
_visible = !_visible;
});
}
@override
Widget build(BuildContext context) {
return ImmichTextInput(
key: widget.key,
label: widget.label ?? context.translations.password,
hintText: widget.hintText,
controller: widget.controller,
focusNode: widget.focusNode,
validator: widget.validator,
onSubmit: widget.onSubmit,
keyboardAction: widget.keyboardAction,
obscureText: !_visible,
suffixIcon: IconButton(
onPressed: _toggleVisibility,
icon: Icon(_visible ? Icons.visibility_off_rounded : Icons.visibility_rounded),
),
autofillHints: [AutofillHints.password],
keyboardType: TextInputType.text,
);
}
}
@@ -0,0 +1,87 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:immich_ui/src/constants.dart';
import 'package:immich_ui/src/types.dart';
class ImmichTextButton extends StatelessWidget {
final String labelText;
final IconData? icon;
final FutureOr<void> Function() onPressed;
final ImmichVariant variant;
final ImmichColor color;
final bool expanded;
final bool loading;
final bool disabled;
const ImmichTextButton({
super.key,
required this.labelText,
this.icon,
required this.onPressed,
this.variant = ImmichVariant.filled,
this.color = ImmichColor.primary,
this.expanded = true,
this.loading = false,
this.disabled = false,
});
Widget _buildButton(ImmichVariant variant) {
final Widget? effectiveIcon = loading
? const SizedBox.square(
dimension: ImmichIconSize.md,
child: CircularProgressIndicator(strokeWidth: ImmichBorderWidth.lg),
)
: icon != null
? Icon(icon, fontWeight: FontWeight.w600)
: null;
final hasIcon = effectiveIcon != null;
final label = Text(labelText, style: const TextStyle(fontSize: ImmichTextSize.body, fontWeight: FontWeight.bold));
final style = ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: ImmichSpacing.md));
final effectiveOnPressed = disabled || loading ? null : onPressed;
switch (variant) {
case ImmichVariant.filled:
if (hasIcon) {
return ElevatedButton.icon(
style: style,
onPressed: effectiveOnPressed,
icon: effectiveIcon,
label: label,
);
}
return ElevatedButton(
style: style,
onPressed: effectiveOnPressed,
child: label,
);
case ImmichVariant.ghost:
if (hasIcon) {
return TextButton.icon(
style: style,
onPressed: effectiveOnPressed,
icon: effectiveIcon,
label: label,
);
}
return TextButton(
style: style,
onPressed: effectiveOnPressed,
child: label,
);
}
}
@override
Widget build(BuildContext context) {
final button = _buildButton(variant);
if (expanded) {
return SizedBox(width: double.infinity, child: button);
}
return button;
}
}
@@ -0,0 +1,88 @@
import 'package:flutter/material.dart';
class ImmichTextInput extends StatefulWidget {
final String label;
final String? hintText;
final TextEditingController? controller;
final FocusNode? focusNode;
final String? Function(String?)? validator;
final void Function(BuildContext, String)? onSubmit;
final TextInputType keyboardType;
final TextInputAction? keyboardAction;
final List<String>? autofillHints;
final Widget? suffixIcon;
final bool obscureText;
const ImmichTextInput({
super.key,
this.controller,
this.focusNode,
required this.label,
this.hintText,
this.validator,
this.onSubmit,
this.keyboardType = TextInputType.text,
this.keyboardAction,
this.autofillHints,
this.suffixIcon,
this.obscureText = false,
});
@override
State createState() => _ImmichTextInputState();
}
class _ImmichTextInputState extends State<ImmichTextInput> {
late final FocusNode _focusNode;
String? _error;
@override
void initState() {
super.initState();
_focusNode = widget.focusNode ?? FocusNode();
}
@override
void dispose() {
if (widget.focusNode == null) {
_focusNode.dispose();
}
super.dispose();
}
String? _validateInput(String? value) {
setState(() {
_error = widget.validator?.call(value);
});
return null;
}
bool get _hasError => _error != null && _error!.isNotEmpty;
@override
Widget build(BuildContext context) {
final themeData = Theme.of(context);
return TextFormField(
controller: widget.controller,
focusNode: _focusNode,
decoration: InputDecoration(
hintText: widget.hintText,
labelText: widget.label,
labelStyle: themeData.inputDecorationTheme.labelStyle?.copyWith(
color: _hasError ? themeData.colorScheme.error : null,
),
errorText: _error,
suffixIcon: widget.suffixIcon,
),
obscureText: widget.obscureText,
validator: _validateInput,
keyboardType: widget.keyboardType,
textInputAction: widget.keyboardAction,
autofillHints: widget.autofillHints,
onTap: () => setState(() => _error = null),
onTapOutside: (_) => _focusNode.unfocus(),
onFieldSubmitted: (value) => widget.onSubmit?.call(context, value),
);
}
}
+199
View File
@@ -0,0 +1,199 @@
/// Spacing constants for gaps between widgets
abstract class ImmichSpacing {
const ImmichSpacing._();
/// Extra small spacing: 4.0
static const double xs = 4.0;
/// Small spacing: 8.0
static const double sm = 8.0;
/// Medium spacing (default): 12.0
static const double md = 12.0;
/// Large spacing: 16.0
static const double lg = 16.0;
/// Extra large spacing: 24.0
static const double xl = 24.0;
/// Extra extra large spacing: 32.0
static const double xxl = 32.0;
/// Extra extra extra large spacing: 48.0
static const double xxxl = 48.0;
}
/// Border radius constants for consistent rounded corners
abstract class ImmichRadius {
const ImmichRadius._();
/// No radius: 0.0
static const double none = 0.0;
/// Extra small radius: 4.0
static const double xs = 4.0;
/// Small radius: 8.0
static const double sm = 8.0;
/// Medium radius (default): 12.0
static const double md = 12.0;
/// Large radius: 16.0
static const double lg = 16.0;
/// Extra large radius: 20.0
static const double xl = 20.0;
/// Extra extra large radius: 24.0
static const double xxl = 24.0;
/// Full circular radius: infinity
static const double full = double.infinity;
}
/// Icon size constants for consistent icon sizing
abstract class ImmichIconSize {
const ImmichIconSize._();
/// Extra small icon: 16.0
static const double xs = 16.0;
/// Small icon: 20.0
static const double sm = 20.0;
/// Medium icon (default): 24.0
static const double md = 24.0;
/// Large icon: 32.0
static const double lg = 32.0;
/// Extra large icon: 40.0
static const double xl = 40.0;
/// Extra extra large icon: 48.0
static const double xxl = 48.0;
}
/// Animation duration constants for consistent timing
abstract class ImmichDuration {
const ImmichDuration._();
/// Extra fast: 100ms
static const Duration extraFast = Duration(milliseconds: 100);
/// Fast: 150ms
static const Duration fast = Duration(milliseconds: 150);
/// Normal: 200ms
static const Duration normal = Duration(milliseconds: 200);
/// Moderate: 300ms
static const Duration moderate = Duration(milliseconds: 300);
/// Slow: 500ms
static const Duration slow = Duration(milliseconds: 500);
/// Extra slow: 700ms
static const Duration extraSlow = Duration(milliseconds: 700);
}
/// Elevation constants for consistent shadows and depth
abstract class ImmichElevation {
const ImmichElevation._();
/// No elevation: 0.0
static const double none = 0.0;
/// Extra small elevation: 1.0
static const double xs = 1.0;
/// Small elevation: 2.0
static const double sm = 2.0;
/// Medium elevation: 4.0
static const double md = 4.0;
/// Large elevation: 8.0
static const double lg = 8.0;
/// Extra large elevation: 12.0
static const double xl = 12.0;
/// Extra extra large elevation: 16.0
static const double xxl = 16.0;
}
/// Border width constants (similar to Tailwind's border-* scale)
abstract class ImmichBorderWidth {
const ImmichBorderWidth._();
/// No border: 0.0
static const double none = 0.0;
/// Hairline border: 0.5
static const double hairline = 0.5;
/// Default border: 1.0 (border)
static const double base = 1.0;
/// Medium border: 2.0 (border-2)
static const double md = 2.0;
/// Large border: 3.0 (border-4)
static const double lg = 3.0;
/// Extra large border: 4.0
static const double xl = 4.0;
}
/// Text size constants with semantic HTML-like naming
/// These follow a type scale for harmonious text hierarchy
abstract class ImmichTextSize {
const ImmichTextSize._();
/// Caption text: 10.0
/// Use for: Tiny labels, legal text, metadata, timestamps
static const double caption = 10.0;
/// Label text: 12.0
/// Use for: Form labels, secondary text, helper text
static const double label = 12.0;
/// Body text: 14.0 (default)
/// Use for: Main body text, paragraphs, default UI text
static const double body = 14.0;
/// Body emphasized: 16.0
/// Use for: Emphasized body text, button labels, tabs
static const double bodyLarge = 16.0;
/// Heading 6: 18.0 (smallest heading)
/// Use for: Subtitles, card titles, section headers
static const double h6 = 18.0;
/// Heading 5: 20.0
/// Use for: Small headings, prominent labels
static const double h5 = 20.0;
/// Heading 4: 24.0
/// Use for: Page titles, dialog titles
static const double h4 = 24.0;
/// Heading 3: 30.0
/// Use for: Section headings, large headings
static const double h3 = 30.0;
/// Heading 2: 36.0
/// Use for: Major section headings
static const double h2 = 36.0;
/// Heading 1: 48.0 (largest heading)
/// Use for: Page hero headings, main titles
static const double h1 = 48.0;
/// Display text: 60.0
/// Use for: Hero numbers, splash screens, extra large display
static const double display = 60.0;
}
+6
View File
@@ -0,0 +1,6 @@
import 'package:flutter/material.dart';
import 'package:immich_ui/src/translation.dart';
extension TranslationHelper on BuildContext {
ImmichTranslations get translations => ImmichTranslationProvider.of(this);
}
+42
View File
@@ -0,0 +1,42 @@
import 'package:flutter/material.dart';
import 'package:immich_ui/src/constants.dart';
class ImmichThemeProvider extends StatelessWidget {
final ColorScheme colorScheme;
final Widget child;
const ImmichThemeProvider({super.key, required this.colorScheme, required this.child});
@override
Widget build(BuildContext context) {
return Theme(
data: Theme.of(context).copyWith(
colorScheme: colorScheme,
brightness: colorScheme.brightness,
inputDecorationTheme: InputDecorationTheme(
floatingLabelBehavior: FloatingLabelBehavior.always,
focusedBorder: OutlineInputBorder(
borderSide: BorderSide(color: colorScheme.primary),
borderRadius: const BorderRadius.all(Radius.circular(ImmichRadius.md)),
),
enabledBorder: OutlineInputBorder(
borderSide: BorderSide(color: colorScheme.primary),
borderRadius: const BorderRadius.all(Radius.circular(ImmichRadius.md)),
),
errorBorder: OutlineInputBorder(
borderSide: BorderSide(color: colorScheme.error),
borderRadius: const BorderRadius.all(Radius.circular(ImmichRadius.md)),
),
focusedErrorBorder: OutlineInputBorder(
borderSide: BorderSide(color: colorScheme.error),
borderRadius: const BorderRadius.all(Radius.circular(ImmichRadius.md)),
),
labelStyle: TextStyle(color: colorScheme.primary, fontWeight: FontWeight.w600),
hintStyle: const TextStyle(fontSize: ImmichTextSize.body),
errorStyle: TextStyle(color: colorScheme.error, fontWeight: FontWeight.w600),
),
),
child: child,
);
}
}
@@ -0,0 +1,31 @@
import 'package:flutter/material.dart';
class ImmichTranslations {
late String submit;
late String password;
ImmichTranslations({String? submit, String? password}) {
this.submit = submit ?? 'Submit';
this.password = password ?? 'Password';
}
}
class ImmichTranslationProvider extends InheritedWidget {
final ImmichTranslations? translations;
const ImmichTranslationProvider({
super.key,
this.translations,
required super.child,
});
static ImmichTranslations of(BuildContext context) {
final provider = context.dependOnInheritedWidgetOfExactType<ImmichTranslationProvider>();
return provider?.translations ?? ImmichTranslations();
}
@override
bool updateShouldNotify(covariant ImmichTranslationProvider oldWidget) {
return oldWidget.translations != translations;
}
}
+1 -1
View File
@@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone
publish_to: 'none'
version: 2.3.1+3028
version: 2.4.1+3030
environment:
sdk: '>=3.8.0 <4.0.0'
@@ -87,6 +87,25 @@ void main() {
verify(() => mockLocalAssetRepository.get('local-1')).called(1);
});
test('uses fetched asset orientation when dimensions are missing on Android', () async {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
// Original asset has default orientation 0, but dimensions are missing
final localAsset = TestUtils.createLocalAsset(id: 'local-1', width: null, height: null, orientation: 0);
// Fetched asset has 90° orientation and proper dimensions
final fetchedAsset = TestUtils.createLocalAsset(id: 'local-1', width: 1920, height: 1080, orientation: 90);
when(() => mockLocalAssetRepository.get('local-1')).thenAnswer((_) async => fetchedAsset);
final result = await sut.getAspectRatio(localAsset);
// Should flip dimensions since fetched asset has 90° orientation
expect(result, 1080 / 1920);
verify(() => mockLocalAssetRepository.get('local-1')).called(1);
});
test('returns 1.0 when dimensions are still unavailable after fetching', () async {
final remoteAsset = TestUtils.createRemoteAsset(id: 'remote-1', width: null, height: null);
@@ -112,7 +131,9 @@ void main() {
expect(result, 1.0);
});
test('handles local asset with remoteId and uses exif from remote', () async {
test('handles local asset with remoteId using local orientation not remote exif', () async {
// When a LocalAsset has a remoteId (merged), we should use local orientation
// because the width/height come from the local asset (pre-corrected on iOS)
final localAsset = TestUtils.createLocalAsset(
id: 'local-1',
remoteId: 'remote-1',
@@ -121,9 +142,24 @@ void main() {
orientation: 0,
);
final exif = const ExifInfo(orientation: '6');
final result = await sut.getAspectRatio(localAsset);
when(() => mockRemoteAssetRepository.getExif('remote-1')).thenAnswer((_) async => exif);
expect(result, 1920 / 1080);
// Should not call remote exif for LocalAsset
verifyNever(() => mockRemoteAssetRepository.getExif(any()));
});
test('handles local asset with remoteId and 90 degree rotation on Android', () async {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
final localAsset = TestUtils.createLocalAsset(
id: 'local-1',
remoteId: 'remote-1',
width: 1920,
height: 1080,
orientation: 90,
);
final result = await sut.getAspectRatio(localAsset);
@@ -0,0 +1,438 @@
import 'package:drift/drift.dart';
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/user.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
void main() {
late Drift db;
late DriftLocalAssetRepository repository;
setUp(() {
db = Drift(DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true));
repository = DriftLocalAssetRepository(db);
});
tearDown(() async {
await db.close();
});
group('getRemovalCandidates', () {
final userId = 'user-123';
final otherUserId = 'user-456';
final now = DateTime(2024, 1, 15);
final cutoffDate = DateTime(2024, 1, 10);
final beforeCutoff = DateTime(2024, 1, 5);
final afterCutoff = DateTime(2024, 1, 12);
Future<void> insertUser(String id, String email) async {
await db.into(db.userEntity).insert(UserEntityCompanion.insert(id: id, email: email, name: email));
}
setUp(() async {
await insertUser(userId, 'user@test.com');
await insertUser(otherUserId, 'other@test.com');
});
Future<void> insertLocalAsset({
required String id,
required String checksum,
required DateTime createdAt,
required AssetType type,
required bool isFavorite,
}) async {
await db
.into(db.localAssetEntity)
.insert(
LocalAssetEntityCompanion.insert(
id: id,
name: 'asset_$id.jpg',
checksum: Value(checksum),
type: type,
createdAt: Value(createdAt),
updatedAt: Value(createdAt),
isFavorite: Value(isFavorite),
),
);
}
Future<void> insertRemoteAsset({
required String id,
required String checksum,
required String ownerId,
DateTime? deletedAt,
}) async {
await db
.into(db.remoteAssetEntity)
.insert(
RemoteAssetEntityCompanion.insert(
id: id,
name: 'remote_$id.jpg',
checksum: checksum,
type: AssetType.image,
createdAt: Value(now),
updatedAt: Value(now),
ownerId: ownerId,
visibility: AssetVisibility.timeline,
deletedAt: Value(deletedAt),
),
);
}
Future<void> insertLocalAlbum({required String id, required String name, required bool isIosSharedAlbum}) async {
await db
.into(db.localAlbumEntity)
.insert(
LocalAlbumEntityCompanion.insert(
id: id,
name: name,
updatedAt: Value(now),
backupSelection: BackupSelection.none,
isIosSharedAlbum: Value(isIosSharedAlbum),
),
);
}
Future<void> insertLocalAlbumAsset({required String albumId, required String assetId}) async {
await db
.into(db.localAlbumAssetEntity)
.insert(LocalAlbumAssetEntityCompanion.insert(albumId: albumId, assetId: assetId));
}
test('returns only assets that match all criteria', () async {
// Asset 1: Should be included - backed up, before cutoff, correct owner, not deleted, not favorite
await insertLocalAsset(
id: 'local-1',
checksum: 'checksum-1',
createdAt: beforeCutoff,
type: AssetType.image,
isFavorite: false,
);
await insertRemoteAsset(id: 'remote-1', checksum: 'checksum-1', ownerId: userId);
// Asset 2: Should NOT be included - not backed up (no remote asset)
await insertLocalAsset(
id: 'local-2',
checksum: 'checksum-2',
createdAt: beforeCutoff,
type: AssetType.image,
isFavorite: false,
);
// Asset 3: Should NOT be included - after cutoff date
await insertLocalAsset(
id: 'local-3',
checksum: 'checksum-3',
createdAt: afterCutoff,
type: AssetType.image,
isFavorite: false,
);
await insertRemoteAsset(id: 'remote-3', checksum: 'checksum-3', ownerId: userId);
// Asset 4: Should NOT be included - different owner
await insertLocalAsset(
id: 'local-4',
checksum: 'checksum-4',
createdAt: beforeCutoff,
type: AssetType.image,
isFavorite: false,
);
await insertRemoteAsset(id: 'remote-4', checksum: 'checksum-4', ownerId: otherUserId);
// Asset 5: Should NOT be included - remote asset is deleted
await insertLocalAsset(
id: 'local-5',
checksum: 'checksum-5',
createdAt: beforeCutoff,
type: AssetType.image,
isFavorite: false,
);
await insertRemoteAsset(id: 'remote-5', checksum: 'checksum-5', ownerId: userId, deletedAt: now);
// Asset 6: Should NOT be included - is favorite (when keepFavorites=true)
await insertLocalAsset(
id: 'local-6',
checksum: 'checksum-6',
createdAt: beforeCutoff,
type: AssetType.image,
isFavorite: true,
);
await insertRemoteAsset(id: 'remote-6', checksum: 'checksum-6', ownerId: userId);
final candidates = await repository.getRemovalCandidates(userId, cutoffDate, keepFavorites: true);
expect(candidates.length, 1);
expect(candidates[0].id, 'local-1');
});
test('includes favorites when keepFavorites is false', () async {
await insertLocalAsset(
id: 'local-favorite',
checksum: 'checksum-fav',
createdAt: beforeCutoff,
type: AssetType.image,
isFavorite: true,
);
await insertRemoteAsset(id: 'remote-favorite', checksum: 'checksum-fav', ownerId: userId);
final candidates = await repository.getRemovalCandidates(userId, cutoffDate, keepFavorites: false);
expect(candidates.length, 1);
expect(candidates[0].id, 'local-favorite');
expect(candidates[0].isFavorite, true);
});
test('filters by photos only', () async {
// Photo
await insertLocalAsset(
id: 'local-photo',
checksum: 'checksum-photo',
createdAt: beforeCutoff,
type: AssetType.image,
isFavorite: false,
);
await insertRemoteAsset(id: 'remote-photo', checksum: 'checksum-photo', ownerId: userId);
// Video
await insertLocalAsset(
id: 'local-video',
checksum: 'checksum-video',
createdAt: beforeCutoff,
type: AssetType.video,
isFavorite: false,
);
await insertRemoteAsset(id: 'remote-video', checksum: 'checksum-video', ownerId: userId);
final candidates = await repository.getRemovalCandidates(
userId,
cutoffDate,
filterType: AssetFilterType.photosOnly,
);
expect(candidates.length, 1);
expect(candidates[0].id, 'local-photo');
expect(candidates[0].type, AssetType.image);
});
test('filters by videos only', () async {
// Photo
await insertLocalAsset(
id: 'local-photo',
checksum: 'checksum-photo',
createdAt: beforeCutoff,
type: AssetType.image,
isFavorite: false,
);
await insertRemoteAsset(id: 'remote-photo', checksum: 'checksum-photo', ownerId: userId);
// Video
await insertLocalAsset(
id: 'local-video',
checksum: 'checksum-video',
createdAt: beforeCutoff,
type: AssetType.video,
isFavorite: false,
);
await insertRemoteAsset(id: 'remote-video', checksum: 'checksum-video', ownerId: userId);
final candidates = await repository.getRemovalCandidates(
userId,
cutoffDate,
filterType: AssetFilterType.videosOnly,
);
expect(candidates.length, 1);
expect(candidates[0].id, 'local-video');
expect(candidates[0].type, AssetType.video);
});
test('returns both photos and videos with filterType.all', () async {
// Photo
await insertLocalAsset(
id: 'local-photo',
checksum: 'checksum-photo',
createdAt: beforeCutoff,
type: AssetType.image,
isFavorite: false,
);
await insertRemoteAsset(id: 'remote-photo', checksum: 'checksum-photo', ownerId: userId);
// Video
await insertLocalAsset(
id: 'local-video',
checksum: 'checksum-video',
createdAt: beforeCutoff,
type: AssetType.video,
isFavorite: false,
);
await insertRemoteAsset(id: 'remote-video', checksum: 'checksum-video', ownerId: userId);
final candidates = await repository.getRemovalCandidates(userId, cutoffDate, filterType: AssetFilterType.all);
expect(candidates.length, 2);
final ids = candidates.map((a) => a.id).toSet();
expect(ids, containsAll(['local-photo', 'local-video']));
});
test('excludes assets in iOS shared albums', () async {
// Regular album
await insertLocalAlbum(id: 'album-regular', name: 'Regular Album', isIosSharedAlbum: false);
// iOS shared album
await insertLocalAlbum(id: 'album-shared', name: 'Shared Album', isIosSharedAlbum: true);
// Asset in regular album (should be included)
await insertLocalAsset(
id: 'local-regular',
checksum: 'checksum-regular',
createdAt: beforeCutoff,
type: AssetType.image,
isFavorite: false,
);
await insertRemoteAsset(id: 'remote-regular', checksum: 'checksum-regular', ownerId: userId);
await insertLocalAlbumAsset(albumId: 'album-regular', assetId: 'local-regular');
// Asset in iOS shared album (should be excluded)
await insertLocalAsset(
id: 'local-shared',
checksum: 'checksum-shared',
createdAt: beforeCutoff,
type: AssetType.image,
isFavorite: false,
);
await insertRemoteAsset(id: 'remote-shared', checksum: 'checksum-shared', ownerId: userId);
await insertLocalAlbumAsset(albumId: 'album-shared', assetId: 'local-shared');
final candidates = await repository.getRemovalCandidates(userId, cutoffDate);
expect(candidates.length, 1);
expect(candidates[0].id, 'local-regular');
});
test('includes assets at exact cutoff date', () async {
await insertLocalAsset(
id: 'local-exact',
checksum: 'checksum-exact',
createdAt: cutoffDate,
type: AssetType.image,
isFavorite: false,
);
await insertRemoteAsset(id: 'remote-exact', checksum: 'checksum-exact', ownerId: userId);
final candidates = await repository.getRemovalCandidates(userId, cutoffDate);
expect(candidates.length, 1);
expect(candidates[0].id, 'local-exact');
});
test('returns empty list when no assets match criteria', () async {
// Only assets after cutoff
await insertLocalAsset(
id: 'local-after',
checksum: 'checksum-after',
createdAt: afterCutoff,
type: AssetType.image,
isFavorite: false,
);
await insertRemoteAsset(id: 'remote-after', checksum: 'checksum-after', ownerId: userId);
final candidates = await repository.getRemovalCandidates(userId, cutoffDate);
expect(candidates, isEmpty);
});
test('handles multiple assets with same checksum', () async {
// Two local assets with same checksum (edge case, but should handle it)
await insertLocalAsset(
id: 'local-dup1',
checksum: 'checksum-dup',
createdAt: beforeCutoff,
type: AssetType.image,
isFavorite: false,
);
await insertLocalAsset(
id: 'local-dup2',
checksum: 'checksum-dup',
createdAt: beforeCutoff,
type: AssetType.image,
isFavorite: false,
);
await insertRemoteAsset(id: 'remote-dup', checksum: 'checksum-dup', ownerId: userId);
final candidates = await repository.getRemovalCandidates(userId, cutoffDate);
expect(candidates.length, 2);
expect(candidates.map((a) => a.checksum).toSet(), equals({'checksum-dup'}));
});
test('includes assets not in any album', () async {
// Asset not in any album should be included
await insertLocalAsset(
id: 'local-no-album',
checksum: 'checksum-no-album',
createdAt: beforeCutoff,
type: AssetType.image,
isFavorite: false,
);
await insertRemoteAsset(id: 'remote-no-album', checksum: 'checksum-no-album', ownerId: userId);
final candidates = await repository.getRemovalCandidates(userId, cutoffDate);
expect(candidates.length, 1);
expect(candidates[0].id, 'local-no-album');
});
test('excludes asset that is in both regular and iOS shared album', () async {
// Regular album
await insertLocalAlbum(id: 'album-regular', name: 'Regular Album', isIosSharedAlbum: false);
// iOS shared album
await insertLocalAlbum(id: 'album-shared', name: 'Shared Album', isIosSharedAlbum: true);
// Asset in BOTH albums - should be excluded because it's in an iOS shared album
await insertLocalAsset(
id: 'local-both',
checksum: 'checksum-both',
createdAt: beforeCutoff,
type: AssetType.image,
isFavorite: false,
);
await insertRemoteAsset(id: 'remote-both', checksum: 'checksum-both', ownerId: userId);
await insertLocalAlbumAsset(albumId: 'album-regular', assetId: 'local-both');
await insertLocalAlbumAsset(albumId: 'album-shared', assetId: 'local-both');
final candidates = await repository.getRemovalCandidates(userId, cutoffDate);
expect(candidates, isEmpty);
});
test('excludes assets with null checksum (not backed up)', () async {
// Asset with null checksum cannot be matched to remote asset
await db
.into(db.localAssetEntity)
.insert(
LocalAssetEntityCompanion.insert(
id: 'local-null-checksum',
name: 'asset_null.jpg',
checksum: const Value.absent(), // null checksum
type: AssetType.image,
createdAt: Value(beforeCutoff),
updatedAt: Value(beforeCutoff),
isFavorite: const Value(false),
),
);
final candidates = await repository.getRemovalCandidates(userId, cutoffDate);
expect(candidates, isEmpty);
});
});
}
@@ -0,0 +1,500 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/presentation/widgets/remote_album/drift_album_option.widget.dart';
import '../../../widget_tester_extensions.dart';
void main() {
group('DriftRemoteAlbumOption', () {
testWidgets('shows kebab menu icon button', (tester) async {
await tester.pumpConsumerWidget(
const DriftRemoteAlbumOption(),
);
expect(find.byIcon(Icons.more_vert_rounded), findsOneWidget);
});
testWidgets('opens menu when icon button is tapped', (tester) async {
await tester.pumpConsumerWidget(
DriftRemoteAlbumOption(
onEditAlbum: () {},
),
);
await tester.tap(find.byIcon(Icons.more_vert_rounded));
await tester.pumpAndSettle();
expect(find.byIcon(Icons.edit), findsOneWidget);
});
testWidgets('shows edit album option when onEditAlbum is provided',
(tester) async {
bool editCalled = false;
await tester.pumpConsumerWidget(
DriftRemoteAlbumOption(
onEditAlbum: () => editCalled = true,
),
);
await tester.tap(find.byIcon(Icons.more_vert_rounded));
await tester.pumpAndSettle();
expect(find.byIcon(Icons.edit), findsOneWidget);
await tester.tap(find.byIcon(Icons.edit));
await tester.pumpAndSettle();
expect(editCalled, isTrue);
});
testWidgets('hides edit album option when onEditAlbum is null',
(tester) async {
await tester.pumpConsumerWidget(
DriftRemoteAlbumOption(
onAddPhotos: () {},
),
);
await tester.tap(find.byIcon(Icons.more_vert_rounded));
await tester.pumpAndSettle();
expect(find.byIcon(Icons.edit), findsNothing);
});
testWidgets('shows add photos option when onAddPhotos is provided',
(tester) async {
bool addPhotosCalled = false;
await tester.pumpConsumerWidget(
DriftRemoteAlbumOption(
onAddPhotos: () => addPhotosCalled = true,
),
);
await tester.tap(find.byIcon(Icons.more_vert_rounded));
await tester.pumpAndSettle();
expect(find.byIcon(Icons.add_a_photo), findsOneWidget);
await tester.tap(find.byIcon(Icons.add_a_photo));
await tester.pumpAndSettle();
expect(addPhotosCalled, isTrue);
});
testWidgets('hides add photos option when onAddPhotos is null',
(tester) async {
await tester.pumpConsumerWidget(
DriftRemoteAlbumOption(
onEditAlbum: () {},
),
);
await tester.tap(find.byIcon(Icons.more_vert_rounded));
await tester.pumpAndSettle();
expect(find.byIcon(Icons.add_a_photo), findsNothing);
});
testWidgets('shows add users option when onAddUsers is provided',
(tester) async {
bool addUsersCalled = false;
await tester.pumpConsumerWidget(
DriftRemoteAlbumOption(
onAddUsers: () => addUsersCalled = true,
),
);
await tester.tap(find.byIcon(Icons.more_vert_rounded));
await tester.pumpAndSettle();
expect(find.byIcon(Icons.group_add), findsOneWidget);
await tester.tap(find.byIcon(Icons.group_add));
await tester.pumpAndSettle();
expect(addUsersCalled, isTrue);
});
testWidgets('hides add users option when onAddUsers is null',
(tester) async {
await tester.pumpConsumerWidget(
DriftRemoteAlbumOption(
onEditAlbum: () {},
),
);
await tester.tap(find.byIcon(Icons.more_vert_rounded));
await tester.pumpAndSettle();
expect(find.byIcon(Icons.group_add), findsNothing);
});
testWidgets('shows leave album option when onLeaveAlbum is provided',
(tester) async {
bool leaveAlbumCalled = false;
await tester.pumpConsumerWidget(
DriftRemoteAlbumOption(
onLeaveAlbum: () => leaveAlbumCalled = true,
),
);
await tester.tap(find.byIcon(Icons.more_vert_rounded));
await tester.pumpAndSettle();
expect(find.byIcon(Icons.person_remove_rounded), findsOneWidget);
await tester.tap(find.byIcon(Icons.person_remove_rounded));
await tester.pumpAndSettle();
expect(leaveAlbumCalled, isTrue);
});
testWidgets('hides leave album option when onLeaveAlbum is null',
(tester) async {
await tester.pumpConsumerWidget(
DriftRemoteAlbumOption(
onEditAlbum: () {},
),
);
await tester.tap(find.byIcon(Icons.more_vert_rounded));
await tester.pumpAndSettle();
expect(find.byIcon(Icons.person_remove_rounded), findsNothing);
});
testWidgets(
'shows toggle album order option when onToggleAlbumOrder is provided',
(tester) async {
bool toggleOrderCalled = false;
await tester.pumpConsumerWidget(
DriftRemoteAlbumOption(
onToggleAlbumOrder: () => toggleOrderCalled = true,
),
);
await tester.tap(find.byIcon(Icons.more_vert_rounded));
await tester.pumpAndSettle();
expect(find.byIcon(Icons.swap_vert_rounded), findsOneWidget);
await tester.tap(find.byIcon(Icons.swap_vert_rounded));
await tester.pumpAndSettle();
expect(toggleOrderCalled, isTrue);
});
testWidgets('hides toggle album order option when onToggleAlbumOrder is null',
(tester) async {
await tester.pumpConsumerWidget(
DriftRemoteAlbumOption(
onEditAlbum: () {},
),
);
await tester.tap(find.byIcon(Icons.more_vert_rounded));
await tester.pumpAndSettle();
expect(find.byIcon(Icons.swap_vert_rounded), findsNothing);
});
testWidgets(
'shows create shared link option when onCreateSharedLink is provided',
(tester) async {
bool createSharedLinkCalled = false;
await tester.pumpConsumerWidget(
DriftRemoteAlbumOption(
onCreateSharedLink: () => createSharedLinkCalled = true,
),
);
await tester.tap(find.byIcon(Icons.more_vert_rounded));
await tester.pumpAndSettle();
expect(find.byIcon(Icons.link), findsOneWidget);
await tester.tap(find.byIcon(Icons.link));
await tester.pumpAndSettle();
expect(createSharedLinkCalled, isTrue);
});
testWidgets('hides create shared link option when onCreateSharedLink is null',
(tester) async {
await tester.pumpConsumerWidget(
DriftRemoteAlbumOption(
onEditAlbum: () {},
),
);
await tester.tap(find.byIcon(Icons.more_vert_rounded));
await tester.pumpAndSettle();
expect(find.byIcon(Icons.link), findsNothing);
});
testWidgets('shows options option when onShowOptions is provided',
(tester) async {
bool showOptionsCalled = false;
await tester.pumpConsumerWidget(
DriftRemoteAlbumOption(
onShowOptions: () => showOptionsCalled = true,
),
);
await tester.tap(find.byIcon(Icons.more_vert_rounded));
await tester.pumpAndSettle();
expect(find.byIcon(Icons.settings), findsOneWidget);
await tester.tap(find.byIcon(Icons.settings));
await tester.pumpAndSettle();
expect(showOptionsCalled, isTrue);
});
testWidgets('hides options option when onShowOptions is null',
(tester) async {
await tester.pumpConsumerWidget(
DriftRemoteAlbumOption(
onEditAlbum: () {},
),
);
await tester.tap(find.byIcon(Icons.more_vert_rounded));
await tester.pumpAndSettle();
expect(find.byIcon(Icons.settings), findsNothing);
});
testWidgets('shows delete album option when onDeleteAlbum is provided',
(tester) async {
bool deleteAlbumCalled = false;
await tester.pumpConsumerWidget(
DriftRemoteAlbumOption(
onDeleteAlbum: () => deleteAlbumCalled = true,
),
);
await tester.tap(find.byIcon(Icons.more_vert_rounded));
await tester.pumpAndSettle();
expect(find.byIcon(Icons.delete), findsOneWidget);
await tester.tap(find.byIcon(Icons.delete));
await tester.pumpAndSettle();
expect(deleteAlbumCalled, isTrue);
});
testWidgets('hides delete album option when onDeleteAlbum is null',
(tester) async {
await tester.pumpConsumerWidget(
DriftRemoteAlbumOption(
onEditAlbum: () {},
),
);
await tester.tap(find.byIcon(Icons.more_vert_rounded));
await tester.pumpAndSettle();
expect(find.byIcon(Icons.delete), findsNothing);
});
testWidgets('shows divider before delete album option', (tester) async {
await tester.pumpConsumerWidget(
DriftRemoteAlbumOption(
onEditAlbum: () {},
onDeleteAlbum: () {},
),
);
await tester.tap(find.byIcon(Icons.more_vert_rounded));
await tester.pumpAndSettle();
expect(find.byType(Divider), findsOneWidget);
});
testWidgets('shows all options when all callbacks are provided',
(tester) async {
await tester.pumpConsumerWidget(
DriftRemoteAlbumOption(
onEditAlbum: () {},
onAddPhotos: () {},
onAddUsers: () {},
onLeaveAlbum: () {},
onToggleAlbumOrder: () {},
onCreateSharedLink: () {},
onShowOptions: () {},
onDeleteAlbum: () {},
),
);
await tester.tap(find.byIcon(Icons.more_vert_rounded));
await tester.pumpAndSettle();
expect(find.byIcon(Icons.edit), findsOneWidget);
expect(find.byIcon(Icons.add_a_photo), findsOneWidget);
expect(find.byIcon(Icons.group_add), findsOneWidget);
expect(find.byIcon(Icons.person_remove_rounded), findsOneWidget);
expect(find.byIcon(Icons.swap_vert_rounded), findsOneWidget);
expect(find.byIcon(Icons.link), findsOneWidget);
expect(find.byIcon(Icons.settings), findsOneWidget);
expect(find.byIcon(Icons.delete), findsOneWidget);
expect(find.byType(Divider), findsOneWidget);
});
testWidgets('shows no options when all callbacks are null', (tester) async {
await tester.pumpConsumerWidget(
const DriftRemoteAlbumOption(),
);
await tester.tap(find.byIcon(Icons.more_vert_rounded));
await tester.pumpAndSettle();
expect(find.byIcon(Icons.edit), findsNothing);
expect(find.byIcon(Icons.add_a_photo), findsNothing);
expect(find.byIcon(Icons.group_add), findsNothing);
expect(find.byIcon(Icons.person_remove_rounded), findsNothing);
expect(find.byIcon(Icons.swap_vert_rounded), findsNothing);
expect(find.byIcon(Icons.link), findsNothing);
expect(find.byIcon(Icons.settings), findsNothing);
expect(find.byIcon(Icons.delete), findsNothing);
});
testWidgets('uses custom icon color when provided', (tester) async {
const customColor = Colors.red;
await tester.pumpConsumerWidget(
const DriftRemoteAlbumOption(
iconColor: customColor,
),
);
final iconButton = tester.widget<IconButton>(find.byType(IconButton));
final icon = iconButton.icon as Icon;
expect(icon.color, equals(customColor));
});
testWidgets('uses default white color when iconColor is null',
(tester) async {
await tester.pumpConsumerWidget(
const DriftRemoteAlbumOption(),
);
final iconButton = tester.widget<IconButton>(find.byType(IconButton));
final icon = iconButton.icon as Icon;
expect(icon.color, equals(Colors.white));
});
testWidgets('applies icon shadows when provided', (tester) async {
final shadows = [
const Shadow(offset: Offset(0, 2), blurRadius: 5, color: Colors.black),
];
await tester.pumpConsumerWidget(
DriftRemoteAlbumOption(
iconShadows: shadows,
),
);
final iconButton = tester.widget<IconButton>(find.byType(IconButton));
final icon = iconButton.icon as Icon;
expect(icon.shadows, equals(shadows));
});
group('owner vs non-owner scenarios', () {
testWidgets('owner sees all management options', (tester) async {
// Simulating owner scenario - all callbacks provided
await tester.pumpConsumerWidget(
DriftRemoteAlbumOption(
onEditAlbum: () {},
onAddPhotos: () {},
onAddUsers: () {},
onToggleAlbumOrder: () {},
onCreateSharedLink: () {},
onShowOptions: () {},
onDeleteAlbum: () {},
),
);
await tester.tap(find.byIcon(Icons.more_vert_rounded));
await tester.pumpAndSettle();
// Owner should see all management options
expect(find.byIcon(Icons.edit), findsOneWidget);
expect(find.byIcon(Icons.add_a_photo), findsOneWidget);
expect(find.byIcon(Icons.group_add), findsOneWidget);
expect(find.byIcon(Icons.swap_vert_rounded), findsOneWidget);
expect(find.byIcon(Icons.link), findsOneWidget);
expect(find.byIcon(Icons.delete), findsOneWidget);
// Owner should NOT see leave album
expect(find.byIcon(Icons.person_remove_rounded), findsNothing);
});
testWidgets('non-owner with editor role sees limited options',
(tester) async {
// Simulating non-owner with editor role - can add photos, show options, leave
await tester.pumpConsumerWidget(
DriftRemoteAlbumOption(
onAddPhotos: () {},
onShowOptions: () {},
onLeaveAlbum: () {},
),
);
await tester.tap(find.byIcon(Icons.more_vert_rounded));
await tester.pumpAndSettle();
// Editor can add photos
expect(find.byIcon(Icons.add_a_photo), findsOneWidget);
// Can see options
expect(find.byIcon(Icons.settings), findsOneWidget);
// Can leave album
expect(find.byIcon(Icons.person_remove_rounded), findsOneWidget);
// Cannot see owner-only options
expect(find.byIcon(Icons.edit), findsNothing);
expect(find.byIcon(Icons.group_add), findsNothing);
expect(find.byIcon(Icons.swap_vert_rounded), findsNothing);
expect(find.byIcon(Icons.link), findsNothing);
expect(find.byIcon(Icons.delete), findsNothing);
});
testWidgets('non-owner viewer sees minimal options', (tester) async {
// Simulating viewer - can only show options and leave
await tester.pumpConsumerWidget(
DriftRemoteAlbumOption(
onShowOptions: () {},
onLeaveAlbum: () {},
),
);
await tester.tap(find.byIcon(Icons.more_vert_rounded));
await tester.pumpAndSettle();
// Can see options
expect(find.byIcon(Icons.settings), findsOneWidget);
// Can leave album
expect(find.byIcon(Icons.person_remove_rounded), findsOneWidget);
// Cannot see any other options
expect(find.byIcon(Icons.edit), findsNothing);
expect(find.byIcon(Icons.add_a_photo), findsNothing);
expect(find.byIcon(Icons.group_add), findsNothing);
expect(find.byIcon(Icons.swap_vert_rounded), findsNothing);
expect(find.byIcon(Icons.link), findsNothing);
expect(find.byIcon(Icons.delete), findsNothing);
});
});
});
}