Compare commits

...

20 Commits

Author SHA1 Message Date
shenlong-tanwen 77cfa43fb0 refactor: migrate album config 2026-05-18 22:59:29 +05:30
shenlong 9cffcc9f4e refactor: migrate network config (#28471) 2026-05-18 16:22:42 +00:00
shenlong 40925f0a06 refactor: immich form and text input (#28479)
refacotr: immich form

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-05-18 16:21:36 +00:00
Oliver Roed Schøler 0544d22902 feat: Selectable metadata in duplicates utility with diffing (#26328) 2026-05-18 17:49:51 +02:00
Jason Rasmussen 3d075f2bf8 feat: workflows & plugins (#26727)
feat: plugins

chore: better types

feat: plugins
2026-05-18 11:09:33 -04:00
Luis Nachtigall 7384799f19 fix(mobile): asset viewer stuck on spinner after rotation (#28019) 2026-05-18 20:32:51 +05:30
Alex 4a7f06e8fd feat: upload and add local asset directly to album (#28123)
* feat: manually upload local assets to album

* feat: manually upload local assets to album

* refactor

* Upload status

* pr feedback
2026-05-18 20:31:22 +05:30
Lauritz Tieste 8f662fc459 refactor: enhance shared link UI and functionality (#26464)
* feat(shared-link): enhance shared link UI and functionality with new expiry options and improved layout

* rebase & cleanup

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-05-18 20:29:56 +05:30
Benjamin Nguyen 24b1dae9f2 feat(mobile): "Add Tags" asset multiselect option (#26269)
* add bulk_tag_assets_action_button to general_bottom_sheet.widget

include create tag tile in 'Add Tags' action modal

* follow provider -> svc -> repo pattern for tags

* rebase and cleanup

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-05-18 20:29:09 +05:30
Lauritz Tieste 3a3469a5f9 feat(ui): add ImmichURLInput (#27105)
feat(ui): implement shared URL input configuration and update input fields
2026-05-18 20:28:57 +05:30
Adam Gastineau 7993619ed2 fix(ios): respect status bar scroll to top in timeline views (#28469)
* fix(ios): respect status bar scroll to top in library views

* Make sure to wrap all loading states in Scaffold
2026-05-18 20:28:01 +05:30
shenlong 4d1f6f869b chore: cleanup mobile mise config (#28473)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-05-18 19:18:52 +05:30
Yaros 3eb03f7934 chore: update readmes to match main (#28458) 2026-05-17 13:08:27 -05:00
Alex 03ed3daa31 chore: improve mobile slideshow (#28460) 2026-05-17 10:54:21 -05:00
Min Idzelis 02581e81a7 fix(web): work around Chrome HDR image seam lines during zoom (#27715)
Change-Id: Ic5a5b1a476c2af93b465ef23dabc601a6a6a6964

Co-authored-by: Alex <alex.tran1502@gmail.com>
2026-05-16 02:15:24 +00:00
Santo Shakil 3ab3d5cf43 fix(mobile): don't force-unwrap nil localizedTitle in ios getAlbums (#28452)
crashes on ios 26 when a PHAssetCollection returns nil for
localizedTitle. fall back to localIdentifier. ref #28428
2026-05-15 18:12:28 -05:00
Ben Beckford 0ef04d9baa feat(mobile): slideshow view (#28421)
* feat(mobile): slideshow view

* move slideshow settings to metadata store

* remove watch in initState

* wrap progress bar in safearea

* show slideshow button on remote albums

* fix crash on unknown assets

* always show slideshow option

* add zoom effect

* add padding to slideshow settings

* chore: styling tweak

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2026-05-15 18:12:04 -05:00
Santo Shakil df016f9228 fix(mobile): mounted check in ThumbnailTile hero flight listener (#28451)
When the user pops back from the asset viewer mid-flight, the hero
animation can fire its status listener after _ThumbnailTileState has
been disposed. setState then throws a null check on State._element.

Guard the listener with `if (!mounted) return;` — same pattern as
#28300 in the album sync action.
2026-05-15 21:41:04 +00:00
Santo Shakil 17779c1e74 fix(mobile): cronet thumbnail buffer overflow regression from #28439 (#28450)
The hybrid added in onReadCompleted reuses Cronet's ByteBuffer between
reads to save a JNI wrap call when no grow is needed. That reuse breaks
advance() — Cronet's position() is cumulative across reads, so the same
K bytes get counted on every subsequent iteration. b.offset overshoots
b.capacity, the reuse branch keeps firing on a now-empty buffer, and
request.read() throws the original IllegalArgumentException again.

Always pass a fresh wrap from wrapRemaining() so byteBuffer.position()
reflects only this iteration's bytes. Same shape as the original PR
had before the broken optimization was layered on top.
2026-05-15 17:25:31 -04:00
Santo Shakil 01d6a244d8 fix(mobile): cronet buffer overflow on compressed thumbnails (#28439)
CronetImageFetcher sized the response buffer from Content-Length, which is
the compressed wire size. Cronet auto-decompresses gzip/br responses and
writes decompressed bytes into the buffer, exceeding it and throwing
IllegalArgumentException: ByteBuffer is already full on the next read. Use
the growable path; Content-Length becomes an initial alloc hint only,
capped at 128 MB so an untrusted server can't overflow Int.MAX_VALUE or
OOM us upfront. Reuse Cronet's ByteBuffer between reads when no grow is
needed.
2026-05-15 14:48:23 -04:00
240 changed files with 9507 additions and 8705 deletions
@@ -16,7 +16,7 @@ services:
- ${UPLOAD_LOCATION:-upload-devcontainer-volume}${UPLOAD_LOCATION:+/photos}:/data
- /etc/localtime:/etc/localtime:ro
- pnpm_store_server:/buildcache/pnpm-store
- ../packages/plugins:/build/corePlugin
- ../packages/plugin-core:/build/plugins/immich-plugin-core
immich-web:
env_file: !reset []
immich-machine-learning:
+10 -14
View File
@@ -62,9 +62,6 @@ jobs:
runs-on: ubuntu-latest
permissions:
contents: read
defaults:
run:
working-directory: ./server
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
@@ -84,7 +81,7 @@ jobs:
github_token: ${{ steps.token.outputs.token }}
- name: Run ci-unit
run: mise run ci-unit
run: mise run //server:ci-unit
cli-unit-tests:
name: Unit Test CLI
@@ -380,7 +377,7 @@ jobs:
cache-dependency-path: '**/pnpm-lock.yaml'
- name: Setup packages
run: pnpm --filter "@immich/*" install --frozen-lockfile && pnpm --filter "@immich/*" build
run: pnpm --filter @immich/sdk --filter @immich/cli install --frozen-lockfile && pnpm --filter @immich/sdk --filter @immich/cli build
- name: Run setup web
run: pnpm install --frozen-lockfile && pnpm exec svelte-kit sync
@@ -678,7 +675,6 @@ jobs:
- name: Install server dependencies
run: SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm --filter immich install --frozen-lockfile
- name: Run API generation
run: mise //:open-api
working-directory: open-api
@@ -717,9 +713,6 @@ jobs:
--health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
ports:
- 5432:5432
defaults:
run:
working-directory: ./server
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
@@ -741,18 +734,21 @@ jobs:
- name: Install server dependencies
run: SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm install --frozen-lockfile
- name: Build plugins
run: mise //:plugins
- name: Build the app
run: pnpm build
run: mise //server:build
- name: Run existing migrations
run: pnpm migrations:run
run: pnpm --filter immich migrations:run
- name: Test npm run schema:reset command works
run: pnpm schema:reset
run: pnpm --filter immich schema:reset
- name: Generate new migrations
continue-on-error: true
run: pnpm migrations:generate src/TestMigration
run: pnpm --filter migrations:generate src/TestMigration
- name: Find file changes
uses: tj-actions/verify-changed-files@a1c6acee9df209257a246f2cc6ae8cb6581c1edf # v20.0.4
@@ -768,7 +764,7 @@ jobs:
run: |
echo "ERROR: Generated migration files not up to date!"
echo "Changed files: ${CHANGED_FILES}"
cat ./src/*-TestMigration.ts
cat ./server/src/*-TestMigration.ts
exit 1
- name: Run SQL generation
+1 -1
View File
@@ -74,7 +74,7 @@ services:
- ${UPLOAD_LOCATION}/photos:/data
- /etc/localtime:/etc/localtime:ro
- pnpm_store_server:/buildcache/pnpm-store
- ../packages/plugins:/build/corePlugin
- ../packages/plugin-core:/build/plugins/immich-plugin-core
env_file:
- .env
environment:
+1 -1
View File
@@ -18,7 +18,7 @@ make e2e
Before you can run the tests, you need to run the following commands _once_:
- `pnpm install`
- `pnpm --filter "@immich/*" build`
- `pnpm --filter @immich/sdk --filter @immich/cli build`
- `mise //:open-api`
Once the test environment is running, the e2e tests can be run via:
+26 -8
View File
@@ -22,13 +22,12 @@
"add_birthday": "Add a birthday",
"add_endpoint": "Add endpoint",
"add_exclusion_pattern": "Add exclusion pattern",
"add_filter": "Add filter",
"add_filter_description": "Click to add a filter condition",
"add_location": "Add location",
"add_more_users": "Add more users",
"add_partner": "Add partner",
"add_path": "Add path",
"add_photos": "Add photos",
"add_step": "Add step",
"add_tag": "Add tag",
"add_to": "Add to…",
"add_to_album": "Add to album",
@@ -42,7 +41,6 @@
"add_to_shared_album": "Add to shared album",
"add_upload_to_stack": "Add upload to stack",
"add_url": "Add URL",
"add_workflow_step": "Add workflow step",
"added_to_archive": "Added to archive",
"added_to_favorites": "Added to favorites",
"added_to_favorites_count": "Added {count, number} to favorites",
@@ -733,6 +731,7 @@
"cannot_update_the_description": "Cannot update the description",
"cast": "Cast",
"cast_description": "Configure available cast destinations",
"change": "Change",
"change_date": "Change date",
"change_description": "Change description",
"change_display_order": "Change display order",
@@ -761,6 +760,7 @@
"check_corrupt_asset_backup_description": "Run this check only over Wi-Fi and once all assets have been backed-up. The procedure might take a few minutes.",
"check_logs": "Check Logs",
"checksum": "Checksum",
"choose": "Choose",
"choose_matching_people_to_merge": "Choose matching people to merge",
"city": "City",
"cleanup_confirm_description": "Immich found {count} assets (created before {date}) safely backed up to the server. Remove the local copies from this device?",
@@ -778,6 +778,7 @@
"clear": "Clear",
"clear_all": "Clear all",
"clear_all_recent_searches": "Clear all recent searches",
"clear_failed_count": "Clear failed ({count})",
"clear_file_cache": "Clear File Cache",
"clear_message": "Clear message",
"clear_value": "Clear value",
@@ -809,6 +810,7 @@
"comments_are_disabled": "Comments are disabled",
"common_create_new_album": "Create new album",
"completed": "Completed",
"configuration": "Configuration",
"confirm": "Confirm",
"confirm_admin_password": "Confirm Admin Password",
"confirm_delete_face": "Are you sure you want to delete {name} face from the asset?",
@@ -823,6 +825,7 @@
"contain": "Contain",
"context": "Context",
"continue": "Continue",
"control_bottom_app_bar_add_tags": "Add Tags",
"control_bottom_app_bar_create_new_album": "Create new album",
"control_bottom_app_bar_delete_from_immich": "Delete from Immich",
"control_bottom_app_bar_delete_from_local": "Delete from device",
@@ -894,6 +897,7 @@
"date_of_birth": "Date of birth",
"date_of_birth_saved": "Date of birth saved successfully",
"date_range": "Date range",
"date_time_original": "Date/Time Original",
"day": "Day",
"days": "Days",
"deduplicate_all": "Deduplicate All",
@@ -1074,6 +1078,7 @@
"failed_to_remove_product_key": "Failed to remove product key",
"failed_to_reset_pin_code": "Failed to reset PIN code",
"failed_to_stack_assets": "Failed to stack assets",
"failed_to_tag_assets": "Failed to tag assets",
"failed_to_unstack_assets": "Failed to un-stack assets",
"failed_to_update_notification_status": "Failed to update notification status",
"incorrect_email_or_password": "Incorrect email or password",
@@ -1193,11 +1198,13 @@
"export_as_json": "Export as JSON",
"export_database": "Export Database",
"export_database_description": "Export the SQLite database",
"exposure_time": "Exposure Time",
"extension": "Extension",
"external": "External",
"external_libraries": "External Libraries",
"external_network": "External network",
"external_network_sheet_info": "When not on the preferred Wi-Fi network, the app will connect to the server through the first of the below URLs it can reach, starting from top to bottom",
"f_number": "F-Number",
"face_unassigned": "Unassigned",
"failed": "Failed",
"failed_count": "Failed: {count}",
@@ -1215,7 +1222,6 @@
"features_setting_description": "Manage the app features",
"file_name_or_extension": "File name or extension",
"file_name_text": "File name",
"file_name_with_value": "File name: {file_name}",
"file_size": "File size",
"filename": "Filename",
"filetype": "Filetype",
@@ -1228,6 +1234,7 @@
"find_them_fast": "Find them fast by name with search",
"first": "First",
"fix_incorrect_match": "Fix incorrect match",
"focal_length": "Focal Length",
"folder": "Folder",
"folder_not_found": "Folder not found",
"folders": "Folders",
@@ -1348,6 +1355,7 @@
"ios_debug_info_no_sync_yet": "No background sync job has run yet",
"ios_debug_info_processes_queued": "{count, plural, one {{count} background process queued} other {{count} background processes queued}}",
"ios_debug_info_processing_ran_at": "Processing ran {dateTime}",
"iso": "ISO",
"items_count": "{count, plural, one {# item} other {# items}}",
"jobs": "Jobs",
"json_editor": "JSON editor",
@@ -1580,6 +1588,7 @@
"mobile_app": "Mobile App",
"mobile_app_download_onboarding_note": "Download the companion mobile app using the following options",
"model": "Model",
"modify_date": "Modify Date",
"month": "Month",
"more": "More",
"motion": "Motion",
@@ -1628,7 +1637,6 @@
"next": "Next",
"next_memory": "Next memory",
"no": "No",
"no_actions_added": "No actions added yet",
"no_albums_found": "No albums found",
"no_albums_message": "Create an album to organize your photos and videos",
"no_albums_with_name_yet": "It looks like you do not have any albums with this name yet.",
@@ -1645,7 +1653,6 @@
"no_exif_info_available": "No exif info available",
"no_explore_results_message": "Upload more photos to explore your collection.",
"no_favorites_message": "Add favorites to quickly find your best pictures and videos",
"no_filters_added": "No filters added yet",
"no_libraries_message": "Create an external library to view your photos and videos",
"no_local_assets_found": "No local assets found with this checksum",
"no_location_set": "No location set",
@@ -1658,6 +1665,7 @@
"no_results": "No results",
"no_results_description": "Try a synonym or more general keyword",
"no_shared_albums_message": "Create an album to share photos and videos with people in your network",
"no_steps": "No steps added yet",
"no_uploads_in_progress": "No uploads in progress",
"none": "None",
"not_allowed": "Not allowed",
@@ -1703,6 +1711,7 @@
"organize_into_albums": "Organize into albums",
"organize_into_albums_description": "Put existing photos into albums using current sync settings",
"organize_your_library": "Organize your library",
"orientation": "Orientation",
"original": "original",
"other": "Other",
"other_devices": "Other devices",
@@ -1794,6 +1803,8 @@
"play_original_video_setting_description": "Prefer playback of original videos rather than transcoded videos. If original asset is not compatible it may not playback correctly.",
"play_transcoded_video": "Play transcoded video",
"please_auth_to_access": "Please authenticate to access",
"plugin_method_filter_type": "Filter",
"plugin_method_filter_type_description": "This method can filter events and conditionally prevent subsequent steps from running",
"port": "Port",
"preferences_settings_subtitle": "Manage the app's preferences",
"preferences_settings_title": "Preferences",
@@ -1815,6 +1826,7 @@
"profile_drawer_readonly_mode": "Read-only mode enabled. Long-press the user avatar icon to exit.",
"profile_image_of_user": "Profile image of {user}",
"profile_picture_set": "Profile picture set.",
"projection_type": "Projection Type",
"public_album": "Public album",
"public_share": "Public Share",
"purchase_account_info": "Supporter",
@@ -2184,7 +2196,9 @@
"show_in_timeline": "Show in timeline",
"show_in_timeline_setting_description": "Show photos and videos from this user in your timeline",
"show_keyboard_shortcuts": "Show keyboard shortcuts",
"show_less": "Show less",
"show_metadata": "Show metadata",
"show_more_fields": "{count, plural, one {Show # more field} other {Show # more fields}}",
"show_or_hide_info": "Show or hide info",
"show_password": "Show password",
"show_person_options": "Show person options",
@@ -2236,6 +2250,10 @@
"start_date_before_end_date": "Start date must be before end date",
"state": "State",
"status": "Status",
"step_delete": "Delete step",
"step_delete_confirm": "Are you sure you want to delete this step?",
"step_details": "Step details",
"steps": "Steps",
"stop_casting": "Stop casting",
"stop_motion_photo": "Stop Motion Photo",
"stop_photo_sharing": "Stop sharing your photos?",
@@ -2329,7 +2347,7 @@
"trash_page_title": "Trash ({count})",
"trashed_items_will_be_permanently_deleted_after": "Trashed items will be permanently deleted after {days, plural, one {# day} other {# days}}.",
"trigger": "Trigger",
"trigger_asset_uploaded": "Asset Uploaded",
"trigger_asset_uploaded": "Asset Upload",
"trigger_asset_uploaded_description": "Triggered when a new asset is uploaded",
"trigger_description": "An event that kicks off the workflow",
"trigger_person_recognized": "Person Recognized",
@@ -2369,7 +2387,6 @@
"unsupported_field_type": "Unsupported field type",
"unsupported_file_type": "File {file} can't be uploaded because its file type {type} is not supported.",
"untagged": "Untagged",
"untitled_workflow": "Untitled workflow",
"up_next": "Up next",
"update_location_action_prompt": "Update the location of {count} selected assets with:",
"updated_at": "Updated",
@@ -2461,6 +2478,7 @@
"welcome_to_immich": "Welcome to Immich",
"width": "Width",
"wifi_name": "Wi-Fi Name",
"workflow": "Workflow",
"workflow_delete_prompt": "Are you sure you want to delete this workflow?",
"workflow_deleted": "Workflow deleted",
"workflow_description": "Workflow description",
+19 -1
View File
@@ -2,7 +2,7 @@ experimental_monorepo_root = true
[monorepo]
config_roots = [
"packages/plugins",
"packages/plugin-core",
"server",
"packages/cli",
"deployment",
@@ -22,12 +22,22 @@ terragrunt = "1.0.3"
opentofu = "1.11.6"
java = "21.0.2"
"npm:oazapfts" = "7.5.0"
"github:extism/cli" = "1.6.3"
"github:webassembly/binaryen" = "version_124"
"github:extism/js-pdk" = "1.6.0"
[tools."github:CQLabs/homebrew-dcm"]
version = "1.37.0"
bin = "dcm"
postinstall = "chmod +x \"$MISE_TOOL_INSTALL_PATH/dcm\" || true"
[tools."github:CQLabs/homebrew-dcm".platforms]
linux-x64 = { asset_pattern = "dcm-linux-x64-release.zip" }
linux-arm64 = { asset_pattern = "dcm-linux-arm-release.zip" }
macos-x64 = { asset_pattern = "dcm-macos-x64-release.zip" }
macos-arm64 = { asset_pattern = "dcm-macos-arm-release.zip" }
windows-x64 = { asset_pattern = "dcm-windows-release.zip" }
[tools."github:jellyfin/jellyfin-ffmpeg"]
version = "7.1.3-6"
@@ -41,6 +51,12 @@ macos-arm64 = { asset_pattern = "jellyfin-ffmpeg_*_portable_macarm64-gpl.tar.xz"
experimental = true
pin = true
[tasks.plugins]
run = [
"pnpm --filter @immich/plugin-sdk --filter @immich/plugin-core install --frozen-lockfile",
"pnpm --filter @immich/plugin-sdk --filter @immich/plugin-core build"
]
[tasks.open-api-typescript]
run = [
"oazapfts --optimistic --argumentStyle=object --useEnumType --allSchemas open-api/immich-openapi-specs.json packages/sdk/src/fetch-client.ts",
@@ -55,6 +71,8 @@ run = "bash ./bin/generate-dart-sdk.sh"
[tasks.open-api]
env = { SHARP_IGNORE_GLOBAL_LIBVIPS = true }
run = [
{ task = "//:plugins" },
{ task = "//server:build" },
{ task = "//server:install" },
{ task = "//server:build" },
{ task = "//server:sync-open-api" },
@@ -23,6 +23,8 @@ import java.io.IOException
import java.nio.ByteBuffer
import java.util.concurrent.ConcurrentHashMap
private const val MAX_PREALLOC_BYTES = 128 * 1024 * 1024
private class RemoteRequest(val cancellationSignal: CancellationSignal)
class RemoteImagesImpl(context: Context) : RemoteImageApi {
@@ -228,7 +230,6 @@ private class CronetImageFetcher : ImageFetcher {
private val onComplete: () -> Unit,
) : UrlRequest.Callback() {
private var buffer: NativeByteBuffer? = null
private var wrapped: ByteBuffer? = null
private var error: Exception? = null
override fun onRedirectReceived(request: UrlRequest, info: UrlResponseInfo, newUrl: String) {
@@ -242,15 +243,16 @@ private class CronetImageFetcher : ImageFetcher {
}
try {
// Content-Length is a size hint only. With Content-Encoding (gzip/br/...),
// Cronet auto-decompresses and writes decompressed bytes to our buffer, which
// may exceed the wire/compressed Content-Length. Always use the growable
// buffer path so we can't overflow.
val contentLength = info.allHeaders["content-length"]?.firstOrNull()?.toIntOrNull() ?: 0
if (contentLength > 0) {
buffer = NativeByteBuffer(contentLength + 1)
wrapped = NativeBuffer.wrap(buffer!!.pointer, contentLength + 1)
request.read(wrapped)
} else {
buffer = NativeByteBuffer(INITIAL_BUFFER_SIZE)
request.read(buffer!!.wrapRemaining())
}
// Cap the up-front alloc: Content-Length is untrusted and can be huge or near
// Int.MAX_VALUE (overflowing `+1`). For larger responses the grow path takes over.
val initialSize = if (contentLength in 1..MAX_PREALLOC_BYTES) contentLength + 1 else INITIAL_BUFFER_SIZE
buffer = NativeByteBuffer(initialSize)
request.read(buffer!!.wrapRemaining())
} catch (e: Exception) {
error = e
return request.cancel()
@@ -263,14 +265,14 @@ private class CronetImageFetcher : ImageFetcher {
byteBuffer: ByteBuffer
) {
try {
val buf = if (wrapped == null) {
buffer!!.run {
advance(byteBuffer.position())
ensureHeadroom()
wrapRemaining()
}
} else {
wrapped
// Always pass a fresh wrap so byteBuffer.position() represents only the
// bytes Cronet wrote in this iteration. Reusing the caller-supplied
// ByteBuffer breaks advance(): Cronet's position keeps accumulating
// across reads, which would double-count previous iterations' bytes.
val buf = buffer!!.run {
advance(byteBuffer.position())
ensureHeadroom()
wrapRemaining()
}
request.read(buf)
} catch (e: Exception) {
@@ -280,7 +282,6 @@ private class CronetImageFetcher : ImageFetcher {
}
override fun onSucceeded(request: UrlRequest, info: UrlResponseInfo) {
wrapped?.let { buffer!!.advance(it.position()) }
onSuccess(buffer!!)
onComplete()
}
+1 -1
View File
@@ -110,7 +110,7 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
var domainAlbum = PlatformAlbum(
id: album.localIdentifier,
name: album.localizedTitle!,
name: album.localizedTitle ?? album.localIdentifier,
updatedAt: nil,
isCloud: isCloud,
assetCount: Int64(assets.count)
+4
View File
@@ -18,3 +18,7 @@ enum CleanupStep { selectDate, scan, delete }
enum AssetKeepType { none, photosOnly, videosOnly }
enum AssetDateAggregation { start, end }
enum SlideshowLook { contain, cover, blurredBackground }
enum SlideshowDirection { forward, backward, shuffle }
@@ -0,0 +1,26 @@
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
class AlbumConfig {
final AlbumSortMode sortMode;
final bool isReverse;
final bool isGrid;
const AlbumConfig({this.sortMode = AlbumSortMode.mostRecent, this.isReverse = true, this.isGrid = false});
AlbumConfig copyWith({AlbumSortMode? sortMode, bool? isReverse, bool? isGrid}) => AlbumConfig(
sortMode: sortMode ?? this.sortMode,
isReverse: isReverse ?? this.isReverse,
isGrid: isGrid ?? this.isGrid,
);
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is AlbumConfig && other.sortMode == sortMode && other.isReverse == isReverse && other.isGrid == isGrid);
@override
int get hashCode => Object.hash(sortMode, isReverse, isGrid);
@override
String toString() => 'AlbumConfig(sortMode: $sortMode, isReverse: $isReverse, isGrid: $isGrid)';
}
@@ -1,6 +1,8 @@
import 'package:immich_mobile/domain/models/config/album_config.dart';
import 'package:immich_mobile/domain/models/config/cleanup_config.dart';
import 'package:immich_mobile/domain/models/config/image_config.dart';
import 'package:immich_mobile/domain/models/config/map_config.dart';
import 'package:immich_mobile/domain/models/config/slideshow_config.dart';
import 'package:immich_mobile/domain/models/config/theme_config.dart';
import 'package:immich_mobile/domain/models/config/timeline_config.dart';
import 'package:immich_mobile/domain/models/config/viewer_config.dart';
@@ -12,6 +14,8 @@ class AppConfig {
final TimelineConfig timeline;
final ImageConfig image;
final ViewerConfig viewer;
final SlideshowConfig slideshow;
final AlbumConfig album;
const AppConfig({
this.theme = const .new(),
@@ -20,6 +24,8 @@ class AppConfig {
this.timeline = const .new(),
this.image = const .new(),
this.viewer = const .new(),
this.slideshow = const .new(),
this.album = const .new(),
});
AppConfig copyWith({
@@ -29,6 +35,8 @@ class AppConfig {
TimelineConfig? timeline,
ImageConfig? image,
ViewerConfig? viewer,
SlideshowConfig? slideshow,
AlbumConfig? album,
}) => .new(
theme: theme ?? this.theme,
cleanup: cleanup ?? this.cleanup,
@@ -36,6 +44,8 @@ class AppConfig {
timeline: timeline ?? this.timeline,
image: image ?? this.image,
viewer: viewer ?? this.viewer,
slideshow: slideshow ?? this.slideshow,
album: album ?? this.album,
);
@override
@@ -47,12 +57,14 @@ class AppConfig {
other.map == map &&
other.timeline == timeline &&
other.image == image &&
other.viewer == viewer);
other.viewer == viewer &&
other.slideshow == slideshow &&
other.album == album);
@override
int get hashCode => Object.hash(theme, cleanup, map, timeline, image, viewer);
int get hashCode => Object.hash(theme, cleanup, map, timeline, image, viewer, slideshow, album);
@override
String toString() =>
'AppConfig(theme: $theme, cleanup: $cleanup, map: $map, timeline: $timeline, image: $image, viewer: $viewer)';
'AppConfig(theme: $theme, cleanup: $cleanup, map: $map, timeline: $timeline, image: $image, viewer: $viewer, slideshow: $slideshow, album: $album)';
}
@@ -0,0 +1,54 @@
import 'package:flutter/foundation.dart';
class NetworkConfig {
final bool autoEndpointSwitching;
final String? preferredWifiName;
final String? localEndpoint;
final List<String> externalEndpointList;
final Map<String, String> customHeaders;
const NetworkConfig({
this.autoEndpointSwitching = false,
this.preferredWifiName,
this.localEndpoint,
this.externalEndpointList = const [],
this.customHeaders = const {},
});
NetworkConfig copyWith({
bool? autoEndpointSwitching,
String? preferredWifiName,
String? localEndpoint,
List<String>? externalEndpointList,
Map<String, String>? customHeaders,
}) => NetworkConfig(
autoEndpointSwitching: autoEndpointSwitching ?? this.autoEndpointSwitching,
preferredWifiName: preferredWifiName ?? this.preferredWifiName,
localEndpoint: localEndpoint ?? this.localEndpoint,
externalEndpointList: externalEndpointList ?? this.externalEndpointList,
customHeaders: customHeaders ?? this.customHeaders,
);
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is NetworkConfig &&
other.autoEndpointSwitching == autoEndpointSwitching &&
other.preferredWifiName == preferredWifiName &&
other.localEndpoint == localEndpoint &&
listEquals(other.externalEndpointList, externalEndpointList) &&
mapEquals(other.customHeaders, customHeaders));
@override
int get hashCode => Object.hash(
autoEndpointSwitching,
preferredWifiName,
localEndpoint,
Object.hashAll(externalEndpointList),
Object.hashAllUnordered(customHeaders.entries.map((e) => Object.hash(e.key, e.value))),
);
@override
String toString() =>
'NetworkConfig(autoEndpointSwitching: $autoEndpointSwitching, preferredWifiName: $preferredWifiName, localEndpoint: $localEndpoint, externalEndpointList: $externalEndpointList, customHeaders: $customHeaders)';
}
@@ -0,0 +1,48 @@
import 'package:immich_mobile/constants/enums.dart';
class SlideshowConfig {
final bool transition;
final bool repeat;
final int duration;
final SlideshowLook look;
final SlideshowDirection direction;
const SlideshowConfig({
this.transition = true,
this.repeat = true,
this.duration = 5,
this.look = SlideshowLook.contain,
this.direction = SlideshowDirection.forward,
});
SlideshowConfig copyWith({
bool? transition,
bool? repeat,
int? duration,
SlideshowLook? look,
SlideshowDirection? direction,
}) => SlideshowConfig(
transition: transition ?? this.transition,
repeat: repeat ?? this.repeat,
duration: duration ?? this.duration,
look: look ?? this.look,
direction: direction ?? this.direction,
);
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is SlideshowConfig &&
other.transition == transition &&
other.repeat == repeat &&
other.duration == duration &&
other.look == look &&
other.direction == direction);
@override
int get hashCode => Object.hash(transition, repeat, duration, look, direction);
@override
String toString() =>
'SlideshowConfig(transition: $transition, repeat: $repeat, duration: $duration, look: $look, direction: $direction)';
}
@@ -1,18 +1,22 @@
import 'package:immich_mobile/domain/models/config/network_config.dart';
import 'package:immich_mobile/domain/models/log.model.dart';
class SystemConfig {
final LogLevel logLevel;
final NetworkConfig network;
const SystemConfig({this.logLevel = .info});
const SystemConfig({this.logLevel = .info, this.network = const .new()});
SystemConfig copyWith({LogLevel? logLevel}) => SystemConfig(logLevel: logLevel ?? this.logLevel);
SystemConfig copyWith({LogLevel? logLevel, NetworkConfig? network}) =>
SystemConfig(logLevel: logLevel ?? this.logLevel, network: network ?? this.network);
@override
bool operator ==(Object other) => identical(this, other) || (other is SystemConfig && other.logLevel == logLevel);
bool operator ==(Object other) =>
identical(this, other) || (other is SystemConfig && other.logLevel == logLevel && other.network == network);
@override
int get hashCode => logLevel.hashCode;
int get hashCode => Object.hash(logLevel, network);
@override
String toString() => 'SystemConfig(logLevel: $logLevel)';
String toString() => 'SystemConfig(logLevel: $logLevel, network: $network)';
}
+82 -1
View File
@@ -8,6 +8,7 @@ import 'package:immich_mobile/domain/models/config/app_config.dart';
import 'package:immich_mobile/domain/models/config/system_config.dart';
import 'package:immich_mobile/domain/models/log.model.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
enum MetadataDomain<T extends Object> {
appConfig<AppConfig>('config.app'),
@@ -34,6 +35,33 @@ enum MetadataKey<T extends Object> {
viewerAutoPlayVideo<bool>(.appConfig, 'viewer.autoPlayVideo', true),
viewerTapToNavigate<bool>(.appConfig, 'viewer.tapToNavigate', false),
// Network
networkAutoEndpointSwitching<bool>(.systemConfig, 'network.autoEndpointSwitching', false),
networkPreferredWifiName<String>(.systemConfig, 'network.preferredWifiName', ''),
networkLocalEndpoint<String>(.systemConfig, 'network.localEndpoint', ''),
networkExternalEndpointList<List<String>>(
.systemConfig,
'network.externalEndpointList',
[],
_ListCodec(_PrimitiveCodec.string),
),
networkCustomHeaders<Map<String, String>>(
.systemConfig,
'network.customHeaders',
{},
_MapCodec(_PrimitiveCodec.string, _PrimitiveCodec.string),
),
// Album
albumSortMode<AlbumSortMode>(
.appConfig,
'album.sortMode',
AlbumSortMode.mostRecent,
_EnumCodec(AlbumSortMode.values),
),
albumIsReverse<bool>(.appConfig, 'album.isReverse', true),
albumIsGrid<bool>(.appConfig, 'album.isGrid', false),
// Timeline
timelineTilesPerRow<int>(.appConfig, 'timeline.tilesPerRow', 4),
timelineGroupAssetsBy<GroupAssetsBy>(
@@ -64,7 +92,19 @@ enum MetadataKey<T extends Object> {
),
cleanupKeepAlbumIds<List<String>>(.appConfig, 'cleanup.keepAlbumIds', [], _ListCodec(_PrimitiveCodec.string)),
cleanupCutoffDaysAgo<int>(.appConfig, 'cleanup.cutoffDaysAgo', -1),
cleanupDefaultsInitialized<bool>(.appConfig, 'cleanup.defaultsInitialized', false);
cleanupDefaultsInitialized<bool>(.appConfig, 'cleanup.defaultsInitialized', false),
// Slideshow
slideshowTransition<bool>(.appConfig, 'slideshow.transition', true),
slideshowRepeat<bool>(.appConfig, 'slideshow.repeat', true),
slideshowDuration<int>(.appConfig, 'slideshow.duration', 5),
slideshowLook<SlideshowLook>(.appConfig, 'slideshow.look', SlideshowLook.contain, _EnumCodec(SlideshowLook.values)),
slideshowDirection<SlideshowDirection>(
.appConfig,
'slideshow.direction',
SlideshowDirection.forward,
_EnumCodec(SlideshowDirection.values),
);
final MetadataDomain domain;
final String name;
@@ -131,6 +171,47 @@ final class _DateTimeCodec extends _MetadataCodec<DateTime> {
DateTime? decode(String raw) => DateTime.tryParse(raw);
}
final class _MapCodec<K extends Object, V extends Object> extends _MetadataCodec<Map<K, V>> {
final _MetadataCodec<K> _keyCodec;
final _MetadataCodec<V> _valueCodec;
const _MapCodec(this._keyCodec, this._valueCodec);
@override
String encode(Map<K, V> value) {
final entries = <String, String>{};
value.forEach((k, v) => entries[_keyCodec.encode(k)] = _valueCodec.encode(v));
return jsonEncode(entries);
}
@override
Map<K, V>? decode(String raw) {
try {
final decoded = jsonDecode(raw);
if (decoded is! Map) {
return null;
}
final result = <K, V>{};
for (final entry in decoded.entries) {
final rawKey = entry.key;
final rawValue = entry.value;
if (rawKey is! String || rawValue is! String) {
return null;
}
final k = _keyCodec.decode(rawKey);
final v = _valueCodec.decode(rawValue);
if (k == null || v == null) {
return null;
}
result[k] = v;
}
return result;
} on FormatException {
return null;
}
}
}
final class _ListCodec<T extends Object> extends _MetadataCodec<List<T>> {
final _MetadataCodec<T> _elementCodec;
+8 -10
View File
@@ -11,23 +11,13 @@ enum StoreKey<T> {
serverUrl<String>._(10),
accessToken<String>._(11),
serverEndpoint<String>._(12),
selectedAlbumSortOrder<int>._(113),
advancedTroubleshooting<bool>._(114),
selectedAlbumSortReverse<bool>._(123),
enableHapticFeedback<bool>._(126),
customHeaders<String>._(127),
syncAlbums<bool>._(131),
// Auto endpoint switching
autoEndpointSwitching<bool>._(132),
preferredWifiName<String>._(133),
localEndpoint<String>._(134),
externalEndpointList<String>._(135),
manageLocalMediaAndroid<bool>._(137),
// Read-only Mode settings
readonlyModeEnabled<bool>._(138),
albumGridView<bool>._(140),
// Experimental stuff
enableBackup<bool>._(1003),
@@ -36,6 +26,14 @@ enum StoreKey<T> {
syncMigrationStatus<String>._(1013),
// Legacy keys that have been migrated to the new metadata store
legacySelectedAlbumSortOrder<int>._(113),
legacySelectedAlbumSortReverse<bool>._(123),
legacyAlbumGridView<bool>._(140),
legacyAutoEndpointSwitching<bool>._(132),
legacyPreferredWifiName<String>._(133),
legacyLocalEndpoint<String>._(134),
legacyExternalEndpointList<String>._(135),
legacyCustomHeaders<String>._(127),
legacyLoopVideo<bool>._(117),
legacyLoadOriginalVideo<bool>._(136),
legacyAutoPlayVideo<bool>._(139),
@@ -9,12 +9,47 @@ import 'package:immich_mobile/infrastructure/repositories/remote_album.repositor
import 'package:immich_mobile/models/albums/album_search.model.dart';
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
import 'package:immich_mobile/services/foreground_upload.service.dart';
import 'package:logging/logging.dart';
/// Categorizes a heterogeneous asset selection into the candidates that can
/// be added to an album immediately (already on the server) and the local-only
/// candidates that must be uploaded first.
class AlbumAssetCandidates {
final List<String> remoteAssetIds;
final List<LocalAsset> localAssetsToUpload;
const AlbumAssetCandidates({required this.remoteAssetIds, required this.localAssetsToUpload});
}
class RemoteAlbumService {
static final _logger = Logger('RemoteAlbumService');
final DriftRemoteAlbumRepository _repository;
final DriftAlbumApiRepository _albumApiRepository;
final ForegroundUploadService _uploadService;
const RemoteAlbumService(this._repository, this._albumApiRepository);
const RemoteAlbumService(this._repository, this._albumApiRepository, this._uploadService);
/// Categorizes a heterogeneous asset selection into already-on-server IDs
/// and local assets that still need to be uploaded.
static AlbumAssetCandidates categorizeCandidates(Iterable<BaseAsset> assets) {
final remoteIds = <String>[];
final localToUpload = <LocalAsset>[];
for (final asset in assets) {
if (asset is RemoteAsset) {
remoteIds.add(asset.id);
} else if (asset is LocalAsset) {
final remoteId = asset.remoteId;
if (remoteId != null) {
remoteIds.add(remoteId);
} else {
localToUpload.add(asset);
}
}
}
return AlbumAssetCandidates(remoteAssetIds: remoteIds, localAssetsToUpload: localToUpload);
}
Stream<RemoteAlbum?> watchAlbum(String albumId) {
return _repository.watchAlbum(albumId);
@@ -148,6 +183,122 @@ class RemoteAlbumService {
return album.added.length;
}
/// !TODO The name here is not clear as we have addAssets method above,
/// which is only add remote assets to album, for the next PR, we will allow
/// adding local assets from album from the timeline as well with this flow.
/// So saving that for the next refactor
Future<int> addAssetsToAlbum({
required String albumId,
required UserDto uploader,
required AlbumAssetCandidates candidates,
UploadCallbacks uploadCallbacks = const UploadCallbacks(),
}) async {
int addedCount = 0;
if (candidates.remoteAssetIds.isNotEmpty) {
addedCount += await addAssets(albumId: albumId, assetIds: candidates.remoteAssetIds);
}
if (candidates.localAssetsToUpload.isNotEmpty) {
addedCount += await _uploadAndAddLocals(albumId, uploader, candidates.localAssetsToUpload, uploadCallbacks);
}
return addedCount;
}
/// Creates an album, seeding it with already-remote asset IDs, then uploads
/// local-only assets and links each one as it finishes.
Future<RemoteAlbum> createAlbumWithAssets({
required String title,
required UserDto owner,
String? description,
AlbumAssetCandidates candidates = const AlbumAssetCandidates(remoteAssetIds: [], localAssetsToUpload: []),
UploadCallbacks uploadCallbacks = const UploadCallbacks(),
}) async {
final album = await createAlbum(
title: title,
owner: owner,
description: description,
assetIds: candidates.remoteAssetIds,
);
if (candidates.localAssetsToUpload.isNotEmpty) {
await _uploadAndAddLocals(album.id, owner, candidates.localAssetsToUpload, uploadCallbacks);
}
return album;
}
Future<int> _uploadAndAddLocals(
String albumId,
UserDto uploader,
List<LocalAsset> localAssets,
UploadCallbacks userCallbacks,
) async {
int addedCount = 0;
final pendingAdds = <Future<void>>[];
final localById = {for (final a in localAssets) a.id: a};
final wrappedCallbacks = UploadCallbacks(
onProgress: (localId, filename, bytes, totalBytes) => _runUploadCallback(
'Upload progress callback failed for $localId',
() => userCallbacks.onProgress?.call(localId, filename, bytes, totalBytes),
),
onICloudProgress: (localId, progress) => _runUploadCallback(
'iCloud progress callback failed for $localId',
() => userCallbacks.onICloudProgress?.call(localId, progress),
),
onError: (localId, errorMessage) => _runUploadCallback(
'Upload error callback failed for $localId',
() => userCallbacks.onError?.call(localId, errorMessage),
),
onSuccess: (localId, remoteId) {
_runUploadCallback(
'Upload success callback failed for $localId',
() => userCallbacks.onSuccess?.call(localId, remoteId),
);
final source = localById[localId];
if (source == null) {
_logger.warning('Upload success for $localId but source LocalAsset missing; skipping album link');
return;
}
pendingAdds.add(
_linkUploadedAssetToAlbum(albumId, remoteId, uploader, source)
.then<void>((added) {
addedCount += added;
})
.catchError((Object error, StackTrace stack) {
_logger.warning('Failed to add uploaded asset $remoteId to album $albumId', error, stack);
}),
);
},
);
await _uploadService.uploadManual(localAssets, callbacks: wrappedCallbacks);
await Future.wait(pendingAdds);
return addedCount;
}
void _runUploadCallback(String message, void Function() callback) {
try {
callback();
} catch (error, stack) {
_logger.warning(message, error, stack);
}
}
/// Links a freshly-uploaded asset to an album, ensuring the local DB
/// reflects the change without waiting for the next sync. We call the API
/// (server is the source of truth), then upsert a placeholder
/// `remote_asset_entity` row from the local source so the FK-protected
/// junction insert succeeds. Sync overwrites the placeholder later with
/// the authoritative server data.
Future<int> _linkUploadedAssetToAlbum(String albumId, String remoteId, UserDto uploader, LocalAsset source) async {
final result = await _albumApiRepository.addAssets(albumId, [remoteId]);
if (result.added.isEmpty) {
return 0;
}
await _repository.upsertRemoteAssetStub(remoteId: remoteId, ownerId: uploader.id, source: source);
await _repository.addAssets(albumId, result.added);
return result.added.length;
}
Future<void> deleteAlbum(String albumId) async {
await _albumApiRepository.deleteAlbum(albumId);
@@ -0,0 +1,31 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/tag.model.dart';
import 'package:immich_mobile/infrastructure/repositories/tags_api.repository.dart';
final tagServiceProvider = Provider<TagService>((ref) => TagService(ref.watch(tagsApiRepositoryProvider)));
class TagService {
final TagsApiRepository _repository;
const TagService(this._repository);
Future<int> bulkTagAssets(List<String> assetIds, List<String> tagIds) async {
return _repository.bulkTagAssets(assetIds, tagIds);
}
Future<Set<Tag>> getAllTags() async {
final dtos = await _repository.getAllTags();
if (dtos == null) {
return {};
}
return dtos.map((dto) => Tag.fromDto(dto)).toSet();
}
Future<List<Tag>> upsertTags(List<String> tags) async {
final dtos = await _repository.upsertTags(tags);
if (dtos == null) {
return [];
}
return dtos.map((dto) => Tag.fromDto(dto)).toList();
}
}
@@ -4,6 +4,8 @@ extension StringExtension on String {
String capitalize() {
return split(" ").map((str) => str.isEmpty ? str : str[0].toUpperCase() + str.substring(1)).join(" ");
}
String? get nullIfEmpty => isEmpty ? null : this;
}
extension DurationExtension on String {
@@ -2,6 +2,7 @@ import 'package:drift/drift.dart';
import 'package:immich_mobile/domain/models/config/app_config.dart';
import 'package:immich_mobile/domain/models/config/system_config.dart';
import 'package:immich_mobile/domain/models/metadata_key.dart';
import 'package:immich_mobile/extensions/string_extensions.dart';
import 'package:immich_mobile/infrastructure/entities/metadata.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
@@ -139,9 +140,30 @@ extension<T extends Object> on MetadataDomain<T> {
autoPlayVideo: repo._read(.viewerAutoPlayVideo),
tapToNavigate: repo._read(.viewerTapToNavigate),
),
slideshow: .new(
transition: repo._read(.slideshowTransition),
repeat: repo._read(.slideshowRepeat),
duration: repo._read(.slideshowDuration),
look: repo._read(.slideshowLook),
direction: repo._read(.slideshowDirection),
),
album: .new(
sortMode: repo._read(.albumSortMode),
isReverse: repo._read(.albumIsReverse),
isGrid: repo._read(.albumIsGrid),
),
);
case .systemConfig:
repo._systemConfig = .new(logLevel: repo._read(.logLevel));
repo._systemConfig = .new(
logLevel: repo._read(.logLevel),
network: .new(
autoEndpointSwitching: repo._read(.networkAutoEndpointSwitching),
preferredWifiName: repo._read(.networkPreferredWifiName).nullIfEmpty,
localEndpoint: repo._read(.networkLocalEndpoint).nullIfEmpty,
externalEndpointList: repo._read(.networkExternalEndpointList),
customHeaders: repo._read(.networkCustomHeaders),
),
);
}
}
}
@@ -10,6 +10,7 @@ import 'package:immich_mobile/infrastructure/entities/remote_album.entity.drift.
import 'package:immich_mobile/infrastructure/entities/remote_album_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/remote_album_user.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
enum SortRemoteAlbumsBy { id, updatedAt }
@@ -159,7 +160,7 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
createdAt: Value(album.createdAt),
updatedAt: Value(album.updatedAt),
description: Value(album.description),
thumbnailAssetId: Value(album.thumbnailAssetId),
thumbnailAssetId: Value(album.thumbnailAssetId ?? (assetIds.isNotEmpty ? assetIds.first : null)),
isActivityEnabled: Value(album.isActivityEnabled),
order: Value(album.order),
);
@@ -274,17 +275,59 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
}
Future<int> addAssets(String albumId, List<String> assetIds) async {
if (assetIds.isEmpty) {
return 0;
}
final albumAssets = assetIds.map(
(assetId) => RemoteAlbumAssetEntityCompanion(albumId: Value(albumId), assetId: Value(assetId)),
);
await _db.batch((batch) {
batch.insertAll(_db.remoteAlbumAssetEntity, albumAssets);
await _db.transaction(() async {
await _db.batch((batch) {
batch.insertAll(_db.remoteAlbumAssetEntity, albumAssets);
});
final album = _db.update(_db.remoteAlbumEntity)
..where((row) => row.id.equals(albumId) & row.thumbnailAssetId.isNull());
await album.write(RemoteAlbumEntityCompanion(thumbnailAssetId: Value(assetIds.first)));
});
return assetIds.length;
}
/// Inserts a placeholder `remote_asset_entity` row from a freshly-uploaded
/// local asset. Skips silently if a row with the same id or
/// (owner_id, checksum) already exists — sync will overwrite with the
/// authoritative server data once the AssetUploadReadyV1 event is processed.
Future<void> upsertRemoteAssetStub({
required String remoteId,
required String ownerId,
required LocalAsset source,
}) async {
await _db
.into(_db.remoteAssetEntity)
.insert(
RemoteAssetEntityCompanion(
id: Value(remoteId),
ownerId: Value(ownerId),
checksum: Value(source.checksum ?? remoteId),
name: Value(source.name),
type: Value(source.type),
createdAt: Value(source.createdAt),
updatedAt: Value(source.updatedAt),
width: Value(source.width),
height: Value(source.height),
durationMs: Value(source.durationMs),
isFavorite: Value(source.isFavorite),
visibility: const Value(AssetVisibility.timeline),
isEdited: Value(source.isEdited),
),
mode: InsertMode.insertOrIgnore,
);
}
Future<void> addUsers(String albumId, List<String> userIds) {
final albumUsers = userIds.map(
(assetId) => RemoteAlbumUserEntityCompanion(
@@ -14,4 +14,13 @@ class TagsApiRepository extends ApiRepository {
Future<List<TagResponseDto>?> getAllTags() async {
return await _api.getAllTags();
}
Future<int> bulkTagAssets(List<String> assetIds, List<String> tagIds) async {
final response = await _api.bulkTagAssets(TagBulkAssetsDto(assetIds: assetIds, tagIds: tagIds));
return response?.count ?? 0;
}
Future<List<TagResponseDto>?> upsertTags(List<String> tags) async {
return _api.upsertTags(TagUpsertDto(tags: tags));
}
}
@@ -1,14 +1,12 @@
import 'dart:convert';
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
import 'package:flutter_hooks/flutter_hooks.dart';
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/domain/models/metadata_key.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
class SettingsHeader {
String key = "";
@@ -24,17 +22,14 @@ class HeaderSettingsPage extends HookConsumerWidget {
final headers = useState<List<SettingsHeader>>([]);
final setInitialHeaders = useState(false);
var headersStr = Store.get(StoreKey.customHeaders, "");
final storedHeaders = ref.read(metadataProvider).systemConfig.network.customHeaders;
if (!setInitialHeaders.value) {
if (headersStr.isNotEmpty) {
var customHeaders = jsonDecode(headersStr) as Map;
customHeaders.forEach((k, v) {
final header = SettingsHeader();
header.key = k;
header.value = v;
headers.value.add(header);
});
}
storedHeaders.forEach((k, v) {
final header = SettingsHeader();
header.key = k;
header.value = v;
headers.value.add(header);
});
// add first one to help the user
if (headers.value.isEmpty) {
@@ -88,8 +83,8 @@ class HeaderSettingsPage extends HookConsumerWidget {
}
saveHeaders(WidgetRef ref, List<SettingsHeader> headers) async {
final headersMap = {};
for (var header in headers) {
final headersMap = <String, String>{};
for (final header in headers) {
final key = header.key.trim();
final value = header.value.trim();
@@ -99,8 +94,7 @@ class HeaderSettingsPage extends HookConsumerWidget {
headersMap[key] = value;
}
var encoded = jsonEncode(headersMap);
await Store.put(StoreKey.customHeaders, encoded);
await ref.read(metadataProvider).write(MetadataKey.networkCustomHeaders, headersMap);
await ref.read(apiServiceProvider).updateHeaders();
}
}
@@ -4,7 +4,6 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/models/shared_link/shared_link.model.dart';
import 'package:immich_mobile/providers/shared_link.provider.dart';
import 'package:immich_mobile/widgets/shared_link/shared_link_item.dart';
@@ -28,71 +27,41 @@ class SharedLinkPage extends HookConsumerWidget {
}, []);
Widget buildNoShares() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(left: 16.0, top: 16.0),
child: const Text(
"shared_link_manage_links",
style: TextStyle(fontSize: 14, color: Colors.grey, fontWeight: FontWeight.bold),
).tr(),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 10),
child: const Text("you_dont_have_any_shared_links", style: TextStyle(fontSize: 14)).tr(),
),
),
Expanded(
child: Center(
child: Icon(Icons.link_off, size: 100, color: context.themeData.iconTheme.color?.withValues(alpha: 0.5)),
),
),
],
return Center(
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.link_off, size: 100, color: Theme.of(context).colorScheme.onSurface.withAlpha(128)),
const SizedBox(height: 20),
const Text("you_dont_have_any_shared_links", style: TextStyle(fontSize: 14)).tr(),
],
),
);
}
Widget buildSharesList(List<SharedLink> links) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(left: 16.0, top: 16.0, bottom: 30.0),
child: Text(
"shared_link_manage_links",
style: context.textTheme.labelLarge?.copyWith(color: context.textTheme.labelLarge?.color?.withAlpha(200)),
).tr(),
),
Expanded(
child: LayoutBuilder(
builder: (context, constraints) {
if (constraints.maxWidth > 600) {
// Two column
return GridView.builder(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
mainAxisExtent: 180,
),
itemCount: links.length,
itemBuilder: (context, index) {
return SharedLinkItem(links.elementAt(index));
},
);
}
// Single column
return ListView.builder(
itemCount: links.length,
itemBuilder: (context, index) {
return SharedLinkItem(links.elementAt(index));
},
);
},
),
),
],
return LayoutBuilder(
builder: (context, constraints) => constraints.maxWidth > 600
? GridView.builder(
key: const PageStorageKey('shared-links-grid'),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
mainAxisExtent: 180,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
),
padding: const EdgeInsets.all(12),
itemCount: links.length,
itemBuilder: (context, index) => SharedLinkItem(links[index]),
)
: ListView.separated(
key: const PageStorageKey('shared-links-list'),
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: links.length,
itemBuilder: (context, index) => SharedLinkItem(links[index]),
separatorBuilder: (context, index) => const Divider(height: 1),
),
);
}
@@ -6,15 +6,20 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/models/shared_link/shared_link.model.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/shared_link.provider.dart';
import 'package:immich_mobile/services/shared_link.service.dart';
import 'package:immich_mobile/utils/url_helper.dart';
import 'package:immich_mobile/widgets/common/confirm_dialog.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:share_plus/share_plus.dart';
@RoutePage()
class SharedLinkEditPage extends HookConsumerWidget {
static const int maxFutureDate = 365 * 2;
final SharedLink? existingLink;
final List<String>? assetsList;
final String? albumId;
@@ -23,71 +28,82 @@ class SharedLinkEditPage extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
const padding = 20.0;
final themeData = context.themeData;
final colorScheme = context.colorScheme;
final externalDomain = ref.watch(serverInfoProvider.select((s) => s.serverConfig.externalDomain));
final displayServerUrl = externalDomain.isNotEmpty ? externalDomain : getServerUrl();
final expiryPresets = <(Duration, String)>[
(Duration.zero, context.t.never),
(const Duration(minutes: 30), context.t.shared_link_edit_expire_after_option_minutes(count: 30)),
(const Duration(hours: 1), context.t.shared_link_edit_expire_after_option_hour),
(const Duration(hours: 6), context.t.shared_link_edit_expire_after_option_hours(count: 6)),
(const Duration(days: 1), context.t.shared_link_edit_expire_after_option_day),
(const Duration(days: 7), context.t.shared_link_edit_expire_after_option_days(count: 7)),
(const Duration(days: 30), context.t.shared_link_edit_expire_after_option_days(count: 30)),
(const Duration(days: 90), context.t.shared_link_edit_expire_after_option_months(count: 3)),
(const Duration(days: 365), context.t.shared_link_edit_expire_after_option_year(count: 1)),
];
final descriptionController = useTextEditingController(text: existingLink?.description ?? "");
final descriptionFocusNode = useFocusNode();
final passwordController = useTextEditingController(text: existingLink?.password ?? "");
final slugController = useTextEditingController(text: existingLink?.slug ?? "");
final slugFocusNode = useFocusNode();
useListenable(slugController);
final showMetadata = useState(existingLink?.showMetadata ?? true);
final allowDownload = useState(existingLink?.allowDownload ?? true);
final allowUpload = useState(existingLink?.allowUpload ?? false);
final editExpiry = useState(false);
final expiryAfter = useState(0);
final expiryAfter = useState<DateTime?>(existingLink?.expiresAt?.toLocal());
final selectedPresetIndex = useState<int?>(existingLink?.expiresAt == null ? 0 : null);
final newShareLink = useState("");
Widget buildSharedLinkRow({required String leading, required String content}) {
return Row(
children: [
Expanded(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Text(
content,
style: TextStyle(color: colorScheme.primary, fontWeight: FontWeight.bold),
),
),
),
const SizedBox(width: 8),
Text(leading, style: const TextStyle(fontWeight: FontWeight.bold)),
],
);
}
Widget buildLinkTitle() {
if (existingLink != null) {
if (existingLink!.type == SharedLinkSource.album) {
return Row(
children: [
const Text('public_album', style: TextStyle(fontWeight: FontWeight.bold)).tr(),
const Text(" | ", style: TextStyle(fontWeight: FontWeight.bold)),
Text(
existingLink!.title,
style: TextStyle(color: colorScheme.primary, fontWeight: FontWeight.bold),
),
],
);
return buildSharedLinkRow(leading: context.t.public_album, content: existingLink!.title);
}
if (existingLink!.type == SharedLinkSource.individual) {
return Row(
children: [
const Text('shared_link_individual_shared', style: TextStyle(fontWeight: FontWeight.bold)).tr(),
const Text(" | ", style: TextStyle(fontWeight: FontWeight.bold)),
Expanded(
child: Text(
existingLink!.description ?? "--",
style: TextStyle(color: colorScheme.primary, fontWeight: FontWeight.bold),
overflow: TextOverflow.ellipsis,
),
),
],
return buildSharedLinkRow(
leading: context.t.shared_link_individual_shared,
content: existingLink!.description ?? "--",
);
}
}
return const Text("create_link_to_share_description", style: TextStyle(fontWeight: FontWeight.bold)).tr();
return Text(context.t.create_link_to_share_description, style: const TextStyle(fontWeight: FontWeight.bold));
}
Widget buildDescriptionField() {
return TextField(
controller: descriptionController,
enabled: newShareLink.value.isEmpty,
focusNode: descriptionFocusNode,
textInputAction: TextInputAction.done,
autofocus: false,
decoration: InputDecoration(
labelText: 'description'.tr(),
labelStyle: TextStyle(fontWeight: FontWeight.bold, color: colorScheme.primary),
labelText: context.t.description,
labelStyle: const TextStyle(fontWeight: FontWeight.bold),
floatingLabelBehavior: FloatingLabelBehavior.always,
border: const OutlineInputBorder(),
hintText: 'shared_link_edit_description_hint'.tr(),
hintText: context.t.shared_link_edit_description_hint,
hintStyle: const TextStyle(fontWeight: FontWeight.normal, fontSize: 14),
disabledBorder: OutlineInputBorder(borderSide: BorderSide(color: Colors.grey.withValues(alpha: 0.5))),
),
onTapOutside: (_) => descriptionFocusNode.unfocus(),
);
@@ -96,16 +112,14 @@ class SharedLinkEditPage extends HookConsumerWidget {
Widget buildPasswordField() {
return TextField(
controller: passwordController,
enabled: newShareLink.value.isEmpty,
autofocus: false,
decoration: InputDecoration(
labelText: 'password'.tr(),
labelStyle: TextStyle(fontWeight: FontWeight.bold, color: colorScheme.primary),
labelText: context.t.password,
labelStyle: const TextStyle(fontWeight: FontWeight.bold),
floatingLabelBehavior: FloatingLabelBehavior.always,
border: const OutlineInputBorder(),
hintText: 'shared_link_edit_password_hint'.tr(),
hintText: context.t.shared_link_edit_password_hint,
hintStyle: const TextStyle(fontWeight: FontWeight.normal, fontSize: 14),
disabledBorder: OutlineInputBorder(borderSide: BorderSide(color: Colors.grey.withValues(alpha: 0.5))),
),
);
}
@@ -113,18 +127,16 @@ class SharedLinkEditPage extends HookConsumerWidget {
Widget buildSlugField() {
return TextField(
controller: slugController,
enabled: newShareLink.value.isEmpty,
focusNode: slugFocusNode,
textInputAction: TextInputAction.done,
autofocus: false,
decoration: InputDecoration(
labelText: 'custom_url'.tr(),
labelStyle: TextStyle(fontWeight: FontWeight.bold, color: colorScheme.primary),
floatingLabelBehavior: FloatingLabelBehavior.always,
labelText: slugController.text.isNotEmpty ? context.t.custom_url : null,
labelStyle: const TextStyle(fontWeight: FontWeight.bold),
border: const OutlineInputBorder(),
hintText: 'custom_url'.tr(),
hintStyle: const TextStyle(fontWeight: FontWeight.normal, fontSize: 14),
disabledBorder: OutlineInputBorder(borderSide: BorderSide(color: Colors.grey.withValues(alpha: 0.5))),
hintText: context.t.custom_url,
prefixText: slugController.text.isNotEmpty ? '/s/' : null,
prefixStyle: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
),
onTapOutside: (_) => slugFocusNode.unfocus(),
);
@@ -133,145 +145,182 @@ class SharedLinkEditPage extends HookConsumerWidget {
Widget buildShowMetaButton() {
return SwitchListTile.adaptive(
value: showMetadata.value,
onChanged: newShareLink.value.isEmpty ? (value) => showMetadata.value = value : null,
activeThumbColor: colorScheme.primary,
onChanged: (value) => showMetadata.value = value,
dense: true,
title: Text("show_metadata", style: themeData.textTheme.labelLarge?.copyWith(fontWeight: FontWeight.bold)).tr(),
title: Text(
context.t.show_metadata,
style: themeData.textTheme.labelLarge?.copyWith(fontWeight: FontWeight.bold),
),
);
}
Widget buildAllowDownloadButton() {
return SwitchListTile.adaptive(
value: allowDownload.value,
onChanged: newShareLink.value.isEmpty ? (value) => allowDownload.value = value : null,
activeThumbColor: colorScheme.primary,
onChanged: (value) => allowDownload.value = value,
dense: true,
title: Text(
"allow_public_user_to_download",
context.t.allow_public_user_to_download,
style: themeData.textTheme.labelLarge?.copyWith(fontWeight: FontWeight.bold),
).tr(),
),
);
}
Widget buildAllowUploadButton() {
return SwitchListTile.adaptive(
value: allowUpload.value,
onChanged: newShareLink.value.isEmpty ? (value) => allowUpload.value = value : null,
activeThumbColor: colorScheme.primary,
onChanged: (value) => allowUpload.value = value,
dense: true,
title: Text(
"allow_public_user_to_upload",
context.t.allow_public_user_to_upload,
style: themeData.textTheme.labelLarge?.copyWith(fontWeight: FontWeight.bold),
).tr(),
),
);
}
Widget buildEditExpiryButton() {
return SwitchListTile.adaptive(
value: editExpiry.value,
onChanged: newShareLink.value.isEmpty ? (value) => editExpiry.value = value : null,
activeThumbColor: colorScheme.primary,
dense: true,
title: Text(
"change_expiration_time",
style: themeData.textTheme.labelLarge?.copyWith(fontWeight: FontWeight.bold),
).tr(),
String formatDateTime(DateTime dateTime) => DateFormat.yMMMd(context.locale.toString()).add_Hm().format(dateTime);
DateTime? getExpiresAtFromPreset(Duration preset) => preset == Duration.zero ? null : DateTime.now().add(preset);
Future<void> selectDate() async {
final today = DateTime.now();
final safeInitialDate = expiryAfter.value ?? today.add(const Duration(days: 7));
final initialDate = safeInitialDate.isBefore(today) ? today : safeInitialDate;
final selectedDate = await showDatePicker(
context: context,
initialDate: initialDate,
firstDate: today,
lastDate: today.add(const Duration(days: maxFutureDate)),
);
if (selectedDate != null && context.mounted) {
final isToday =
selectedDate.year == today.year && selectedDate.month == today.month && selectedDate.day == today.day;
final initialTime = isToday ? TimeOfDay.fromDateTime(today) : const TimeOfDay(hour: 12, minute: 0);
final selectedTime = await showTimePicker(context: context, initialTime: initialTime);
if (selectedTime != null) {
final now = DateTime.now();
var finalDateTime = DateTime(
selectedDate.year,
selectedDate.month,
selectedDate.day,
selectedTime.hour,
selectedTime.minute,
);
if (finalDateTime.isBefore(now) && isToday) {
finalDateTime = now;
}
selectedPresetIndex.value = null;
expiryAfter.value = finalDateTime;
}
}
}
Widget buildExpiryAfterButton() {
return DropdownMenu(
label: Text(
"expire_after",
style: TextStyle(fontWeight: FontWeight.bold, color: colorScheme.primary),
).tr(),
enableSearch: false,
enableFilter: false,
width: context.width - 40,
initialSelection: expiryAfter.value,
enabled: newShareLink.value.isEmpty && (existingLink == null || editExpiry.value),
onSelected: (value) {
expiryAfter.value = value!;
},
dropdownMenuEntries: [
DropdownMenuEntry(value: 0, label: "never".tr()),
DropdownMenuEntry(
value: 30,
label: "shared_link_edit_expire_after_option_minutes".tr(namedArgs: {'count': "30"}),
),
DropdownMenuEntry(value: 60, label: "shared_link_edit_expire_after_option_hour".tr()),
DropdownMenuEntry(
value: 60 * 6,
label: "shared_link_edit_expire_after_option_hours".tr(namedArgs: {'count': "6"}),
),
DropdownMenuEntry(value: 60 * 24, label: "shared_link_edit_expire_after_option_day".tr()),
DropdownMenuEntry(
value: 60 * 24 * 7,
label: "shared_link_edit_expire_after_option_days".tr(namedArgs: {'count': "7"}),
),
DropdownMenuEntry(
value: 60 * 24 * 30,
label: "shared_link_edit_expire_after_option_days".tr(namedArgs: {'count': "30"}),
),
DropdownMenuEntry(
value: 60 * 24 * 30 * 3,
label: "shared_link_edit_expire_after_option_months".tr(namedArgs: {'count': "3"}),
),
DropdownMenuEntry(
value: 60 * 24 * 30 * 12,
label: "shared_link_edit_expire_after_option_year".tr(namedArgs: {'count': "1"}),
),
],
);
}
void copyLinkToClipboard() {
Clipboard.setData(ClipboardData(text: newShareLink.value)).then((_) {
context.scaffoldMessenger.showSnackBar(
SnackBar(
content: Text(
"shared_link_clipboard_copied_massage",
style: context.textTheme.bodyLarge?.copyWith(color: context.primaryColor),
).tr(),
duration: const Duration(seconds: 2),
),
);
});
}
Widget buildNewLinkField() {
return Column(
return ExpansionTile(
title: Text(
context.t.expire_after,
style: themeData.textTheme.labelLarge?.copyWith(fontWeight: FontWeight.bold),
),
subtitle: Text(
expiryAfter.value == null ? context.t.shared_link_expires_never : formatDateTime(expiryAfter.value!),
style: TextStyle(color: themeData.colorScheme.primary),
),
children: [
const Padding(padding: EdgeInsets.only(top: 20, bottom: 20), child: Divider()),
TextFormField(
readOnly: true,
initialValue: newShareLink.value,
decoration: InputDecoration(
border: const OutlineInputBorder(),
enabledBorder: themeData.inputDecorationTheme.focusedBorder,
suffixIcon: IconButton(onPressed: copyLinkToClipboard, icon: const Icon(Icons.copy)),
),
),
Padding(
padding: const EdgeInsets.only(top: 16.0),
child: Align(
alignment: Alignment.bottomRight,
child: ElevatedButton(
onPressed: () {
context.maybePop();
},
child: const Text("done", style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold)).tr(),
),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Wrap(
spacing: 8,
runSpacing: 8,
children: List.generate(expiryPresets.length, (index) {
final preset = expiryPresets[index];
return ChoiceChip(
label: Text(preset.$2),
selected: selectedPresetIndex.value == index,
onSelected: (_) {
selectedPresetIndex.value = index;
expiryAfter.value = getExpiresAtFromPreset(preset.$1);
},
);
}),
),
if (expiryAfter.value != null) ...[
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: selectDate,
icon: const Icon(Icons.edit_calendar),
label: Text(context.t.edit_date_and_time),
),
),
],
],
),
),
],
);
}
DateTime calculateExpiry() {
return DateTime.now().add(Duration(minutes: expiryAfter.value));
Future<void> copyToClipboard(String link) async {
await Clipboard.setData(ClipboardData(text: link));
if (!context.mounted) {
return;
}
context.scaffoldMessenger.showSnackBar(
SnackBar(
content: Text(
context.t.shared_link_clipboard_copied_massage,
style: context.textTheme.bodyLarge?.copyWith(color: context.primaryColor),
),
duration: const Duration(seconds: 2),
),
);
}
Widget buildLinkCopyField(String link) {
return TextFormField(
readOnly: true,
onTap: () => copyToClipboard(link),
initialValue: link,
decoration: InputDecoration(
border: const OutlineInputBorder(),
enabledBorder: themeData.inputDecorationTheme.focusedBorder,
suffixIcon: IconButton(onPressed: () => Share.share(link), icon: const Icon(Icons.share)),
),
);
}
Widget buildNewLinkReadyScreen() {
return Padding(
padding: const EdgeInsets.all(20),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.add_link, size: 100, color: themeData.colorScheme.primary),
const SizedBox(height: 20),
buildLinkCopyField(newShareLink.value),
const SizedBox(height: 20),
ElevatedButton.icon(
onPressed: () => context.maybePop(),
icon: const Icon(Icons.check),
label: Text(context.t.done, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold)),
),
],
),
);
}
DateTime? calculateExpiry() => expiryAfter.value;
Future<void> handleNewLink() async {
final newLink = await ref
.read(sharedLinkServiceProvider)
@@ -284,30 +333,30 @@ class SharedLinkEditPage extends HookConsumerWidget {
description: descriptionController.text.isEmpty ? null : descriptionController.text,
password: passwordController.text.isEmpty ? null : passwordController.text,
slug: slugController.text.isEmpty ? null : slugController.text,
expiresAt: expiryAfter.value == 0 ? null : calculateExpiry(),
expiresAt: calculateExpiry()?.toUtc(),
);
if (!context.mounted) {
return;
}
ref.invalidate(sharedLinksStateProvider);
await ref.read(serverInfoProvider.notifier).getServerConfig();
if (!context.mounted) {
return;
}
final externalDomain = ref.read(serverInfoProvider.select((s) => s.serverConfig.externalDomain));
var serverUrl = externalDomain.isNotEmpty ? externalDomain : getServerUrl();
if (serverUrl != null && !serverUrl.endsWith('/')) {
serverUrl += '/';
}
final serverUrl = externalDomain.isNotEmpty ? externalDomain : getServerUrl();
if (newLink != null && serverUrl != null) {
final hasSlug = newLink.slug?.isNotEmpty == true;
final urlPath = hasSlug ? newLink.slug : newLink.key;
final basePath = hasSlug ? 's' : 'share';
newShareLink.value = "$serverUrl$basePath/$urlPath";
copyLinkToClipboard();
} else if (newLink == null) {
if (newLink != null) {
newShareLink.value = buildSharedLinkUrl(baseUrl: serverUrl, slug: newLink.slug, key: newLink.key) ?? '';
await copyToClipboard(newShareLink.value);
} else {
ImmichToast.show(
context: context,
gravity: ToastGravity.BOTTOM,
toastType: ToastType.error,
msg: 'shared_link_create_error'.tr(),
msg: context.t.shared_link_create_error,
);
}
}
@@ -348,8 +397,9 @@ class SharedLinkEditPage extends HookConsumerWidget {
slug = existingLink!.slug;
}
if (editExpiry.value) {
expiry = expiryAfter.value == 0 ? null : calculateExpiry();
final newExpiry = expiryAfter.value;
if (newExpiry?.toUtc() != existingLink!.expiresAt?.toUtc()) {
expiry = newExpiry;
changeExpiry = true;
}
@@ -363,69 +413,115 @@ class SharedLinkEditPage extends HookConsumerWidget {
description: desc,
password: password,
slug: slug,
expiresAt: expiry,
expiresAt: expiry?.toUtc(),
changeExpiry: changeExpiry,
);
if (!context.mounted) {
return;
}
ref.invalidate(sharedLinksStateProvider);
await context.maybePop();
}
Future<void> handleDeleteLink() async {
return showDialog(
context: context,
builder: (BuildContext context) => ConfirmDialog(
title: "delete_shared_link_dialog_title",
content: "confirm_delete_shared_link",
onOk: () async {
await ref.read(sharedLinkServiceProvider).deleteSharedLink(existingLink!.id);
ref.invalidate(sharedLinksStateProvider);
if (context.mounted) {
await context.maybePop();
}
},
),
);
}
return Scaffold(
appBar: AppBar(
title: Text(existingLink == null ? "create_link_to_share" : "edit_link").tr(),
title: Text(existingLink == null ? context.t.create_link_to_share : context.t.edit_link),
elevation: 0,
leading: const CloseButton(),
centerTitle: false,
),
body: SafeArea(
child: ListView(
children: [
Padding(padding: const EdgeInsets.all(padding), child: buildLinkTitle()),
Padding(padding: const EdgeInsets.all(padding), child: buildDescriptionField()),
Padding(padding: const EdgeInsets.all(padding), child: buildPasswordField()),
Padding(padding: const EdgeInsets.all(padding), child: buildSlugField()),
Padding(
padding: const EdgeInsets.only(left: padding, right: padding, bottom: padding),
child: buildShowMetaButton(),
),
Padding(
padding: const EdgeInsets.only(left: padding, right: padding, bottom: padding),
child: buildAllowDownloadButton(),
),
Padding(
padding: const EdgeInsets.only(left: padding, right: 20, bottom: 20),
child: buildAllowUploadButton(),
),
if (existingLink != null)
Padding(
padding: const EdgeInsets.only(left: padding, right: padding, bottom: padding),
child: buildEditExpiryButton(),
),
Padding(
padding: const EdgeInsets.only(left: padding, right: padding, bottom: padding),
child: buildExpiryAfterButton(),
),
if (newShareLink.value.isEmpty)
Align(
alignment: Alignment.bottomRight,
child: Padding(
padding: const EdgeInsets.only(right: padding + 10, bottom: padding),
child: ElevatedButton(
onPressed: existingLink != null ? handleEditLink : handleNewLink,
child: Text(
existingLink != null ? "shared_link_edit_submit_button" : "create_link",
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
).tr(),
),
child: newShareLink.value.isEmpty
? Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: ListView(
children: [
const SizedBox(height: 20),
buildLinkTitle(),
if (existingLink != null)
Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const SizedBox(height: 16),
buildLinkCopyField(
buildSharedLinkUrl(
baseUrl: displayServerUrl,
slug: existingLink!.slug,
key: existingLink!.key,
) ??
'',
),
const SizedBox(height: 24),
const Divider(),
],
),
const SizedBox(height: 24),
buildDescriptionField(),
const SizedBox(height: 16),
buildPasswordField(),
const SizedBox(height: 16),
buildSlugField(),
const SizedBox(height: 16),
buildShowMetaButton(),
const SizedBox(height: 16),
buildAllowDownloadButton(),
const SizedBox(height: 16),
buildAllowUploadButton(),
const SizedBox(height: 16),
buildExpiryAfterButton(),
const SizedBox(height: 24),
Align(
alignment: Alignment.centerRight,
child: Row(
mainAxisSize: MainAxisSize.min,
spacing: 8,
children: [
if (existingLink != null)
OutlinedButton.icon(
style: OutlinedButton.styleFrom(
foregroundColor: themeData.colorScheme.error,
side: BorderSide(color: themeData.colorScheme.error),
),
onPressed: handleDeleteLink,
icon: const Icon(Icons.delete_outline),
label: Text(
context.t.delete,
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
),
),
ElevatedButton.icon(
icon: const Icon(Icons.check),
onPressed: existingLink != null ? handleEditLink : handleNewLink,
label: Text(
existingLink != null ? context.t.shared_link_edit_submit_button : context.t.create_link,
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
),
),
],
),
),
const SizedBox(height: 40),
],
),
),
if (newShareLink.value.isNotEmpty)
Padding(
padding: const EdgeInsets.only(left: padding, right: padding, bottom: padding),
child: buildNewLinkField(),
),
],
),
)
: Center(child: buildNewLinkReadyScreen()),
),
);
}
@@ -37,6 +37,7 @@ class _DriftAlbumsPageState extends ConsumerState<DriftAlbumsPage> {
final scrollView = CustomScrollView(
controller: _scrollController,
physics: const AlwaysScrollableScrollPhysics(),
slivers: [
ImmichSliverAppBar(
snap: false,
@@ -5,7 +5,6 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
@RoutePage()
class DriftAssetSelectionTimelinePage extends ConsumerWidget {
@@ -22,17 +21,13 @@ class DriftAssetSelectionTimelinePage extends ConsumerWidget {
),
),
timelineServiceProvider.overrideWith((ref) {
final user = ref.watch(currentUserProvider);
if (user == null) {
throw Exception('User must be logged in to access asset selection timeline');
}
final timelineService = ref.watch(timelineFactoryProvider).remoteAssets(user.id);
final timelineUsers = ref.watch(timelineUsersProvider).valueOrNull ?? [];
final timelineService = ref.watch(timelineFactoryProvider).main(timelineUsers);
ref.onDispose(timelineService.dispose);
return timelineService;
}),
],
child: const Timeline(),
child: const Timeline(showStorageIndicator: true),
);
}
}
@@ -179,17 +179,14 @@ class _DriftCreateAlbumPageState extends ConsumerState<DriftCreateAlbumPage> {
}
final album = await ref
.watch(remoteAlbumProvider.notifier)
.createAlbum(
.read(remoteAlbumProvider.notifier)
.createAlbumWithAssets(
title: title,
description: albumDescriptionController.text.trim(),
assetIds: selectedAssets.map((asset) {
final remoteAsset = asset as RemoteAsset;
return remoteAsset.id;
}).toList(),
assets: selectedAssets,
);
if (album != null) {
if (album != null && context.mounted) {
unawaited(context.replaceRoute(RemoteAlbumRoute(album: album)));
}
}
@@ -8,6 +8,7 @@ import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/album/pending_uploads_banner.widget.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/remote_album_bottom_sheet.widget.dart';
import 'package:immich_mobile/presentation/widgets/remote_album/drift_album_option.widget.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
@@ -39,7 +40,8 @@ class _RemoteAlbumPageState extends ConsumerState<RemoteAlbumPage> {
}
Future<void> addAssets(BuildContext context) async {
final albumAssets = await ref.read(remoteAlbumProvider.notifier).getAssets(_album.id);
final notifier = ref.read(remoteAlbumProvider.notifier);
final albumAssets = await notifier.getAssets(_album.id);
final newAssets = await context.pushRoute<Set<BaseAsset>>(
DriftAssetSelectionTimelineRoute(lockedSelectionAssets: albumAssets.toSet()),
@@ -49,17 +51,9 @@ class _RemoteAlbumPageState extends ConsumerState<RemoteAlbumPage> {
return;
}
final added = await ref
.read(remoteAlbumProvider.notifier)
.addAssets(
_album.id,
newAssets.map((asset) {
final remoteAsset = asset as RemoteAsset;
return remoteAsset.id;
}).toList(),
);
final added = await notifier.addAssetsToAlbum(_album.id, newAssets);
if (added > 0) {
if (added > 0 && context.mounted) {
ImmichToast.show(
context: context,
msg: "assets_added_to_album_count".t(context: context, args: {'count': added.toString()}),
@@ -186,6 +180,7 @@ class _RemoteAlbumPageState extends ConsumerState<RemoteAlbumPage> {
currentRemoteAlbumScopedProvider.overrideWithValue(_album),
],
child: Timeline(
topSliverWidget: PendingUploadsBanner(albumId: _album.id),
appBar: RemoteAlbumSliverAppBar(
icon: Icons.photo_album_outlined,
kebabMenu: _AlbumKebabMenu(
@@ -0,0 +1,376 @@
import 'dart:async';
import 'dart:math';
import 'dart:ui';
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/config/slideshow_config.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/scroll_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/pages/common/settings.page.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/video_viewer.widget.dart';
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart';
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart';
import 'package:immich_mobile/widgets/photo_view/photo_view.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
@RoutePage()
class DriftSlideshowPage extends ConsumerStatefulWidget {
final TimelineService timeline;
const DriftSlideshowPage({super.key, required this.timeline});
@override
ConsumerState<DriftSlideshowPage> createState() => _DriftSlideshowPageState();
}
class _DriftSlideshowPageState extends ConsumerState<DriftSlideshowPage> {
late SlideshowConfig _config;
late final PageController _pageController;
late final Stopwatch _stopwatch;
late Timer _timer;
late int _index;
late int _nextIndex;
bool _paused = false;
bool _showAppBar = false;
@override
initState() {
super.initState();
_config = ref.read(appConfigProvider.select((s) => s.slideshow));
final asset = ref.read(assetViewerProvider).currentAsset;
_index = asset == null ? 0 : widget.timeline.getIndex(asset.heroTag) ?? 0;
_pageController = PageController(initialPage: _index);
_stopwatch = Stopwatch();
_createTimer();
_updateNextIndex();
ref.listenManual(appConfigProvider.select((s) => s.slideshow), _onConfigChanged);
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
unawaited(WakelockPlus.enable());
}
@override
dispose() {
_timer.cancel();
_stopwatch.stop();
_pageController.dispose();
unawaited(WakelockPlus.disable());
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
super.dispose();
}
void _play() {
final asset = widget.timeline.getAssetSafe(_index)!;
if (asset.isImage) {
_createTimer();
} else if (ref.read(videoPlayerProvider(asset.heroTag)).status == VideoPlaybackStatus.paused) {
ref.read(videoPlayerProvider(asset.heroTag).notifier).play();
} else {
_nextPage();
}
_updateNextIndex();
setState(() {
_paused = false;
});
}
void _pause() {
_timer.cancel();
_stopwatch.stop();
final asset = widget.timeline.getAssetSafe(_index)!;
if (!asset.isImage) {
ref.read(videoPlayerProvider(asset.heroTag).notifier).pause();
}
setState(() {
_paused = true;
});
}
void _onConfigChanged(SlideshowConfig? previous, SlideshowConfig next) {
if (_config == next) {
return;
}
final durationChanged = _config.duration != next.duration;
_config = next;
_updateNextIndex();
final asset = widget.timeline.getAssetSafe(_index);
if (durationChanged && !_paused && asset?.isImage == true) {
_timer.cancel();
_createTimer();
}
setState(() {});
}
void _updateNextIndex() {
_nextIndex = switch (_config.direction) {
SlideshowDirection.forward => _index + 1,
SlideshowDirection.backward => _index - 1,
SlideshowDirection.shuffle => widget.timeline.getIndex(widget.timeline.getRandomAsset().heroTag)!,
};
if (!widget.timeline.hasRange(_nextIndex, 1)) {
widget.timeline.preloadAssets(_nextIndex);
}
}
void _nextPage() async {
if (_nextIndex < 0 || _nextIndex >= widget.timeline.totalAssets) {
if (_config.repeat) {
final wrapped = _config.direction == SlideshowDirection.forward ? 0 : widget.timeline.totalAssets - 1;
await widget.timeline.preloadAssets(wrapped);
_pageController.jumpToPage(wrapped);
} else {
setState(() {
_paused = true;
});
}
return;
}
if (!widget.timeline.hasRange(_nextIndex, 1)) {
await widget.timeline.preloadAssets(_nextIndex);
}
if (_config.direction == SlideshowDirection.shuffle || !_config.transition) {
_pageController.jumpToPage(_nextIndex);
} else {
unawaited(_pageController.animateToPage(_nextIndex, duration: Durations.long2, curve: Curves.easeIn));
}
}
void _createTimer() {
_timer = Timer(Duration(milliseconds: _config.duration * 1000 - _stopwatch.elapsedMilliseconds), () {
_stopwatch.stop();
_stopwatch.reset();
_nextPage();
});
_stopwatch.start();
}
void _pageChanged(int page) {
final asset = widget.timeline.getAssetSafe(page)!;
setState(() {
_index = page;
if (!asset.isImage) {
_paused = false;
}
});
_timer.cancel();
_stopwatch.stop();
_stopwatch.reset();
if (!_paused && asset.isImage) {
_createTimer();
}
_updateNextIndex();
}
void _onTapUp() async {
await SystemChrome.setEnabledSystemUIMode(_showAppBar ? SystemUiMode.immersive : SystemUiMode.edgeToEdge);
WidgetsBinding.instance.addPostFrameCallback((_) {
setState(() {
_showAppBar = !_showAppBar;
});
});
}
Widget _getProgressBar(BuildContext context) {
final asset = widget.timeline.getAssetSafe(_index);
if (asset == null) {
return Container();
}
if (asset.isImage) {
final elapsed = _stopwatch.elapsedMilliseconds;
final duration = _config.duration * 1000;
return TweenAnimationBuilder(
key: Key(_index.toString()),
tween: Tween<double>(begin: elapsed / duration.toDouble(), end: _paused ? elapsed / duration.toDouble() : 1.0),
duration: Duration(milliseconds: _paused ? 1 : max(duration - elapsed, 1)),
builder: (context, value, _) => LinearProgressIndicator(
color: context.colorScheme.primary,
borderRadius: const BorderRadius.all(Radius.zero),
minHeight: 5,
value: value,
),
);
} else {
return LinearProgressIndicator(
color: context.colorScheme.primary,
borderRadius: const BorderRadius.all(Radius.zero),
minHeight: 5,
value:
ref.watch(videoPlayerProvider(asset.heroTag).select((s) => s.position)).inMilliseconds /
asset.duration.inMilliseconds,
);
}
}
Widget _getBlur(BuildContext context, int index) {
final asset = widget.timeline.getAssetSafe(index);
if (asset == null) {
return Container();
}
return ImageFiltered(
imageFilter: ImageFilter.blur(sigmaX: 30, sigmaY: 30),
child: Container(
decoration: BoxDecoration(
image: DecorationImage(
image: getFullImageProvider(asset, size: Size(context.width, context.height)),
fit: BoxFit.cover,
),
),
child: Container(color: Colors.black.withValues(alpha: 0.2)),
),
);
}
Widget _getPhotoView(BuildContext context, int index) {
final asset = widget.timeline.getAssetSafe(index);
if (asset == null) {
return const Center(child: ImmichLoadingIndicator());
}
final scale = _config.look == SlideshowLook.cover
? PhotoViewComputedScale.covered
: PhotoViewComputedScale.contained;
final isCurrent = _index == index;
final imageProvider = getFullImageProvider(asset, size: context.sizeData);
if (asset.isImage) {
final zoomOut = index % 2 == 1;
final elapsed = _stopwatch.elapsedMilliseconds;
final duration = _config.duration * 1000;
final progress = zoomOut ? 1.0 - elapsed / duration.toDouble() : elapsed / duration.toDouble();
return TweenAnimationBuilder(
tween: Tween<double>(
begin: progress,
end: _paused
? progress
: zoomOut
? 0.0
: 1.0,
),
duration: Duration(milliseconds: _paused ? 1 : max(duration - elapsed, 1)),
builder: (context, value, _) => PhotoView(
imageProvider: imageProvider,
index: index,
disableScaleGestures: true,
gaplessPlayback: true,
filterQuality: FilterQuality.high,
initialScale: scale * (1.0 + value / 10.0),
controller: PhotoViewController(),
onTapUp: (_, _, _) => _onTapUp(),
),
);
} else {
final status = ref.watch(videoPlayerProvider(asset.heroTag).select((s) => s.status));
final position = ref.read(videoPlayerProvider(asset.heroTag)).position;
if (status == VideoPlaybackStatus.completed && isCurrent && position.inMicroseconds > 0) {
_nextPage();
} else if (status == VideoPlaybackStatus.playing) {
ref.read(videoPlayerProvider(asset.heroTag).notifier).setLoop(false);
}
return PhotoView.customChild(
onTapUp: (_, _, _) => _onTapUp(),
disableScaleGestures: true,
filterQuality: FilterQuality.high,
initialScale: scale,
child: NativeVideoViewer(
asset: asset,
isCurrent: isCurrent,
image: Image(image: imageProvider, fit: BoxFit.contain, alignment: Alignment.center),
),
);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: PreferredSize(
preferredSize: Size(AppBar().preferredSize.width, AppBar().preferredSize.height + 5),
child: IgnorePointer(
ignoring: !_showAppBar,
child: AnimatedOpacity(
opacity: _showAppBar ? 1.0 : 0.0,
duration: Durations.short2,
child: Column(
children: [
AppBar(
backgroundColor: context.scaffoldBackgroundColor,
title: Text("slideshow".t(context: context)),
actions: [
IconButton(
onPressed: _paused ? _play : _pause,
icon: Icon(_paused ? Icons.play_arrow : Icons.pause),
),
IconButton(
onPressed: () {
_pause();
context.pushRoute(SettingsSubRoute(section: SettingSection.assetViewer));
},
icon: const Icon(Icons.settings),
),
],
),
_getProgressBar(context),
],
),
),
),
),
extendBody: true,
extendBodyBehindAppBar: true,
backgroundColor: Colors.black,
body: PhotoViewGestureDetectorScope(
axis: Axis.horizontal,
child: PageView.builder(
controller: _pageController,
physics: const FastClampingScrollPhysics(),
itemCount: widget.timeline.totalAssets,
onPageChanged: _pageChanged,
itemBuilder: (context, index) => Stack(
children: [
if (_config.look == SlideshowLook.blurredBackground) _getBlur(context, index),
_getPhotoView(context, index),
],
),
),
),
);
}
}
@@ -186,7 +186,7 @@ class DriftSearchPage extends HookConsumerWidget {
expanded: true,
onSearch: handleApply,
onClear: handleClear,
child: TagPicker(onSelect: handleOnSelect, filter: (filter.value.tagIds ?? []).toSet()),
child: TagPicker(onSelectExistingTag: handleOnSelect, filter: (filter.value.tagIds ?? []).toSet()),
),
),
);
@@ -50,10 +50,13 @@ class BaseActionButton extends ConsumerWidget {
final iconColor = this.iconColor;
return MenuItemButton(
style: MenuItemButton.styleFrom(alignment: Alignment.centerLeft, padding: const EdgeInsets.all(16)),
leadingIcon: Icon(iconData, color: iconColor),
style: MenuItemButton.styleFrom(
alignment: Alignment.centerLeft,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
),
leadingIcon: Icon(iconData, color: iconColor, size: 20),
onPressed: onPressed,
child: Text(label, style: TextStyle(fontSize: 16, color: iconColor)),
child: Text(label, style: TextStyle(fontSize: 15, color: iconColor)),
);
}
@@ -0,0 +1,46 @@
import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
class BulkTagAssetsActionButton extends ConsumerWidget {
final ActionSource source;
const BulkTagAssetsActionButton({super.key, required this.source});
Future<void> _onTap(BuildContext context, WidgetRef ref) async {
final result = await ref.read(actionProvider.notifier).tagAssets(source, context);
if (result == null) {
return;
}
ref.read(multiSelectProvider.notifier).reset();
if (!context.mounted) {
return;
}
ImmichToast.show(
context: context,
msg: result.success
? 'tagged_assets'.t(context: context, args: {'count': result.count.toString()})
: 'errors.failed_to_tag_assets'.t(context: context),
gravity: ToastGravity.BOTTOM,
toastType: result.success ? ToastType.success : ToastType.error,
);
}
@override
Widget build(BuildContext context, WidgetRef ref) {
return BaseActionButton(
iconData: Icons.sell_outlined,
label: "control_bottom_app_bar_add_tags".t(context: context),
onPressed: () => _onTap(context, ref),
);
}
}
@@ -0,0 +1,34 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/routing/router.dart';
class SlideshowActionButton extends ConsumerWidget {
final bool iconOnly;
final bool menuItem;
const SlideshowActionButton({super.key, this.iconOnly = false, this.menuItem = false});
void _onTap(BuildContext context, WidgetRef ref) {
if (!context.mounted) {
return;
}
context.pushRoute(DriftSlideshowRoute(timeline: ref.read(timelineServiceProvider)));
}
@override
Widget build(BuildContext context, WidgetRef ref) {
return BaseActionButton(
iconData: Icons.slideshow,
label: "slideshow".t(context: context),
iconOnly: iconOnly,
menuItem: menuItem,
onPressed: () => _onTap(context, ref),
maxWidth: 100,
);
}
}
@@ -15,15 +15,15 @@ import 'package:immich_mobile/models/albums/album_search.model.dart';
import 'package:immich_mobile/presentation/widgets/album/album_tile.dart';
import 'package:immich_mobile/presentation/widgets/album/new_album_name_modal.widget.dart';
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
import 'package:immich_mobile/domain/models/metadata_key.dart';
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/utils/album_filter.utils.dart';
import 'package:immich_mobile/widgets/common/confirm_dialog.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
@@ -58,19 +58,11 @@ class _AlbumSelectorState extends ConsumerState<AlbumSelector> {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
final appSettings = ref.read(appSettingsServiceProvider);
final savedSortMode = appSettings.getSetting(AppSettingsEnum.selectedAlbumSortOrder);
final savedIsReverse = appSettings.getSetting(AppSettingsEnum.selectedAlbumSortReverse);
final savedIsGrid = appSettings.getSetting(AppSettingsEnum.albumGridView);
final albumSortMode = AlbumSortMode.values.firstWhere(
(e) => e.storeIndex == savedSortMode,
orElse: () => AlbumSortMode.lastModified,
);
final albumConfig = ref.read(metadataProvider).appConfig.album;
setState(() {
sort = AlbumSort(mode: albumSortMode, isReverse: savedIsReverse);
isGrid = savedIsGrid;
sort = AlbumSort(mode: albumConfig.sortMode, isReverse: albumConfig.isReverse);
isGrid = albumConfig.isGrid;
});
ref.read(remoteAlbumProvider.notifier).refresh();
@@ -102,7 +94,7 @@ class _AlbumSelectorState extends ConsumerState<AlbumSelector> {
setState(() {
isGrid = !isGrid;
});
ref.read(appSettingsServiceProvider).setSetting(AppSettingsEnum.albumGridView, isGrid);
ref.read(metadataProvider).write(MetadataKey.albumIsGrid, isGrid);
}
void changeFilter(QuickFilterMode mode) {
@@ -118,9 +110,9 @@ class _AlbumSelectorState extends ConsumerState<AlbumSelector> {
this.sort = sort;
});
final appSettings = ref.read(appSettingsServiceProvider);
await appSettings.setSetting(AppSettingsEnum.selectedAlbumSortOrder, sort.mode.storeIndex);
await appSettings.setSetting(AppSettingsEnum.selectedAlbumSortReverse, sort.isReverse);
final metadata = ref.read(metadataProvider);
await metadata.write(MetadataKey.albumSortMode, sort.mode);
await metadata.write(MetadataKey.albumIsReverse, sort.isReverse);
await sortAlbums();
}
@@ -0,0 +1,252 @@
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/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
import 'package:immich_mobile/providers/album/pending_album_uploads.provider.dart';
/// Pinned banner sliver that surfaces in-flight album uploads directly under
/// the album app bar. Renders nothing while the queue is empty. Tapping the
/// banner opens a bottom sheet with per-asset progress.
class PendingUploadsBanner extends ConsumerWidget {
static const double _height = 52;
final String albumId;
const PendingUploadsBanner({super.key, required this.albumId});
@override
Widget build(BuildContext context, WidgetRef ref) {
final pending = ref.watch(pendingAlbumUploadsProvider(albumId));
if (pending.isEmpty) {
return const SliverToBoxAdapter(child: SizedBox.shrink());
}
final hasFailures = pending.any((p) => p.failed);
final clamped = pending.map((p) => p.progress.clamp(0.0, 1.0)).toList(growable: false);
final overallProgress = clamped.isEmpty ? 0.0 : clamped.reduce((a, b) => a + b) / clamped.length;
final isIndeterminate = overallProgress <= 0.0;
return SliverPersistentHeader(
pinned: true,
delegate: _PendingUploadsBannerDelegate(
height: _height,
child: _PendingUploadsBannerContent(
albumId: albumId,
previewAsset: pending.first.asset,
count: pending.length,
overallProgress: overallProgress,
isIndeterminate: isIndeterminate,
hasFailures: hasFailures,
),
),
);
}
static void _openSheet(BuildContext context, String albumId) {
showModalBottomSheet(
context: context,
showDragHandle: true,
builder: (_) => _PendingUploadsSheet(albumId: albumId),
);
}
}
class _PendingUploadsBannerDelegate extends SliverPersistentHeaderDelegate {
final double height;
final Widget child;
const _PendingUploadsBannerDelegate({required this.height, required this.child});
@override
double get minExtent => height;
@override
double get maxExtent => height;
@override
Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) => child;
@override
bool shouldRebuild(covariant _PendingUploadsBannerDelegate oldDelegate) =>
height != oldDelegate.height || child != oldDelegate.child;
}
class _PendingUploadsBannerContent extends StatelessWidget {
final String albumId;
final BaseAsset previewAsset;
final int count;
final double overallProgress;
final bool isIndeterminate;
final bool hasFailures;
const _PendingUploadsBannerContent({
required this.albumId,
required this.previewAsset,
required this.count,
required this.overallProgress,
required this.isIndeterminate,
required this.hasFailures,
});
@override
Widget build(BuildContext context) {
final percentLabel = isIndeterminate ? '' : ' · ${(overallProgress * 100).toInt()}%';
return Material(
color: hasFailures ? context.colorScheme.errorContainer : context.colorScheme.surfaceContainerHigh,
child: InkWell(
onTap: () => PendingUploadsBanner._openSheet(context, albumId),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(4)),
child: SizedBox(width: 32, height: 32, child: Thumbnail.fromAsset(asset: previewAsset)),
),
const SizedBox(width: 12),
Expanded(
child: Text(
'${'uploading'.t(context: context)} $count$percentLabel',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w500),
),
),
if (hasFailures)
Padding(
padding: const EdgeInsets.only(left: 8),
child: Icon(Icons.error_outline, color: context.colorScheme.error, size: 20),
),
Icon(Icons.chevron_right_rounded, color: context.colorScheme.onSurfaceVariant),
],
),
),
),
SizedBox(
height: 3,
child: LinearProgressIndicator(
value: isIndeterminate ? null : overallProgress,
backgroundColor: context.colorScheme.surfaceContainerHighest,
valueColor: AlwaysStoppedAnimation<Color>(
hasFailures ? context.colorScheme.error : context.colorScheme.primary,
),
),
),
],
),
),
);
}
}
class _PendingUploadsSheet extends ConsumerWidget {
final String albumId;
const _PendingUploadsSheet({required this.albumId});
@override
Widget build(BuildContext context, WidgetRef ref) {
final pending = ref.watch(pendingAlbumUploadsProvider(albumId));
// Auto-dismiss when the queue empties.
if (pending.isEmpty) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (Navigator.of(context).canPop()) {
Navigator.of(context).pop();
}
});
return const SizedBox.shrink();
}
final failedCount = pending.where((p) => p.failed).length;
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Row(
children: [
Expanded(
child: Text(
'${'uploading'.t(context: context)} (${pending.length})',
style: context.textTheme.titleMedium,
),
),
if (failedCount > 0)
TextButton.icon(
onPressed: () => ref.read(pendingAlbumUploadsProvider(albumId).notifier).clearFailed(),
icon: const Icon(Icons.clear_rounded, size: 18),
label: Text('clear_failed_count'.t(context: context, args: {'count': failedCount})),
style: TextButton.styleFrom(foregroundColor: context.colorScheme.error),
),
],
),
),
SizedBox(
height: 96,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: pending.length,
separatorBuilder: (_, __) => const SizedBox(width: 8),
itemBuilder: (_, index) => _PendingUploadTile(entry: pending[index]),
),
),
],
),
),
);
}
}
class _PendingUploadTile extends StatelessWidget {
final PendingAlbumUpload entry;
const _PendingUploadTile({required this.entry});
@override
Widget build(BuildContext context) {
return ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: SizedBox(
width: 96,
height: 96,
child: Stack(
fit: StackFit.expand,
children: [
Thumbnail.fromAsset(asset: entry.asset),
Positioned.fill(
child: ColoredBox(
color: entry.failed ? Colors.red.withValues(alpha: 0.6) : Colors.black54,
child: Center(
child: entry.failed
? const Icon(Icons.error_outline, color: Colors.white, size: 28)
: SizedBox(
width: 32,
height: 32,
child: CircularProgressIndicator(
value: entry.progress > 0 ? entry.progress : null,
strokeWidth: 2.5,
backgroundColor: Colors.white24,
valueColor: const AlwaysStoppedAnimation<Color>(Colors.white),
),
),
),
),
),
],
),
),
);
}
}
@@ -56,10 +56,13 @@ class _AssetPageState extends ConsumerState<AssetPage> {
_DragIntent _dragIntent = _DragIntent.none;
Drag? _drag;
BaseAsset? _asset;
@override
void initState() {
super.initState();
_eventSubscription = EventStream.shared.listen(_onEvent);
_asset = ref.read(timelineServiceProvider).getAssetSafe(widget.index);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || !_scrollController.hasClients) {
return;
@@ -71,6 +74,14 @@ class _AssetPageState extends ConsumerState<AssetPage> {
});
}
@override
void didUpdateWidget(AssetPage oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.index != widget.index) {
_asset = ref.read(timelineServiceProvider).getAssetSafe(widget.index);
}
}
@override
void dispose() {
_scrollController.dispose();
@@ -383,7 +394,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
final stackIndex = ref.watch(assetViewerProvider.select((s) => s.stackIndex));
final isPlayingMotionVideo = ref.watch(isPlayingMotionVideoProvider);
final asset = ref.read(timelineServiceProvider).getAssetSafe(widget.index);
final asset = _asset;
if (asset == null) {
return const Center(child: ImmichLoadingIndicator());
}
@@ -8,6 +8,7 @@ import 'package:immich_mobile/domain/models/setting.model.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/advanced_info_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/bulk_tag_assets_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart';
@@ -26,6 +27,7 @@ import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.d
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/setting.provider.dart';
import 'package:immich_mobile/providers/infrastructure/user_metadata.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
@@ -57,6 +59,9 @@ class _GeneralBottomSheetState extends ConsumerState<GeneralBottomSheet> {
final multiselect = ref.watch(multiSelectProvider);
final isTrashEnable = ref.watch(serverInfoProvider.select((state) => state.serverFeatures.trash));
final advancedTroubleshooting = ref.watch(settingsProvider.notifier).get(Setting.advancedTroubleshooting);
final tagsEnabled = ref.watch(
userMetadataPreferencesProvider.select((value) => value.valueOrNull?.tagsEnabled ?? false),
);
Future<void> addAssetsToAlbum(RemoteAlbum album) async {
final selectedAssets = multiselect.selectedAssets;
@@ -114,6 +119,7 @@ class _GeneralBottomSheetState extends ConsumerState<GeneralBottomSheet> {
: const DeletePermanentActionButton(source: ActionSource.timeline),
const FavoriteActionButton(source: ActionSource.timeline),
const ArchiveActionButton(source: ActionSource.timeline),
if (tagsEnabled) const BulkTagAssetsActionButton(source: ActionSource.timeline),
const EditDateTimeActionButton(source: ActionSource.timeline),
const EditLocationActionButton(source: ActionSource.timeline),
const MoveToLockFolderActionButton(source: ActionSource.timeline),
@@ -120,6 +120,9 @@ class _ThumbnailTileState extends ConsumerState<ThumbnailTile> {
},
flightShuttleBuilder: (context, animation, direction, from, to) {
void animationStatusListener(AnimationStatus status) {
if (!mounted) {
return;
}
final heroInFlight = status == AnimationStatus.forward || status == AnimationStatus.reverse;
if (_hideIndicators != heroInFlight) {
setState(() => _hideIndicators = heroInFlight);
@@ -242,7 +242,11 @@ class _AssetTileWidget extends ConsumerWidget {
return false;
}
return lockSelectionAssets.contains(asset);
// Iterate with `==` instead of `Set.contains` because `RemoteAsset.hashCode`
// includes `localId` while `==` does not — so the same server asset can
// hash to a different bucket when its `localId` differs (e.g., album-fetched
// copy has localId=null, merged-timeline copy has it populated).
return lockSelectionAssets.any((a) => a == asset);
}
@override
@@ -64,36 +64,32 @@ class Timeline extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
resizeToAvoidBottomInset: false,
floatingActionButton: const DownloadStatusFloatingButton(),
body: LayoutBuilder(
builder: (_, constraints) => ProviderScope(
overrides: [
timelineArgsProvider.overrideWith(
(ref) => TimelineArgs(
maxWidth: constraints.maxWidth,
maxHeight: constraints.maxHeight,
columnCount: ref.watch(appConfigProvider.select((config) => config.timeline.tilesPerRow)),
showStorageIndicator: showStorageIndicator,
withStack: withStack,
groupBy: groupBy,
),
return LayoutBuilder(
builder: (_, constraints) => ProviderScope(
overrides: [
timelineArgsProvider.overrideWith(
(ref) => TimelineArgs(
maxWidth: constraints.maxWidth,
maxHeight: constraints.maxHeight,
columnCount: ref.watch(appConfigProvider.select((config) => config.timeline.tilesPerRow)),
showStorageIndicator: showStorageIndicator,
withStack: withStack,
groupBy: groupBy,
),
if (readOnly) readonlyModeProvider.overrideWith(() => _AlwaysReadOnlyNotifier()),
],
child: _SliverTimeline(
topSliverWidget: topSliverWidget,
topSliverWidgetHeight: topSliverWidgetHeight,
bottomSliverWidget: bottomSliverWidget,
appBar: appBar,
bottomSheet: bottomSheet,
withScrubber: withScrubber,
persistentBottomBar: persistentBottomBar,
snapToMonth: snapToMonth,
maxWidth: constraints.maxWidth,
loadingWidget: loadingWidget,
),
if (readOnly) readonlyModeProvider.overrideWith(() => _AlwaysReadOnlyNotifier()),
],
child: _SliverTimeline(
topSliverWidget: topSliverWidget,
topSliverWidgetHeight: topSliverWidgetHeight,
bottomSliverWidget: bottomSliverWidget,
appBar: appBar,
bottomSheet: bottomSheet,
withScrubber: withScrubber,
persistentBottomBar: persistentBottomBar,
snapToMonth: snapToMonth,
maxWidth: constraints.maxWidth,
loadingWidget: loadingWidget,
),
),
);
@@ -379,121 +375,126 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
ref.read(multiSelectProvider.notifier).reset();
}
},
child: asyncSegments.widgetWhen(
onLoading: widget.loadingWidget != null ? () => widget.loadingWidget! : null,
onData: (segments) {
final childCount = (segments.lastOrNull?.lastIndex ?? -1) + 1;
final double appBarExpandedHeight = widget.appBar != null && widget.appBar is MesmerizingSliverAppBar
? 200
: 0;
final topPadding = context.padding.top + (widget.appBar == null ? 0 : kToolbarHeight) + 10;
child: PrimaryScrollController(
controller: _scrollController,
child: Scaffold(
resizeToAvoidBottomInset: false,
floatingActionButton: const DownloadStatusFloatingButton(),
body: asyncSegments.widgetWhen(
onLoading: widget.loadingWidget != null ? () => widget.loadingWidget! : null,
onData: (segments) {
final childCount = (segments.lastOrNull?.lastIndex ?? -1) + 1;
final double appBarExpandedHeight = widget.appBar != null && widget.appBar is MesmerizingSliverAppBar
? 200
: 0;
final topPadding = context.padding.top + (widget.appBar == null ? 0 : kToolbarHeight) + 10;
const bottomSheetOpenModifier = 120.0;
final contentBottomPadding = context.padding.bottom + (isMultiSelectEnabled ? bottomSheetOpenModifier : 0);
final scrubberBottomPadding = contentBottomPadding + kScrubberThumbHeight;
const bottomSheetOpenModifier = 120.0;
final contentBottomPadding =
context.padding.bottom + (isMultiSelectEnabled ? bottomSheetOpenModifier : 0);
final scrubberBottomPadding = contentBottomPadding + kScrubberThumbHeight;
final grid = CustomScrollView(
primary: true,
physics: _scrollPhysics,
cacheExtent: maxHeight * 2,
slivers: [
if (isSelectionMode) const SelectionSliverAppBar() else if (widget.appBar != null) widget.appBar!,
if (widget.topSliverWidget != null) widget.topSliverWidget!,
_SliverSegmentedList(
segments: segments,
delegate: SliverChildBuilderDelegate(
(ctx, index) {
if (index >= childCount) {
return null;
}
final segment = segments.findByIndex(index);
return segment?.builder(ctx, index) ?? const SizedBox.shrink();
},
childCount: childCount,
addAutomaticKeepAlives: false,
// We add repaint boundary around tiles, so skip the auto boundaries
addRepaintBoundaries: false,
),
),
if (widget.bottomSliverWidget != null) widget.bottomSliverWidget!,
SliverPadding(padding: EdgeInsets.only(bottom: contentBottomPadding)),
],
);
final grid = CustomScrollView(
primary: true,
physics: _scrollPhysics,
cacheExtent: maxHeight * 2,
slivers: [
if (isSelectionMode) const SelectionSliverAppBar() else if (widget.appBar != null) widget.appBar!,
if (widget.topSliverWidget != null) widget.topSliverWidget!,
_SliverSegmentedList(
segments: segments,
delegate: SliverChildBuilderDelegate(
(ctx, index) {
if (index >= childCount) {
return null;
}
final segment = segments.findByIndex(index);
return segment?.builder(ctx, index) ?? const SizedBox.shrink();
},
childCount: childCount,
addAutomaticKeepAlives: false,
// We add repaint boundary around tiles, so skip the auto boundaries
addRepaintBoundaries: false,
),
),
if (widget.bottomSliverWidget != null) widget.bottomSliverWidget!,
SliverPadding(padding: EdgeInsets.only(bottom: contentBottomPadding)),
],
);
final Widget timeline;
if (widget.withScrubber) {
timeline = Scrubber(
snapToMonth: widget.snapToMonth,
layoutSegments: segments,
timelineHeight: maxHeight,
topPadding: topPadding,
bottomPadding: scrubberBottomPadding,
monthSegmentSnappingOffset: widget.topSliverWidgetHeight ?? 0 + appBarExpandedHeight,
hasAppBar: widget.appBar != null,
child: grid,
);
} else {
timeline = grid;
}
final Widget timeline;
if (widget.withScrubber) {
timeline = Scrubber(
snapToMonth: widget.snapToMonth,
layoutSegments: segments,
timelineHeight: maxHeight,
topPadding: topPadding,
bottomPadding: scrubberBottomPadding,
monthSegmentSnappingOffset: widget.topSliverWidgetHeight ?? 0 + appBarExpandedHeight,
hasAppBar: widget.appBar != null,
child: grid,
);
} else {
timeline = grid;
}
return PrimaryScrollController(
controller: _scrollController,
child: RawGestureDetector(
gestures: {
CustomScaleGestureRecognizer: GestureRecognizerFactoryWithHandlers<CustomScaleGestureRecognizer>(
() => CustomScaleGestureRecognizer(),
(CustomScaleGestureRecognizer scale) {
scale.onStart = (details) {
_baseScaleFactor = _scaleFactor;
};
return RawGestureDetector(
gestures: {
CustomScaleGestureRecognizer: GestureRecognizerFactoryWithHandlers<CustomScaleGestureRecognizer>(
() => CustomScaleGestureRecognizer(),
(CustomScaleGestureRecognizer scale) {
scale.onStart = (details) {
_baseScaleFactor = _scaleFactor;
};
scale.onUpdate = (details) {
final newScaleFactor = math.max(math.min(5.0, _baseScaleFactor * details.scale), 1.0);
final newPerRow = 7 - newScaleFactor.toInt();
scale.onUpdate = (details) {
final newScaleFactor = math.max(math.min(5.0, _baseScaleFactor * details.scale), 1.0);
final newPerRow = 7 - newScaleFactor.toInt();
if (newPerRow != _perRow) {
final targetAssetIndex = _getCurrentAssetIndex(segments);
setState(() {
_scaleFactor = newScaleFactor;
_perRow = newPerRow;
_restoreAssetIndex = targetAssetIndex;
});
if (newPerRow != _perRow) {
final targetAssetIndex = _getCurrentAssetIndex(segments);
setState(() {
_scaleFactor = newScaleFactor;
_perRow = newPerRow;
_restoreAssetIndex = targetAssetIndex;
});
ref.read(metadataProvider).write(MetadataKey.timelineTilesPerRow, _perRow);
}
};
},
),
},
child: TimelineDragRegion(
onStart: !isReadonlyModeEnabled ? _setDragStartIndex : null,
onAssetEnter: _handleDragAssetEnter,
onEnd: !isReadonlyModeEnabled ? _stopDrag : null,
onScroll: _dragScroll,
onScrollStart: () {
// Minimize the bottom sheet when drag selection starts
ref.read(timelineStateProvider.notifier).setScrolling(true);
ref.read(metadataProvider).write(MetadataKey.timelineTilesPerRow, _perRow);
}
};
},
),
},
child: Stack(
clipBehavior: Clip.none,
children: [
timeline,
if (isBottomWidgetVisible)
Positioned(
top: MediaQuery.paddingOf(context).top,
left: 25,
child: const SizedBox(
height: kToolbarHeight,
child: Center(child: _MultiSelectStatusButton()),
child: TimelineDragRegion(
onStart: !isReadonlyModeEnabled ? _setDragStartIndex : null,
onAssetEnter: _handleDragAssetEnter,
onEnd: !isReadonlyModeEnabled ? _stopDrag : null,
onScroll: _dragScroll,
onScrollStart: () {
// Minimize the bottom sheet when drag selection starts
ref.read(timelineStateProvider.notifier).setScrolling(true);
},
child: Stack(
clipBehavior: Clip.none,
children: [
timeline,
if (isBottomWidgetVisible)
Positioned(
top: MediaQuery.paddingOf(context).top,
left: 25,
child: const SizedBox(
height: kToolbarHeight,
child: Center(child: _MultiSelectStatusButton()),
),
),
),
if (isBottomWidgetVisible) widget.bottomSheet!,
],
if (isBottomWidgetVisible) widget.bottomSheet!,
],
),
),
),
),
);
},
);
},
),
),
),
);
}
@@ -0,0 +1,81 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
class PendingAlbumUpload {
final LocalAsset asset;
final double progress;
final bool failed;
const PendingAlbumUpload({required this.asset, this.progress = 0.0, this.failed = false});
PendingAlbumUpload copyWith({double? progress, bool? failed}) =>
PendingAlbumUpload(asset: asset, progress: progress ?? this.progress, failed: failed ?? this.failed);
}
class AlbumPendingUploadsNotifier extends AutoDisposeFamilyNotifier<List<PendingAlbumUpload>, String> {
KeepAliveLink? _keepAliveLink;
@override
List<PendingAlbumUpload> build(String albumId) {
ref.onDispose(() {
_keepAliveLink?.close();
_keepAliveLink = null;
});
return const [];
}
void enqueue(Iterable<LocalAsset> assets) {
if (assets.isEmpty) {
return;
}
final existingIds = state.map((e) => e.asset.id).toSet();
final additions = assets.where((a) => !existingIds.contains(a.id)).map((a) => PendingAlbumUpload(asset: a));
state = [...state, ...additions];
_syncKeepAlive();
}
void updateProgress(String localAssetId, double progress) {
state = [
for (final entry in state)
if (entry.asset.id == localAssetId) entry.copyWith(progress: progress, failed: false) else entry,
];
_syncKeepAlive();
}
void markFailed(String localAssetId) {
state = [
for (final entry in state)
if (entry.asset.id == localAssetId) entry.copyWith(failed: true) else entry,
];
_syncKeepAlive();
}
void markAllFailed() {
state = [for (final entry in state) entry.copyWith(failed: true)];
_syncKeepAlive();
}
void remove(String localAssetId) {
state = state.where((e) => e.asset.id != localAssetId).toList();
_syncKeepAlive();
}
void clearFailed() {
state = state.where((e) => !e.failed).toList();
_syncKeepAlive();
}
void _syncKeepAlive() {
if (state.isEmpty) {
_keepAliveLink?.close();
_keepAliveLink = null;
} else {
_keepAliveLink ??= ref.keepAlive();
}
}
}
final pendingAlbumUploadsProvider = NotifierProvider.autoDispose
.family<AlbumPendingUploadsNotifier, List<PendingAlbumUpload>, String>(AlbumPendingUploadsNotifier.new);
+10 -5
View File
@@ -1,6 +1,9 @@
import 'dart:convert';
import 'package:flutter_udid/flutter_udid.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/metadata_key.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/domain/services/user.service.dart';
@@ -8,6 +11,7 @@ import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/models/auth/auth_state.model.dart';
import 'package:immich_mobile/models/auth/login_response.model.dart';
import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/services/auth.service.dart';
@@ -126,7 +130,8 @@ class AuthNotifier extends StateNotifier<AuthState> {
await _apiService.updateHeaders();
final serverEndpoint = Store.get(StoreKey.serverEndpoint);
final customHeaders = Store.tryGet(StoreKey.customHeaders);
final headerMap = _ref.read(metadataProvider).systemConfig.network.customHeaders;
final customHeaders = headerMap.isEmpty ? null : jsonEncode(headerMap);
await _widgetService.writeCredentials(serverEndpoint, accessToken, customHeaders);
// Get the deviceid from the store if it exists, otherwise generate a new one
@@ -174,19 +179,19 @@ class AuthNotifier extends StateNotifier<AuthState> {
}
Future<void> saveWifiName(String wifiName) async {
await Store.put(StoreKey.preferredWifiName, wifiName);
await _ref.read(metadataProvider).write(MetadataKey.networkPreferredWifiName, wifiName);
}
Future<void> saveLocalEndpoint(String url) async {
await Store.put(StoreKey.localEndpoint, url);
await _ref.read(metadataProvider).write(MetadataKey.networkLocalEndpoint, url);
}
String? getSavedWifiName() {
return Store.tryGet(StoreKey.preferredWifiName);
return _ref.read(metadataProvider).systemConfig.network.preferredWifiName;
}
String? getSavedLocalEndpoint() {
return Store.tryGet(StoreKey.localEndpoint);
return _ref.read(metadataProvider).systemConfig.network.localEndpoint;
}
/// Returns the current server endpoint (with /api) URL from the store
@@ -13,6 +13,7 @@ import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'
import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart' show assetExifProvider;
import 'package:immich_mobile/providers/infrastructure/tag.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/providers/websocket.provider.dart';
@@ -353,6 +354,23 @@ class ActionNotifier extends Notifier<void> {
}
}
Future<ActionResult?> tagAssets(ActionSource source, BuildContext context) async {
final ids = _getOwnedRemoteIdsForSource(source);
try {
final count = await _service.tagAssets(ids, context);
if (count == null) {
return null;
}
ref.invalidate(tagProvider);
return ActionResult(count: count, success: true);
} catch (error, stack) {
_logger.severe('Failed to tag assets', error, stack);
ref.invalidate(tagProvider);
return ActionResult(count: ids.length, success: false, error: error.toString());
}
}
Future<ActionResult> removeFromAlbum(ActionSource source, String albumId) async {
final ids = _getRemoteIdsForSource(source);
try {
@@ -9,6 +9,7 @@ import 'package:immich_mobile/infrastructure/repositories/remote_album.repositor
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/providers/infrastructure/remote_album.provider.dart';
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
import 'package:immich_mobile/services/foreground_upload.service.dart';
final localAlbumRepository = Provider<DriftLocalAlbumRepository>(
(ref) => DriftLocalAlbumRepository(ref.watch(driftProvider)),
@@ -33,7 +34,11 @@ final remoteAlbumRepository = Provider<DriftRemoteAlbumRepository>(
);
final remoteAlbumServiceProvider = Provider<RemoteAlbumService>(
(ref) => RemoteAlbumService(ref.watch(remoteAlbumRepository), ref.watch(driftAlbumApiRepositoryProvider)),
(ref) => RemoteAlbumService(
ref.watch(remoteAlbumRepository),
ref.watch(driftAlbumApiRepositoryProvider),
ref.watch(foregroundUploadServiceProvider),
),
dependencies: [remoteAlbumRepository],
);
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:collection/collection.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
@@ -6,8 +8,10 @@ import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/domain/services/remote_album.service.dart';
import 'package:immich_mobile/models/albums/album_search.model.dart';
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
import 'package:immich_mobile/providers/album/pending_album_uploads.provider.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/services/foreground_upload.service.dart';
import 'package:logging/logging.dart';
class RemoteAlbumState {
@@ -105,6 +109,46 @@ class RemoteAlbumNotifier extends Notifier<RemoteAlbumState> {
}
}
/// Creates an album from a heterogeneous asset selection. Already-remote
/// assets seed the album immediately; local-only assets are uploaded in the
/// background and linked one-by-one as each upload completes.
Future<RemoteAlbum?> createAlbumWithAssets({
required String title,
String? description,
Iterable<BaseAsset> assets = const [],
}) async {
try {
final currentUser = ref.read(currentUserProvider);
if (currentUser == null) {
throw Exception('User not logged in');
}
final candidates = RemoteAlbumService.categorizeCandidates(assets);
final album = await _remoteAlbumService.createAlbum(
title: title,
owner: currentUser,
description: description,
assetIds: candidates.remoteAssetIds,
);
state = state.copyWith(albums: [...state.albums, album]);
if (candidates.localAssetsToUpload.isNotEmpty) {
unawaited(
addAssetsToAlbum(
album.id,
candidates.localAssetsToUpload,
).then<void>((_) {}).catchError((Object _, StackTrace _) {}),
);
}
return album;
} catch (error, stack) {
_logger.severe('Failed to create album with assets', error, stack);
rethrow;
}
}
Future<RemoteAlbum?> updateAlbum(
String albumId, {
String? name,
@@ -155,8 +199,65 @@ class RemoteAlbumNotifier extends Notifier<RemoteAlbumState> {
return _remoteAlbumService.getAssets(albumId);
}
Future<int> addAssets(String albumId, List<String> assetIds) {
return _remoteAlbumService.addAssets(albumId: albumId, assetIds: assetIds);
Future<int> addAssets(String albumId, List<String> assetIds) async {
final added = await _remoteAlbumService.addAssets(albumId: albumId, assetIds: assetIds);
if (added > 0) {
await _refreshAlbumInState(albumId);
}
return added;
}
/// Adds a heterogeneous asset selection to an album. Already-remote assets
/// are linked immediately; local-only assets are queued in
/// [pendingAlbumUploadsProvider] (so the album page can show them with
/// progress indicators), uploaded, and linked one-by-one as each finishes.
Future<int> addAssetsToAlbum(String albumId, Iterable<BaseAsset> assets) async {
final currentUser = ref.read(currentUserProvider);
if (currentUser == null) {
throw Exception('User not logged in');
}
final candidates = RemoteAlbumService.categorizeCandidates(assets);
final pendingNotifier = ref.read(pendingAlbumUploadsProvider(albumId).notifier);
pendingNotifier.enqueue(candidates.localAssetsToUpload);
try {
final added = await _remoteAlbumService.addAssetsToAlbum(
albumId: albumId,
uploader: currentUser,
candidates: candidates,
uploadCallbacks: UploadCallbacks(
onProgress: (localAssetId, _, bytes, totalBytes) {
final progress = totalBytes > 0 ? bytes / totalBytes : 0.0;
pendingNotifier.updateProgress(localAssetId, progress);
},
onSuccess: (localAssetId, _) => pendingNotifier.remove(localAssetId),
onError: (localAssetId, _) => pendingNotifier.markFailed(localAssetId),
),
);
if (added > 0) {
await _refreshAlbumInState(albumId);
}
return added;
} catch (error, stack) {
if (candidates.localAssetsToUpload.isNotEmpty) {
pendingNotifier.markAllFailed();
}
_logger.severe('Failed to add assets to album $albumId', error, stack);
rethrow;
}
}
/// Re-reads a single album from the local DB and replaces it in [state] so
/// that views bound to the album list (counts, thumbnails) reflect the
/// latest junction-table changes without a full `refresh()`.
Future<void> _refreshAlbumInState(String albumId) async {
final updated = await _remoteAlbumService.get(albumId);
if (updated == null) {
return;
}
state = state.copyWith(albums: state.albums.map((album) => album.id == albumId ? updated : album).toList());
}
Future<void> addUsers(String albumId, List<String> userIds) {
@@ -1,16 +1,22 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/tag.model.dart';
import 'package:immich_mobile/infrastructure/repositories/tags_api.repository.dart';
import 'package:immich_mobile/domain/services/tag.service.dart';
class TagNotifier extends AsyncNotifier<Set<Tag>> {
@override
Future<Set<Tag>> build() async {
final repo = ref.read(tagsApiRepositoryProvider);
final allTags = await repo.getAllTags();
if (allTags == null) {
return {};
}
return allTags.map((t) => Tag.fromDto(t)).toSet();
return ref.watch(tagServiceProvider).getAllTags();
}
Future<int> bulkTagAssets(List<String> assetIds, List<String> tagIds) async {
return ref.read(tagServiceProvider).bulkTagAssets(assetIds, tagIds);
}
Future<List<Tag>> upsertTags(List<String> tags) async {
final upsertedTags = await ref.read(tagServiceProvider).upsertTags(tags);
state = AsyncValue.data({...?state.valueOrNull, ...upsertedTags});
return upsertedTags;
}
}
+13 -19
View File
@@ -1,46 +1,40 @@
import 'dart:convert';
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/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart';
import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
final authRepositoryProvider = Provider<AuthRepository>((ref) => AuthRepository(ref.watch(driftProvider)));
final authRepositoryProvider = Provider<AuthRepository>(
(ref) => AuthRepository(ref.watch(driftProvider), ref.watch(metadataProvider)),
);
class AuthRepository {
final Drift _drift;
final MetadataRepository _metadata;
const AuthRepository(this._drift);
const AuthRepository(this._drift, this._metadata);
Future<void> clearLocalData() async {
await SyncStreamRepository(_drift).reset();
}
bool getEndpointSwitchingFeature() {
return Store.tryGet(StoreKey.autoEndpointSwitching) ?? false;
return _metadata.systemConfig.network.autoEndpointSwitching;
}
String? getPreferredWifiName() {
return Store.tryGet(StoreKey.preferredWifiName);
return _metadata.systemConfig.network.preferredWifiName;
}
String? getLocalEndpoint() {
return Store.tryGet(StoreKey.localEndpoint);
return _metadata.systemConfig.network.localEndpoint;
}
List<AuxilaryEndpoint> getExternalEndpointList() {
final jsonString = Store.tryGet(StoreKey.externalEndpointList);
if (jsonString == null) {
return [];
}
final List<dynamic> jsonList = jsonDecode(jsonString);
final endpointList = jsonList.map((e) => AuxilaryEndpoint.fromJson(e)).toList();
return endpointList;
return _metadata.systemConfig.network.externalEndpointList
.map((url) => AuxilaryEndpoint(url: url, status: .valid))
.toList();
}
}
+2
View File
@@ -60,6 +60,7 @@ import 'package:immich_mobile/presentation/pages/drift_place_detail.page.dart';
import 'package:immich_mobile/presentation/pages/drift_recently_taken.page.dart';
import 'package:immich_mobile/presentation/pages/drift_recently_added.page.dart';
import 'package:immich_mobile/presentation/pages/drift_remote_album.page.dart';
import 'package:immich_mobile/presentation/pages/drift_slideshow.page.dart';
import 'package:immich_mobile/presentation/pages/drift_trash.page.dart';
import 'package:immich_mobile/presentation/pages/drift_user_selection.page.dart';
import 'package:immich_mobile/presentation/pages/drift_video.page.dart';
@@ -189,6 +190,7 @@ class AppRouter extends RootStackRouter {
AutoRoute(page: AssetTroubleshootRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: DownloadInfoRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: CleanupPreviewRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: DriftSlideshowRoute.page, guards: [_authGuard, _duplicateGuard]),
// required to handle all deeplinks in deep_link.service.dart
// auto_route_library#1722
RedirectRoute(path: '*', redirectTo: '/'),
+47
View File
@@ -1095,6 +1095,53 @@ class DriftSearchRoute extends PageRouteInfo<void> {
);
}
/// generated route for
/// [DriftSlideshowPage]
class DriftSlideshowRoute extends PageRouteInfo<DriftSlideshowRouteArgs> {
DriftSlideshowRoute({
Key? key,
required TimelineService timeline,
List<PageRouteInfo>? children,
}) : super(
DriftSlideshowRoute.name,
args: DriftSlideshowRouteArgs(key: key, timeline: timeline),
initialChildren: children,
);
static const String name = 'DriftSlideshowRoute';
static PageInfo page = PageInfo(
name,
builder: (data) {
final args = data.argsAs<DriftSlideshowRouteArgs>();
return DriftSlideshowPage(key: args.key, timeline: args.timeline);
},
);
}
class DriftSlideshowRouteArgs {
const DriftSlideshowRouteArgs({this.key, required this.timeline});
final Key? key;
final TimelineService timeline;
@override
String toString() {
return 'DriftSlideshowRouteArgs{key: $key, timeline: $timeline}';
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
if (other is! DriftSlideshowRouteArgs) return false;
return key == other.key && timeline == other.timeline;
}
@override
int get hashCode => key.hashCode ^ timeline.hashCode;
}
/// generated route for
/// [DriftTrashPage]
class DriftTrashRoute extends PageRouteInfo<void> {
+25
View File
@@ -7,6 +7,7 @@ import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/asset_edit.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/tag.service.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
@@ -23,6 +24,7 @@ import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/timezone.dart';
import 'package:immich_mobile/widgets/common/date_time_picker.dart';
import 'package:immich_mobile/widgets/common/location_picker.dart';
import 'package:immich_mobile/widgets/common/tag_picker.dart';
import 'package:maplibre_gl/maplibre_gl.dart' as maplibre;
final actionServiceProvider = Provider<ActionService>(
@@ -35,6 +37,7 @@ final actionServiceProvider = Provider<ActionService>(
ref.watch(trashedLocalAssetRepository),
ref.watch(assetMediaRepositoryProvider),
ref.watch(downloadRepositoryProvider),
ref.watch(tagServiceProvider),
),
);
@@ -47,6 +50,7 @@ class ActionService {
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
final AssetMediaRepository _assetMediaRepository;
final DownloadRepository _downloadRepository;
final TagService _tagService;
const ActionService(
this._assetApiRepository,
@@ -57,6 +61,7 @@ class ActionService {
this._trashedLocalAssetRepository,
this._assetMediaRepository,
this._downloadRepository,
this._tagService,
);
Future<void> shareLink(List<String> remoteIds, BuildContext context) async {
@@ -234,6 +239,26 @@ class ActionService {
return true;
}
Future<int?> tagAssets(List<String> remoteIds, BuildContext context) async {
final tagResults = await showTagPickerModal(context: context);
if (tagResults == null) {
// user cancelled
return null;
}
final selectedTagIds = Set<String>.from(tagResults.$1);
final selectedNewTagValues = tagResults.$2;
if (selectedNewTagValues.isNotEmpty) {
final upsertedTags = await _tagService.upsertTags(selectedNewTagValues.toList());
selectedTagIds.addAll(upsertedTags.map((t) => t.id));
}
if (selectedTagIds.isEmpty) {
return 0;
}
return _tagService.bulkTagAssets(remoteIds, selectedTagIds.toList());
}
Future<void> stack(String userId, List<String> remoteIds) async {
final stack = await _assetApiRepository.stack(remoteIds);
await _remoteAssetRepository.stack(userId, stack);
+8 -17
View File
@@ -5,8 +5,8 @@ import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart';
import 'package:immich_mobile/utils/debug_print.dart';
import 'package:immich_mobile/utils/url_helper.dart';
import 'package:logging/logging.dart';
@@ -177,30 +177,21 @@ class ApiService {
if (serverEndpoint != null && serverEndpoint.isNotEmpty) {
urls.add(serverEndpoint);
}
final localEndpoint = Store.tryGet(StoreKey.localEndpoint);
if (localEndpoint != null && localEndpoint.isNotEmpty) {
final network = MetadataRepository.instance.systemConfig.network;
final localEndpoint = network.localEndpoint;
if (localEndpoint != null) {
urls.add(localEndpoint);
}
final externalJson = Store.tryGet(StoreKey.externalEndpointList);
if (externalJson != null) {
final List<dynamic> list = jsonDecode(externalJson);
for (final entry in list) {
final url = AuxilaryEndpoint.fromJson(entry).url;
if (url.isNotEmpty) {
urls.add(url);
}
for (final url in network.externalEndpointList) {
if (url.isNotEmpty) {
urls.add(url);
}
}
return urls;
}
static Map<String, String> getRequestHeaders() {
var customHeadersStr = Store.get(StoreKey.customHeaders, "");
if (customHeadersStr.isEmpty) {
return const {};
}
return (jsonDecode(customHeadersStr) as Map).cast<String, String>();
return MetadataRepository.instance.systemConfig.network.customHeaders;
}
ApiClient get apiClient => _apiClient;
@@ -2,18 +2,14 @@ import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
enum AppSettingsEnum<T> {
selectedAlbumSortOrder<int>(StoreKey.selectedAlbumSortOrder, "selectedAlbumSortOrder", 2),
advancedTroubleshooting<bool>(StoreKey.advancedTroubleshooting, null, false),
manageLocalMediaAndroid<bool>(StoreKey.manageLocalMediaAndroid, null, false),
selectedAlbumSortReverse<bool>(StoreKey.selectedAlbumSortReverse, null, true),
enableHapticFeedback<bool>(StoreKey.enableHapticFeedback, null, true),
syncAlbums<bool>(StoreKey.syncAlbums, null, false),
autoEndpointSwitching<bool>(StoreKey.autoEndpointSwitching, null, false),
enableBackup<bool>(StoreKey.enableBackup, null, false),
useCellularForUploadVideos<bool>(StoreKey.useWifiForUploadVideos, null, false),
useCellularForUploadPhotos<bool>(StoreKey.useWifiForUploadPhotos, null, false),
readonlyModeEnabled<bool>(StoreKey.readonlyModeEnabled, "readonlyModeEnabled", false),
albumGridView<bool>(StoreKey.albumGridView, "albumGridView", false),
backupRequireCharging<bool>(StoreKey.backupRequireCharging, null, false),
backupTriggerDelay<int>(StoreKey.backupTriggerDelay, null, 30);
-4
View File
@@ -123,10 +123,6 @@ class AuthService {
_authRepository.clearLocalData(),
Store.delete(StoreKey.currentUser),
Store.delete(StoreKey.accessToken),
Store.delete(StoreKey.autoEndpointSwitching),
Store.delete(StoreKey.preferredWifiName),
Store.delete(StoreKey.localEndpoint),
Store.delete(StoreKey.externalEndpointList),
]);
}
@@ -27,6 +27,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/set_profile_pi
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/similar_photos_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/slideshow_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/unarchive_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/unstack_action_button.widget.dart';
@@ -73,6 +74,7 @@ enum ActionButtonType {
similarPhotos,
setProfilePicture,
viewInTimeline,
slideshow,
download,
upload,
openInBrowser,
@@ -179,6 +181,7 @@ enum ActionButtonType {
context.timelineOrigin != TimelineOrigin.localAlbum &&
context.isOwner,
ActionButtonType.cast => context.isCasting || context.asset.hasRemote,
ActionButtonType.slideshow => true,
};
}
@@ -200,6 +203,7 @@ enum ActionButtonType {
iconOnly: iconOnly,
menuItem: menuItem,
),
ActionButtonType.slideshow => SlideshowActionButton(iconOnly: iconOnly, menuItem: menuItem),
ActionButtonType.archive => ArchiveActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
ActionButtonType.unarchive => UnArchiveActionButton(
source: context.source,
+116 -12
View File
@@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:convert';
import 'package:drift/drift.dart';
import 'package:flutter/material.dart';
@@ -12,7 +13,8 @@ import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/entities/metadata.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart';
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
const int targetVersion = 26;
@@ -37,12 +39,35 @@ Future<void> _migrateTo25() async {
return;
}
final serverUrls = ApiService.getServerUrls();
if (serverUrls.isEmpty) {
final urls = <String>[];
final serverEndpoint = Store.tryGet(StoreKey.serverEndpoint);
if (serverEndpoint != null && serverEndpoint.isNotEmpty) {
urls.add(serverEndpoint);
}
final localEndpoint = Store.tryGet(StoreKey.legacyLocalEndpoint);
if (localEndpoint != null && localEndpoint.isNotEmpty) {
urls.add(localEndpoint);
}
final externalJson = Store.tryGet(StoreKey.legacyExternalEndpointList);
if (externalJson != null) {
final List<dynamic> list = jsonDecode(externalJson);
for (final entry in list) {
final url = AuxilaryEndpoint.fromJson(entry).url;
if (url.isNotEmpty) {
urls.add(url);
}
}
}
if (urls.isEmpty) {
return;
}
await NetworkRepository.setHeaders(ApiService.getRequestHeaders(), serverUrls, token: accessToken);
final customHeadersStr = Store.get(StoreKey.legacyCustomHeaders, "");
final headers = customHeadersStr.isEmpty
? const <String, String>{}
: (jsonDecode(customHeadersStr) as Map).cast<String, String>();
await NetworkRepository.setHeaders(headers, urls, token: accessToken);
}
Future<void> _migrateTo26(Drift drift) async {
@@ -57,14 +82,7 @@ Future<void> _migrateTo26(Drift drift) async {
final cleanupKeepAlbumIds = await migrator.readLegacyStoreString(StoreKey.legacyCleanupKeepAlbumIds.id);
if (cleanupKeepAlbumIds != null) {
final ids = cleanupKeepAlbumIds.split(',').where((id) => id.isNotEmpty).toList();
await drift.metadataEntity.insertOnConflictUpdate(
MetadataEntityCompanion.insert(
key: MetadataKey.cleanupKeepAlbumIds.key,
value: MetadataKey.cleanupKeepAlbumIds.encode(ids),
updatedAt: Value(DateTime.now()),
),
);
await migrator.deleteLegacyStoreRows([StoreKey.legacyCleanupKeepAlbumIds.id]);
migrator.stage(StoreKey.legacyCleanupKeepAlbumIds, MetadataKey.cleanupKeepAlbumIds, ids);
}
await migrator.migrateBool(StoreKey.legacyCleanupKeepFavorites, MetadataKey.cleanupKeepFavorites);
await migrator.migrateEnumIndex(
@@ -96,9 +114,80 @@ Future<void> _migrateTo26(Drift drift) async {
await migrator.migrateBool(StoreKey.legacyLoadOriginalVideo, MetadataKey.viewerLoadOriginalVideo);
await migrator.migrateBool(StoreKey.legacyAutoPlayVideo, MetadataKey.viewerAutoPlayVideo);
await migrator.migrateBool(StoreKey.legacyTapToNavigate, MetadataKey.viewerTapToNavigate);
// Network
await migrator.migrateBool(StoreKey.legacyAutoEndpointSwitching, MetadataKey.networkAutoEndpointSwitching);
await migrator.migrateString(StoreKey.legacyPreferredWifiName, MetadataKey.networkPreferredWifiName);
await migrator.migrateString(StoreKey.legacyLocalEndpoint, MetadataKey.networkLocalEndpoint);
await _migrateExternalEndpointList(migrator);
await _migrateCustomHeaders(migrator);
// Album
await _migrateAlbumSortMode(migrator);
await migrator.migrateBool(StoreKey.legacySelectedAlbumSortReverse, MetadataKey.albumIsReverse);
await migrator.migrateBool(StoreKey.legacyAlbumGridView, MetadataKey.albumIsGrid);
await migrator.complete();
}
Future<void> _migrateAlbumSortMode(_StoreMigrator migrator) async {
final raw = await migrator.readLegacyStoreInt(StoreKey.legacySelectedAlbumSortOrder.id);
if (raw == null) {
return;
}
final mode = AlbumSortMode.values.firstWhere(
(e) => e.storeIndex == raw,
orElse: () => MetadataKey.albumSortMode.defaultValue,
);
migrator.stage(StoreKey.legacySelectedAlbumSortOrder, MetadataKey.albumSortMode, mode);
}
Future<void> _migrateExternalEndpointList(_StoreMigrator migrator) async {
final raw = await migrator.readLegacyStoreString(StoreKey.legacyExternalEndpointList.id);
if (raw == null) {
return;
}
final urls = <String>[];
try {
final decoded = jsonDecode(raw);
if (decoded is List) {
for (final entry in decoded) {
final url = AuxilaryEndpoint.fromJson(entry).url;
if (url.isNotEmpty) {
urls.add(url);
}
}
}
} on FormatException {
// ignore invalid entries
}
migrator.stage(StoreKey.legacyExternalEndpointList, MetadataKey.networkExternalEndpointList, urls);
}
Future<void> _migrateCustomHeaders(_StoreMigrator migrator) async {
final raw = await migrator.readLegacyStoreString(StoreKey.legacyCustomHeaders.id);
if (raw == null) {
return;
}
final headers = <String, String>{};
try {
final decoded = jsonDecode(raw);
if (decoded is Map) {
decoded.forEach((key, value) {
if (key is String && value is String) {
headers[key] = value;
}
});
}
} on FormatException {
// ignore invalid entries
}
migrator.stage(StoreKey.legacyCustomHeaders, MetadataKey.networkCustomHeaders, headers);
}
class _StoreMigrator {
final Drift _db;
final Map<MetadataKey<Object>, Object> _cache = {};
@@ -153,6 +242,21 @@ class _StoreMigrator {
_migratedStoreIds.add(legacyKey.id);
}
Future<void> migrateString(StoreKey<String> legacyKey, MetadataKey<String> newKey) async {
final value = await readLegacyStoreString(legacyKey.id);
if (value == null) {
return;
}
_cache[newKey] = value;
_migratedStoreIds.add(legacyKey.id);
}
void stage<T extends Object>(StoreKey legacyKey, MetadataKey<T> newKey, T value) {
_cache[newKey] = value;
_migratedStoreIds.add(legacyKey.id);
}
Future<void> complete() async {
await _db.batch((batch) {
for (final entry in _cache.entries) {
+11
View File
@@ -24,6 +24,17 @@ String? getServerUrl() {
);
}
String? buildSharedLinkUrl({required String? baseUrl, required String key, String? slug}) {
if (baseUrl == null || baseUrl.isEmpty) {
return null;
}
final normalizedBaseUrl = baseUrl.endsWith('/') ? baseUrl : '$baseUrl/';
final path = (slug != null && slug.isNotEmpty) ? 's/$slug' : 'share/$key';
return '$normalizedBaseUrl$path';
}
/// Converts a Unicode URL to its ASCII-compatible encoding (Punycode).
///
/// This is especially useful for internationalized domain names (IDNs),
@@ -18,6 +18,7 @@ import 'package:immich_mobile/providers/infrastructure/current_album.provider.da
import 'package:immich_mobile/providers/infrastructure/remote_album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/widgets/album/remote_album_shared_user_icons.dart';
class RemoteAlbumSliverAppBar extends ConsumerStatefulWidget {
@@ -89,6 +90,10 @@ class _MesmerizingSliverAppBarState extends ConsumerState<RemoteAlbumSliverAppBa
onPressed: () => context.maybePop(),
),
actions: [
IconButton(
onPressed: () => context.pushRoute(DriftSlideshowRoute(timeline: ref.read(timelineServiceProvider))),
icon: Icon(Icons.slideshow_outlined, color: actionIconColor, shadows: actionIconShadows),
),
if (currentAlbum.isActivityEnabled && currentAlbum.isShared)
IconButton(
icon: Icon(Icons.chat_outlined, color: actionIconColor, shadows: actionIconShadows),
+113 -6
View File
@@ -8,12 +8,78 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/infrastructure/tag.provider.dart';
import 'package:immich_mobile/widgets/common/search_field.dart';
class TagPicker extends HookConsumerWidget {
const TagPicker({super.key, required this.onSelect, required this.filter});
String _trimSlashes(String s) => s.replaceAll(RegExp(r'^/+|/+$'), '');
Future<(Set<String>, Set<String>)?> showTagPickerModal({required BuildContext context, Set<String>? initialSelection}) {
return showDialog<(Set<String>, Set<String>)?>(
context: context,
builder: (context) => _TagPickerModal(initialSelection: initialSelection),
);
}
class _TagPickerModal extends HookConsumerWidget {
final Set<String>? initialSelection;
const _TagPickerModal({this.initialSelection});
@override
Widget build(BuildContext context, WidgetRef ref) {
final selectedTagIds = useState<Set<String>>(initialSelection ?? {});
final newTagValues = useState<Set<String>>({});
void onSelectExistingTag(Iterable<Tag> tags) {
selectedTagIds.value = tags.map((tag) => tag.id).toSet();
}
void onSelectNewTag(Set<String> tags) {
newTagValues.value = tags;
}
return AlertDialog(
contentPadding: const EdgeInsets.symmetric(vertical: 16, horizontal: 0),
actions: [
TextButton(
onPressed: () => context.pop(),
child: Text(
"cancel",
style: context.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
color: context.colorScheme.error,
),
).tr(),
),
TextButton(
onPressed: () => context.pop((selectedTagIds.value, newTagValues.value)),
child: Text(
"action_common_update",
style: context.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600, color: context.primaryColor),
).tr(),
),
],
content: SizedBox(
width: MediaQuery.of(context).size.width * 0.8,
height: MediaQuery.of(context).size.height * 0.6,
child: TagPicker(
onSelectExistingTag: onSelectExistingTag,
filter: selectedTagIds.value,
onSelectNewTag: onSelectNewTag,
),
),
);
}
}
class TagPicker extends HookConsumerWidget {
const TagPicker({super.key, required this.onSelectExistingTag, required this.filter, this.onSelectNewTag});
final Function(Iterable<Tag>) onSelect;
final Set<String> filter;
/// Callback when existing tags are selected/deselected.
final Function(Iterable<Tag>) onSelectExistingTag;
/// If not null, shows a tile to create a new tag with user's filter input.
final Function(Set<String>)? onSelectNewTag;
@override
Widget build(BuildContext context, WidgetRef ref) {
final formFocus = useFocusNode();
@@ -21,6 +87,7 @@ class TagPicker extends HookConsumerWidget {
final tags = ref.watch(tagProvider);
final selectedTagIds = useState<Set<String>>(filter);
final borderRadius = const BorderRadius.all(Radius.circular(10));
final selectedNewTagValues = useState<Set<String>>({});
return Column(
children: [
@@ -41,13 +108,53 @@ class TagPicker extends HookConsumerWidget {
Expanded(
child: tags.widgetWhen(
onData: (tags) {
final trimmedQuery = _trimSlashes(searchQuery.value);
final queryResult = tags
.where((t) => t.value.toLowerCase().contains(searchQuery.value.toLowerCase()))
.where((t) => t.value.toLowerCase().contains(trimmedQuery.toLowerCase()))
.toList();
final showCreateTile =
(onSelectNewTag != null) &&
trimmedQuery.isNotEmpty &&
!tags.any((t) => t.value.toLowerCase() == trimmedQuery.toLowerCase());
final isCreateSelected = selectedNewTagValues.value.contains(trimmedQuery);
return ListView.builder(
itemCount: queryResult.length,
itemCount: queryResult.length + (showCreateTile ? 1 : 0),
padding: const EdgeInsets.all(8),
itemBuilder: (context, index) {
if (showCreateTile && index == queryResult.length) {
// Create new tag tile
return Padding(
padding: const EdgeInsets.only(bottom: 2.0),
child: Container(
decoration: BoxDecoration(
color: isCreateSelected ? context.primaryColor : context.primaryColor.withAlpha(25),
borderRadius: const BorderRadius.all(Radius.circular(10)),
),
child: ListTile(
title: Text(
trimmedQuery,
style: context.textTheme.bodyLarge?.copyWith(
color: isCreateSelected ? context.colorScheme.onPrimary : context.colorScheme.onSurface,
),
),
trailing: Icon(
Icons.add,
color: isCreateSelected ? context.colorScheme.onPrimary : context.colorScheme.onSurface,
),
onTap: () {
final newSelectedNewTagValues = {...selectedNewTagValues.value};
if (isCreateSelected) {
newSelectedNewTagValues.remove(trimmedQuery);
} else {
newSelectedNewTagValues.add(trimmedQuery);
}
selectedNewTagValues.value = newSelectedNewTagValues;
onSelectNewTag!.call(newSelectedNewTagValues);
},
),
),
);
}
final tag = queryResult[index];
final isSelected = selectedTagIds.value.any((id) => id == tag.id);
@@ -73,7 +180,7 @@ class TagPicker extends HookConsumerWidget {
newSelected.add(tag.id);
}
selectedTagIds.value = newSelected;
onSelect(tags.where((t) => newSelected.contains(t.id)));
onSelectExistingTag(tags.where((t) => newSelected.contains(t.id)));
},
),
),
+10 -13
View File
@@ -397,19 +397,16 @@ class LoginForm extends HookConsumerWidget {
mainAxisSize: MainAxisSize.max,
children: [
ImmichForm(
onSubmit: getServerAuthSettings,
submitText: 'next'.t(context: context),
submitIcon: Icons.arrow_forward_rounded,
onSubmit: getServerAuthSettings,
child: ImmichTextInput(
builder: (_, form) => ImmichURLInput(
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],
autoCorrect: false,
onSubmit: (ctx, _) => ImmichForm.of(ctx).submit(),
keyboardAction: .next,
onSubmit: (_) => form.submit(),
),
),
ImmichTextButton(
@@ -437,10 +434,10 @@ class LoginForm extends HookConsumerWidget {
),
if (isPasswordLoginEnable.value)
ImmichForm(
onSubmit: login,
submitText: 'login'.t(context: context),
submitIcon: Icons.login_rounded,
onSubmit: login,
child: Column(
builder: (context, form) => Column(
spacing: ImmichSpacing.md,
children: [
ImmichTextInput(
@@ -451,7 +448,7 @@ class LoginForm extends HookConsumerWidget {
keyboardAction: TextInputAction.next,
keyboardType: TextInputType.emailAddress,
autofillHints: const [AutofillHints.email],
onSubmit: (_, _) => passwordFocusNode.requestFocus(),
onSubmit: (_) => passwordFocusNode.requestFocus(),
),
ImmichPasswordInput(
controller: passwordController,
@@ -459,17 +456,17 @@ class LoginForm extends HookConsumerWidget {
label: 'password'.t(context: context),
hintText: 'login_form_password_hint'.t(context: context),
keyboardAction: TextInputAction.go,
onSubmit: (ctx, _) => ImmichForm.of(ctx).submit(),
onSubmit: (_) => form.submit(),
),
],
),
),
if (isOauthEnable.value)
ImmichForm(
onSubmit: oAuthLogin,
submitText: oAuthButtonLabel.value,
submitIcon: Icons.pin_outlined,
onSubmit: oAuthLogin,
child: isPasswordLoginEnable.value
builder: (context, _) => 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),
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:immich_mobile/widgets/settings/asset_viewer_settings/image_viewer_quality_setting.dart';
import 'package:immich_mobile/widgets/settings/asset_viewer_settings/image_viewer_tap_to_navigate_setting.dart';
import 'package:immich_mobile/widgets/settings/asset_viewer_settings/video_viewer_settings.dart';
import 'package:immich_mobile/widgets/settings/asset_viewer_settings/slideshow_settings.dart';
import 'package:immich_mobile/widgets/settings/settings_sub_page_scaffold.dart';
class AssetViewerSettings extends StatelessWidget {
@@ -13,6 +14,7 @@ class AssetViewerSettings extends StatelessWidget {
const ImageViewerQualitySetting(),
const ImageViewerTapToNavigateSetting(),
const VideoViewerSettings(),
const SlideshowSettings(),
];
return SettingsSubPageScaffold(settings: assetViewerSetting, showDivider: true);
@@ -0,0 +1,123 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
import 'package:immich_mobile/widgets/settings/setting_group_title.dart';
import 'package:immich_mobile/widgets/settings/settings_radio_list_tile.dart';
import 'package:immich_mobile/widgets/settings/settings_slider_list_tile.dart';
import 'package:immich_mobile/widgets/settings/settings_sub_title.dart';
import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart';
class SlideshowSettings extends HookConsumerWidget {
const SlideshowSettings({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final slideshow = ref.read(appConfigProvider).slideshow;
final useTransition = useState(slideshow.transition);
final useRepeat = useState(slideshow.repeat);
final useDuration = useState(slideshow.duration);
final useLook = useState(slideshow.look);
final useDirection = useState(slideshow.direction);
useValueChanged<bool, void>(useTransition.value, (_, __) {
ref.read(metadataProvider).write(.slideshowTransition, useTransition.value);
});
useValueChanged<bool, void>(useRepeat.value, (_, __) {
ref.read(metadataProvider).write(.slideshowRepeat, useRepeat.value);
});
useValueChanged<int, void>(useDuration.value, (_, __) {
ref.read(metadataProvider).write(.slideshowDuration, useDuration.value);
});
useValueChanged<SlideshowLook, void>(useLook.value, (_, __) {
ref.read(metadataProvider).write(.slideshowLook, useLook.value);
});
useValueChanged<SlideshowDirection, void>(useDirection.value, (_, __) {
ref.read(metadataProvider).write(.slideshowDirection, useDirection.value);
});
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SettingGroupTitle(
title: 'slideshow'.t(context: context),
icon: Icons.slideshow_outlined,
),
SettingsSwitchListTile(
valueNotifier: useTransition,
title: "show_slideshow_transition".t(context: context),
enabled: useDirection.value != SlideshowDirection.shuffle,
),
SettingsSwitchListTile(
valueNotifier: useRepeat,
title: "slideshow_repeat".t(context: context),
subtitle: "slideshow_repeat_description".t(context: context),
),
SettingsSliderListTile(
valueNotifier: useDuration,
text: "duration".t(context: context),
minValue: 5,
noDivisons: 5,
maxValue: 30,
),
Padding(
padding: const EdgeInsets.only(top: 20),
child: SettingsSubTitle(title: 'look'.t(context: context)),
),
SettingsRadioListTile(
groups: [
SettingsRadioGroup(
title: 'contain'.t(context: context),
value: SlideshowLook.contain,
),
SettingsRadioGroup(
title: 'cover'.t(context: context),
value: SlideshowLook.cover,
),
SettingsRadioGroup(
title: 'blurred_background'.t(context: context),
value: SlideshowLook.blurredBackground,
),
],
groupBy: useLook.value,
onRadioChanged: (value) {
if (value != null) {
useLook.value = value;
}
},
),
Padding(
padding: const EdgeInsets.only(top: 20),
child: SettingsSubTitle(title: 'direction'.t(context: context)),
),
Padding(
padding: const EdgeInsets.only(bottom: 32),
child: SettingsRadioListTile(
groups: [
SettingsRadioGroup(
title: 'forward'.t(context: context),
value: SlideshowDirection.forward,
),
SettingsRadioGroup(
title: 'backward'.t(context: context),
value: SlideshowDirection.backward,
),
SettingsRadioGroup(
title: 'shuffle'.t(context: context),
value: SlideshowDirection.shuffle,
),
],
groupBy: useDirection.value,
onRadioChanged: (value) {
if (value != null) {
useDirection.value = value;
}
},
),
),
],
);
}
}
@@ -1,10 +1,10 @@
import 'package:easy_localization/easy_localization.dart';
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/models/auth/auxilary_endpoint.model.dart';
import 'package:immich_mobile/providers/auth.provider.dart';
import 'package:immich_mobile/widgets/settings/networking_settings/networking_settings.dart';
import 'package:immich_ui/immich_ui.dart';
class EndpointInput extends StatefulHookConsumerWidget {
const EndpointInput({
@@ -111,28 +111,12 @@ class EndpointInputState extends ConsumerState<EndpointInput> {
status: auxCheckStatus,
enabled: widget.enabled,
),
subtitle: TextFormField(
subtitle: ImmichURLInput(
enabled: widget.enabled,
onTapOutside: (_) => focusNode.unfocus(),
autovalidateMode: AutovalidateMode.onUserInteraction,
autovalidateMode: .onUserInteraction,
validator: validateUrl,
keyboardType: TextInputType.url,
style: const TextStyle(fontFamily: 'GoogleSansCode', fontSize: 14),
decoration: InputDecoration(
hintText: 'http(s)://immich.domain.com',
contentPadding: const EdgeInsets.all(16),
filled: true,
fillColor: context.colorScheme.surfaceContainer,
border: const OutlineInputBorder(borderRadius: BorderRadius.all(Radius.circular(16))),
errorBorder: OutlineInputBorder(
borderSide: BorderSide(color: Colors.red[300]!),
borderRadius: const BorderRadius.all(Radius.circular(16)),
),
disabledBorder: OutlineInputBorder(
borderSide: BorderSide(color: context.isDarkTheme ? Colors.grey[900]! : Colors.grey[300]!),
borderRadius: const BorderRadius.all(Radius.circular(16)),
),
),
keyboardAction: .next,
hintText: 'http(s)://immich.domain.com',
controller: controller,
focusNode: focusNode,
),
@@ -1,13 +1,11 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
import 'package:flutter_hooks/flutter_hooks.dart';
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/domain/models/metadata_key.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart';
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
import 'package:immich_mobile/widgets/settings/networking_settings/endpoint_input.dart';
class ExternalNetworkPreference extends HookConsumerWidget {
@@ -23,11 +21,12 @@ class ExternalNetworkPreference extends HookConsumerWidget {
saveEndpointList() {
canSave.value = entries.value.every((e) => e.status == AuxCheckStatus.valid);
final endpointList = entries.value.where((url) => url.status == AuxCheckStatus.valid).toList();
final urls = entries.value
.where((e) => e.status == AuxCheckStatus.valid && e.url.isNotEmpty)
.map((e) => e.url)
.toList();
final jsonString = jsonEncode(endpointList);
Store.put(StoreKey.externalEndpointList, jsonString);
ref.read(metadataProvider).write(MetadataKey.networkExternalEndpointList, urls);
}
updateValidationStatus(String url, int index, AuxCheckStatus status) {
@@ -69,14 +68,13 @@ class ExternalNetworkPreference extends HookConsumerWidget {
}
useEffect(() {
final jsonString = Store.tryGet(StoreKey.externalEndpointList);
final urls = ref.read(metadataProvider).systemConfig.network.externalEndpointList;
if (jsonString == null) {
if (urls.isEmpty) {
return null;
}
final List<dynamic> jsonList = jsonDecode(jsonString);
entries.value = jsonList.map((e) => AuxilaryEndpoint.fromJson(e)).toList();
entries.value = urls.map((url) => AuxilaryEndpoint(url: url, status: .valid)).toList();
return null;
}, const []);
@@ -8,24 +8,29 @@ 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/network.provider.dart';
import 'package:immich_ui/immich_ui.dart';
class LocalNetworkPreference extends HookConsumerWidget {
const LocalNetworkPreference({super.key, required this.enabled});
final bool enabled;
Future<String?> _showEditDialog(BuildContext context, String title, String hintText, String initialValue) {
Future<String?> _showEditDialog(
BuildContext context,
String title,
String hintText,
String initialValue, {
bool isUrlField = false,
}) {
final controller = TextEditingController(text: initialValue);
return showDialog<String>(
context: context,
builder: (context) => AlertDialog(
title: Text(title),
content: TextField(
controller: controller,
autofocus: true,
decoration: InputDecoration(border: const OutlineInputBorder(), hintText: hintText),
),
content: isUrlField
? ImmichURLInput(controller: controller, autofocus: true, keyboardAction: .done, hintText: hintText)
: ImmichTextInput(controller: controller, autofocus: true, keyboardAction: .done, hintText: hintText),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
@@ -81,6 +86,7 @@ class LocalNetworkPreference extends HookConsumerWidget {
"server_endpoint".tr(),
"http://local-ip:2283",
localEndpointText.value,
isUrlField: true,
);
if (localEndpoint != null) {
@@ -1,13 +1,12 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart';
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
import 'package:immich_mobile/providers/network.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart';
import 'package:immich_mobile/utils/url_helper.dart';
import 'package:immich_mobile/widgets/settings/networking_settings/external_network_preference.dart';
import 'package:immich_mobile/widgets/settings/networking_settings/local_network_preference.dart';
@@ -20,7 +19,10 @@ class NetworkingSettings extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final currentEndpoint = getServerUrl();
final featureEnabled = useAppSettingsState(AppSettingsEnum.autoEndpointSwitching);
final featureEnabled = useState(ref.read(systemConfigProvider).network.autoEndpointSwitching);
useValueChanged<bool, void>(featureEnabled.value, (_, __) {
ref.read(metadataProvider).write(.networkAutoEndpointSwitching, featureEnabled.value);
});
Future<void> checkWifiReadPermission() async {
final [hasLocationInUse, hasLocationAlways] = await Future.wait([
@@ -1,201 +1,140 @@
import 'dart:math' as math;
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/models/shared_link/shared_link.model.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/shared_link.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/debug_print.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:immich_mobile/utils/url_helper.dart';
import 'package:immich_mobile/widgets/common/confirm_dialog.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:immich_mobile/widgets/search/thumbnail_with_info.dart';
import 'package:immich_mobile/utils/debug_print.dart';
class SharedLinkItem extends ConsumerWidget {
final SharedLink sharedLink;
const SharedLinkItem(this.sharedLink, {super.key});
bool isExpired() {
if (sharedLink.expiresAt != null) {
return DateTime.now().isAfter(sharedLink.expiresAt!);
}
return false;
}
bool isExpired() => sharedLink.expiresAt?.isBefore(DateTime.now()) ?? false;
Widget buildExpiryDuration(BuildContext context) {
var expiresText = context.t.shared_link_expires_never;
IconData expiryIcon = Icons.schedule;
Widget getExpiryDuration(bool isDarkMode) {
var expiresText = "shared_link_expires_never".tr();
if (sharedLink.expiresAt != null) {
if (isExpired()) {
return Text("expired", style: TextStyle(color: Colors.red[300])).tr();
expiresText = context.t.expired;
expiryIcon = Icons.timer_off_outlined;
}
final difference = sharedLink.expiresAt!.difference(DateTime.now());
dPrint(() => "Difference: $difference");
if (difference.inDays > 0) {
var dayDifference = difference.inDays;
if (difference.inHours % 24 > 12) {
dayDifference += 1;
}
expiresText = "shared_link_expires_days".tr(namedArgs: {'count': dayDifference.toString()});
expiresText = context.t.shared_link_expires_days(count: dayDifference);
} else if (difference.inHours > 0) {
expiresText = "shared_link_expires_hours".tr(namedArgs: {'count': difference.inHours.toString()});
expiresText = context.t.shared_link_expires_hours(count: difference.inHours);
} else if (difference.inMinutes > 0) {
expiresText = "shared_link_expires_minutes".tr(namedArgs: {'count': difference.inMinutes.toString()});
expiresText = context.t.shared_link_expires_minutes(count: difference.inMinutes);
} else if (difference.inSeconds > 0) {
expiresText = "shared_link_expires_seconds".tr(namedArgs: {'count': difference.inSeconds.toString()});
expiresText = context.t.shared_link_expires_seconds(count: difference.inSeconds);
}
}
return Text(expiresText, style: TextStyle(color: isDarkMode ? Colors.grey[400] : Colors.grey[600]));
return Row(
children: [
Icon(expiryIcon, size: 12, color: isExpired() ? context.colorScheme.error : context.colorScheme.onSurface),
const SizedBox(width: 4),
Text(
expiresText,
style: TextStyle(color: isExpired() ? context.colorScheme.error : context.colorScheme.onSurface),
),
],
);
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final colorScheme = context.colorScheme;
final isDarkMode = colorScheme.brightness == Brightness.dark;
final thumbnailUrl = sharedLink.thumbAssetId != null ? getThumbnailUrlForRemoteId(sharedLink.thumbAssetId!) : null;
final imageSize = math.min(context.width / 4, 100.0);
void copyShareLinkToClipboard() {
Future<void> copyShareLinkToClipboard() async {
final externalDomain = ref.read(serverInfoProvider.select((s) => s.serverConfig.externalDomain));
var serverUrl = externalDomain.isNotEmpty ? externalDomain : getServerUrl();
if (serverUrl != null && !serverUrl.endsWith('/')) {
serverUrl += '/';
}
if (serverUrl == null) {
final serverUrl = externalDomain.isNotEmpty ? externalDomain : getServerUrl();
final shareUrl = buildSharedLinkUrl(baseUrl: serverUrl, slug: sharedLink.slug, key: sharedLink.key);
if (shareUrl == null) {
ImmichToast.show(
context: context,
gravity: ToastGravity.BOTTOM,
toastType: ToastType.error,
msg: "shared_link_error_server_url_fetch".tr(),
msg: context.t.shared_link_error_server_url_fetch,
);
return;
}
final hasSlug = sharedLink.slug?.isNotEmpty == true;
final urlPath = hasSlug ? sharedLink.slug : sharedLink.key;
final basePath = hasSlug ? 's' : 'share';
Clipboard.setData(ClipboardData(text: "$serverUrl$basePath/$urlPath")).then((_) {
context.scaffoldMessenger.showSnackBar(
SnackBar(
content: Text(
"shared_link_clipboard_copied_massage",
style: context.textTheme.bodyLarge?.copyWith(color: context.primaryColor),
).tr(),
duration: const Duration(seconds: 2),
await Clipboard.setData(ClipboardData(text: shareUrl));
if (!context.mounted) {
return;
}
context.scaffoldMessenger.showSnackBar(
SnackBar(
content: Text(
context.t.shared_link_clipboard_copied_massage,
style: context.textTheme.bodyLarge?.copyWith(color: context.primaryColor),
),
);
});
}
Future<void> deleteShareLink() async {
return showDialog(
context: context,
builder: (BuildContext context) {
return ConfirmDialog(
title: "delete_shared_link_dialog_title",
content: "confirm_delete_shared_link",
onOk: () => ref.read(sharedLinksStateProvider.notifier).deleteLink(sharedLink.id),
);
},
duration: const Duration(seconds: 2),
),
);
}
Widget buildThumbnail() {
if (thumbnailUrl == null) {
return Container(
height: imageSize * 1.2,
width: imageSize,
decoration: BoxDecoration(color: isDarkMode ? Colors.grey[800] : Colors.grey[200]),
child: Center(
child: Icon(Icons.image_not_supported_outlined, color: isDarkMode ? Colors.grey[100] : Colors.grey[700]),
),
);
}
return SizedBox(
height: imageSize * 1.2,
width: imageSize,
child: Padding(
padding: const EdgeInsets.only(right: 4.0),
child: ThumbnailWithInfo(
imageUrl: thumbnailUrl,
key: key,
textInfo: '',
noImageIcon: Icons.image_not_supported_outlined,
onTap: () {},
),
),
child: thumbnailUrl == null
? const Card(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(10))),
child: Icon(Icons.image_not_supported_outlined),
)
: ThumbnailWithInfo(
imageUrl: thumbnailUrl,
key: key,
textInfo: '',
noImageIcon: Icons.image_not_supported_outlined,
onTap: () => context.pushRoute(SharedLinkEditRoute(existingLink: sharedLink)),
),
);
}
Widget buildInfoChip(String labelText) {
return Padding(
padding: const EdgeInsets.only(right: 10),
child: Chip(
backgroundColor: colorScheme.primary,
label: Text(
labelText,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
color: isDarkMode ? Colors.black : Colors.white,
),
),
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(25))),
return Card.outlined(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
child: Text(labelText, style: const TextStyle(fontSize: 11)),
),
);
}
Widget buildBottomInfo() {
Widget buildShareParameterInfos() {
return Row(
spacing: 4,
children: [
if (sharedLink.allowUpload) buildInfoChip("upload".tr()),
if (sharedLink.allowDownload) buildInfoChip("download".tr()),
if (sharedLink.showMetadata) buildInfoChip("shared_link_info_chip_metadata".tr()),
],
);
}
Widget buildSharedLinkActions() {
const actionIconSize = 20.0;
return Row(
children: [
IconButton(
splashRadius: 25,
constraints: const BoxConstraints(),
iconSize: actionIconSize,
icon: const Icon(Icons.delete_outline),
style: const ButtonStyle(
tapTargetSize: MaterialTapTargetSize.shrinkWrap, // the '2023' part
),
onPressed: deleteShareLink,
),
IconButton(
splashRadius: 25,
constraints: const BoxConstraints(),
iconSize: actionIconSize,
icon: const Icon(Icons.edit_outlined),
style: const ButtonStyle(
tapTargetSize: MaterialTapTargetSize.shrinkWrap, // the '2023' part
),
onPressed: () => context.pushRoute(SharedLinkEditRoute(existingLink: sharedLink)),
),
IconButton(
splashRadius: 25,
constraints: const BoxConstraints(),
iconSize: actionIconSize,
icon: const Icon(Icons.copy_outlined),
style: const ButtonStyle(
tapTargetSize: MaterialTapTargetSize.shrinkWrap, // the '2023' part
),
onPressed: copyShareLinkToClipboard,
),
if (sharedLink.allowUpload) buildInfoChip(context.t.upload),
if (sharedLink.allowDownload) buildInfoChip(context.t.download),
if (sharedLink.showMetadata) buildInfoChip(context.t.shared_link_info_chip_metadata),
],
);
}
@@ -204,69 +143,64 @@ class SharedLinkItem extends ConsumerWidget {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
getExpiryDuration(isDarkMode),
Padding(
padding: const EdgeInsets.only(top: 5),
child: Tooltip(
verticalOffset: 0,
decoration: BoxDecoration(
color: colorScheme.primary.withValues(alpha: 0.9),
borderRadius: const BorderRadius.all(Radius.circular(10)),
),
textStyle: TextStyle(color: isDarkMode ? Colors.black : Colors.white, fontWeight: FontWeight.bold),
message: sharedLink.title,
preferBelow: false,
triggerMode: TooltipTriggerMode.tap,
child: Text(
sharedLink.title,
style: TextStyle(
color: colorScheme.primary,
fontWeight: FontWeight.bold,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(height: 5),
Text(
sharedLink.title,
style: TextStyle(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.bold,
overflow: TextOverflow.ellipsis,
),
),
Row(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Tooltip(
verticalOffset: 0,
decoration: BoxDecoration(
color: colorScheme.primary.withValues(alpha: 0.9),
borderRadius: const BorderRadius.all(Radius.circular(10)),
),
textStyle: TextStyle(color: isDarkMode ? Colors.black : Colors.white, fontWeight: FontWeight.bold),
message: sharedLink.description ?? "",
preferBelow: false,
triggerMode: TooltipTriggerMode.tap,
child: Text(sharedLink.description ?? "", overflow: TextOverflow.ellipsis),
),
),
Padding(padding: const EdgeInsets.only(right: 15), child: buildSharedLinkActions()),
],
),
buildBottomInfo(),
if (sharedLink.description?.isNotEmpty ?? false)
Text(sharedLink.description!, overflow: TextOverflow.ellipsis),
buildExpiryDuration(context),
buildShareParameterInfos(),
],
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(padding: const EdgeInsets.only(left: 15), child: buildThumbnail()),
Expanded(
child: Padding(padding: const EdgeInsets.only(left: 15), child: buildSharedLinkDetails()),
),
],
return Dismissible(
key: ValueKey(sharedLink.id),
direction: DismissDirection.endToStart,
background: Container(
color: Theme.of(context).colorScheme.error,
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 20),
child: Icon(Icons.delete, color: Theme.of(context).colorScheme.onError),
),
confirmDismiss: (_) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (BuildContext context) => ConfirmDialog(
title: "delete_shared_link_dialog_title",
content: "confirm_delete_shared_link",
onOk: () {},
),
);
if (confirmed == true) {
await ref.read(sharedLinksStateProvider.notifier).deleteLink(sharedLink.id);
return true;
}
return false;
},
child: InkWell(
onTap: () => context.pushRoute(SharedLinkEditRoute(existingLink: sharedLink)),
onLongPress: copyShareLinkToClipboard,
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
buildThumbnail(),
const SizedBox(width: 12),
Expanded(child: buildSharedLinkDetails()),
],
),
),
const Padding(padding: EdgeInsets.all(20), child: Divider(height: 0)),
],
),
);
}
}
-8
View File
@@ -1,11 +1,3 @@
[tools]
flutter = "3.41.9"
[tools."github:CQLabs/homebrew-dcm"]
version = "1.30.0"
bin = "dcm"
postinstall = "chmod +x $MISE_TOOL_INSTALL_PATH/dcm"
[tasks."codegen:dart"]
alias = "codegen"
description = "Execute build_runner to auto-generate dart code"
+12 -16
View File
@@ -205,8 +205,8 @@ 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
*PluginsApi* | [**searchPluginMethods**](doc//PluginsApi.md#searchpluginmethods) | **GET** /plugins/methods | Retrieve plugin methods
*PluginsApi* | [**searchPlugins**](doc//PluginsApi.md#searchplugins) | **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
*QueuesApi* | [**getQueueJobs**](doc//QueuesApi.md#getqueuejobs) | **GET** /queues/{name}/jobs | Retrieve queue jobs
@@ -314,7 +314,9 @@ Class | Method | HTTP request | Description
*WorkflowsApi* | [**createWorkflow**](doc//WorkflowsApi.md#createworkflow) | **POST** /workflows | Create a workflow
*WorkflowsApi* | [**deleteWorkflow**](doc//WorkflowsApi.md#deleteworkflow) | **DELETE** /workflows/{id} | Delete a workflow
*WorkflowsApi* | [**getWorkflow**](doc//WorkflowsApi.md#getworkflow) | **GET** /workflows/{id} | Retrieve a workflow
*WorkflowsApi* | [**getWorkflows**](doc//WorkflowsApi.md#getworkflows) | **GET** /workflows | List all workflows
*WorkflowsApi* | [**getWorkflowForShare**](doc//WorkflowsApi.md#getworkflowforshare) | **GET** /workflows/{id}/share | Retrieve a workflow
*WorkflowsApi* | [**getWorkflowTriggers**](doc//WorkflowsApi.md#getworkflowtriggers) | **GET** /workflows/triggers | List all workflow triggers
*WorkflowsApi* | [**searchWorkflows**](doc//WorkflowsApi.md#searchworkflows) | **GET** /workflows | List all workflows
*WorkflowsApi* | [**updateWorkflow**](doc//WorkflowsApi.md#updateworkflow) | **PUT** /workflows/{id} | Update a workflow
@@ -487,16 +489,8 @@ Class | Method | HTTP request | Description
- [PinCodeResetDto](doc//PinCodeResetDto.md)
- [PinCodeSetupDto](doc//PinCodeSetupDto.md)
- [PlacesResponseDto](doc//PlacesResponseDto.md)
- [PluginActionResponseDto](doc//PluginActionResponseDto.md)
- [PluginContextType](doc//PluginContextType.md)
- [PluginFilterResponseDto](doc//PluginFilterResponseDto.md)
- [PluginJsonSchema](doc//PluginJsonSchema.md)
- [PluginJsonSchemaProperty](doc//PluginJsonSchemaProperty.md)
- [PluginJsonSchemaPropertyAdditionalProperties](doc//PluginJsonSchemaPropertyAdditionalProperties.md)
- [PluginJsonSchemaType](doc//PluginJsonSchemaType.md)
- [PluginMethodResponseDto](doc//PluginMethodResponseDto.md)
- [PluginResponseDto](doc//PluginResponseDto.md)
- [PluginTriggerResponseDto](doc//PluginTriggerResponseDto.md)
- [PluginTriggerType](doc//PluginTriggerType.md)
- [PurchaseResponse](doc//PurchaseResponse.md)
- [PurchaseUpdate](doc//PurchaseUpdate.md)
- [QueueCommand](doc//QueueCommand.md)
@@ -669,12 +663,14 @@ Class | Method | HTTP request | Description
- [VersionCheckStateResponseDto](doc//VersionCheckStateResponseDto.md)
- [VideoCodec](doc//VideoCodec.md)
- [VideoContainer](doc//VideoContainer.md)
- [WorkflowActionItemDto](doc//WorkflowActionItemDto.md)
- [WorkflowActionResponseDto](doc//WorkflowActionResponseDto.md)
- [WorkflowCreateDto](doc//WorkflowCreateDto.md)
- [WorkflowFilterItemDto](doc//WorkflowFilterItemDto.md)
- [WorkflowFilterResponseDto](doc//WorkflowFilterResponseDto.md)
- [WorkflowResponseDto](doc//WorkflowResponseDto.md)
- [WorkflowShareResponseDto](doc//WorkflowShareResponseDto.md)
- [WorkflowShareStepDto](doc//WorkflowShareStepDto.md)
- [WorkflowStepDto](doc//WorkflowStepDto.md)
- [WorkflowTrigger](doc//WorkflowTrigger.md)
- [WorkflowTriggerResponseDto](doc//WorkflowTriggerResponseDto.md)
- [WorkflowType](doc//WorkflowType.md)
- [WorkflowUpdateDto](doc//WorkflowUpdateDto.md)
+7 -13
View File
@@ -235,16 +235,8 @@ part 'model/pin_code_change_dto.dart';
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_type.dart';
part 'model/plugin_filter_response_dto.dart';
part 'model/plugin_json_schema.dart';
part 'model/plugin_json_schema_property.dart';
part 'model/plugin_json_schema_property_additional_properties.dart';
part 'model/plugin_json_schema_type.dart';
part 'model/plugin_method_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';
part 'model/queue_command.dart';
@@ -417,12 +409,14 @@ part 'model/validate_library_response_dto.dart';
part 'model/version_check_state_response_dto.dart';
part 'model/video_codec.dart';
part 'model/video_container.dart';
part 'model/workflow_action_item_dto.dart';
part 'model/workflow_action_response_dto.dart';
part 'model/workflow_create_dto.dart';
part 'model/workflow_filter_item_dto.dart';
part 'model/workflow_filter_response_dto.dart';
part 'model/workflow_response_dto.dart';
part 'model/workflow_share_response_dto.dart';
part 'model/workflow_share_step_dto.dart';
part 'model/workflow_step_dto.dart';
part 'model/workflow_trigger.dart';
part 'model/workflow_trigger_response_dto.dart';
part 'model/workflow_type.dart';
part 'model/workflow_update_dto.dart';
+144 -13
View File
@@ -73,14 +73,40 @@ class PluginsApi {
return null;
}
/// List all plugin triggers
/// Retrieve plugin methods
///
/// Retrieve a list of all available plugin triggers.
/// Retrieve a list of plugin methods
///
/// Note: This method returns the HTTP [Response].
Future<Response> getPluginTriggersWithHttpInfo() async {
///
/// Parameters:
///
/// * [String] description:
///
/// * [bool] enabled:
/// Whether the plugin method is enabled
///
/// * [String] id:
/// Plugin method ID
///
/// * [String] name:
///
/// * [String] pluginName:
/// Plugin name
///
/// * [String] pluginVersion:
/// Plugin version
///
/// * [String] title:
///
/// * [WorkflowTrigger] trigger:
/// Workflow trigger
///
/// * [WorkflowType] type:
/// Workflow types
Future<Response> searchPluginMethodsWithHttpInfo({ String? description, bool? enabled, String? id, String? name, String? pluginName, String? pluginVersion, String? title, WorkflowTrigger? trigger, WorkflowType? type, }) async {
// ignore: prefer_const_declarations
final apiPath = r'/plugins/triggers';
final apiPath = r'/plugins/methods';
// ignore: prefer_final_locals
Object? postBody;
@@ -89,6 +115,34 @@ class PluginsApi {
final headerParams = <String, String>{};
final formParams = <String, String>{};
if (description != null) {
queryParams.addAll(_queryParams('', 'description', description));
}
if (enabled != null) {
queryParams.addAll(_queryParams('', 'enabled', enabled));
}
if (id != null) {
queryParams.addAll(_queryParams('', 'id', id));
}
if (name != null) {
queryParams.addAll(_queryParams('', 'name', name));
}
if (pluginName != null) {
queryParams.addAll(_queryParams('', 'pluginName', pluginName));
}
if (pluginVersion != null) {
queryParams.addAll(_queryParams('', 'pluginVersion', pluginVersion));
}
if (title != null) {
queryParams.addAll(_queryParams('', 'title', title));
}
if (trigger != null) {
queryParams.addAll(_queryParams('', 'trigger', trigger));
}
if (type != null) {
queryParams.addAll(_queryParams('', 'type', type));
}
const contentTypes = <String>[];
@@ -103,11 +157,37 @@ class PluginsApi {
);
}
/// List all plugin triggers
/// Retrieve plugin methods
///
/// Retrieve a list of all available plugin triggers.
Future<List<PluginTriggerResponseDto>?> getPluginTriggers() async {
final response = await getPluginTriggersWithHttpInfo();
/// Retrieve a list of plugin methods
///
/// Parameters:
///
/// * [String] description:
///
/// * [bool] enabled:
/// Whether the plugin method is enabled
///
/// * [String] id:
/// Plugin method ID
///
/// * [String] name:
///
/// * [String] pluginName:
/// Plugin name
///
/// * [String] pluginVersion:
/// Plugin version
///
/// * [String] title:
///
/// * [WorkflowTrigger] trigger:
/// Workflow trigger
///
/// * [WorkflowType] type:
/// Workflow types
Future<List<PluginMethodResponseDto>?> searchPluginMethods({ String? description, bool? enabled, String? id, String? name, String? pluginName, String? pluginVersion, String? title, WorkflowTrigger? trigger, WorkflowType? type, }) async {
final response = await searchPluginMethodsWithHttpInfo( description: description, enabled: enabled, id: id, name: name, pluginName: pluginName, pluginVersion: pluginVersion, title: title, trigger: trigger, type: type, );
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
@@ -116,8 +196,8 @@ class PluginsApi {
// 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>()
return (await apiClient.deserializeAsync(responseBody, 'List<PluginMethodResponseDto>') as List)
.cast<PluginMethodResponseDto>()
.toList(growable: false);
}
@@ -129,7 +209,23 @@ class PluginsApi {
/// Retrieve a list of plugins available to the authenticated user.
///
/// Note: This method returns the HTTP [Response].
Future<Response> getPluginsWithHttpInfo() async {
///
/// Parameters:
///
/// * [String] description:
///
/// * [bool] enabled:
/// Whether the plugin is enabled
///
/// * [String] id:
/// Plugin ID
///
/// * [String] name:
///
/// * [String] title:
///
/// * [String] version:
Future<Response> searchPluginsWithHttpInfo({ String? description, bool? enabled, String? id, String? name, String? title, String? version, }) async {
// ignore: prefer_const_declarations
final apiPath = r'/plugins';
@@ -140,6 +236,25 @@ class PluginsApi {
final headerParams = <String, String>{};
final formParams = <String, String>{};
if (description != null) {
queryParams.addAll(_queryParams('', 'description', description));
}
if (enabled != null) {
queryParams.addAll(_queryParams('', 'enabled', enabled));
}
if (id != null) {
queryParams.addAll(_queryParams('', 'id', id));
}
if (name != null) {
queryParams.addAll(_queryParams('', 'name', name));
}
if (title != null) {
queryParams.addAll(_queryParams('', 'title', title));
}
if (version != null) {
queryParams.addAll(_queryParams('', 'version', version));
}
const contentTypes = <String>[];
@@ -157,8 +272,24 @@ class PluginsApi {
/// List all plugins
///
/// Retrieve a list of plugins available to the authenticated user.
Future<List<PluginResponseDto>?> getPlugins() async {
final response = await getPluginsWithHttpInfo();
///
/// Parameters:
///
/// * [String] description:
///
/// * [bool] enabled:
/// Whether the plugin is enabled
///
/// * [String] id:
/// Plugin ID
///
/// * [String] name:
///
/// * [String] title:
///
/// * [String] version:
Future<List<PluginResponseDto>?> searchPlugins({ String? description, bool? enabled, String? id, String? name, String? title, String? version, }) async {
final response = await searchPluginsWithHttpInfo( description: description, enabled: enabled, id: id, name: name, title: title, version: version, );
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
+164 -6
View File
@@ -178,14 +178,19 @@ class WorkflowsApi {
return null;
}
/// List all workflows
/// Retrieve a workflow
///
/// Retrieve a list of workflows available to the authenticated user.
/// Retrieve a workflow details without ids, default values, etc.
///
/// Note: This method returns the HTTP [Response].
Future<Response> getWorkflowsWithHttpInfo() async {
///
/// Parameters:
///
/// * [String] id (required):
Future<Response> getWorkflowForShareWithHttpInfo(String id,) async {
// ignore: prefer_const_declarations
final apiPath = r'/workflows';
final apiPath = r'/workflows/{id}/share'
.replaceAll('{id}', id);
// ignore: prefer_final_locals
Object? postBody;
@@ -208,11 +213,164 @@ class WorkflowsApi {
);
}
/// Retrieve a workflow
///
/// Retrieve a workflow details without ids, default values, etc.
///
/// Parameters:
///
/// * [String] id (required):
Future<WorkflowShareResponseDto?> getWorkflowForShare(String id,) async {
final response = await getWorkflowForShareWithHttpInfo(id,);
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) {
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'WorkflowShareResponseDto',) as WorkflowShareResponseDto;
}
return null;
}
/// List all workflow triggers
///
/// Retrieve a list of all available workflow triggers.
///
/// Note: This method returns the HTTP [Response].
Future<Response> getWorkflowTriggersWithHttpInfo() async {
// ignore: prefer_const_declarations
final apiPath = r'/workflows/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 workflow triggers
///
/// Retrieve a list of all available workflow triggers.
Future<List<WorkflowTriggerResponseDto>?> getWorkflowTriggers() async {
final response = await getWorkflowTriggersWithHttpInfo();
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<WorkflowTriggerResponseDto>') as List)
.cast<WorkflowTriggerResponseDto>()
.toList(growable: false);
}
return null;
}
/// List all workflows
///
/// Retrieve a list of workflows available to the authenticated user.
Future<List<WorkflowResponseDto>?> getWorkflows() async {
final response = await getWorkflowsWithHttpInfo();
///
/// Note: This method returns the HTTP [Response].
///
/// Parameters:
///
/// * [String] description:
/// Workflow description
///
/// * [bool] enabled:
/// Workflow enabled
///
/// * [String] id:
/// Workflow ID
///
/// * [String] name:
/// Workflow name
///
/// * [WorkflowTrigger] trigger:
/// Workflow trigger type
Future<Response> searchWorkflowsWithHttpInfo({ String? description, bool? enabled, String? id, String? name, WorkflowTrigger? trigger, }) async {
// ignore: prefer_const_declarations
final apiPath = r'/workflows';
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
if (description != null) {
queryParams.addAll(_queryParams('', 'description', description));
}
if (enabled != null) {
queryParams.addAll(_queryParams('', 'enabled', enabled));
}
if (id != null) {
queryParams.addAll(_queryParams('', 'id', id));
}
if (name != null) {
queryParams.addAll(_queryParams('', 'name', name));
}
if (trigger != null) {
queryParams.addAll(_queryParams('', 'trigger', trigger));
}
const contentTypes = <String>[];
return apiClient.invokeAPI(
apiPath,
'GET',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// List all workflows
///
/// Retrieve a list of workflows available to the authenticated user.
///
/// Parameters:
///
/// * [String] description:
/// Workflow description
///
/// * [bool] enabled:
/// Workflow enabled
///
/// * [String] id:
/// Workflow ID
///
/// * [String] name:
/// Workflow name
///
/// * [WorkflowTrigger] trigger:
/// Workflow trigger type
Future<List<WorkflowResponseDto>?> searchWorkflows({ String? description, bool? enabled, String? id, String? name, WorkflowTrigger? trigger, }) async {
final response = await searchWorkflowsWithHttpInfo( description: description, enabled: enabled, id: id, name: name, trigger: trigger, );
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
+14 -26
View File
@@ -516,26 +516,10 @@ class ApiClient {
return PinCodeSetupDto.fromJson(value);
case 'PlacesResponseDto':
return PlacesResponseDto.fromJson(value);
case 'PluginActionResponseDto':
return PluginActionResponseDto.fromJson(value);
case 'PluginContextType':
return PluginContextTypeTypeTransformer().decode(value);
case 'PluginFilterResponseDto':
return PluginFilterResponseDto.fromJson(value);
case 'PluginJsonSchema':
return PluginJsonSchema.fromJson(value);
case 'PluginJsonSchemaProperty':
return PluginJsonSchemaProperty.fromJson(value);
case 'PluginJsonSchemaPropertyAdditionalProperties':
return PluginJsonSchemaPropertyAdditionalProperties.fromJson(value);
case 'PluginJsonSchemaType':
return PluginJsonSchemaTypeTypeTransformer().decode(value);
case 'PluginMethodResponseDto':
return PluginMethodResponseDto.fromJson(value);
case 'PluginResponseDto':
return PluginResponseDto.fromJson(value);
case 'PluginTriggerResponseDto':
return PluginTriggerResponseDto.fromJson(value);
case 'PluginTriggerType':
return PluginTriggerTypeTypeTransformer().decode(value);
case 'PurchaseResponse':
return PurchaseResponse.fromJson(value);
case 'PurchaseUpdate':
@@ -880,18 +864,22 @@ class ApiClient {
return VideoCodecTypeTransformer().decode(value);
case 'VideoContainer':
return VideoContainerTypeTransformer().decode(value);
case 'WorkflowActionItemDto':
return WorkflowActionItemDto.fromJson(value);
case 'WorkflowActionResponseDto':
return WorkflowActionResponseDto.fromJson(value);
case 'WorkflowCreateDto':
return WorkflowCreateDto.fromJson(value);
case 'WorkflowFilterItemDto':
return WorkflowFilterItemDto.fromJson(value);
case 'WorkflowFilterResponseDto':
return WorkflowFilterResponseDto.fromJson(value);
case 'WorkflowResponseDto':
return WorkflowResponseDto.fromJson(value);
case 'WorkflowShareResponseDto':
return WorkflowShareResponseDto.fromJson(value);
case 'WorkflowShareStepDto':
return WorkflowShareStepDto.fromJson(value);
case 'WorkflowStepDto':
return WorkflowStepDto.fromJson(value);
case 'WorkflowTrigger':
return WorkflowTriggerTypeTransformer().decode(value);
case 'WorkflowTriggerResponseDto':
return WorkflowTriggerResponseDto.fromJson(value);
case 'WorkflowType':
return WorkflowTypeTypeTransformer().decode(value);
case 'WorkflowUpdateDto':
return WorkflowUpdateDto.fromJson(value);
default:
+6 -9
View File
@@ -142,15 +142,6 @@ String parameterToString(dynamic value) {
if (value is Permission) {
return PermissionTypeTransformer().encode(value).toString();
}
if (value is PluginContextType) {
return PluginContextTypeTypeTransformer().encode(value).toString();
}
if (value is PluginJsonSchemaType) {
return PluginJsonSchemaTypeTypeTransformer().encode(value).toString();
}
if (value is PluginTriggerType) {
return PluginTriggerTypeTypeTransformer().encode(value).toString();
}
if (value is QueueCommand) {
return QueueCommandTypeTransformer().encode(value).toString();
}
@@ -208,6 +199,12 @@ String parameterToString(dynamic value) {
if (value is VideoContainer) {
return VideoContainerTypeTransformer().encode(value).toString();
}
if (value is WorkflowTrigger) {
return WorkflowTriggerTypeTransformer().encode(value).toString();
}
if (value is WorkflowType) {
return WorkflowTypeTypeTransformer().encode(value).toString();
}
return value.toString();
}
+3 -3
View File
@@ -77,7 +77,7 @@ class JobName {
static const versionCheck = JobName._(r'VersionCheck');
static const ocrQueueAll = JobName._(r'OcrQueueAll');
static const ocr = JobName._(r'Ocr');
static const workflowRun = JobName._(r'WorkflowRun');
static const workflowAssetCreate = JobName._(r'WorkflowAssetCreate');
/// List of all possible values in this [enum][JobName].
static const values = <JobName>[
@@ -135,7 +135,7 @@ class JobName {
versionCheck,
ocrQueueAll,
ocr,
workflowRun,
workflowAssetCreate,
];
static JobName? fromJson(dynamic value) => JobNameTypeTransformer().decode(value);
@@ -228,7 +228,7 @@ class JobNameTypeTransformer {
case r'VersionCheck': return JobName.versionCheck;
case r'OcrQueueAll': return JobName.ocrQueueAll;
case r'Ocr': return JobName.ocr;
case r'WorkflowRun': return JobName.workflowRun;
case r'WorkflowAssetCreate': return JobName.workflowAssetCreate;
default:
if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data');
-158
View File
@@ -1,158 +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 PluginActionResponseDto {
/// Returns a new [PluginActionResponseDto] instance.
PluginActionResponseDto({
required this.description,
required this.id,
required this.methodName,
required this.pluginId,
required this.schema,
this.supportedContexts = const [],
required this.title,
});
/// Action description
String description;
/// Action ID
String id;
/// Method name
String methodName;
/// Plugin ID
String pluginId;
/// Action schema
PluginJsonSchema? schema;
/// Supported contexts
List<PluginContextType> supportedContexts;
/// Action title
String title;
@override
bool operator ==(Object other) => identical(this, other) || other is PluginActionResponseDto &&
other.description == description &&
other.id == id &&
other.methodName == methodName &&
other.pluginId == pluginId &&
other.schema == schema &&
_deepEquality.equals(other.supportedContexts, supportedContexts) &&
other.title == title;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(description.hashCode) +
(id.hashCode) +
(methodName.hashCode) +
(pluginId.hashCode) +
(schema == null ? 0 : schema!.hashCode) +
(supportedContexts.hashCode) +
(title.hashCode);
@override
String toString() => 'PluginActionResponseDto[description=$description, id=$id, methodName=$methodName, pluginId=$pluginId, schema=$schema, supportedContexts=$supportedContexts, title=$title]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'description'] = this.description;
json[r'id'] = this.id;
json[r'methodName'] = this.methodName;
json[r'pluginId'] = this.pluginId;
if (this.schema != null) {
json[r'schema'] = this.schema;
} else {
// json[r'schema'] = null;
}
json[r'supportedContexts'] = this.supportedContexts;
json[r'title'] = this.title;
return json;
}
/// Returns a new [PluginActionResponseDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static PluginActionResponseDto? fromJson(dynamic value) {
upgradeDto(value, "PluginActionResponseDto");
if (value is Map) {
final json = value.cast<String, dynamic>();
return PluginActionResponseDto(
description: mapValueOfType<String>(json, r'description')!,
id: mapValueOfType<String>(json, r'id')!,
methodName: mapValueOfType<String>(json, r'methodName')!,
pluginId: mapValueOfType<String>(json, r'pluginId')!,
schema: PluginJsonSchema.fromJson(json[r'schema']),
supportedContexts: PluginContextType.listFromJson(json[r'supportedContexts']),
title: mapValueOfType<String>(json, r'title')!,
);
}
return null;
}
static List<PluginActionResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <PluginActionResponseDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = PluginActionResponseDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, PluginActionResponseDto> mapFromJson(dynamic json) {
final map = <String, PluginActionResponseDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = PluginActionResponseDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of PluginActionResponseDto-objects as value to a dart map
static Map<String, List<PluginActionResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<PluginActionResponseDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = PluginActionResponseDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'description',
'id',
'methodName',
'pluginId',
'schema',
'supportedContexts',
'title',
};
}
-88
View File
@@ -1,88 +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;
/// Plugin context
class PluginContextType {
/// Instantiate a new enum with the provided [value].
const PluginContextType._(this.value);
/// The underlying value of this enum member.
final String value;
@override
String toString() => value;
String toJson() => value;
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][PluginContextType].
static const values = <PluginContextType>[
asset,
album,
person,
];
static PluginContextType? fromJson(dynamic value) => PluginContextTypeTypeTransformer().decode(value);
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 = PluginContextType.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
}
/// 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 PluginContextTypeTypeTransformer._();
String encode(PluginContextType data) => data.value;
/// 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]
/// 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.
PluginContextType? decode(dynamic data, {bool allowNull = true}) {
if (data != null) {
switch (data) {
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');
}
}
}
return null;
}
/// Singleton [PluginContextTypeTypeTransformer] instance.
static PluginContextTypeTypeTransformer? _instance;
}
-158
View File
@@ -1,158 +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 PluginFilterResponseDto {
/// Returns a new [PluginFilterResponseDto] instance.
PluginFilterResponseDto({
required this.description,
required this.id,
required this.methodName,
required this.pluginId,
required this.schema,
this.supportedContexts = const [],
required this.title,
});
/// Filter description
String description;
/// Filter ID
String id;
/// Method name
String methodName;
/// Plugin ID
String pluginId;
/// Filter schema
PluginJsonSchema? schema;
/// Supported contexts
List<PluginContextType> supportedContexts;
/// Filter title
String title;
@override
bool operator ==(Object other) => identical(this, other) || other is PluginFilterResponseDto &&
other.description == description &&
other.id == id &&
other.methodName == methodName &&
other.pluginId == pluginId &&
other.schema == schema &&
_deepEquality.equals(other.supportedContexts, supportedContexts) &&
other.title == title;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(description.hashCode) +
(id.hashCode) +
(methodName.hashCode) +
(pluginId.hashCode) +
(schema == null ? 0 : schema!.hashCode) +
(supportedContexts.hashCode) +
(title.hashCode);
@override
String toString() => 'PluginFilterResponseDto[description=$description, id=$id, methodName=$methodName, pluginId=$pluginId, schema=$schema, supportedContexts=$supportedContexts, title=$title]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'description'] = this.description;
json[r'id'] = this.id;
json[r'methodName'] = this.methodName;
json[r'pluginId'] = this.pluginId;
if (this.schema != null) {
json[r'schema'] = this.schema;
} else {
// json[r'schema'] = null;
}
json[r'supportedContexts'] = this.supportedContexts;
json[r'title'] = this.title;
return json;
}
/// Returns a new [PluginFilterResponseDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static PluginFilterResponseDto? fromJson(dynamic value) {
upgradeDto(value, "PluginFilterResponseDto");
if (value is Map) {
final json = value.cast<String, dynamic>();
return PluginFilterResponseDto(
description: mapValueOfType<String>(json, r'description')!,
id: mapValueOfType<String>(json, r'id')!,
methodName: mapValueOfType<String>(json, r'methodName')!,
pluginId: mapValueOfType<String>(json, r'pluginId')!,
schema: PluginJsonSchema.fromJson(json[r'schema']),
supportedContexts: PluginContextType.listFromJson(json[r'supportedContexts']),
title: mapValueOfType<String>(json, r'title')!,
);
}
return null;
}
static List<PluginFilterResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <PluginFilterResponseDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = PluginFilterResponseDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, PluginFilterResponseDto> mapFromJson(dynamic json) {
final map = <String, PluginFilterResponseDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = PluginFilterResponseDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of PluginFilterResponseDto-objects as value to a dart map
static Map<String, List<PluginFilterResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<PluginFilterResponseDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = PluginFilterResponseDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'description',
'id',
'methodName',
'pluginId',
'schema',
'supportedContexts',
'title',
};
}
-158
View File
@@ -1,158 +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 PluginJsonSchema {
/// Returns a new [PluginJsonSchema] instance.
PluginJsonSchema({
this.additionalProperties,
this.description,
this.properties = const {},
this.required_ = const [],
this.type,
});
///
/// 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.
///
bool? additionalProperties;
///
/// 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.
///
String? description;
Map<String, PluginJsonSchemaProperty> properties;
List<String> required_;
///
/// 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.
///
PluginJsonSchemaType? type;
@override
bool operator ==(Object other) => identical(this, other) || other is PluginJsonSchema &&
other.additionalProperties == additionalProperties &&
other.description == description &&
_deepEquality.equals(other.properties, properties) &&
_deepEquality.equals(other.required_, required_) &&
other.type == type;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(additionalProperties == null ? 0 : additionalProperties!.hashCode) +
(description == null ? 0 : description!.hashCode) +
(properties.hashCode) +
(required_.hashCode) +
(type == null ? 0 : type!.hashCode);
@override
String toString() => 'PluginJsonSchema[additionalProperties=$additionalProperties, description=$description, properties=$properties, required_=$required_, type=$type]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
if (this.additionalProperties != null) {
json[r'additionalProperties'] = this.additionalProperties;
} else {
// json[r'additionalProperties'] = null;
}
if (this.description != null) {
json[r'description'] = this.description;
} else {
// json[r'description'] = null;
}
json[r'properties'] = this.properties;
json[r'required'] = this.required_;
if (this.type != null) {
json[r'type'] = this.type;
} else {
// json[r'type'] = null;
}
return json;
}
/// Returns a new [PluginJsonSchema] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static PluginJsonSchema? fromJson(dynamic value) {
upgradeDto(value, "PluginJsonSchema");
if (value is Map) {
final json = value.cast<String, dynamic>();
return PluginJsonSchema(
additionalProperties: mapValueOfType<bool>(json, r'additionalProperties'),
description: mapValueOfType<String>(json, r'description'),
properties: PluginJsonSchemaProperty.mapFromJson(json[r'properties']),
required_: json[r'required'] is Iterable
? (json[r'required'] as Iterable).cast<String>().toList(growable: false)
: const [],
type: PluginJsonSchemaType.fromJson(json[r'type']),
);
}
return null;
}
static List<PluginJsonSchema> listFromJson(dynamic json, {bool growable = false,}) {
final result = <PluginJsonSchema>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = PluginJsonSchema.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, PluginJsonSchema> mapFromJson(dynamic json) {
final map = <String, PluginJsonSchema>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = PluginJsonSchema.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of PluginJsonSchema-objects as value to a dart map
static Map<String, List<PluginJsonSchema>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<PluginJsonSchema>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = PluginJsonSchema.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
};
}
-195
View File
@@ -1,195 +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 PluginJsonSchemaProperty {
/// Returns a new [PluginJsonSchemaProperty] instance.
PluginJsonSchemaProperty({
this.additionalProperties,
this.default_,
this.description,
this.enum_ = const [],
this.items,
this.properties = const {},
this.required_ = const [],
this.type,
});
///
/// 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.
///
PluginJsonSchemaPropertyAdditionalProperties? additionalProperties;
Object? default_;
///
/// 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.
///
String? description;
List<String> enum_;
///
/// 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.
///
PluginJsonSchemaProperty? items;
Map<String, PluginJsonSchemaProperty> properties;
List<String> required_;
///
/// 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.
///
PluginJsonSchemaType? type;
@override
bool operator ==(Object other) => identical(this, other) || other is PluginJsonSchemaProperty &&
other.additionalProperties == additionalProperties &&
other.default_ == default_ &&
other.description == description &&
_deepEquality.equals(other.enum_, enum_) &&
other.items == items &&
_deepEquality.equals(other.properties, properties) &&
_deepEquality.equals(other.required_, required_) &&
other.type == type;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(additionalProperties == null ? 0 : additionalProperties!.hashCode) +
(default_ == null ? 0 : default_!.hashCode) +
(description == null ? 0 : description!.hashCode) +
(enum_.hashCode) +
(items == null ? 0 : items!.hashCode) +
(properties.hashCode) +
(required_.hashCode) +
(type == null ? 0 : type!.hashCode);
@override
String toString() => 'PluginJsonSchemaProperty[additionalProperties=$additionalProperties, default_=$default_, description=$description, enum_=$enum_, items=$items, properties=$properties, required_=$required_, type=$type]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
if (this.additionalProperties != null) {
json[r'additionalProperties'] = this.additionalProperties;
} else {
// json[r'additionalProperties'] = null;
}
if (this.default_ != null) {
json[r'default'] = this.default_;
} else {
// json[r'default'] = null;
}
if (this.description != null) {
json[r'description'] = this.description;
} else {
// json[r'description'] = null;
}
json[r'enum'] = this.enum_;
if (this.items != null) {
json[r'items'] = this.items;
} else {
// json[r'items'] = null;
}
json[r'properties'] = this.properties;
json[r'required'] = this.required_;
if (this.type != null) {
json[r'type'] = this.type;
} else {
// json[r'type'] = null;
}
return json;
}
/// Returns a new [PluginJsonSchemaProperty] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static PluginJsonSchemaProperty? fromJson(dynamic value) {
upgradeDto(value, "PluginJsonSchemaProperty");
if (value is Map) {
final json = value.cast<String, dynamic>();
return PluginJsonSchemaProperty(
additionalProperties: PluginJsonSchemaPropertyAdditionalProperties.fromJson(json[r'additionalProperties']),
default_: mapValueOfType<Object>(json, r'default'),
description: mapValueOfType<String>(json, r'description'),
enum_: json[r'enum'] is Iterable
? (json[r'enum'] as Iterable).cast<String>().toList(growable: false)
: const [],
items: PluginJsonSchemaProperty.fromJson(json[r'items']),
properties: PluginJsonSchemaProperty.mapFromJson(json[r'properties']),
required_: json[r'required'] is Iterable
? (json[r'required'] as Iterable).cast<String>().toList(growable: false)
: const [],
type: PluginJsonSchemaType.fromJson(json[r'type']),
);
}
return null;
}
static List<PluginJsonSchemaProperty> listFromJson(dynamic json, {bool growable = false,}) {
final result = <PluginJsonSchemaProperty>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = PluginJsonSchemaProperty.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, PluginJsonSchemaProperty> mapFromJson(dynamic json) {
final map = <String, PluginJsonSchemaProperty>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = PluginJsonSchemaProperty.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of PluginJsonSchemaProperty-objects as value to a dart map
static Map<String, List<PluginJsonSchemaProperty>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<PluginJsonSchemaProperty>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = PluginJsonSchemaProperty.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
};
}
@@ -1,195 +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 PluginJsonSchemaPropertyAdditionalProperties {
/// Returns a new [PluginJsonSchemaPropertyAdditionalProperties] instance.
PluginJsonSchemaPropertyAdditionalProperties({
this.additionalProperties,
this.default_,
this.description,
this.enum_ = const [],
this.items,
this.properties = const {},
this.required_ = const [],
this.type,
});
///
/// 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.
///
PluginJsonSchemaPropertyAdditionalProperties? additionalProperties;
Object? default_;
///
/// 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.
///
String? description;
List<String> enum_;
///
/// 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.
///
PluginJsonSchemaProperty? items;
Map<String, PluginJsonSchemaProperty> properties;
List<String> required_;
///
/// 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.
///
PluginJsonSchemaType? type;
@override
bool operator ==(Object other) => identical(this, other) || other is PluginJsonSchemaPropertyAdditionalProperties &&
other.additionalProperties == additionalProperties &&
other.default_ == default_ &&
other.description == description &&
_deepEquality.equals(other.enum_, enum_) &&
other.items == items &&
_deepEquality.equals(other.properties, properties) &&
_deepEquality.equals(other.required_, required_) &&
other.type == type;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(additionalProperties == null ? 0 : additionalProperties!.hashCode) +
(default_ == null ? 0 : default_!.hashCode) +
(description == null ? 0 : description!.hashCode) +
(enum_.hashCode) +
(items == null ? 0 : items!.hashCode) +
(properties.hashCode) +
(required_.hashCode) +
(type == null ? 0 : type!.hashCode);
@override
String toString() => 'PluginJsonSchemaPropertyAdditionalProperties[additionalProperties=$additionalProperties, default_=$default_, description=$description, enum_=$enum_, items=$items, properties=$properties, required_=$required_, type=$type]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
if (this.additionalProperties != null) {
json[r'additionalProperties'] = this.additionalProperties;
} else {
// json[r'additionalProperties'] = null;
}
if (this.default_ != null) {
json[r'default'] = this.default_;
} else {
// json[r'default'] = null;
}
if (this.description != null) {
json[r'description'] = this.description;
} else {
// json[r'description'] = null;
}
json[r'enum'] = this.enum_;
if (this.items != null) {
json[r'items'] = this.items;
} else {
// json[r'items'] = null;
}
json[r'properties'] = this.properties;
json[r'required'] = this.required_;
if (this.type != null) {
json[r'type'] = this.type;
} else {
// json[r'type'] = null;
}
return json;
}
/// Returns a new [PluginJsonSchemaPropertyAdditionalProperties] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static PluginJsonSchemaPropertyAdditionalProperties? fromJson(dynamic value) {
upgradeDto(value, "PluginJsonSchemaPropertyAdditionalProperties");
if (value is Map) {
final json = value.cast<String, dynamic>();
return PluginJsonSchemaPropertyAdditionalProperties(
additionalProperties: PluginJsonSchemaPropertyAdditionalProperties.fromJson(json[r'additionalProperties']),
default_: mapValueOfType<Object>(json, r'default'),
description: mapValueOfType<String>(json, r'description'),
enum_: json[r'enum'] is Iterable
? (json[r'enum'] as Iterable).cast<String>().toList(growable: false)
: const [],
items: PluginJsonSchemaProperty.fromJson(json[r'items']),
properties: PluginJsonSchemaProperty.mapFromJson(json[r'properties']),
required_: json[r'required'] is Iterable
? (json[r'required'] as Iterable).cast<String>().toList(growable: false)
: const [],
type: PluginJsonSchemaType.fromJson(json[r'type']),
);
}
return null;
}
static List<PluginJsonSchemaPropertyAdditionalProperties> listFromJson(dynamic json, {bool growable = false,}) {
final result = <PluginJsonSchemaPropertyAdditionalProperties>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = PluginJsonSchemaPropertyAdditionalProperties.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, PluginJsonSchemaPropertyAdditionalProperties> mapFromJson(dynamic json) {
final map = <String, PluginJsonSchemaPropertyAdditionalProperties>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = PluginJsonSchemaPropertyAdditionalProperties.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of PluginJsonSchemaPropertyAdditionalProperties-objects as value to a dart map
static Map<String, List<PluginJsonSchemaPropertyAdditionalProperties>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<PluginJsonSchemaPropertyAdditionalProperties>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = PluginJsonSchemaPropertyAdditionalProperties.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
};
}
-100
View File
@@ -1,100 +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 PluginJsonSchemaType {
/// Instantiate a new enum with the provided [value].
const PluginJsonSchemaType._(this.value);
/// The underlying value of this enum member.
final String value;
@override
String toString() => value;
String toJson() => value;
static const string = PluginJsonSchemaType._(r'string');
static const number = PluginJsonSchemaType._(r'number');
static const integer = PluginJsonSchemaType._(r'integer');
static const boolean = PluginJsonSchemaType._(r'boolean');
static const object = PluginJsonSchemaType._(r'object');
static const array = PluginJsonSchemaType._(r'array');
static const null_ = PluginJsonSchemaType._(r'null');
/// List of all possible values in this [enum][PluginJsonSchemaType].
static const values = <PluginJsonSchemaType>[
string,
number,
integer,
boolean,
object,
array,
null_,
];
static PluginJsonSchemaType? fromJson(dynamic value) => PluginJsonSchemaTypeTypeTransformer().decode(value);
static List<PluginJsonSchemaType> listFromJson(dynamic json, {bool growable = false,}) {
final result = <PluginJsonSchemaType>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = PluginJsonSchemaType.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
}
/// Transformation class that can [encode] an instance of [PluginJsonSchemaType] to String,
/// and [decode] dynamic data back to [PluginJsonSchemaType].
class PluginJsonSchemaTypeTypeTransformer {
factory PluginJsonSchemaTypeTypeTransformer() => _instance ??= const PluginJsonSchemaTypeTypeTransformer._();
const PluginJsonSchemaTypeTypeTransformer._();
String encode(PluginJsonSchemaType data) => data.value;
/// Decodes a [dynamic value][data] to a PluginJsonSchemaType.
///
/// 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.
PluginJsonSchemaType? decode(dynamic data, {bool allowNull = true}) {
if (data != null) {
switch (data) {
case r'string': return PluginJsonSchemaType.string;
case r'number': return PluginJsonSchemaType.number;
case r'integer': return PluginJsonSchemaType.integer;
case r'boolean': return PluginJsonSchemaType.boolean;
case r'object': return PluginJsonSchemaType.object;
case r'array': return PluginJsonSchemaType.array;
case r'null': return PluginJsonSchemaType.null_;
default:
if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data');
}
}
}
return null;
}
/// Singleton [PluginJsonSchemaTypeTypeTransformer] instance.
static PluginJsonSchemaTypeTypeTransformer? _instance;
}
+172
View File
@@ -0,0 +1,172 @@
//
// 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 PluginMethodResponseDto {
/// Returns a new [PluginMethodResponseDto] instance.
PluginMethodResponseDto({
required this.description,
required this.hostFunctions,
required this.key,
required this.name,
this.schema,
required this.title,
this.types = const [],
this.uiHints = const [],
});
/// Description
String description;
bool hostFunctions;
/// Key
String key;
/// Name
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.
///
Object? schema;
/// Title
String title;
/// Workflow types
List<WorkflowType> types;
/// Ui hints
List<String> uiHints;
@override
bool operator ==(Object other) => identical(this, other) || other is PluginMethodResponseDto &&
other.description == description &&
other.hostFunctions == hostFunctions &&
other.key == key &&
other.name == name &&
other.schema == schema &&
other.title == title &&
_deepEquality.equals(other.types, types) &&
_deepEquality.equals(other.uiHints, uiHints);
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(description.hashCode) +
(hostFunctions.hashCode) +
(key.hashCode) +
(name.hashCode) +
(schema == null ? 0 : schema!.hashCode) +
(title.hashCode) +
(types.hashCode) +
(uiHints.hashCode);
@override
String toString() => 'PluginMethodResponseDto[description=$description, hostFunctions=$hostFunctions, key=$key, name=$name, schema=$schema, title=$title, types=$types, uiHints=$uiHints]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'description'] = this.description;
json[r'hostFunctions'] = this.hostFunctions;
json[r'key'] = this.key;
json[r'name'] = this.name;
if (this.schema != null) {
json[r'schema'] = this.schema;
} else {
// json[r'schema'] = null;
}
json[r'title'] = this.title;
json[r'types'] = this.types;
json[r'uiHints'] = this.uiHints;
return json;
}
/// Returns a new [PluginMethodResponseDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static PluginMethodResponseDto? fromJson(dynamic value) {
upgradeDto(value, "PluginMethodResponseDto");
if (value is Map) {
final json = value.cast<String, dynamic>();
return PluginMethodResponseDto(
description: mapValueOfType<String>(json, r'description')!,
hostFunctions: mapValueOfType<bool>(json, r'hostFunctions')!,
key: mapValueOfType<String>(json, r'key')!,
name: mapValueOfType<String>(json, r'name')!,
schema: mapValueOfType<Object>(json, r'schema'),
title: mapValueOfType<String>(json, r'title')!,
types: WorkflowType.listFromJson(json[r'types']),
uiHints: json[r'uiHints'] is Iterable
? (json[r'uiHints'] as Iterable).cast<String>().toList(growable: false)
: const [],
);
}
return null;
}
static List<PluginMethodResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <PluginMethodResponseDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = PluginMethodResponseDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, PluginMethodResponseDto> mapFromJson(dynamic json) {
final map = <String, PluginMethodResponseDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = PluginMethodResponseDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of PluginMethodResponseDto-objects as value to a dart map
static Map<String, List<PluginMethodResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<PluginMethodResponseDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = PluginMethodResponseDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'description',
'hostFunctions',
'key',
'name',
'title',
'types',
'uiHints',
};
}
+10 -19
View File
@@ -13,21 +13,17 @@ part of openapi.api;
class PluginResponseDto {
/// Returns a new [PluginResponseDto] instance.
PluginResponseDto({
this.actions = const [],
required this.author,
required this.createdAt,
required this.description,
this.filters = const [],
required this.id,
this.methods = const [],
required this.name,
required this.title,
required this.updatedAt,
required this.version,
});
/// Plugin actions
List<PluginActionResponseDto> actions;
/// Plugin author
String author;
@@ -37,12 +33,12 @@ class PluginResponseDto {
/// Plugin description
String description;
/// Plugin filters
List<PluginFilterResponseDto> filters;
/// Plugin ID
String id;
/// Plugin methods
List<PluginMethodResponseDto> methods;
/// Plugin name
String name;
@@ -57,12 +53,11 @@ class PluginResponseDto {
@override
bool operator ==(Object other) => identical(this, other) || other is PluginResponseDto &&
_deepEquality.equals(other.actions, actions) &&
other.author == author &&
other.createdAt == createdAt &&
other.description == description &&
_deepEquality.equals(other.filters, filters) &&
other.id == id &&
_deepEquality.equals(other.methods, methods) &&
other.name == name &&
other.title == title &&
other.updatedAt == updatedAt &&
@@ -71,28 +66,26 @@ class PluginResponseDto {
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(actions.hashCode) +
(author.hashCode) +
(createdAt.hashCode) +
(description.hashCode) +
(filters.hashCode) +
(id.hashCode) +
(methods.hashCode) +
(name.hashCode) +
(title.hashCode) +
(updatedAt.hashCode) +
(version.hashCode);
@override
String toString() => 'PluginResponseDto[actions=$actions, author=$author, createdAt=$createdAt, description=$description, filters=$filters, id=$id, name=$name, title=$title, updatedAt=$updatedAt, version=$version]';
String toString() => 'PluginResponseDto[author=$author, createdAt=$createdAt, description=$description, id=$id, methods=$methods, name=$name, title=$title, updatedAt=$updatedAt, version=$version]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'actions'] = this.actions;
json[r'author'] = this.author;
json[r'createdAt'] = this.createdAt;
json[r'description'] = this.description;
json[r'filters'] = this.filters;
json[r'id'] = this.id;
json[r'methods'] = this.methods;
json[r'name'] = this.name;
json[r'title'] = this.title;
json[r'updatedAt'] = this.updatedAt;
@@ -109,12 +102,11 @@ class PluginResponseDto {
final json = value.cast<String, dynamic>();
return PluginResponseDto(
actions: PluginActionResponseDto.listFromJson(json[r'actions']),
author: mapValueOfType<String>(json, r'author')!,
createdAt: mapValueOfType<String>(json, r'createdAt')!,
description: mapValueOfType<String>(json, r'description')!,
filters: PluginFilterResponseDto.listFromJson(json[r'filters']),
id: mapValueOfType<String>(json, r'id')!,
methods: PluginMethodResponseDto.listFromJson(json[r'methods']),
name: mapValueOfType<String>(json, r'name')!,
title: mapValueOfType<String>(json, r'title')!,
updatedAt: mapValueOfType<String>(json, r'updatedAt')!,
@@ -166,12 +158,11 @@ class PluginResponseDto {
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'actions',
'author',
'createdAt',
'description',
'filters',
'id',
'methods',
'name',
'title',
'updatedAt',
-107
View File
@@ -1,107 +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 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',
};
}
-107
View File
@@ -1,107 +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 WorkflowActionItemDto {
/// Returns a new [WorkflowActionItemDto] instance.
WorkflowActionItemDto({
this.actionConfig = const {},
required this.pluginActionId,
});
Map<String, Object> actionConfig;
/// Plugin action ID
String pluginActionId;
@override
bool operator ==(Object other) => identical(this, other) || other is WorkflowActionItemDto &&
_deepEquality.equals(other.actionConfig, actionConfig) &&
other.pluginActionId == pluginActionId;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(actionConfig.hashCode) +
(pluginActionId.hashCode);
@override
String toString() => 'WorkflowActionItemDto[actionConfig=$actionConfig, pluginActionId=$pluginActionId]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'actionConfig'] = this.actionConfig;
json[r'pluginActionId'] = this.pluginActionId;
return json;
}
/// Returns a new [WorkflowActionItemDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static WorkflowActionItemDto? fromJson(dynamic value) {
upgradeDto(value, "WorkflowActionItemDto");
if (value is Map) {
final json = value.cast<String, dynamic>();
return WorkflowActionItemDto(
actionConfig: mapCastOfType<String, Object>(json, r'actionConfig') ?? const {},
pluginActionId: mapValueOfType<String>(json, r'pluginActionId')!,
);
}
return null;
}
static List<WorkflowActionItemDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <WorkflowActionItemDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = WorkflowActionItemDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, WorkflowActionItemDto> mapFromJson(dynamic json) {
final map = <String, WorkflowActionItemDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = WorkflowActionItemDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of WorkflowActionItemDto-objects as value to a dart map
static Map<String, List<WorkflowActionItemDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<WorkflowActionItemDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = WorkflowActionItemDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'pluginActionId',
};
}
-142
View File
@@ -1,142 +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 WorkflowActionResponseDto {
/// Returns a new [WorkflowActionResponseDto] instance.
WorkflowActionResponseDto({
required this.actionConfig,
required this.id,
required this.order,
required this.pluginActionId,
required this.workflowId,
});
Map<String, Object>? actionConfig;
/// Action ID
String id;
/// Action order
///
/// Minimum value: -9007199254740991
/// Maximum value: 9007199254740991
int order;
/// Plugin action ID
String pluginActionId;
/// Workflow ID
String workflowId;
@override
bool operator ==(Object other) => identical(this, other) || other is WorkflowActionResponseDto &&
_deepEquality.equals(other.actionConfig, actionConfig) &&
other.id == id &&
other.order == order &&
other.pluginActionId == pluginActionId &&
other.workflowId == workflowId;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(actionConfig == null ? 0 : actionConfig!.hashCode) +
(id.hashCode) +
(order.hashCode) +
(pluginActionId.hashCode) +
(workflowId.hashCode);
@override
String toString() => 'WorkflowActionResponseDto[actionConfig=$actionConfig, id=$id, order=$order, pluginActionId=$pluginActionId, workflowId=$workflowId]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
if (this.actionConfig != null) {
json[r'actionConfig'] = this.actionConfig;
} else {
// json[r'actionConfig'] = null;
}
json[r'id'] = this.id;
json[r'order'] = this.order;
json[r'pluginActionId'] = this.pluginActionId;
json[r'workflowId'] = this.workflowId;
return json;
}
/// Returns a new [WorkflowActionResponseDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static WorkflowActionResponseDto? fromJson(dynamic value) {
upgradeDto(value, "WorkflowActionResponseDto");
if (value is Map) {
final json = value.cast<String, dynamic>();
return WorkflowActionResponseDto(
actionConfig: mapCastOfType<String, Object>(json, r'actionConfig'),
id: mapValueOfType<String>(json, r'id')!,
order: mapValueOfType<int>(json, r'order')!,
pluginActionId: mapValueOfType<String>(json, r'pluginActionId')!,
workflowId: mapValueOfType<String>(json, r'workflowId')!,
);
}
return null;
}
static List<WorkflowActionResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <WorkflowActionResponseDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = WorkflowActionResponseDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, WorkflowActionResponseDto> mapFromJson(dynamic json) {
final map = <String, WorkflowActionResponseDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = WorkflowActionResponseDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of WorkflowActionResponseDto-objects as value to a dart map
static Map<String, List<WorkflowActionResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<WorkflowActionResponseDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = WorkflowActionResponseDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'actionConfig',
'id',
'order',
'pluginActionId',
'workflowId',
};
}
+23 -37
View File
@@ -13,24 +13,14 @@ part of openapi.api;
class WorkflowCreateDto {
/// Returns a new [WorkflowCreateDto] instance.
WorkflowCreateDto({
this.actions = const [],
this.description,
this.enabled,
this.filters = const [],
required this.name,
required this.triggerType,
this.name,
this.steps = const [],
required this.trigger,
});
/// Workflow actions
List<WorkflowActionItemDto> actions;
/// Workflow description
///
/// 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.
///
String? description;
/// Workflow enabled
@@ -42,39 +32,35 @@ class WorkflowCreateDto {
///
bool? enabled;
/// Workflow filters
List<WorkflowFilterItemDto> filters;
/// Workflow name
String name;
String? name;
PluginTriggerType triggerType;
List<WorkflowStepDto> steps;
WorkflowTrigger trigger;
@override
bool operator ==(Object other) => identical(this, other) || other is WorkflowCreateDto &&
_deepEquality.equals(other.actions, actions) &&
other.description == description &&
other.enabled == enabled &&
_deepEquality.equals(other.filters, filters) &&
other.name == name &&
other.triggerType == triggerType;
_deepEquality.equals(other.steps, steps) &&
other.trigger == trigger;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(actions.hashCode) +
(description == null ? 0 : description!.hashCode) +
(enabled == null ? 0 : enabled!.hashCode) +
(filters.hashCode) +
(name.hashCode) +
(triggerType.hashCode);
(name == null ? 0 : name!.hashCode) +
(steps.hashCode) +
(trigger.hashCode);
@override
String toString() => 'WorkflowCreateDto[actions=$actions, description=$description, enabled=$enabled, filters=$filters, name=$name, triggerType=$triggerType]';
String toString() => 'WorkflowCreateDto[description=$description, enabled=$enabled, name=$name, steps=$steps, trigger=$trigger]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'actions'] = this.actions;
if (this.description != null) {
json[r'description'] = this.description;
} else {
@@ -85,9 +71,13 @@ class WorkflowCreateDto {
} else {
// json[r'enabled'] = null;
}
json[r'filters'] = this.filters;
if (this.name != null) {
json[r'name'] = this.name;
json[r'triggerType'] = this.triggerType;
} else {
// json[r'name'] = null;
}
json[r'steps'] = this.steps;
json[r'trigger'] = this.trigger;
return json;
}
@@ -100,12 +90,11 @@ class WorkflowCreateDto {
final json = value.cast<String, dynamic>();
return WorkflowCreateDto(
actions: WorkflowActionItemDto.listFromJson(json[r'actions']),
description: mapValueOfType<String>(json, r'description'),
enabled: mapValueOfType<bool>(json, r'enabled'),
filters: WorkflowFilterItemDto.listFromJson(json[r'filters']),
name: mapValueOfType<String>(json, r'name')!,
triggerType: PluginTriggerType.fromJson(json[r'triggerType'])!,
name: mapValueOfType<String>(json, r'name'),
steps: WorkflowStepDto.listFromJson(json[r'steps']),
trigger: WorkflowTrigger.fromJson(json[r'trigger'])!,
);
}
return null;
@@ -153,10 +142,7 @@ class WorkflowCreateDto {
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'actions',
'filters',
'name',
'triggerType',
'trigger',
};
}
-107
View File
@@ -1,107 +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 WorkflowFilterItemDto {
/// Returns a new [WorkflowFilterItemDto] instance.
WorkflowFilterItemDto({
this.filterConfig = const {},
required this.pluginFilterId,
});
Map<String, Object> filterConfig;
/// Plugin filter ID
String pluginFilterId;
@override
bool operator ==(Object other) => identical(this, other) || other is WorkflowFilterItemDto &&
_deepEquality.equals(other.filterConfig, filterConfig) &&
other.pluginFilterId == pluginFilterId;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(filterConfig.hashCode) +
(pluginFilterId.hashCode);
@override
String toString() => 'WorkflowFilterItemDto[filterConfig=$filterConfig, pluginFilterId=$pluginFilterId]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'filterConfig'] = this.filterConfig;
json[r'pluginFilterId'] = this.pluginFilterId;
return json;
}
/// Returns a new [WorkflowFilterItemDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static WorkflowFilterItemDto? fromJson(dynamic value) {
upgradeDto(value, "WorkflowFilterItemDto");
if (value is Map) {
final json = value.cast<String, dynamic>();
return WorkflowFilterItemDto(
filterConfig: mapCastOfType<String, Object>(json, r'filterConfig') ?? const {},
pluginFilterId: mapValueOfType<String>(json, r'pluginFilterId')!,
);
}
return null;
}
static List<WorkflowFilterItemDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <WorkflowFilterItemDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = WorkflowFilterItemDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, WorkflowFilterItemDto> mapFromJson(dynamic json) {
final map = <String, WorkflowFilterItemDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = WorkflowFilterItemDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of WorkflowFilterItemDto-objects as value to a dart map
static Map<String, List<WorkflowFilterItemDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<WorkflowFilterItemDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = WorkflowFilterItemDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'pluginFilterId',
};
}
-142
View File
@@ -1,142 +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 WorkflowFilterResponseDto {
/// Returns a new [WorkflowFilterResponseDto] instance.
WorkflowFilterResponseDto({
required this.filterConfig,
required this.id,
required this.order,
required this.pluginFilterId,
required this.workflowId,
});
Map<String, Object>? filterConfig;
/// Filter ID
String id;
/// Filter order
///
/// Minimum value: -9007199254740991
/// Maximum value: 9007199254740991
int order;
/// Plugin filter ID
String pluginFilterId;
/// Workflow ID
String workflowId;
@override
bool operator ==(Object other) => identical(this, other) || other is WorkflowFilterResponseDto &&
_deepEquality.equals(other.filterConfig, filterConfig) &&
other.id == id &&
other.order == order &&
other.pluginFilterId == pluginFilterId &&
other.workflowId == workflowId;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(filterConfig == null ? 0 : filterConfig!.hashCode) +
(id.hashCode) +
(order.hashCode) +
(pluginFilterId.hashCode) +
(workflowId.hashCode);
@override
String toString() => 'WorkflowFilterResponseDto[filterConfig=$filterConfig, id=$id, order=$order, pluginFilterId=$pluginFilterId, workflowId=$workflowId]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
if (this.filterConfig != null) {
json[r'filterConfig'] = this.filterConfig;
} else {
// json[r'filterConfig'] = null;
}
json[r'id'] = this.id;
json[r'order'] = this.order;
json[r'pluginFilterId'] = this.pluginFilterId;
json[r'workflowId'] = this.workflowId;
return json;
}
/// Returns a new [WorkflowFilterResponseDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static WorkflowFilterResponseDto? fromJson(dynamic value) {
upgradeDto(value, "WorkflowFilterResponseDto");
if (value is Map) {
final json = value.cast<String, dynamic>();
return WorkflowFilterResponseDto(
filterConfig: mapCastOfType<String, Object>(json, r'filterConfig'),
id: mapValueOfType<String>(json, r'id')!,
order: mapValueOfType<int>(json, r'order')!,
pluginFilterId: mapValueOfType<String>(json, r'pluginFilterId')!,
workflowId: mapValueOfType<String>(json, r'workflowId')!,
);
}
return null;
}
static List<WorkflowFilterResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <WorkflowFilterResponseDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = WorkflowFilterResponseDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, WorkflowFilterResponseDto> mapFromJson(dynamic json) {
final map = <String, WorkflowFilterResponseDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = WorkflowFilterResponseDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of WorkflowFilterResponseDto-objects as value to a dart map
static Map<String, List<WorkflowFilterResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<WorkflowFilterResponseDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = WorkflowFilterResponseDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'filterConfig',
'id',
'order',
'pluginFilterId',
'workflowId',
};
}
+32 -37
View File
@@ -13,86 +13,83 @@ part of openapi.api;
class WorkflowResponseDto {
/// Returns a new [WorkflowResponseDto] instance.
WorkflowResponseDto({
this.actions = const [],
required this.createdAt,
required this.description,
required this.enabled,
this.filters = const [],
required this.id,
required this.name,
required this.ownerId,
required this.triggerType,
this.steps = const [],
required this.trigger,
required this.updatedAt,
});
/// Workflow actions
List<WorkflowActionResponseDto> actions;
/// Creation date
String createdAt;
/// Workflow description
String description;
String? description;
/// Workflow enabled
bool enabled;
/// Workflow filters
List<WorkflowFilterResponseDto> filters;
/// Workflow ID
String id;
/// Workflow name
String? name;
/// Owner user ID
String ownerId;
/// Workflow steps
List<WorkflowStepDto> steps;
PluginTriggerType triggerType;
WorkflowTrigger trigger;
/// Update date
String updatedAt;
@override
bool operator ==(Object other) => identical(this, other) || other is WorkflowResponseDto &&
_deepEquality.equals(other.actions, actions) &&
other.createdAt == createdAt &&
other.description == description &&
other.enabled == enabled &&
_deepEquality.equals(other.filters, filters) &&
other.id == id &&
other.name == name &&
other.ownerId == ownerId &&
other.triggerType == triggerType;
_deepEquality.equals(other.steps, steps) &&
other.trigger == trigger &&
other.updatedAt == updatedAt;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(actions.hashCode) +
(createdAt.hashCode) +
(description.hashCode) +
(description == null ? 0 : description!.hashCode) +
(enabled.hashCode) +
(filters.hashCode) +
(id.hashCode) +
(name == null ? 0 : name!.hashCode) +
(ownerId.hashCode) +
(triggerType.hashCode);
(steps.hashCode) +
(trigger.hashCode) +
(updatedAt.hashCode);
@override
String toString() => 'WorkflowResponseDto[actions=$actions, createdAt=$createdAt, description=$description, enabled=$enabled, filters=$filters, id=$id, name=$name, ownerId=$ownerId, triggerType=$triggerType]';
String toString() => 'WorkflowResponseDto[createdAt=$createdAt, description=$description, enabled=$enabled, id=$id, name=$name, steps=$steps, trigger=$trigger, updatedAt=$updatedAt]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'actions'] = this.actions;
json[r'createdAt'] = this.createdAt;
if (this.description != null) {
json[r'description'] = this.description;
} else {
// json[r'description'] = null;
}
json[r'enabled'] = this.enabled;
json[r'filters'] = this.filters;
json[r'id'] = this.id;
if (this.name != null) {
json[r'name'] = this.name;
} else {
// json[r'name'] = null;
}
json[r'ownerId'] = this.ownerId;
json[r'triggerType'] = this.triggerType;
json[r'steps'] = this.steps;
json[r'trigger'] = this.trigger;
json[r'updatedAt'] = this.updatedAt;
return json;
}
@@ -105,15 +102,14 @@ class WorkflowResponseDto {
final json = value.cast<String, dynamic>();
return WorkflowResponseDto(
actions: WorkflowActionResponseDto.listFromJson(json[r'actions']),
createdAt: mapValueOfType<String>(json, r'createdAt')!,
description: mapValueOfType<String>(json, r'description')!,
description: mapValueOfType<String>(json, r'description'),
enabled: mapValueOfType<bool>(json, r'enabled')!,
filters: WorkflowFilterResponseDto.listFromJson(json[r'filters']),
id: mapValueOfType<String>(json, r'id')!,
name: mapValueOfType<String>(json, r'name'),
ownerId: mapValueOfType<String>(json, r'ownerId')!,
triggerType: PluginTriggerType.fromJson(json[r'triggerType'])!,
steps: WorkflowStepDto.listFromJson(json[r'steps']),
trigger: WorkflowTrigger.fromJson(json[r'trigger'])!,
updatedAt: mapValueOfType<String>(json, r'updatedAt')!,
);
}
return null;
@@ -161,15 +157,14 @@ class WorkflowResponseDto {
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'actions',
'createdAt',
'description',
'enabled',
'filters',
'id',
'name',
'ownerId',
'triggerType',
'steps',
'trigger',
'updatedAt',
};
}
+134
View File
@@ -0,0 +1,134 @@
//
// 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 WorkflowShareResponseDto {
/// Returns a new [WorkflowShareResponseDto] instance.
WorkflowShareResponseDto({
required this.description,
required this.name,
this.steps = const [],
required this.trigger,
});
/// Workflow description
String? description;
/// Workflow name
String? name;
/// Workflow steps
List<WorkflowShareStepDto> steps;
WorkflowTrigger trigger;
@override
bool operator ==(Object other) => identical(this, other) || other is WorkflowShareResponseDto &&
other.description == description &&
other.name == name &&
_deepEquality.equals(other.steps, steps) &&
other.trigger == trigger;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(description == null ? 0 : description!.hashCode) +
(name == null ? 0 : name!.hashCode) +
(steps.hashCode) +
(trigger.hashCode);
@override
String toString() => 'WorkflowShareResponseDto[description=$description, name=$name, steps=$steps, trigger=$trigger]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
if (this.description != null) {
json[r'description'] = this.description;
} else {
// json[r'description'] = null;
}
if (this.name != null) {
json[r'name'] = this.name;
} else {
// json[r'name'] = null;
}
json[r'steps'] = this.steps;
json[r'trigger'] = this.trigger;
return json;
}
/// Returns a new [WorkflowShareResponseDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static WorkflowShareResponseDto? fromJson(dynamic value) {
upgradeDto(value, "WorkflowShareResponseDto");
if (value is Map) {
final json = value.cast<String, dynamic>();
return WorkflowShareResponseDto(
description: mapValueOfType<String>(json, r'description'),
name: mapValueOfType<String>(json, r'name'),
steps: WorkflowShareStepDto.listFromJson(json[r'steps']),
trigger: WorkflowTrigger.fromJson(json[r'trigger'])!,
);
}
return null;
}
static List<WorkflowShareResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <WorkflowShareResponseDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = WorkflowShareResponseDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, WorkflowShareResponseDto> mapFromJson(dynamic json) {
final map = <String, WorkflowShareResponseDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = WorkflowShareResponseDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of WorkflowShareResponseDto-objects as value to a dart map
static Map<String, List<WorkflowShareResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<WorkflowShareResponseDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = WorkflowShareResponseDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'description',
'name',
'steps',
'trigger',
};
}
+131
View File
@@ -0,0 +1,131 @@
//
// 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 WorkflowShareStepDto {
/// Returns a new [WorkflowShareStepDto] instance.
WorkflowShareStepDto({
this.config = const {},
this.enabled,
required this.method,
});
/// Step configuration
Map<String, Object>? config;
/// Step is enabled
///
/// 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.
///
bool? enabled;
/// Step plugin method
String method;
@override
bool operator ==(Object other) => identical(this, other) || other is WorkflowShareStepDto &&
_deepEquality.equals(other.config, config) &&
other.enabled == enabled &&
other.method == method;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(config == null ? 0 : config!.hashCode) +
(enabled == null ? 0 : enabled!.hashCode) +
(method.hashCode);
@override
String toString() => 'WorkflowShareStepDto[config=$config, enabled=$enabled, method=$method]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
if (this.config != null) {
json[r'config'] = this.config;
} else {
// json[r'config'] = null;
}
if (this.enabled != null) {
json[r'enabled'] = this.enabled;
} else {
// json[r'enabled'] = null;
}
json[r'method'] = this.method;
return json;
}
/// Returns a new [WorkflowShareStepDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static WorkflowShareStepDto? fromJson(dynamic value) {
upgradeDto(value, "WorkflowShareStepDto");
if (value is Map) {
final json = value.cast<String, dynamic>();
return WorkflowShareStepDto(
config: mapCastOfType<String, Object>(json, r'config'),
enabled: mapValueOfType<bool>(json, r'enabled'),
method: mapValueOfType<String>(json, r'method')!,
);
}
return null;
}
static List<WorkflowShareStepDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <WorkflowShareStepDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = WorkflowShareStepDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, WorkflowShareStepDto> mapFromJson(dynamic json) {
final map = <String, WorkflowShareStepDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = WorkflowShareStepDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of WorkflowShareStepDto-objects as value to a dart map
static Map<String, List<WorkflowShareStepDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<WorkflowShareStepDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = WorkflowShareStepDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'config',
'method',
};
}
+131
View File
@@ -0,0 +1,131 @@
//
// 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 WorkflowStepDto {
/// Returns a new [WorkflowStepDto] instance.
WorkflowStepDto({
this.config = const {},
this.enabled,
required this.method,
});
/// Step configuration
Map<String, Object>? config;
/// Step is enabled
///
/// 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.
///
bool? enabled;
/// Step plugin method
String method;
@override
bool operator ==(Object other) => identical(this, other) || other is WorkflowStepDto &&
_deepEquality.equals(other.config, config) &&
other.enabled == enabled &&
other.method == method;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(config == null ? 0 : config!.hashCode) +
(enabled == null ? 0 : enabled!.hashCode) +
(method.hashCode);
@override
String toString() => 'WorkflowStepDto[config=$config, enabled=$enabled, method=$method]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
if (this.config != null) {
json[r'config'] = this.config;
} else {
// json[r'config'] = null;
}
if (this.enabled != null) {
json[r'enabled'] = this.enabled;
} else {
// json[r'enabled'] = null;
}
json[r'method'] = this.method;
return json;
}
/// Returns a new [WorkflowStepDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static WorkflowStepDto? fromJson(dynamic value) {
upgradeDto(value, "WorkflowStepDto");
if (value is Map) {
final json = value.cast<String, dynamic>();
return WorkflowStepDto(
config: mapCastOfType<String, Object>(json, r'config'),
enabled: mapValueOfType<bool>(json, r'enabled'),
method: mapValueOfType<String>(json, r'method')!,
);
}
return null;
}
static List<WorkflowStepDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <WorkflowStepDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = WorkflowStepDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, WorkflowStepDto> mapFromJson(dynamic json) {
final map = <String, WorkflowStepDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = WorkflowStepDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of WorkflowStepDto-objects as value to a dart map
static Map<String, List<WorkflowStepDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<WorkflowStepDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = WorkflowStepDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'config',
'method',
};
}
@@ -11,9 +11,9 @@
part of openapi.api;
/// Plugin trigger type
class PluginTriggerType {
class WorkflowTrigger {
/// Instantiate a new enum with the provided [value].
const PluginTriggerType._(this.value);
const WorkflowTrigger._(this.value);
/// The underlying value of this enum member.
final String value;
@@ -23,22 +23,22 @@ class PluginTriggerType {
String toJson() => value;
static const assetCreate = PluginTriggerType._(r'AssetCreate');
static const personRecognized = PluginTriggerType._(r'PersonRecognized');
static const assetCreate = WorkflowTrigger._(r'AssetCreate');
static const personRecognized = WorkflowTrigger._(r'PersonRecognized');
/// List of all possible values in this [enum][PluginTriggerType].
static const values = <PluginTriggerType>[
/// List of all possible values in this [enum][WorkflowTrigger].
static const values = <WorkflowTrigger>[
assetCreate,
personRecognized,
];
static PluginTriggerType? fromJson(dynamic value) => PluginTriggerTypeTypeTransformer().decode(value);
static WorkflowTrigger? fromJson(dynamic value) => WorkflowTriggerTypeTransformer().decode(value);
static List<PluginTriggerType> listFromJson(dynamic json, {bool growable = false,}) {
final result = <PluginTriggerType>[];
static List<WorkflowTrigger> listFromJson(dynamic json, {bool growable = false,}) {
final result = <WorkflowTrigger>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = PluginTriggerType.fromJson(row);
final value = WorkflowTrigger.fromJson(row);
if (value != null) {
result.add(value);
}
@@ -48,16 +48,16 @@ class PluginTriggerType {
}
}
/// Transformation class that can [encode] an instance of [PluginTriggerType] to String,
/// and [decode] dynamic data back to [PluginTriggerType].
class PluginTriggerTypeTypeTransformer {
factory PluginTriggerTypeTypeTransformer() => _instance ??= const PluginTriggerTypeTypeTransformer._();
/// Transformation class that can [encode] an instance of [WorkflowTrigger] to String,
/// and [decode] dynamic data back to [WorkflowTrigger].
class WorkflowTriggerTypeTransformer {
factory WorkflowTriggerTypeTransformer() => _instance ??= const WorkflowTriggerTypeTransformer._();
const PluginTriggerTypeTypeTransformer._();
const WorkflowTriggerTypeTransformer._();
String encode(PluginTriggerType data) => data.value;
String encode(WorkflowTrigger data) => data.value;
/// Decodes a [dynamic value][data] to a PluginTriggerType.
/// Decodes a [dynamic value][data] to a WorkflowTrigger.
///
/// 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]
@@ -65,11 +65,11 @@ class PluginTriggerTypeTypeTransformer {
///
/// 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.
PluginTriggerType? decode(dynamic data, {bool allowNull = true}) {
WorkflowTrigger? decode(dynamic data, {bool allowNull = true}) {
if (data != null) {
switch (data) {
case r'AssetCreate': return PluginTriggerType.assetCreate;
case r'PersonRecognized': return PluginTriggerType.personRecognized;
case r'AssetCreate': return WorkflowTrigger.assetCreate;
case r'PersonRecognized': return WorkflowTrigger.personRecognized;
default:
if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data');
@@ -79,7 +79,7 @@ class PluginTriggerTypeTypeTransformer {
return null;
}
/// Singleton [PluginTriggerTypeTypeTransformer] instance.
static PluginTriggerTypeTypeTransformer? _instance;
/// Singleton [WorkflowTriggerTypeTransformer] instance.
static WorkflowTriggerTypeTransformer? _instance;
}
@@ -0,0 +1,108 @@
//
// 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 WorkflowTriggerResponseDto {
/// Returns a new [WorkflowTriggerResponseDto] instance.
WorkflowTriggerResponseDto({
required this.trigger,
this.types = const [],
});
WorkflowTrigger trigger;
/// Workflow types
List<WorkflowType> types;
@override
bool operator ==(Object other) => identical(this, other) || other is WorkflowTriggerResponseDto &&
other.trigger == trigger &&
_deepEquality.equals(other.types, types);
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(trigger.hashCode) +
(types.hashCode);
@override
String toString() => 'WorkflowTriggerResponseDto[trigger=$trigger, types=$types]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'trigger'] = this.trigger;
json[r'types'] = this.types;
return json;
}
/// Returns a new [WorkflowTriggerResponseDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static WorkflowTriggerResponseDto? fromJson(dynamic value) {
upgradeDto(value, "WorkflowTriggerResponseDto");
if (value is Map) {
final json = value.cast<String, dynamic>();
return WorkflowTriggerResponseDto(
trigger: WorkflowTrigger.fromJson(json[r'trigger'])!,
types: WorkflowType.listFromJson(json[r'types']),
);
}
return null;
}
static List<WorkflowTriggerResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <WorkflowTriggerResponseDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = WorkflowTriggerResponseDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, WorkflowTriggerResponseDto> mapFromJson(dynamic json) {
final map = <String, WorkflowTriggerResponseDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = WorkflowTriggerResponseDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of WorkflowTriggerResponseDto-objects as value to a dart map
static Map<String, List<WorkflowTriggerResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<WorkflowTriggerResponseDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = WorkflowTriggerResponseDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'trigger',
'types',
};
}
+85
View File
@@ -0,0 +1,85 @@
//
// 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;
/// Workflow type
class WorkflowType {
/// Instantiate a new enum with the provided [value].
const WorkflowType._(this.value);
/// The underlying value of this enum member.
final String value;
@override
String toString() => value;
String toJson() => value;
static const assetV1 = WorkflowType._(r'AssetV1');
static const assetPersonV1 = WorkflowType._(r'AssetPersonV1');
/// List of all possible values in this [enum][WorkflowType].
static const values = <WorkflowType>[
assetV1,
assetPersonV1,
];
static WorkflowType? fromJson(dynamic value) => WorkflowTypeTypeTransformer().decode(value);
static List<WorkflowType> listFromJson(dynamic json, {bool growable = false,}) {
final result = <WorkflowType>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = WorkflowType.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
}
/// Transformation class that can [encode] an instance of [WorkflowType] to String,
/// and [decode] dynamic data back to [WorkflowType].
class WorkflowTypeTypeTransformer {
factory WorkflowTypeTypeTransformer() => _instance ??= const WorkflowTypeTypeTransformer._();
const WorkflowTypeTypeTransformer._();
String encode(WorkflowType data) => data.value;
/// Decodes a [dynamic value][data] to a WorkflowType.
///
/// 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.
WorkflowType? decode(dynamic data, {bool allowNull = true}) {
if (data != null) {
switch (data) {
case r'AssetV1': return WorkflowType.assetV1;
case r'AssetPersonV1': return WorkflowType.assetPersonV1;
default:
if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data');
}
}
}
return null;
}
/// Singleton [WorkflowTypeTypeTransformer] instance.
static WorkflowTypeTypeTransformer? _instance;
}
+16 -37
View File
@@ -13,24 +13,14 @@ part of openapi.api;
class WorkflowUpdateDto {
/// Returns a new [WorkflowUpdateDto] instance.
WorkflowUpdateDto({
this.actions = const [],
this.description,
this.enabled,
this.filters = const [],
this.name,
this.triggerType,
this.steps = const [],
this.trigger,
});
/// Workflow actions
List<WorkflowActionItemDto> actions;
/// Workflow description
///
/// 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.
///
String? description;
/// Workflow enabled
@@ -42,51 +32,41 @@ class WorkflowUpdateDto {
///
bool? enabled;
/// Workflow filters
List<WorkflowFilterItemDto> filters;
/// Workflow 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.
///
String? name;
List<WorkflowStepDto> steps;
///
/// 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;
WorkflowTrigger? trigger;
@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.triggerType == triggerType;
_deepEquality.equals(other.steps, steps) &&
other.trigger == trigger;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(actions.hashCode) +
(description == null ? 0 : description!.hashCode) +
(enabled == null ? 0 : enabled!.hashCode) +
(filters.hashCode) +
(name == null ? 0 : name!.hashCode) +
(triggerType == null ? 0 : triggerType!.hashCode);
(steps.hashCode) +
(trigger == null ? 0 : trigger!.hashCode);
@override
String toString() => 'WorkflowUpdateDto[actions=$actions, description=$description, enabled=$enabled, filters=$filters, name=$name, triggerType=$triggerType]';
String toString() => 'WorkflowUpdateDto[description=$description, enabled=$enabled, name=$name, steps=$steps, trigger=$trigger]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'actions'] = this.actions;
if (this.description != null) {
json[r'description'] = this.description;
} else {
@@ -97,16 +77,16 @@ class WorkflowUpdateDto {
} else {
// json[r'enabled'] = null;
}
json[r'filters'] = this.filters;
if (this.name != null) {
json[r'name'] = this.name;
} else {
// json[r'name'] = null;
}
if (this.triggerType != null) {
json[r'triggerType'] = this.triggerType;
json[r'steps'] = this.steps;
if (this.trigger != null) {
json[r'trigger'] = this.trigger;
} else {
// json[r'triggerType'] = null;
// json[r'trigger'] = null;
}
return json;
}
@@ -120,12 +100,11 @@ class WorkflowUpdateDto {
final json = value.cast<String, dynamic>();
return WorkflowUpdateDto(
actions: WorkflowActionItemDto.listFromJson(json[r'actions']),
description: mapValueOfType<String>(json, r'description'),
enabled: mapValueOfType<bool>(json, r'enabled'),
filters: WorkflowFilterItemDto.listFromJson(json[r'filters']),
name: mapValueOfType<String>(json, r'name'),
triggerType: PluginTriggerType.fromJson(json[r'triggerType']),
steps: WorkflowStepDto.listFromJson(json[r'steps']),
trigger: WorkflowTrigger.fromJson(json[r'trigger']),
);
}
return null;
+1
View File
@@ -5,6 +5,7 @@ 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/components/url_input.dart';
export 'src/constants.dart';
export 'src/theme.dart';
export 'src/translation.dart';
+69 -69
View File
@@ -4,95 +4,95 @@ 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;
class ImmichFormController extends ChangeNotifier {
ImmichFormController({this.onSubmit});
const ImmichForm({
super.key,
this.submitText,
this.submitIcon,
required this.onSubmit,
required this.child,
});
FutureOr<void> Function()? onSubmit;
final formKey = GlobalKey<FormState>();
@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;
bool get isLoading => _isLoading;
FutureOr<void> submit() async {
final isValid = _formKey.currentState?.validate() ?? false;
if (!isValid) {
Future<void> submit() async {
if (_isLoading) {
return;
}
if (!(formKey.currentState?.validate() ?? false)) {
return;
}
setState(() {
_isLoading = true;
});
_isLoading = true;
notifyListeners();
try {
await widget.onSubmit?.call();
await onSubmit?.call();
} finally {
if (mounted) {
setState(() {
_isLoading = false;
});
}
_isLoading = false;
notifyListeners();
}
}
}
class ImmichForm extends StatefulWidget {
final FutureOr<void> Function()? onSubmit;
final Widget Function(BuildContext context, ImmichFormController form) builder;
final String? submitText;
final IconData? submitIcon;
const ImmichForm({
super.key,
this.onSubmit,
this.submitText,
this.submitIcon,
required this.builder,
});
@override
State<ImmichForm> createState() => _ImmichFormState();
}
class _ImmichFormState extends State<ImmichForm> {
late final ImmichFormController _controller;
@override
void initState() {
super.initState();
_controller = ImmichFormController(onSubmit: widget.onSubmit);
}
@override
void didUpdateWidget(ImmichForm oldWidget) {
super.didUpdateWidget(oldWidget);
_controller.onSubmit = widget.onSubmit;
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@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(
return Form(
key: _controller.formKey,
child: Column(
spacing: ImmichSpacing.md,
children: [
widget.builder(context, _controller),
ListenableBuilder(
listenable: _controller,
builder: (context, _) => ImmichTextButton(
labelText: submitText,
icon: widget.submitIcon,
variant: ImmichVariant.filled,
loading: _isLoading,
onPressed: submit,
disabled: widget.onSubmit == null,
loading: _controller.isLoading,
onPressed: _controller.submit,
disabled: _controller.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;
}

Some files were not shown because too many files have changed in this diff Show More