forked from Cutlery/immich
Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bd865d02b3 | |||
| 7bc8918067 | |||
| 29e103d96d | |||
| e8fdddf08e | |||
| 947132ac77 | |||
| b7b3735c40 | |||
| 89021ce995 | |||
| e00b37f4d2 | |||
| 7e28522cb1 | |||
| e682de67fa | |||
| 1eb3cdca42 | |||
| e2ad4ac5b3 | |||
| a7b62a5ad3 | |||
| 9d23d37601 | |||
| 66ebdf1556 | |||
| f7eab1cc9c | |||
| 048dbc83ba | |||
| 3576d078cf | |||
| 9bd2934aa2 | |||
| ac18f7515a | |||
| 968fc77183 | |||
| 207049df00 | |||
| 980c6b0dbb | |||
| ea6bc8fb10 | |||
| 6e2624da7c | |||
| a37adc0c96 | |||
| 37b4f8455b | |||
| 3c3de8b2af | |||
| fa8ce261d8 | |||
| 69c95e2b73 | |||
| 51ffac5d15 | |||
| 70cf100407 | |||
| 22e5c6ead2 | |||
| 4a34198b78 | |||
| a85780c930 | |||
| 717dfe51e7 | |||
| 5168278215 | |||
| d92dbbe655 |
@@ -45,7 +45,7 @@ jobs:
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: "stable"
|
||||
flutter-version: "3.13.0"
|
||||
flutter-version: "3.10.5"
|
||||
cache: true
|
||||
|
||||
- name: Create the Keystore
|
||||
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: "stable"
|
||||
flutter-version: "3.13.0"
|
||||
flutter-version: "3.10.5"
|
||||
|
||||
- name: Install dependencies
|
||||
run: dart pub get
|
||||
|
||||
@@ -149,7 +149,7 @@ jobs:
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: "stable"
|
||||
flutter-version: "3.13.0"
|
||||
flutter-version: "3.10.5"
|
||||
- name: Run tests
|
||||
working-directory: ./mobile
|
||||
run: flutter test -j 1
|
||||
@@ -171,7 +171,6 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
poetry install --with dev
|
||||
poetry run pip install --no-deps -r requirements.txt
|
||||
- name: Lint with ruff
|
||||
run: |
|
||||
poetry run ruff check --format=github app
|
||||
|
||||
Generated
+623
-521
File diff suppressed because it is too large
Load Diff
Generated
+1
-1
@@ -4,7 +4,7 @@
|
||||
* Immich
|
||||
* Immich API
|
||||
*
|
||||
* The version of the OpenAPI document: 1.75.2
|
||||
* The version of the OpenAPI document: 1.73.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
||||
Generated
+1
-1
@@ -4,7 +4,7 @@
|
||||
* Immich
|
||||
* Immich API
|
||||
*
|
||||
* The version of the OpenAPI document: 1.75.2
|
||||
* The version of the OpenAPI document: 1.73.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
||||
Generated
+1
-1
@@ -4,7 +4,7 @@
|
||||
* Immich
|
||||
* Immich API
|
||||
*
|
||||
* The version of the OpenAPI document: 1.75.2
|
||||
* The version of the OpenAPI document: 1.73.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
||||
Generated
+1
-1
@@ -4,7 +4,7 @@
|
||||
* Immich
|
||||
* Immich API
|
||||
*
|
||||
* The version of the OpenAPI document: 1.75.2
|
||||
* The version of the OpenAPI document: 1.73.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
||||
@@ -4,14 +4,14 @@ import { SessionService } from '../services/session.service';
|
||||
import { LoginError } from '../cores/errors/login-error';
|
||||
import { exit } from 'node:process';
|
||||
import os from 'os';
|
||||
import { ServerVersionResponseDto, UserResponseDto } from 'src/api/open-api';
|
||||
import { ServerVersionReponseDto, UserResponseDto } from 'src/api/open-api';
|
||||
|
||||
export abstract class BaseCommand {
|
||||
protected sessionService!: SessionService;
|
||||
protected immichApi!: ImmichApi;
|
||||
protected deviceId!: string;
|
||||
protected user!: UserResponseDto;
|
||||
protected serverVersion!: ServerVersionResponseDto;
|
||||
protected serverVersion!: ServerVersionReponseDto;
|
||||
|
||||
protected configDir;
|
||||
protected authPath;
|
||||
|
||||
@@ -100,8 +100,8 @@ services:
|
||||
environment:
|
||||
- TYPESENSE_API_KEY=${TYPESENSE_API_KEY}
|
||||
- TYPESENSE_DATA_DIR=/data
|
||||
# remove this to get debug messages
|
||||
- GLOG_minloglevel=1
|
||||
logging:
|
||||
driver: none
|
||||
volumes:
|
||||
- tsdata:/data
|
||||
|
||||
|
||||
@@ -68,8 +68,8 @@ services:
|
||||
environment:
|
||||
- TYPESENSE_API_KEY=${TYPESENSE_API_KEY}
|
||||
- TYPESENSE_DATA_DIR=/data
|
||||
# remove this to get debug messages
|
||||
- GLOG_minloglevel=1
|
||||
logging:
|
||||
driver: none
|
||||
volumes:
|
||||
- tsdata:/data
|
||||
restart: always
|
||||
|
||||
@@ -54,8 +54,6 @@ services:
|
||||
environment:
|
||||
- TYPESENSE_API_KEY=${TYPESENSE_API_KEY}
|
||||
- TYPESENSE_DATA_DIR=/data
|
||||
# remove this to get debug messages
|
||||
- GLOG_minloglevel=1
|
||||
volumes:
|
||||
- tsdata:/data
|
||||
restart: always
|
||||
|
||||
+2
-2
@@ -39,7 +39,7 @@ This often happens when using a reverse proxy or cloudflare tunnel in front of I
|
||||
|
||||
### Why is Immich slow on low-memory systems like the Raspberry Pi?
|
||||
|
||||
Immich uses optional machine-learning features to enhance search results. This feature, however, can be too heavy to run on a Raspberry Pi. To disable machine learning, comment out the `immich-machine-learning` section of your docker-compose.yml and set `IMMICH_MACHINE_LEARNING_ENABLED=false` in your .env file.
|
||||
Immich uses optional machine-learning features to enhance search results. This feature, however, can be too heavy to run on a Raspberry Pi. To disable machine learning, comment out the `immich-machine-learning` section of your docker-compose.yml and set `IMMICH_MACHINE_LEARNING_URL=false` in your .env file.
|
||||
|
||||
### How to disable machine-learning and TypeSense?
|
||||
|
||||
@@ -47,7 +47,7 @@ Immich uses optional machine-learning features to enhance search results. This f
|
||||
Disabling both will result in poor search experience and typesense utilizes CLIP embeddings which are generated by machine-learning.
|
||||
:::
|
||||
|
||||
These features can be disabled by commenting out `immich-typesense` and `immich-machine-learning` sections of the docker-compose.yml and setting `IMMICH_MACHINE_LEARNING_ENABLED=false` & `TYPESENSE_ENABLED=false` in your .env file.
|
||||
These features can be disabled by commenting out `immich-typesense` and `immich-machine-learning` sections of the docker-compose.yml and setting `IMMICH_MACHINE_LEARNING_URL=false` & `TYPESENSE_ENABLED=false` in your .env file.
|
||||
|
||||
### What happens to existing files after I choose a new [Storage Template](/docs/administration/storage-template.mdx)?
|
||||
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
# Config File
|
||||
|
||||
A config file can be provided as an alternative to the UI configuration.
|
||||
|
||||
### Step 1 - Create a new config file
|
||||
|
||||
In JSON format, create a new config file (e.g. `immich.config`) and put it in a location that can be accessed by Immich.
|
||||
The default configuration looks like this:
|
||||
|
||||
```json
|
||||
{
|
||||
"ffmpeg": {
|
||||
"crf": 23,
|
||||
"threads": 0,
|
||||
"preset": "ultrafast",
|
||||
"targetVideoCodec": "h264",
|
||||
"targetAudioCodec": "aac",
|
||||
"targetResolution": "720",
|
||||
"maxBitrate": "0",
|
||||
"twoPass": false,
|
||||
"transcode": "required",
|
||||
"tonemap": "hable",
|
||||
"accel": "disabled"
|
||||
},
|
||||
"job": {
|
||||
"backgroundTask": {
|
||||
"concurrency": 5
|
||||
},
|
||||
"clipEncoding": {
|
||||
"concurrency": 2
|
||||
},
|
||||
"metadataExtraction": {
|
||||
"concurrency": 5
|
||||
},
|
||||
"objectTagging": {
|
||||
"concurrency": 2
|
||||
},
|
||||
"recognizeFaces": {
|
||||
"concurrency": 2
|
||||
},
|
||||
"search": {
|
||||
"concurrency": 5
|
||||
},
|
||||
"sidecar": {
|
||||
"concurrency": 5
|
||||
},
|
||||
"storageTemplateMigration": {
|
||||
"concurrency": 5
|
||||
},
|
||||
"thumbnailGeneration": {
|
||||
"concurrency": 5
|
||||
},
|
||||
"videoConversion": {
|
||||
"concurrency": 1
|
||||
}
|
||||
},
|
||||
"oauth": {
|
||||
"enabled": false,
|
||||
"issuerUrl": "",
|
||||
"clientId": "",
|
||||
"clientSecret": "",
|
||||
"mobileOverrideEnabled": false,
|
||||
"mobileRedirectUri": "",
|
||||
"scope": "openid email profile",
|
||||
"storageLabelClaim": "preferred_username",
|
||||
"buttonText": "Login with OAuth",
|
||||
"autoRegister": true,
|
||||
"autoLaunch": false
|
||||
},
|
||||
"passwordLogin": {
|
||||
"enabled": true
|
||||
},
|
||||
"storageTemplate": {
|
||||
"template": "{{y}}/{{y}}-{{MM}}-{{dd}}/{{filename}}"
|
||||
},
|
||||
"thumbnail": {
|
||||
"webpSize": 250,
|
||||
"jpegSize": 1440
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
:::tip
|
||||
In Administration > Settings is a button to copy the current configuration to your clipboard.
|
||||
So you can just grab it from there, paste it into a file and you're pretty much good to go.
|
||||
:::
|
||||
|
||||
### Step 2 - Specify the file location
|
||||
|
||||
In your `.env` file, set the variable `IMMICH_CONFIG_FILE` to the path of your config.
|
||||
For more information, refer to the [Environment Variables](https://docs.immich.app/docs/install/environment-variables) section.
|
||||
@@ -132,6 +132,7 @@ PUBLIC_LOGIN_PAGE_MESSAGE="My Family Photos and Videos Backup Server"
|
||||
|
||||
IMMICH_WEB_URL=http://immich-web:3000
|
||||
IMMICH_SERVER_URL=http://immich-server:3001
|
||||
IMMICH_MACHINE_LEARNING_URL=http://immich-machine-learning:3003
|
||||
|
||||
####################################################################################
|
||||
# Alternative API's External Address - Optional
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
---
|
||||
sidebar_position: 90
|
||||
---
|
||||
|
||||
# Environment Variables
|
||||
|
||||
## Docker Compose
|
||||
@@ -26,7 +22,6 @@ These environment variables are used by the `docker-compose.yml` file and do **N
|
||||
| `LOG_LEVEL` | Log Level (verbose, debug, log, warn, error) | `log` | server, microservices |
|
||||
| `IMMICH_MEDIA_LOCATION` | Media Location | `./upload` | server, microservices |
|
||||
| `PUBLIC_LOGIN_PAGE_MESSAGE` | Public Login Page Message | | web |
|
||||
| `IMMICH_CONFIG_FILE` | Path to config file | | server |
|
||||
|
||||
:::tip
|
||||
|
||||
@@ -55,14 +50,13 @@ These environment variables are used by the `docker-compose.yml` file and do **N
|
||||
|
||||
## 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 |
|
||||
| `IMMICH_MACHINE_LEARNING_URL` | Immich Machine Learning URL, set `"false"` to disable ML | `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 |
|
||||
|
||||
:::info
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
sidebar_position: 80
|
||||
sidebar_position: 100
|
||||
---
|
||||
|
||||
import RegisterAdminUser from '../partials/_register-admin.md';
|
||||
|
||||
@@ -10,9 +10,8 @@ RUN poetry config installer.max-workers 10 && \
|
||||
RUN python -m venv /opt/venv
|
||||
ENV VIRTUAL_ENV="/opt/venv" PATH="/opt/venv/bin:${PATH}"
|
||||
|
||||
COPY poetry.lock pyproject.toml requirements.txt ./
|
||||
COPY poetry.lock pyproject.toml ./
|
||||
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
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import BaseSettings
|
||||
@@ -9,31 +8,25 @@ 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"
|
||||
clip_image_model: str = "clip-ViT-B-32"
|
||||
clip_text_model: str = "clip-ViT-B-32"
|
||||
facial_recognition_model: str = "buffalo_l"
|
||||
min_tag_score: float = 0.9
|
||||
eager_startup: bool = False
|
||||
eager_startup: bool = True
|
||||
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
|
||||
model_intra_op_threads: int = 2
|
||||
|
||||
class Config:
|
||||
env_prefix = "MACHINE_LEARNING_"
|
||||
case_sensitive = False
|
||||
|
||||
|
||||
_clean_name = str.maketrans(":\\/", "___", ".")
|
||||
|
||||
|
||||
def get_cache_dir(model_name: str, model_type: ModelType) -> Path:
|
||||
return Path(settings.cache_folder) / model_type.value / model_name.translate(_clean_name)
|
||||
return Path(settings.cache_folder, model_type.value, model_name)
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import asyncio
|
||||
import os
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from io import BytesIO
|
||||
from typing import Any
|
||||
|
||||
@@ -10,8 +8,6 @@ import uvicorn
|
||||
from fastapi import Body, Depends, FastAPI
|
||||
from PIL import Image
|
||||
|
||||
from app.models.base import InferenceModel
|
||||
|
||||
from .config import settings
|
||||
from .models.cache import ModelCache
|
||||
from .schemas import (
|
||||
@@ -29,21 +25,19 @@ app = FastAPI()
|
||||
|
||||
def init_state() -> None:
|
||||
app.state.model_cache = ModelCache(ttl=settings.model_ttl, revalidate=settings.model_ttl > 0)
|
||||
# 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, {}),
|
||||
models = [
|
||||
(settings.classification_model, ModelType.IMAGE_CLASSIFICATION),
|
||||
(settings.clip_image_model, ModelType.CLIP),
|
||||
(settings.clip_text_model, ModelType.CLIP),
|
||||
(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)
|
||||
for model_name, model_type in models:
|
||||
await app.state.model_cache.get(model_name, model_type, eager=settings.eager_startup)
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
@@ -52,16 +46,11 @@ async def startup_event() -> None:
|
||||
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]]:
|
||||
def dep_cv_image(byte_image: bytes = Body(...)) -> cv2.Mat:
|
||||
byte_image_np = np.frombuffer(byte_image, np.uint8)
|
||||
return cv2.imdecode(byte_image_np, cv2.IMREAD_COLOR)
|
||||
|
||||
@@ -85,7 +74,7 @@ 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)
|
||||
labels = model.predict(image)
|
||||
return labels
|
||||
|
||||
|
||||
@@ -97,8 +86,8 @@ async def image_classification(
|
||||
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)
|
||||
model = await app.state.model_cache.get(settings.clip_image_model, ModelType.CLIP)
|
||||
embedding = model.predict(image)
|
||||
return embedding
|
||||
|
||||
|
||||
@@ -108,8 +97,8 @@ async def clip_encode_image(
|
||||
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)
|
||||
model = await app.state.model_cache.get(settings.clip_text_model, ModelType.CLIP)
|
||||
embedding = model.predict(payload.text)
|
||||
return embedding
|
||||
|
||||
|
||||
@@ -122,14 +111,10 @@ 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)
|
||||
faces = model.predict(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(
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
from .clip import CLIPEncoder
|
||||
from .clip import CLIPSTEncoder
|
||||
from .facial_recognition import FaceRecognizer
|
||||
from .image_classification import ImageClassifier
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import pickle
|
||||
from abc import ABC, abstractmethod
|
||||
from pathlib import Path
|
||||
from shutil import rmtree
|
||||
from typing import Any
|
||||
from zipfile import BadZipFile
|
||||
|
||||
import onnxruntime as ort
|
||||
from onnxruntime.capi.onnxruntime_pybind11_state import InvalidProtobuf # type: ignore
|
||||
|
||||
from ..config import get_cache_dir, settings
|
||||
from ..config import get_cache_dir
|
||||
from ..schemas import ModelType
|
||||
|
||||
|
||||
@@ -19,31 +16,12 @@ class InferenceModel(ABC):
|
||||
_model_type: ModelType
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
model_name: str,
|
||||
cache_dir: Path | str | None = None,
|
||||
eager: bool = True,
|
||||
inter_op_num_threads: int = settings.model_inter_op_threads,
|
||||
intra_op_num_threads: int = settings.model_intra_op_threads,
|
||||
**model_kwargs: Any,
|
||||
self, model_name: str, cache_dir: Path | str | None = None, eager: bool = True, **model_kwargs: Any
|
||||
) -> None:
|
||||
self.model_name = model_name
|
||||
self._loaded = False
|
||||
self._cache_dir = Path(cache_dir) if cache_dir is not None else get_cache_dir(model_name, self.model_type)
|
||||
loader = self.load if eager else self.download
|
||||
|
||||
self.providers = model_kwargs.pop("providers", ["CPUExecutionProvider"])
|
||||
# don't pre-allocate more memory than needed
|
||||
self.provider_options = model_kwargs.pop(
|
||||
"provider_options", [{"arena_extend_strategy": "kSameAsRequested"}] * len(self.providers)
|
||||
)
|
||||
self.sess_options = PicklableSessionOptions()
|
||||
# avoid thread contention between models
|
||||
if inter_op_num_threads > 1:
|
||||
self.sess_options.execution_mode = ort.ExecutionMode.ORT_PARALLEL
|
||||
self.sess_options.inter_op_num_threads = inter_op_num_threads
|
||||
self.sess_options.intra_op_num_threads = intra_op_num_threads
|
||||
|
||||
try:
|
||||
loader(**model_kwargs)
|
||||
except (OSError, InvalidProtobuf, BadZipFile):
|
||||
@@ -52,7 +30,6 @@ class InferenceModel(ABC):
|
||||
|
||||
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...")
|
||||
self._download(**model_kwargs)
|
||||
|
||||
def load(self, **model_kwargs: Any) -> None:
|
||||
@@ -62,7 +39,6 @@ class InferenceModel(ABC):
|
||||
|
||||
def predict(self, inputs: Any) -> Any:
|
||||
if not self._loaded:
|
||||
print(f"Loading {self.model_type.value.replace('_', ' ')} model...")
|
||||
self.load()
|
||||
return self._predict(inputs)
|
||||
|
||||
@@ -113,14 +89,3 @@ class InferenceModel(ABC):
|
||||
else:
|
||||
self.cache_dir.unlink()
|
||||
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
# HF deep copies configs, so we need to make session options picklable
|
||||
class PicklableSessionOptions(ort.SessionOptions):
|
||||
def __getstate__(self) -> bytes:
|
||||
return pickle.dumps([(attr, getattr(self, attr)) for attr in dir(self) if not callable(getattr(self, attr))])
|
||||
|
||||
def __setstate__(self, state: Any) -> None:
|
||||
self.__init__() # type: ignore
|
||||
for attr, val in pickle.loads(state):
|
||||
setattr(self, attr, val)
|
||||
|
||||
@@ -46,7 +46,7 @@ class ModelCache:
|
||||
model: The requested model.
|
||||
"""
|
||||
|
||||
key = f"{model_name}{model_type.value}{model_kwargs.get('mode', '')}"
|
||||
key = self.cache.build_key(model_name, model_type.value)
|
||||
async with OptimisticLock(self.cache, key) as lock:
|
||||
model = await self.cache.get(key)
|
||||
if model is None:
|
||||
|
||||
@@ -1,141 +1,31 @@
|
||||
import os
|
||||
import zipfile
|
||||
from typing import Any, Literal
|
||||
from typing import Any
|
||||
|
||||
import onnxruntime as ort
|
||||
import torch
|
||||
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 torchvision.transforms import CenterCrop, Compose, Normalize, Resize, ToTensor
|
||||
from sentence_transformers import SentenceTransformer
|
||||
from sentence_transformers.util import snapshot_download
|
||||
|
||||
from ..schemas import ModelType
|
||||
from .base import InferenceModel
|
||||
|
||||
_ST_TO_JINA_MODEL_NAME = {
|
||||
"clip-ViT-B-16": "ViT-B-16::openai",
|
||||
"clip-ViT-B-32": "ViT-B-32::openai",
|
||||
"clip-ViT-B-32-multilingual-v1": "M-CLIP/XLM-Roberta-Large-Vit-B-32",
|
||||
"clip-ViT-L-14": "ViT-L-14::openai",
|
||||
}
|
||||
|
||||
|
||||
class CLIPEncoder(InferenceModel):
|
||||
class CLIPSTEncoder(InferenceModel):
|
||||
_model_type = ModelType.CLIP
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
model_name: str,
|
||||
cache_dir: str | None = None,
|
||||
mode: Literal["text", "vision"] | None = None,
|
||||
**model_kwargs: Any,
|
||||
) -> None:
|
||||
if mode is not None and mode not in ("text", "vision"):
|
||||
raise ValueError(f"Mode must be 'text', 'vision', or omitted; got '{mode}'")
|
||||
if "vit-b" not in model_name.lower():
|
||||
raise ValueError(f"Only ViT-B models are currently supported; got '{model_name}'")
|
||||
self.mode = mode
|
||||
jina_model_name = self._get_jina_model_name(model_name)
|
||||
super().__init__(jina_model_name, cache_dir, **model_kwargs)
|
||||
|
||||
def _download(self, **model_kwargs: Any) -> None:
|
||||
models: tuple[tuple[str, str], tuple[str, str]] = _MODELS[self.model_name]
|
||||
text_onnx_path = self.cache_dir / "textual.onnx"
|
||||
vision_onnx_path = self.cache_dir / "visual.onnx"
|
||||
|
||||
if not text_onnx_path.is_file():
|
||||
self._download_model(*models[0])
|
||||
|
||||
if not vision_onnx_path.is_file():
|
||||
self._download_model(*models[1])
|
||||
repo_id = self.model_name if "/" in self.model_name else f"sentence-transformers/{self.model_name}"
|
||||
snapshot_download(
|
||||
cache_dir=self.cache_dir,
|
||||
repo_id=repo_id,
|
||||
library_name="sentence-transformers",
|
||||
ignore_files=["flax_model.msgpack", "rust_model.ot", "tf_model.h5"],
|
||||
)
|
||||
|
||||
def _load(self, **model_kwargs: Any) -> None:
|
||||
if self.mode == "text" or self.mode is None:
|
||||
self.text_model = ort.InferenceSession(
|
||||
self.cache_dir / "textual.onnx",
|
||||
sess_options=self.sess_options,
|
||||
providers=self.providers,
|
||||
provider_options=self.provider_options,
|
||||
)
|
||||
self.text_outputs = [output.name for output in self.text_model.get_outputs()]
|
||||
self.tokenizer = Tokenizer(self.model_name)
|
||||
|
||||
if self.mode == "vision" or self.mode is None:
|
||||
self.vision_model = ort.InferenceSession(
|
||||
self.cache_dir / "visual.onnx",
|
||||
sess_options=self.sess_options,
|
||||
providers=self.providers,
|
||||
provider_options=self.provider_options,
|
||||
)
|
||||
self.vision_outputs = [output.name for output in self.vision_model.get_outputs()]
|
||||
|
||||
image_size = _VISUAL_MODEL_IMAGE_SIZE[CLIPOnnxModel.get_model_name(self.model_name)]
|
||||
self.transform = _transform_pil_image(image_size)
|
||||
self.model = SentenceTransformer(
|
||||
self.model_name,
|
||||
cache_folder=self.cache_dir.as_posix(),
|
||||
**model_kwargs,
|
||||
)
|
||||
|
||||
def _predict(self, image_or_text: Image | str) -> list[float]:
|
||||
match image_or_text:
|
||||
case Image():
|
||||
if self.mode == "text":
|
||||
raise TypeError("Cannot encode image as text-only model")
|
||||
pixel_values = self.transform(image_or_text)
|
||||
assert isinstance(pixel_values, torch.Tensor)
|
||||
pixel_values = torch.unsqueeze(pixel_values, 0).numpy()
|
||||
outputs = self.vision_model.run(self.vision_outputs, {"pixel_values": pixel_values})
|
||||
case str():
|
||||
if self.mode == "vision":
|
||||
raise TypeError("Cannot encode text as vision-only model")
|
||||
text_inputs: dict[str, torch.Tensor] = self.tokenizer(image_or_text)
|
||||
inputs = {
|
||||
"input_ids": text_inputs["input_ids"].int().numpy(),
|
||||
"attention_mask": text_inputs["attention_mask"].int().numpy(),
|
||||
}
|
||||
outputs = self.text_model.run(self.text_outputs, inputs)
|
||||
case _:
|
||||
raise TypeError(f"Expected Image or str, but got: {type(image_or_text)}")
|
||||
|
||||
return outputs[0][0].tolist()
|
||||
|
||||
def _get_jina_model_name(self, model_name: str) -> str:
|
||||
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}'."),
|
||||
)
|
||||
return _ST_TO_JINA_MODEL_NAME[model_name]
|
||||
else:
|
||||
raise ValueError(f"Unknown model name {model_name}.")
|
||||
|
||||
def _download_model(self, model_name: str, model_md5: str) -> bool:
|
||||
# downloading logic is adapted from clip-server's CLIPOnnxModel class
|
||||
download_model(
|
||||
url=_S3_BUCKET_V2 + model_name,
|
||||
target_folder=self.cache_dir.as_posix(),
|
||||
md5sum=model_md5,
|
||||
with_resume=True,
|
||||
)
|
||||
file = self.cache_dir / model_name.split("/")[1]
|
||||
if file.suffix == ".zip":
|
||||
with zipfile.ZipFile(file, "r") as zip_ref:
|
||||
zip_ref.extractall(self.cache_dir)
|
||||
os.remove(file)
|
||||
return True
|
||||
|
||||
|
||||
# same as `_transform_blob` without `_blob2image`
|
||||
def _transform_pil_image(n_px: int) -> Compose:
|
||||
return Compose(
|
||||
[
|
||||
Resize(n_px, interpolation=BICUBIC),
|
||||
CenterCrop(n_px),
|
||||
_convert_image_to_rgb,
|
||||
ToTensor(),
|
||||
Normalize(
|
||||
(0.48145466, 0.4578275, 0.40821073),
|
||||
(0.26862954, 0.26130258, 0.27577711),
|
||||
),
|
||||
]
|
||||
)
|
||||
return self.model.encode(image_or_text).tolist()
|
||||
|
||||
@@ -4,7 +4,6 @@ from typing import Any
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
import onnxruntime as ort
|
||||
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
|
||||
@@ -43,31 +42,15 @@ class FaceRecognizer(InferenceModel):
|
||||
rec_file = next(self.cache_dir.glob("w600k_*.onnx"))
|
||||
except StopIteration:
|
||||
raise FileNotFoundError("Facial recognition models not found in cache directory")
|
||||
|
||||
self.det_model = RetinaFace(
|
||||
session=ort.InferenceSession(
|
||||
det_file.as_posix(),
|
||||
sess_options=self.sess_options,
|
||||
providers=self.providers,
|
||||
provider_options=self.provider_options,
|
||||
),
|
||||
)
|
||||
self.rec_model = ArcFaceONNX(
|
||||
rec_file.as_posix(),
|
||||
session=ort.InferenceSession(
|
||||
rec_file.as_posix(),
|
||||
sess_options=self.sess_options,
|
||||
providers=self.providers,
|
||||
provider_options=self.provider_options,
|
||||
),
|
||||
)
|
||||
self.det_model = RetinaFace(det_file.as_posix())
|
||||
self.rec_model = ArcFaceONNX(rec_file.as_posix())
|
||||
|
||||
self.det_model.prepare(
|
||||
ctx_id=0,
|
||||
ctx_id=-1,
|
||||
det_thresh=self.min_score,
|
||||
input_size=(640, 640),
|
||||
)
|
||||
self.rec_model.prepare(ctx_id=0)
|
||||
self.rec_model.prepare(ctx_id=-1)
|
||||
|
||||
def _predict(self, image: cv2.Mat) -> list[dict[str, Any]]:
|
||||
bboxes, kpss = self.det_model.detect(image)
|
||||
|
||||
@@ -2,10 +2,8 @@ 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 transformers import AutoImageProcessor
|
||||
from transformers.pipelines import pipeline
|
||||
|
||||
from ..config import settings
|
||||
from ..schemas import ModelType
|
||||
@@ -27,34 +25,15 @@ class ImageClassifier(InferenceModel):
|
||||
|
||||
def _download(self, **model_kwargs: Any) -> None:
|
||||
snapshot_download(
|
||||
cache_dir=self.cache_dir,
|
||||
repo_id=self.model_name,
|
||||
allow_patterns=["*.bin", "*.json", "*.txt"],
|
||||
local_dir=self.cache_dir,
|
||||
local_dir_use_symlinks=True,
|
||||
cache_dir=self.cache_dir, repo_id=self.model_name, allow_patterns=["*.bin", "*.json", "*.txt"]
|
||||
)
|
||||
|
||||
def _load(self, **model_kwargs: Any) -> None:
|
||||
processor = AutoImageProcessor.from_pretrained(self.cache_dir)
|
||||
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:
|
||||
self.sess_options.optimized_model_filepath = model_path.as_posix()
|
||||
self.model = pipeline(
|
||||
self.model_type.value,
|
||||
self.model_name,
|
||||
model_kwargs=model_kwargs,
|
||||
feature_extractor=processor,
|
||||
)
|
||||
self.model = pipeline(
|
||||
self.model_type.value,
|
||||
self.model_name,
|
||||
model_kwargs={"cache_dir": self.cache_dir, **model_kwargs},
|
||||
)
|
||||
|
||||
def _predict(self, image: Image) -> list[str]:
|
||||
predictions: list[dict[str, Any]] = self.model(image) # type: ignore
|
||||
|
||||
@@ -1,20 +1,17 @@
|
||||
import pickle
|
||||
from io import BytesIO
|
||||
from typing import TypeAlias
|
||||
from unittest import mock
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
import onnxruntime as ort
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from PIL import Image
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
from .config import settings
|
||||
from .models.base import PicklableSessionOptions
|
||||
from .models.cache import ModelCache
|
||||
from .models.clip import CLIPEncoder
|
||||
from .models.clip import CLIPSTEncoder
|
||||
from .models.facial_recognition import FaceRecognizer
|
||||
from .models.image_classification import ImageClassifier
|
||||
from .schemas import ModelType
|
||||
@@ -75,47 +72,45 @@ class TestCLIP:
|
||||
embedding = np.random.rand(512).astype(np.float32)
|
||||
|
||||
def test_eager_init(self, mocker: MockerFixture) -> None:
|
||||
mocker.patch.object(CLIPEncoder, "download")
|
||||
mock_load = mocker.patch.object(CLIPEncoder, "load")
|
||||
clip_model = CLIPEncoder("ViT-B-32::openai", cache_dir="test_cache", eager=True, test_arg="test_arg")
|
||||
mocker.patch.object(CLIPSTEncoder, "download")
|
||||
mock_load = mocker.patch.object(CLIPSTEncoder, "load")
|
||||
clip_model = CLIPSTEncoder("test_model_name", cache_dir="test_cache", eager=True, test_arg="test_arg")
|
||||
|
||||
assert clip_model.model_name == "ViT-B-32::openai"
|
||||
assert clip_model.model_name == "test_model_name"
|
||||
mock_load.assert_called_once_with(test_arg="test_arg")
|
||||
|
||||
def test_lazy_init(self, mocker: MockerFixture) -> None:
|
||||
mock_download = mocker.patch.object(CLIPEncoder, "download")
|
||||
mock_load = mocker.patch.object(CLIPEncoder, "load")
|
||||
clip_model = CLIPEncoder("ViT-B-32::openai", cache_dir="test_cache", eager=False, test_arg="test_arg")
|
||||
mock_download = mocker.patch.object(CLIPSTEncoder, "download")
|
||||
mock_load = mocker.patch.object(CLIPSTEncoder, "load")
|
||||
clip_model = CLIPSTEncoder("test_model_name", cache_dir="test_cache", eager=False, test_arg="test_arg")
|
||||
|
||||
assert clip_model.model_name == "ViT-B-32::openai"
|
||||
assert clip_model.model_name == "test_model_name"
|
||||
mock_download.assert_called_once_with(test_arg="test_arg")
|
||||
mock_load.assert_not_called()
|
||||
|
||||
def test_basic_image(self, pil_image: Image.Image, mocker: MockerFixture) -> None:
|
||||
mocker.patch.object(CLIPEncoder, "download")
|
||||
mocked = mocker.patch("app.models.clip.ort.InferenceSession", autospec=True)
|
||||
mocked.return_value.run.return_value = [[self.embedding]]
|
||||
clip_encoder = CLIPEncoder("ViT-B-32::openai", cache_dir="test_cache", mode="vision")
|
||||
assert clip_encoder.mode == "vision"
|
||||
mocker.patch.object(CLIPSTEncoder, "load")
|
||||
clip_encoder = CLIPSTEncoder("test_model_name", cache_dir="test_cache")
|
||||
clip_encoder.model = mock.Mock()
|
||||
clip_encoder.model.encode.return_value = self.embedding
|
||||
embedding = clip_encoder.predict(pil_image)
|
||||
|
||||
assert isinstance(embedding, list)
|
||||
assert len(embedding) == 512
|
||||
assert all([isinstance(num, float) for num in embedding])
|
||||
clip_encoder.vision_model.run.assert_called_once()
|
||||
clip_encoder.model.encode.assert_called_once()
|
||||
|
||||
def test_basic_text(self, mocker: MockerFixture) -> None:
|
||||
mocker.patch.object(CLIPEncoder, "download")
|
||||
mocked = mocker.patch("app.models.clip.ort.InferenceSession", autospec=True)
|
||||
mocked.return_value.run.return_value = [[self.embedding]]
|
||||
clip_encoder = CLIPEncoder("ViT-B-32::openai", cache_dir="test_cache", mode="text")
|
||||
assert clip_encoder.mode == "text"
|
||||
mocker.patch.object(CLIPSTEncoder, "load")
|
||||
clip_encoder = CLIPSTEncoder("test_model_name", cache_dir="test_cache")
|
||||
clip_encoder.model = mock.Mock()
|
||||
clip_encoder.model.encode.return_value = self.embedding
|
||||
embedding = clip_encoder.predict("test search query")
|
||||
|
||||
assert isinstance(embedding, list)
|
||||
assert len(embedding) == 512
|
||||
assert all([isinstance(num, float) for num in embedding])
|
||||
clip_encoder.text_model.run.assert_called_once()
|
||||
clip_encoder.model.encode.assert_called_once()
|
||||
|
||||
|
||||
class TestFaceRecognition:
|
||||
@@ -259,13 +254,3 @@ class TestEndpoints:
|
||||
headers=headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_sess_options() -> None:
|
||||
sess_options = PicklableSessionOptions()
|
||||
sess_options.intra_op_num_threads = 1
|
||||
sess_options.inter_op_num_threads = 1
|
||||
pickled = pickle.dumps(sess_options)
|
||||
unpickled = pickle.loads(pickled)
|
||||
assert unpickled.intra_op_num_threads == 1
|
||||
assert unpickled.inter_op_num_threads == 1
|
||||
|
||||
Generated
+433
-1304
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "machine-learning"
|
||||
version = "1.75.2"
|
||||
version = "1.73.0"
|
||||
description = ""
|
||||
authors = ["Hau Tran <alex.tran1502@gmail.com>"]
|
||||
readme = "README.md"
|
||||
@@ -13,6 +13,7 @@ torch = [
|
||||
{markers = "platform_machine == 'amd64' or platform_machine == 'x86_64'", version = "=2.0.1", source = "pytorch-cpu"}
|
||||
]
|
||||
transformers = "^4.29.2"
|
||||
sentence-transformers = "^2.2.2"
|
||||
onnxruntime = "^1.15.0"
|
||||
insightface = "^0.7.3"
|
||||
opencv-python-headless = "^4.7.0.72"
|
||||
@@ -21,15 +22,6 @@ fastapi = "^0.95.2"
|
||||
uvicorn = {extras = ["standard"], version = "^0.22.0"}
|
||||
pydantic = "^1.10.8"
|
||||
aiocache = "^0.12.1"
|
||||
optimum = "^1.9.1"
|
||||
torchvision = [
|
||||
{markers = "platform_machine == 'arm64' or platform_machine == 'aarch64'", version = "=0.15.2", source = "pypi"},
|
||||
{markers = "platform_machine == 'amd64' or platform_machine == 'x86_64'", version = "=0.15.2", source = "pytorch-cpu"}
|
||||
]
|
||||
rich = "^13.4.2"
|
||||
ftfy = "^6.1.1"
|
||||
setuptools = "^68.0.0"
|
||||
open-clip-torch = "^2.20.0"
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
mypy = "^1.3.0"
|
||||
@@ -70,20 +62,13 @@ warn_untyped_fields = true
|
||||
[[tool.mypy.overrides]]
|
||||
module = [
|
||||
"huggingface_hub",
|
||||
"transformers",
|
||||
"transformers.pipelines",
|
||||
"cv2",
|
||||
"insightface.model_zoo",
|
||||
"insightface.utils.face_align",
|
||||
"insightface.utils.storage",
|
||||
"onnxruntime",
|
||||
"optimum",
|
||||
"optimum.pipelines",
|
||||
"optimum.onnxruntime",
|
||||
"clip_server.model.clip",
|
||||
"clip_server.model.clip_onnx",
|
||||
"clip_server.model.pretrained_models",
|
||||
"clip_server.model.tokenization",
|
||||
"torchvision.transforms",
|
||||
"sentence_transformers",
|
||||
"sentence_transformers.util",
|
||||
"aiocache.backends.memory",
|
||||
"aiocache.lock",
|
||||
"aiocache.plugins"
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
# requirements to be installed with `--no-deps` flag
|
||||
clip-server==0.8.*
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"flutterSdkVersion": "3.13.0",
|
||||
"flutterSdkVersion": "3.10.5",
|
||||
"flavors": {}
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ android {
|
||||
defaultConfig {
|
||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||
applicationId "app.alextran.immich"
|
||||
minSdkVersion 26
|
||||
minSdkVersion 23
|
||||
targetSdkVersion 33
|
||||
versionCode flutterVersionCode.toInteger()
|
||||
versionName flutterVersionName
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
|
||||
<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.READ_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_MEDIA_LOCATION" />
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
|
||||
|
||||
@@ -35,8 +35,8 @@ platform :android do
|
||||
task: 'bundle',
|
||||
build_type: 'Release',
|
||||
properties: {
|
||||
"android.injected.version.code" => 98,
|
||||
"android.injected.version.name" => "1.75.2",
|
||||
"android.injected.version.code" => 96,
|
||||
"android.injected.version.name" => "1.73.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')
|
||||
|
||||
@@ -300,6 +300,5 @@
|
||||
"version_announcement_overlay_text_1": "Hi friend, there is a new release of",
|
||||
"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"
|
||||
}
|
||||
"version_announcement_overlay_title": "New Server Version Available \uD83C\uDF89"
|
||||
}
|
||||
+13
-13
@@ -33,7 +33,7 @@ PODS:
|
||||
- FlutterMacOS
|
||||
- path_provider_ios (0.0.1):
|
||||
- Flutter
|
||||
- permission_handler_apple (9.1.1):
|
||||
- permission_handler_apple (9.0.4):
|
||||
- Flutter
|
||||
- photo_manager (2.0.0):
|
||||
- Flutter
|
||||
@@ -53,7 +53,7 @@ PODS:
|
||||
- Flutter
|
||||
- video_player_avfoundation (0.0.1):
|
||||
- Flutter
|
||||
- wakelock_plus (0.0.1):
|
||||
- wakelock (0.0.1):
|
||||
- Flutter
|
||||
|
||||
DEPENDENCIES:
|
||||
@@ -78,7 +78,7 @@ DEPENDENCIES:
|
||||
- sqflite (from `.symlinks/plugins/sqflite/ios`)
|
||||
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
|
||||
- video_player_avfoundation (from `.symlinks/plugins/video_player_avfoundation/ios`)
|
||||
- wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`)
|
||||
- wakelock (from `.symlinks/plugins/wakelock/ios`)
|
||||
|
||||
SPEC REPOS:
|
||||
trunk:
|
||||
@@ -130,8 +130,8 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/url_launcher_ios/ios"
|
||||
video_player_avfoundation:
|
||||
:path: ".symlinks/plugins/video_player_avfoundation/ios"
|
||||
wakelock_plus:
|
||||
:path: ".symlinks/plugins/wakelock_plus/ios"
|
||||
wakelock:
|
||||
:path: ".symlinks/plugins/wakelock/ios"
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
connectivity_plus: 07c49e96d7fc92bc9920617b83238c4d178b446a
|
||||
@@ -141,26 +141,26 @@ SPEC CHECKSUMS:
|
||||
flutter_native_splash: 52501b97d1c0a5f898d687f1646226c1f93c56ef
|
||||
flutter_udid: 0848809dbed4c055175747ae6a45a8b4f6771e1c
|
||||
flutter_web_auth: c25208760459cec375a3c39f6a8759165ca0fa4d
|
||||
fluttertoast: fafc4fa4d01a6a9e4f772ecd190ffa525e9e2d9c
|
||||
fluttertoast: eb263d302cc92e04176c053d2385237e9f43fad0
|
||||
FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a
|
||||
image_picker_ios: 4a8aadfbb6dc30ad5141a2ce3832af9214a705b5
|
||||
integration_test: 13825b8a9334a850581300559b8839134b124670
|
||||
isar_flutter_libs: b69f437aeab9c521821c3f376198c4371fa21073
|
||||
package_info_plus: fd030dabf36271f146f1f3beacd48f564b0f17f7
|
||||
path_provider_foundation: 29f094ae23ebbca9d3d0cec13889cd9060c0e943
|
||||
package_info_plus: 6c92f08e1f853dc01228d6f553146438dafcd14e
|
||||
path_provider_foundation: eaf5b3e458fc0e5fbb9940fb09980e853fe058b8
|
||||
path_provider_ios: 14f3d2fd28c4fdb42f44e0f751d12861c43cee02
|
||||
permission_handler_apple: e76247795d700c14ea09e3a2d8855d41ee80a2e6
|
||||
permission_handler_apple: 44366e37eaf29454a1e7b1b7d736c2cceaeb17ce
|
||||
photo_manager: 4f6810b7dfc4feb03b461ac1a70dacf91fba7604
|
||||
ReachabilitySwift: 985039c6f7b23a1da463388634119492ff86c825
|
||||
SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c
|
||||
share_plus: 599aa54e4ea31d4b4c0e9c911bcc26c55e791028
|
||||
shared_preferences_foundation: 5b919d13b803cadd15ed2dc053125c68730e5126
|
||||
share_plus: 056a1e8ac890df3e33cb503afffaf1e9b4fbae68
|
||||
shared_preferences_foundation: e2dae3258e06f44cc55f49d42024fd8dd03c590c
|
||||
sqflite: 31f7eba61e3074736dff8807a9b41581e4f7f15a
|
||||
Toast: 91b396c56ee72a5790816f40d3a94dd357abc196
|
||||
url_launcher_ios: 08a3dfac5fb39e8759aeb0abbd5d9480f30fc8b4
|
||||
video_player_avfoundation: 81e49bb3d9fb63dccf9fa0f6d877dc3ddbeac126
|
||||
wakelock_plus: 8b09852c8876491e4b6d179e17dfe2a0b5f60d47
|
||||
wakelock: d0fc7c864128eac40eba1617cb5264d9c940b46f
|
||||
|
||||
PODFILE CHECKSUM: 599d8aeb73728400c15364e734525722250a5382
|
||||
|
||||
COCOAPODS: 1.11.3
|
||||
COCOAPODS: 1.12.1
|
||||
|
||||
@@ -171,7 +171,7 @@
|
||||
97C146E61CF9000F007C117D /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
LastUpgradeCheck = 1430;
|
||||
LastUpgradeCheck = 1300;
|
||||
ORGANIZATIONNAME = "";
|
||||
TargetAttributes = {
|
||||
97C146ED1CF9000F007C117D = {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1430"
|
||||
LastUpgradeVersion = "1300"
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/usr/bin/env sh
|
||||
#!/bin/sh
|
||||
|
||||
# The default execution directory of this script is the ci_scripts directory.
|
||||
cd $CI_WORKSPACE/mobile
|
||||
|
||||
@@ -19,7 +19,7 @@ platform :ios do
|
||||
desc "iOS Beta"
|
||||
lane :beta do
|
||||
increment_version_number(
|
||||
version_number: "1.75.2"
|
||||
version_number: "1.73.0"
|
||||
)
|
||||
increment_build_number(
|
||||
build_number: latest_testflight_build_number + 1,
|
||||
|
||||
@@ -139,10 +139,6 @@ class ImmichAppState extends ConsumerState<ImmichApp>
|
||||
debugPrint("[APP STATE] detached");
|
||||
ref.read(appStateProvider.notifier).handleAppDetached();
|
||||
break;
|
||||
case AppLifecycleState.hidden:
|
||||
debugPrint("[APP STATE] hidden");
|
||||
ref.read(appStateProvider.notifier).handleAppHidden();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -56,16 +56,6 @@ class SharedAlbumNotifier extends StateNotifier<List<Album>> {
|
||||
return _albumService.removeAssetFromAlbum(album, assets);
|
||||
}
|
||||
|
||||
Future<bool> removeUserFromAlbum(Album album, User user) async {
|
||||
final result = await _albumService.removeUserFromAlbum(album, user);
|
||||
|
||||
if (result && album.sharedUsers.isEmpty) {
|
||||
state = state.where((element) => element.id != album.id).toList();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_streamSub.cancel();
|
||||
|
||||
@@ -348,26 +348,6 @@ class AlbumService {
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> removeUserFromAlbum(
|
||||
Album album,
|
||||
User user,
|
||||
) async {
|
||||
try {
|
||||
await _apiService.albumApi.removeUserFromAlbum(
|
||||
album.remoteId!,
|
||||
user.id,
|
||||
);
|
||||
|
||||
album.sharedUsers.remove(user);
|
||||
await _db.writeTxn(() => album.sharedUsers.update(unlink: [user]));
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
debugPrint("Error removeUserFromAlbum ${e.toString()}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> changeTitleAlbum(
|
||||
Album album,
|
||||
String newAlbumTitle,
|
||||
|
||||
@@ -49,7 +49,7 @@ class AlbumThumbnailListTile extends StatelessWidget {
|
||||
type: ThumbnailFormat.JPEG,
|
||||
),
|
||||
httpHeaders: {
|
||||
"Authorization": "Bearer ${Store.get(StoreKey.accessToken)}",
|
||||
"Authorization": "Bearer ${Store.get(StoreKey.accessToken)}"
|
||||
},
|
||||
cacheKey: getAlbumThumbNailCacheKey(album, type: ThumbnailFormat.JPEG),
|
||||
errorWidget: (context, url, error) =>
|
||||
@@ -105,9 +105,9 @@ class AlbumThumbnailListTile extends StatelessWidget {
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
),
|
||||
).tr(),
|
||||
).tr()
|
||||
],
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -69,11 +69,6 @@ class AlbumTitleTextField extends ConsumerWidget {
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
hintText: 'share_add_title'.tr(),
|
||||
hintStyle: TextStyle(
|
||||
fontSize: 28,
|
||||
color: isDarkTheme ? Colors.grey[300] : Colors.grey[700],
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
focusColor: Colors.grey[300],
|
||||
fillColor: isDarkTheme
|
||||
? const Color.fromARGB(255, 32, 33, 35)
|
||||
|
||||
@@ -39,7 +39,7 @@ class AlbumViewerAppbar extends HookConsumerWidget
|
||||
final newAlbumTitle = ref.watch(albumViewerProvider).editTitleText;
|
||||
final isEditAlbum = ref.watch(albumViewerProvider).isEditAlbum;
|
||||
|
||||
deleteAlbum() async {
|
||||
void onDeleteAlbumPressed() async {
|
||||
ImmichLoadingOverlayController.appLoader.show();
|
||||
|
||||
final bool success;
|
||||
@@ -65,52 +65,6 @@ class AlbumViewerAppbar extends HookConsumerWidget
|
||||
ImmichLoadingOverlayController.appLoader.hide();
|
||||
}
|
||||
|
||||
Future<void> showConfirmationDialog() async {
|
||||
return showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false, // user must tap button!
|
||||
builder: (BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: const Text('Delete album'),
|
||||
content: const Text(
|
||||
'Are you sure you want to delete this album from your account?',
|
||||
),
|
||||
actions: <Widget>[
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, 'Cancel'),
|
||||
child: Text(
|
||||
'Cancel',
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).primaryColor,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(context, 'Confirm');
|
||||
deleteAlbum();
|
||||
},
|
||||
child: Text(
|
||||
'Confirm',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).brightness == Brightness.light
|
||||
? Colors.red
|
||||
: Colors.red[300],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void onDeleteAlbumPressed() async {
|
||||
showConfirmationDialog();
|
||||
}
|
||||
|
||||
void onLeaveAlbumPressed() async {
|
||||
ImmichLoadingOverlayController.appLoader.show();
|
||||
|
||||
@@ -198,61 +152,43 @@ class AlbumViewerAppbar extends HookConsumerWidget
|
||||
}
|
||||
|
||||
void buildBottomSheet() {
|
||||
final ownerActions = [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.person_add_alt_rounded),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
onAddUsers!(album);
|
||||
},
|
||||
title: const Text(
|
||||
"album_viewer_page_share_add_users",
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
).tr(),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.settings_rounded),
|
||||
onTap: () =>
|
||||
AutoRouter.of(context).navigate(AlbumOptionsRoute(album: album)),
|
||||
title: const Text(
|
||||
"translated_text_options",
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
).tr(),
|
||||
),
|
||||
];
|
||||
|
||||
final commonActions = [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.add_photo_alternate_outlined),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
onAddPhotos!(album);
|
||||
},
|
||||
title: const Text(
|
||||
"share_add_photos",
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
).tr(),
|
||||
),
|
||||
];
|
||||
showModalBottomSheet(
|
||||
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
|
||||
isScrollControlled: false,
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 24.0),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
buildBottomSheetActionButton(),
|
||||
if (selected.isEmpty && onAddPhotos != null) ...commonActions,
|
||||
if (selected.isEmpty &&
|
||||
onAddPhotos != null &&
|
||||
userId == album.ownerId)
|
||||
...ownerActions,
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
buildBottomSheetActionButton(),
|
||||
if (selected.isEmpty && onAddPhotos != null)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.add_photo_alternate_outlined),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
onAddPhotos!(album);
|
||||
},
|
||||
title: const Text(
|
||||
"share_add_photos",
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
).tr(),
|
||||
),
|
||||
if (selected.isEmpty &&
|
||||
onAddPhotos != null &&
|
||||
userId == album.ownerId)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.person_add_alt_rounded),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
onAddUsers!(album);
|
||||
},
|
||||
title: const Text(
|
||||
"album_viewer_page_share_add_users",
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
).tr(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -281,8 +217,6 @@ class AlbumViewerAppbar extends HookConsumerWidget
|
||||
toastType: ToastType.error,
|
||||
);
|
||||
}
|
||||
|
||||
titleFocusNode.unfocus();
|
||||
},
|
||||
icon: const Icon(Icons.check_rounded),
|
||||
splashRadius: 25,
|
||||
|
||||
@@ -84,11 +84,6 @@ class AlbumViewerEditableTitle extends HookConsumerWidget {
|
||||
: Colors.grey[200],
|
||||
filled: titleFocusNode.hasFocus,
|
||||
hintText: 'share_add_title'.tr(),
|
||||
hintStyle: TextStyle(
|
||||
fontSize: 28,
|
||||
color: isDarkTheme ? Colors.grey[300] : Colors.grey[700],
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,205 +0,0 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
|
||||
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/shared/models/album.dart';
|
||||
import 'package:immich_mobile/shared/models/user.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_toast.dart';
|
||||
import 'package:immich_mobile/shared/ui/user_circle_avatar.dart';
|
||||
import 'package:immich_mobile/shared/views/immich_loading_overlay.dart';
|
||||
|
||||
class AlbumOptionsPage extends HookConsumerWidget {
|
||||
final Album album;
|
||||
|
||||
const AlbumOptionsPage({super.key, required this.album});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final sharedUsers = useState(album.sharedUsers.toList());
|
||||
final owner = album.owner.value;
|
||||
final userId = ref.watch(authenticationProvider).userId;
|
||||
final isOwner = owner?.id == userId;
|
||||
|
||||
void showErrorMessage() {
|
||||
Navigator.pop(context);
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: "Error leaving/removing from album",
|
||||
toastType: ToastType.error,
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
);
|
||||
}
|
||||
|
||||
void leaveAlbum() async {
|
||||
ImmichLoadingOverlayController.appLoader.show();
|
||||
|
||||
try {
|
||||
final isSuccess =
|
||||
await ref.read(sharedAlbumProvider.notifier).leaveAlbum(album);
|
||||
|
||||
if (isSuccess) {
|
||||
AutoRouter.of(context)
|
||||
.navigate(const TabControllerRoute(children: [SharingRoute()]));
|
||||
} else {
|
||||
showErrorMessage();
|
||||
}
|
||||
} catch (_) {
|
||||
showErrorMessage();
|
||||
}
|
||||
|
||||
ImmichLoadingOverlayController.appLoader.hide();
|
||||
}
|
||||
|
||||
void removeUserFromAlbum(User user) async {
|
||||
ImmichLoadingOverlayController.appLoader.show();
|
||||
|
||||
try {
|
||||
await ref
|
||||
.read(sharedAlbumProvider.notifier)
|
||||
.removeUserFromAlbum(album, user);
|
||||
album.sharedUsers.remove(user);
|
||||
sharedUsers.value = album.sharedUsers.toList();
|
||||
} catch (error) {
|
||||
showErrorMessage();
|
||||
}
|
||||
|
||||
Navigator.pop(context);
|
||||
ImmichLoadingOverlayController.appLoader.hide();
|
||||
}
|
||||
|
||||
void handleUserClick(User user) {
|
||||
var actions = [];
|
||||
|
||||
if (user.id == userId) {
|
||||
actions = [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.exit_to_app_rounded),
|
||||
title: const Text("Leave album"),
|
||||
onTap: leaveAlbum,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
if (isOwner) {
|
||||
actions = [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.person_remove_rounded),
|
||||
title: const Text("Remove user from album"),
|
||||
onTap: () => removeUserFromAlbum(user),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
showModalBottomSheet(
|
||||
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
|
||||
isScrollControlled: false,
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 24.0),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [...actions],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
buildOwnerInfo() {
|
||||
return ListTile(
|
||||
leading: owner != null
|
||||
? UserCircleAvatar(
|
||||
user: owner,
|
||||
useRandomBackgroundColor: true,
|
||||
)
|
||||
: const SizedBox(),
|
||||
title: Text(
|
||||
album.owner.value?.firstName ?? "",
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
album.owner.value?.email ?? "",
|
||||
style: TextStyle(color: Colors.grey[500]),
|
||||
),
|
||||
trailing: const Text(
|
||||
"Owner",
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
buildSharedUsersList() {
|
||||
return ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: sharedUsers.value.length,
|
||||
itemBuilder: (context, index) {
|
||||
final user = sharedUsers.value[index];
|
||||
return ListTile(
|
||||
leading: UserCircleAvatar(
|
||||
user: user,
|
||||
useRandomBackgroundColor: true,
|
||||
radius: 22,
|
||||
),
|
||||
title: Text(
|
||||
user.firstName,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
user.email,
|
||||
style: TextStyle(color: Colors.grey[500]),
|
||||
),
|
||||
trailing: userId == user.id || isOwner
|
||||
? const Icon(Icons.more_horiz_rounded)
|
||||
: const SizedBox(),
|
||||
onTap: userId == user.id || isOwner
|
||||
? () => handleUserClick(user)
|
||||
: null,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
buildSectionTitle(String text) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Text(text, style: Theme.of(context).textTheme.bodySmall),
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back_ios_new_rounded),
|
||||
onPressed: () {
|
||||
AutoRouter.of(context).pop(null);
|
||||
},
|
||||
),
|
||||
centerTitle: true,
|
||||
title: Text("translated_text_options".tr()),
|
||||
),
|
||||
body: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
buildSectionTitle("PEOPLE"),
|
||||
buildOwnerInfo(),
|
||||
buildSharedUsersList(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,6 @@ import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/shared/models/album.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/user_circle_avatar.dart';
|
||||
import 'package:immich_mobile/shared/views/immich_loading_overlay.dart';
|
||||
|
||||
class AlbumViewerPage extends HookConsumerWidget {
|
||||
@@ -117,7 +116,7 @@ class AlbumViewerPage extends HookConsumerWidget {
|
||||
|
||||
Widget buildControlButton(Album album) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: 16.0, top: 8, bottom: 16),
|
||||
padding: const EdgeInsets.only(left: 16.0, top: 8, bottom: 8),
|
||||
child: SizedBox(
|
||||
height: 40,
|
||||
child: ListView(
|
||||
@@ -142,7 +141,7 @@ class AlbumViewerPage extends HookConsumerWidget {
|
||||
|
||||
Widget buildTitle(Album album) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: 8, right: 8, top: 24),
|
||||
padding: const EdgeInsets.only(left: 8, right: 8, top: 16),
|
||||
child: userId == album.ownerId && album.isRemote
|
||||
? AlbumViewerEditableTitle(
|
||||
album: album,
|
||||
@@ -173,6 +172,7 @@ class AlbumViewerPage extends HookConsumerWidget {
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
left: 16.0,
|
||||
top: 8.0,
|
||||
bottom: album.shared ? 0.0 : 8.0,
|
||||
),
|
||||
child: Text(
|
||||
@@ -180,34 +180,7 @@ class AlbumViewerPage extends HookConsumerWidget {
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildSharedUserIconsRow(Album album) {
|
||||
return GestureDetector(
|
||||
onTap: () async {
|
||||
await AutoRouter.of(context).push(AlbumOptionsRoute(album: album));
|
||||
ref.invalidate(albumDetailProvider(album.id));
|
||||
},
|
||||
child: SizedBox(
|
||||
height: 50,
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.only(left: 16),
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemBuilder: ((context, index) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 8.0),
|
||||
child: UserCircleAvatar(
|
||||
user: album.sharedUsers.toList()[index],
|
||||
radius: 18,
|
||||
size: 36,
|
||||
useRandomBackgroundColor: true,
|
||||
),
|
||||
);
|
||||
}),
|
||||
itemCount: album.sharedUsers.length,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -220,7 +193,33 @@ class AlbumViewerPage extends HookConsumerWidget {
|
||||
children: [
|
||||
buildTitle(album),
|
||||
if (album.assets.isNotEmpty == true) buildAlbumDateRange(album),
|
||||
if (album.shared) buildSharedUserIconsRow(album),
|
||||
if (album.shared)
|
||||
SizedBox(
|
||||
height: 50,
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.only(left: 16),
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemBuilder: ((context, index) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 8.0),
|
||||
child: CircleAvatar(
|
||||
backgroundColor: Colors.grey[300],
|
||||
radius: 18,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(2.0),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(50.0),
|
||||
child: Image.asset(
|
||||
'assets/immich-logo-no-outline.png',
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
itemCount: album.sharedUsers.length,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -73,12 +73,9 @@ class AssetSelectionPage extends HookConsumerWidget {
|
||||
AutoRouter.of(context)
|
||||
.popForced<AssetSelectionPageResult>(payload);
|
||||
},
|
||||
child: Text(
|
||||
child: const Text(
|
||||
"share_add",
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).primaryColor,
|
||||
),
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
).tr(),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -30,8 +30,7 @@ class CreateAlbumPage extends HookConsumerWidget {
|
||||
final albumTitleTextFieldFocusNode = useFocusNode();
|
||||
final isAlbumTitleTextFieldFocus = useState(false);
|
||||
final isAlbumTitleEmpty = useState(true);
|
||||
final selectedAssets = useState<Set<Asset>>(
|
||||
initialAssets != null ? Set.from(initialAssets!) : const {},);
|
||||
final selectedAssets = useState<Set<Asset>>(initialAssets != null ? Set.from(initialAssets!) : const {});
|
||||
final isDarkTheme = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
showSelectUserPage() async {
|
||||
@@ -249,9 +248,8 @@ class CreateAlbumPage extends HookConsumerWidget {
|
||||
: null,
|
||||
child: Text(
|
||||
'create_shared_album_page_create'.tr(),
|
||||
style: TextStyle(
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).primaryColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -60,7 +60,7 @@ class LibraryPage extends HookConsumerWidget {
|
||||
Widget buildSortButton() {
|
||||
final options = [
|
||||
"library_page_sort_created".tr(),
|
||||
"library_page_sort_title".tr(),
|
||||
"library_page_sort_title".tr()
|
||||
];
|
||||
|
||||
return PopupMenuButton(
|
||||
@@ -87,7 +87,7 @@ class LibraryPage extends HookConsumerWidget {
|
||||
color: selected ? Theme.of(context).primaryColor : null,
|
||||
fontSize: 12.0,
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -7,7 +7,6 @@ import 'package:immich_mobile/modules/album/providers/suggested_shared_users.pro
|
||||
import 'package:immich_mobile/shared/models/album.dart';
|
||||
import 'package:immich_mobile/shared/models/user.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
|
||||
import 'package:immich_mobile/shared/ui/user_circle_avatar.dart';
|
||||
|
||||
class SelectAdditionalUserForSharingPage extends HookConsumerWidget {
|
||||
final Album album;
|
||||
@@ -36,8 +35,10 @@ class SelectAdditionalUserForSharingPage extends HookConsumerWidget {
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return UserCircleAvatar(
|
||||
user: user,
|
||||
return CircleAvatar(
|
||||
backgroundImage:
|
||||
const AssetImage('assets/immich-logo-no-outline.png'),
|
||||
backgroundColor: Theme.of(context).primaryColor.withAlpha(50),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -102,7 +103,7 @@ class SelectAdditionalUserForSharingPage extends HookConsumerWidget {
|
||||
} else {
|
||||
sharedUsersList.value = {
|
||||
...sharedUsersList.value,
|
||||
users[index],
|
||||
users[index]
|
||||
};
|
||||
}
|
||||
},
|
||||
@@ -135,7 +136,7 @@ class SelectAdditionalUserForSharingPage extends HookConsumerWidget {
|
||||
"share_add",
|
||||
style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
|
||||
).tr(),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
body: suggestedShareUsers.when(
|
||||
|
||||
@@ -10,7 +10,6 @@ import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/models/user.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
|
||||
import 'package:immich_mobile/shared/ui/user_circle_avatar.dart';
|
||||
|
||||
class SelectUserForSharingPage extends HookConsumerWidget {
|
||||
const SelectUserForSharingPage({Key? key, required this.assets})
|
||||
@@ -57,8 +56,10 @@ class SelectUserForSharingPage extends HookConsumerWidget {
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return UserCircleAvatar(
|
||||
user: user,
|
||||
return CircleAvatar(
|
||||
backgroundImage:
|
||||
const AssetImage('assets/immich-logo-no-outline.png'),
|
||||
backgroundColor: Theme.of(context).primaryColor.withAlpha(50),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -123,7 +124,7 @@ class SelectUserForSharingPage extends HookConsumerWidget {
|
||||
} else {
|
||||
sharedUsersList.value = {
|
||||
...sharedUsersList.value,
|
||||
users[index],
|
||||
users[index]
|
||||
};
|
||||
}
|
||||
},
|
||||
@@ -163,7 +164,7 @@ class SelectUserForSharingPage extends HookConsumerWidget {
|
||||
// color: Theme.of(context).primaryColor,
|
||||
),
|
||||
).tr(),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
body: suggestedShareUsers.when(
|
||||
|
||||
@@ -160,7 +160,7 @@ class SharingPage extends HookConsumerWidget {
|
||||
maxLines: 1,
|
||||
).tr(),
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -91,7 +91,7 @@ class ArchivePage extends HookConsumerWidget {
|
||||
selectionEnabledHook.value = false;
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -124,7 +124,7 @@ class ArchivePage extends HookConsumerWidget {
|
||||
),
|
||||
if (selectionEnabledHook.value) buildBottomBar(),
|
||||
if (processing.value)
|
||||
const Center(child: ImmichLoadingIndicator()),
|
||||
const Center(child: ImmichLoadingIndicator())
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -16,32 +16,16 @@ class ExifBottomSheet extends HookConsumerWidget {
|
||||
|
||||
const ExifBottomSheet({Key? key, required this.asset}) : super(key: key);
|
||||
|
||||
bool get hasCoordinates =>
|
||||
bool get showMap =>
|
||||
asset.exifInfo?.latitude != null && asset.exifInfo?.longitude != null;
|
||||
|
||||
String get formattedDateTime {
|
||||
final fileCreatedAt = asset.fileCreatedAt.toLocal();
|
||||
final date = DateFormat.yMMMEd().format(fileCreatedAt);
|
||||
final time = DateFormat.jm().format(fileCreatedAt);
|
||||
|
||||
return '$date • $time';
|
||||
}
|
||||
|
||||
Future<Uri?> _createCoordinatesUri() async {
|
||||
if (!hasCoordinates) {
|
||||
return null;
|
||||
}
|
||||
|
||||
double latitude = asset.exifInfo!.latitude!;
|
||||
double longitude = asset.exifInfo!.longitude!;
|
||||
|
||||
const zoomLevel = 16;
|
||||
|
||||
Future<Uri> _createCoordinatesUri(double latitude, double longitude) async {
|
||||
const zoomLevel = 5;
|
||||
if (Platform.isAndroid) {
|
||||
Uri uri = Uri(
|
||||
scheme: 'geo',
|
||||
host: '$latitude,$longitude',
|
||||
queryParameters: {'z': '$zoomLevel', 'q': formattedDateTime},
|
||||
queryParameters: {'z': '$zoomLevel', 'q': '$latitude,$longitude'},
|
||||
);
|
||||
if (await canLaunchUrl(uri)) {
|
||||
return uri;
|
||||
@@ -49,20 +33,16 @@ class ExifBottomSheet extends HookConsumerWidget {
|
||||
} else if (Platform.isIOS) {
|
||||
var params = {
|
||||
'll': '$latitude,$longitude',
|
||||
'q': formattedDateTime,
|
||||
'z': '$zoomLevel',
|
||||
'q': '$latitude, $longitude',
|
||||
};
|
||||
Uri uri = Uri.https('maps.apple.com', '/', params);
|
||||
if (await canLaunchUrl(uri)) {
|
||||
if (!await canLaunchUrl(uri)) {
|
||||
return uri;
|
||||
}
|
||||
}
|
||||
|
||||
return Uri(
|
||||
scheme: 'https',
|
||||
host: 'openstreetmap.org',
|
||||
queryParameters: {'mlat': '$latitude', 'mlon': '$longitude'},
|
||||
fragment: 'map=$zoomLevel/$latitude/$longitude',
|
||||
return Uri.https(
|
||||
'www.google.com',
|
||||
'/maps/place/$latitude,$longitude/@$latitude,$longitude,${zoomLevel}z',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -92,14 +72,16 @@ class ExifBottomSheet extends HookConsumerWidget {
|
||||
),
|
||||
zoom: 16.0,
|
||||
onTap: (tapPosition, latLong) async {
|
||||
Uri? uri = await _createCoordinatesUri();
|
||||
|
||||
if (uri == null) {
|
||||
return;
|
||||
if (exifInfo != null &&
|
||||
exifInfo.latitude != null &&
|
||||
exifInfo.longitude != null) {
|
||||
launchUrl(
|
||||
await _createCoordinatesUri(
|
||||
exifInfo.latitude!,
|
||||
exifInfo.longitude!,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
debugPrint('Opening Map Uri: $uri');
|
||||
launchUrl(uri);
|
||||
},
|
||||
),
|
||||
nonRotatedChildren: [
|
||||
@@ -169,7 +151,7 @@ class ExifBottomSheet extends HookConsumerWidget {
|
||||
|
||||
buildLocation() {
|
||||
// Guard no lat/lng
|
||||
if (!hasCoordinates) {
|
||||
if (!showMap) {
|
||||
return Container();
|
||||
}
|
||||
|
||||
@@ -217,7 +199,7 @@ class ExifBottomSheet extends HookConsumerWidget {
|
||||
Text(
|
||||
"${exifInfo!.latitude!.toStringAsFixed(4)}, ${exifInfo.longitude!.toStringAsFixed(4)}",
|
||||
style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
],
|
||||
@@ -225,8 +207,12 @@ class ExifBottomSheet extends HookConsumerWidget {
|
||||
}
|
||||
|
||||
buildDate() {
|
||||
final fileCreatedAt = asset.fileCreatedAt.toLocal();
|
||||
final date = DateFormat.yMMMEd().format(fileCreatedAt);
|
||||
final time = DateFormat.jm().format(fileCreatedAt);
|
||||
|
||||
return Text(
|
||||
formattedDateTime,
|
||||
'$date • $time',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 14,
|
||||
@@ -320,7 +306,7 @@ class ExifBottomSheet extends HookConsumerWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Flexible(
|
||||
flex: hasCoordinates ? 5 : 0,
|
||||
flex: showMap ? 5 : 0,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(right: 8.0),
|
||||
child: buildLocation(),
|
||||
@@ -350,7 +336,7 @@ class ExifBottomSheet extends HookConsumerWidget {
|
||||
if (asset.isRemote) DescriptionInput(asset: asset),
|
||||
const SizedBox(height: 8.0),
|
||||
buildLocation(),
|
||||
SizedBox(height: hasCoordinates ? 16.0 : 0.0),
|
||||
SizedBox(height: showMap ? 16.0 : 0.0),
|
||||
buildDetail(),
|
||||
const SizedBox(height: 50),
|
||||
],
|
||||
|
||||
@@ -128,7 +128,7 @@ class TopControlAppBar extends HookConsumerWidget {
|
||||
if (asset.isLocal && !asset.isRemote) buildUploadButton(),
|
||||
if (asset.isRemote && !asset.isLocal) buildDownloadButton(),
|
||||
if (asset.isRemote) buildAddToAlbumButtom(),
|
||||
buildMoreInfoButton(),
|
||||
buildMoreInfoButton()
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import 'package:immich_mobile/shared/models/store.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
import 'package:video_player/video_player.dart';
|
||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||
import 'package:wakelock/wakelock.dart';
|
||||
|
||||
// ignore: must_be_immutable
|
||||
class VideoViewerPage extends HookConsumerWidget {
|
||||
@@ -136,16 +136,16 @@ class _VideoPlayerState extends State<VideoPlayer> {
|
||||
videoPlayerController.addListener(() {
|
||||
if (videoPlayerController.value.isInitialized) {
|
||||
if (videoPlayerController.value.isPlaying) {
|
||||
WakelockPlus.enable();
|
||||
Wakelock.enable();
|
||||
widget.onPlaying?.call();
|
||||
} else if (!videoPlayerController.value.isPlaying) {
|
||||
WakelockPlus.disable();
|
||||
Wakelock.disable();
|
||||
widget.onPaused?.call();
|
||||
}
|
||||
|
||||
if (videoPlayerController.value.position ==
|
||||
videoPlayerController.value.duration) {
|
||||
WakelockPlus.disable();
|
||||
Wakelock.disable();
|
||||
widget.onVideoEnded();
|
||||
}
|
||||
}
|
||||
@@ -155,8 +155,8 @@ class _VideoPlayerState extends State<VideoPlayer> {
|
||||
Future<void> initializePlayer() async {
|
||||
try {
|
||||
videoPlayerController = widget.file == null
|
||||
? VideoPlayerController.networkUrl(
|
||||
Uri.parse(widget.url!),
|
||||
? VideoPlayerController.network(
|
||||
widget.url!,
|
||||
httpHeaders: {"Authorization": "Bearer ${widget.jwtToken}"},
|
||||
)
|
||||
: VideoPlayerController.file(widget.file!);
|
||||
@@ -210,7 +210,8 @@ class _VideoPlayerState extends State<VideoPlayer> {
|
||||
child: Center(
|
||||
child: Stack(
|
||||
children: [
|
||||
if (widget.placeholder != null) widget.placeholder!,
|
||||
if (widget.placeholder != null)
|
||||
widget.placeholder!,
|
||||
const Center(
|
||||
child: ImmichLoadingIndicator(),
|
||||
),
|
||||
|
||||
@@ -90,7 +90,7 @@ class BackgroundService {
|
||||
requireUnmetered,
|
||||
requireCharging,
|
||||
triggerUpdateDelay,
|
||||
triggerMaxDelay,
|
||||
triggerMaxDelay
|
||||
],
|
||||
);
|
||||
return ok;
|
||||
|
||||
@@ -511,7 +511,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
state = state.copyWith(
|
||||
selectedAlbumsBackupAssetsIds: {
|
||||
...state.selectedAlbumsBackupAssetsIds,
|
||||
deviceAssetId,
|
||||
deviceAssetId
|
||||
},
|
||||
allAssetsInDatabase: [...state.allAssetsInDatabase, deviceAssetId],
|
||||
);
|
||||
|
||||
@@ -149,30 +149,16 @@ class ManualUploadNotifier extends StateNotifier<ManualUploadState> {
|
||||
}
|
||||
|
||||
Future<bool> _startUpload(Iterable<Asset> allManualUploads) async {
|
||||
bool hasErrors = false;
|
||||
try {
|
||||
_backupProvider.updateBackupProgress(BackUpProgressEnum.manualInProgress);
|
||||
|
||||
if (ref.read(galleryPermissionNotifier.notifier).hasPermission) {
|
||||
await PhotoManager.clearFileCache();
|
||||
|
||||
// We do not have 1:1 mapping of all AssetEntity fields to Asset. This results in cases
|
||||
// where platform specific fields such as `subtype` used to detect platform specific assets such as
|
||||
// LivePhoto in iOS is lost when we directly fetch the local asset from Asset using Asset.local
|
||||
List<AssetEntity?> allAssetsFromDevice = await Future.wait(
|
||||
allManualUploads
|
||||
// Filter local only assets
|
||||
.where((e) => e.isLocal && !e.isRemote)
|
||||
.map((e) => e.local!.obtainForNewProperties()),
|
||||
);
|
||||
|
||||
if (allAssetsFromDevice.length != allManualUploads.length) {
|
||||
_log.warning(
|
||||
'[_startUpload] Refreshed upload list -> ${allManualUploads.length - allAssetsFromDevice.length} asset will not be uploaded',
|
||||
);
|
||||
}
|
||||
|
||||
Set<AssetEntity> allUploadAssets = allAssetsFromDevice.nonNulls.toSet();
|
||||
Set<AssetEntity> allUploadAssets = allManualUploads
|
||||
.where((e) => e.isLocal && e.local != null)
|
||||
.map((e) => e.local!)
|
||||
.toSet();
|
||||
|
||||
if (allUploadAssets.isEmpty) {
|
||||
debugPrint("[_startUpload] No Assets to upload - Abort Process");
|
||||
@@ -227,7 +213,7 @@ class ManualUploadNotifier extends StateNotifier<ManualUploadState> {
|
||||
'[_startUpload] Manual Upload Completed - success: ${state.successfulUploads},'
|
||||
' failed: ${state.totalAssetsToUpload - state.successfulUploads}',
|
||||
);
|
||||
|
||||
bool hasErrors = false;
|
||||
// User cancelled upload
|
||||
if (!ok && state.cancelToken.isCancelled) {
|
||||
await _localNotificationService.showOrUpdateManualUploadStatus(
|
||||
@@ -251,29 +237,32 @@ class ManualUploadNotifier extends StateNotifier<ManualUploadState> {
|
||||
presentBanner: true,
|
||||
);
|
||||
}
|
||||
|
||||
_backupProvider.updateBackupProgress(BackUpProgressEnum.idle);
|
||||
_handleAppInActivity();
|
||||
await _backupProvider.notifyBackgroundServiceCanRun();
|
||||
return !hasErrors;
|
||||
} else {
|
||||
openAppSettings();
|
||||
debugPrint("[_startUpload] Do not have permission to the gallery");
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("ERROR _startUpload: ${e.toString()}");
|
||||
hasErrors = true;
|
||||
} finally {
|
||||
_backupProvider.updateBackupProgress(BackUpProgressEnum.idle);
|
||||
_handleAppInActivity();
|
||||
await _localNotificationService.closeNotification(
|
||||
LocalNotificationService.manualUploadDetailedNotificationID,
|
||||
);
|
||||
await _backupProvider.notifyBackgroundServiceCanRun();
|
||||
}
|
||||
return !hasErrors;
|
||||
_backupProvider.updateBackupProgress(BackUpProgressEnum.idle);
|
||||
_handleAppInActivity();
|
||||
await _localNotificationService.closeNotification(
|
||||
LocalNotificationService.manualUploadDetailedNotificationID,
|
||||
);
|
||||
await _backupProvider.notifyBackgroundServiceCanRun();
|
||||
return false;
|
||||
}
|
||||
|
||||
void _handleAppInActivity() {
|
||||
final appState = ref.read(appStateProvider.notifier).getAppState();
|
||||
// The app is currently in background. Perform the necessary cleanups which
|
||||
// are on-hold for upload completion
|
||||
if (appState != AppStateEnum.active && appState != AppStateEnum.resumed) {
|
||||
if (appState != AppStateEnum.active || appState != AppStateEnum.resumed) {
|
||||
ref.read(appStateProvider.notifier).handleAppInactivity();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -248,9 +248,9 @@ class BackupService {
|
||||
|
||||
req.fields['deviceAssetId'] = entity.id;
|
||||
req.fields['deviceId'] = deviceId;
|
||||
req.fields['fileCreatedAt'] = entity.createDateTime.toUtc().toIso8601String();
|
||||
req.fields['fileCreatedAt'] = entity.createDateTime.toIso8601String();
|
||||
req.fields['fileModifiedAt'] =
|
||||
entity.modifiedDateTime.toUtc().toIso8601String();
|
||||
entity.modifiedDateTime.toIso8601String();
|
||||
req.fields['isFavorite'] = entity.isFavorite.toString();
|
||||
req.fields['duration'] = entity.videoDuration.toString();
|
||||
|
||||
|
||||
@@ -174,7 +174,7 @@ class AlbumInfoCard extends HookConsumerWidget {
|
||||
bottom: 10,
|
||||
right: 25,
|
||||
child: buildSelectedTextBox(),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -218,7 +218,7 @@ class AlbumInfoCard extends HookConsumerWidget {
|
||||
}),
|
||||
future: albumInfo.assetCount,
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -212,7 +212,7 @@ class CurrentUploadingAssetInfoBox extends HookConsumerWidget {
|
||||
Text(
|
||||
" ${uploadProgress.toStringAsFixed(0)}%",
|
||||
style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -247,7 +247,7 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
|
||||
child: Wrap(
|
||||
children: [
|
||||
...buildSelectedAlbumNameChip(),
|
||||
...buildExcludedAlbumNameChip(),
|
||||
...buildExcludedAlbumNameChip()
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -301,7 +301,7 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
|
||||
.watch(backupProvider)
|
||||
.availableAlbums
|
||||
.length
|
||||
.toString(),
|
||||
.toString()
|
||||
],
|
||||
),
|
||||
style: const TextStyle(
|
||||
|
||||
@@ -26,7 +26,7 @@ import 'package:immich_mobile/shared/ui/confirm_dialog.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_toast.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||
import 'package:wakelock/wakelock.dart';
|
||||
|
||||
class BackupControllerPage extends HookConsumerWidget {
|
||||
const BackupControllerPage({Key? key}) : super(key: key);
|
||||
@@ -114,7 +114,7 @@ class BackupControllerPage extends HookConsumerWidget {
|
||||
);
|
||||
return;
|
||||
}
|
||||
WakelockPlus.enable();
|
||||
Wakelock.enable();
|
||||
const limit = 100;
|
||||
final toDelete = await ref
|
||||
.read(backupVerificationServiceProvider)
|
||||
@@ -140,7 +140,7 @@ class BackupControllerPage extends HookConsumerWidget {
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
WakelockPlus.disable();
|
||||
Wakelock.disable();
|
||||
checkInProgress.value = false;
|
||||
}
|
||||
}
|
||||
@@ -202,7 +202,7 @@ class BackupControllerPage extends HookConsumerWidget {
|
||||
child: const Text('backup_controller_page_storage_format').tr(
|
||||
args: [
|
||||
backupState.serverInfo.diskUse,
|
||||
backupState.serverInfo.diskSize,
|
||||
backupState.serverInfo.diskSize
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -256,7 +256,7 @@ class BackupControllerPage extends HookConsumerWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -624,7 +624,7 @@ class BackupControllerPage extends HookConsumerWidget {
|
||||
style: TextStyle(fontSize: 12),
|
||||
).tr(),
|
||||
buildSelectedAlbumName(),
|
||||
buildExcludedAlbumName(),
|
||||
buildExcludedAlbumName()
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -776,7 +776,7 @@ class BackupControllerPage extends HookConsumerWidget {
|
||||
const Divider(),
|
||||
const CurrentUploadingAssetInfoBox(),
|
||||
if (!hasExclusiveAccess) buildBackgroundBackupInfo(),
|
||||
buildBackupButton(),
|
||||
buildBackupButton()
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -129,7 +129,7 @@ class FailedBackupStatusPage extends HookConsumerWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -83,7 +83,7 @@ class FavoritesPage extends HookConsumerWidget {
|
||||
style: TextStyle(fontSize: 14),
|
||||
),
|
||||
onTap: processing.value ? null : unfavorite,
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -108,7 +108,7 @@ class FavoritesPage extends HookConsumerWidget {
|
||||
selectionActive: selectionEnabledHook.value,
|
||||
listener: selectionListener,
|
||||
),
|
||||
if (selectionEnabledHook.value) buildBottomBar(),
|
||||
if (selectionEnabledHook.value) buildBottomBar()
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -57,7 +57,7 @@ class GroupDividerTitle extends ConsumerWidget {
|
||||
Icons.check_circle_outline_rounded,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -89,7 +89,7 @@ class ImmichAssetGrid extends HookConsumerWidget {
|
||||
perRow.value = 7 - scaleFactor.value.toInt();
|
||||
}
|
||||
};
|
||||
}),
|
||||
})
|
||||
},
|
||||
child: ImmichAssetGridView(
|
||||
onRefresh: onRefresh,
|
||||
|
||||
@@ -225,7 +225,7 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
|
||||
right: i + 1 == num ? 0.0 : widget.margin,
|
||||
),
|
||||
color: Colors.grey,
|
||||
),
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -300,13 +300,7 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
|
||||
}
|
||||
|
||||
Text _labelBuilder(int pos) {
|
||||
final maxLength = widget.renderList.elements.length;
|
||||
if (pos < 0 || pos >= maxLength) {
|
||||
return const Text("");
|
||||
}
|
||||
|
||||
final date = widget.renderList.elements[pos % maxLength].date;
|
||||
|
||||
final date = widget.renderList.elements[pos].date;
|
||||
return Text(
|
||||
DateFormat.yMMMM().format(date),
|
||||
style: const TextStyle(
|
||||
@@ -341,8 +335,7 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
|
||||
itemBuilder: _itemBuilder,
|
||||
itemPositionsListener: _itemPositionsListener,
|
||||
itemScrollController: _itemScrollController,
|
||||
itemCount: widget.renderList.elements.length +
|
||||
(widget.topWidget != null ? 1 : 0),
|
||||
itemCount: widget.renderList.elements.length + (widget.topWidget != null ? 1 : 0),
|
||||
addRepaintBoundaries: true,
|
||||
);
|
||||
|
||||
|
||||
@@ -155,7 +155,7 @@ class ControlBottomAppBar extends ConsumerWidget {
|
||||
if (hasRemote)
|
||||
const SliverToBoxAdapter(
|
||||
child: SizedBox(height: 200),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/shared/models/store.dart';
|
||||
import 'package:immich_mobile/shared/ui/user_circle_avatar.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/user_circle_avatar.dart';
|
||||
import 'package:immich_mobile/modules/login/models/authentication_state.model.dart';
|
||||
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
|
||||
|
||||
@@ -30,9 +29,9 @@ class HomePageAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
||||
backupState.backgroundBackup || backupState.autoBackup;
|
||||
final ServerInfoState serverInfoState = ref.watch(serverInfoProvider);
|
||||
AuthenticationState authState = ref.watch(authenticationProvider);
|
||||
final user = Store.tryGet(StoreKey.currentUser);
|
||||
|
||||
buildProfilePhoto() {
|
||||
if (authState.profileImagePath.isEmpty || user == null) {
|
||||
if (authState.profileImagePath.isEmpty) {
|
||||
return IconButton(
|
||||
splashRadius: 25,
|
||||
icon: const Icon(
|
||||
@@ -48,10 +47,9 @@ class HomePageAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
||||
onTap: () {
|
||||
Scaffold.of(context).openDrawer();
|
||||
},
|
||||
child: UserCircleAvatar(
|
||||
child: const UserCircleAvatar(
|
||||
radius: 18,
|
||||
size: 33,
|
||||
user: user,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -108,7 +108,7 @@ class ProfileDrawer extends HookConsumerWidget {
|
||||
buildSignOutButton(),
|
||||
],
|
||||
),
|
||||
const ServerInfoBox(),
|
||||
const ServerInfoBox()
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -3,8 +3,7 @@ import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:immich_mobile/modules/home/providers/upload_profile_image.provider.dart';
|
||||
import 'package:immich_mobile/shared/models/store.dart';
|
||||
import 'package:immich_mobile/shared/ui/user_circle_avatar.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/user_circle_avatar.dart';
|
||||
import 'package:immich_mobile/modules/login/models/authentication_state.model.dart';
|
||||
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
|
||||
@@ -20,10 +19,14 @@ class ProfileDrawerHeader extends HookConsumerWidget {
|
||||
final uploadProfileImageStatus =
|
||||
ref.watch(uploadProfileImageProvider).status;
|
||||
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
|
||||
final user = Store.tryGet(StoreKey.currentUser);
|
||||
|
||||
buildUserProfileImage() {
|
||||
if (authState.profileImagePath.isEmpty || user == null) {
|
||||
var userImage = const UserCircleAvatar(
|
||||
radius: 35,
|
||||
size: 66,
|
||||
);
|
||||
|
||||
if (authState.profileImagePath.isEmpty) {
|
||||
return const CircleAvatar(
|
||||
radius: 35,
|
||||
backgroundImage: AssetImage('assets/immich-logo-no-outline.png'),
|
||||
@@ -31,12 +34,6 @@ class ProfileDrawerHeader extends HookConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
var userImage = UserCircleAvatar(
|
||||
radius: 35,
|
||||
size: 66,
|
||||
user: user,
|
||||
);
|
||||
|
||||
if (uploadProfileImageStatus == UploadProfileStatus.idle) {
|
||||
if (authState.profileImagePath.isNotEmpty) {
|
||||
return userImage;
|
||||
@@ -156,7 +153,7 @@ class ProfileDrawerHeader extends HookConsumerWidget {
|
||||
Text(
|
||||
authState.userEmail,
|
||||
style: Theme.of(context).textTheme.labelMedium,
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/login/models/authentication_state.model.dart';
|
||||
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
|
||||
import 'package:immich_mobile/shared/models/store.dart';
|
||||
import 'package:immich_mobile/shared/ui/transparent_image.dart';
|
||||
|
||||
class UserCircleAvatar extends ConsumerWidget {
|
||||
final double radius;
|
||||
final double size;
|
||||
const UserCircleAvatar({super.key, required this.radius, required this.size});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
AuthenticationState authState = ref.watch(authenticationProvider);
|
||||
|
||||
var profileImageUrl =
|
||||
'${Store.get(StoreKey.serverEndpoint)}/user/profile-image/${authState.userId}?d=${Random().nextInt(1024)}';
|
||||
return CircleAvatar(
|
||||
backgroundColor: Theme.of(context).primaryColor,
|
||||
radius: radius,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(50),
|
||||
child: FadeInImage(
|
||||
fit: BoxFit.cover,
|
||||
placeholder: MemoryImage(kTransparentImage),
|
||||
width: size,
|
||||
height: size,
|
||||
image: NetworkImage(
|
||||
profileImageUrl,
|
||||
headers: {
|
||||
"Authorization": "Bearer ${Store.get(StoreKey.accessToken)}"
|
||||
},
|
||||
),
|
||||
fadeInDuration: const Duration(milliseconds: 200),
|
||||
imageErrorBuilder: (context, error, stackTrace) =>
|
||||
Image.memory(kTransparentImage),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -221,7 +221,7 @@ class HomePage extends HookConsumerWidget {
|
||||
namedArgs: {
|
||||
"album": album.name,
|
||||
"added": result.successfullyAdded.toString(),
|
||||
"failed": result.alreadyInAlbum.length.toString(),
|
||||
"failed": result.alreadyInAlbum.length.toString()
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -323,7 +323,7 @@ class HomePage extends HookConsumerWidget {
|
||||
).tr(),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -365,7 +365,7 @@ class HomePage extends HookConsumerWidget {
|
||||
enabled: !processing.value,
|
||||
selectionAssetState: selectionAssetState.value,
|
||||
),
|
||||
if (processing.value) const Center(child: ImmichLoadingIndicator()),
|
||||
if (processing.value) const Center(child: ImmichLoadingIndicator())
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -94,7 +94,7 @@ class ChangePasswordForm extends HookConsumerWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -110,7 +110,7 @@ class MemoryCard extends HookConsumerWidget {
|
||||
left: 18.0,
|
||||
bottom: 18.0,
|
||||
child: buildTitle(),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -153,7 +153,6 @@ class PermissionOnboardingPage extends HookConsumerWidget {
|
||||
child = buildRequestPermission();
|
||||
break;
|
||||
case PermissionStatus.granted:
|
||||
case PermissionStatus.provisional:
|
||||
child = buildPermissionGranted();
|
||||
break;
|
||||
case PermissionStatus.restricted:
|
||||
@@ -184,7 +183,7 @@ class PermissionOnboardingPage extends HookConsumerWidget {
|
||||
),
|
||||
TextButton(
|
||||
child: const Text('permission_onboarding_log_out').tr(),
|
||||
onPressed: () {
|
||||
onPressed: () {
|
||||
ref.read(authenticationProvider.notifier).logout();
|
||||
AutoRouter.of(context).replace(
|
||||
const LoginRoute(),
|
||||
|
||||
@@ -44,7 +44,7 @@ class PartnerPage extends HookConsumerWidget {
|
||||
Text("${u.firstName} ${u.lastName}"),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
);
|
||||
},
|
||||
@@ -151,7 +151,7 @@ class PartnerPage extends HookConsumerWidget {
|
||||
availableUsers.whenOrNull(data: (data) => addNewUsersHandler),
|
||||
icon: const Icon(Icons.person_add),
|
||||
tooltip: "partner_page_add_partner".tr(),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
body: buildUserList(partners),
|
||||
|
||||
@@ -50,7 +50,7 @@ class CuratedPeopleRow extends StatelessWidget {
|
||||
itemBuilder: (context, index) {
|
||||
final person = content[index];
|
||||
final headers = {
|
||||
"Authorization": "Bearer ${Store.get(StoreKey.accessToken)}",
|
||||
"Authorization": "Bearer ${Store.get(StoreKey.accessToken)}"
|
||||
};
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 18.0),
|
||||
@@ -102,7 +102,7 @@ class CuratedPeopleRow extends StatelessWidget {
|
||||
fontSize: 13.0,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -39,7 +39,7 @@ class SearchSuggestionList extends ConsumerWidget {
|
||||
color: Theme.of(context).primaryColor,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -46,7 +46,7 @@ class ThumbnailWithInfo extends StatelessWidget {
|
||||
imageUrl: imageUrl!,
|
||||
httpHeaders: {
|
||||
"Authorization":
|
||||
"Bearer ${Store.get(StoreKey.accessToken)}",
|
||||
"Bearer ${Store.get(StoreKey.accessToken)}"
|
||||
},
|
||||
errorWidget: (context, url, error) =>
|
||||
const Icon(Icons.image_not_supported_outlined),
|
||||
|
||||
@@ -56,7 +56,7 @@ class PersonResultPage extends HookConsumerWidget {
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
onTap: showEditNameDialog,
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -134,7 +134,7 @@ class PersonResultPage extends HookConsumerWidget {
|
||||
getFaceThumbnailUrl(personId),
|
||||
headers: {
|
||||
"Authorization":
|
||||
"Bearer ${isar_store.Store.get(isar_store.StoreKey.accessToken)}",
|
||||
"Bearer ${isar_store.Store.get(isar_store.StoreKey.accessToken)}"
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
@@ -42,7 +42,7 @@ class SettingsPage extends HookConsumerWidget {
|
||||
const AssetListSettings(),
|
||||
const NotificationSetting(),
|
||||
// const ExperimentalSettings(),
|
||||
const AdvancedSettings(),
|
||||
const AdvancedSettings()
|
||||
],
|
||||
).toList(),
|
||||
],
|
||||
|
||||
@@ -2,7 +2,6 @@ import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/album/models/asset_selection_page_result.model.dart';
|
||||
import 'package:immich_mobile/modules/album/views/album_options_part.dart';
|
||||
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';
|
||||
@@ -75,7 +74,7 @@ part 'router.gr.dart';
|
||||
AutoRoute(page: HomePage, guards: [AuthGuard, DuplicateGuard]),
|
||||
AutoRoute(page: SearchPage, guards: [AuthGuard, DuplicateGuard]),
|
||||
AutoRoute(page: SharingPage, guards: [AuthGuard, DuplicateGuard]),
|
||||
AutoRoute(page: LibraryPage, guards: [AuthGuard, DuplicateGuard]),
|
||||
AutoRoute(page: LibraryPage, guards: [AuthGuard, DuplicateGuard])
|
||||
],
|
||||
transitionsBuilder: TransitionsBuilders.fadeIn,
|
||||
),
|
||||
@@ -153,7 +152,6 @@ part 'router.gr.dart';
|
||||
),
|
||||
AutoRoute(page: AllPeoplePage, guards: [AuthGuard, DuplicateGuard]),
|
||||
AutoRoute(page: MemoryPage, guards: [AuthGuard, DuplicateGuard]),
|
||||
AutoRoute(page: AlbumOptionsPage, guards: [AuthGuard, DuplicateGuard]),
|
||||
],
|
||||
)
|
||||
class AppRouter extends _$AppRouter {
|
||||
|
||||
@@ -296,16 +296,6 @@ class _$AppRouter extends RootStackRouter {
|
||||
),
|
||||
);
|
||||
},
|
||||
AlbumOptionsRoute.name: (routeData) {
|
||||
final args = routeData.argsAs<AlbumOptionsRouteArgs>();
|
||||
return MaterialPageX<dynamic>(
|
||||
routeData: routeData,
|
||||
child: AlbumOptionsPage(
|
||||
key: args.key,
|
||||
album: args.album,
|
||||
),
|
||||
);
|
||||
},
|
||||
HomeRoute.name: (routeData) {
|
||||
return MaterialPageX<dynamic>(
|
||||
routeData: routeData,
|
||||
@@ -605,14 +595,6 @@ class _$AppRouter extends RootStackRouter {
|
||||
duplicateGuard,
|
||||
],
|
||||
),
|
||||
RouteConfig(
|
||||
AlbumOptionsRoute.name,
|
||||
path: '/album-options-page',
|
||||
guards: [
|
||||
authGuard,
|
||||
duplicateGuard,
|
||||
],
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1337,40 +1319,6 @@ class MemoryRouteArgs {
|
||||
}
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [AlbumOptionsPage]
|
||||
class AlbumOptionsRoute extends PageRouteInfo<AlbumOptionsRouteArgs> {
|
||||
AlbumOptionsRoute({
|
||||
Key? key,
|
||||
required Album album,
|
||||
}) : super(
|
||||
AlbumOptionsRoute.name,
|
||||
path: '/album-options-page',
|
||||
args: AlbumOptionsRouteArgs(
|
||||
key: key,
|
||||
album: album,
|
||||
),
|
||||
);
|
||||
|
||||
static const String name = 'AlbumOptionsRoute';
|
||||
}
|
||||
|
||||
class AlbumOptionsRouteArgs {
|
||||
const AlbumOptionsRouteArgs({
|
||||
this.key,
|
||||
required this.album,
|
||||
});
|
||||
|
||||
final Key? key;
|
||||
|
||||
final Album album;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'AlbumOptionsRouteArgs{key: $key, album: $album}';
|
||||
}
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [HomePage]
|
||||
class HomeRoute extends PageRouteInfo<void> {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
class ServerInfoState {
|
||||
final ServerVersionResponseDto serverVersion;
|
||||
final ServerVersionReponseDto serverVersion;
|
||||
final bool isVersionMismatch;
|
||||
final String versionMismatchErrorMessage;
|
||||
|
||||
@@ -12,7 +12,7 @@ class ServerInfoState {
|
||||
});
|
||||
|
||||
ServerInfoState copyWith({
|
||||
ServerVersionResponseDto? serverVersion,
|
||||
ServerVersionReponseDto? serverVersion,
|
||||
bool? isVersionMismatch,
|
||||
String? versionMismatchErrorMessage,
|
||||
}) {
|
||||
|
||||
@@ -157,7 +157,7 @@ User _userDeserialize(
|
||||
isPartnerSharedBy: reader.readBoolOrNull(offsets[4]) ?? false,
|
||||
isPartnerSharedWith: reader.readBoolOrNull(offsets[5]) ?? false,
|
||||
lastName: reader.readString(offsets[6]),
|
||||
memoryEnabled: reader.readBoolOrNull(offsets[7]),
|
||||
memoryEnabled: reader.readBoolOrNull(offsets[7]) ?? true,
|
||||
profileImagePath: reader.readStringOrNull(offsets[8]) ?? '',
|
||||
updatedAt: reader.readDateTime(offsets[9]),
|
||||
);
|
||||
@@ -186,7 +186,7 @@ P _userDeserializeProp<P>(
|
||||
case 6:
|
||||
return (reader.readString(offset)) as P;
|
||||
case 7:
|
||||
return (reader.readBoolOrNull(offset)) as P;
|
||||
return (reader.readBoolOrNull(offset) ?? true) as P;
|
||||
case 8:
|
||||
return (reader.readStringOrNull(offset) ?? '') as P;
|
||||
case 9:
|
||||
@@ -979,24 +979,8 @@ extension UserQueryFilter on QueryBuilder<User, User, QFilterCondition> {
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<User, User, QAfterFilterCondition> memoryEnabledIsNull() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(const FilterCondition.isNull(
|
||||
property: r'memoryEnabled',
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<User, User, QAfterFilterCondition> memoryEnabledIsNotNull() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(const FilterCondition.isNotNull(
|
||||
property: r'memoryEnabled',
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<User, User, QAfterFilterCondition> memoryEnabledEqualTo(
|
||||
bool? value) {
|
||||
bool value) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.equalTo(
|
||||
property: r'memoryEnabled',
|
||||
@@ -1677,7 +1661,7 @@ extension UserQueryProperty on QueryBuilder<User, User, QQueryProperty> {
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<User, bool?, QQueryOperations> memoryEnabledProperty() {
|
||||
QueryBuilder<User, bool, QQueryOperations> memoryEnabledProperty() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addPropertyName(r'memoryEnabled');
|
||||
});
|
||||
|
||||
@@ -21,7 +21,6 @@ enum AppStateEnum {
|
||||
paused,
|
||||
resumed,
|
||||
detached,
|
||||
hidden,
|
||||
}
|
||||
|
||||
class AppStateNotiifer extends StateNotifier<AppStateEnum> {
|
||||
@@ -85,10 +84,6 @@ class AppStateNotiifer extends StateNotifier<AppStateEnum> {
|
||||
state = AppStateEnum.detached;
|
||||
ref.watch(manualUploadProvider.notifier).cancelBackup();
|
||||
}
|
||||
|
||||
void handleAppHidden() {
|
||||
state = AppStateEnum.hidden;
|
||||
}
|
||||
}
|
||||
|
||||
final appStateProvider =
|
||||
|
||||
@@ -10,7 +10,7 @@ class ServerInfoNotifier extends StateNotifier<ServerInfoState> {
|
||||
ServerInfoNotifier(this._serverInfoService)
|
||||
: super(
|
||||
ServerInfoState(
|
||||
serverVersion: ServerVersionResponseDto(
|
||||
serverVersion: ServerVersionReponseDto(
|
||||
major: 0,
|
||||
patch_: 0,
|
||||
minor: 0,
|
||||
@@ -23,7 +23,7 @@ class ServerInfoNotifier extends StateNotifier<ServerInfoState> {
|
||||
final ServerInfoService _serverInfoService;
|
||||
|
||||
getServerVersion() async {
|
||||
ServerVersionResponseDto? serverVersion =
|
||||
ServerVersionReponseDto? serverVersion =
|
||||
await _serverInfoService.getServerVersion();
|
||||
|
||||
if (serverVersion == null) {
|
||||
|
||||
@@ -69,6 +69,7 @@ class AssetService {
|
||||
await _apiService.assetApi.getAllAssetsWithETag(
|
||||
eTag: etag,
|
||||
userId: user.id,
|
||||
withoutThumbs: true,
|
||||
);
|
||||
if (assets == null) {
|
||||
return null;
|
||||
|
||||
@@ -102,7 +102,7 @@ class LocalNotificationService {
|
||||
cancelUploadActionID,
|
||||
'Cancel',
|
||||
showsUserInterface: true,
|
||||
),
|
||||
)
|
||||
]
|
||||
: null,
|
||||
)
|
||||
|
||||
@@ -24,7 +24,7 @@ class ServerInfoService {
|
||||
}
|
||||
}
|
||||
|
||||
Future<ServerVersionResponseDto?> getServerVersion() async {
|
||||
Future<ServerVersionReponseDto?> getServerVersion() async {
|
||||
try {
|
||||
return await _apiService.serverInfoApi.getServerVersion();
|
||||
} catch (e) {
|
||||
|
||||
@@ -16,7 +16,7 @@ class IgnorableChangeNotifier extends ChangeNotifier {
|
||||
if (_ignorableListeners == null) {
|
||||
AssertionError([
|
||||
'A $runtimeType was used after being disposed.',
|
||||
'Once you have called dispose() on a $runtimeType, it can no longer be used.',
|
||||
'Once you have called dispose() on a $runtimeType, it can no longer be used.'
|
||||
]);
|
||||
}
|
||||
return true;
|
||||
|
||||
@@ -15,7 +15,7 @@ class ShareDialog extends StatelessWidget {
|
||||
margin: const EdgeInsets.only(top: 12),
|
||||
child: const Text('share_dialog_preparing')
|
||||
.tr(),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/shared/models/store.dart';
|
||||
import 'package:immich_mobile/shared/models/user.dart';
|
||||
import 'package:immich_mobile/shared/ui/transparent_image.dart';
|
||||
|
||||
// ignore: must_be_immutable
|
||||
class UserCircleAvatar extends ConsumerWidget {
|
||||
final User user;
|
||||
double radius;
|
||||
double size;
|
||||
bool useRandomBackgroundColor;
|
||||
|
||||
UserCircleAvatar({
|
||||
super.key,
|
||||
this.radius = 22,
|
||||
this.size = 44,
|
||||
this.useRandomBackgroundColor = false,
|
||||
required this.user,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final randomColors = [
|
||||
Colors.red[200],
|
||||
Colors.blue[200],
|
||||
Colors.green[200],
|
||||
Colors.yellow[200],
|
||||
Colors.purple[200],
|
||||
Colors.orange[200],
|
||||
Colors.pink[200],
|
||||
Colors.teal[200],
|
||||
Colors.indigo[200],
|
||||
Colors.cyan[200],
|
||||
Colors.brown[200],
|
||||
];
|
||||
|
||||
final profileImageUrl =
|
||||
'${Store.get(StoreKey.serverEndpoint)}/user/profile-image/${user.id}?d=${Random().nextInt(1024)}';
|
||||
return CircleAvatar(
|
||||
backgroundColor: useRandomBackgroundColor
|
||||
? randomColors[Random().nextInt(randomColors.length)]
|
||||
: Theme.of(context).primaryColor,
|
||||
radius: radius,
|
||||
child: user.profileImagePath == ""
|
||||
? Text(
|
||||
user.firstName[0],
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.black,
|
||||
),
|
||||
)
|
||||
: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(50),
|
||||
child: FadeInImage(
|
||||
fit: BoxFit.cover,
|
||||
placeholder: MemoryImage(kTransparentImage),
|
||||
width: size,
|
||||
height: size,
|
||||
image: NetworkImage(
|
||||
profileImageUrl,
|
||||
headers: {
|
||||
"Authorization": "Bearer ${Store.get(StoreKey.accessToken)}",
|
||||
},
|
||||
),
|
||||
fadeInDuration: const Duration(milliseconds: 200),
|
||||
imageErrorBuilder: (context, error, stackTrace) =>
|
||||
Image.memory(kTransparentImage),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -47,7 +47,7 @@ class AppLogDetailPage extends HookConsumerWidget {
|
||||
size: 16.0,
|
||||
color: Theme.of(context).primaryColor,
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
Container(
|
||||
@@ -106,7 +106,7 @@ class AppLogDetailPage extends HookConsumerWidget {
|
||||
size: 16.0,
|
||||
color: Theme.of(context).primaryColor,
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
Container(
|
||||
@@ -181,7 +181,7 @@ class AppLogDetailPage extends HookConsumerWidget {
|
||||
if (logMessage.context1 != null)
|
||||
buildLogContext1(logMessage.context1.toString()),
|
||||
if (logMessage.context2 != null)
|
||||
buildStackMessage(logMessage.context2.toString()),
|
||||
buildStackMessage(logMessage.context2.toString())
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user