1
0
forked from Cutlery/immich

Compare commits

..

38 Commits

Author SHA1 Message Date
Alex Tran bd865d02b3 merge main 2023-08-16 15:10:59 -05:00
Alex Tran 7bc8918067 merge main 2023-08-16 13:30:20 -05:00
Alex Tran 29e103d96d merge main 2023-08-14 17:50:09 -05:00
Jason Rasmussen e8fdddf08e feat: persist people rules 2023-08-14 17:55:55 -04:00
Jason Rasmussen 947132ac77 chore: open api 2023-08-14 17:44:36 -04:00
Jason Rasmussen b7b3735c40 feat: api validation 2023-08-14 17:44:30 -04:00
Alex Tran 89021ce995 styling 2023-08-14 11:58:42 -05:00
Alex Tran e00b37f4d2 remove faces from payload 2023-08-14 11:04:23 -05:00
Alex Tran 7e28522cb1 Change to set 2023-08-13 20:35:04 -05:00
Alex Tran e682de67fa better selection 2023-08-13 16:53:57 -05:00
Alex Tran 1eb3cdca42 api 2023-08-13 10:35:07 -05:00
Alex Tran e2ad4ac5b3 add faces 2023-08-13 10:11:22 -05:00
Alex Tran a7b62a5ad3 Merge branch 'main' of github.com:immich-app/immich into dev/smart-album 2023-08-13 09:07:33 -05:00
Alex Tran 9d23d37601 face selection 2023-08-12 23:35:04 -05:00
Alex Tran 66ebdf1556 show faces 2023-08-12 23:21:49 -05:00
Alex Tran f7eab1cc9c component order 2023-08-12 22:53:48 -05:00
Alex Tran 048dbc83ba layout 2023-08-12 20:25:25 -05:00
Alex Tran 3576d078cf Hooking up events 2023-08-12 18:04:41 -05:00
Alex Tran 9bd2934aa2 event listener 2023-08-12 17:57:53 -05:00
Alex Tran ac18f7515a layout 2023-08-12 17:26:22 -05:00
Alex Tran 968fc77183 styling 2023-08-12 16:20:25 -05:00
Alex Tran 207049df00 stronk type 2023-08-12 14:55:26 -05:00
Alex Tran 980c6b0dbb ui: Modal work 2023-08-12 10:36:21 -05:00
Jason Rasmussen ea6bc8fb10 chore: open api 2023-08-11 21:58:29 -04:00
Jason Rasmussen 6e2624da7c refactor: rule controller 2023-08-11 21:57:55 -04:00
Alex Tran a37adc0c96 job 2023-08-11 15:27:51 -05:00
Alex Tran 37b4f8455b job 2023-08-11 15:20:12 -05:00
Alex Tran 3c3de8b2af Merge branch 'main' of github.com:immich-app/immich into dev/smart-album 2023-08-11 11:48:24 -05:00
Alex Tran fa8ce261d8 naming 2023-08-10 23:21:24 -05:00
Alex Tran 69c95e2b73 remove rule 2023-08-10 21:11:14 -05:00
Alex Tran 51ffac5d15 get album response 2023-08-10 21:00:36 -05:00
Alex Tran 70cf100407 dev: create rule 2023-08-10 20:56:51 -05:00
Alex Tran 22e5c6ead2 dev: controller 2023-08-10 16:23:48 -05:00
Alex Tran 4a34198b78 dev: remove index 2023-08-10 14:52:14 -05:00
Alex Tran a85780c930 dev: naming 2023-08-10 14:51:51 -05:00
Alex Tran 717dfe51e7 dev: stub 2023-08-10 13:45:21 -05:00
Alex Tran 5168278215 entity 2023-08-10 13:42:00 -05:00
Alex Tran d92dbbe655 entity 2023-08-10 13:26:42 -05:00
370 changed files with 5725 additions and 7717 deletions
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -2
View File
@@ -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
+623 -521
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -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).
+1 -1
View File
@@ -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).
+1 -1
View File
@@ -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).
+1 -1
View File
@@ -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).
+2 -2
View File
@@ -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;
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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
-2
View File
@@ -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
View File
@@ -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)?
-91
View File
@@ -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.
+1
View File
@@ -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
+7 -13
View File
@@ -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 -1
View File
@@ -1,5 +1,5 @@
---
sidebar_position: 80
sidebar_position: 100
---
import RegisterAdminUser from '../partials/_register-admin.md';
+1 -2
View File
@@ -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
+4 -11
View File
@@ -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()
+14 -29
View File
@@ -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 -1
View File
@@ -1,3 +1,3 @@
from .clip import CLIPEncoder
from .clip import CLIPSTEncoder
from .facial_recognition import FaceRecognizer
from .image_classification import ImageClassifier
+2 -37
View File
@@ -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)
+1 -1
View File
@@ -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:
+17 -127
View File
@@ -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
+19 -34
View File
@@ -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
+433 -1304
View File
File diff suppressed because it is too large Load Diff
+5 -20
View File
@@ -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"
-2
View File
@@ -1,2 +0,0 @@
# requirements to be installed with `--no-deps` flag
clip-server==0.8.*
+1 -1
View File
@@ -1,4 +1,4 @@
{
"flutterSdkVersion": "3.13.0",
"flutterSdkVersion": "3.10.5",
"flavors": {}
}
+1 -1
View File
@@ -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" />
+2 -2
View File
@@ -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')
+2 -3
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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 -1
View File
@@ -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
+1 -1
View File
@@ -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,
-4
View File
@@ -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),
),
),
);
}
}
+3 -3
View File
@@ -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(),
],
+1 -3
View File
@@ -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 {
-52
View File
@@ -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,
}) {
+4 -20
View File
@@ -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;
+1 -1
View File
@@ -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