mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
Merge branch 'main' into feature/bottom-buttons-order
This commit is contained in:
@@ -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')
|
||||
|
||||
@@ -33,4 +33,5 @@ Runner/GeneratedPluginRegistrant.*
|
||||
!default.perspectivev3
|
||||
|
||||
fastlane/report.xml
|
||||
Gemfile.lock
|
||||
Gemfile.lock
|
||||
certs/
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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: '/'),
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Generated
+11
-3
@@ -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)
|
||||
|
||||
Generated
+7
-2
@@ -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';
|
||||
|
||||
Generated
+124
-17
@@ -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));
|
||||
}
|
||||
|
||||
Generated
+51
@@ -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
@@ -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));
|
||||
}
|
||||
|
||||
Generated
+14
-4
@@ -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':
|
||||
|
||||
Generated
+2
-5
@@ -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
@@ -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
@@ -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
@@ -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')!,
|
||||
);
|
||||
}
|
||||
|
||||
Generated
+24
-24
@@ -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
@@ -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')!,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
+5
-4
@@ -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;
|
||||
}
|
||||
+15
-9
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user