mirror of
https://github.com/immich-app/immich.git
synced 2025-12-19 10:52:30 -08:00
Compare commits
49 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
608543da0b | ||
|
|
b4fa60d4fd | ||
|
|
b1467bd1da | ||
|
|
3cf0f5f11b | ||
|
|
454737ca79 | ||
|
|
26bc889f8d | ||
|
|
54775b896f | ||
|
|
9217fb4094 | ||
|
|
04d4a30471 | ||
|
|
90f9501902 | ||
|
|
f8d26bd865 | ||
|
|
816d040d81 | ||
|
|
2069293cc1 | ||
|
|
4bd77d5899 | ||
|
|
f8ff342852 | ||
|
|
67ac686704 | ||
|
|
4e5bf7ae2e | ||
|
|
b7fd5dcb4a | ||
|
|
bea287c5b3 | ||
|
|
46c716d450 | ||
|
|
9539a361e4 | ||
|
|
ca35e5557b | ||
|
|
a26ed3d1a6 | ||
|
|
c7d53a5006 | ||
|
|
41461e0d5d | ||
|
|
c0a48d7357 | ||
|
|
66cc744c22 | ||
|
|
58ae734fc2 | ||
|
|
54b2779b79 | ||
|
|
df26e12db6 | ||
|
|
343d89c032 | ||
|
|
49c2d4d115 | ||
|
|
58aefc928d | ||
|
|
70d8902737 | ||
|
|
78eeebf8e6 | ||
|
|
585330b179 | ||
|
|
5b1ac27058 | ||
|
|
bcc36d14a1 | ||
|
|
22f5e05060 | ||
|
|
e510e733cd | ||
|
|
d0a06739d8 | ||
|
|
26c43617d1 | ||
|
|
0a89c7ffc4 | ||
|
|
7097cf6319 | ||
|
|
912a13ea0d | ||
|
|
cb391342d7 | ||
|
|
305889f32b | ||
|
|
f1027d7807 | ||
|
|
2806ac6eb4 |
2
.github/workflows/build-mobile.yml
vendored
2
.github/workflows/build-mobile.yml
vendored
@@ -31,7 +31,7 @@ jobs:
|
||||
ref="${input_ref:-$github_ref}"
|
||||
echo "ref=$ref" >> $GITHUB_OUTPUT
|
||||
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ steps.get-ref.outputs.ref }}
|
||||
|
||||
|
||||
2
.github/workflows/cache-cleanup.yml
vendored
2
.github/workflows/cache-cleanup.yml
vendored
@@ -13,7 +13,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Cleanup
|
||||
run: |
|
||||
|
||||
2
.github/workflows/codeql-analysis.yml
vendored
2
.github/workflows/codeql-analysis.yml
vendored
@@ -42,7 +42,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
|
||||
8
.github/workflows/docker.yml
vendored
8
.github/workflows/docker.yml
vendored
@@ -36,13 +36,13 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2.2.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2.9.1
|
||||
uses: docker/setup-buildx-action@v2.10.0
|
||||
# Workaround to fix error:
|
||||
# failed to push: failed to copy: io: read/write on closed pipe
|
||||
# See https://github.com/docker/build-push-action/issues/761
|
||||
@@ -120,13 +120,13 @@ jobs:
|
||||
platforms: "linux/arm64,linux/amd64"
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2.2.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2.9.1
|
||||
uses: docker/setup-buildx-action@v2.10.0
|
||||
# Workaround to fix error:
|
||||
# failed to push: failed to copy: io: read/write on closed pipe
|
||||
# See https://github.com/docker/build-push-action/issues/761
|
||||
|
||||
4
.github/workflows/prepare-release.yml
vendored
4
.github/workflows/prepare-release.yml
vendored
@@ -30,7 +30,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.ORG_RELEASE_TOKEN }}
|
||||
|
||||
@@ -64,7 +64,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.ORG_RELEASE_TOKEN }}
|
||||
|
||||
|
||||
2
.github/workflows/static_analysis.yml
vendored
2
.github/workflows/static_analysis.yml
vendored
@@ -17,7 +17,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Flutter SDK
|
||||
uses: subosito/flutter-action@v2
|
||||
|
||||
20
.github/workflows/test.yml
vendored
20
.github/workflows/test.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Run npm install
|
||||
run: npm ci
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Run npm install
|
||||
run: npm ci
|
||||
@@ -59,7 +59,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Run npm install
|
||||
run: npm ci
|
||||
@@ -89,7 +89,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Run npm install
|
||||
run: npm ci
|
||||
@@ -115,7 +115,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Run npm install
|
||||
run: npm ci
|
||||
@@ -144,7 +144,7 @@ jobs:
|
||||
name: Run mobile unit tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Flutter SDK
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
@@ -161,7 +161,7 @@ jobs:
|
||||
run:
|
||||
working-directory: ./machine-learning
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install poetry
|
||||
run: pipx install poetry
|
||||
- uses: actions/setup-python@v4
|
||||
@@ -189,7 +189,7 @@ jobs:
|
||||
name: Check generated files are up-to-date
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
- name: Run API generation
|
||||
run: npm --prefix server run api:generate
|
||||
- name: Find file changes
|
||||
@@ -224,7 +224,7 @@ jobs:
|
||||
ports:
|
||||
- 5432:5432
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install server dependencies
|
||||
run: npm --prefix server ci
|
||||
- name: Run existing migrations
|
||||
@@ -249,7 +249,7 @@ jobs:
|
||||
# name: Run mobile end-to-end integration tests
|
||||
# runs-on: macos-latest
|
||||
# steps:
|
||||
# - uses: actions/checkout@v3
|
||||
# - uses: actions/checkout@v4
|
||||
# - uses: actions/setup-java@v3
|
||||
# with:
|
||||
# distribution: 'zulu'
|
||||
|
||||
363
cli/src/api/open-api/api.ts
generated
363
cli/src/api/open-api/api.ts
generated
@@ -4,7 +4,7 @@
|
||||
* Immich
|
||||
* Immich API
|
||||
*
|
||||
* The version of the OpenAPI document: 1.75.1
|
||||
* The version of the OpenAPI document: 1.77.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
@@ -645,6 +645,12 @@ export interface AssetResponseDto {
|
||||
* @memberof AssetResponseDto
|
||||
*/
|
||||
'originalPath': string;
|
||||
/**
|
||||
*
|
||||
* @type {UserResponseDto}
|
||||
* @memberof AssetResponseDto
|
||||
*/
|
||||
'owner'?: UserResponseDto;
|
||||
/**
|
||||
*
|
||||
* @type {string}
|
||||
@@ -862,6 +868,68 @@ export interface BulkIdsDto {
|
||||
*/
|
||||
'ids': Array<string>;
|
||||
}
|
||||
/**
|
||||
*
|
||||
* @export
|
||||
* @interface CLIPConfig
|
||||
*/
|
||||
export interface CLIPConfig {
|
||||
/**
|
||||
*
|
||||
* @type {boolean}
|
||||
* @memberof CLIPConfig
|
||||
*/
|
||||
'enabled': boolean;
|
||||
/**
|
||||
*
|
||||
* @type {CLIPMode}
|
||||
* @memberof CLIPConfig
|
||||
*/
|
||||
'mode'?: CLIPMode;
|
||||
/**
|
||||
*
|
||||
* @type {string}
|
||||
* @memberof CLIPConfig
|
||||
*/
|
||||
'modelName': string;
|
||||
/**
|
||||
*
|
||||
* @type {ModelType}
|
||||
* @memberof CLIPConfig
|
||||
*/
|
||||
'modelType'?: ModelType;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
*
|
||||
* @export
|
||||
* @enum {string}
|
||||
*/
|
||||
|
||||
export const CLIPMode = {
|
||||
Vision: 'vision',
|
||||
Text: 'text'
|
||||
} as const;
|
||||
|
||||
export type CLIPMode = typeof CLIPMode[keyof typeof CLIPMode];
|
||||
|
||||
|
||||
/**
|
||||
*
|
||||
* @export
|
||||
* @enum {string}
|
||||
*/
|
||||
|
||||
export const CQMode = {
|
||||
Auto: 'auto',
|
||||
Cqp: 'cqp',
|
||||
Icq: 'icq'
|
||||
} as const;
|
||||
|
||||
export type CQMode = typeof CQMode[keyof typeof CQMode];
|
||||
|
||||
|
||||
/**
|
||||
*
|
||||
* @export
|
||||
@@ -951,6 +1019,53 @@ export interface CheckExistingAssetsResponseDto {
|
||||
*/
|
||||
'existingIds': Array<string>;
|
||||
}
|
||||
/**
|
||||
*
|
||||
* @export
|
||||
* @interface ClassificationConfig
|
||||
*/
|
||||
export interface ClassificationConfig {
|
||||
/**
|
||||
*
|
||||
* @type {boolean}
|
||||
* @memberof ClassificationConfig
|
||||
*/
|
||||
'enabled': boolean;
|
||||
/**
|
||||
*
|
||||
* @type {number}
|
||||
* @memberof ClassificationConfig
|
||||
*/
|
||||
'minScore': number;
|
||||
/**
|
||||
*
|
||||
* @type {string}
|
||||
* @memberof ClassificationConfig
|
||||
*/
|
||||
'modelName': string;
|
||||
/**
|
||||
*
|
||||
* @type {ModelType}
|
||||
* @memberof ClassificationConfig
|
||||
*/
|
||||
'modelType'?: ModelType;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
*
|
||||
* @export
|
||||
* @enum {string}
|
||||
*/
|
||||
|
||||
export const Colorspace = {
|
||||
Srgb: 'srgb',
|
||||
P3: 'p3'
|
||||
} as const;
|
||||
|
||||
export type Colorspace = typeof Colorspace[keyof typeof Colorspace];
|
||||
|
||||
|
||||
/**
|
||||
*
|
||||
* @export
|
||||
@@ -1766,6 +1881,34 @@ export interface MergePersonDto {
|
||||
*/
|
||||
'ids': Array<string>;
|
||||
}
|
||||
/**
|
||||
*
|
||||
* @export
|
||||
* @enum {string}
|
||||
*/
|
||||
|
||||
export const ModelType = {
|
||||
ImageClassification: 'image-classification',
|
||||
FacialRecognition: 'facial-recognition',
|
||||
Clip: 'clip'
|
||||
} as const;
|
||||
|
||||
export type ModelType = typeof ModelType[keyof typeof ModelType];
|
||||
|
||||
|
||||
/**
|
||||
*
|
||||
* @export
|
||||
* @interface OAuthAuthorizeResponseDto
|
||||
*/
|
||||
export interface OAuthAuthorizeResponseDto {
|
||||
/**
|
||||
*
|
||||
* @type {string}
|
||||
* @memberof OAuthAuthorizeResponseDto
|
||||
*/
|
||||
'url': string;
|
||||
}
|
||||
/**
|
||||
*
|
||||
* @export
|
||||
@@ -1874,7 +2017,7 @@ export interface PeopleUpdateDto {
|
||||
*/
|
||||
export interface PeopleUpdateItem {
|
||||
/**
|
||||
* Person date of birth.
|
||||
* Person date of birth. Note: the mobile app cannot currently set the birth date to null.
|
||||
* @type {string}
|
||||
* @memberof PeopleUpdateItem
|
||||
*/
|
||||
@@ -1948,7 +2091,7 @@ export interface PersonResponseDto {
|
||||
*/
|
||||
export interface PersonUpdateDto {
|
||||
/**
|
||||
* Person date of birth.
|
||||
* Person date of birth. Note: the mobile app cannot currently set the birth date to null.
|
||||
* @type {string}
|
||||
* @memberof PersonUpdateDto
|
||||
*/
|
||||
@@ -1991,6 +2134,45 @@ export interface QueueStatusDto {
|
||||
*/
|
||||
'isPaused': boolean;
|
||||
}
|
||||
/**
|
||||
*
|
||||
* @export
|
||||
* @interface RecognitionConfig
|
||||
*/
|
||||
export interface RecognitionConfig {
|
||||
/**
|
||||
*
|
||||
* @type {boolean}
|
||||
* @memberof RecognitionConfig
|
||||
*/
|
||||
'enabled': boolean;
|
||||
/**
|
||||
*
|
||||
* @type {number}
|
||||
* @memberof RecognitionConfig
|
||||
*/
|
||||
'maxDistance': number;
|
||||
/**
|
||||
*
|
||||
* @type {number}
|
||||
* @memberof RecognitionConfig
|
||||
*/
|
||||
'minScore': number;
|
||||
/**
|
||||
*
|
||||
* @type {string}
|
||||
* @memberof RecognitionConfig
|
||||
*/
|
||||
'modelName': string;
|
||||
/**
|
||||
*
|
||||
* @type {ModelType}
|
||||
* @memberof RecognitionConfig
|
||||
*/
|
||||
'modelType'?: ModelType;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
*
|
||||
* @export
|
||||
@@ -2665,24 +2847,54 @@ export interface SystemConfigFFmpegDto {
|
||||
* @memberof SystemConfigFFmpegDto
|
||||
*/
|
||||
'accel': TranscodeHWAccel;
|
||||
/**
|
||||
*
|
||||
* @type {number}
|
||||
* @memberof SystemConfigFFmpegDto
|
||||
*/
|
||||
'bframes': number;
|
||||
/**
|
||||
*
|
||||
* @type {CQMode}
|
||||
* @memberof SystemConfigFFmpegDto
|
||||
*/
|
||||
'cqMode': CQMode;
|
||||
/**
|
||||
*
|
||||
* @type {number}
|
||||
* @memberof SystemConfigFFmpegDto
|
||||
*/
|
||||
'crf': number;
|
||||
/**
|
||||
*
|
||||
* @type {number}
|
||||
* @memberof SystemConfigFFmpegDto
|
||||
*/
|
||||
'gopSize': number;
|
||||
/**
|
||||
*
|
||||
* @type {string}
|
||||
* @memberof SystemConfigFFmpegDto
|
||||
*/
|
||||
'maxBitrate': string;
|
||||
/**
|
||||
*
|
||||
* @type {number}
|
||||
* @memberof SystemConfigFFmpegDto
|
||||
*/
|
||||
'npl': number;
|
||||
/**
|
||||
*
|
||||
* @type {string}
|
||||
* @memberof SystemConfigFFmpegDto
|
||||
*/
|
||||
'preset': string;
|
||||
/**
|
||||
*
|
||||
* @type {number}
|
||||
* @memberof SystemConfigFFmpegDto
|
||||
*/
|
||||
'refs': number;
|
||||
/**
|
||||
*
|
||||
* @type {AudioCodec}
|
||||
@@ -2701,6 +2913,12 @@ export interface SystemConfigFFmpegDto {
|
||||
* @memberof SystemConfigFFmpegDto
|
||||
*/
|
||||
'targetVideoCodec': VideoCodec;
|
||||
/**
|
||||
*
|
||||
* @type {boolean}
|
||||
* @memberof SystemConfigFFmpegDto
|
||||
*/
|
||||
'temporalAQ': boolean;
|
||||
/**
|
||||
*
|
||||
* @type {number}
|
||||
@@ -2803,10 +3021,16 @@ export interface SystemConfigJobDto {
|
||||
export interface SystemConfigMachineLearningDto {
|
||||
/**
|
||||
*
|
||||
* @type {boolean}
|
||||
* @type {ClassificationConfig}
|
||||
* @memberof SystemConfigMachineLearningDto
|
||||
*/
|
||||
'clipEncodeEnabled': boolean;
|
||||
'classification': ClassificationConfig;
|
||||
/**
|
||||
*
|
||||
* @type {CLIPConfig}
|
||||
* @memberof SystemConfigMachineLearningDto
|
||||
*/
|
||||
'clip': CLIPConfig;
|
||||
/**
|
||||
*
|
||||
* @type {boolean}
|
||||
@@ -2815,16 +3039,10 @@ export interface SystemConfigMachineLearningDto {
|
||||
'enabled': boolean;
|
||||
/**
|
||||
*
|
||||
* @type {boolean}
|
||||
* @type {RecognitionConfig}
|
||||
* @memberof SystemConfigMachineLearningDto
|
||||
*/
|
||||
'facialRecognitionEnabled': boolean;
|
||||
/**
|
||||
*
|
||||
* @type {boolean}
|
||||
* @memberof SystemConfigMachineLearningDto
|
||||
*/
|
||||
'tagImageEnabled': boolean;
|
||||
'facialRecognition': RecognitionConfig;
|
||||
/**
|
||||
*
|
||||
* @type {string}
|
||||
@@ -2986,12 +3204,24 @@ export interface SystemConfigTemplateStorageOptionDto {
|
||||
* @interface SystemConfigThumbnailDto
|
||||
*/
|
||||
export interface SystemConfigThumbnailDto {
|
||||
/**
|
||||
*
|
||||
* @type {Colorspace}
|
||||
* @memberof SystemConfigThumbnailDto
|
||||
*/
|
||||
'colorspace': Colorspace;
|
||||
/**
|
||||
*
|
||||
* @type {number}
|
||||
* @memberof SystemConfigThumbnailDto
|
||||
*/
|
||||
'jpegSize': number;
|
||||
/**
|
||||
*
|
||||
* @type {number}
|
||||
* @memberof SystemConfigThumbnailDto
|
||||
*/
|
||||
'quality': number;
|
||||
/**
|
||||
*
|
||||
* @type {number}
|
||||
@@ -2999,6 +3229,8 @@ export interface SystemConfigThumbnailDto {
|
||||
*/
|
||||
'webpSize': number;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
*
|
||||
* @export
|
||||
@@ -3191,12 +3423,6 @@ export interface UpdateAssetDto {
|
||||
* @memberof UpdateAssetDto
|
||||
*/
|
||||
'isFavorite'?: boolean;
|
||||
/**
|
||||
*
|
||||
* @type {Array<string>}
|
||||
* @memberof UpdateAssetDto
|
||||
*/
|
||||
'tagIds'?: Array<string>;
|
||||
}
|
||||
/**
|
||||
*
|
||||
@@ -6073,7 +6299,7 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
|
||||
};
|
||||
},
|
||||
/**
|
||||
* Update an asset
|
||||
*
|
||||
* @param {string} id
|
||||
* @param {UpdateAssetDto} updateAssetDto
|
||||
* @param {*} [options] Override http request option.
|
||||
@@ -6552,7 +6778,7 @@ export const AssetApiFp = function(configuration?: Configuration) {
|
||||
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
|
||||
},
|
||||
/**
|
||||
* Update an asset
|
||||
*
|
||||
* @param {string} id
|
||||
* @param {UpdateAssetDto} updateAssetDto
|
||||
* @param {*} [options] Override http request option.
|
||||
@@ -6809,7 +7035,7 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath
|
||||
return localVarFp.serveFile(requestParameters.id, requestParameters.isThumb, requestParameters.isWeb, requestParameters.key, options).then((request) => request(axios, basePath));
|
||||
},
|
||||
/**
|
||||
* Update an asset
|
||||
*
|
||||
* @param {AssetApiUpdateAssetRequest} requestParameters Request parameters.
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
@@ -7726,7 +7952,7 @@ export class AssetApi extends BaseAPI {
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an asset
|
||||
*
|
||||
* @param {AssetApiUpdateAssetRequest} requestParameters Request parameters.
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
@@ -8756,6 +8982,41 @@ export class JobApi extends BaseAPI {
|
||||
*/
|
||||
export const OAuthApiAxiosParamCreator = function (configuration?: Configuration) {
|
||||
return {
|
||||
/**
|
||||
*
|
||||
* @param {OAuthConfigDto} oAuthConfigDto
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
authorizeOAuth: async (oAuthConfigDto: OAuthConfigDto, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
|
||||
// verify required parameter 'oAuthConfigDto' is not null or undefined
|
||||
assertParamExists('authorizeOAuth', 'oAuthConfigDto', oAuthConfigDto)
|
||||
const localVarPath = `/oauth/authorize`;
|
||||
// use dummy base URL string because the URL constructor only accepts absolute URLs.
|
||||
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
|
||||
let baseOptions;
|
||||
if (configuration) {
|
||||
baseOptions = configuration.baseOptions;
|
||||
}
|
||||
|
||||
const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options};
|
||||
const localVarHeaderParameter = {} as any;
|
||||
const localVarQueryParameter = {} as any;
|
||||
|
||||
|
||||
|
||||
localVarHeaderParameter['Content-Type'] = 'application/json';
|
||||
|
||||
setSearchParams(localVarUrlObj, localVarQueryParameter);
|
||||
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
|
||||
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
|
||||
localVarRequestOptions.data = serializeDataIfNeeded(oAuthConfigDto, localVarRequestOptions, configuration)
|
||||
|
||||
return {
|
||||
url: toPathString(localVarUrlObj),
|
||||
options: localVarRequestOptions,
|
||||
};
|
||||
},
|
||||
/**
|
||||
*
|
||||
* @param {OAuthCallbackDto} oAuthCallbackDto
|
||||
@@ -8792,9 +9053,10 @@ export const OAuthApiAxiosParamCreator = function (configuration?: Configuration
|
||||
};
|
||||
},
|
||||
/**
|
||||
*
|
||||
* @deprecated use feature flags and /oauth/authorize
|
||||
* @param {OAuthConfigDto} oAuthConfigDto
|
||||
* @param {*} [options] Override http request option.
|
||||
* @deprecated
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
generateConfig: async (oAuthConfigDto: OAuthConfigDto, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
|
||||
@@ -8947,6 +9209,16 @@ export const OAuthApiAxiosParamCreator = function (configuration?: Configuration
|
||||
export const OAuthApiFp = function(configuration?: Configuration) {
|
||||
const localVarAxiosParamCreator = OAuthApiAxiosParamCreator(configuration)
|
||||
return {
|
||||
/**
|
||||
*
|
||||
* @param {OAuthConfigDto} oAuthConfigDto
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
async authorizeOAuth(oAuthConfigDto: OAuthConfigDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<OAuthAuthorizeResponseDto>> {
|
||||
const localVarAxiosArgs = await localVarAxiosParamCreator.authorizeOAuth(oAuthConfigDto, options);
|
||||
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
|
||||
},
|
||||
/**
|
||||
*
|
||||
* @param {OAuthCallbackDto} oAuthCallbackDto
|
||||
@@ -8958,9 +9230,10 @@ export const OAuthApiFp = function(configuration?: Configuration) {
|
||||
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
|
||||
},
|
||||
/**
|
||||
*
|
||||
* @deprecated use feature flags and /oauth/authorize
|
||||
* @param {OAuthConfigDto} oAuthConfigDto
|
||||
* @param {*} [options] Override http request option.
|
||||
* @deprecated
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
async generateConfig(oAuthConfigDto: OAuthConfigDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<OAuthConfigResponseDto>> {
|
||||
@@ -9005,6 +9278,15 @@ export const OAuthApiFp = function(configuration?: Configuration) {
|
||||
export const OAuthApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) {
|
||||
const localVarFp = OAuthApiFp(configuration)
|
||||
return {
|
||||
/**
|
||||
*
|
||||
* @param {OAuthApiAuthorizeOAuthRequest} requestParameters Request parameters.
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
authorizeOAuth(requestParameters: OAuthApiAuthorizeOAuthRequest, options?: AxiosRequestConfig): AxiosPromise<OAuthAuthorizeResponseDto> {
|
||||
return localVarFp.authorizeOAuth(requestParameters.oAuthConfigDto, options).then((request) => request(axios, basePath));
|
||||
},
|
||||
/**
|
||||
*
|
||||
* @param {OAuthApiCallbackRequest} requestParameters Request parameters.
|
||||
@@ -9015,9 +9297,10 @@ export const OAuthApiFactory = function (configuration?: Configuration, basePath
|
||||
return localVarFp.callback(requestParameters.oAuthCallbackDto, options).then((request) => request(axios, basePath));
|
||||
},
|
||||
/**
|
||||
*
|
||||
* @deprecated use feature flags and /oauth/authorize
|
||||
* @param {OAuthApiGenerateConfigRequest} requestParameters Request parameters.
|
||||
* @param {*} [options] Override http request option.
|
||||
* @deprecated
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
generateConfig(requestParameters: OAuthApiGenerateConfigRequest, options?: AxiosRequestConfig): AxiosPromise<OAuthConfigResponseDto> {
|
||||
@@ -9051,6 +9334,20 @@ export const OAuthApiFactory = function (configuration?: Configuration, basePath
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Request parameters for authorizeOAuth operation in OAuthApi.
|
||||
* @export
|
||||
* @interface OAuthApiAuthorizeOAuthRequest
|
||||
*/
|
||||
export interface OAuthApiAuthorizeOAuthRequest {
|
||||
/**
|
||||
*
|
||||
* @type {OAuthConfigDto}
|
||||
* @memberof OAuthApiAuthorizeOAuth
|
||||
*/
|
||||
readonly oAuthConfigDto: OAuthConfigDto
|
||||
}
|
||||
|
||||
/**
|
||||
* Request parameters for callback operation in OAuthApi.
|
||||
* @export
|
||||
@@ -9100,6 +9397,17 @@ export interface OAuthApiLinkRequest {
|
||||
* @extends {BaseAPI}
|
||||
*/
|
||||
export class OAuthApi extends BaseAPI {
|
||||
/**
|
||||
*
|
||||
* @param {OAuthApiAuthorizeOAuthRequest} requestParameters Request parameters.
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
* @memberof OAuthApi
|
||||
*/
|
||||
public authorizeOAuth(requestParameters: OAuthApiAuthorizeOAuthRequest, options?: AxiosRequestConfig) {
|
||||
return OAuthApiFp(this.configuration).authorizeOAuth(requestParameters.oAuthConfigDto, options).then((request) => request(this.axios, this.basePath));
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {OAuthApiCallbackRequest} requestParameters Request parameters.
|
||||
@@ -9112,9 +9420,10 @@ export class OAuthApi extends BaseAPI {
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @deprecated use feature flags and /oauth/authorize
|
||||
* @param {OAuthApiGenerateConfigRequest} requestParameters Request parameters.
|
||||
* @param {*} [options] Override http request option.
|
||||
* @deprecated
|
||||
* @throws {RequiredError}
|
||||
* @memberof OAuthApi
|
||||
*/
|
||||
|
||||
2
cli/src/api/open-api/base.ts
generated
2
cli/src/api/open-api/base.ts
generated
@@ -4,7 +4,7 @@
|
||||
* Immich
|
||||
* Immich API
|
||||
*
|
||||
* The version of the OpenAPI document: 1.75.1
|
||||
* The version of the OpenAPI document: 1.77.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
||||
2
cli/src/api/open-api/common.ts
generated
2
cli/src/api/open-api/common.ts
generated
@@ -4,7 +4,7 @@
|
||||
* Immich
|
||||
* Immich API
|
||||
*
|
||||
* The version of the OpenAPI document: 1.75.1
|
||||
* The version of the OpenAPI document: 1.77.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
||||
2
cli/src/api/open-api/configuration.ts
generated
2
cli/src/api/open-api/configuration.ts
generated
@@ -4,7 +4,7 @@
|
||||
* Immich
|
||||
* Immich API
|
||||
*
|
||||
* The version of the OpenAPI document: 1.75.1
|
||||
* The version of the OpenAPI document: 1.77.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
||||
2
cli/src/api/open-api/index.ts
generated
2
cli/src/api/open-api/index.ts
generated
@@ -4,7 +4,7 @@
|
||||
* Immich
|
||||
* Immich API
|
||||
*
|
||||
* The version of the OpenAPI document: 1.75.1
|
||||
* The version of the OpenAPI document: 1.77.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
||||
@@ -34,7 +34,7 @@ services:
|
||||
ports:
|
||||
- 3003:3003
|
||||
volumes:
|
||||
- ../machine-learning/app:/usr/src/app
|
||||
- ../machine-learning:/usr/src/app
|
||||
- model-cache:/cache
|
||||
env_file:
|
||||
- .env
|
||||
|
||||
@@ -68,16 +68,16 @@ Be aware that as this runs inside a container, you need to mount the folder from
|
||||
|
||||
```bash title="Upload current directory"
|
||||
cd /DIRECTORY/WITH/IMAGES
|
||||
docker run -it --rm -v "$(pwd):/import" ghcr.io/immich-app/immich-cli:latest upload --key HFEJ38DNSDUEG --server http://192.168.1.216:2283/api
|
||||
docker run -it --rm -v "$(pwd):/import" ghcr.io/immich-app/immich-cli:latest upload --recursive --key HFEJ38DNSDUEG --server http://192.168.1.216:2283/api
|
||||
```
|
||||
|
||||
```bash title="Upload target directory"
|
||||
docker run -it --rm -v "/DIRECTORY/WITH/IMAGES:/import" ghcr.io/immich-app/immich-cli:latest upload --key HFEJ38DNSDUEG --server http://192.168.1.216:2283/api
|
||||
docker run -it --rm -v "/DIRECTORY/WITH/IMAGES:/import" ghcr.io/immich-app/immich-cli:latest upload --recursive --key HFEJ38DNSDUEG --server http://192.168.1.216:2283/api
|
||||
```
|
||||
|
||||
```bash title="Create an alias"
|
||||
alias immich='docker run -it --rm -v "$(pwd):/import" ghcr.io/immich-app/immich-cli:latest'
|
||||
immich upload --key HFEJ38DNSDUEG --server http://192.168.1.216:2283/api
|
||||
immich upload --recursive --key HFEJ38DNSDUEG --server http://192.168.1.216:2283/api
|
||||
```
|
||||
|
||||
:::tip Internal networking
|
||||
@@ -88,7 +88,7 @@ If you are running the CLI container on the same machine as your Immich server,
|
||||
3. Use `--server http://immich-server:3001` for the upload command instead of the external address.
|
||||
|
||||
```bash title="Upload to internal address"
|
||||
docker run --network immich_default -it --rm -v "$(pwd):/import" ghcr.io/immich-app/immich-cli:latest upload --key HFEJ38DNSDUEG --server http://immich-server:3001
|
||||
docker run --network immich_default -it --rm -v "$(pwd):/import" ghcr.io/immich-app/immich-cli:latest upload --recursive --key HFEJ38DNSDUEG --server http://immich-server:3001
|
||||
```
|
||||
|
||||
:::
|
||||
|
||||
@@ -46,23 +46,22 @@ These environment variables are used by the `docker-compose.yml` file and do **N
|
||||
|
||||
## Ports
|
||||
|
||||
| Variable | Description | Default | Services |
|
||||
| :---------------------- | :-------------------- | :-----: | :--------------- |
|
||||
| `PORT` | Web Port | `3000` | web |
|
||||
| `SERVER_PORT` | Server Port | `3001` | server |
|
||||
| `MICROSERVICES_PORT` | Microservices Port | `3002` | microservices |
|
||||
| `MACHINE_LEARNING_PORT` | Machine Learning Port | `3003` | machine learning |
|
||||
| Variable | Description | Default | Services |
|
||||
| :---------------------- | :-------------------- | :-------: | :--------------- |
|
||||
| `PORT` | Web Port | `3000` | web |
|
||||
| `SERVER_PORT` | Server Port | `3001` | server |
|
||||
| `MICROSERVICES_PORT` | Microservices Port | `3002` | microservices |
|
||||
| `MACHINE_LEARNING_HOST` | Machine Learning Host | `0.0.0.0` | machine learning |
|
||||
| `MACHINE_LEARNING_PORT` | Machine Learning Port | `3003` | machine learning |
|
||||
|
||||
## URLs
|
||||
|
||||
| Variable | Description | Default | Services |
|
||||
| :-------------------------------- | :--------------------------- | :-----------------------------------: | :-------------------- |
|
||||
| `IMMICH_WEB_URL` | Immich Web URL | `http://immich-web:3000` | proxy |
|
||||
| `IMMICH_SERVER_URL` | Immich Server URL | `http://immich-server:3001` | web, proxy |
|
||||
| `IMMICH_MACHINE_LEARNING_ENABLED` | Enabled machine learning | `true` | server, microservices |
|
||||
| `IMMICH_MACHINE_LEARNING_URL` | Immich Machine Learning URL, | `http://immich-machine-learning:3003` | server, microservices |
|
||||
| `PUBLIC_IMMICH_SERVER_URL` | Public Immich URL | `http://immich-server:3001` | web |
|
||||
| `IMMICH_API_URL_EXTERNAL` | Immich API URL External | `/api` | web |
|
||||
| Variable | Description | Default | Services |
|
||||
| :------------------------- | :---------------------- | :-------------------------: | :--------- |
|
||||
| `IMMICH_WEB_URL` | Immich Web URL | `http://immich-web:3000` | proxy |
|
||||
| `IMMICH_SERVER_URL` | Immich Server URL | `http://immich-server:3001` | web, proxy |
|
||||
| `PUBLIC_IMMICH_SERVER_URL` | Public Immich URL | `http://immich-server:3001` | web |
|
||||
| `IMMICH_API_URL_EXTERNAL` | Immich API URL External | `/api` | web |
|
||||
|
||||
:::info
|
||||
|
||||
@@ -178,18 +177,21 @@ Typesense URL example JSON before encoding:
|
||||
|
||||
## Machine Learning
|
||||
|
||||
| Variable | Description | Default | Services |
|
||||
| :------------------------------------------ | :----------------------------- | :-------------------: | :--------------- |
|
||||
| `MACHINE_LEARNING_MIN_FACE_SCORE` | Minimum Face Score | `0.7` | machine learning |
|
||||
| `MACHINE_LEARNING_MODEL_TTL` | Model TTL | `300` | machine learning |
|
||||
| `MACHINE_LEARNING_EAGER_STARTUP` | Eager Startup | `true` | machine learning |
|
||||
| `MACHINE_LEARNING_MIN_TAG_SCORE` | Minimum Tag Score | `0.9` | machine learning |
|
||||
| `MACHINE_LEARNING_FACIAL_RECOGNITION_MODEL` | Facial Recognition Model | `buffalo_l` | machine learning |
|
||||
| `MACHINE_LEARNING_CLIP_TEXT_MODEL` | Clip Text Model | `clip-ViT-B-32` | machine learning |
|
||||
| `MACHINE_LEARNING_CLIP_IMAGE_MODEL` | Clip Image Model | `clip-ViT-B-32` | machine learning |
|
||||
| `MACHINE_LEARNING_CLASSIFICATION_MODEL` | Classification Model | `microsoft/resnet-50` | machine learning |
|
||||
| `MACHINE_LEARNING_CACHE_FOLDER` | ML Cache Location | `/cache` | machine learning |
|
||||
| `TRANSFORMERS_CACHE` | ML Transformers Cache Location | `/cache` | machine learning |
|
||||
| Variable | Description | Default | Services |
|
||||
| :----------------------------------------------- | :----------------------------------------- | :-----------------: | :--------------- |
|
||||
| `MACHINE_LEARNING_MODEL_TTL` | Model TTL | `300` | machine learning |
|
||||
| `MACHINE_LEARNING_CACHE_FOLDER` | ML Cache Location | `/cache` | machine learning |
|
||||
| `MACHINE_LEARNING_REQUEST_THREADS`<sup>\*1</sup> | Request thread pool size | number of CPU cores | machine learning |
|
||||
| `MACHINE_LEARNING_MODEL_INTER_OP_THREADS` | Number of parallel model operations | `1` | machine learning |
|
||||
| `MACHINE_LEARNING_MODEL_INTRA_OP_THREADS` | Number of threads for each model operation | `2` | machine learning |
|
||||
|
||||
\*1: It is recommended to begin with this parameter when changing the concurrency levels of the machine learning service and then tune the other ones.
|
||||
|
||||
:::info
|
||||
|
||||
Other machine learning parameters can be tuned from the admin UI.
|
||||
|
||||
:::
|
||||
|
||||
## Docker Secrets
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM python:3.11.4-bullseye@sha256:5b401676aff858495a5c9c726c60b8b73fe52833e9e16eccdb59e93d52741727 as builder
|
||||
FROM python:3.11-bookworm as builder
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
@@ -14,9 +14,9 @@ COPY poetry.lock pyproject.toml requirements.txt ./
|
||||
RUN poetry install --sync --no-interaction --no-ansi --no-root --only main
|
||||
RUN pip install --no-deps -r requirements.txt
|
||||
|
||||
FROM python:3.11.4-slim-bullseye@sha256:91d194f58f50594cda71dcd2e8fdefd90e7ecc57d07823813b67c8521e565dcd
|
||||
FROM python:3.11-slim-bookworm
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends tini && rm -rf /var/lib/apt/lists/*
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends tini libmimalloc2.0 && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
ENV NODE_ENV=production \
|
||||
@@ -27,6 +27,7 @@ ENV NODE_ENV=production \
|
||||
PYTHONPATH=/usr/src
|
||||
|
||||
COPY --from=builder /opt/venv /opt/venv
|
||||
COPY start.sh log_conf.json ./
|
||||
COPY app .
|
||||
ENTRYPOINT ["tini", "--"]
|
||||
CMD ["python", "-m", "app.main"]
|
||||
CMD ["./start.sh"]
|
||||
|
||||
@@ -1,24 +1,22 @@
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import gunicorn
|
||||
import starlette
|
||||
from pydantic import BaseSettings
|
||||
from rich.console import Console
|
||||
from rich.logging import RichHandler
|
||||
|
||||
from .schemas import ModelType
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
cache_folder: str = "/cache"
|
||||
classification_model: str = "microsoft/resnet-50"
|
||||
clip_image_model: str = "ViT-B-32::openai"
|
||||
clip_text_model: str = "ViT-B-32::openai"
|
||||
facial_recognition_model: str = "buffalo_l"
|
||||
min_tag_score: float = 0.9
|
||||
eager_startup: bool = False
|
||||
model_ttl: int = 0
|
||||
host: str = "0.0.0.0"
|
||||
port: int = 3003
|
||||
workers: int = 1
|
||||
min_face_score: float = 0.7
|
||||
test_full: bool = False
|
||||
request_threads: int = os.cpu_count() or 4
|
||||
model_inter_op_threads: int = 1
|
||||
@@ -29,6 +27,14 @@ class Settings(BaseSettings):
|
||||
case_sensitive = False
|
||||
|
||||
|
||||
class LogSettings(BaseSettings):
|
||||
log_level: str = "info"
|
||||
no_color: bool = False
|
||||
|
||||
class Config:
|
||||
case_sensitive = False
|
||||
|
||||
|
||||
_clean_name = str.maketrans(":\\/", "___", ".")
|
||||
|
||||
|
||||
@@ -36,4 +42,28 @@ def get_cache_dir(model_name: str, model_type: ModelType) -> Path:
|
||||
return Path(settings.cache_folder) / model_type.value / model_name.translate(_clean_name)
|
||||
|
||||
|
||||
LOG_LEVELS: dict[str, int] = {
|
||||
"critical": logging.ERROR,
|
||||
"error": logging.ERROR,
|
||||
"warning": logging.WARNING,
|
||||
"warn": logging.WARNING,
|
||||
"info": logging.INFO,
|
||||
"log": logging.INFO,
|
||||
"debug": logging.DEBUG,
|
||||
"verbose": logging.DEBUG,
|
||||
}
|
||||
|
||||
settings = Settings()
|
||||
log_settings = LogSettings()
|
||||
|
||||
|
||||
class CustomRichHandler(RichHandler):
|
||||
def __init__(self) -> None:
|
||||
console = Console(color_system="standard", no_color=log_settings.no_color)
|
||||
super().__init__(
|
||||
show_path=False, omit_repeated_times=False, console=console, tracebacks_suppress=[gunicorn, starlette]
|
||||
)
|
||||
|
||||
|
||||
log = logging.getLogger("gunicorn.access")
|
||||
log.setLevel(LOG_LEVELS.get(log_settings.log_level.lower(), logging.INFO))
|
||||
|
||||
@@ -1,69 +1,42 @@
|
||||
import asyncio
|
||||
import os
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from io import BytesIO
|
||||
from typing import Any
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
import uvicorn
|
||||
from fastapi import Body, Depends, FastAPI
|
||||
from PIL import Image
|
||||
import orjson
|
||||
from fastapi import FastAPI, Form, HTTPException, UploadFile
|
||||
from fastapi.responses import ORJSONResponse
|
||||
from starlette.formparsers import MultiPartParser
|
||||
|
||||
from app.models.base import InferenceModel
|
||||
|
||||
from .config import settings
|
||||
from .config import log, settings
|
||||
from .models.cache import ModelCache
|
||||
from .schemas import (
|
||||
EmbeddingResponse,
|
||||
FaceResponse,
|
||||
MessageResponse,
|
||||
ModelType,
|
||||
TagResponse,
|
||||
TextModelRequest,
|
||||
TextResponse,
|
||||
)
|
||||
|
||||
MultiPartParser.max_file_size = 2**24 # spools to disk if payload is 16 MiB or larger
|
||||
app = FastAPI()
|
||||
|
||||
|
||||
def init_state() -> None:
|
||||
app.state.model_cache = ModelCache(ttl=settings.model_ttl, revalidate=settings.model_ttl > 0)
|
||||
log.info(
|
||||
(
|
||||
"Created in-memory cache with unloading "
|
||||
f"{f'after {settings.model_ttl}s of inactivity' if settings.model_ttl > 0 else 'disabled'}."
|
||||
)
|
||||
)
|
||||
# asyncio is a huge bottleneck for performance, so we use a thread pool to run blocking code
|
||||
app.state.thread_pool = ThreadPoolExecutor(settings.request_threads)
|
||||
|
||||
|
||||
async def load_models() -> None:
|
||||
models: list[tuple[str, ModelType, dict[str, Any]]] = [
|
||||
(settings.classification_model, ModelType.IMAGE_CLASSIFICATION, {}),
|
||||
(settings.clip_image_model, ModelType.CLIP, {"mode": "vision"}),
|
||||
(settings.clip_text_model, ModelType.CLIP, {"mode": "text"}),
|
||||
(settings.facial_recognition_model, ModelType.FACIAL_RECOGNITION, {}),
|
||||
]
|
||||
|
||||
# Get all models
|
||||
for model_name, model_type, model_kwargs in models:
|
||||
await app.state.model_cache.get(model_name, model_type, eager=settings.eager_startup, **model_kwargs)
|
||||
app.state.thread_pool = ThreadPoolExecutor(settings.request_threads) if settings.request_threads > 0 else None
|
||||
log.info(f"Initialized request thread pool with {settings.request_threads} threads.")
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup_event() -> None:
|
||||
init_state()
|
||||
await load_models()
|
||||
|
||||
|
||||
@app.on_event("shutdown")
|
||||
async def shutdown_event() -> None:
|
||||
app.state.thread_pool.shutdown()
|
||||
|
||||
|
||||
def dep_pil_image(byte_image: bytes = Body(...)) -> Image.Image:
|
||||
return Image.open(BytesIO(byte_image))
|
||||
|
||||
|
||||
def dep_cv_image(byte_image: bytes = Body(...)) -> np.ndarray[int, np.dtype[Any]]:
|
||||
byte_image_np = np.frombuffer(byte_image, np.uint8)
|
||||
return cv2.imdecode(byte_image_np, cv2.IMREAD_COLOR)
|
||||
|
||||
|
||||
@app.get("/", response_model=MessageResponse)
|
||||
@@ -76,66 +49,28 @@ def ping() -> str:
|
||||
return "pong"
|
||||
|
||||
|
||||
@app.post(
|
||||
"/image-classifier/tag-image",
|
||||
response_model=TagResponse,
|
||||
status_code=200,
|
||||
)
|
||||
async def image_classification(
|
||||
image: Image.Image = Depends(dep_pil_image),
|
||||
) -> list[str]:
|
||||
model = await app.state.model_cache.get(settings.classification_model, ModelType.IMAGE_CLASSIFICATION)
|
||||
labels = await predict(model, image)
|
||||
return labels
|
||||
@app.post("/predict")
|
||||
async def predict(
|
||||
model_name: str = Form(alias="modelName"),
|
||||
model_type: ModelType = Form(alias="modelType"),
|
||||
options: str = Form(default="{}"),
|
||||
text: str | None = Form(default=None),
|
||||
image: UploadFile | None = None,
|
||||
) -> Any:
|
||||
if image is not None:
|
||||
inputs: str | bytes = await image.read()
|
||||
elif text is not None:
|
||||
inputs = text
|
||||
else:
|
||||
raise HTTPException(400, "Either image or text must be provided")
|
||||
|
||||
model: InferenceModel = await app.state.model_cache.get(model_name, model_type, **orjson.loads(options))
|
||||
outputs = await run(model, inputs)
|
||||
return ORJSONResponse(outputs)
|
||||
|
||||
|
||||
@app.post(
|
||||
"/sentence-transformer/encode-image",
|
||||
response_model=EmbeddingResponse,
|
||||
status_code=200,
|
||||
)
|
||||
async def clip_encode_image(
|
||||
image: Image.Image = Depends(dep_pil_image),
|
||||
) -> list[float]:
|
||||
model = await app.state.model_cache.get(settings.clip_image_model, ModelType.CLIP, mode="vision")
|
||||
embedding = await predict(model, image)
|
||||
return embedding
|
||||
|
||||
|
||||
@app.post(
|
||||
"/sentence-transformer/encode-text",
|
||||
response_model=EmbeddingResponse,
|
||||
status_code=200,
|
||||
)
|
||||
async def clip_encode_text(payload: TextModelRequest) -> list[float]:
|
||||
model = await app.state.model_cache.get(settings.clip_text_model, ModelType.CLIP, mode="text")
|
||||
embedding = await predict(model, payload.text)
|
||||
return embedding
|
||||
|
||||
|
||||
@app.post(
|
||||
"/facial-recognition/detect-faces",
|
||||
response_model=FaceResponse,
|
||||
status_code=200,
|
||||
)
|
||||
async def facial_recognition(
|
||||
image: cv2.Mat = Depends(dep_cv_image),
|
||||
) -> list[dict[str, Any]]:
|
||||
model = await app.state.model_cache.get(settings.facial_recognition_model, ModelType.FACIAL_RECOGNITION)
|
||||
faces = await predict(model, image)
|
||||
return faces
|
||||
|
||||
|
||||
async def predict(model: InferenceModel, inputs: Any) -> Any:
|
||||
return await asyncio.get_running_loop().run_in_executor(app.state.thread_pool, model.predict, inputs)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
is_dev = os.getenv("NODE_ENV") == "development"
|
||||
uvicorn.run(
|
||||
"app.main:app",
|
||||
host=settings.host,
|
||||
port=settings.port,
|
||||
reload=is_dev,
|
||||
workers=settings.workers,
|
||||
)
|
||||
async def run(model: InferenceModel, inputs: Any) -> Any:
|
||||
if app.state.thread_pool is not None:
|
||||
return await asyncio.get_running_loop().run_in_executor(app.state.thread_pool, model.predict, inputs)
|
||||
else:
|
||||
return model.predict(inputs)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import pickle
|
||||
from abc import ABC, abstractmethod
|
||||
from pathlib import Path
|
||||
@@ -9,9 +8,9 @@ from typing import Any
|
||||
from zipfile import BadZipFile
|
||||
|
||||
import onnxruntime as ort
|
||||
from onnxruntime.capi.onnxruntime_pybind11_state import InvalidProtobuf # type: ignore
|
||||
from onnxruntime.capi.onnxruntime_pybind11_state import InvalidProtobuf, NoSuchFile # type: ignore
|
||||
|
||||
from ..config import get_cache_dir, settings
|
||||
from ..config import get_cache_dir, log, settings
|
||||
from ..schemas import ModelType
|
||||
|
||||
|
||||
@@ -37,22 +36,42 @@ class InferenceModel(ABC):
|
||||
self.provider_options = model_kwargs.pop(
|
||||
"provider_options", [{"arena_extend_strategy": "kSameAsRequested"}] * len(self.providers)
|
||||
)
|
||||
log.debug(
|
||||
(
|
||||
f"Setting '{self.model_name}' execution providers to {self.providers}"
|
||||
"in descending order of preference"
|
||||
),
|
||||
)
|
||||
log.debug(f"Setting execution provider options to {self.provider_options}")
|
||||
self.sess_options = PicklableSessionOptions()
|
||||
# avoid thread contention between models
|
||||
if inter_op_num_threads > 1:
|
||||
self.sess_options.execution_mode = ort.ExecutionMode.ORT_PARALLEL
|
||||
|
||||
log.debug(f"Setting execution_mode to {self.sess_options.execution_mode.name}")
|
||||
log.debug(f"Setting inter_op_num_threads to {inter_op_num_threads}")
|
||||
log.debug(f"Setting intra_op_num_threads to {intra_op_num_threads}")
|
||||
self.sess_options.inter_op_num_threads = inter_op_num_threads
|
||||
self.sess_options.intra_op_num_threads = intra_op_num_threads
|
||||
self.sess_options.enable_cpu_mem_arena = False
|
||||
|
||||
try:
|
||||
loader(**model_kwargs)
|
||||
except (OSError, InvalidProtobuf, BadZipFile):
|
||||
except (OSError, InvalidProtobuf, BadZipFile, NoSuchFile):
|
||||
log.warn(
|
||||
(
|
||||
f"Failed to load {self.model_type.replace('_', ' ')} model '{self.model_name}'."
|
||||
"Clearing cache and retrying."
|
||||
)
|
||||
)
|
||||
self.clear_cache()
|
||||
loader(**model_kwargs)
|
||||
|
||||
def download(self, **model_kwargs: Any) -> None:
|
||||
if not self.cached:
|
||||
print(f"Downloading {self.model_type.value.replace('_', ' ')} model. This may take a while...")
|
||||
log.info(
|
||||
(f"Downloading {self.model_type.replace('_', ' ')} model '{self.model_name}'." "This may take a while.")
|
||||
)
|
||||
self._download(**model_kwargs)
|
||||
|
||||
def load(self, **model_kwargs: Any) -> None:
|
||||
@@ -60,16 +79,21 @@ class InferenceModel(ABC):
|
||||
self._load(**model_kwargs)
|
||||
self._loaded = True
|
||||
|
||||
def predict(self, inputs: Any) -> Any:
|
||||
def predict(self, inputs: Any, **model_kwargs: Any) -> Any:
|
||||
if not self._loaded:
|
||||
print(f"Loading {self.model_type.value.replace('_', ' ')} model...")
|
||||
log.info(f"Loading {self.model_type.replace('_', ' ')} model '{self.model_name}'")
|
||||
self.load()
|
||||
if model_kwargs:
|
||||
self.configure(**model_kwargs)
|
||||
return self._predict(inputs)
|
||||
|
||||
@abstractmethod
|
||||
def _predict(self, inputs: Any) -> Any:
|
||||
...
|
||||
|
||||
def configure(self, **model_kwargs: Any) -> None:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def _download(self, **model_kwargs: Any) -> None:
|
||||
...
|
||||
@@ -104,13 +128,23 @@ class InferenceModel(ABC):
|
||||
|
||||
def clear_cache(self) -> None:
|
||||
if not self.cache_dir.exists():
|
||||
log.warn(
|
||||
f"Attempted to clear cache for model '{self.model_name}' but cache directory does not exist.",
|
||||
)
|
||||
return
|
||||
if not rmtree.avoids_symlink_attacks:
|
||||
raise RuntimeError("Attempted to clear cache, but rmtree is not safe on this platform.")
|
||||
|
||||
if self.cache_dir.is_dir():
|
||||
log.info(f"Cleared cache directory for model '{self.model_name}'.")
|
||||
rmtree(self.cache_dir)
|
||||
else:
|
||||
log.warn(
|
||||
(
|
||||
f"Encountered file instead of directory at cache path "
|
||||
f"for '{self.model_name}'. Removing file and replacing with a directory."
|
||||
),
|
||||
)
|
||||
self.cache_dir.unlink()
|
||||
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import os
|
||||
import zipfile
|
||||
from io import BytesIO
|
||||
from typing import Any, Literal
|
||||
|
||||
import onnxruntime as ort
|
||||
@@ -8,9 +9,10 @@ from clip_server.model.clip import BICUBIC, _convert_image_to_rgb
|
||||
from clip_server.model.clip_onnx import _MODELS, _S3_BUCKET_V2, CLIPOnnxModel, download_model
|
||||
from clip_server.model.pretrained_models import _VISUAL_MODEL_IMAGE_SIZE
|
||||
from clip_server.model.tokenization import Tokenizer
|
||||
from PIL.Image import Image
|
||||
from PIL import Image
|
||||
from torchvision.transforms import CenterCrop, Compose, Normalize, Resize, ToTensor
|
||||
|
||||
from ..config import log
|
||||
from ..schemas import ModelType
|
||||
from .base import InferenceModel
|
||||
|
||||
@@ -74,9 +76,12 @@ class CLIPEncoder(InferenceModel):
|
||||
image_size = _VISUAL_MODEL_IMAGE_SIZE[CLIPOnnxModel.get_model_name(self.model_name)]
|
||||
self.transform = _transform_pil_image(image_size)
|
||||
|
||||
def _predict(self, image_or_text: Image | str) -> list[float]:
|
||||
def _predict(self, image_or_text: Image.Image | str) -> list[float]:
|
||||
if isinstance(image_or_text, bytes):
|
||||
image_or_text = Image.open(BytesIO(image_or_text))
|
||||
|
||||
match image_or_text:
|
||||
case Image():
|
||||
case Image.Image():
|
||||
if self.mode == "text":
|
||||
raise TypeError("Cannot encode image as text-only model")
|
||||
pixel_values = self.transform(image_or_text)
|
||||
@@ -101,9 +106,11 @@ class CLIPEncoder(InferenceModel):
|
||||
if model_name in _MODELS:
|
||||
return model_name
|
||||
elif model_name in _ST_TO_JINA_MODEL_NAME:
|
||||
print(
|
||||
(f"Warning: Sentence-Transformer model names such as '{model_name}' are no longer supported."),
|
||||
(f"Using '{_ST_TO_JINA_MODEL_NAME[model_name]}' instead as it is the best match for '{model_name}'."),
|
||||
log.warn(
|
||||
(
|
||||
f"Sentence-Transformer models like '{model_name}' are not supported."
|
||||
f"Using '{_ST_TO_JINA_MODEL_NAME[model_name]}' instead as it is the best match for '{model_name}'."
|
||||
),
|
||||
)
|
||||
return _ST_TO_JINA_MODEL_NAME[model_name]
|
||||
else:
|
||||
@@ -124,6 +131,10 @@ class CLIPEncoder(InferenceModel):
|
||||
os.remove(file)
|
||||
return True
|
||||
|
||||
@property
|
||||
def cached(self) -> bool:
|
||||
return (self.cache_dir / "textual.onnx").is_file() and (self.cache_dir / "visual.onnx").is_file()
|
||||
|
||||
|
||||
# same as `_transform_blob` without `_blob2image`
|
||||
def _transform_pil_image(n_px: int) -> Compose:
|
||||
|
||||
@@ -9,7 +9,6 @@ from insightface.model_zoo import ArcFaceONNX, RetinaFace
|
||||
from insightface.utils.face_align import norm_crop
|
||||
from insightface.utils.storage import BASE_REPO_URL, download_file
|
||||
|
||||
from ..config import settings
|
||||
from ..schemas import ModelType
|
||||
from .base import InferenceModel
|
||||
|
||||
@@ -20,11 +19,11 @@ class FaceRecognizer(InferenceModel):
|
||||
def __init__(
|
||||
self,
|
||||
model_name: str,
|
||||
min_score: float = settings.min_face_score,
|
||||
min_score: float = 0.7,
|
||||
cache_dir: Path | str | None = None,
|
||||
**model_kwargs: Any,
|
||||
) -> None:
|
||||
self.min_score = min_score
|
||||
self.min_score = model_kwargs.pop("minScore", min_score)
|
||||
super().__init__(model_name, cache_dir, **model_kwargs)
|
||||
|
||||
def _download(self, **model_kwargs: Any) -> None:
|
||||
@@ -69,11 +68,13 @@ class FaceRecognizer(InferenceModel):
|
||||
)
|
||||
self.rec_model.prepare(ctx_id=0)
|
||||
|
||||
def _predict(self, image: cv2.Mat) -> list[dict[str, Any]]:
|
||||
def _predict(self, image: np.ndarray[int, np.dtype[Any]] | bytes) -> list[dict[str, Any]]:
|
||||
if isinstance(image, bytes):
|
||||
image = cv2.imdecode(np.frombuffer(image, np.uint8), cv2.IMREAD_COLOR)
|
||||
bboxes, kpss = self.det_model.detect(image)
|
||||
if bboxes.size == 0:
|
||||
return []
|
||||
assert isinstance(kpss, np.ndarray)
|
||||
assert isinstance(image, np.ndarray) and isinstance(kpss, np.ndarray)
|
||||
|
||||
scores = bboxes[:, 4].tolist()
|
||||
bboxes = bboxes[:, :4].round().tolist()
|
||||
@@ -102,3 +103,6 @@ class FaceRecognizer(InferenceModel):
|
||||
@property
|
||||
def cached(self) -> bool:
|
||||
return self.cache_dir.is_dir() and any(self.cache_dir.glob("*.onnx"))
|
||||
|
||||
def configure(self, **model_kwargs: Any) -> None:
|
||||
self.det_model.det_thresh = model_kwargs.pop("minScore", self.det_model.det_thresh)
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from huggingface_hub import snapshot_download
|
||||
from optimum.onnxruntime import ORTModelForImageClassification
|
||||
from optimum.pipelines import pipeline
|
||||
from PIL.Image import Image
|
||||
from PIL import Image
|
||||
from transformers import AutoImageProcessor
|
||||
|
||||
from ..config import settings
|
||||
from ..config import log
|
||||
from ..schemas import ModelType
|
||||
from .base import InferenceModel
|
||||
|
||||
@@ -18,11 +19,11 @@ class ImageClassifier(InferenceModel):
|
||||
def __init__(
|
||||
self,
|
||||
model_name: str,
|
||||
min_score: float = settings.min_tag_score,
|
||||
min_score: float = 0.9,
|
||||
cache_dir: Path | str | None = None,
|
||||
**model_kwargs: Any,
|
||||
) -> None:
|
||||
self.min_score = min_score
|
||||
self.min_score = model_kwargs.pop("minScore", min_score)
|
||||
super().__init__(model_name, cache_dir, **model_kwargs)
|
||||
|
||||
def _download(self, **model_kwargs: Any) -> None:
|
||||
@@ -35,19 +36,25 @@ class ImageClassifier(InferenceModel):
|
||||
)
|
||||
|
||||
def _load(self, **model_kwargs: Any) -> None:
|
||||
processor = AutoImageProcessor.from_pretrained(self.cache_dir)
|
||||
processor = AutoImageProcessor.from_pretrained(self.cache_dir, cache_dir=self.cache_dir)
|
||||
model_path = self.cache_dir / "model.onnx"
|
||||
model_kwargs |= {
|
||||
"cache_dir": self.cache_dir,
|
||||
"provider": self.providers[0],
|
||||
"provider_options": self.provider_options[0],
|
||||
"session_options": self.sess_options,
|
||||
}
|
||||
model_path = self.cache_dir / "model.onnx"
|
||||
|
||||
if model_path.exists():
|
||||
model = ORTModelForImageClassification.from_pretrained(self.cache_dir, **model_kwargs)
|
||||
self.model = pipeline(self.model_type.value, model, feature_extractor=processor)
|
||||
else:
|
||||
log.info(
|
||||
(
|
||||
f"ONNX model not found in cache directory for '{self.model_name}'."
|
||||
"Exporting optimized model for future use."
|
||||
),
|
||||
)
|
||||
self.sess_options.optimized_model_filepath = model_path.as_posix()
|
||||
self.model = pipeline(
|
||||
self.model_type.value,
|
||||
@@ -56,8 +63,13 @@ class ImageClassifier(InferenceModel):
|
||||
feature_extractor=processor,
|
||||
)
|
||||
|
||||
def _predict(self, image: Image) -> list[str]:
|
||||
def _predict(self, image: Image.Image | bytes) -> list[str]:
|
||||
if isinstance(image, bytes):
|
||||
image = Image.open(BytesIO(image))
|
||||
predictions: list[dict[str, Any]] = self.model(image) # type: ignore
|
||||
tags = [tag for pred in predictions for tag in pred["label"].split(", ") if pred["score"] >= self.min_score]
|
||||
|
||||
return tags
|
||||
|
||||
def configure(self, **model_kwargs: Any) -> None:
|
||||
self.min_score = model_kwargs.pop("minScore", self.min_score)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from enum import Enum
|
||||
from enum import StrEnum
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
@@ -20,18 +20,6 @@ class MessageResponse(BaseModel):
|
||||
message: str
|
||||
|
||||
|
||||
class TagResponse(BaseModel):
|
||||
__root__: list[str]
|
||||
|
||||
|
||||
class Embedding(BaseModel):
|
||||
__root__: list[float]
|
||||
|
||||
|
||||
class EmbeddingResponse(BaseModel):
|
||||
__root__: Embedding
|
||||
|
||||
|
||||
class BoundingBox(BaseModel):
|
||||
x1: int
|
||||
y1: int
|
||||
@@ -39,23 +27,7 @@ class BoundingBox(BaseModel):
|
||||
y2: int
|
||||
|
||||
|
||||
class Face(BaseModel):
|
||||
image_width: int
|
||||
image_height: int
|
||||
bounding_box: BoundingBox
|
||||
score: float
|
||||
embedding: Embedding
|
||||
|
||||
class Config:
|
||||
alias_generator = to_lower_camel
|
||||
allow_population_by_field_name = True
|
||||
|
||||
|
||||
class FaceResponse(BaseModel):
|
||||
__root__: list[Face]
|
||||
|
||||
|
||||
class ModelType(Enum):
|
||||
class ModelType(StrEnum):
|
||||
IMAGE_CLASSIFICATION = "image-classification"
|
||||
CLIP = "clip"
|
||||
FACIAL_RECOGNITION = "facial-recognition"
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
export MACHINE_LEARNING_CACHE_FOLDER=/tmp/model_cache
|
||||
export MACHINE_LEARNING_MIN_FACE_SCORE=0.034 # returns 1 face per request; setting this to 0 blows up the number of faces to the thousands
|
||||
export MACHINE_LEARNING_MIN_TAG_SCORE=0.0
|
||||
export PID_FILE=/tmp/locust_pid
|
||||
export LOG_FILE=/tmp/gunicorn.log
|
||||
export HEADLESS=false
|
||||
export HOST=127.0.0.1:3003
|
||||
export CONCURRENCY=4
|
||||
export NUM_ENDPOINTS=3
|
||||
export PYTHONPATH=app
|
||||
|
||||
gunicorn app.main:app --worker-class uvicorn.workers.UvicornWorker \
|
||||
--bind $HOST --daemon --error-logfile $LOG_FILE --pid $PID_FILE
|
||||
while true ; do
|
||||
echo "Loading models..."
|
||||
sleep 5
|
||||
if cat $LOG_FILE | grep -q -E "startup complete"; then break; fi
|
||||
done
|
||||
|
||||
# "users" are assigned only one task, so multiply concurrency by the number of tasks
|
||||
locust --host http://$HOST --web-host 127.0.0.1 \
|
||||
--run-time 120s --users $(($CONCURRENCY * $NUM_ENDPOINTS)) $(if $HEADLESS; then echo "--headless"; fi)
|
||||
|
||||
if [[ -e $PID_FILE ]]; then kill $(cat $PID_FILE); fi
|
||||
@@ -1,13 +1,32 @@
|
||||
from io import BytesIO
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from locust import HttpUser, events, task
|
||||
from locust.env import Environment
|
||||
from PIL import Image
|
||||
from argparse import ArgumentParser
|
||||
byte_image = BytesIO()
|
||||
|
||||
|
||||
@events.init_command_line_parser.add_listener
|
||||
def _(parser: ArgumentParser) -> None:
|
||||
parser.add_argument("--tag-model", type=str, default="microsoft/resnet-50")
|
||||
parser.add_argument("--clip-model", type=str, default="ViT-B-32::openai")
|
||||
parser.add_argument("--face-model", type=str, default="buffalo_l")
|
||||
parser.add_argument("--tag-min-score", type=int, default=0.0,
|
||||
help="Returns all tags at or above this score. The default returns all tags.")
|
||||
parser.add_argument("--face-min-score", type=int, default=0.034,
|
||||
help=("Returns all faces at or above this score. The default returns 1 face per request; "
|
||||
"setting this to 0 blows up the number of faces to the thousands."))
|
||||
parser.add_argument("--image-size", type=int, default=1000)
|
||||
|
||||
|
||||
@events.test_start.add_listener
|
||||
def on_test_start(environment, **kwargs):
|
||||
def on_test_start(environment: Environment, **kwargs: Any) -> None:
|
||||
global byte_image
|
||||
image = Image.new("RGB", (1000, 1000))
|
||||
assert environment.parsed_options is not None
|
||||
image = Image.new("RGB", (environment.parsed_options.image_size, environment.parsed_options.image_size))
|
||||
byte_image = BytesIO()
|
||||
image.save(byte_image, format="jpeg")
|
||||
|
||||
@@ -19,34 +38,55 @@ class InferenceLoadTest(HttpUser):
|
||||
headers: dict[str, str] = {"Content-Type": "image/jpg"}
|
||||
|
||||
# re-use the image across all instances in a process
|
||||
def on_start(self):
|
||||
def on_start(self) -> None:
|
||||
global byte_image
|
||||
self.data = byte_image.getvalue()
|
||||
|
||||
|
||||
class ClassificationLoadTest(InferenceLoadTest):
|
||||
class ClassificationFormDataLoadTest(InferenceLoadTest):
|
||||
@task
|
||||
def classify(self):
|
||||
self.client.post(
|
||||
"/image-classifier/tag-image", data=self.data, headers=self.headers
|
||||
)
|
||||
def classify(self) -> None:
|
||||
data = [
|
||||
("modelName", self.environment.parsed_options.clip_model),
|
||||
("modelType", "clip"),
|
||||
("options", json.dumps({"minScore": self.environment.parsed_options.tag_min_score})),
|
||||
]
|
||||
files = {"image": self.data}
|
||||
self.client.post("/predict", data=data, files=files)
|
||||
|
||||
|
||||
class CLIPLoadTest(InferenceLoadTest):
|
||||
class CLIPTextFormDataLoadTest(InferenceLoadTest):
|
||||
@task
|
||||
def encode_image(self):
|
||||
self.client.post(
|
||||
"/sentence-transformer/encode-image",
|
||||
data=self.data,
|
||||
headers=self.headers,
|
||||
)
|
||||
def encode_text(self) -> None:
|
||||
data = [
|
||||
("modelName", self.environment.parsed_options.clip_model),
|
||||
("modelType", "clip"),
|
||||
("options", json.dumps({"mode": "text"})),
|
||||
("text", "test search query")
|
||||
]
|
||||
self.client.post("/predict", data=data)
|
||||
|
||||
|
||||
class RecognitionLoadTest(InferenceLoadTest):
|
||||
class CLIPVisionFormDataLoadTest(InferenceLoadTest):
|
||||
@task
|
||||
def recognize(self):
|
||||
self.client.post(
|
||||
"/facial-recognition/detect-faces",
|
||||
data=self.data,
|
||||
headers=self.headers,
|
||||
)
|
||||
def encode_image(self) -> None:
|
||||
data = [
|
||||
("modelName", self.environment.parsed_options.clip_model),
|
||||
("modelType", "clip"),
|
||||
("options", json.dumps({"mode": "vision"})),
|
||||
]
|
||||
files = {"image": self.data}
|
||||
self.client.post("/predict", data=data, files=files)
|
||||
|
||||
|
||||
class RecognitionFormDataLoadTest(InferenceLoadTest):
|
||||
@task
|
||||
def recognize(self) -> None:
|
||||
data = [
|
||||
("modelName", self.environment.parsed_options.face_model),
|
||||
("modelType", "facial-recognition"),
|
||||
("options", json.dumps({"minScore": self.environment.parsed_options.face_min_score})),
|
||||
]
|
||||
files = {"image": self.data}
|
||||
|
||||
self.client.post("/predict", data=data, files=files)
|
||||
|
||||
17
machine-learning/log_conf.json
Normal file
17
machine-learning/log_conf.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"version": 1,
|
||||
"disable_existing_loggers": true,
|
||||
"formatters": { "rich": { "show_path": false, "omit_repeated_times": false } },
|
||||
"handlers": {
|
||||
"console": {
|
||||
"class": "app.config.CustomRichHandler",
|
||||
"formatter": "rich",
|
||||
"level": "INFO"
|
||||
}
|
||||
},
|
||||
"loggers": {
|
||||
"gunicorn.access": { "propagate": true },
|
||||
"gunicorn.error": { "propagate": true }
|
||||
},
|
||||
"root": { "handlers": ["console"] }
|
||||
}
|
||||
929
machine-learning/poetry.lock
generated
929
machine-learning/poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "machine-learning"
|
||||
version = "1.75.1"
|
||||
version = "1.77.0"
|
||||
description = ""
|
||||
authors = ["Hau Tran <alex.tran1502@gmail.com>"]
|
||||
readme = "README.md"
|
||||
@@ -30,13 +30,16 @@ rich = "^13.4.2"
|
||||
ftfy = "^6.1.1"
|
||||
setuptools = "^68.0.0"
|
||||
open-clip-torch = "^2.20.0"
|
||||
python-multipart = "^0.0.6"
|
||||
orjson = "^3.9.5"
|
||||
safetensors = "0.3.2"
|
||||
gunicorn = "^21.1.0"
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
mypy = "^1.3.0"
|
||||
black = "^23.3.0"
|
||||
pytest = "^7.3.1"
|
||||
locust = "^2.15.1"
|
||||
gunicorn = "^20.1.0"
|
||||
httpx = "^0.24.1"
|
||||
pytest-asyncio = "^0.21.0"
|
||||
pytest-cov = "^4.1.0"
|
||||
@@ -71,6 +74,7 @@ warn_untyped_fields = true
|
||||
module = [
|
||||
"huggingface_hub",
|
||||
"transformers",
|
||||
"gunicorn",
|
||||
"cv2",
|
||||
"insightface.model_zoo",
|
||||
"insightface.utils.face_align",
|
||||
|
||||
13
machine-learning/start.sh
Executable file
13
machine-learning/start.sh
Executable file
@@ -0,0 +1,13 @@
|
||||
#!/usr/bin/env sh
|
||||
|
||||
export LD_PRELOAD="/usr/lib/$(arch)-linux-gnu/libmimalloc.so.2"
|
||||
|
||||
: "${MACHINE_LEARNING_HOST:=0.0.0.0}"
|
||||
: "${MACHINE_LEARNING_PORT:=3003}"
|
||||
: "${MACHINE_LEARNING_WORKERS:=1}"
|
||||
|
||||
gunicorn app.main:app \
|
||||
-k uvicorn.workers.UvicornWorker \
|
||||
-w $MACHINE_LEARNING_WORKERS \
|
||||
-b $MACHINE_LEARNING_HOST:$MACHINE_LEARNING_PORT \
|
||||
--log-config-json log_conf.json
|
||||
@@ -96,3 +96,8 @@ dependencies {
|
||||
implementation "com.github.bumptech.glide:glide:$glide_version"
|
||||
kapt "com.github.bumptech.glide:compiler:$glide_version"
|
||||
}
|
||||
|
||||
// This is uncommented in F-Droid build script
|
||||
//f configurations.all {
|
||||
//f exclude group: 'com.google.android.gms'
|
||||
//f }
|
||||
|
||||
@@ -55,7 +55,6 @@
|
||||
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32"/>
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_MEDIA_LOCATION" />
|
||||
@@ -64,6 +63,7 @@
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
|
||||
<queries>
|
||||
<intent>
|
||||
|
||||
@@ -35,8 +35,8 @@ platform :android do
|
||||
task: 'bundle',
|
||||
build_type: 'Release',
|
||||
properties: {
|
||||
"android.injected.version.code" => 98,
|
||||
"android.injected.version.name" => "1.75.1",
|
||||
"android.injected.version.code" => 100,
|
||||
"android.injected.version.name" => "1.77.0",
|
||||
}
|
||||
)
|
||||
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')
|
||||
|
||||
@@ -10,12 +10,12 @@
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="1: bundleRelease" time="67.877631">
|
||||
<testcase classname="fastlane.lanes" name="1: bundleRelease" time="63.585931">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="23.895222">
|
||||
<testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="24.755096">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
@@ -193,6 +193,8 @@
|
||||
"login_form_save_login": "Stay logged in",
|
||||
"login_form_server_empty": "Enter a server URL.",
|
||||
"login_form_server_error": "Could not connect to server.",
|
||||
"login_password_changed_success": "Password updated successfully",
|
||||
"login_password_changed_error": "There was an error updating your password",
|
||||
"monthly_title_text_date_format": "MMMM y",
|
||||
"motion_photos_page_title": "Motion Photos",
|
||||
"notification_permission_dialog_cancel": "Cancel",
|
||||
@@ -301,5 +303,20 @@
|
||||
"version_announcement_overlay_text_2": "please take your time to visit the ",
|
||||
"version_announcement_overlay_text_3": " and ensure your docker-compose and .env setup is up-to-date to prevent any misconfigurations, especially if you use WatchTower or any mechanism that handles updating your server application automatically.",
|
||||
"version_announcement_overlay_title": "New Server Version Available \uD83C\uDF89",
|
||||
"translated_text_options": "Options"
|
||||
"translated_text_options": "Options",
|
||||
"map_no_assets_in_bounds": "No photos in this area",
|
||||
"map_zoom_to_see_photos": "Zoom out to see photos",
|
||||
"map_settings_dialog_title": "Map Settings",
|
||||
"map_settings_dark_mode": "Dark mode",
|
||||
"map_settings_only_show_favorites": "Show Favorite Only",
|
||||
"map_settings_only_relative_range": "Date range",
|
||||
"map_settings_dialog_cancel": "Cancel",
|
||||
"map_settings_dialog_save": "Save",
|
||||
"map_cannot_get_user_location": "Cannot get user's location",
|
||||
"map_location_service_disabled_title": "Location Service disabled",
|
||||
"map_location_service_disabled_content": "Location service needs to be enabled to display assets from your current location. Do you want to enable it now?",
|
||||
"map_no_location_permission_title": "Location Permission denied",
|
||||
"map_no_location_permission_content": "Location permission is needed to display assets from your current location. Do you want to allow it now?",
|
||||
"map_location_dialog_cancel": "Cancel",
|
||||
"map_location_dialog_yes": "Yes"
|
||||
}
|
||||
|
||||
BIN
mobile/assets/lighthouse.png
Normal file
BIN
mobile/assets/lighthouse.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 45 KiB |
@@ -20,6 +20,8 @@ PODS:
|
||||
- FMDB (2.7.5):
|
||||
- FMDB/standard (= 2.7.5)
|
||||
- FMDB/standard (2.7.5)
|
||||
- geolocator_apple (1.2.0):
|
||||
- Flutter
|
||||
- image_picker_ios (0.0.1):
|
||||
- Flutter
|
||||
- integration_test (0.0.1):
|
||||
@@ -65,6 +67,7 @@ DEPENDENCIES:
|
||||
- flutter_udid (from `.symlinks/plugins/flutter_udid/ios`)
|
||||
- flutter_web_auth (from `.symlinks/plugins/flutter_web_auth/ios`)
|
||||
- fluttertoast (from `.symlinks/plugins/fluttertoast/ios`)
|
||||
- geolocator_apple (from `.symlinks/plugins/geolocator_apple/ios`)
|
||||
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
|
||||
- integration_test (from `.symlinks/plugins/integration_test/ios`)
|
||||
- isar_flutter_libs (from `.symlinks/plugins/isar_flutter_libs/ios`)
|
||||
@@ -104,6 +107,8 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/flutter_web_auth/ios"
|
||||
fluttertoast:
|
||||
:path: ".symlinks/plugins/fluttertoast/ios"
|
||||
geolocator_apple:
|
||||
:path: ".symlinks/plugins/geolocator_apple/ios"
|
||||
image_picker_ios:
|
||||
:path: ".symlinks/plugins/image_picker_ios/ios"
|
||||
integration_test:
|
||||
@@ -143,6 +148,7 @@ SPEC CHECKSUMS:
|
||||
flutter_web_auth: c25208760459cec375a3c39f6a8759165ca0fa4d
|
||||
fluttertoast: fafc4fa4d01a6a9e4f772ecd190ffa525e9e2d9c
|
||||
FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a
|
||||
geolocator_apple: cc556e6844d508c95df1e87e3ea6fa4e58c50401
|
||||
image_picker_ios: 4a8aadfbb6dc30ad5141a2ce3832af9214a705b5
|
||||
integration_test: 13825b8a9334a850581300559b8839134b124670
|
||||
isar_flutter_libs: b69f437aeab9c521821c3f376198c4371fa21073
|
||||
@@ -163,4 +169,4 @@ SPEC CHECKSUMS:
|
||||
|
||||
PODFILE CHECKSUM: 599d8aeb73728400c15364e734525722250a5382
|
||||
|
||||
COCOAPODS: 1.11.3
|
||||
COCOAPODS: 1.12.1
|
||||
|
||||
@@ -379,7 +379,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 113;
|
||||
CURRENT_PROJECT_VERSION = 116;
|
||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
@@ -515,7 +515,7 @@
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 113;
|
||||
CURRENT_PROJECT_VERSION = 116;
|
||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
@@ -543,7 +543,7 @@
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 113;
|
||||
CURRENT_PROJECT_VERSION = 116;
|
||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
|
||||
@@ -59,11 +59,11 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.73.0</string>
|
||||
<string>1.76.0</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>113</string>
|
||||
<string>116</string>
|
||||
<key>FLTEnableImpeller</key>
|
||||
<true />
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
@@ -83,8 +83,6 @@
|
||||
</dict>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>We need to access the camera to let you take beautiful video using this app</string>
|
||||
<key>NSLocationAlwaysUsageDescription</key>
|
||||
<string>Enable location setting to show position of assets on map</string>
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
<string>Enable location setting to show position of assets on map</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
|
||||
@@ -19,7 +19,7 @@ platform :ios do
|
||||
desc "iOS Beta"
|
||||
lane :beta do
|
||||
increment_version_number(
|
||||
version_number: "1.75.1"
|
||||
version_number: "1.77.0"
|
||||
)
|
||||
increment_build_number(
|
||||
build_number: latest_testflight_build_number + 1,
|
||||
|
||||
@@ -5,32 +5,32 @@
|
||||
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000187">
|
||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000243">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="1: increment_version_number" time="2.403882">
|
||||
<testcase classname="fastlane.lanes" name="1: increment_version_number" time="2.611762">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="2: latest_testflight_build_number" time="5.068392">
|
||||
<testcase classname="fastlane.lanes" name="2: latest_testflight_build_number" time="6.937008">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="3: increment_build_number" time="1.988079">
|
||||
<testcase classname="fastlane.lanes" name="3: increment_build_number" time="2.740416">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="4: build_app" time="96.47923">
|
||||
<testcase classname="fastlane.lanes" name="4: build_app" time="93.625943">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="5: upload_to_testflight" time="57.517755">
|
||||
<testcase classname="fastlane.lanes" name="5: upload_to_testflight" time="62.107671">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
@@ -63,11 +63,15 @@ Future<void> initApp() async {
|
||||
|
||||
FlutterError.onError = (details) {
|
||||
FlutterError.presentError(details);
|
||||
log.severe(details.toString(), details, details.stack);
|
||||
log.severe(
|
||||
'Catch all error: ${details.toString()} - ${details.exception} - ${details.library} - ${details.context} - ${details.stack}',
|
||||
details,
|
||||
details.stack,
|
||||
);
|
||||
};
|
||||
|
||||
PlatformDispatcher.instance.onError = (error, stack) {
|
||||
log.severe(error.toString(), error, stack);
|
||||
log.severe('Catch all error: ${error.toString()} - $error', error, stack);
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,14 +2,12 @@ import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/archive/providers/archive_asset_provider.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/providers/asset.provider.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_toast.dart';
|
||||
import 'package:immich_mobile/utils/selection_handlers.dart';
|
||||
|
||||
class ArchivePage extends HookConsumerWidget {
|
||||
const ArchivePage({super.key});
|
||||
@@ -68,24 +66,12 @@ class ArchivePage extends HookConsumerWidget {
|
||||
: () async {
|
||||
processing.value = true;
|
||||
try {
|
||||
if (selection.value.isNotEmpty) {
|
||||
await ref
|
||||
.watch(assetProvider.notifier)
|
||||
.toggleArchive(
|
||||
selection.value.toList(),
|
||||
false,
|
||||
);
|
||||
|
||||
final assetOrAssets = selection.value.length > 1
|
||||
? 'assets'
|
||||
: 'asset';
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg:
|
||||
'Moved ${selection.value.length} $assetOrAssets to library',
|
||||
gravity: ToastGravity.CENTER,
|
||||
);
|
||||
}
|
||||
await handleArchiveAssets(
|
||||
ref,
|
||||
context,
|
||||
selection.value.toList(),
|
||||
shouldArchive: false,
|
||||
);
|
||||
} finally {
|
||||
processing.value = false;
|
||||
selectionEnabledHook.value = false;
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/ui/description_input.dart';
|
||||
import 'package:immich_mobile/modules/map/ui/map_thumbnail.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/ui/drag_sheet.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
@@ -41,7 +42,10 @@ class ExifBottomSheet extends HookConsumerWidget {
|
||||
Uri uri = Uri(
|
||||
scheme: 'geo',
|
||||
host: '$latitude,$longitude',
|
||||
queryParameters: {'z': '$zoomLevel', 'q': formattedDateTime},
|
||||
queryParameters: {
|
||||
'z': '$zoomLevel',
|
||||
'q': '$latitude,$longitude($formattedDateTime)',
|
||||
},
|
||||
);
|
||||
if (await canLaunchUrl(uri)) {
|
||||
return uri;
|
||||
@@ -77,65 +81,35 @@ class ExifBottomSheet extends HookConsumerWidget {
|
||||
padding: const EdgeInsets.symmetric(vertical: 16.0),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return Container(
|
||||
height: 150,
|
||||
width: constraints.maxWidth,
|
||||
decoration: const BoxDecoration(
|
||||
borderRadius: BorderRadius.all(Radius.circular(15)),
|
||||
return MapThumbnail(
|
||||
coords: LatLng(
|
||||
exifInfo?.latitude ?? 0,
|
||||
exifInfo?.longitude ?? 0,
|
||||
),
|
||||
child: FlutterMap(
|
||||
options: MapOptions(
|
||||
interactiveFlags: InteractiveFlag.none,
|
||||
center: LatLng(
|
||||
height: 150,
|
||||
zoom: 16.0,
|
||||
markers: [
|
||||
Marker(
|
||||
anchorPos: AnchorPos.align(AnchorAlign.top),
|
||||
point: LatLng(
|
||||
exifInfo?.latitude ?? 0,
|
||||
exifInfo?.longitude ?? 0,
|
||||
),
|
||||
zoom: 16.0,
|
||||
onTap: (tapPosition, latLong) async {
|
||||
Uri? uri = await _createCoordinatesUri();
|
||||
|
||||
if (uri == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
debugPrint('Opening Map Uri: $uri');
|
||||
launchUrl(uri);
|
||||
},
|
||||
builder: (ctx) => const Image(
|
||||
image: AssetImage('assets/location-pin.png'),
|
||||
),
|
||||
),
|
||||
nonRotatedChildren: [
|
||||
RichAttributionWidget(
|
||||
attributions: [
|
||||
TextSourceAttribution(
|
||||
'OpenStreetMap contributors',
|
||||
onTap: () => launchUrl(
|
||||
Uri.parse('https://openstreetmap.org/copyright'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
children: [
|
||||
TileLayer(
|
||||
urlTemplate:
|
||||
"https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
|
||||
subdomains: const ['a', 'b', 'c'],
|
||||
),
|
||||
MarkerLayer(
|
||||
markers: [
|
||||
Marker(
|
||||
anchorPos: AnchorPos.align(AnchorAlign.top),
|
||||
point: LatLng(
|
||||
exifInfo?.latitude ?? 0,
|
||||
exifInfo?.longitude ?? 0,
|
||||
),
|
||||
builder: (ctx) => const Image(
|
||||
image: AssetImage('assets/location-pin.png'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
onTap: (tapPosition, latLong) async {
|
||||
Uri? uri = await _createCoordinatesUri();
|
||||
|
||||
if (uri == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
debugPrint('Opening Map Uri: $uri');
|
||||
launchUrl(uri);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -281,7 +255,7 @@ class ExifBottomSheet extends HookConsumerWidget {
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
"ƒ/${exifInfo.fNumber} ${exifInfo.exposureTime} ${exifInfo.focalLength} mm ISO${exifInfo.iso} ",
|
||||
"ƒ/${exifInfo.fNumber} ${exifInfo.exposureTime} ${exifInfo.focalLength} mm ISO ${exifInfo.iso ?? ''} ",
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -2,13 +2,11 @@ import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/favorite/providers/favorite_provider.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/providers/asset.provider.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_toast.dart';
|
||||
import 'package:immich_mobile/utils/selection_handlers.dart';
|
||||
|
||||
class FavoritesPage extends HookConsumerWidget {
|
||||
const FavoritesPage({Key? key}) : super(key: key);
|
||||
@@ -44,16 +42,11 @@ class FavoritesPage extends HookConsumerWidget {
|
||||
void unfavorite() async {
|
||||
try {
|
||||
if (selection.value.isNotEmpty) {
|
||||
await ref.watch(assetProvider.notifier).toggleFavorite(
|
||||
selection.value.toList(),
|
||||
false,
|
||||
);
|
||||
final assetOrAssets = selection.value.length > 1 ? 'assets' : 'asset';
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg:
|
||||
'Removed ${selection.value.length} $assetOrAssets from favorites',
|
||||
gravity: ToastGravity.CENTER,
|
||||
await handleFavoriteAssets(
|
||||
ref,
|
||||
context,
|
||||
selection.value.toList(),
|
||||
shouldFavorite: false,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
|
||||
@@ -30,6 +30,8 @@ class ImmichAssetGrid extends HookConsumerWidget {
|
||||
final void Function(ItemPosition start, ItemPosition end)?
|
||||
visibleItemsListener;
|
||||
final Widget? topWidget;
|
||||
final bool shrinkWrap;
|
||||
final bool showDragScroll;
|
||||
|
||||
const ImmichAssetGrid({
|
||||
super.key,
|
||||
@@ -47,6 +49,8 @@ class ImmichAssetGrid extends HookConsumerWidget {
|
||||
this.showMultiSelectIndicator = true,
|
||||
this.visibleItemsListener,
|
||||
this.topWidget,
|
||||
this.shrinkWrap = false,
|
||||
this.showDragScroll = true,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -108,6 +112,8 @@ class ImmichAssetGrid extends HookConsumerWidget {
|
||||
visibleItemsListener: visibleItemsListener,
|
||||
topWidget: topWidget,
|
||||
heroOffset: heroOffset(),
|
||||
shrinkWrap: shrinkWrap,
|
||||
showDragScroll: showDragScroll,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -35,6 +35,8 @@ class ImmichAssetGridView extends StatefulWidget {
|
||||
visibleItemsListener;
|
||||
final Widget? topWidget;
|
||||
final int heroOffset;
|
||||
final bool shrinkWrap;
|
||||
final bool showDragScroll;
|
||||
|
||||
const ImmichAssetGridView({
|
||||
super.key,
|
||||
@@ -52,6 +54,8 @@ class ImmichAssetGridView extends StatefulWidget {
|
||||
this.visibleItemsListener,
|
||||
this.topWidget,
|
||||
this.heroOffset = 0,
|
||||
this.shrinkWrap = false,
|
||||
this.showDragScroll = true,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -324,7 +328,8 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
|
||||
}
|
||||
|
||||
Widget _buildAssetGrid() {
|
||||
final useDragScrolling = widget.renderList.totalAssets >= 20;
|
||||
final useDragScrolling =
|
||||
widget.showDragScroll && widget.renderList.totalAssets >= 20;
|
||||
|
||||
void dragScrolling(bool active) {
|
||||
if (active != _scrolling) {
|
||||
@@ -344,6 +349,7 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
|
||||
itemCount: widget.renderList.elements.length +
|
||||
(widget.topWidget != null ? 1 : 0),
|
||||
addRepaintBoundaries: true,
|
||||
shrinkWrap: widget.shrinkWrap,
|
||||
);
|
||||
|
||||
final child = useDragScrolling
|
||||
|
||||
@@ -25,10 +25,9 @@ import 'package:immich_mobile/shared/providers/asset.provider.dart';
|
||||
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/shared/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/shared/providers/websocket.provider.dart';
|
||||
import 'package:immich_mobile/shared/services/share.service.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_toast.dart';
|
||||
import 'package:immich_mobile/shared/ui/share_dialog.dart';
|
||||
import 'package:immich_mobile/utils/selection_handlers.dart';
|
||||
|
||||
class HomePage extends HookConsumerWidget {
|
||||
const HomePage({Key? key}) : super(key: key);
|
||||
@@ -88,17 +87,7 @@ class HomePage extends HookConsumerWidget {
|
||||
}
|
||||
|
||||
void onShareAssets() {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext buildContext) {
|
||||
ref
|
||||
.watch(shareServiceProvider)
|
||||
.shareAssets(selection.value.toList())
|
||||
.then((_) => Navigator.of(buildContext).pop());
|
||||
return const ShareDialog();
|
||||
},
|
||||
barrierDismissible: false,
|
||||
);
|
||||
handleShareAssets(ref, context, selection.value.toList());
|
||||
|
||||
selectionEnabledHook.value = false;
|
||||
}
|
||||
@@ -126,16 +115,7 @@ class HomePage extends HookConsumerWidget {
|
||||
localErrorMessage: 'home_page_favorite_err_local'.tr(),
|
||||
);
|
||||
if (remoteAssets.isNotEmpty) {
|
||||
await ref
|
||||
.watch(assetProvider.notifier)
|
||||
.toggleFavorite(remoteAssets, true);
|
||||
|
||||
final assetOrAssets = remoteAssets.length > 1 ? 'assets' : 'asset';
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: 'Added ${remoteAssets.length} $assetOrAssets to favorites',
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
);
|
||||
await handleFavoriteAssets(ref, context, remoteAssets);
|
||||
}
|
||||
} finally {
|
||||
processing.value = false;
|
||||
@@ -149,18 +129,7 @@ class HomePage extends HookConsumerWidget {
|
||||
final remoteAssets = remoteOnlySelection(
|
||||
localErrorMessage: 'home_page_archive_err_local'.tr(),
|
||||
);
|
||||
if (remoteAssets.isNotEmpty) {
|
||||
await ref
|
||||
.read(assetProvider.notifier)
|
||||
.toggleArchive(remoteAssets, true);
|
||||
|
||||
final assetOrAssets = remoteAssets.length > 1 ? 'assets' : 'asset';
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: 'Moved ${remoteAssets.length} $assetOrAssets to archive',
|
||||
gravity: ToastGravity.CENTER,
|
||||
);
|
||||
}
|
||||
await handleArchiveAssets(ref, context, remoteAssets);
|
||||
} finally {
|
||||
processing.value = false;
|
||||
selectionEnabledHook.value = false;
|
||||
|
||||
@@ -113,7 +113,17 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
|
||||
Store.delete(StoreKey.accessToken),
|
||||
]);
|
||||
|
||||
state = state.copyWith(isAuthenticated: false);
|
||||
state = state.copyWith(
|
||||
deviceId: "",
|
||||
userId: "",
|
||||
userEmail: "",
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
profileImagePath: '',
|
||||
isAdmin: false,
|
||||
shouldChangePassword: false,
|
||||
isAuthenticated: false,
|
||||
);
|
||||
} catch (e) {
|
||||
log.severe("Error logging out $e");
|
||||
}
|
||||
|
||||
@@ -2,13 +2,14 @@ import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
|
||||
import 'package:immich_mobile/modules/backup/providers/manual_upload.provider.dart';
|
||||
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/shared/providers/asset.provider.dart';
|
||||
import 'package:immich_mobile/shared/providers/websocket.provider.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_toast.dart';
|
||||
|
||||
class ChangePasswordForm extends HookConsumerWidget {
|
||||
const ChangePasswordForm({Key? key}) : super(key: key);
|
||||
@@ -84,14 +85,35 @@ class ChangePasswordForm extends HookConsumerWidget {
|
||||
.read(manualUploadProvider.notifier)
|
||||
.cancelBackup();
|
||||
ref.read(backupProvider.notifier).cancelBackup();
|
||||
ref.read(assetProvider.notifier).clearAllAsset();
|
||||
await ref
|
||||
.read(assetProvider.notifier)
|
||||
.clearAllAsset();
|
||||
ref.read(websocketProvider.notifier).disconnect();
|
||||
|
||||
AutoRouter.of(context).replace(const LoginRoute());
|
||||
AutoRouter.of(context).navigateBack();
|
||||
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: "login_password_changed_success".tr(),
|
||||
toastType: ToastType.success,
|
||||
gravity: ToastGravity.TOP,
|
||||
);
|
||||
} else {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: "login_password_changed_error".tr(),
|
||||
toastType: ToastType.error,
|
||||
gravity: ToastGravity.TOP,
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
TextButton.icon(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => AutoRouter.of(context).navigateBack(),
|
||||
label: const Text('Back'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
40
mobile/lib/modules/map/models/map_page_event.model.dart
Normal file
40
mobile/lib/modules/map/models/map_page_event.model.dart
Normal file
@@ -0,0 +1,40 @@
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
|
||||
enum MapPageEventType {
|
||||
mapTap,
|
||||
bottomSheetScrolled,
|
||||
assetsInBoundUpdated,
|
||||
zoomToAsset,
|
||||
zoomToCurrentLocation,
|
||||
}
|
||||
|
||||
class MapPageEventBase {
|
||||
final MapPageEventType type;
|
||||
|
||||
const MapPageEventBase(this.type);
|
||||
}
|
||||
|
||||
class MapPageOnTapEvent extends MapPageEventBase {
|
||||
const MapPageOnTapEvent() : super(MapPageEventType.mapTap);
|
||||
}
|
||||
|
||||
class MapPageAssetsInBoundUpdated extends MapPageEventBase {
|
||||
List<Asset> assets;
|
||||
MapPageAssetsInBoundUpdated(this.assets)
|
||||
: super(MapPageEventType.assetsInBoundUpdated);
|
||||
}
|
||||
|
||||
class MapPageBottomSheetScrolled extends MapPageEventBase {
|
||||
Asset? asset;
|
||||
MapPageBottomSheetScrolled(this.asset)
|
||||
: super(MapPageEventType.bottomSheetScrolled);
|
||||
}
|
||||
|
||||
class MapPageZoomToAsset extends MapPageEventBase {
|
||||
Asset? asset;
|
||||
MapPageZoomToAsset(this.asset) : super(MapPageEventType.zoomToAsset);
|
||||
}
|
||||
|
||||
class MapPageZoomToLocation extends MapPageEventBase {
|
||||
const MapPageZoomToLocation() : super(MapPageEventType.zoomToCurrentLocation);
|
||||
}
|
||||
45
mobile/lib/modules/map/models/map_state.model.dart
Normal file
45
mobile/lib/modules/map/models/map_state.model.dart
Normal file
@@ -0,0 +1,45 @@
|
||||
class MapState {
|
||||
final bool isDarkTheme;
|
||||
final bool showFavoriteOnly;
|
||||
final int relativeTime;
|
||||
|
||||
MapState({
|
||||
this.isDarkTheme = false,
|
||||
this.showFavoriteOnly = false,
|
||||
this.relativeTime = 0,
|
||||
});
|
||||
|
||||
MapState copyWith({
|
||||
bool? isDarkTheme,
|
||||
bool? showFavoriteOnly,
|
||||
int? relativeTime,
|
||||
}) {
|
||||
return MapState(
|
||||
isDarkTheme: isDarkTheme ?? this.isDarkTheme,
|
||||
showFavoriteOnly: showFavoriteOnly ?? this.showFavoriteOnly,
|
||||
relativeTime: relativeTime ?? this.relativeTime,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'MapSettingsState(isDarkTheme: $isDarkTheme, showFavoriteOnly: $showFavoriteOnly, relativeTime: $relativeTime)';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
|
||||
return other is MapState &&
|
||||
other.isDarkTheme == isDarkTheme &&
|
||||
other.showFavoriteOnly == showFavoriteOnly &&
|
||||
other.relativeTime == relativeTime;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return isDarkTheme.hashCode ^
|
||||
showFavoriteOnly.hashCode ^
|
||||
relativeTime.hashCode;
|
||||
}
|
||||
}
|
||||
58
mobile/lib/modules/map/providers/map_marker.provider.dart
Normal file
58
mobile/lib/modules/map/providers/map_marker.provider.dart
Normal file
@@ -0,0 +1,58 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/map/providers/map_state.provider.dart';
|
||||
import 'package:immich_mobile/modules/map/services/map.service.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
final mapMarkersProvider =
|
||||
FutureProvider.autoDispose<Set<AssetMarkerData>>((ref) async {
|
||||
final service = ref.read(mapServiceProvider);
|
||||
final mapState = ref.read(mapStateNotifier);
|
||||
DateTime? fileCreatedAfter;
|
||||
bool? isFavorite;
|
||||
|
||||
if (mapState.relativeTime != 0) {
|
||||
fileCreatedAfter =
|
||||
DateTime.now().subtract(Duration(days: mapState.relativeTime));
|
||||
}
|
||||
|
||||
if (mapState.showFavoriteOnly) {
|
||||
isFavorite = true;
|
||||
}
|
||||
|
||||
final markers = await service.getMapMarkers(
|
||||
isFavorite: isFavorite,
|
||||
fileCreatedAfter: fileCreatedAfter,
|
||||
);
|
||||
|
||||
final assetMarkerData = await Future.wait(
|
||||
markers.map((e) async {
|
||||
final asset = await service.getAssetForMarkerId(e.id);
|
||||
bool hasInvalidCoords = e.lat < -90 || e.lat > 90;
|
||||
hasInvalidCoords = hasInvalidCoords || (e.lon < -180 || e.lon > 180);
|
||||
if (asset == null || hasInvalidCoords) return null;
|
||||
return AssetMarkerData(asset, LatLng(e.lat, e.lon));
|
||||
}),
|
||||
);
|
||||
|
||||
return assetMarkerData.nonNulls.toSet();
|
||||
});
|
||||
|
||||
class AssetMarkerData {
|
||||
final LatLng point;
|
||||
final Asset asset;
|
||||
|
||||
const AssetMarkerData(this.asset, this.point);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
|
||||
return other is AssetMarkerData && other.asset.remoteId == asset.remoteId;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return asset.remoteId.hashCode;
|
||||
}
|
||||
}
|
||||
51
mobile/lib/modules/map/providers/map_state.provider.dart
Normal file
51
mobile/lib/modules/map/providers/map_state.provider.dart
Normal file
@@ -0,0 +1,51 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/map/models/map_state.model.dart';
|
||||
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
|
||||
|
||||
class MapStateNotifier extends StateNotifier<MapState> {
|
||||
MapStateNotifier(this.appSettingsProvider)
|
||||
: super(
|
||||
MapState(
|
||||
isDarkTheme: appSettingsProvider
|
||||
.getSetting<bool>(AppSettingsEnum.mapThemeMode),
|
||||
showFavoriteOnly: appSettingsProvider
|
||||
.getSetting<bool>(AppSettingsEnum.mapShowFavoriteOnly),
|
||||
relativeTime: appSettingsProvider
|
||||
.getSetting<int>(AppSettingsEnum.mapRelativeDate),
|
||||
),
|
||||
);
|
||||
|
||||
final AppSettingsService appSettingsProvider;
|
||||
|
||||
bool get isDarkTheme => state.isDarkTheme;
|
||||
|
||||
void switchTheme(bool isDarkTheme) {
|
||||
appSettingsProvider.setSetting(
|
||||
AppSettingsEnum.mapThemeMode,
|
||||
isDarkTheme,
|
||||
);
|
||||
state = state.copyWith(isDarkTheme: isDarkTheme);
|
||||
}
|
||||
|
||||
void switchFavoriteOnly(bool isFavoriteOnly) {
|
||||
appSettingsProvider.setSetting(
|
||||
AppSettingsEnum.mapShowFavoriteOnly,
|
||||
appSettingsProvider,
|
||||
);
|
||||
state = state.copyWith(showFavoriteOnly: isFavoriteOnly);
|
||||
}
|
||||
|
||||
void setRelativeTime(int relativeTime) {
|
||||
appSettingsProvider.setSetting(
|
||||
AppSettingsEnum.mapRelativeDate,
|
||||
relativeTime,
|
||||
);
|
||||
state = state.copyWith(relativeTime: relativeTime);
|
||||
}
|
||||
}
|
||||
|
||||
final mapStateNotifier =
|
||||
StateNotifierProvider<MapStateNotifier, MapState>((ref) {
|
||||
return MapStateNotifier(ref.watch(appSettingsServiceProvider));
|
||||
});
|
||||
62
mobile/lib/modules/map/services/map.service.dart
Normal file
62
mobile/lib/modules/map/services/map.service.dart
Normal file
@@ -0,0 +1,62 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/providers/api.provider.dart';
|
||||
import 'package:immich_mobile/shared/providers/db.provider.dart';
|
||||
import 'package:immich_mobile/shared/services/api.service.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
final mapServiceProvider = Provider(
|
||||
(ref) => MapSerivce(
|
||||
ref.read(apiServiceProvider),
|
||||
ref.read(dbProvider),
|
||||
),
|
||||
);
|
||||
|
||||
class MapSerivce {
|
||||
final ApiService _apiService;
|
||||
final Isar _db;
|
||||
final log = Logger("MapService");
|
||||
|
||||
MapSerivce(this._apiService, this._db);
|
||||
|
||||
Future<List<MapMarkerResponseDto>> getMapMarkers({
|
||||
bool? isFavorite,
|
||||
DateTime? fileCreatedAfter,
|
||||
DateTime? fileCreatedBefore,
|
||||
}) async {
|
||||
try {
|
||||
final markers = await _apiService.assetApi.getMapMarkers(
|
||||
isFavorite: isFavorite,
|
||||
fileCreatedAfter: fileCreatedAfter,
|
||||
fileCreatedBefore: fileCreatedBefore,
|
||||
);
|
||||
|
||||
return markers ?? [];
|
||||
} catch (error, stack) {
|
||||
log.severe("Cannot get map markers ${error.toString()}", error, stack);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
Future<Asset?> getAssetForMarkerId(String remoteId) async {
|
||||
try {
|
||||
final assets = await _db.assets.getAllByRemoteId([remoteId]);
|
||||
if (assets.isNotEmpty) return assets[0];
|
||||
|
||||
final dto = await _apiService.assetApi.getAssetById(remoteId);
|
||||
if (dto == null) {
|
||||
return null;
|
||||
}
|
||||
return Asset.remote(dto);
|
||||
} catch (error, stack) {
|
||||
log.severe(
|
||||
"Cannot get asset for marker ${error.toString()}",
|
||||
error,
|
||||
stack,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
144
mobile/lib/modules/map/ui/asset_marker_icon.dart
Normal file
144
mobile/lib/modules/map/ui/asset_marker_icon.dart
Normal file
@@ -0,0 +1,144 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/shared/models/store.dart';
|
||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||
|
||||
class AssetMarkerIcon extends StatelessWidget {
|
||||
const AssetMarkerIcon({
|
||||
super.key,
|
||||
required this.id,
|
||||
this.isDarkTheme = false,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final bool isDarkTheme;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final imageUrl = getThumbnailUrlForRemoteId(id);
|
||||
final cacheKey = getThumbnailCacheKeyForRemoteId(id);
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
left: constraints.maxWidth * 0.5,
|
||||
child: CustomPaint(
|
||||
painter: _PinPainter(
|
||||
primaryColor: isDarkTheme ? Colors.white : Colors.black,
|
||||
secondaryColor: isDarkTheme ? Colors.black : Colors.white,
|
||||
primaryRadius: constraints.maxHeight * 0.06,
|
||||
secondaryRadius: constraints.maxHeight * 0.038,
|
||||
),
|
||||
child: SizedBox(
|
||||
height: constraints.maxHeight * 0.14,
|
||||
width: constraints.maxWidth * 0.14,
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: constraints.maxHeight * 0.07,
|
||||
left: constraints.maxWidth * 0.17,
|
||||
child: CircleAvatar(
|
||||
radius: constraints.maxHeight * 0.40,
|
||||
backgroundColor: isDarkTheme ? Colors.white : Colors.black,
|
||||
child: CircleAvatar(
|
||||
radius: constraints.maxHeight * 0.37,
|
||||
backgroundImage: CachedNetworkImageProvider(
|
||||
imageUrl,
|
||||
cacheKey: cacheKey,
|
||||
headers: {
|
||||
"Authorization":
|
||||
"Bearer ${Store.get(StoreKey.accessToken)}",
|
||||
},
|
||||
errorListener: () =>
|
||||
const Icon(Icons.image_not_supported_outlined),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PinPainter extends CustomPainter {
|
||||
final Color primaryColor;
|
||||
final Color secondaryColor;
|
||||
final double primaryRadius;
|
||||
final double secondaryRadius;
|
||||
|
||||
_PinPainter({
|
||||
this.primaryColor = Colors.black,
|
||||
this.secondaryColor = Colors.white,
|
||||
required this.primaryRadius,
|
||||
required this.secondaryRadius,
|
||||
});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
Paint primaryBrush = Paint()
|
||||
..color = primaryColor
|
||||
..style = PaintingStyle.fill;
|
||||
|
||||
Paint secondaryBrush = Paint()
|
||||
..color = secondaryColor
|
||||
..style = PaintingStyle.fill;
|
||||
|
||||
Paint lineBrush = Paint()
|
||||
..color = primaryColor
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 2;
|
||||
|
||||
canvas.drawCircle(
|
||||
Offset(size.width / 2, size.height),
|
||||
primaryRadius,
|
||||
primaryBrush,
|
||||
);
|
||||
canvas.drawCircle(
|
||||
Offset(size.width / 2, size.height),
|
||||
secondaryRadius,
|
||||
secondaryBrush,
|
||||
);
|
||||
canvas.drawPath(getTrianglePath(size.width, size.height), primaryBrush);
|
||||
// The line is to make the above triangluar path more prominent since it has a slight curve
|
||||
canvas.drawLine(
|
||||
Offset(size.width / 2, 0),
|
||||
Offset(
|
||||
size.width / 2,
|
||||
size.height,
|
||||
),
|
||||
lineBrush,
|
||||
);
|
||||
}
|
||||
|
||||
Path getTrianglePath(double x, double y) {
|
||||
final firstEndPoint = Offset(x / 2, y);
|
||||
final controlPoint = Offset(x / 2, y * 0.3);
|
||||
final secondEndPoint = Offset(x, 0);
|
||||
|
||||
return Path()
|
||||
..quadraticBezierTo(
|
||||
controlPoint.dx,
|
||||
controlPoint.dy,
|
||||
firstEndPoint.dx,
|
||||
firstEndPoint.dy,
|
||||
)
|
||||
..quadraticBezierTo(
|
||||
controlPoint.dx,
|
||||
controlPoint.dy,
|
||||
secondEndPoint.dx,
|
||||
secondEndPoint.dy,
|
||||
)
|
||||
..lineTo(0, 0);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_PinPainter old) {
|
||||
return old.primaryColor != primaryColor ||
|
||||
old.secondaryColor != secondaryColor;
|
||||
}
|
||||
}
|
||||
30
mobile/lib/modules/map/ui/location_dialog.dart
Normal file
30
mobile/lib/modules/map/ui/location_dialog.dart
Normal file
@@ -0,0 +1,30 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'package:immich_mobile/shared/ui/confirm_dialog.dart';
|
||||
|
||||
class LocationServiceDisabledDialog extends ConfirmDialog {
|
||||
LocationServiceDisabledDialog({Key? key})
|
||||
: super(
|
||||
key: key,
|
||||
title: 'map_location_service_disabled_title'.tr(),
|
||||
content: 'map_location_service_disabled_content'.tr(),
|
||||
cancel: 'map_location_dialog_cancel'.tr(),
|
||||
ok: 'map_location_dialog_yes'.tr(),
|
||||
onOk: () async {
|
||||
await Geolocator.openLocationSettings();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
class LocationPermissionDisabledDialog extends ConfirmDialog {
|
||||
LocationPermissionDisabledDialog({Key? key})
|
||||
: super(
|
||||
key: key,
|
||||
title: 'map_no_location_permission_title'.tr(),
|
||||
content: 'map_no_location_permission_content'.tr(),
|
||||
cancel: 'map_location_dialog_cancel'.tr(),
|
||||
ok: 'map_location_dialog_yes'.tr(),
|
||||
onOk: () {},
|
||||
);
|
||||
}
|
||||
138
mobile/lib/modules/map/ui/map_page_app_bar.dart
Normal file
138
mobile/lib/modules/map/ui/map_page_app_bar.dart
Normal file
@@ -0,0 +1,138 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/asset_grid/disable_multi_select_button.dart';
|
||||
import 'package:immich_mobile/modules/map/ui/map_settings_dialog.dart';
|
||||
|
||||
class MapAppBar extends HookWidget implements PreferredSizeWidget {
|
||||
final ValueNotifier<bool> selectionEnabled;
|
||||
final int selectedAssetsLength;
|
||||
final bool isDarkTheme;
|
||||
|
||||
final void Function() onShare;
|
||||
final void Function() onFavorite;
|
||||
final void Function() onArchive;
|
||||
|
||||
const MapAppBar({
|
||||
super.key,
|
||||
required this.selectionEnabled,
|
||||
required this.selectedAssetsLength,
|
||||
required this.onShare,
|
||||
required this.onArchive,
|
||||
required this.onFavorite,
|
||||
this.isDarkTheme = false,
|
||||
});
|
||||
|
||||
List<Widget> buildNonSelectionWidgets(BuildContext context) {
|
||||
return [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 15, top: 15),
|
||||
child: ElevatedButton(
|
||||
onPressed: () => AutoRouter.of(context).pop(),
|
||||
style: ElevatedButton.styleFrom(
|
||||
shape: const CircleBorder(),
|
||||
padding: const EdgeInsets.all(12),
|
||||
),
|
||||
child: const Icon(Icons.arrow_back_ios_new_rounded, size: 22),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 15, top: 15),
|
||||
child: ElevatedButton(
|
||||
onPressed: () => showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext _) {
|
||||
return const MapSettingsDialog();
|
||||
},
|
||||
),
|
||||
style: ElevatedButton.styleFrom(
|
||||
shape: const CircleBorder(),
|
||||
padding: const EdgeInsets.all(12),
|
||||
),
|
||||
child: const Icon(Icons.more_vert_rounded, size: 22),
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
List<Widget> buildSelectionWidgets() {
|
||||
return [
|
||||
DisableMultiSelectButton(
|
||||
onPressed: () {
|
||||
selectionEnabled.value = false;
|
||||
},
|
||||
selectedItemCount: selectedAssetsLength,
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
// Share button
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 15),
|
||||
child: ElevatedButton(
|
||||
onPressed: onShare,
|
||||
style: ElevatedButton.styleFrom(
|
||||
shape: const CircleBorder(),
|
||||
padding: const EdgeInsets.all(12),
|
||||
),
|
||||
child: Icon(
|
||||
Platform.isAndroid
|
||||
? Icons.share_rounded
|
||||
: Icons.ios_share_rounded,
|
||||
size: 22,
|
||||
),
|
||||
),
|
||||
),
|
||||
// Favorite button
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 15),
|
||||
child: ElevatedButton(
|
||||
onPressed: onFavorite,
|
||||
style: ElevatedButton.styleFrom(
|
||||
shape: const CircleBorder(),
|
||||
padding: const EdgeInsets.all(12),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.favorite,
|
||||
size: 22,
|
||||
),
|
||||
),
|
||||
),
|
||||
// Archive Button
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 10, top: 15),
|
||||
child: ElevatedButton(
|
||||
onPressed: onArchive,
|
||||
style: ElevatedButton.styleFrom(
|
||||
shape: const CircleBorder(),
|
||||
padding: const EdgeInsets.all(12),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.archive,
|
||||
size: 22,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(top: MediaQuery.of(context).padding.top + 15),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
if (!selectionEnabled.value) ...buildNonSelectionWidgets(context),
|
||||
if (selectionEnabled.value) ...buildSelectionWidgets(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Size get preferredSize => const Size.fromHeight(100);
|
||||
}
|
||||
368
mobile/lib/modules/map/ui/map_page_bottom_sheet.dart
Normal file
368
mobile/lib/modules/map/ui/map_page_bottom_sheet.dart
Normal file
@@ -0,0 +1,368 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/providers/render_list.provider.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid_view.dart';
|
||||
import 'package:immich_mobile/modules/map/models/map_page_event.model.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/ui/drag_sheet.dart';
|
||||
import 'package:immich_mobile/utils/color_filter_generator.dart';
|
||||
import 'package:immich_mobile/utils/debounce.dart';
|
||||
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
class MapPageBottomSheet extends StatefulHookConsumerWidget {
|
||||
final Stream mapPageEventStream;
|
||||
final StreamController bottomSheetEventSC;
|
||||
final bool selectionEnabled;
|
||||
final ImmichAssetGridSelectionListener selectionlistener;
|
||||
final bool isDarkTheme;
|
||||
|
||||
const MapPageBottomSheet({
|
||||
super.key,
|
||||
required this.mapPageEventStream,
|
||||
required this.bottomSheetEventSC,
|
||||
required this.selectionEnabled,
|
||||
required this.selectionlistener,
|
||||
this.isDarkTheme = false,
|
||||
});
|
||||
|
||||
@override
|
||||
AssetsInBoundBottomSheetState createState() =>
|
||||
AssetsInBoundBottomSheetState();
|
||||
}
|
||||
|
||||
class AssetsInBoundBottomSheetState extends ConsumerState<MapPageBottomSheet> {
|
||||
// Non-State variables
|
||||
bool userTappedOnMap = false;
|
||||
RenderList? _cachedRenderList;
|
||||
int assetOffsetInSheet = -1;
|
||||
late final DraggableScrollableController bottomSheetController;
|
||||
late final Debounce debounce;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
bottomSheetController = DraggableScrollableController();
|
||||
debounce = Debounce(
|
||||
const Duration(milliseconds: 100),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
|
||||
final bottomPadding =
|
||||
Platform.isAndroid ? MediaQuery.of(context).padding.bottom - 10 : 0.0;
|
||||
final maxHeight = MediaQuery.of(context).size.height - bottomPadding;
|
||||
final isSheetScrolled = useState(false);
|
||||
final isSheetExpanded = useState(false);
|
||||
final assetsInBound = useState(<Asset>[]);
|
||||
final currentExtend = useState(0.1);
|
||||
|
||||
void handleMapPageEvents(dynamic event) {
|
||||
if (event is MapPageAssetsInBoundUpdated) {
|
||||
assetsInBound.value = event.assets;
|
||||
} else if (event is MapPageOnTapEvent) {
|
||||
userTappedOnMap = true;
|
||||
assetOffsetInSheet = -1;
|
||||
bottomSheetController.animateTo(
|
||||
0.1,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.linearToEaseOut,
|
||||
);
|
||||
isSheetScrolled.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(
|
||||
() {
|
||||
final mapPageEventSubscription =
|
||||
widget.mapPageEventStream.listen(handleMapPageEvents);
|
||||
return mapPageEventSubscription.cancel;
|
||||
},
|
||||
[widget.mapPageEventStream],
|
||||
);
|
||||
|
||||
void handleVisibleItems(ItemPosition start, ItemPosition end) {
|
||||
final renderElement = _cachedRenderList?.elements[start.index];
|
||||
if (renderElement == null) {
|
||||
return;
|
||||
}
|
||||
final rowOffset = renderElement.offset;
|
||||
if ((-start.itemLeadingEdge) != 0) {
|
||||
var columnOffset = -start.itemLeadingEdge ~/ 0.05;
|
||||
columnOffset = columnOffset < renderElement.totalCount
|
||||
? columnOffset
|
||||
: renderElement.totalCount - 1;
|
||||
assetOffsetInSheet = rowOffset + columnOffset;
|
||||
final asset = _cachedRenderList?.allAssets?[assetOffsetInSheet];
|
||||
userTappedOnMap = false;
|
||||
if (!userTappedOnMap && isSheetExpanded.value) {
|
||||
widget.bottomSheetEventSC.add(
|
||||
MapPageBottomSheetScrolled(asset),
|
||||
);
|
||||
}
|
||||
if (isSheetExpanded.value) {
|
||||
isSheetScrolled.value = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void visibleItemsListener(ItemPosition start, ItemPosition end) {
|
||||
if (_cachedRenderList == null) {
|
||||
debounce.dispose();
|
||||
return;
|
||||
}
|
||||
debounce.call(() => handleVisibleItems(start, end));
|
||||
}
|
||||
|
||||
Widget buildNoPhotosWidget() {
|
||||
const image = Image(
|
||||
image: AssetImage('assets/lighthouse.png'),
|
||||
);
|
||||
|
||||
return isSheetExpanded.value
|
||||
? Column(
|
||||
children: [
|
||||
const SizedBox(
|
||||
height: 80,
|
||||
),
|
||||
SizedBox(
|
||||
height: 150,
|
||||
width: 150,
|
||||
child: isDarkMode
|
||||
? const InvertionFilter(
|
||||
child: SaturationFilter(
|
||||
saturation: -1,
|
||||
child: BrightnessFilter(
|
||||
brightness: -5,
|
||||
child: image,
|
||||
),
|
||||
),
|
||||
)
|
||||
: image,
|
||||
),
|
||||
const SizedBox(
|
||||
height: 20,
|
||||
),
|
||||
Text(
|
||||
"map_zoom_to_see_photos".tr(),
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
color: Theme.of(context).textTheme.displayLarge?.color,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: const SizedBox.shrink();
|
||||
}
|
||||
|
||||
void onTapMapButton() {
|
||||
if (assetOffsetInSheet != -1) {
|
||||
widget.bottomSheetEventSC.add(
|
||||
MapPageZoomToAsset(
|
||||
_cachedRenderList?.allAssets?[assetOffsetInSheet],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget buildDragHandle(ScrollController scrollController) {
|
||||
final textToDisplay = assetsInBound.value.isNotEmpty
|
||||
? "${assetsInBound.value.length} photo${assetsInBound.value.length > 1 ? "s" : ""}"
|
||||
: "map_no_assets_in_bounds".tr();
|
||||
final dragHandle = Container(
|
||||
height: 60,
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: isDarkMode ? Colors.grey[900] : Colors.grey[100],
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const SizedBox(height: 5),
|
||||
const CustomDraggingHandle(),
|
||||
const SizedBox(height: 15),
|
||||
Text(
|
||||
textToDisplay,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: Theme.of(context).textTheme.displayLarge?.color,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Divider(
|
||||
height: 10,
|
||||
color: Theme.of(context)
|
||||
.textTheme
|
||||
.displayLarge
|
||||
?.color
|
||||
?.withOpacity(0.5),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (isSheetExpanded.value && isSheetScrolled.value)
|
||||
Positioned(
|
||||
top: 5,
|
||||
right: 10,
|
||||
child: IconButton(
|
||||
icon: Icon(
|
||||
Icons.map_outlined,
|
||||
color: Theme.of(context).textTheme.displayLarge?.color,
|
||||
),
|
||||
iconSize: 20,
|
||||
tooltip: 'Zoom to bounds',
|
||||
onPressed: onTapMapButton,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
return SingleChildScrollView(
|
||||
controller: scrollController,
|
||||
physics: const ClampingScrollPhysics(),
|
||||
child: dragHandle,
|
||||
);
|
||||
}
|
||||
|
||||
return NotificationListener<DraggableScrollableNotification>(
|
||||
onNotification: (DraggableScrollableNotification notification) {
|
||||
final sheetExtended = notification.extent > 0.2;
|
||||
isSheetExpanded.value = sheetExtended;
|
||||
currentExtend.value = notification.extent;
|
||||
if (!sheetExtended) {
|
||||
// reset state
|
||||
userTappedOnMap = false;
|
||||
assetOffsetInSheet = -1;
|
||||
isSheetScrolled.value = false;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: bottomPadding,
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
DraggableScrollableSheet(
|
||||
controller: bottomSheetController,
|
||||
initialChildSize: 0.1,
|
||||
minChildSize: 0.1,
|
||||
maxChildSize: 0.55,
|
||||
snap: true,
|
||||
builder: (
|
||||
BuildContext context,
|
||||
ScrollController scrollController,
|
||||
) {
|
||||
return Card(
|
||||
color: isDarkMode ? Colors.grey[900] : Colors.grey[100],
|
||||
surfaceTintColor: Colors.transparent,
|
||||
elevation: 18.0,
|
||||
margin: const EdgeInsets.all(0),
|
||||
child: Column(
|
||||
children: [
|
||||
buildDragHandle(scrollController),
|
||||
if (isSheetExpanded.value &&
|
||||
assetsInBound.value.isNotEmpty)
|
||||
ref
|
||||
.watch(
|
||||
renderListProvider(
|
||||
assetsInBound.value,
|
||||
),
|
||||
)
|
||||
.when(
|
||||
data: (renderList) {
|
||||
_cachedRenderList = renderList;
|
||||
final assetGrid = ImmichAssetGrid(
|
||||
shrinkWrap: true,
|
||||
renderList: renderList,
|
||||
showDragScroll: false,
|
||||
selectionActive: widget.selectionEnabled,
|
||||
showMultiSelectIndicator: false,
|
||||
listener: widget.selectionlistener,
|
||||
visibleItemsListener: visibleItemsListener,
|
||||
);
|
||||
|
||||
return Expanded(child: assetGrid);
|
||||
},
|
||||
error: (error, stackTrace) {
|
||||
log.warning(
|
||||
"Cannot get assets in the current map bounds ${error.toString()}",
|
||||
error,
|
||||
stackTrace,
|
||||
);
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
loading: () => const SizedBox.shrink(),
|
||||
),
|
||||
if (isSheetExpanded.value && assetsInBound.value.isEmpty)
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
child: buildNoPhotosWidget(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
Positioned(
|
||||
bottom: maxHeight * currentExtend.value,
|
||||
left: 0,
|
||||
child: GestureDetector(
|
||||
onTap: () => launchUrl(
|
||||
Uri.parse('https://openstreetmap.org/copyright'),
|
||||
),
|
||||
child: ColoredBox(
|
||||
color: (widget.isDarkTheme
|
||||
? Colors.grey[900]
|
||||
: Colors.grey[100])!,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(3),
|
||||
child: Text(
|
||||
'© OpenStreetMap contributors',
|
||||
style: TextStyle(
|
||||
fontSize: 6,
|
||||
color: !widget.isDarkTheme
|
||||
? Colors.grey[900]
|
||||
: Colors.grey[100],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
bottom: maxHeight * (0.14 + (currentExtend.value - 0.1)),
|
||||
right: 15,
|
||||
child: ElevatedButton(
|
||||
onPressed: () => widget.bottomSheetEventSC
|
||||
.add(const MapPageZoomToLocation()),
|
||||
style: ElevatedButton.styleFrom(
|
||||
shape: const CircleBorder(),
|
||||
padding: const EdgeInsets.all(12),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.my_location,
|
||||
size: 22,
|
||||
fill: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
193
mobile/lib/modules/map/ui/map_settings_dialog.dart
Normal file
193
mobile/lib/modules/map/ui/map_settings_dialog.dart
Normal file
@@ -0,0 +1,193 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/map/providers/map_state.provider.dart';
|
||||
|
||||
class MapSettingsDialog extends HookConsumerWidget {
|
||||
const MapSettingsDialog({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final mapSettingsNotifier = ref.read(mapStateNotifier.notifier);
|
||||
final mapSettings = ref.read(mapStateNotifier);
|
||||
final isDarkMode = useState(mapSettings.isDarkTheme);
|
||||
final showFavoriteOnly = useState(mapSettings.showFavoriteOnly);
|
||||
final showRelativeDate = useState(mapSettings.relativeTime);
|
||||
final ThemeData theme = Theme.of(context);
|
||||
|
||||
Widget buildMapThemeSetting() {
|
||||
return SwitchListTile.adaptive(
|
||||
value: isDarkMode.value,
|
||||
onChanged: (value) {
|
||||
isDarkMode.value = value;
|
||||
},
|
||||
activeColor: theme.primaryColor,
|
||||
dense: true,
|
||||
title: Text(
|
||||
"map_settings_dark_mode".tr(),
|
||||
style:
|
||||
theme.textTheme.labelLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildFavoriteOnlySetting() {
|
||||
return SwitchListTile.adaptive(
|
||||
value: showFavoriteOnly.value,
|
||||
onChanged: (value) {
|
||||
showFavoriteOnly.value = value;
|
||||
},
|
||||
activeColor: theme.primaryColor,
|
||||
dense: true,
|
||||
title: Text(
|
||||
"map_settings_only_show_favorites".tr(),
|
||||
style:
|
||||
theme.textTheme.labelLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildDateRangeSetting() {
|
||||
final now = DateTime.now();
|
||||
return DropdownMenu(
|
||||
enableSearch: false,
|
||||
enableFilter: false,
|
||||
initialSelection: showRelativeDate.value,
|
||||
onSelected: (value) {
|
||||
showRelativeDate.value = value!;
|
||||
},
|
||||
dropdownMenuEntries: [
|
||||
const DropdownMenuEntry(value: 0, label: "All"),
|
||||
const DropdownMenuEntry(
|
||||
value: 1,
|
||||
label: "Past 24 hours",
|
||||
),
|
||||
const DropdownMenuEntry(
|
||||
value: 7,
|
||||
label: "Past 7 days",
|
||||
),
|
||||
const DropdownMenuEntry(
|
||||
value: 30,
|
||||
label: "Past 30 days",
|
||||
),
|
||||
DropdownMenuEntry(
|
||||
value: now
|
||||
.difference(
|
||||
DateTime(
|
||||
now.year - 1,
|
||||
now.month,
|
||||
now.day,
|
||||
now.hour,
|
||||
now.minute,
|
||||
now.second,
|
||||
),
|
||||
)
|
||||
.inDays,
|
||||
label: "Past year",
|
||||
),
|
||||
DropdownMenuEntry(
|
||||
value: now
|
||||
.difference(
|
||||
DateTime(
|
||||
now.year - 3,
|
||||
now.month,
|
||||
now.day,
|
||||
now.hour,
|
||||
now.minute,
|
||||
now.second,
|
||||
),
|
||||
)
|
||||
.inDays,
|
||||
label: "Past 3 years",
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> getDialogActions() {
|
||||
return <Widget>[
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
style: TextButton.styleFrom(
|
||||
backgroundColor:
|
||||
mapSettings.isDarkTheme ? Colors.grey[100] : Colors.grey[700],
|
||||
),
|
||||
child: Text(
|
||||
"map_settings_dialog_cancel".tr(),
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color:
|
||||
mapSettings.isDarkTheme ? Colors.grey[900] : Colors.grey[100],
|
||||
),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
mapSettingsNotifier.switchTheme(isDarkMode.value);
|
||||
mapSettingsNotifier.switchFavoriteOnly(showFavoriteOnly.value);
|
||||
mapSettingsNotifier.setRelativeTime(showRelativeDate.value);
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
style: TextButton.styleFrom(
|
||||
backgroundColor: theme.primaryColor,
|
||||
),
|
||||
child: Text(
|
||||
"map_settings_dialog_save".tr(),
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: theme.primaryTextTheme.labelLarge?.color,
|
||||
),
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
return AlertDialog(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
||||
title: Center(
|
||||
child: Text(
|
||||
"map_settings_dialog_title".tr(),
|
||||
style: TextStyle(
|
||||
color: theme.primaryColor,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 18,
|
||||
),
|
||||
),
|
||||
),
|
||||
content: SizedBox(
|
||||
width: double.maxFinite,
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.of(context).size.height * 0.6,
|
||||
),
|
||||
child: ListView(
|
||||
shrinkWrap: true,
|
||||
children: [
|
||||
buildMapThemeSetting(),
|
||||
buildFavoriteOnlySetting(),
|
||||
const SizedBox(
|
||||
height: 10,
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
"map_settings_only_relative_range".tr(),
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
buildDateRangeSetting(),
|
||||
],
|
||||
),
|
||||
),
|
||||
].toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: getDialogActions(),
|
||||
actionsAlignment: MainAxisAlignment.spaceEvenly,
|
||||
);
|
||||
}
|
||||
}
|
||||
76
mobile/lib/modules/map/ui/map_thumbnail.dart
Normal file
76
mobile/lib/modules/map/ui/map_thumbnail.dart
Normal file
@@ -0,0 +1,76 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/plugin_api.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/utils/color_filter_generator.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
// A non-interactive thumbnail of a map in the given coordinates with optional markers
|
||||
class MapThumbnail extends HookConsumerWidget {
|
||||
final Function(TapPosition, LatLng)? onTap;
|
||||
final LatLng coords;
|
||||
final double zoom;
|
||||
final List<Marker> markers;
|
||||
final double height;
|
||||
final bool showAttribution;
|
||||
final bool isDarkTheme;
|
||||
|
||||
const MapThumbnail({
|
||||
super.key,
|
||||
required this.coords,
|
||||
required this.height,
|
||||
this.onTap,
|
||||
this.zoom = 1,
|
||||
this.showAttribution = true,
|
||||
this.isDarkTheme = false,
|
||||
this.markers = const [],
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final tileLayer = TileLayer(
|
||||
urlTemplate: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
|
||||
subdomains: const ['a', 'b', 'c'],
|
||||
);
|
||||
|
||||
return SizedBox(
|
||||
height: height,
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(15)),
|
||||
child: FlutterMap(
|
||||
options: MapOptions(
|
||||
interactiveFlags: InteractiveFlag.none,
|
||||
center: coords,
|
||||
zoom: zoom,
|
||||
onTap: onTap,
|
||||
),
|
||||
nonRotatedChildren: [
|
||||
if (showAttribution)
|
||||
RichAttributionWidget(
|
||||
animationConfig: const ScaleRAWA(),
|
||||
attributions: [
|
||||
TextSourceAttribution(
|
||||
'OpenStreetMap contributors',
|
||||
onTap: () => launchUrl(
|
||||
Uri.parse('https://openstreetmap.org/copyright'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
children: [
|
||||
isDarkTheme
|
||||
? InvertionFilter(
|
||||
child: SaturationFilter(
|
||||
saturation: -1,
|
||||
child: tileLayer,
|
||||
),
|
||||
)
|
||||
: tileLayer,
|
||||
if (markers.isNotEmpty) MarkerLayer(markers: markers),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
508
mobile/lib/modules/map/views/map_page.dart
Normal file
508
mobile/lib/modules/map/views/map_page.dart
Normal file
@@ -0,0 +1,508 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:flutter_map/plugin_api.dart';
|
||||
import 'package:flutter_map_heatmap/flutter_map_heatmap.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/map/models/map_page_event.model.dart';
|
||||
import 'package:immich_mobile/modules/map/providers/map_marker.provider.dart';
|
||||
import 'package:immich_mobile/modules/map/providers/map_state.provider.dart';
|
||||
import 'package:immich_mobile/modules/map/ui/asset_marker_icon.dart';
|
||||
import 'package:immich_mobile/modules/map/ui/location_dialog.dart';
|
||||
import 'package:immich_mobile/modules/map/ui/map_page_bottom_sheet.dart';
|
||||
import 'package:immich_mobile/modules/map/ui/map_page_app_bar.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_toast.dart';
|
||||
import 'package:immich_mobile/utils/color_filter_generator.dart';
|
||||
import 'package:immich_mobile/utils/debounce.dart';
|
||||
import 'package:immich_mobile/utils/flutter_map_extensions.dart';
|
||||
import 'package:immich_mobile/utils/immich_app_theme.dart';
|
||||
import 'package:immich_mobile/utils/selection_handlers.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
class MapPage extends StatefulHookConsumerWidget {
|
||||
const MapPage({super.key});
|
||||
|
||||
@override
|
||||
MapPageState createState() => MapPageState();
|
||||
}
|
||||
|
||||
class MapPageState extends ConsumerState<MapPage> {
|
||||
// Non-State variables
|
||||
late final MapController mapController;
|
||||
// Streams are used instead of callbacks to prevent unnecessary rebuilds on events
|
||||
final StreamController mapPageEventSC =
|
||||
StreamController<MapPageEventBase>.broadcast();
|
||||
final StreamController bottomSheetEventSC =
|
||||
StreamController<MapPageEventBase>.broadcast();
|
||||
late final Stream bottomSheetEventStream;
|
||||
// Making assets in bounds as a state variable will result in un-necessary rebuilds of the bottom sheet
|
||||
// resulting in it getting reloaded each time a map move occurs
|
||||
Set<AssetMarkerData> assetsInBounds = {};
|
||||
// TODO: Migrate the handling to MapEventMove#id when flutter_map is upgraded
|
||||
// https://github.com/fleaflet/flutter_map/issues/1542
|
||||
// The below is used instead of MapEventMove#id to handle event from controller
|
||||
// in onMapEvent() since MapEventMove#id is not populated properly in the
|
||||
// current version of flutter_map(4.0.0) used
|
||||
bool forceAssetUpdate = false;
|
||||
late final Debounce debounce;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
mapController = MapController();
|
||||
bottomSheetEventStream = bottomSheetEventSC.stream;
|
||||
// Map zoom events and move events are triggered often. Throttle the call to limit rebuilds
|
||||
debounce = Debounce(
|
||||
const Duration(milliseconds: 300),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
debounce.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void reloadAssetsInBound(
|
||||
Set<AssetMarkerData>? assetMarkers, {
|
||||
bool forceReload = false,
|
||||
}) {
|
||||
final bounds = mapController.bounds;
|
||||
if (bounds != null) {
|
||||
final oldAssetsInBounds = assetsInBounds.toSet();
|
||||
assetsInBounds =
|
||||
assetMarkers?.where((e) => bounds.contains(e.point)).toSet() ?? {};
|
||||
final shouldReload = forceReload ||
|
||||
assetsInBounds.difference(oldAssetsInBounds).isNotEmpty ||
|
||||
assetsInBounds.length != oldAssetsInBounds.length;
|
||||
if (shouldReload) {
|
||||
mapPageEventSC.add(
|
||||
MapPageAssetsInBoundUpdated(
|
||||
assetsInBounds.map((e) => e.asset).toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void openAssetInViewer(Asset asset) {
|
||||
AutoRouter.of(context).push(
|
||||
GalleryViewerRoute(
|
||||
initialIndex: 0,
|
||||
loadAsset: (index) => asset,
|
||||
totalAssets: 1,
|
||||
heroOffset: 0,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final log = Logger("MapService");
|
||||
final isDarkTheme =
|
||||
ref.watch(mapStateNotifier.select((state) => state.isDarkTheme));
|
||||
final ValueNotifier<Set<AssetMarkerData>> mapMarkerData =
|
||||
useState(<AssetMarkerData>{});
|
||||
final ValueNotifier<AssetMarkerData?> closestAssetMarker = useState(null);
|
||||
final selectionEnabledHook = useState(false);
|
||||
final selectedAssets = useState(<Asset>{});
|
||||
final showLoadingIndicator = useState(false);
|
||||
final refetchMarkers = useState(true);
|
||||
|
||||
if (refetchMarkers.value) {
|
||||
mapMarkerData.value = ref.watch(mapMarkersProvider).when(
|
||||
skipLoadingOnRefresh: false,
|
||||
error: (error, stackTrace) {
|
||||
log.warning(
|
||||
"Cannot get map markers ${error.toString()}",
|
||||
error,
|
||||
stackTrace,
|
||||
);
|
||||
showLoadingIndicator.value = false;
|
||||
return {};
|
||||
},
|
||||
loading: () {
|
||||
showLoadingIndicator.value = true;
|
||||
return {};
|
||||
},
|
||||
data: (data) {
|
||||
showLoadingIndicator.value = false;
|
||||
refetchMarkers.value = false;
|
||||
closestAssetMarker.value = null;
|
||||
debounce(
|
||||
() => reloadAssetsInBound(
|
||||
mapMarkerData.value,
|
||||
forceReload: true,
|
||||
),
|
||||
);
|
||||
return data;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
ref.listen(mapStateNotifier, (previous, next) {
|
||||
bool shouldRefetch =
|
||||
previous?.showFavoriteOnly != next.showFavoriteOnly ||
|
||||
previous?.relativeTime != next.relativeTime;
|
||||
if (shouldRefetch) {
|
||||
refetchMarkers.value = shouldRefetch;
|
||||
ref.invalidate(mapMarkersProvider);
|
||||
}
|
||||
});
|
||||
|
||||
void onZoomToAssetEvent(Asset? assetInBottomSheet) {
|
||||
if (assetInBottomSheet != null) {
|
||||
final mapMarker = mapMarkerData.value
|
||||
.firstWhereOrNull((e) => e.asset.id == assetInBottomSheet.id);
|
||||
if (mapMarker != null) {
|
||||
const zoomLevel = 16.0;
|
||||
LatLng? newCenter = mapController.centerBoundsWithPadding(
|
||||
mapMarker.point,
|
||||
const Offset(0, -120),
|
||||
zoomLevel: zoomLevel,
|
||||
);
|
||||
if (newCenter != null) {
|
||||
forceAssetUpdate = true;
|
||||
mapController.move(newCenter, zoomLevel);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void onZoomToLocation() async {
|
||||
try {
|
||||
bool serviceEnabled = await Geolocator.isLocationServiceEnabled();
|
||||
if (!serviceEnabled) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => Theme(
|
||||
data: isDarkTheme ? immichDarkTheme : immichLightTheme,
|
||||
child: LocationServiceDisabledDialog(),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
LocationPermission permission = await Geolocator.checkPermission();
|
||||
bool shouldRequestPermission = false;
|
||||
|
||||
if (permission == LocationPermission.denied) {
|
||||
shouldRequestPermission = await showDialog(
|
||||
context: context,
|
||||
builder: (context) => Theme(
|
||||
data: isDarkTheme ? immichDarkTheme : immichLightTheme,
|
||||
child: LocationPermissionDisabledDialog(),
|
||||
),
|
||||
);
|
||||
if (shouldRequestPermission) {
|
||||
permission = await Geolocator.requestPermission();
|
||||
}
|
||||
}
|
||||
|
||||
if (permission == LocationPermission.denied ||
|
||||
permission == LocationPermission.deniedForever) {
|
||||
// Open app settings only if you did not request for permission before
|
||||
if (permission == LocationPermission.deniedForever &&
|
||||
!shouldRequestPermission) {
|
||||
await Geolocator.openAppSettings();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
Position currentUserLocation = await Geolocator.getCurrentPosition(
|
||||
desiredAccuracy: LocationAccuracy.medium,
|
||||
timeLimit: const Duration(seconds: 5),
|
||||
);
|
||||
|
||||
forceAssetUpdate = true;
|
||||
mapController.move(
|
||||
LatLng(currentUserLocation.latitude, currentUserLocation.longitude),
|
||||
12,
|
||||
);
|
||||
} catch (error) {
|
||||
log.severe(
|
||||
"Cannot get user's current location due to ${error.toString()}",
|
||||
);
|
||||
if (context.mounted) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
toastType: ToastType.error,
|
||||
msg: "map_cannot_get_user_location".tr(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void handleBottomSheetEvents(dynamic event) {
|
||||
if (event is MapPageBottomSheetScrolled) {
|
||||
final assetInBottomSheet = event.asset;
|
||||
if (assetInBottomSheet != null) {
|
||||
final mapMarker = mapMarkerData.value
|
||||
.firstWhereOrNull((e) => e.asset.id == assetInBottomSheet.id);
|
||||
closestAssetMarker.value = mapMarker;
|
||||
if (mapMarker != null && mapController.zoom >= 5) {
|
||||
LatLng? newCenter = mapController.centerBoundsWithPadding(
|
||||
mapMarker.point,
|
||||
const Offset(0, -120),
|
||||
);
|
||||
if (newCenter != null) {
|
||||
mapController.move(
|
||||
newCenter,
|
||||
mapController.zoom,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (event is MapPageZoomToAsset) {
|
||||
onZoomToAssetEvent(event.asset);
|
||||
} else if (event is MapPageZoomToLocation) {
|
||||
onZoomToLocation();
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(
|
||||
() {
|
||||
final bottomSheetEventSubscription =
|
||||
bottomSheetEventStream.listen(handleBottomSheetEvents);
|
||||
return bottomSheetEventSubscription.cancel;
|
||||
},
|
||||
[bottomSheetEventStream],
|
||||
);
|
||||
|
||||
void handleMapTapEvent(LatLng tapPosition) {
|
||||
const d = Distance();
|
||||
final assetsInBoundsList = assetsInBounds.toList();
|
||||
assetsInBoundsList.sort(
|
||||
(a, b) => d
|
||||
.distance(a.point, tapPosition)
|
||||
.compareTo(d.distance(b.point, tapPosition)),
|
||||
);
|
||||
// First asset less than the threshold from the tap point
|
||||
final nearestAsset = assetsInBoundsList.firstWhereOrNull(
|
||||
(element) =>
|
||||
d.distance(element.point, tapPosition) <
|
||||
mapController.getTapThresholdForZoomLevel(),
|
||||
);
|
||||
// Reset marker if no assets are near the tap point
|
||||
if (nearestAsset == null && closestAssetMarker.value != null) {
|
||||
selectionEnabledHook.value = false;
|
||||
mapPageEventSC.add(
|
||||
const MapPageOnTapEvent(),
|
||||
);
|
||||
}
|
||||
closestAssetMarker.value = nearestAsset;
|
||||
}
|
||||
|
||||
void onMapEvent(MapEvent mapEvent) {
|
||||
if (mapEvent is MapEventMove || mapEvent is MapEventDoubleTapZoom) {
|
||||
if (forceAssetUpdate ||
|
||||
mapEvent.source != MapEventSource.mapController) {
|
||||
debounce(() {
|
||||
if (selectionEnabledHook.value) {
|
||||
selectionEnabledHook.value = false;
|
||||
}
|
||||
reloadAssetsInBound(
|
||||
mapMarkerData.value,
|
||||
forceReload: forceAssetUpdate,
|
||||
);
|
||||
forceAssetUpdate = false;
|
||||
});
|
||||
}
|
||||
} else if (mapEvent is MapEventTap) {
|
||||
handleMapTapEvent(mapEvent.tapPosition);
|
||||
}
|
||||
}
|
||||
|
||||
void onShareAsset() {
|
||||
handleShareAssets(ref, context, selectedAssets.value.toList());
|
||||
selectionEnabledHook.value = false;
|
||||
}
|
||||
|
||||
void onFavoriteAsset() async {
|
||||
showLoadingIndicator.value = true;
|
||||
try {
|
||||
await handleFavoriteAssets(ref, context, selectedAssets.value.toList());
|
||||
} finally {
|
||||
showLoadingIndicator.value = false;
|
||||
selectionEnabledHook.value = false;
|
||||
refetchMarkers.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
void onArchiveAsset() async {
|
||||
showLoadingIndicator.value = true;
|
||||
try {
|
||||
await handleArchiveAssets(ref, context, selectedAssets.value.toList());
|
||||
} finally {
|
||||
showLoadingIndicator.value = false;
|
||||
selectionEnabledHook.value = false;
|
||||
refetchMarkers.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
void selectionListener(bool isMultiSelect, Set<Asset> selection) {
|
||||
selectionEnabledHook.value = isMultiSelect;
|
||||
selectedAssets.value = selection;
|
||||
}
|
||||
|
||||
final tileLayer = TileLayer(
|
||||
urlTemplate: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
|
||||
subdomains: const ['a', 'b', 'c'],
|
||||
maxNativeZoom: 19,
|
||||
maxZoom: 19,
|
||||
);
|
||||
|
||||
final darkTileLayer = InvertionFilter(
|
||||
child: SaturationFilter(
|
||||
saturation: -1,
|
||||
child: BrightnessFilter(
|
||||
brightness: -1,
|
||||
child: tileLayer,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final markerLayer = MarkerLayer(
|
||||
markers: [
|
||||
if (closestAssetMarker.value != null)
|
||||
AssetMarker(
|
||||
remoteId: closestAssetMarker.value!.asset.remoteId!,
|
||||
anchorPos: AnchorPos.align(AnchorAlign.top),
|
||||
point: closestAssetMarker.value!.point,
|
||||
width: 100,
|
||||
height: 100,
|
||||
builder: (ctx) => GestureDetector(
|
||||
onTap: () => openAssetInViewer(closestAssetMarker.value!.asset),
|
||||
child: AssetMarkerIcon(
|
||||
key: Key(closestAssetMarker.value!.asset.remoteId!),
|
||||
isDarkTheme: isDarkTheme,
|
||||
id: closestAssetMarker.value!.asset.remoteId!,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
final heatMapLayer = mapMarkerData.value.isNotEmpty
|
||||
? HeatMapLayer(
|
||||
heatMapDataSource: InMemoryHeatMapDataSource(
|
||||
data: mapMarkerData.value
|
||||
.map(
|
||||
(e) => WeightedLatLng(
|
||||
LatLng(e.point.latitude, e.point.longitude),
|
||||
1,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
heatMapOptions: HeatMapOptions(
|
||||
radius: 60,
|
||||
layerOpacity: 0.5,
|
||||
gradient: {
|
||||
0.20: Colors.deepPurple,
|
||||
0.40: Colors.blue,
|
||||
0.60: Colors.green,
|
||||
0.95: Colors.yellow,
|
||||
1.0: Colors.deepOrange,
|
||||
},
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink();
|
||||
|
||||
return AnnotatedRegion<SystemUiOverlayStyle>(
|
||||
value: SystemUiOverlayStyle(
|
||||
statusBarColor:
|
||||
(isDarkTheme ? Colors.black : Colors.white).withOpacity(0.5),
|
||||
statusBarIconBrightness:
|
||||
isDarkTheme ? Brightness.light : Brightness.dark,
|
||||
systemNavigationBarColor:
|
||||
isDarkTheme ? Colors.grey[900] : Colors.grey[100],
|
||||
systemNavigationBarIconBrightness:
|
||||
isDarkTheme ? Brightness.light : Brightness.dark,
|
||||
systemNavigationBarDividerColor: Colors.transparent,
|
||||
),
|
||||
child: Theme(
|
||||
// Override app theme based on map theme
|
||||
data: isDarkTheme ? immichDarkTheme : immichLightTheme,
|
||||
child: Scaffold(
|
||||
appBar: MapAppBar(
|
||||
isDarkTheme: isDarkTheme,
|
||||
selectionEnabled: selectionEnabledHook,
|
||||
selectedAssetsLength: selectedAssets.value.length,
|
||||
onShare: onShareAsset,
|
||||
onArchive: onArchiveAsset,
|
||||
onFavorite: onFavoriteAsset,
|
||||
),
|
||||
extendBodyBehindAppBar: true,
|
||||
body: Stack(
|
||||
children: [
|
||||
FlutterMap(
|
||||
mapController: mapController,
|
||||
options: MapOptions(
|
||||
maxBounds:
|
||||
LatLngBounds(LatLng(-90, -180.0), LatLng(90.0, 180.0)),
|
||||
interactiveFlags: InteractiveFlag.doubleTapZoom |
|
||||
InteractiveFlag.drag |
|
||||
InteractiveFlag.flingAnimation |
|
||||
InteractiveFlag.pinchMove |
|
||||
InteractiveFlag.pinchZoom,
|
||||
center: LatLng(20, 20),
|
||||
zoom: 2,
|
||||
minZoom: 1,
|
||||
maxZoom: 18, // max level supported by OSM,
|
||||
onMapReady: () {
|
||||
mapController.mapEventStream.listen(onMapEvent);
|
||||
},
|
||||
),
|
||||
children: [
|
||||
isDarkTheme ? darkTileLayer : tileLayer,
|
||||
heatMapLayer,
|
||||
markerLayer,
|
||||
],
|
||||
),
|
||||
MapPageBottomSheet(
|
||||
mapPageEventStream: mapPageEventSC.stream,
|
||||
bottomSheetEventSC: bottomSheetEventSC,
|
||||
selectionEnabled: selectionEnabledHook.value,
|
||||
selectionlistener: selectionListener,
|
||||
isDarkTheme: isDarkTheme,
|
||||
),
|
||||
if (showLoadingIndicator.value)
|
||||
Positioned(
|
||||
top: MediaQuery.of(context).size.height * 0.35,
|
||||
left: MediaQuery.of(context).size.width * 0.425,
|
||||
child: const ImmichLoadingIndicator(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AssetMarker extends Marker {
|
||||
String remoteId;
|
||||
|
||||
AssetMarker({
|
||||
super.key,
|
||||
required this.remoteId,
|
||||
super.anchorPos,
|
||||
required super.point,
|
||||
super.width = 100.0,
|
||||
super.height = 100.0,
|
||||
required super.builder,
|
||||
});
|
||||
}
|
||||
110
mobile/lib/modules/search/ui/curated_places_row.dart
Normal file
110
mobile/lib/modules/search/ui/curated_places_row.dart
Normal file
@@ -0,0 +1,110 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/modules/map/ui/map_thumbnail.dart';
|
||||
import 'package:immich_mobile/modules/search/ui/curated_row.dart';
|
||||
import 'package:immich_mobile/modules/search/ui/thumbnail_with_info.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/shared/models/store.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
class CuratedPlacesRow extends CuratedRow {
|
||||
const CuratedPlacesRow({
|
||||
super.key,
|
||||
required super.content,
|
||||
super.imageSize,
|
||||
super.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget buildMapThumbnail() {
|
||||
return GestureDetector(
|
||||
onTap: () => AutoRouter.of(context).push(
|
||||
const MapRoute(),
|
||||
),
|
||||
child: SizedBox(
|
||||
height: imageSize,
|
||||
width: imageSize,
|
||||
child: Stack(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 10.0),
|
||||
child: MapThumbnail(
|
||||
zoom: 2,
|
||||
coords: LatLng(
|
||||
47,
|
||||
5,
|
||||
),
|
||||
height: imageSize,
|
||||
showAttribution: false,
|
||||
isDarkTheme: Theme.of(context).brightness == Brightness.dark,
|
||||
),
|
||||
),
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
color: Colors.black,
|
||||
gradient: LinearGradient(
|
||||
begin: FractionalOffset.topCenter,
|
||||
end: FractionalOffset.bottomCenter,
|
||||
colors: [
|
||||
Colors.blueGrey.withOpacity(0.0),
|
||||
Colors.black.withOpacity(0.4),
|
||||
],
|
||||
stops: const [0.0, 1.0],
|
||||
),
|
||||
),
|
||||
),
|
||||
const Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(bottom: 10),
|
||||
child: Text(
|
||||
"Your Map",
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
),
|
||||
itemBuilder: (context, index) {
|
||||
// Injecting Map thumbnail as the first element
|
||||
if (index == 0) {
|
||||
return buildMapThumbnail();
|
||||
}
|
||||
// The actual index is 1 less than the virutal index since we inject map into the first position
|
||||
final actualIndex = index - 1;
|
||||
final object = content[actualIndex];
|
||||
final thumbnailRequestUrl =
|
||||
'${Store.get(StoreKey.serverEndpoint)}/asset/thumbnail/${object.id}';
|
||||
return SizedBox(
|
||||
width: imageSize,
|
||||
height: imageSize,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(right: 10.0),
|
||||
child: ThumbnailWithInfo(
|
||||
imageUrl: thumbnailRequestUrl,
|
||||
textInfo: object.label,
|
||||
onTap: () => onTap?.call(object, actualIndex),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
// Adding 1 to inject map thumbnail as first element
|
||||
itemCount: content.length + 1,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dart:math' as math;
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -7,7 +8,7 @@ import 'package:immich_mobile/modules/search/models/curated_content.dart';
|
||||
import 'package:immich_mobile/modules/search/providers/people.provider.dart';
|
||||
import 'package:immich_mobile/modules/search/providers/search_page_state.provider.dart';
|
||||
import 'package:immich_mobile/modules/search/ui/curated_people_row.dart';
|
||||
import 'package:immich_mobile/modules/search/ui/curated_row.dart';
|
||||
import 'package:immich_mobile/modules/search/ui/curated_places_row.dart';
|
||||
import 'package:immich_mobile/modules/search/ui/immich_search_bar.dart';
|
||||
import 'package:immich_mobile/modules/search/ui/person_name_edit_form.dart';
|
||||
import 'package:immich_mobile/modules/search/ui/search_row_title.dart';
|
||||
@@ -27,7 +28,7 @@ class SearchPage extends HookConsumerWidget {
|
||||
final curatedLocation = ref.watch(getCuratedLocationProvider);
|
||||
final curatedPeople = ref.watch(getCuratedPeopleProvider);
|
||||
var isDarkTheme = Theme.of(context).brightness == Brightness.dark;
|
||||
double imageSize = MediaQuery.of(context).size.width / 3;
|
||||
double imageSize = math.min(MediaQuery.of(context).size.width / 3, 150);
|
||||
|
||||
TextStyle categoryTitleStyle = const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
@@ -69,7 +70,7 @@ class SearchPage extends HookConsumerWidget {
|
||||
|
||||
buildPeople() {
|
||||
return SizedBox(
|
||||
height: MediaQuery.of(context).size.width / 3,
|
||||
height: imageSize,
|
||||
child: curatedPeople.when(
|
||||
loading: () => const Center(child: ImmichLoadingIndicator()),
|
||||
error: (err, stack) => Center(child: Text('Error: $err')),
|
||||
@@ -105,7 +106,7 @@ class SearchPage extends HookConsumerWidget {
|
||||
child: curatedLocation.when(
|
||||
loading: () => const Center(child: ImmichLoadingIndicator()),
|
||||
error: (err, stack) => Center(child: Text('Error: $err')),
|
||||
data: (locations) => CuratedRow(
|
||||
data: (locations) => CuratedPlacesRow(
|
||||
content: locations
|
||||
.map(
|
||||
(o) => CuratedContent(
|
||||
@@ -155,6 +156,7 @@ class SearchPage extends HookConsumerWidget {
|
||||
),
|
||||
top: 0,
|
||||
),
|
||||
const SizedBox(height: 10.0),
|
||||
buildPlaces(),
|
||||
const SizedBox(height: 24.0),
|
||||
Padding(
|
||||
|
||||
@@ -46,6 +46,9 @@ enum AppSettingsEnum<T> {
|
||||
advancedTroubleshooting<bool>(StoreKey.advancedTroubleshooting, null, false),
|
||||
logLevel<int>(StoreKey.logLevel, null, 5), // Level.INFO = 5
|
||||
preferRemoteImage<bool>(StoreKey.preferRemoteImage, null, false),
|
||||
mapThemeMode<bool>(StoreKey.mapThemeMode, null, false),
|
||||
mapShowFavoriteOnly<bool>(StoreKey.mapShowFavoriteOnly, null, false),
|
||||
mapRelativeDate<int>(StoreKey.mapRelativeDate, null, 0),
|
||||
;
|
||||
|
||||
const AppSettingsEnum(this.storeKey, this.hiveKey, this.defaultValue);
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:immich_mobile/modules/album/views/album_viewer_page.dart';
|
||||
import 'package:immich_mobile/modules/album/views/asset_selection_page.dart';
|
||||
import 'package:immich_mobile/modules/album/views/create_album_page.dart';
|
||||
import 'package:immich_mobile/modules/album/views/library_page.dart';
|
||||
import 'package:immich_mobile/modules/map/views/map_page.dart';
|
||||
import 'package:immich_mobile/modules/memories/models/memory.dart';
|
||||
import 'package:immich_mobile/modules/memories/views/memory_page.dart';
|
||||
import 'package:immich_mobile/modules/partner/views/partner_detail_page.dart';
|
||||
@@ -153,6 +154,7 @@ part 'router.gr.dart';
|
||||
),
|
||||
AutoRoute(page: AllPeoplePage, guards: [AuthGuard, DuplicateGuard]),
|
||||
AutoRoute(page: MemoryPage, guards: [AuthGuard, DuplicateGuard]),
|
||||
AutoRoute(page: MapPage, guards: [AuthGuard, DuplicateGuard]),
|
||||
AutoRoute(page: AlbumOptionsPage, guards: [AuthGuard, DuplicateGuard]),
|
||||
],
|
||||
)
|
||||
|
||||
@@ -296,6 +296,12 @@ class _$AppRouter extends RootStackRouter {
|
||||
),
|
||||
);
|
||||
},
|
||||
MapRoute.name: (routeData) {
|
||||
return MaterialPageX<dynamic>(
|
||||
routeData: routeData,
|
||||
child: const MapPage(),
|
||||
);
|
||||
},
|
||||
AlbumOptionsRoute.name: (routeData) {
|
||||
final args = routeData.argsAs<AlbumOptionsRouteArgs>();
|
||||
return MaterialPageX<dynamic>(
|
||||
@@ -605,6 +611,14 @@ class _$AppRouter extends RootStackRouter {
|
||||
duplicateGuard,
|
||||
],
|
||||
),
|
||||
RouteConfig(
|
||||
MapRoute.name,
|
||||
path: '/map-page',
|
||||
guards: [
|
||||
authGuard,
|
||||
duplicateGuard,
|
||||
],
|
||||
),
|
||||
RouteConfig(
|
||||
AlbumOptionsRoute.name,
|
||||
path: '/album-options-page',
|
||||
@@ -1337,6 +1351,17 @@ class MemoryRouteArgs {
|
||||
}
|
||||
}
|
||||
|
||||
/// [MapPage]
|
||||
class MapRoute extends PageRouteInfo<void> {
|
||||
const MapRoute()
|
||||
: super(
|
||||
MapRoute.name,
|
||||
path: '/map-page',
|
||||
);
|
||||
|
||||
static const String name = 'MapRoute';
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [AlbumOptionsPage]
|
||||
class AlbumOptionsRoute extends PageRouteInfo<AlbumOptionsRouteArgs> {
|
||||
|
||||
@@ -174,6 +174,10 @@ enum StoreKey<T> {
|
||||
advancedTroubleshooting<bool>(114, type: bool),
|
||||
logLevel<int>(115, type: int),
|
||||
preferRemoteImage<bool>(116, type: bool),
|
||||
// map related settings
|
||||
mapThemeMode<bool>(117, type: bool),
|
||||
mapShowFavoriteOnly<bool>(118, type: bool),
|
||||
mapRelativeDate<int>(119, type: int),
|
||||
;
|
||||
|
||||
const StoreKey(
|
||||
|
||||
@@ -26,7 +26,7 @@ class ConfirmDialog extends ConsumerWidget {
|
||||
content: Text(content).tr(),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: Text(
|
||||
cancel,
|
||||
style: TextStyle(
|
||||
@@ -38,7 +38,7 @@ class ConfirmDialog extends ConsumerWidget {
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
onOk();
|
||||
Navigator.of(context).pop();
|
||||
Navigator.of(context).pop(true);
|
||||
},
|
||||
child: Text(
|
||||
ok,
|
||||
|
||||
104
mobile/lib/utils/color_filter_generator.dart
Normal file
104
mobile/lib/utils/color_filter_generator.dart
Normal file
@@ -0,0 +1,104 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class InvertionFilter extends StatelessWidget {
|
||||
final Widget? child;
|
||||
const InvertionFilter({super.key, this.child});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ColorFiltered(
|
||||
colorFilter: const ColorFilter.matrix(<double>[
|
||||
-1, 0, 0, 0, 255, //
|
||||
0, -1, 0, 0, 255, //
|
||||
0, 0, -1, 0, 255, //
|
||||
0, 0, 0, 1, 0, //
|
||||
]),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// -1 - darkest, 1 - brightest, 0 - unchanged
|
||||
class BrightnessFilter extends StatelessWidget {
|
||||
final Widget? child;
|
||||
final double brightness;
|
||||
const BrightnessFilter({super.key, this.child, this.brightness = 0});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ColorFiltered(
|
||||
colorFilter: ColorFilter.matrix(
|
||||
_ColorFilterGenerator.brightnessAdjustMatrix(brightness),
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// -1 - greyscale, 1 - most saturated, 0 - unchanged
|
||||
class SaturationFilter extends StatelessWidget {
|
||||
final Widget? child;
|
||||
final double saturation;
|
||||
const SaturationFilter({super.key, this.child, this.saturation = 0});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ColorFiltered(
|
||||
colorFilter: ColorFilter.matrix(
|
||||
_ColorFilterGenerator.saturationAdjustMatrix(saturation),
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ColorFilterGenerator {
|
||||
static List<double> brightnessAdjustMatrix(double value) {
|
||||
value = value * 10;
|
||||
|
||||
if (value == 0) {
|
||||
return [
|
||||
1, 0, 0, 0, 0, //
|
||||
0, 1, 0, 0, 0, //
|
||||
0, 0, 1, 0, 0, //
|
||||
0, 0, 0, 1, 0, //
|
||||
];
|
||||
}
|
||||
|
||||
return List<double>.from(<double>[
|
||||
1, 0, 0, 0, value, 0, 1, 0, 0, value, 0, 0, 1, 0, value, 0, 0, 0, 1, 0, //
|
||||
]).map((i) => i.toDouble()).toList();
|
||||
}
|
||||
|
||||
static List<double> saturationAdjustMatrix(double value) {
|
||||
value = value * 100;
|
||||
|
||||
if (value == 0) {
|
||||
return [
|
||||
1, 0, 0, 0, 0, //
|
||||
0, 1, 0, 0, 0, //
|
||||
0, 0, 1, 0, 0, //
|
||||
0, 0, 0, 1, 0, //
|
||||
];
|
||||
}
|
||||
|
||||
double x =
|
||||
((1 + ((value > 0) ? ((3 * value) / 100) : (value / 100)))).toDouble();
|
||||
double lumR = 0.3086;
|
||||
double lumG = 0.6094;
|
||||
double lumB = 0.082;
|
||||
|
||||
return List<double>.from(<double>[
|
||||
(lumR * (1 - x)) + x, lumG * (1 - x), lumB * (1 - x), //
|
||||
0, 0, //
|
||||
lumR * (1 - x), //
|
||||
(lumG * (1 - x)) + x, //
|
||||
lumB * (1 - x), //
|
||||
0, 0, //
|
||||
lumR * (1 - x), //
|
||||
lumG * (1 - x), //
|
||||
(lumB * (1 - x)) + x, //
|
||||
0, 0, 0, 0, 0, 1, 0, //
|
||||
]).map((i) => i.toDouble()).toList();
|
||||
}
|
||||
}
|
||||
26
mobile/lib/utils/debounce.dart
Normal file
26
mobile/lib/utils/debounce.dart
Normal file
@@ -0,0 +1,26 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class Debounce {
|
||||
Debounce(Duration interval) : _interval = interval.inMilliseconds;
|
||||
final int _interval;
|
||||
Timer? _timer;
|
||||
VoidCallback? action;
|
||||
|
||||
void call(VoidCallback? action) {
|
||||
this.action = action;
|
||||
_timer?.cancel();
|
||||
_timer = Timer(Duration(milliseconds: _interval), _callAndRest);
|
||||
}
|
||||
|
||||
void _callAndRest() {
|
||||
action?.call();
|
||||
_timer = null;
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
}
|
||||
}
|
||||
67
mobile/lib/utils/flutter_map_extensions.dart
Normal file
67
mobile/lib/utils/flutter_map_extensions.dart
Normal file
@@ -0,0 +1,67 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'dart:math' as math;
|
||||
|
||||
extension MoveByBounds on MapController {
|
||||
// TODO: Remove this in favor of built-in method when upgrading flutter_map to 5.0.0
|
||||
LatLng? centerBoundsWithPadding(
|
||||
LatLng coordinates,
|
||||
Offset offset, {
|
||||
double? zoomLevel,
|
||||
}) {
|
||||
const crs = Epsg3857();
|
||||
final oldCenterPt = crs.latLngToPoint(coordinates, zoomLevel ?? zoom);
|
||||
final mapCenterPoint = _rotatePoint(
|
||||
oldCenterPt,
|
||||
oldCenterPt - CustomPoint(offset.dx, offset.dy),
|
||||
);
|
||||
return crs.pointToLatLng(mapCenterPoint, zoomLevel ?? zoom);
|
||||
}
|
||||
|
||||
CustomPoint<double> _rotatePoint(
|
||||
CustomPoint<double> mapCenter,
|
||||
CustomPoint<double> point, {
|
||||
bool counterRotation = true,
|
||||
}) {
|
||||
final counterRotationFactor = counterRotation ? -1 : 1;
|
||||
|
||||
final m = Matrix4.identity()
|
||||
..translate(mapCenter.x, mapCenter.y)
|
||||
..rotateZ(degToRadian(rotation) * counterRotationFactor)
|
||||
..translate(-mapCenter.x, -mapCenter.y);
|
||||
|
||||
final tp = MatrixUtils.transformPoint(m, Offset(point.x, point.y));
|
||||
|
||||
return CustomPoint(tp.dx, tp.dy);
|
||||
}
|
||||
|
||||
double getTapThresholdForZoomLevel() {
|
||||
const scale = [
|
||||
25000000,
|
||||
15000000,
|
||||
8000000,
|
||||
4000000,
|
||||
2000000,
|
||||
1000000,
|
||||
500000,
|
||||
250000,
|
||||
100000,
|
||||
50000,
|
||||
25000,
|
||||
15000,
|
||||
8000,
|
||||
4000,
|
||||
2000,
|
||||
1000,
|
||||
500,
|
||||
250,
|
||||
100,
|
||||
50,
|
||||
25,
|
||||
10,
|
||||
5,
|
||||
];
|
||||
return scale[math.max(0, math.min(20, zoom.round() + 2))].toDouble() / 6;
|
||||
}
|
||||
}
|
||||
@@ -7,17 +7,20 @@ String getThumbnailUrl(
|
||||
final Asset asset, {
|
||||
ThumbnailFormat type = ThumbnailFormat.WEBP,
|
||||
}) {
|
||||
return _getThumbnailUrl(asset.remoteId!, type: type);
|
||||
return getThumbnailUrlForRemoteId(asset.remoteId!, type: type);
|
||||
}
|
||||
|
||||
String getThumbnailCacheKey(
|
||||
final Asset asset, {
|
||||
ThumbnailFormat type = ThumbnailFormat.WEBP,
|
||||
}) {
|
||||
return _getThumbnailCacheKey(asset.remoteId!, type);
|
||||
return getThumbnailCacheKeyForRemoteId(asset.remoteId!, type: type);
|
||||
}
|
||||
|
||||
String _getThumbnailCacheKey(final String id, final ThumbnailFormat type) {
|
||||
String getThumbnailCacheKeyForRemoteId(
|
||||
final String id, {
|
||||
ThumbnailFormat type = ThumbnailFormat.WEBP,
|
||||
}) {
|
||||
if (type == ThumbnailFormat.WEBP) {
|
||||
return 'thumbnail-image-$id';
|
||||
} else {
|
||||
@@ -32,7 +35,8 @@ String getAlbumThumbnailUrl(
|
||||
if (album.thumbnail.value?.remoteId == null) {
|
||||
return '';
|
||||
}
|
||||
return _getThumbnailUrl(album.thumbnail.value!.remoteId!, type: type);
|
||||
return getThumbnailUrlForRemoteId(album.thumbnail.value!.remoteId!,
|
||||
type: type,);
|
||||
}
|
||||
|
||||
String getAlbumThumbNailCacheKey(
|
||||
@@ -42,7 +46,10 @@ String getAlbumThumbNailCacheKey(
|
||||
if (album.thumbnail.value?.remoteId == null) {
|
||||
return '';
|
||||
}
|
||||
return _getThumbnailCacheKey(album.thumbnail.value!.remoteId!, type);
|
||||
return getThumbnailCacheKeyForRemoteId(
|
||||
album.thumbnail.value!.remoteId!,
|
||||
type: type,
|
||||
);
|
||||
}
|
||||
|
||||
String getImageUrl(final Asset asset) {
|
||||
@@ -53,7 +60,7 @@ String getImageCacheKey(final Asset asset) {
|
||||
return '${asset.id}_fullStage';
|
||||
}
|
||||
|
||||
String _getThumbnailUrl(
|
||||
String getThumbnailUrlForRemoteId(
|
||||
final String id, {
|
||||
ThumbnailFormat type = ThumbnailFormat.WEBP,
|
||||
}) {
|
||||
|
||||
76
mobile/lib/utils/selection_handlers.dart
Normal file
76
mobile/lib/utils/selection_handlers.dart
Normal file
@@ -0,0 +1,76 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/providers/asset.provider.dart';
|
||||
import 'package:immich_mobile/shared/services/share.service.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_toast.dart';
|
||||
import 'package:immich_mobile/shared/ui/share_dialog.dart';
|
||||
|
||||
void handleShareAssets(
|
||||
WidgetRef ref,
|
||||
BuildContext context,
|
||||
List<Asset> selection,
|
||||
) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext buildContext) {
|
||||
ref
|
||||
.watch(shareServiceProvider)
|
||||
.shareAssets(selection.toList())
|
||||
.then((_) => Navigator.of(buildContext).pop());
|
||||
return const ShareDialog();
|
||||
},
|
||||
barrierDismissible: false,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> handleArchiveAssets(
|
||||
WidgetRef ref,
|
||||
BuildContext context,
|
||||
List<Asset> selection, {
|
||||
bool shouldArchive = true,
|
||||
ToastGravity toastGravity = ToastGravity.BOTTOM,
|
||||
}) async {
|
||||
if (selection.isNotEmpty) {
|
||||
await ref
|
||||
.read(assetProvider.notifier)
|
||||
.toggleArchive(selection, shouldArchive);
|
||||
|
||||
final assetOrAssets = selection.length > 1 ? 'assets' : 'asset';
|
||||
final archiveOrLibrary = shouldArchive ? 'archive' : 'library';
|
||||
if (context.mounted) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: 'Moved ${selection.length} $assetOrAssets to $archiveOrLibrary',
|
||||
gravity: toastGravity,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> handleFavoriteAssets(
|
||||
WidgetRef ref,
|
||||
BuildContext context,
|
||||
List<Asset> selection, {
|
||||
bool shouldFavorite = true,
|
||||
ToastGravity toastGravity = ToastGravity.BOTTOM,
|
||||
}) async {
|
||||
if (selection.isNotEmpty) {
|
||||
await ref
|
||||
.watch(assetProvider.notifier)
|
||||
.toggleFavorite(selection, shouldFavorite);
|
||||
|
||||
final assetOrAssets = selection.length > 1 ? 'assets' : 'asset';
|
||||
final toastMessage = shouldFavorite
|
||||
? 'Added ${selection.length} $assetOrAssets to favorites'
|
||||
: 'Removed ${selection.length} $assetOrAssets from favorites';
|
||||
if (context.mounted) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: toastMessage,
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
24
mobile/openapi/.openapi-generator/FILES
generated
24
mobile/openapi/.openapi-generator/FILES
generated
@@ -35,11 +35,16 @@ doc/AuthDeviceResponseDto.md
|
||||
doc/AuthenticationApi.md
|
||||
doc/BulkIdResponseDto.md
|
||||
doc/BulkIdsDto.md
|
||||
doc/CLIPConfig.md
|
||||
doc/CLIPMode.md
|
||||
doc/CQMode.md
|
||||
doc/ChangePasswordDto.md
|
||||
doc/CheckDuplicateAssetDto.md
|
||||
doc/CheckDuplicateAssetResponseDto.md
|
||||
doc/CheckExistingAssetsDto.md
|
||||
doc/CheckExistingAssetsResponseDto.md
|
||||
doc/ClassificationConfig.md
|
||||
doc/Colorspace.md
|
||||
doc/CreateAlbumDto.md
|
||||
doc/CreateProfileImageResponseDto.md
|
||||
doc/CreateTagDto.md
|
||||
@@ -68,7 +73,9 @@ doc/LogoutResponseDto.md
|
||||
doc/MapMarkerResponseDto.md
|
||||
doc/MemoryLaneResponseDto.md
|
||||
doc/MergePersonDto.md
|
||||
doc/ModelType.md
|
||||
doc/OAuthApi.md
|
||||
doc/OAuthAuthorizeResponseDto.md
|
||||
doc/OAuthCallbackDto.md
|
||||
doc/OAuthConfigDto.md
|
||||
doc/OAuthConfigResponseDto.md
|
||||
@@ -80,6 +87,7 @@ doc/PersonApi.md
|
||||
doc/PersonResponseDto.md
|
||||
doc/PersonUpdateDto.md
|
||||
doc/QueueStatusDto.md
|
||||
doc/RecognitionConfig.md
|
||||
doc/SearchAlbumResponseDto.md
|
||||
doc/SearchApi.md
|
||||
doc/SearchAssetDto.md
|
||||
@@ -189,6 +197,11 @@ lib/model/check_duplicate_asset_dto.dart
|
||||
lib/model/check_duplicate_asset_response_dto.dart
|
||||
lib/model/check_existing_assets_dto.dart
|
||||
lib/model/check_existing_assets_response_dto.dart
|
||||
lib/model/classification_config.dart
|
||||
lib/model/clip_config.dart
|
||||
lib/model/clip_mode.dart
|
||||
lib/model/colorspace.dart
|
||||
lib/model/cq_mode.dart
|
||||
lib/model/create_album_dto.dart
|
||||
lib/model/create_profile_image_response_dto.dart
|
||||
lib/model/create_tag_dto.dart
|
||||
@@ -216,6 +229,8 @@ lib/model/logout_response_dto.dart
|
||||
lib/model/map_marker_response_dto.dart
|
||||
lib/model/memory_lane_response_dto.dart
|
||||
lib/model/merge_person_dto.dart
|
||||
lib/model/model_type.dart
|
||||
lib/model/o_auth_authorize_response_dto.dart
|
||||
lib/model/o_auth_callback_dto.dart
|
||||
lib/model/o_auth_config_dto.dart
|
||||
lib/model/o_auth_config_response_dto.dart
|
||||
@@ -225,6 +240,7 @@ lib/model/people_update_item.dart
|
||||
lib/model/person_response_dto.dart
|
||||
lib/model/person_update_dto.dart
|
||||
lib/model/queue_status_dto.dart
|
||||
lib/model/recognition_config.dart
|
||||
lib/model/search_album_response_dto.dart
|
||||
lib/model/search_asset_dto.dart
|
||||
lib/model/search_asset_response_dto.dart
|
||||
@@ -309,6 +325,11 @@ test/check_duplicate_asset_dto_test.dart
|
||||
test/check_duplicate_asset_response_dto_test.dart
|
||||
test/check_existing_assets_dto_test.dart
|
||||
test/check_existing_assets_response_dto_test.dart
|
||||
test/classification_config_test.dart
|
||||
test/clip_config_test.dart
|
||||
test/clip_mode_test.dart
|
||||
test/colorspace_test.dart
|
||||
test/cq_mode_test.dart
|
||||
test/create_album_dto_test.dart
|
||||
test/create_profile_image_response_dto_test.dart
|
||||
test/create_tag_dto_test.dart
|
||||
@@ -337,7 +358,9 @@ test/logout_response_dto_test.dart
|
||||
test/map_marker_response_dto_test.dart
|
||||
test/memory_lane_response_dto_test.dart
|
||||
test/merge_person_dto_test.dart
|
||||
test/model_type_test.dart
|
||||
test/o_auth_api_test.dart
|
||||
test/o_auth_authorize_response_dto_test.dart
|
||||
test/o_auth_callback_dto_test.dart
|
||||
test/o_auth_config_dto_test.dart
|
||||
test/o_auth_config_response_dto_test.dart
|
||||
@@ -349,6 +372,7 @@ test/person_api_test.dart
|
||||
test/person_response_dto_test.dart
|
||||
test/person_update_dto_test.dart
|
||||
test/queue_status_dto_test.dart
|
||||
test/recognition_config_test.dart
|
||||
test/search_album_response_dto_test.dart
|
||||
test/search_api_test.dart
|
||||
test/search_asset_dto_test.dart
|
||||
|
||||
11
mobile/openapi/README.md
generated
11
mobile/openapi/README.md
generated
@@ -3,7 +3,7 @@ Immich API
|
||||
|
||||
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
|
||||
|
||||
- API version: 1.75.1
|
||||
- API version: 1.77.0
|
||||
- Build package: org.openapitools.codegen.languages.DartClientCodegen
|
||||
|
||||
## Requirements
|
||||
@@ -124,6 +124,7 @@ Class | Method | HTTP request | Description
|
||||
*AuthenticationApi* | [**validateAccessToken**](doc//AuthenticationApi.md#validateaccesstoken) | **POST** /auth/validateToken |
|
||||
*JobApi* | [**getAllJobsStatus**](doc//JobApi.md#getalljobsstatus) | **GET** /jobs |
|
||||
*JobApi* | [**sendJobCommand**](doc//JobApi.md#sendjobcommand) | **PUT** /jobs/{id} |
|
||||
*OAuthApi* | [**authorizeOAuth**](doc//OAuthApi.md#authorizeoauth) | **POST** /oauth/authorize |
|
||||
*OAuthApi* | [**callback**](doc//OAuthApi.md#callback) | **POST** /oauth/callback |
|
||||
*OAuthApi* | [**generateConfig**](doc//OAuthApi.md#generateconfig) | **POST** /oauth/config |
|
||||
*OAuthApi* | [**link**](doc//OAuthApi.md#link) | **POST** /oauth/link |
|
||||
@@ -208,11 +209,16 @@ Class | Method | HTTP request | Description
|
||||
- [AuthDeviceResponseDto](doc//AuthDeviceResponseDto.md)
|
||||
- [BulkIdResponseDto](doc//BulkIdResponseDto.md)
|
||||
- [BulkIdsDto](doc//BulkIdsDto.md)
|
||||
- [CLIPConfig](doc//CLIPConfig.md)
|
||||
- [CLIPMode](doc//CLIPMode.md)
|
||||
- [CQMode](doc//CQMode.md)
|
||||
- [ChangePasswordDto](doc//ChangePasswordDto.md)
|
||||
- [CheckDuplicateAssetDto](doc//CheckDuplicateAssetDto.md)
|
||||
- [CheckDuplicateAssetResponseDto](doc//CheckDuplicateAssetResponseDto.md)
|
||||
- [CheckExistingAssetsDto](doc//CheckExistingAssetsDto.md)
|
||||
- [CheckExistingAssetsResponseDto](doc//CheckExistingAssetsResponseDto.md)
|
||||
- [ClassificationConfig](doc//ClassificationConfig.md)
|
||||
- [Colorspace](doc//Colorspace.md)
|
||||
- [CreateAlbumDto](doc//CreateAlbumDto.md)
|
||||
- [CreateProfileImageResponseDto](doc//CreateProfileImageResponseDto.md)
|
||||
- [CreateTagDto](doc//CreateTagDto.md)
|
||||
@@ -240,6 +246,8 @@ Class | Method | HTTP request | Description
|
||||
- [MapMarkerResponseDto](doc//MapMarkerResponseDto.md)
|
||||
- [MemoryLaneResponseDto](doc//MemoryLaneResponseDto.md)
|
||||
- [MergePersonDto](doc//MergePersonDto.md)
|
||||
- [ModelType](doc//ModelType.md)
|
||||
- [OAuthAuthorizeResponseDto](doc//OAuthAuthorizeResponseDto.md)
|
||||
- [OAuthCallbackDto](doc//OAuthCallbackDto.md)
|
||||
- [OAuthConfigDto](doc//OAuthConfigDto.md)
|
||||
- [OAuthConfigResponseDto](doc//OAuthConfigResponseDto.md)
|
||||
@@ -249,6 +257,7 @@ Class | Method | HTTP request | Description
|
||||
- [PersonResponseDto](doc//PersonResponseDto.md)
|
||||
- [PersonUpdateDto](doc//PersonUpdateDto.md)
|
||||
- [QueueStatusDto](doc//QueueStatusDto.md)
|
||||
- [RecognitionConfig](doc//RecognitionConfig.md)
|
||||
- [SearchAlbumResponseDto](doc//SearchAlbumResponseDto.md)
|
||||
- [SearchAssetDto](doc//SearchAssetDto.md)
|
||||
- [SearchAssetResponseDto](doc//SearchAssetResponseDto.md)
|
||||
|
||||
2
mobile/openapi/doc/AssetApi.md
generated
2
mobile/openapi/doc/AssetApi.md
generated
@@ -1368,8 +1368,6 @@ Name | Type | Description | Notes
|
||||
|
||||
|
||||
|
||||
Update an asset
|
||||
|
||||
### Example
|
||||
```dart
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
1
mobile/openapi/doc/AssetResponseDto.md
generated
1
mobile/openapi/doc/AssetResponseDto.md
generated
@@ -21,6 +21,7 @@ Name | Type | Description | Notes
|
||||
**livePhotoVideoId** | **String** | | [optional]
|
||||
**originalFileName** | **String** | |
|
||||
**originalPath** | **String** | |
|
||||
**owner** | [**UserResponseDto**](UserResponseDto.md) | | [optional]
|
||||
**ownerId** | **String** | |
|
||||
**people** | [**List<PersonResponseDto>**](PersonResponseDto.md) | | [optional] [default to const []]
|
||||
**resized** | **bool** | |
|
||||
|
||||
18
mobile/openapi/doc/CLIPConfig.md
generated
Normal file
18
mobile/openapi/doc/CLIPConfig.md
generated
Normal file
@@ -0,0 +1,18 @@
|
||||
# openapi.model.CLIPConfig
|
||||
|
||||
## Load the model package
|
||||
```dart
|
||||
import 'package:openapi/api.dart';
|
||||
```
|
||||
|
||||
## Properties
|
||||
Name | Type | Description | Notes
|
||||
------------ | ------------- | ------------- | -------------
|
||||
**enabled** | **bool** | |
|
||||
**mode** | [**CLIPMode**](CLIPMode.md) | | [optional]
|
||||
**modelName** | **String** | |
|
||||
**modelType** | [**ModelType**](ModelType.md) | | [optional]
|
||||
|
||||
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
|
||||
|
||||
|
||||
14
mobile/openapi/doc/CLIPMode.md
generated
Normal file
14
mobile/openapi/doc/CLIPMode.md
generated
Normal file
@@ -0,0 +1,14 @@
|
||||
# openapi.model.CLIPMode
|
||||
|
||||
## Load the model package
|
||||
```dart
|
||||
import 'package:openapi/api.dart';
|
||||
```
|
||||
|
||||
## Properties
|
||||
Name | Type | Description | Notes
|
||||
------------ | ------------- | ------------- | -------------
|
||||
|
||||
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
|
||||
|
||||
|
||||
14
mobile/openapi/doc/CQMode.md
generated
Normal file
14
mobile/openapi/doc/CQMode.md
generated
Normal file
@@ -0,0 +1,14 @@
|
||||
# openapi.model.CQMode
|
||||
|
||||
## Load the model package
|
||||
```dart
|
||||
import 'package:openapi/api.dart';
|
||||
```
|
||||
|
||||
## Properties
|
||||
Name | Type | Description | Notes
|
||||
------------ | ------------- | ------------- | -------------
|
||||
|
||||
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
|
||||
|
||||
|
||||
18
mobile/openapi/doc/ClassificationConfig.md
generated
Normal file
18
mobile/openapi/doc/ClassificationConfig.md
generated
Normal file
@@ -0,0 +1,18 @@
|
||||
# openapi.model.ClassificationConfig
|
||||
|
||||
## Load the model package
|
||||
```dart
|
||||
import 'package:openapi/api.dart';
|
||||
```
|
||||
|
||||
## Properties
|
||||
Name | Type | Description | Notes
|
||||
------------ | ------------- | ------------- | -------------
|
||||
**enabled** | **bool** | |
|
||||
**minScore** | **int** | |
|
||||
**modelName** | **String** | |
|
||||
**modelType** | [**ModelType**](ModelType.md) | | [optional]
|
||||
|
||||
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
|
||||
|
||||
|
||||
14
mobile/openapi/doc/Colorspace.md
generated
Normal file
14
mobile/openapi/doc/Colorspace.md
generated
Normal file
@@ -0,0 +1,14 @@
|
||||
# openapi.model.Colorspace
|
||||
|
||||
## Load the model package
|
||||
```dart
|
||||
import 'package:openapi/api.dart';
|
||||
```
|
||||
|
||||
## Properties
|
||||
Name | Type | Description | Notes
|
||||
------------ | ------------- | ------------- | -------------
|
||||
|
||||
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
|
||||
|
||||
|
||||
14
mobile/openapi/doc/ModelType.md
generated
Normal file
14
mobile/openapi/doc/ModelType.md
generated
Normal file
@@ -0,0 +1,14 @@
|
||||
# openapi.model.ModelType
|
||||
|
||||
## Load the model package
|
||||
```dart
|
||||
import 'package:openapi/api.dart';
|
||||
```
|
||||
|
||||
## Properties
|
||||
Name | Type | Description | Notes
|
||||
------------ | ------------- | ------------- | -------------
|
||||
|
||||
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
|
||||
|
||||
|
||||
44
mobile/openapi/doc/OAuthApi.md
generated
44
mobile/openapi/doc/OAuthApi.md
generated
@@ -9,6 +9,7 @@ All URIs are relative to */api*
|
||||
|
||||
Method | HTTP request | Description
|
||||
------------- | ------------- | -------------
|
||||
[**authorizeOAuth**](OAuthApi.md#authorizeoauth) | **POST** /oauth/authorize |
|
||||
[**callback**](OAuthApi.md#callback) | **POST** /oauth/callback |
|
||||
[**generateConfig**](OAuthApi.md#generateconfig) | **POST** /oauth/config |
|
||||
[**link**](OAuthApi.md#link) | **POST** /oauth/link |
|
||||
@@ -16,6 +17,47 @@ Method | HTTP request | Description
|
||||
[**unlink**](OAuthApi.md#unlink) | **POST** /oauth/unlink |
|
||||
|
||||
|
||||
# **authorizeOAuth**
|
||||
> OAuthAuthorizeResponseDto authorizeOAuth(oAuthConfigDto)
|
||||
|
||||
|
||||
|
||||
### Example
|
||||
```dart
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
final api_instance = OAuthApi();
|
||||
final oAuthConfigDto = OAuthConfigDto(); // OAuthConfigDto |
|
||||
|
||||
try {
|
||||
final result = api_instance.authorizeOAuth(oAuthConfigDto);
|
||||
print(result);
|
||||
} catch (e) {
|
||||
print('Exception when calling OAuthApi->authorizeOAuth: $e\n');
|
||||
}
|
||||
```
|
||||
|
||||
### Parameters
|
||||
|
||||
Name | Type | Description | Notes
|
||||
------------- | ------------- | ------------- | -------------
|
||||
**oAuthConfigDto** | [**OAuthConfigDto**](OAuthConfigDto.md)| |
|
||||
|
||||
### Return type
|
||||
|
||||
[**OAuthAuthorizeResponseDto**](OAuthAuthorizeResponseDto.md)
|
||||
|
||||
### Authorization
|
||||
|
||||
No authorization required
|
||||
|
||||
### HTTP request headers
|
||||
|
||||
- **Content-Type**: application/json
|
||||
- **Accept**: application/json
|
||||
|
||||
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
|
||||
|
||||
# **callback**
|
||||
> LoginResponseDto callback(oAuthCallbackDto)
|
||||
|
||||
@@ -62,6 +104,8 @@ No authorization required
|
||||
|
||||
|
||||
|
||||
@deprecated use feature flags and /oauth/authorize
|
||||
|
||||
### Example
|
||||
```dart
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
15
mobile/openapi/doc/OAuthAuthorizeResponseDto.md
generated
Normal file
15
mobile/openapi/doc/OAuthAuthorizeResponseDto.md
generated
Normal file
@@ -0,0 +1,15 @@
|
||||
# openapi.model.OAuthAuthorizeResponseDto
|
||||
|
||||
## Load the model package
|
||||
```dart
|
||||
import 'package:openapi/api.dart';
|
||||
```
|
||||
|
||||
## Properties
|
||||
Name | Type | Description | Notes
|
||||
------------ | ------------- | ------------- | -------------
|
||||
**url** | **String** | |
|
||||
|
||||
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
|
||||
|
||||
|
||||
2
mobile/openapi/doc/PeopleUpdateItem.md
generated
2
mobile/openapi/doc/PeopleUpdateItem.md
generated
@@ -8,7 +8,7 @@ import 'package:openapi/api.dart';
|
||||
## Properties
|
||||
Name | Type | Description | Notes
|
||||
------------ | ------------- | ------------- | -------------
|
||||
**birthDate** | [**DateTime**](DateTime.md) | Person date of birth. | [optional]
|
||||
**birthDate** | [**DateTime**](DateTime.md) | Person date of birth. Note: the mobile app cannot currently set the birth date to null. | [optional]
|
||||
**featureFaceAssetId** | **String** | Asset is used to get the feature face thumbnail. | [optional]
|
||||
**id** | **String** | Person id. |
|
||||
**isHidden** | **bool** | Person visibility | [optional]
|
||||
|
||||
2
mobile/openapi/doc/PersonUpdateDto.md
generated
2
mobile/openapi/doc/PersonUpdateDto.md
generated
@@ -8,7 +8,7 @@ import 'package:openapi/api.dart';
|
||||
## Properties
|
||||
Name | Type | Description | Notes
|
||||
------------ | ------------- | ------------- | -------------
|
||||
**birthDate** | [**DateTime**](DateTime.md) | Person date of birth. | [optional]
|
||||
**birthDate** | [**DateTime**](DateTime.md) | Person date of birth. Note: the mobile app cannot currently set the birth date to null. | [optional]
|
||||
**featureFaceAssetId** | **String** | Asset is used to get the feature face thumbnail. | [optional]
|
||||
**isHidden** | **bool** | Person visibility | [optional]
|
||||
**name** | **String** | Person name. | [optional]
|
||||
|
||||
19
mobile/openapi/doc/RecognitionConfig.md
generated
Normal file
19
mobile/openapi/doc/RecognitionConfig.md
generated
Normal file
@@ -0,0 +1,19 @@
|
||||
# openapi.model.RecognitionConfig
|
||||
|
||||
## Load the model package
|
||||
```dart
|
||||
import 'package:openapi/api.dart';
|
||||
```
|
||||
|
||||
## Properties
|
||||
Name | Type | Description | Notes
|
||||
------------ | ------------- | ------------- | -------------
|
||||
**enabled** | **bool** | |
|
||||
**maxDistance** | **int** | |
|
||||
**minScore** | **int** | |
|
||||
**modelName** | **String** | |
|
||||
**modelType** | [**ModelType**](ModelType.md) | | [optional]
|
||||
|
||||
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
|
||||
|
||||
|
||||
6
mobile/openapi/doc/SystemConfigFFmpegDto.md
generated
6
mobile/openapi/doc/SystemConfigFFmpegDto.md
generated
@@ -9,12 +9,18 @@ import 'package:openapi/api.dart';
|
||||
Name | Type | Description | Notes
|
||||
------------ | ------------- | ------------- | -------------
|
||||
**accel** | [**TranscodeHWAccel**](TranscodeHWAccel.md) | |
|
||||
**bframes** | **int** | |
|
||||
**cqMode** | [**CQMode**](CQMode.md) | |
|
||||
**crf** | **int** | |
|
||||
**gopSize** | **int** | |
|
||||
**maxBitrate** | **String** | |
|
||||
**npl** | **int** | |
|
||||
**preset** | **String** | |
|
||||
**refs** | **int** | |
|
||||
**targetAudioCodec** | [**AudioCodec**](AudioCodec.md) | |
|
||||
**targetResolution** | **String** | |
|
||||
**targetVideoCodec** | [**VideoCodec**](VideoCodec.md) | |
|
||||
**temporalAQ** | **bool** | |
|
||||
**threads** | **int** | |
|
||||
**tonemap** | [**ToneMapping**](ToneMapping.md) | |
|
||||
**transcode** | [**TranscodePolicy**](TranscodePolicy.md) | |
|
||||
|
||||
@@ -8,10 +8,10 @@ import 'package:openapi/api.dart';
|
||||
## Properties
|
||||
Name | Type | Description | Notes
|
||||
------------ | ------------- | ------------- | -------------
|
||||
**clipEncodeEnabled** | **bool** | |
|
||||
**classification** | [**ClassificationConfig**](ClassificationConfig.md) | |
|
||||
**clip** | [**CLIPConfig**](CLIPConfig.md) | |
|
||||
**enabled** | **bool** | |
|
||||
**facialRecognitionEnabled** | **bool** | |
|
||||
**tagImageEnabled** | **bool** | |
|
||||
**facialRecognition** | [**RecognitionConfig**](RecognitionConfig.md) | |
|
||||
**url** | **String** | |
|
||||
|
||||
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
|
||||
|
||||
2
mobile/openapi/doc/SystemConfigThumbnailDto.md
generated
2
mobile/openapi/doc/SystemConfigThumbnailDto.md
generated
@@ -8,7 +8,9 @@ import 'package:openapi/api.dart';
|
||||
## Properties
|
||||
Name | Type | Description | Notes
|
||||
------------ | ------------- | ------------- | -------------
|
||||
**colorspace** | [**Colorspace**](Colorspace.md) | |
|
||||
**jpegSize** | **int** | |
|
||||
**quality** | **int** | |
|
||||
**webpSize** | **int** | |
|
||||
|
||||
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
|
||||
|
||||
1
mobile/openapi/doc/UpdateAssetDto.md
generated
1
mobile/openapi/doc/UpdateAssetDto.md
generated
@@ -11,7 +11,6 @@ Name | Type | Description | Notes
|
||||
**description** | **String** | | [optional]
|
||||
**isArchived** | **bool** | | [optional]
|
||||
**isFavorite** | **bool** | | [optional]
|
||||
**tagIds** | **List<String>** | | [optional] [default to const []]
|
||||
|
||||
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
|
||||
|
||||
|
||||
8
mobile/openapi/lib/api.dart
generated
8
mobile/openapi/lib/api.dart
generated
@@ -71,11 +71,16 @@ part 'model/audit_deletes_response_dto.dart';
|
||||
part 'model/auth_device_response_dto.dart';
|
||||
part 'model/bulk_id_response_dto.dart';
|
||||
part 'model/bulk_ids_dto.dart';
|
||||
part 'model/clip_config.dart';
|
||||
part 'model/clip_mode.dart';
|
||||
part 'model/cq_mode.dart';
|
||||
part 'model/change_password_dto.dart';
|
||||
part 'model/check_duplicate_asset_dto.dart';
|
||||
part 'model/check_duplicate_asset_response_dto.dart';
|
||||
part 'model/check_existing_assets_dto.dart';
|
||||
part 'model/check_existing_assets_response_dto.dart';
|
||||
part 'model/classification_config.dart';
|
||||
part 'model/colorspace.dart';
|
||||
part 'model/create_album_dto.dart';
|
||||
part 'model/create_profile_image_response_dto.dart';
|
||||
part 'model/create_tag_dto.dart';
|
||||
@@ -103,6 +108,8 @@ part 'model/logout_response_dto.dart';
|
||||
part 'model/map_marker_response_dto.dart';
|
||||
part 'model/memory_lane_response_dto.dart';
|
||||
part 'model/merge_person_dto.dart';
|
||||
part 'model/model_type.dart';
|
||||
part 'model/o_auth_authorize_response_dto.dart';
|
||||
part 'model/o_auth_callback_dto.dart';
|
||||
part 'model/o_auth_config_dto.dart';
|
||||
part 'model/o_auth_config_response_dto.dart';
|
||||
@@ -112,6 +119,7 @@ part 'model/people_update_item.dart';
|
||||
part 'model/person_response_dto.dart';
|
||||
part 'model/person_update_dto.dart';
|
||||
part 'model/queue_status_dto.dart';
|
||||
part 'model/recognition_config.dart';
|
||||
part 'model/search_album_response_dto.dart';
|
||||
part 'model/search_asset_dto.dart';
|
||||
part 'model/search_asset_response_dto.dart';
|
||||
|
||||
7
mobile/openapi/lib/api/asset_api.dart
generated
7
mobile/openapi/lib/api/asset_api.dart
generated
@@ -1384,10 +1384,7 @@ class AssetApi {
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Update an asset
|
||||
///
|
||||
/// Note: This method returns the HTTP [Response].
|
||||
///
|
||||
/// Performs an HTTP 'PUT /asset/{id}' operation and returns the [Response].
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] id (required):
|
||||
@@ -1419,8 +1416,6 @@ class AssetApi {
|
||||
);
|
||||
}
|
||||
|
||||
/// Update an asset
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] id (required):
|
||||
|
||||
54
mobile/openapi/lib/api/o_auth_api.dart
generated
54
mobile/openapi/lib/api/o_auth_api.dart
generated
@@ -16,6 +16,53 @@ class OAuthApi {
|
||||
|
||||
final ApiClient apiClient;
|
||||
|
||||
/// Performs an HTTP 'POST /oauth/authorize' operation and returns the [Response].
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [OAuthConfigDto] oAuthConfigDto (required):
|
||||
Future<Response> authorizeOAuthWithHttpInfo(OAuthConfigDto oAuthConfigDto,) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final path = r'/oauth/authorize';
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody = oAuthConfigDto;
|
||||
|
||||
final queryParams = <QueryParam>[];
|
||||
final headerParams = <String, String>{};
|
||||
final formParams = <String, String>{};
|
||||
|
||||
const contentTypes = <String>['application/json'];
|
||||
|
||||
|
||||
return apiClient.invokeAPI(
|
||||
path,
|
||||
'POST',
|
||||
queryParams,
|
||||
postBody,
|
||||
headerParams,
|
||||
formParams,
|
||||
contentTypes.isEmpty ? null : contentTypes.first,
|
||||
);
|
||||
}
|
||||
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [OAuthConfigDto] oAuthConfigDto (required):
|
||||
Future<OAuthAuthorizeResponseDto?> authorizeOAuth(OAuthConfigDto oAuthConfigDto,) async {
|
||||
final response = await authorizeOAuthWithHttpInfo(oAuthConfigDto,);
|
||||
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), 'OAuthAuthorizeResponseDto',) as OAuthAuthorizeResponseDto;
|
||||
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Performs an HTTP 'POST /oauth/callback' operation and returns the [Response].
|
||||
/// Parameters:
|
||||
///
|
||||
@@ -63,7 +110,10 @@ class OAuthApi {
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Performs an HTTP 'POST /oauth/config' operation and returns the [Response].
|
||||
/// @deprecated use feature flags and /oauth/authorize
|
||||
///
|
||||
/// Note: This method returns the HTTP [Response].
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [OAuthConfigDto] oAuthConfigDto (required):
|
||||
@@ -92,6 +142,8 @@ class OAuthApi {
|
||||
);
|
||||
}
|
||||
|
||||
/// @deprecated use feature flags and /oauth/authorize
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [OAuthConfigDto] oAuthConfigDto (required):
|
||||
|
||||
16
mobile/openapi/lib/api_client.dart
generated
16
mobile/openapi/lib/api_client.dart
generated
@@ -235,6 +235,12 @@ class ApiClient {
|
||||
return BulkIdResponseDto.fromJson(value);
|
||||
case 'BulkIdsDto':
|
||||
return BulkIdsDto.fromJson(value);
|
||||
case 'CLIPConfig':
|
||||
return CLIPConfig.fromJson(value);
|
||||
case 'CLIPMode':
|
||||
return CLIPModeTypeTransformer().decode(value);
|
||||
case 'CQMode':
|
||||
return CQModeTypeTransformer().decode(value);
|
||||
case 'ChangePasswordDto':
|
||||
return ChangePasswordDto.fromJson(value);
|
||||
case 'CheckDuplicateAssetDto':
|
||||
@@ -245,6 +251,10 @@ class ApiClient {
|
||||
return CheckExistingAssetsDto.fromJson(value);
|
||||
case 'CheckExistingAssetsResponseDto':
|
||||
return CheckExistingAssetsResponseDto.fromJson(value);
|
||||
case 'ClassificationConfig':
|
||||
return ClassificationConfig.fromJson(value);
|
||||
case 'Colorspace':
|
||||
return ColorspaceTypeTransformer().decode(value);
|
||||
case 'CreateAlbumDto':
|
||||
return CreateAlbumDto.fromJson(value);
|
||||
case 'CreateProfileImageResponseDto':
|
||||
@@ -299,6 +309,10 @@ class ApiClient {
|
||||
return MemoryLaneResponseDto.fromJson(value);
|
||||
case 'MergePersonDto':
|
||||
return MergePersonDto.fromJson(value);
|
||||
case 'ModelType':
|
||||
return ModelTypeTypeTransformer().decode(value);
|
||||
case 'OAuthAuthorizeResponseDto':
|
||||
return OAuthAuthorizeResponseDto.fromJson(value);
|
||||
case 'OAuthCallbackDto':
|
||||
return OAuthCallbackDto.fromJson(value);
|
||||
case 'OAuthConfigDto':
|
||||
@@ -317,6 +331,8 @@ class ApiClient {
|
||||
return PersonUpdateDto.fromJson(value);
|
||||
case 'QueueStatusDto':
|
||||
return QueueStatusDto.fromJson(value);
|
||||
case 'RecognitionConfig':
|
||||
return RecognitionConfig.fromJson(value);
|
||||
case 'SearchAlbumResponseDto':
|
||||
return SearchAlbumResponseDto.fromJson(value);
|
||||
case 'SearchAssetDto':
|
||||
|
||||
12
mobile/openapi/lib/api_helper.dart
generated
12
mobile/openapi/lib/api_helper.dart
generated
@@ -64,6 +64,15 @@ String parameterToString(dynamic value) {
|
||||
if (value is AudioCodec) {
|
||||
return AudioCodecTypeTransformer().encode(value).toString();
|
||||
}
|
||||
if (value is CLIPMode) {
|
||||
return CLIPModeTypeTransformer().encode(value).toString();
|
||||
}
|
||||
if (value is CQMode) {
|
||||
return CQModeTypeTransformer().encode(value).toString();
|
||||
}
|
||||
if (value is Colorspace) {
|
||||
return ColorspaceTypeTransformer().encode(value).toString();
|
||||
}
|
||||
if (value is DeleteAssetStatus) {
|
||||
return DeleteAssetStatusTypeTransformer().encode(value).toString();
|
||||
}
|
||||
@@ -76,6 +85,9 @@ String parameterToString(dynamic value) {
|
||||
if (value is JobName) {
|
||||
return JobNameTypeTransformer().encode(value).toString();
|
||||
}
|
||||
if (value is ModelType) {
|
||||
return ModelTypeTypeTransformer().encode(value).toString();
|
||||
}
|
||||
if (value is SharedLinkType) {
|
||||
return SharedLinkTypeTypeTransformer().encode(value).toString();
|
||||
}
|
||||
|
||||
19
mobile/openapi/lib/model/asset_response_dto.dart
generated
19
mobile/openapi/lib/model/asset_response_dto.dart
generated
@@ -26,6 +26,7 @@ class AssetResponseDto {
|
||||
this.livePhotoVideoId,
|
||||
required this.originalFileName,
|
||||
required this.originalPath,
|
||||
this.owner,
|
||||
required this.ownerId,
|
||||
this.people = const [],
|
||||
required this.resized,
|
||||
@@ -69,6 +70,14 @@ class AssetResponseDto {
|
||||
|
||||
String originalPath;
|
||||
|
||||
///
|
||||
/// 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.
|
||||
///
|
||||
UserResponseDto? owner;
|
||||
|
||||
String ownerId;
|
||||
|
||||
List<PersonResponseDto> people;
|
||||
@@ -107,6 +116,7 @@ class AssetResponseDto {
|
||||
other.livePhotoVideoId == livePhotoVideoId &&
|
||||
other.originalFileName == originalFileName &&
|
||||
other.originalPath == originalPath &&
|
||||
other.owner == owner &&
|
||||
other.ownerId == ownerId &&
|
||||
other.people == people &&
|
||||
other.resized == resized &&
|
||||
@@ -132,6 +142,7 @@ class AssetResponseDto {
|
||||
(livePhotoVideoId == null ? 0 : livePhotoVideoId!.hashCode) +
|
||||
(originalFileName.hashCode) +
|
||||
(originalPath.hashCode) +
|
||||
(owner == null ? 0 : owner!.hashCode) +
|
||||
(ownerId.hashCode) +
|
||||
(people.hashCode) +
|
||||
(resized.hashCode) +
|
||||
@@ -142,7 +153,7 @@ class AssetResponseDto {
|
||||
(updatedAt.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'AssetResponseDto[checksum=$checksum, deviceAssetId=$deviceAssetId, deviceId=$deviceId, duration=$duration, exifInfo=$exifInfo, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, id=$id, isArchived=$isArchived, isFavorite=$isFavorite, livePhotoVideoId=$livePhotoVideoId, originalFileName=$originalFileName, originalPath=$originalPath, ownerId=$ownerId, people=$people, resized=$resized, smartInfo=$smartInfo, tags=$tags, thumbhash=$thumbhash, type=$type, updatedAt=$updatedAt]';
|
||||
String toString() => 'AssetResponseDto[checksum=$checksum, deviceAssetId=$deviceAssetId, deviceId=$deviceId, duration=$duration, exifInfo=$exifInfo, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, id=$id, isArchived=$isArchived, isFavorite=$isFavorite, livePhotoVideoId=$livePhotoVideoId, originalFileName=$originalFileName, originalPath=$originalPath, owner=$owner, ownerId=$ownerId, people=$people, resized=$resized, smartInfo=$smartInfo, tags=$tags, thumbhash=$thumbhash, type=$type, updatedAt=$updatedAt]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
@@ -167,6 +178,11 @@ class AssetResponseDto {
|
||||
}
|
||||
json[r'originalFileName'] = this.originalFileName;
|
||||
json[r'originalPath'] = this.originalPath;
|
||||
if (this.owner != null) {
|
||||
json[r'owner'] = this.owner;
|
||||
} else {
|
||||
// json[r'owner'] = null;
|
||||
}
|
||||
json[r'ownerId'] = this.ownerId;
|
||||
json[r'people'] = this.people;
|
||||
json[r'resized'] = this.resized;
|
||||
@@ -207,6 +223,7 @@ class AssetResponseDto {
|
||||
livePhotoVideoId: mapValueOfType<String>(json, r'livePhotoVideoId'),
|
||||
originalFileName: mapValueOfType<String>(json, r'originalFileName')!,
|
||||
originalPath: mapValueOfType<String>(json, r'originalPath')!,
|
||||
owner: UserResponseDto.fromJson(json[r'owner']),
|
||||
ownerId: mapValueOfType<String>(json, r'ownerId')!,
|
||||
people: PersonResponseDto.listFromJson(json[r'people']),
|
||||
resized: mapValueOfType<bool>(json, r'resized')!,
|
||||
|
||||
131
mobile/openapi/lib/model/classification_config.dart
generated
Normal file
131
mobile/openapi/lib/model/classification_config.dart
generated
Normal file
@@ -0,0 +1,131 @@
|
||||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.12
|
||||
|
||||
// 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 ClassificationConfig {
|
||||
/// Returns a new [ClassificationConfig] instance.
|
||||
ClassificationConfig({
|
||||
required this.enabled,
|
||||
required this.minScore,
|
||||
required this.modelName,
|
||||
this.modelType,
|
||||
});
|
||||
|
||||
bool enabled;
|
||||
|
||||
int minScore;
|
||||
|
||||
String modelName;
|
||||
|
||||
///
|
||||
/// 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.
|
||||
///
|
||||
ModelType? modelType;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is ClassificationConfig &&
|
||||
other.enabled == enabled &&
|
||||
other.minScore == minScore &&
|
||||
other.modelName == modelName &&
|
||||
other.modelType == modelType;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(enabled.hashCode) +
|
||||
(minScore.hashCode) +
|
||||
(modelName.hashCode) +
|
||||
(modelType == null ? 0 : modelType!.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'ClassificationConfig[enabled=$enabled, minScore=$minScore, modelName=$modelName, modelType=$modelType]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'enabled'] = this.enabled;
|
||||
json[r'minScore'] = this.minScore;
|
||||
json[r'modelName'] = this.modelName;
|
||||
if (this.modelType != null) {
|
||||
json[r'modelType'] = this.modelType;
|
||||
} else {
|
||||
// json[r'modelType'] = null;
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
/// Returns a new [ClassificationConfig] instance and imports its values from
|
||||
/// [value] if it's a [Map], null otherwise.
|
||||
// ignore: prefer_constructors_over_static_methods
|
||||
static ClassificationConfig? fromJson(dynamic value) {
|
||||
if (value is Map) {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return ClassificationConfig(
|
||||
enabled: mapValueOfType<bool>(json, r'enabled')!,
|
||||
minScore: mapValueOfType<int>(json, r'minScore')!,
|
||||
modelName: mapValueOfType<String>(json, r'modelName')!,
|
||||
modelType: ModelType.fromJson(json[r'modelType']),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<ClassificationConfig> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <ClassificationConfig>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = ClassificationConfig.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
|
||||
static Map<String, ClassificationConfig> mapFromJson(dynamic json) {
|
||||
final map = <String, ClassificationConfig>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = ClassificationConfig.fromJson(entry.value);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// maps a json object with a list of ClassificationConfig-objects as value to a dart map
|
||||
static Map<String, List<ClassificationConfig>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<ClassificationConfig>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
// ignore: parameter_assignments
|
||||
json = json.cast<String, dynamic>();
|
||||
for (final entry in json.entries) {
|
||||
map[entry.key] = ClassificationConfig.listFromJson(entry.value, growable: growable,);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'enabled',
|
||||
'minScore',
|
||||
'modelName',
|
||||
};
|
||||
}
|
||||
|
||||
140
mobile/openapi/lib/model/clip_config.dart
generated
Normal file
140
mobile/openapi/lib/model/clip_config.dart
generated
Normal file
@@ -0,0 +1,140 @@
|
||||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.12
|
||||
|
||||
// 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 CLIPConfig {
|
||||
/// Returns a new [CLIPConfig] instance.
|
||||
CLIPConfig({
|
||||
required this.enabled,
|
||||
this.mode,
|
||||
required this.modelName,
|
||||
this.modelType,
|
||||
});
|
||||
|
||||
bool 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.
|
||||
///
|
||||
CLIPMode? mode;
|
||||
|
||||
String modelName;
|
||||
|
||||
///
|
||||
/// 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.
|
||||
///
|
||||
ModelType? modelType;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is CLIPConfig &&
|
||||
other.enabled == enabled &&
|
||||
other.mode == mode &&
|
||||
other.modelName == modelName &&
|
||||
other.modelType == modelType;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(enabled.hashCode) +
|
||||
(mode == null ? 0 : mode!.hashCode) +
|
||||
(modelName.hashCode) +
|
||||
(modelType == null ? 0 : modelType!.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'CLIPConfig[enabled=$enabled, mode=$mode, modelName=$modelName, modelType=$modelType]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'enabled'] = this.enabled;
|
||||
if (this.mode != null) {
|
||||
json[r'mode'] = this.mode;
|
||||
} else {
|
||||
// json[r'mode'] = null;
|
||||
}
|
||||
json[r'modelName'] = this.modelName;
|
||||
if (this.modelType != null) {
|
||||
json[r'modelType'] = this.modelType;
|
||||
} else {
|
||||
// json[r'modelType'] = null;
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
/// Returns a new [CLIPConfig] instance and imports its values from
|
||||
/// [value] if it's a [Map], null otherwise.
|
||||
// ignore: prefer_constructors_over_static_methods
|
||||
static CLIPConfig? fromJson(dynamic value) {
|
||||
if (value is Map) {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return CLIPConfig(
|
||||
enabled: mapValueOfType<bool>(json, r'enabled')!,
|
||||
mode: CLIPMode.fromJson(json[r'mode']),
|
||||
modelName: mapValueOfType<String>(json, r'modelName')!,
|
||||
modelType: ModelType.fromJson(json[r'modelType']),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<CLIPConfig> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <CLIPConfig>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = CLIPConfig.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
|
||||
static Map<String, CLIPConfig> mapFromJson(dynamic json) {
|
||||
final map = <String, CLIPConfig>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = CLIPConfig.fromJson(entry.value);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// maps a json object with a list of CLIPConfig-objects as value to a dart map
|
||||
static Map<String, List<CLIPConfig>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<CLIPConfig>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
// ignore: parameter_assignments
|
||||
json = json.cast<String, dynamic>();
|
||||
for (final entry in json.entries) {
|
||||
map[entry.key] = CLIPConfig.listFromJson(entry.value, growable: growable,);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'enabled',
|
||||
'modelName',
|
||||
};
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user